diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
| commit | e332019acb312ec64893c26cf3d797d5ce472f26 (patch) | |
| tree | d97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src | |
| parent | 99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff) | |
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src')
28 files changed, 15223 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/animation.js b/public/bluebey-studio/src/animation.js new file mode 100644 index 0000000..4446878 --- /dev/null +++ b/public/bluebey-studio/src/animation.js @@ -0,0 +1,161 @@ +/** + * The "living" layer: idle body motion, blinking and idle eye drift. + * + * These never touch the saved state. `pose()` returns the base pose with the + * idle offsets added, and `eyeOpen`/`look` are multipliers the caller merges + * into the face parameters, so turning the animation off restores exactly the + * pose and expression the user had before. + */ + +export class Animator { + constructor({ state }) { + this.state = state; + this.time = 0; + this.blinkPhase = -1; + this.blinkDuration = 0.17; + this.blinkTimer = 2 + Math.random() * 1.5; + this.eyeOpen = 1; + this.look = { x: 0, y: 0 }; + this.idleWeight = 0; + } + + reset() { + this.eyeOpen = 1; + this.look = { x: 0, y: 0 }; + this.blinkPhase = -1; + this.blinkTimer = 2; + this.time = 0; + } + + update(dt) { + const speed = clamp(this.state.anim.speed ?? 1, 0.1, 3); + const step = Math.min(dt, 0.1) * speed; + this.time += step; + + const moving = (this.state.anim.mode ?? 'off') !== 'off'; + this.idleWeight += ((moving ? 1 : 0) - this.idleWeight) * Math.min(1, step * 4); + + this.updateBlink(step); + this.updateLook(step); + } + + updateBlink(dt) { + if (!this.state.anim.blink) { + this.eyeOpen = 1; + this.blinkPhase = -1; + return; + } + if (this.blinkPhase >= 0) { + this.blinkPhase += dt / this.blinkDuration; + if (this.blinkPhase >= 1) { + this.blinkPhase = -1; + this.eyeOpen = 1; + const base = Math.max(0.5, this.state.anim.blinkInterval ?? 3.4); + // Blink again sooner sometimes, so it does not look metronomic. + this.blinkTimer = base * (0.55 + Math.random()); + if (Math.random() < 0.22) this.blinkTimer *= 0.35; // occasional double blink + } else { + this.eyeOpen = 1 - Math.pow(Math.sin(Math.PI * this.blinkPhase), 0.8); + } + return; + } + this.blinkTimer -= dt; + if (this.blinkTimer <= 0) this.blinkPhase = 0; + } + + updateLook(dt) { + if (!this.state.anim.lookAround) { + this.look.x = 0; + this.look.y = 0; + return; + } + const t = this.time; + this.look.x = Math.sin(t * 0.37) * 0.3 + Math.sin(t * 0.13 + 1.7) * 0.14; + this.look.y = Math.sin(t * 0.29 + 0.6) * 0.2; + } + + /** Base pose plus the current movement offsets; `pose` is `{ bones, root }`. */ + pose(basePose = {}) { + const bones = { ...(basePose.bones ?? {}) }; + const root = [...(basePose.root ?? [0, 0, 0])]; + const mode = this.state.anim.mode ?? 'off'; + if (mode === 'off' || this.idleWeight <= 0.001) return { bones, root }; + + const w = this.idleWeight; + const t = this.time; + const add = (name, dx, dy, dz) => { + const current = bones[name] ?? [0, 0, 0]; + bones[name] = [current[0] + dx * w, current[1] + dy * w, current[2] + dz * w]; + }; + + if (mode === 'walk') { + // A waddle for a character with no legs: a step bob, a side-to-side rock + // and alternating arms and feet. + // + // Z is the forward/back swing, and it is mirrored between the sides - so the + // SAME value on both arms swings them opposite ways, which is what a walk + // wants. X (which the first version used) is the *lift*, so opposite signs + // there just raised one flipper and dropped the other one. + // `legsupport` pivots at the middle of the body, so the legs take a much + // smaller angle than the arms. + const phase = Math.sin(t * 2.4); + const bob = Math.abs(Math.sin(t * 2.4)); + add('master', -2, 0, phase * 4); + add('arm.l', 6, 0, phase * 26); + add('arm.r', 6, 0, phase * 26); + add('hand.l', 0, 0, phase * 10); + add('hand.r', 0, 0, phase * 10); + add('legsupport.l', 0, 0, phase * 9); + add('legsupport.r', 0, 0, phase * 9); + root[1] += bob * 0.05; + return { bones, root }; + } + + // Default: gentle breathing, as if standing there alive. + add('master', Math.sin(t * 1.5) * 1.3, 0, Math.sin(t * 0.81) * 1.6); + add('arm.l', 0, 0, Math.sin(t * 1.28) * 4.5); + add('arm.r', 0, 0, -Math.sin(t * 1.28 + 0.5) * 4.5); + add('hand.l', 0, 0, Math.sin(t * 1.05 + 1) * 3); + add('hand.r', 0, 0, -Math.sin(t * 1.05 + 1) * 3); + root[1] += Math.sin(t * 1.5) * 0.022; + return { bones, root }; + } +} + +/** + * Records the viewport canvas to a WebM blob while the animation plays. + * Chrome and Edge support `MediaRecorder` on `canvas.captureStream()`. + */ +export function createRecorder(canvas) { + if (typeof MediaRecorder === 'undefined' || !canvas.captureStream) return null; + const mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'] + .find((type) => MediaRecorder.isTypeSupported?.(type)); + return { canvas, mimeType: mimeType ?? '', recorder: null, chunks: [] }; +} + +export function startRecording(session) { + if (!session) return false; + const stream = session.canvas.captureStream(30); + const recorder = session.mimeType + ? new MediaRecorder(stream, { mimeType: session.mimeType, videoBitsPerSecond: 8000000 }) + : new MediaRecorder(stream); + session.chunks = []; + session.recorder = recorder; + recorder.ondataavailable = (event) => { if (event.data?.size) session.chunks.push(event.data); }; + recorder.start(100); + return true; +} + +export function stopRecording(session) { + return new Promise((resolve) => { + if (!session?.recorder || session.recorder.state === 'inactive') { + resolve(null); + return; + } + const { recorder, chunks } = session; + recorder.onstop = () => resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' })); + recorder.stop(); + }); +} + +const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value)); diff --git a/public/bluebey-studio/src/background.js b/public/bluebey-studio/src/background.js new file mode 100644 index 0000000..72bc396 --- /dev/null +++ b/public/bluebey-studio/src/background.js @@ -0,0 +1,644 @@ +/** + * The studio backdrop, as a DOM layer that sits behind the WebGL canvas. + * + * The renderer is created with `alpha: true`, so whenever it is cleared to + * alpha 0 the page shows through. Painting the backdrop into a sibling layer + * placed *under* `#view` therefore gives the character a scene without touching + * the scene graph: the CC0 photo, the user's own picture and the phone's camera + * feed are all just CSS on one element, and no texture has to be uploaded per + * frame. + * + * `backdropStyle()` is deliberately pure. It is the only place that turns the + * `view.background*` slice into a CSS descriptor, so the behaviour `apply()` + * ships with is exactly the behaviour the unit tests cover. + */ + +/** The library already downloaded into `assets/backgrounds/` (see CREDITS.json). + * Most are Poly Haven *HDRIs* - CC0 photographs of real places - so each one comes + * with a floor, a horizon and perspective, which is what gives a picture depth + * that a flat wall texture cannot. `mutoujima` is the exception: an original + * illustration drawn for this studio by the author, not a third-party CC0 photo. */ +export const BACKGROUND_PRESETS = [ + { name: 'empty_warehouse_01', label: '倉庫' }, + { name: 'ballroom', label: '広間' }, + { name: 'kloppenheim_06_puresky', label: '空と雲' }, + { name: 'autumn_park', label: '秋の公園' }, + // The places a councillor explains things in, or stands in to make a point. + // Poly Haven has no true classroom, desert or 社長室, so these are the closest + // real places it does have. + { name: 'newman_cafeteria', label: '学校' }, + { name: 'wooden_lounge', label: '社長室(木の部屋)' }, + { name: 'minedump_flats', label: '砂漠' }, + { name: 'spiaggia_di_mondello', label: '海岸' }, + // ポリヘイブンの写真ではなく、作者(安竹洋平)が描き起こしたオリジナルの一枚絵。 + { name: 'mutoujima', label: '無人島' }, + // 「ぶるべーを探せ!」用。物がたくさん詰まっていて、同じ形が何度も出てくる場所。 + { name: 'abandoned_factory_canteen_01', label: '廃工場の食堂' }, + { name: 'basement_boxing_ring', label: '地下室のリング' }, + { name: 'autoshop_01', label: '自動車工場' }, + { name: 'urban_alley_01', label: '路地' }, +]; + +const DEFAULT_PRESET = 'autumn_park'; + +/** + * Manga effect-line backdrops, drawn *procedurally* (no file, no network). + * + * They are the classic stress marks: a burst of lines from behind the character + * (`focus`), lines raining down (`fall`), lines streaming sideways (`speed`) and + * short lines ringing a wide ellipse so the empty middle reads as isolation + * (`ellipse`). + * Each is a canvas painted black on white; the same generator runs for the + * preview, the PNG and the standalone build. + */ +export const EFFECT_PRESETS = [ + { name: 'focus', label: '集中線' }, + { name: 'fall', label: '落ち込み線' }, + { name: 'speed', label: '疾走線' }, + { name: 'ellipse', label: '楕円集中線' }, +]; + +const DEFAULT_EFFECT = 'focus'; + +/** The canvas every effect is painted on, so preview and export agree. */ +export const EFFECT_SIZE = { width: 1200, height: 800 }; + +/** + * A small deterministic PRNG (FNV-1a seeding + mulberry32), so an effect looks + * the same on every run, in every build, and in the tests. + */ +function makeRandom(seedText) { + let seed = 2166136261; + for (let i = 0; i < seedText.length; i++) { + seed ^= seedText.charCodeAt(i); + seed = Math.imul(seed, 16777619); + } + return () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Paint one effect-line backdrop onto `ctx`. Pure and deterministic: given the + * same name and size it always issues the same black-on-white strokes, so the + * unit tests can pin the look down and the cache below stays sound. + * + * @param {CanvasRenderingContext2D} ctx + * @param {'focus'|'fall'|'speed'|'ellipse'} name + * @param {{width:number,height:number}} [size] + */ +export function drawEffectLines(ctx, name, size = EFFECT_SIZE) { + const width = positiveOrOne(size?.width); + const height = positiveOrOne(size?.height); + const kind = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT; + const random = makeRandom(kind); + + ctx.save(); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, width, height); + ctx.strokeStyle = '#111111'; + ctx.lineCap = 'butt'; + + if (kind === 'focus') { + // A burst: lines radiate from a point behind the head, and stop short of it + // so the character's face is not crossed by ink. + const cx = width * 0.5; + const cy = height * 0.42; + const reach = Math.hypot(width, height) * 1.1; + const near = Math.min(width, height); + for (let i = 0; i < 150; i++) { + const angle = (i / 150) * Math.PI * 2 + (random() - 0.5) * 0.03; + const inner = near * (0.12 + random() * 0.5); + ctx.lineWidth = 1 + random() * 4; + ctx.beginPath(); + ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner); + ctx.lineTo(cx + Math.cos(angle) * reach, cy + Math.sin(angle) * reach); + ctx.stroke(); + } + } else if (kind === 'fall') { + // Lines raining down from the top edge, of uneven length. + for (let i = 0; i < 90; i++) { + const x = Math.min(width, Math.max(0, ((i + 0.5) / 90) * width + (random() - 0.5) * 8)); + const length = height * (0.25 + random() * 0.7); + ctx.lineWidth = 1 + random() * 3; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, length); + ctx.stroke(); + } + } else if (kind === 'speed') { + // Lines streaming in from one side or the other. + for (let i = 0; i < 70; i++) { + const y = Math.min(height, Math.max(0, ((i + 0.5) / 70) * height + (random() - 0.5) * 6)); + const length = width * (0.35 + random() * 0.65); + const x = random() < 0.5 ? 0 : width - length; + ctx.lineWidth = 1 + random() * 3.5; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + length, y); + ctx.stroke(); + } + } else if (kind === 'ellipse') { + // A dense ring of short lines *outside* a wide ellipse: the blank inside is + // the point (the 「ひとり」 panel), so nothing is drawn there. The start + // points are spaced by arc length, not by angle - an equal angular step + // bunches the lines at the two ends of a wide ellipse and leaves gaps along + // the flat top and bottom, which is what made the old ring look uneven. Each + // line then leaves along the ellipse's outward normal, so the inner edge + // stays a clean ellipse all the way round. Lengths vary (0.5-1.7x reach) and + // every line is anchored to the ring, so none reads as a stray scratch. + const cx = width * 0.5; + const cy = height * 0.5; + const rx = Math.min(width * 0.42, height * 0.9); + const ry = rx * 0.55; + const lines = 200; + const reach = Math.min(width, height) * 0.24; + + // Cumulative arc length around the ring, so the lines can be spread evenly + // around it rather than evenly by angle. + const samples = 512; + const arc = [0]; + for (let s = 1; s <= samples; s++) { + const prev = ((s - 1) / samples) * Math.PI * 2; + const next = (s / samples) * Math.PI * 2; + const dx = (Math.cos(next) - Math.cos(prev)) * rx; + const dy = (Math.sin(next) - Math.sin(prev)) * ry; + arc.push(arc[s - 1] + Math.hypot(dx, dy)); + } + const total = arc[samples]; + + let s = 1; + for (let i = 0; i < lines; i++) { + const target = ((i + 0.5) / lines) * total; + while (s < samples && arc[s] < target) s++; + const span = arc[s] - arc[s - 1] || 1; + const t = (((s - 1) + (target - arc[s - 1]) / span) / samples) * Math.PI * 2; + const cos = Math.cos(t); + const sin = Math.sin(t); + // The outward normal of an ellipse points along (cos/rx, sin/ry), not along + // the radius from the centre, so the lines meet the ring at a right angle. + const nx = cos / rx; + const ny = sin / ry; + const length = (reach * (0.5 + random() * 1.1)) / (Math.hypot(nx, ny) || 1); + ctx.lineWidth = 1 + random() * 3.5; + const startX = cx + cos * rx; + const startY = cy + sin * ry; + ctx.beginPath(); + ctx.moveTo(startX, startY); + ctx.lineTo(startX + nx * length, startY + ny * length); + ctx.stroke(); + } + } + ctx.restore(); +} + +/** How each `backgroundFit` paints: a CSS size, plus whether the image tiles. */ +const FIT_STYLES = { + cover: { backgroundSize: 'cover', backgroundRepeat: 'no-repeat' }, + contain: { backgroundSize: 'contain', backgroundRepeat: 'no-repeat' }, + stretch: { backgroundSize: '100% 100%', backgroundRepeat: 'no-repeat' }, + tile: { backgroundSize: 'auto', backgroundRepeat: 'repeat' }, +}; +const DEFAULT_FIT = 'cover'; + +/** A live frame is a replaced element, so the same fit maps to `object-fit`. */ +const OBJECT_FIT = { cover: 'cover', contain: 'contain', '100% 100%': 'fill', auto: 'cover' }; + +const MODES = new Set(['solid', 'transparent', 'preset', 'image', 'effect', 'camera']); + +/** Device orientation fires around 60 Hz; 30 Hz is plenty for swinging a camera. */ +const GYRO_INTERVAL_MS = 33; + +/** Coerce to a finite number, or `null` when the value is not one. */ +function finiteOrNull(value) { + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function normalizeMode(mode) { + return MODES.has(mode) ? mode : 'solid'; +} + +function clamp01(value) { + const n = finiteOrNull(value); + if (n == null) return 0; + return Math.min(1, Math.max(0, n)); +} + +function nonNegative(value) { + const n = finiteOrNull(value); + return n == null ? 0 : Math.max(0, n); +} + +function positiveOrOne(value) { + const n = finiteOrNull(value); + return n == null || n <= 0 ? 1 : n; +} + +/** The shortest signed distance between two angles, in degrees. */ +function wrapDeg(degrees) { + return ((degrees + 540) % 360) - 180; +} + +/** + * Turn the `view` slice into the plain descriptor `apply()` renders from. + * Every field is optional, so a half-written state still yields a usable look; + * an unknown `background` falls back to `solid` and an unknown `backgroundFit` + * to `cover`. + * + * `offset` is in fractions of the layer size, and `visible` is false only for + * `transparent` (where the page background is meant to show through). + */ +export function backdropStyle(viewState) { + const state = viewState && typeof viewState === 'object' ? viewState : {}; + const fit = FIT_STYLES[state.backgroundFit] ?? FIT_STYLES[DEFAULT_FIT]; + const offset = state.backgroundOffset && typeof state.backgroundOffset === 'object' + ? state.backgroundOffset + : {}; + + return { + backgroundSize: fit.backgroundSize, + backgroundRepeat: fit.backgroundRepeat, + mirror: state.cameraMirror === true, + darken: clamp01(state.backgroundDarken), + blur: nonNegative(state.backgroundBlur), + offset: { x: finiteOrNull(offset.x) ?? 0, y: finiteOrNull(offset.y) ?? 0 }, + scale: positiveOrOne(state.backgroundScale), + visible: normalizeMode(state.background) !== 'transparent', + }; +} + +/** `assets/backgrounds/<name>.webp` - what the multi-file build serves. */ +function defaultResolveUrl(name) { + return `assets/backgrounds/${name}.webp`; +} + +/** + * Build the backdrop layer for a `#stage` element. + * + * `resolveUrl(name)` maps a preset name to an image URL; the single-file build + * passes one that reads an inlined map, while the default points at + * `assets/backgrounds/`. `onNeedsRender` is called whenever the layer changes so + * the host can repaint. + * + * The returned object is the only way in: the layer never reads or writes the + * studio state itself, `apply(viewState)` is the single entry point. + */ +export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) { + if (!stage || typeof stage.prepend !== 'function') { + throw new Error('createBackdrop: ステージ要素(#stage)が必要です'); + } + + const doc = stage.ownerDocument ?? globalThis.document; + const resolve = typeof resolveUrl === 'function' ? resolveUrl : defaultResolveUrl; + const needsRender = typeof onNeedsRender === 'function' ? onNeedsRender : () => {}; + + const el = doc.createElement('div'); + el.className = 'backdrop'; + el.setAttribute('aria-hidden', 'true'); + Object.assign(el.style, { + position: 'absolute', + inset: '0', + zIndex: '0', + overflow: 'hidden', + pointerEvents: 'none', + transformOrigin: 'center', + }); + + // The image surface is a plain div so blur/scale/tint stay pure CSS. + const media = doc.createElement('div'); + Object.assign(media.style, { position: 'absolute', inset: '0', backgroundPosition: 'center' }); + + // The camera feed is a muted, inline, autoplaying video for the AR mode. + const video = doc.createElement('video'); + video.muted = true; + video.playsInline = true; + video.autoplay = true; + video.setAttribute('muted', ''); + video.setAttribute('playsinline', ''); + video.setAttribute('aria-hidden', 'true'); + Object.assign(video.style, { position: 'absolute', inset: '0', display: 'none' }); + + // The dark veil sits on top of whichever surface is showing, so the character + // keeps its contrast against a busy photo. + const veil = doc.createElement('div'); + Object.assign(veil.style, { position: 'absolute', inset: '0' }); + + el.append(media, video, veil); + // Prepended, i.e. before the canvas; the canvas is lifted back above it. + stage.prepend(el); + + const canvas = stage.querySelector('canvas'); + if (canvas) { + canvas.style.position = 'relative'; + canvas.style.zIndex = '1'; + } + + /** The most recent state slice, so the imperative helpers keep its styling. */ + let last = {}; + let stream = null; + + let gyroEnabled = false; + let gyroAttached = false; + let gyroBase = null; + let gyroCallback = null; + let lastGyroAt = 0; + + const effectUrls = new Map(); + + /** The data URL for an effect-line backdrop, generated once and cached. */ + function effectUrl(name) { + const key = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT; + if (effectUrls.has(key)) return effectUrls.get(key); + const surface = doc.createElement('canvas'); + surface.width = EFFECT_SIZE.width; + surface.height = EFFECT_SIZE.height; + const surfaceCtx = surface.getContext('2d'); + if (!surfaceCtx) return null; + drawEffectLines(surfaceCtx, key, EFFECT_SIZE); + const url = surface.toDataURL('image/png'); + effectUrls.set(key, url); + return url; + } + + function imageUrlOf(mode, viewState) { + if (mode === 'image') { + return typeof viewState.backgroundImage === 'string' && viewState.backgroundImage + ? viewState.backgroundImage + : null; + } + if (mode === 'effect') { + const name = typeof viewState.backgroundEffect === 'string' && viewState.backgroundEffect + ? viewState.backgroundEffect + : DEFAULT_EFFECT; + return effectUrl(name); + } + if (mode !== 'preset') return null; + + const name = typeof viewState.backgroundPreset === 'string' && viewState.backgroundPreset + ? viewState.backgroundPreset + : DEFAULT_PRESET; + try { + const url = resolve(name); + if (typeof url === 'string' && url) return url; + } catch { + // Fall through to the file path: an unknown preset is not worth throwing. + } + return defaultResolveUrl(name); + } + + function colorOr(value) { + return typeof value === 'string' && value ? value : '#ffffff'; + } + + /** + * Fit, mirror, blur and the blur bleeding past the edges (the negative inset + * keeps `filter: blur()` from fading the border to transparent). + */ + function styleSurface(node, style) { + node.style.inset = style.blur > 0 ? `-${style.blur * 2}px` : '0'; + node.style.filter = style.blur > 0 ? `blur(${style.blur}px)` : 'none'; + node.style.transform = style.mirror ? 'scaleX(-1)' : 'none'; + + if (node === media) { + node.style.backgroundSize = style.backgroundSize; + node.style.backgroundRepeat = style.backgroundRepeat; + } else { + node.style.objectFit = OBJECT_FIT[style.backgroundSize] ?? 'cover'; + } + } + + function apply(viewState) { + last = viewState && typeof viewState === 'object' ? viewState : {}; + const style = backdropStyle(last); + + const wanted = normalizeMode(last.background); + const url = imageUrlOf(wanted, last); + // A preset that cannot be resolved is shown as a plain colour, never blank. + const drawable = wanted === 'preset' || wanted === 'image' || wanted === 'effect'; + const mode = drawable && !url ? 'solid' : wanted; + + el.style.display = style.visible ? 'block' : 'none'; + el.style.backgroundColor = mode === 'solid' ? colorOr(last.backgroundColor) : 'transparent'; + el.style.transform = style.scale === 1 && style.offset.x === 0 && style.offset.y === 0 + ? 'none' + : `scale(${style.scale}) translate(${style.offset.x * 100}%, ${style.offset.y * 100}%)`; + + veil.style.background = `rgba(0, 0, 0, ${style.darken})`; + veil.style.display = style.darken > 0 ? 'block' : 'none'; + + const showCamera = style.visible && mode === 'camera'; + const showImage = style.visible && (mode === 'preset' || mode === 'image' || mode === 'effect'); + // Leaving the AR mode must release the camera, not just hide the video. + if (!showCamera && stream) stopCamera(); + + styleSurface(media, style); + styleSurface(video, style); + media.style.display = showImage ? 'block' : 'none'; + video.style.display = showCamera ? 'block' : 'none'; + media.style.backgroundImage = showImage ? `url("${url}")` : 'none'; + + needsRender(); + } + + /** Show a built-in backdrop, keeping the fit/darken/blur already in play. */ + function loadPreset(name) { + const preset = typeof name === 'string' && name ? name : DEFAULT_PRESET; + apply({ ...last, background: 'preset', backgroundPreset: preset }); + } + + /** Apply a stored data URL (as kept in `view.backgroundImage`). */ + function applyImage(dataUrl) { + if (typeof dataUrl !== 'string' || !dataUrl) { + apply({ ...last, background: 'solid', backgroundImage: null }); + return; + } + apply({ ...last, background: 'image', backgroundImage: dataUrl }); + } + + /** Read a user File into a data URL, show it, and hand the URL back to save. */ + async function loadImageFile(file) { + const dataUrl = await new Promise((resolveUrl, reject) => { + const reader = new FileReader(); + reader.onload = () => resolveUrl(String(reader.result)); + reader.onerror = () => reject(reader.error ?? new Error('画像を読み込めませんでした')); + reader.readAsDataURL(file); + }); + applyImage(dataUrl); + return dataUrl; + } + + /** Japanese, actionable text for the ways `getUserMedia` usually fails. */ + function cameraFailureReason(error) { + switch (error?.name) { + case 'NotAllowedError': + case 'PermissionDeniedError': + return 'カメラの使用が許可されていません'; + case 'NotFoundError': + case 'DevicesNotFoundError': + return '使えるカメラが見つかりませんでした'; + case 'NotReadableError': + case 'TrackStartError': + return 'カメラを起動できませんでした(他のアプリが使用中の可能性があります)'; + case 'OverconstrainedError': + case 'ConstraintNotSatisfiedError': + return '指定したカメラを使用できません'; + case 'SecurityError': + return 'カメラを使うには https か localhost が必要です'; + default: + return 'カメラを起動できませんでした'; + } + } + + async function startCamera(facing = 'environment') { + const mediaDevices = globalThis.navigator?.mediaDevices; + if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') { + return { ok: false, reason: 'カメラを使うには https か localhost が必要です' }; + } + + stopCamera(); + try { + const next = await mediaDevices.getUserMedia({ video: { facingMode: facing } }); + stream = next; + video.srcObject = next; + try { + await video.play(); + } catch { + // A blocked autoplay still resolves to a painting stream in practice. + } + apply({ ...last, background: 'camera', cameraFacing: facing }); + return { ok: true }; + } catch (error) { + stopCamera(); + return { ok: false, reason: cameraFailureReason(error) }; + } + } + + function stopCamera() { + const active = stream; + stream = null; + if (active) { + try { + for (const track of active.getTracks()) track.stop(); + } catch { + // A track that cannot be stopped is already gone as far as we care. + } + } + if (video) { + try { + video.pause(); + } catch { + // Pausing a video without a source is expected to throw. + } + video.srcObject = null; + video.style.display = 'none'; + } + } + + function emitGyro(yaw, pitch, roll) { + gyroCallback?.({ yaw, pitch, roll }); + } + + /** + * Attach the orientation listener and take the current pose as zero, so the + * first reading a callback sees is `{ yaw: 0, pitch: 0, roll: 0 }`. + */ + function enableGyro() { + gyroEnabled = true; + gyroBase = null; + if (!gyroAttached && typeof globalThis.addEventListener === 'function') { + globalThis.addEventListener('deviceorientation', handleOrientation); + gyroAttached = true; + } + emitGyro(0, 0, 0); + } + + /** + * Degrees of turn from the pose gyro started at. `yaw` grows clockwise + * (turning the device to the right); `pitch` and `roll` follow the raw + * `beta` and `gamma` axes. Readings are throttled to roughly 30 Hz. + */ + function handleOrientation(event) { + if (!gyroEnabled) return; + + const alpha = typeof event?.alpha === 'number' ? event.alpha : null; + const beta = typeof event?.beta === 'number' ? event.beta : null; + const gamma = typeof event?.gamma === 'number' ? event.gamma : null; + if (alpha == null && beta == null && gamma == null) return; + + if (!gyroBase) gyroBase = { alpha: alpha ?? 0, beta: beta ?? 0, gamma: gamma ?? 0 }; + + const now = Date.now(); + if (now - lastGyroAt < GYRO_INTERVAL_MS) return; + lastGyroAt = now; + + emitGyro( + alpha == null ? 0 : wrapDeg(gyroBase.alpha - alpha), + beta == null ? 0 : wrapDeg(beta - gyroBase.beta), + gamma == null ? 0 : wrapDeg(gamma - gyroBase.gamma), + ); + } + + /** Ask for orientation permission (iOS) and start listening. */ + async function requestGyro() { + const OrientationEvent = globalThis.DeviceOrientationEvent; + if (!OrientationEvent || typeof globalThis.addEventListener !== 'function') return false; + + if (typeof OrientationEvent.requestPermission === 'function') { + let granted = false; + try { + granted = (await OrientationEvent.requestPermission()) === 'granted'; + } catch { + granted = false; + } + if (!granted) return false; + } + + enableGyro(); + return true; + } + + /** Subscribe to `{ yaw, pitch, roll }`; returns an unsubscribe function. */ + function onGyro(callback) { + gyroCallback = typeof callback === 'function' ? callback : null; + if (!gyroEnabled) enableGyro(); + return () => { + if (gyroCallback === callback) gyroCallback = null; + }; + } + + function dispose() { + stopCamera(); + if (gyroAttached) { + globalThis.removeEventListener?.('deviceorientation', handleOrientation); + gyroAttached = false; + } + gyroEnabled = false; + gyroCallback = null; + gyroBase = null; + el.remove(); + } + + return { + el, + apply, + loadPreset, + loadImageFile, + applyImage, + startCamera, + stopCamera, + /** The data URL of a drawn effect line, so exports can composite the same + * bitmap the preview shows. */ + effectUrl, + get cameraActive() { + return stream !== null; + }, + requestGyro, + onGyro, + snapshot() {}, + dispose, + }; +} diff --git a/public/bluebey-studio/src/caption.js b/public/bluebey-studio/src/caption.js new file mode 100644 index 0000000..15f5bf3 --- /dev/null +++ b/public/bluebey-studio/src/caption.js @@ -0,0 +1,896 @@ +import { + captionFontStack, + hasGlyphs, + layoutText, + textToPathData, +} from './textOutlines.js'; + +/** + * The speech bubble that sits next to the character. + * + * The same caption is drawn three times: into the 2D canvas that is overlaid on + * the 3D view, into the canvas that gets composited into an exported PNG, and + * into the vector SVG export. Drawing a shape three times by hand is how a + * preview starts to disagree with the file it produced, so the outline is built + * exactly once, here, as a short list of path commands. `drawCaption` replays + * that list through a 2D context and `captionToSvg` prints the same list as SVG + * path data, which is what keeps the two renderers together. + * + * The module never measures or wraps text itself: `textOutlines.js` owns line + * breaking and glyph outlines. This file only decides how large the bubble has + * to be for the block it is handed, padding included, and guarantees that block + * fits inside. + * + * It is pure in the same sense as `trace.js`: no DOM, no globals and no + * asynchronous work, so the tests can drive it in Node with a stub context. + */ + +/** Defaults mirroring the `caption` slice of `presets.js`. */ +const DEFAULT_FONT_SIZE = 34; +const DEFAULT_LINE_HEIGHT = 1.42; +const DEFAULT_PADDING = 18; +const DEFAULT_RADIUS = 24; +const DEFAULT_BORDER_WIDTH = 4; +const DEFAULT_MAX_WIDTH = 0.36; +const DEFAULT_TEXT_COLOR = '#3f2b52'; +const DEFAULT_BUBBLE_COLOR = '#ffffff'; +const DEFAULT_BORDER_COLOR = '#55386e'; + +/** Advance widths used before the vendored font has loaded, as a fraction of em. */ +const FALLBACK_WIDE_EM = 1; +const FALLBACK_NARROW_EM = 0.55; + +/** + * Vertical metrics of the fallback stack, as a fraction of the font size. + * + * A typical Japanese family sits close to these numbers, so the baseline of the + * pre-load preview barely moves when the real font arrives. + */ +const FALLBACK_ASCENT = 0.88; +const FALLBACK_DESCENT = 0.12; + +/** Control-point distance that turns a corner into a quarter circle. */ +const KAPPA = 0.5522847498307936; + +/** Code-point ranges that deserve a full em in the fallback measurement. */ +const WIDE_RANGES = [ + [0x1100, 0x11ff], [0x2e80, 0x30ff], [0x3130, 0x318f], [0x3400, 0x4dbf], + [0x4e00, 0x9fff], [0xac00, 0xd7ff], [0xf900, 0xfaff], [0xff00, 0xffef], +]; + +/** + * The `ctx.font` string for a caption. + * + * Canvas wants size, weight and family in one string, and the size has to carry + * the export scale because the same caption is drawn at 1x for the preview and + * at 2x or more into a PNG. `loaded` is the app's answer to "is the vendored + * subset ready?": until it is, the vendored family is dropped from the stack so + * the preview does not ask for a font that is still downloading. + * + * @param {object} caption the `caption` slice of the state + * @param {number} [scale=1] + * @param {boolean} [loaded=false] + * @returns {string} + */ +export function fontSpec(caption, scale = 1, loaded = false) { + const state = caption ?? {}; + const size = resolveFontSize(state, positiveScale(scale)); + const weight = state.bold ? 'bold ' : ''; + return `${weight}${formatNumber(size, 3)}px ${fontFamilyStack(state, loaded)}`; +} + +/** + * Resolve a caption to pixel geometry at the output size. + * + * `x`/`y` are fractions of the image and land on the bubble's top-left corner; + * `maxWidth` is a fraction of the image width and caps the text column. The box + * is always the bubble itself, padding included and the tail excluded, and it + * always fits inside the image: padding gives way first, then the position is + * clamped (leaving room for the tail), and only a block larger than the image + * is clipped. Every returned number is finite, even for an empty caption or a + * zero-sized image. + * + * `font` is an opentype font from `textOutlines.js`. Without it the text is + * wrapped by a rough character count, so the preview still shows a bubble while + * the font is loading. + * + * @param {object} caption + * @param {object} [options] + * @param {number} [options.width=0] + * @param {number} [options.height=0] + * @param {number} [options.scale=1] + * @param {import('opentype.js').Font|null} [options.font=null] + * @returns {{ + * box: {x: number, y: number, w: number, h: number}, + * lines: Array<{text: string, width: number}>, + * fontSize: number, lineHeight: number, padding: number, scale: number, + * usedOutlines: boolean, + * }} + */ +export function layoutCaption(caption, { width, height, scale = 1, font = null } = {}) { + const state = caption ?? {}; + const resolvedScale = positiveScale(scale); + const outWidth = toNonNegative(width, 0); + const outHeight = toNonNegative(height, 0); + + const fontSize = resolveFontSize(state, resolvedScale); + const lineHeight = toPositive(state.lineHeight, DEFAULT_LINE_HEIGHT); + const requested = toNonNegative(state.padding, DEFAULT_PADDING) * resolvedScale; + const text = typeof state.text === 'string' ? state.text : ''; + + const block = layoutBlock(font, text, { + fontSize, + maxWidth: resolveColumn(state, outWidth, requested), + lineHeight, + align: alignOf(state), + }); + + // Padding gives way before the bubble does, so a caption that cannot fit with + // its usual breathing room still fits inside the image. + const padding = Math.max( + 0, + Math.min(requested, (outWidth - block.width) / 2, (outHeight - block.height) / 2), + ); + const w = Math.min(block.width + 2 * padding, outWidth); + const h = Math.min(block.height + 2 * padding, outHeight); + + // The tail sticks out of the box, so the box has to keep that much clear of + // the edge it points at. + const tail = tailOf(state); + const tailLength = tail === 'none' ? 0 : Math.max(0, Math.min(fontSize * 0.9, h * 0.5)); + const roomX = Math.max(0, outWidth - w); + const roomY = Math.max(0, outHeight - h); + + // Which way the tail leaves the box, as a direction where each axis is -1, + // 0 or 1. A diagonal tail needs room on *both* axes, so the clearance is + // driven by the direction rather than by which name the tail has. + const dir = tailDirection(tail); + let lowX = 0; + let highX = roomX; + if (dir.x < 0) lowX = Math.min(tailLength, roomX); + else if (dir.x > 0) highX = Math.max(0, roomX - tailLength); + + let lowY = 0; + let highY = roomY; + if (dir.y < 0) lowY = Math.min(tailLength, roomY); + else if (dir.y > 0) highY = Math.max(0, roomY - tailLength); + + const box = { + x: clamp(toFinite(state.x, 0) * outWidth, lowX, highX), + y: clamp(toFinite(state.y, 0) * outHeight, lowY, highY), + w, + h, + }; + + return { + box, + lines: block.lines, + fontSize, + lineHeight, + padding, + scale: resolvedScale, + usedOutlines: Boolean(font), + }; +} + +/** + * Lay the bubble down in a 2D context, ready to be filled and stroked. + * + * The tail is part of the same path as the bubble, so the border runs along it + * and joins the outline cleanly instead of meeting it at a seam. The caller + * owns the colours; this only traces the shape. + * + * @param {CanvasRenderingContext2D} ctx + * @param {{x: number, y: number, w: number, h: number}} box bubble box, tail excluded + * @param {object} caption + * @param {number} [scale=1] + * @returns {boolean} `true` when there was a shape to trace + */ +export function bubblePath(ctx, box, caption, scale = 1) { + const commands = bubbleCommands(box, caption ?? {}, positiveScale(scale)); + applyCommands(ctx, commands); + return commands.length > 0; +} + +/** + * Draw a caption: bubble first, then the text line by line. + * + * The 2D canvas is overlaid exactly on the 3D view for the preview and is + * composited into the PNG on export, which is why the very same function runs + * in both places: what the user sees is what the file contains. + * + * The bubble box comes back even when the caption is switched off, so the app + * can show a placeholder and hit-test a drag without a second layout. + * + * @param {CanvasRenderingContext2D} ctx + * @param {object} caption + * @param {object} [options] + * @param {number} [options.width=0] + * @param {number} [options.height=0] + * @param {number} [options.scale=1] + * @param {import('opentype.js').Font|null} [options.font=null] + * @returns {{x: number, y: number, w: number, h: number}} + */ +export function drawCaption(ctx, caption, { width, height, scale = 1, font = null } = {}) { + const state = caption ?? {}; + const layout = layoutCaption(state, { width, height, scale, font }); + const { box } = layout; + if (!state.enabled || !ctx) return box; + + ctx.save(); + + const commands = bubbleCommands(box, state, layout.scale); + if (commands.length > 0) { + applyCommands(ctx, commands); + ctx.fillStyle = colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR); + ctx.fill(); + + const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale; + if (borderWidth > 0) { + ctx.strokeStyle = colorOf(state.borderColor, DEFAULT_BORDER_COLOR); + ctx.lineWidth = borderWidth; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.stroke(); + } + } + + if (layout.lines.length > 0) drawText(ctx, layout, state, font); + + ctx.restore(); + return box; +} + +/** + * The caption as an SVG fragment, plus whether the text is outlined. + * + * Text becomes glyph outlines whenever the font covers it, because a `<text>` + * element is redrawn with whatever font the viewer happens to have and a + * caption that reflows is worse than one that cannot be selected. When a glyph + * is missing the whole caption falls back to `<text>` and `usedOutlines` is + * `false`, which is the caller's signal to warn that the file now depends on + * the viewer's fonts. + * + * The bubble and its tail are one `<path>`, traced from the same command list + * the canvas uses. `enabled` is not consulted: the export path only runs for an + * enabled caption, and drawing whatever is handed over keeps this usable for a + * thumbnail. + * + * @param {object} caption + * @param {object} [options] + * @param {number} [options.width=0] + * @param {number} [options.height=0] + * @param {number} [options.scale=1] + * @param {import('opentype.js').Font|null} [options.font=null] + * @param {number} [options.round=2] decimal places in the output + * @returns {{svg: string, usedOutlines: boolean}} + */ +export function captionToSvg(caption, { width, height, scale = 1, font = null, round = 2 } = {}) { + const state = caption ?? {}; + const places = Math.max(0, Math.min(8, Math.floor(Number.isFinite(round) ? round : 2))); + const layout = layoutCaption(state, { width, height, scale, font }); + const { box, lines, fontSize, lineHeight, padding } = layout; + const outWidth = toNonNegative(width, 0); + const outHeight = toNonNegative(height, 0); + const text = typeof state.text === 'string' ? state.text : ''; + + let usedOutlines = Boolean(font) && text.trim() !== '' && hasGlyphs(font, text); + + const parts = [ + '<svg xmlns="http://www.w3.org/2000/svg"' + + ` width="${formatNumber(outWidth, places)}"` + + ` height="${formatNumber(outHeight, places)}"` + + ` viewBox="0 0 ${formatNumber(outWidth, places)} ${formatNumber(outHeight, places)}">`, + ]; + + const bubble = commandsToPathData(bubbleCommands(box, state, layout.scale), places); + if (bubble) { + const fill = escapeAttribute(colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR)); + const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale; + const stroke = borderWidth > 0 + ? ` stroke="${escapeAttribute(colorOf(state.borderColor, DEFAULT_BORDER_COLOR))}"` + + ` stroke-width="${formatNumber(borderWidth, places)}" stroke-linejoin="round"` + : ''; + parts.push(`<path d="${bubble}" fill="${fill}"${stroke}/>`); + } + + if (lines.length > 0) { + const textColor = colorOf(state.textColor, DEFAULT_TEXT_COLOR); + let outlined = ''; + if (usedOutlines) { + // `textToPathData` re-applies the alignment against the widest line, so + // the block it needs is the one `layoutText` measured. + outlined = textToPathData(font, { + lines, + width: blockWidthOf(lines), + height: lines.length * fontSize * lineHeight, + lineHeight, + fontSize, + align: alignOf(state), + }, { x: box.x + padding, y: box.y + padding, round: places }); + usedOutlines = outlined !== ''; + } + if (usedOutlines) { + parts.push(`<path d="${outlined}" fill="${escapeAttribute(textColor)}"/>`); + } else { + parts.push(liveText(state, layout, font, textColor, places)); + } + } + + parts.push('</svg>'); + return { svg: parts.join('\n') + '\n', usedOutlines }; +} + +// --- text -------------------------------------------------------------------- + +/** Draw every line at its own baseline, shifted sideways by the alignment. */ +function drawText(ctx, layout, state, font) { + const { box, lines, fontSize, lineHeight, padding, scale } = layout; + const metrics = baselineMetrics(font, fontSize); + const step = fontSize * lineHeight; + const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2; + const blockWidth = blockWidthOf(lines); + const left = box.x + padding; + const top = box.y + padding; + const align = alignOf(state); + + ctx.font = fontSpec(state, scale, Boolean(font)); + ctx.fillStyle = colorOf(state.textColor, DEFAULT_TEXT_COLOR); + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const x = left + alignOffset(align, blockWidth, line.width); + const y = top + i * step + halfLeading + metrics.ascent; + ctx.fillText(line.text, x, y); + } +} + +/** + * The `<text>` fallback. + * + * The family comes from the font stack alone, not from `fontSpec`: size and + * weight have attributes of their own, and a `font-family` that carried them + * would be ignored by every viewer. + */ +function liveText(state, layout, font, fill, places) { + const { box, lines, fontSize, lineHeight, padding } = layout; + const blockWidth = blockWidthOf(lines); + const align = alignOf(state); + const left = box.x + padding; + const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start'; + const x = align === 'center' + ? left + blockWidth / 2 + : align === 'right' + ? left + blockWidth + : left; + + const metrics = baselineMetrics(font, fontSize); + const step = fontSize * lineHeight; + const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2; + const top = box.y + padding; + + const attributes = [ + `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`, + `font-size="${formatNumber(fontSize, places)}"`, + state.bold ? 'font-weight="bold"' : '', + `fill="${escapeAttribute(fill)}"`, + `text-anchor="${anchor}"`, + ].filter(Boolean).join(' '); + + const tspans = lines.map((line, i) => { + const y = top + i * step + halfLeading + metrics.ascent; + return `<tspan x="${formatNumber(x, places)}" y="${formatNumber(y, places)}">` + + `${escapeText(line.text)}</tspan>`; + }).join(''); + + return `<text ${attributes}>${tspans}</text>`; +} + +/** + * Wrap text without a font, from a character count. + * + * The preview has to show something while the vendored font downloads, and this + * is a deliberately crude stand-in: wide (CJK) characters count as one em and + * everything else as half an em. Once `layoutText` can run its metrics replace + * these numbers, so the rough version only ever decides a preview, never a file. + */ +function wrapByCount(text, { fontSize, maxWidth, lineHeight }) { + const limit = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : Infinity; + const lines = []; + + for (const paragraph of String(text).split(/\r\n|\r|\n/)) { + let current = ''; + for (const ch of paragraph) { + const candidate = current + ch; + if (current.trimEnd() !== '' && measureFallback(candidate, fontSize) > limit) { + lines.push(current.trimEnd()); + current = /\s/.test(ch) ? '' : ch; // the break swallows the space + } else { + current = candidate; + } + } + lines.push(current.trimEnd()); + } + + const measured = lines.map((line) => ({ text: line, width: measureFallback(line, fontSize) })); + let width = 0; + for (const line of measured) width = Math.max(width, line.width); + return { lines: measured, width, height: measured.length * fontSize * lineHeight }; +} + +/** Wrap with `layoutText`, or with the character count when there is no font. */ +function layoutBlock(font, text, options) { + if (text === '') return { lines: [], width: 0, height: 0 }; + if (font) { + const layout = layoutText(font, text, options); + return { lines: layout.lines, width: layout.width, height: layout.height }; + } + return wrapByCount(text, options); +} + +/** Text column in pixels: a fraction of the image, never wider than the image. */ +function resolveColumn(state, outWidth, padding) { + const fraction = toNonNegative(state.maxWidth, DEFAULT_MAX_WIDTH); + if (!(fraction > 0)) return 0; // 0 means "do not wrap", as in `layoutText` + return Math.min(fraction * outWidth, Math.max(0, outWidth - 2 * padding)); +} + +/** Ascent above and descent below the baseline, in pixels. */ +function baselineMetrics(font, fontSize) { + const unitsPerEm = font?.unitsPerEm; + if (Number.isFinite(font?.ascender) && Number.isFinite(font?.descender) && unitsPerEm > 0) { + return { + ascent: (font.ascender * fontSize) / unitsPerEm, + descent: (-font.descender * fontSize) / unitsPerEm, + }; + } + return { ascent: fontSize * FALLBACK_ASCENT, descent: fontSize * FALLBACK_DESCENT }; +} + +/** Widest line of the block, which is the width the alignment works against. */ +function blockWidthOf(lines) { + let width = 0; + for (const line of lines) width = Math.max(width, toFinite(line?.width, 0)); + return width; +} + +/** Sideways shift of one line inside the block. */ +function alignOffset(align, blockWidth, lineWidth) { + const slack = Math.max(0, blockWidth - lineWidth); + if (align === 'center') return slack / 2; + if (align === 'right') return slack; + return 0; +} + +/** Width of a string by character count, in pixels. */ +function measureFallback(text, fontSize) { + let width = 0; + for (const ch of String(text ?? '')) { + width += isWideChar(ch) ? fontSize * FALLBACK_WIDE_EM : fontSize * FALLBACK_NARROW_EM; + } + return width; +} + +/** Characters that usually take a full em in a Japanese font. */ +function isWideChar(ch) { + const code = ch.codePointAt(0); + for (const [start, end] of WIDE_RANGES) { + if (code >= start && code <= end) return true; + } + return false; +} + +// --- geometry ---------------------------------------------------------------- + +/** + * The bubble outline as path commands. + * + * Both renderers go through here, which is the point: the canvas preview, the + * PNG and the SVG are three views of one geometry rather than three + * implementations of it. + * + * @returns {Array<object>} empty when there is nothing to draw + */ +function bubbleCommands(box, state, scale) { + const shape = bubbleOf(state); + const rect = { + x: toFinite(box?.x, 0), + y: toFinite(box?.y, 0), + w: toNonNegative(box?.w, 0), + h: toNonNegative(box?.h, 0), + }; + if (shape === 'none' || !(rect.w > 0) || !(rect.h > 0)) return []; + + const radius = toNonNegative(state.radius, DEFAULT_RADIUS) * scale; + const edges = shape === 'shout' + ? shoutEdges(rect, scale) + : shape === 'rect' + ? rectEdges(rect) + : roundEdges(rect, radius); + + return edgesToCommands(withTail(edges, rect, state, scale)); +} + +/** Clockwise rectangle, starting at the top-left corner. */ +function rectEdges(box) { + const { x, y, w, h } = box; + const tl = { x, y }; + const tr = { x: x + w, y }; + const br = { x: x + w, y: y + h }; + const bl = { x, y: y + h }; + return [ + lineEdge(tl, tr), + lineEdge(tr, br), + lineEdge(br, bl), + lineEdge(bl, tl), + ]; +} + +/** + * Rounded rectangle as straight sides plus quarter-circle corners. + * + * Keeping the sides straight (rather than approximating the whole outline with + * a polyline) is what lets the tail attach to a real straight edge; the radius + * is clamped so the corners can never cross each other. + */ +function roundEdges(box, radius) { + const { x, y, w, h } = box; + const r = clamp(radius, 0, Math.min(w, h) / 2); + if (!(r > 0.5)) return rectEdges(box); + + const k = r * KAPPA; + const p = (px, py) => ({ x: px, y: py }); + const corner = (from, c1, c2, to) => cubicEdge(p(...from), p(...c1), p(...c2), p(...to)); + return [ + lineEdge(p(x + r, y), p(x + w - r, y)), + corner([x + w - r, y], [x + w - r + k, y], [x + w, y + r - k], [x + w, y + r]), + lineEdge(p(x + w, y + r), p(x + w, y + h - r)), + corner([x + w, y + h - r], [x + w, y + h - r + k], [x + w - r + k, y + h], [x + w - r, y + h]), + lineEdge(p(x + w - r, y + h), p(x + r, y + h)), + corner([x + r, y + h], [x + r - k, y + h], [x, y + h - r + k], [x, y + h - r]), + lineEdge(p(x, y + h - r), p(x, y + r)), + corner([x, y + r], [x, y + r - k], [x + r - k, y], [x + r, y]), + ]; +} + +/** + * Star-burst: a rectangle whose four sides zig-zag outwards. + * + * The tooth depth and pitch are fractions of the bubble, so a big "shout" and a + * small one have the same number of spikes instead of the big one looking like + * a saw. + */ +function shoutEdges(box, scale) { + const { x, y, w, h } = box; + const amp = Math.min(w, h) * 0.07; + const pitch = Math.max(scale, Math.min(w, h) * 0.22); + const corners = [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }]; + const normals = [{ x: 0, y: -1 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }]; + + const points = []; + for (let side = 0; side < 4; side++) { + const a = corners[side]; + const b = corners[(side + 1) % 4]; + const normal = normals[side]; + const length = Math.hypot(b.x - a.x, b.y - a.y); + const teeth = Math.max(2, Math.round(length / pitch)); + points.push(a); + for (let i = 0; i < teeth; i++) { + if (i > 0) points.push(lerp(a, b, i / teeth)); + const out = lerp(a, b, (i + 0.5) / teeth); + points.push({ x: out.x + normal.x * amp, y: out.y + normal.y * amp }); + } + } + + return points.map((from, i) => lineEdge(from, points[(i + 1) % points.length])); +} + +/** + * Replace part of the edge the tail points at with a spike. + * + * The spike is inserted *into* the outline, not appended to it: the two sides + * of the tail and the bubble become one continuous border. The straight edge + * nearest the tail's anchor is used, which for every shape except the shout is + * the obvious side and for the shout is the nearest zig-zag segment. + */ +function withTail(edges, box, state, scale) { + const tail = tailOf(state); + if (tail === 'none' || edges.length === 0) return edges; + + const tailLength = Math.min(resolveFontSize(state, scale) * 0.9, Math.min(box.w, box.h) * 0.5); + if (!(tailLength > 0)) return edges; + + const dir = tailDirection(tail); + // A diagonal tail leaves from a corner; an axis-aligned one leaves from the + // middle of the side it points at. + const anchor = { + x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2, + y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2, + }; + const diagonal = dir.x !== 0 && dir.y !== 0; + const normal = { + x: dir.x * (diagonal ? Math.SQRT1_2 : 1), + y: dir.y * (diagonal ? Math.SQRT1_2 : 1), + }; + + const index = nearestEdge(edges, anchor, box, tail); + if (index < 0) return edges; + + const edge = edges[index]; + const length = distance(edge.from, edge.to); + const base = Math.min(tailLength * 0.7, length * 0.45); + if (!(base > 0) || !(length > 0)) return edges; + + // The spike sits at the point of the edge nearest the anchor - the middle for + // a side, the corner for a diagonal - kept far enough in that both feet land + // on the edge itself. + const t = clamp(projectionT(edge, anchor), base / length, 1 - base / length); + const middle = lerp(edge.from, edge.to, t); + // A diagonal spike advances `tailLength` on *each* axis, so it reaches as far + // as a side tail does and reads as a proper corner. + const reach = tailLength * (diagonal ? Math.SQRT2 : 1); + const tip = { x: middle.x + normal.x * reach, y: middle.y + normal.y * reach }; + const leftFoot = lerp(edge.from, edge.to, t - base / length); + const rightFoot = lerp(edge.from, edge.to, t + base / length); + const replacement = [ + lineEdge(edge.from, leftFoot), + lineEdge(leftFoot, tip), + lineEdge(tip, rightFoot), + lineEdge(rightFoot, edge.to), + ]; + return edges.slice(0, index).concat(replacement, edges.slice(index + 1)); +} + +/** Index of the straight edge closest to the tail's anchor, or `-1`. */ +function nearestEdge(edges, anchor, box, tail) { + let best = -1; + let bestDistance = Infinity; + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + if (edge.kind !== 'line') continue; + const middle = { x: (edge.from.x + edge.to.x) / 2, y: (edge.from.y + edge.to.y) / 2 }; + if (!onSide(middle, box, tail)) continue; + const d = distance(middle, anchor); + if (d < bestDistance) { + bestDistance = d; + best = i; + } + } + return best; +} + +/** Is `point` on the side of the box the tail points at? */ +function onSide(point, box, tail) { + const dir = tailDirection(tail); + const midX = box.x + box.w * 0.5; + const midY = box.y + box.h * 0.5; + if (dir.x < 0 && point.x > midX) return false; + if (dir.x > 0 && point.x < midX) return false; + if (dir.y < 0 && point.y > midY) return false; + if (dir.y > 0 && point.y < midY) return false; + return true; +} + +/** How far along `edge` (0..1) the point nearest `point` falls. */ +function projectionT(edge, point) { + const dx = edge.to.x - edge.from.x; + const dy = edge.to.y - edge.from.y; + const lengthSq = dx * dx + dy * dy; + if (!(lengthSq > 0)) return 0.5; + return ((point.x - edge.from.x) * dx + (point.y - edge.from.y) * dy) / lengthSq; +} + +/** One straight edge of a closed outline. */ +function lineEdge(from, to) { + return { kind: 'line', from, to }; +} + +/** One cubic edge of a closed outline. */ +function cubicEdge(from, c1, c2, to) { + return { kind: 'cubic', from, c1, c2, to }; +} + +/** Walk the edges into `M`/`L`/`C`/`Z` commands. */ +function edgesToCommands(edges) { + if (edges.length === 0) return []; + const commands = [{ type: 'M', x: edges[0].from.x, y: edges[0].from.y }]; + for (const edge of edges) { + if (edge.kind === 'line') { + commands.push({ type: 'L', x: edge.to.x, y: edge.to.y }); + } else { + commands.push({ + type: 'C', + x1: edge.c1.x, y1: edge.c1.y, + x2: edge.c2.x, y2: edge.c2.y, + x: edge.to.x, y: edge.to.y, + }); + } + } + commands.push({ type: 'Z' }); + return commands; +} + +/** Replay commands into a 2D context. */ +function applyCommands(ctx, commands) { + ctx.beginPath(); + for (const command of commands) { + if (command.type === 'M') ctx.moveTo(command.x, command.y); + else if (command.type === 'L') ctx.lineTo(command.x, command.y); + else if (command.type === 'C') { + ctx.bezierCurveTo(command.x1, command.y1, command.x2, command.y2, command.x, command.y); + } else if (command.type === 'Z') { + ctx.closePath(); + } + } +} + +/** Print commands as SVG path data. */ +function commandsToPathData(commands, places) { + const parts = []; + for (const command of commands) { + if (command.type === 'M') { + parts.push(`M${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`); + } else if (command.type === 'L') { + parts.push(`L${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`); + } else if (command.type === 'C') { + parts.push( + `C${formatNumber(command.x1, places)} ${formatNumber(command.y1, places)}` + + ` ${formatNumber(command.x2, places)} ${formatNumber(command.y2, places)}` + + ` ${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`, + ); + } else if (command.type === 'Z') { + parts.push('Z'); + } + } + return parts.join(' '); +} + +// --- shared helpers ---------------------------------------------------------- + +/** The family list for a caption, or the system-only list before the font loads. */ +function fontFamilyStack(caption, loaded) { + const stack = captionFontStack(); + if (loaded && caption?.font !== 'system') return stack; + return dropLeadingFamily(stack); +} + +/** `captionFontStack()` minus its first family, for a plain system fallback. */ +function dropLeadingFamily(stack) { + let quote = null; + for (let i = 0; i < stack.length; i++) { + const ch = stack[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + } else if (ch === "'" || ch === '"') { + quote = ch; + } else if (ch === ',') { + const rest = stack.slice(i + 1).trim(); + return rest === '' ? 'sans-serif' : rest; + } + } + return 'sans-serif'; +} + +/** Font size in output pixels, always positive and finite. */ +function resolveFontSize(caption, scale) { + const base = toPositive(caption?.fontSize, DEFAULT_FONT_SIZE); + return toPositive(base * positiveScale(scale), DEFAULT_FONT_SIZE); +} + +function bubbleOf(caption) { + const value = caption?.bubble; + if (value === 'round' || value === 'rect' || value === 'shout' || value === 'none') return value; + return 'round'; +} + +function tailOf(caption) { + const value = caption?.tail; + return TAIL_VALUES.has(value) ? value : 'left'; +} + +const TAIL_VALUES = new Set([ + 'left', 'right', 'top', 'bottom', + 'topLeft', 'topRight', 'bottomLeft', 'bottomRight', + 'none', +]); + +/** + * Which way a tail leaves the box, as a direction where each axis is -1, 0 or + * 1 (0 for `none` or anything unknown). A diagonal tail carries a sign on both + * axes, so `bottomLeft` is `{ x: -1, y: 1 }`. + */ +function tailDirection(tail) { + switch (tail) { + case 'left': return { x: -1, y: 0 }; + case 'right': return { x: 1, y: 0 }; + case 'top': return { x: 0, y: -1 }; + case 'bottom': return { x: 0, y: 1 }; + case 'topLeft': return { x: -1, y: -1 }; + case 'topRight': return { x: 1, y: -1 }; + case 'bottomLeft': return { x: -1, y: 1 }; + case 'bottomRight': return { x: 1, y: 1 }; + default: return { x: 0, y: 0 }; + } +} + +function alignOf(caption) { + const value = caption?.align; + return value === 'center' || value === 'right' ? value : 'left'; +} + +function colorOf(value, fallback) { + return typeof value === 'string' && value !== '' ? value : fallback; +} + +/** Scale is a multiplier: anything that is not a positive number means 1. */ +function positiveScale(value) { + return Number.isFinite(value) && value > 0 ? value : 1; +} + +function toFinite(value, fallback) { + return Number.isFinite(value) ? value : fallback; +} + +function toPositive(value, fallback) { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +function toNonNegative(value, fallback) { + return Number.isFinite(value) && value >= 0 ? value : fallback; +} + +function clamp(value, low, high) { + if (high < low) return low; + return Math.min(Math.max(value, low), high); +} + +/** Decimal string for both `ctx.font` and SVG, never `NaN` and never `-0`. */ +function formatNumber(value, places = 2) { + if (!Number.isFinite(value)) return '0'; + const decimals = Number.isFinite(places) ? Math.max(0, Math.min(8, Math.floor(places))) : 2; + const factor = Math.pow(10, decimals); + const rounded = Math.round(value * factor) / factor; + return Object.is(rounded, -0) ? '0' : String(rounded); +} + +/** + * Escape character data. + * + * Quotes are escaped too even though element content allows them: the caption + * is user text, and the extra entities cost nothing next to never emitting a + * document a stricter parser could object to. + */ +function escapeText(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Escape an attribute value. + * + * Only the double quote matters inside a double-quoted attribute, so single + * quotes survive and a family stack such as `'Hiragino Maru Gothic ProN', + * sans-serif` stays readable in the file. + */ +function escapeAttribute(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); +} + +function distance(a, b) { + return Math.hypot(b.x - a.x, b.y - a.y); +} + +function lerp(a, b, t) { + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} diff --git a/public/bluebey-studio/src/clip.js b/public/bluebey-studio/src/clip.js new file mode 100644 index 0000000..d94e8e4 --- /dev/null +++ b/public/bluebey-studio/src/clip.js @@ -0,0 +1,174 @@ +import * as THREE from 'three'; + +/** + * 見えない壁 (the invisible wall): a clipping plane that hides whatever falls + * behind it, so the character can be buried in the wall and only the rest of the + * body shows. + * + * The wall is a *finite* rectangle: an invisible quad that writes depth and no + * colour, drawn before everything else in the opaque pass, so the character + * behind it is culled by the depth test. That is what a real wall does, and it + * is the only way to bound the effect - a `renderer.clippingPlanes` entry is an + * infinite half-space, so it can never be limited to a rectangle. + * + * Nothing needs to agree per-material: the depth buffer does the work, so the + * body, the outline hulls, the face plates, the ink pass and the offscreen + * renders an export uses are all hidden by the same wall. + * + * `wallPlane` is pure (no three.js maths), so the geometry can be unit-tested + * without a renderer. + */ + +const DEG = Math.PI / 180; + +/** + * A hair toward the kept side. The guide sits exactly on the cut, and a plane + * on its own boundary is half inside the discarded half - nudging it along the + * normal keeps all of it on the visible side. + */ +const GUIDE_EPSILON = 0.002; + +/** + * The plane a wall setting lies in. + * + * The wall is a *finite* quad, so this no longer decides which half of space is + * hidden - the quad's own depth does that. What `apply` needs from here is the + * normal, which is the direction the quad faces (and so the direction the wall + * lies along), plus the constant of the plane through the point it sits on. + * + * By default the wall lies in the XY plane through `(x, y, z)`; `yaw` turns it + * about Y and `tilt` leans it about X afterwards. + * + * @param {{x?:number,y?:number,z?:number,yaw?:number,tilt?:number}} [wall] + * @returns {{normal:[number,number,number], constant:number}} + */ +export function wallPlane(wall = {}) { + const yaw = (wall.yaw ?? 0) * DEG; + const tilt = (wall.tilt ?? 0) * DEG; + const cosYaw = Math.cos(yaw); + const sinYaw = Math.sin(yaw); + const cosTilt = Math.cos(tilt); + const sinTilt = Math.sin(tilt); + + // Start from +Z, turn about Y, then lean about the (already turned) X axis. + let nx = sinYaw; + let ny = -cosYaw * sinTilt; + let nz = cosYaw * cosTilt; + + const length = Math.hypot(nx, ny, nz) || 1; + nx /= length; + ny /= length; + nz /= length; + + // Constant so the plane passes through (x, y, z): dot(n, p) + c = 0. + const constant = -(nx * (wall.x ?? 0) + ny * (wall.y ?? 0) + nz * (wall.z ?? 0)); + return { normal: [nx, ny, nz], constant }; +} + +/** The faint plane shown while placing the wall. Hidden from every export. */ +/** + * The wall itself: an invisible, *finite* quad that writes depth but no colour. + * + * It is drawn before everything else in the opaque pass (`renderOrder`), so the + * character behind it is culled by the depth test. That is what a real wall does, + * and - unlike a clip plane, which is an infinite half-space - it only hides what + * the rectangle actually covers. So the size sliders are the wall's real size. + */ +function makeWall() { + const material = new THREE.MeshBasicMaterial({ + colorWrite: false, + side: THREE.DoubleSide, + toneMapped: false, + }); + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material); + mesh.name = 'wall'; + // Before everything, including the silhouette-only hulls (renderOrder -1), so + // its depth hides a nose buried in the wall (see styles.js hullMaterialFor). + mesh.renderOrder = -2; + mesh.visible = false; + mesh.castShadow = false; + mesh.receiveShadow = false; + return mesh; +} + +function makeGuide() { + const geometry = new THREE.PlaneGeometry(1, 1); + const material = new THREE.MeshBasicMaterial({ + color: 0x8a4fe0, + transparent: true, + opacity: 0.16, + side: THREE.DoubleSide, + depthWrite: false, + toneMapped: false, + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.name = 'wall-guide'; + mesh.userData.isHelper = true; + mesh.visible = false; + + // An edge, so the plane's extent is legible even where the fill is faint. + const edge = new THREE.LineSegments( + new THREE.EdgesGeometry(geometry), + new THREE.LineBasicMaterial({ color: 0x8a4fe0, transparent: true, opacity: 0.55, toneMapped: false }), + ); + edge.userData.isHelper = true; + mesh.add(edge); + return mesh; +} + +export function createClipper({ scene, renderer, model }) { + const plane = new THREE.Plane(); + const guide = makeGuide(); + const occluder = makeWall(); + scene.add(guide); + scene.add(occluder); + // A little larger than the character: big enough to read as a wall, small + // enough not to cover the whole viewport. + const guideSpan = Math.max(model.size.x, model.size.y, model.size.z) * 1.5; + + /** + * @param {object} wall `state.render.wall` + * + * The guide is drawn only when `wall.guide` is on, which is also when the plane + * can be grabbed in the viewport. Off, the wall is invisible: the cut still + * applies, so you can see the character half-hidden with nothing in the way. + */ + function apply(wall) { + if (wall?.on !== true) { + occluder.visible = false; + guide.visible = false; + return; + } + + const { normal, constant } = wallPlane(wall); + plane.set(new THREE.Vector3(normal[0], normal[1], normal[2]), constant); + + const point = new THREE.Vector3(wall.x ?? 0, wall.y ?? 0, wall.z ?? 0); + const span = guideSpan * Math.max(0.05, wall.size ?? 1); + // The wall and its guide are the same rectangle, turned to face along the + // plane's normal: the wall is the occluder, the guide is the tinted copy that + // is shown only while placing. + for (const mesh of [occluder, guide]) { + mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), plane.normal); + mesh.scale.setScalar(span); + } + occluder.position.copy(point); + occluder.visible = true; + guide.position.copy(point).addScaledVector(plane.normal, GUIDE_EPSILON); + guide.visible = wall.guide === true; + } + + return { + plane, + guide, + occluder, + apply, + dispose() { + for (const mesh of [guide, occluder]) { + mesh.geometry.dispose(); + mesh.material.dispose(); + mesh.removeFromParent(); + } + }, + }; +} diff --git a/public/bluebey-studio/src/exporter.js b/public/bluebey-studio/src/exporter.js new file mode 100644 index 0000000..147af1c --- /dev/null +++ b/public/bluebey-studio/src/exporter.js @@ -0,0 +1,323 @@ +import { traceAlphaContours, contoursToPathData } from './trace.js'; +import { roughenContours } from './handDrawn.js'; +import { captionToSvg } from './caption.js'; +import { EYE_LAYOUT, MOUTH_LAYOUT } from './faceArt.js'; + +/** + * Export helpers. + * + * Everything renders through the live renderer at a temporary resolution, so an + * export always matches what is on screen (same camera, same framing) and the + * only difference is the pixel size. + */ + +/** Render the current view into an offscreen 2D canvas at an arbitrary size. */ +export function renderStill(view, { width, height, background }) { + const { renderer, scene, camera } = view; + const previousAspect = camera.aspect; + + renderer.setPixelRatio(1); + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + + applyBackground(renderer, background); + // The screen-space outline renders the scene itself, so it replaces the plain + // render rather than following it. (buildSVG passes outlineOptions of its own: + // it wants the ink and nothing else, because the ink is what it traces.) + if (view.outline && view.outlineOptions?.enabled) { + view.outline.setSize(width, height); + view.outline.render(() => renderer.render(scene, camera), { camera, ...view.outlineOptions }); + } else { + renderer.render(scene, camera); + } + + // Copy before restoring: resizing the renderer throws the frame away. + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvas.getContext('2d').drawImage(renderer.domElement, 0, 0); + + camera.aspect = previousAspect; + camera.updateProjectionMatrix(); + view.restore?.(); + + return canvas; +} + +/** `background` is `{ mode: 'transparent' }` or `{ mode: 'solid', color }`. */ +export function applyBackground(renderer, background) { + if (!background || background.mode === 'transparent') { + renderer.setClearColor(0x000000, 0); + } else { + renderer.setClearColor(background.color ?? '#ffffff', 1); + } +} + +/** A PNG data URL of the current view, scaled up by `scale`. */ +export async function capturePNG(view, { scale = 2, background, width, height }) { + const baseWidth = view.width || view.renderer.domElement.clientWidth || 1280; + const baseHeight = view.height || view.renderer.domElement.clientHeight || 800; + const outWidth = Math.min(8192, Math.max(64, Math.round((width ?? baseWidth) * scale))); + const outHeight = Math.min(8192, Math.max(64, Math.round((height ?? baseHeight) * scale))); + const canvas = renderStill(view, { width: outWidth, height: outHeight, background }); + const blob = await canvasToBlob(canvas); + return { blob, canvas, width: outWidth, height: outHeight }; +} + +/** + * Encode a canvas as a PNG blob. Exported because the caller composites the + * backdrop and the caption onto the rendered canvas afterwards, and then has to + * re-encode it. + */ +export function canvasToBlob(canvas) { + return new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); +} + +export async function copyCanvasToClipboard(canvas) { + if (!navigator.clipboard || typeof ClipboardItem === 'undefined') { + throw new Error('この環境ではクリップボードにコピーできません'); + } + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); +} + +/** + * Vector line art: render the outline-only style, trace the coverage mask of + * the result and emit one even-odd path. Rendering at a high resolution keeps + * the traced curves smooth. + */ +export function buildSVG(view, { + styles, + face, + faceParams, + width = 2048, + threshold = 0.4, + lineColor = '#111111', + lineWidth = null, + background = null, + handDrawn = null, + captions = [], + captionFont = null, + outlinePixels = 2, +} = {}) { + const previousStyle = styles.style; + const previousMode = face.styleMode; + + // The `outline` style keeps every body mesh invisible (colorWrite off) and lets + // the outline passes draw the lines, exactly as on screen: the hulls ink the + // body, the screen-space pass inks the leaves. What reaches the trace is + // therefore the ink alone - and a traced stroke comes out as its own outline, + // i.e. two nested contours that `fill-rule="evenodd"` fills as a line of the + // same width. That is what finally gives the leaves, which no inverted hull can + // outline, clean even lines in the vector file too. + // + // `withOutlineFor` in main.js sets that split up for this call, and guards it so + // a renderer without the outline pass still falls back to a hull-only trace. + const screenSpace = Boolean(view.outline); + + styles.setStyle('outline'); + face.setParams(faceParams, 'line'); + face.flush(performance.now(), 0); + + const aspect = (view.height || 800) / (view.width || 1280); + const height = Math.max(64, Math.round(width * aspect)); + // `radius` is in screen pixels, so it has to grow with the render: the SVG is + // drawn far larger than the viewport, and a 2px line would become a hairline. + const scale = width / Math.max(1, view.width || 1280); + const canvas = renderStill( + { + ...view, + outlineOptions: { + enabled: screenSpace, + color: lineColor, + radius: outlinePixels * scale, + }, + }, + { width, height, background: { mode: 'transparent' } }, + ); + + styles.setStyle(previousStyle); + face.setParams(faceParams, previousMode); + face.flush(performance.now(), 0); + + const pixels = canvas.getContext('2d').getImageData(0, 0, width, height).data; + const alpha = new Uint8Array(width * height); + for (let i = 0; i < alpha.length; i++) alpha[i] = pixels[i * 4 + 3]; + + let contours = traceAlphaContours(alpha, width, height, { + threshold, + simplifyTolerance: 0.6, + minArea: 5, + }); + // 手描き風: nudge the traced outlines so they read as pen strokes instead of + // the mathematically smooth curves a mask trace produces. + if (handDrawn && handDrawn.amount > 0) { + contours = roughenContours(contours, { + amount: handDrawn.amount, + seed: handDrawn.seed ?? 1, + scale: handDrawn.scale ?? 40, + passes: handDrawn.passes ?? 1, + }); + } + const d = contoursToPathData(contours, (x, y) => [x, y], 2); + + const rect = background + ? `<rect width="${width}" height="${height}" fill="${background}"/>\n` + : ''; + const widthAttr = lineWidth ? ` stroke="${lineColor}" stroke-width="${lineWidth}"` : ''; + + // The caption is authored against the viewport size, so it scales with the + // requested SVG width. Its bubble and text are emitted as real vector shapes. + // Every bubble is emitted as its own real vector shapes, one after the other. + let captionSvg = ''; + let captionUsedOutlines = captions.some(Boolean); + for (const caption of captions) { + if (!caption) continue; + const scale = width / Math.max(1, view.width || 1280); + const result = captionToSvg(caption, { width, height, scale, font: captionFont }); + captionSvg += result.svg; + if (result.usedOutlines === false) captionUsedOutlines = false; + } + + return { + contours: contours.length, + captionUsedOutlines, + svg: `<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"> +<title>ぶるべー 線画</title> +${rect}<path fill="${lineColor}"${widthAttr} fill-rule="evenodd" d="${d}"/> +${captionSvg}</svg> +`, + }; +} + +/** Alignment guides: blue on the white paper, light enough to paint over. */ +const FACE_MAP_GUIDE = 'rgba(90, 140, 220, 0.55)'; +/** Half-length of the centre ticks, in artwork-window pixels. */ +const FACE_MAP_TICK = 16; + +/** + * Build the「下地」image an author paints a custom face texture on. + * + * The studio loads a hand-drawn image by drawing it at the artwork window's own + * offset and size (see `Face.loadImage` / `placeInWindow`), so an image the size + * of the *plate canvas*, with its artwork aligned to `offsetX/offsetY`, imports + * 1:1. That is exactly what this hands out: the live drawing as a reference, an + * opaque white window to paint on, and faint guides for the eye centres, the lid + * line or the mouth chord. + * + * The white and the guides go on a **copy**: the plate canvas *is* the live + * texture, and painting it would show up in the view. + * + * @param {import('./face.js').Face} face + * @param {'eyes'|'mouth'} kind + * @returns {HTMLCanvasElement} + */ +export function buildFaceMap(face, kind) { + const eyes = kind === 'eyes'; + const layout = eyes ? face.eyeLayout : face.mouthLayout; + const plate = eyes ? face.eyeCanvas : face.mouthCanvas; + + const canvas = document.createElement('canvas'); + canvas.width = layout.width; + canvas.height = layout.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(plate, 0, 0); + + const { offsetX, offsetY, window: win } = layout; + // `destination-over` so the white paper lands *under* the copy: filling it + // normally would erase the very drawing the author lines the new art up against. + ctx.globalCompositeOperation = 'destination-over'; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(offsetX, offsetY, win.width, win.height); + ctx.globalCompositeOperation = 'source-over'; + + // The plate canvas keeps the artwork at its original pixel size (only the canvas + // grows around it), so a fixed-width stroke reads the same on both parts. + ctx.strokeStyle = FACE_MAP_GUIDE; + ctx.fillStyle = FACE_MAP_GUIDE; + ctx.lineWidth = 2; + + // Where the drawing has to fit, and where its middle is. The cross uses short + // ticks, not full-width lines, so it cannot be mistaken for artwork. + ctx.strokeRect(offsetX, offsetY, win.width, win.height); + const cx = offsetX + win.width / 2; + const cy = offsetY + win.height / 2; + guideLine(ctx, cx - FACE_MAP_TICK, cy, cx + FACE_MAP_TICK, cy); + guideLine(ctx, cx, cy - FACE_MAP_TICK, cx, cy + FACE_MAP_TICK); + + if (eyes) { + // Both eyeballs (`radius`) and the lid line through their centres, so a + // hand-drawn brow or eye can be placed against the parametric ones. + const [a, b] = EYE_LAYOUT.eyes; + const y = offsetY + a.cy; + guideLine(ctx, offsetX + a.cx, y, offsetX + b.cx, y); + for (const eye of EYE_LAYOUT.eyes) { + const ex = offsetX + eye.cx; + const ey = offsetY + eye.cy; + ctx.beginPath(); + ctx.arc(ex, ey, EYE_LAYOUT.radius, 0, Math.PI * 2); + ctx.stroke(); + // A small dot marks the exact centre, where the eyeball pivots. + ctx.beginPath(); + ctx.arc(ex, ey, ctx.lineWidth * 1.5, 0, Math.PI * 2); + ctx.fill(); + } + } else { + // The lip line the mouth is drawn around: end to end, plus a tick at the centre. + const y = offsetY + MOUTH_LAYOUT.chordY; + const { centreX, halfChord } = MOUTH_LAYOUT; + guideLine(ctx, offsetX + centreX - halfChord, y, offsetX + centreX + halfChord, y); + guideLine(ctx, offsetX + centreX, y - FACE_MAP_TICK, offsetX + centreX, y + FACE_MAP_TICK); + } + + return canvas; +} + +/** One guide segment, kept out of `buildFaceMap` so the placements stay readable. */ +function guideLine(ctx, x0, y0, x1, y1) { + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); +} + +export function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 4000); +} + +export function downloadText(text, filename, type = 'application/json') { + downloadBlob(new Blob([text], { type }), filename); +} + +export function readFileAsText(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + +export function readFileAsArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(file); + }); +} + +export function timestamp() { + const now = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; +} diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js new file mode 100644 index 0000000..18f1436 --- /dev/null +++ b/public/bluebey-studio/src/face.js @@ -0,0 +1,362 @@ +import * as THREE from 'three'; +import { drawEyes, drawMouth, canvasLayout } from './faceArt.js'; + +/** + * Owns the two procedural textures (eyes and mouth) and keeps them in sync with + * the face parameters. + * + * The artwork is carried by the model's face plates, but those plates are shells + * of the *whole* front half of the body, so their UVs cover far more than the + * 0..1 window the drawing lives in. `remapPlate` rewrites each plate's UVs onto + * 0..1 and the canvas is enlarged to match, which is what removes the + * clamp-to-edge smear at the border and what gives a moved tear, brow or mouth + * room to move (see `canvasLayout` in src/faceArt.js). + * + * Both parts can independently fall back to the original hand-drawn texture + * that ships inside the GLB. + */ +export class Face { + constructor({ eyeMesh, mouthMesh, originals }) { + this.eyeMesh = eyeMesh; + this.mouthMesh = mouthMesh; + + // Rewrite each plate's UVs so its own range fills 0..1, and work out the + // canvas that keeps the artwork exactly where the 2022 textures put it. + this.eyeLayout = remapPlate(eyeMesh); + this.mouthLayout = remapPlate(mouthMesh); + + // The hand-drawn eye textures bake the face colour into their background, + // which is invisible in the lit "real" style but shows up as a flat purple + // patch in the flat and line-art styles. Key it out once, here. Both kinds + // of original are window-sized artwork, so they belong *in* the window, not + // stretched across the enlarged canvas. + this.originals = { + eyes: Object.fromEntries( + Object.entries(originals.eyes).map(([key, texture]) => [key, keyOutBackground(texture, this.eyeLayout)]), + ), + mouth: placeInWindowTexture(originals.mouth, this.mouthLayout), + }; + + this.customNames = new Map(); + this.eyeCanvas = makeCanvas(this.eyeLayout); + this.mouthCanvas = makeCanvas(this.mouthLayout); + this.eyeCtx = this.eyeCanvas.getContext('2d'); + this.mouthCtx = this.mouthCanvas.getContext('2d'); + + this.eyeTexture = makeTexture(this.eyeCanvas); + this.mouthTexture = makeTexture(this.mouthCanvas); + + this.params = null; + this.styleMode = 'paint'; + this.eyeSource = 'parametric'; + this.mouthSource = 'parametric'; + this.dirty = true; + this.lastDraw = -Infinity; + + // The face plates are shells of the body surface, pushed a hair outwards by + // the loader, so they win the depth test against the body by themselves. + // Nudging them with a polygon offset as well keeps the two from z-fighting + // along grazing angles. Depth *testing* stays on: that is what lets the nose + // (which pokes further out) and an arm waved in front of the face hide the + // artwork the way they should - it only ever hid the old planes because + // those did not reach far enough down the head. + const orders = new Map([[eyeMesh, 2], [mouthMesh, 1]]); + for (const mesh of [eyeMesh, mouthMesh]) { + const material = mesh.material; + material.polygonOffset = true; + material.polygonOffsetFactor = -1; + material.polygonOffsetUnits = -2; + material.depthTest = true; + material.depthWrite = false; + // The GLB may describe the plate material as opaque; the artwork is a + // texture with alpha, so blend instead of replacing what is behind it. + material.transparent = true; + material.side = THREE.DoubleSide; + material.needsUpdate = true; + // Tears are drawn on the eye plate and the mouth on the other one, and + // both sit on the same shell: draw the mouth first so a teardrop can fall + // across it instead of being painted over. + mesh.renderOrder = orders.get(mesh) ?? 0; + } + + this.applyTextures(); + } + + /** Show or hide the mouth without touching the artwork. */ + setMouthVisible(visible) { + if (this.mouthMesh.visible !== visible) this.mouthMesh.visible = visible; + } + + applyTextures() { + const eyeMap = this.eyeSource === 'parametric' + ? this.eyeTexture + : (this.originals.eyes[this.eyeSource] ?? this.eyeTexture); + const mouthMap = this.mouthSource === 'parametric' + ? this.mouthTexture + : (this.originals.mouth ?? this.mouthTexture); + + if (this.eyeMesh.material.map !== eyeMap) { + this.eyeMesh.material.map = eyeMap; + this.eyeMesh.material.needsUpdate = true; + } + if (this.mouthMesh.material.map !== mouthMap) { + this.mouthMesh.material.map = mouthMap; + this.mouthMesh.material.needsUpdate = true; + } + } + + /** + * Load a hand-drawn image and use it for the eyes or the mouth. Any size is + * accepted; it is scaled to the layout the model expects (1024 x 380). + * Returns the key the new artwork is registered under. + */ + async loadImage(kind, file) { + const layout = kind === 'eyes' ? this.eyeLayout : this.mouthLayout; + const bitmap = await createImageBitmap(file); + // Any size is accepted; it is scaled into the artwork window, which is where + // the plate's rewritten UVs expect it. + const canvas = placeInWindow(bitmap, layout); + if (typeof bitmap.close === 'function') bitmap.close(); + const texture = makeTexture(canvas); + + if (kind === 'eyes') { + const key = `custom-${this.customNames.size + 1}`; + this.customNames.set(key, file.name); + this.originals.eyes[key] = texture; + this.setSource('eyes', key); + return key; + } + this.originals.mouth = texture; + this.setSource('mouth', 'original'); + return 'original'; + } + + /** `kind` is `'eyes'` or `'mouth'`; `source` is `'parametric'` or a variant key. */ + setSource(kind, source) { + if (kind === 'eyes') { + if (this.eyeSource === source) return; + this.eyeSource = source; + } else { + if (this.mouthSource === source) return; + this.mouthSource = source; + } + this.applyTextures(); + this.dirty = true; + } + + setParams(params, styleMode = this.styleMode) { + this.params = params; + this.styleMode = styleMode; + this.dirty = true; + } + + /** + * Redraw the procedural textures when they are stale. `minInterval` throttles + * redraws while animating; pass 0 before an export so nothing is left pending. + */ + flush(now = performance.now(), minInterval = 0) { + if (!this.dirty) return false; + if (now - this.lastDraw < minInterval) return false; + this.redraw(); + this.lastDraw = now; + return true; + } + + redraw() { + const p = this.params; + if (!p) { + this.dirty = false; + return; + } + // Hiding the mouth has to hide the plane itself, not just stop drawing on + // it - otherwise the last drawing stays on screen. + this.setMouthVisible(p.mouth.visible !== false); + const mode = this.styleMode === 'line' ? 'line' : 'paint'; + + if (this.eyeSource === 'parametric') { + const layout = this.eyeLayout; + const ctx = this.eyeCtx; + ctx.clearRect(0, 0, layout.width, layout.height); + ctx.save(); + // The artwork window sits at an offset inside the bigger canvas. + ctx.translate(layout.offsetX, layout.offsetY); + drawEyes(ctx, { + mode, + eyes: { left: p.eyes.left, right: p.eyes.right }, + style: { + white: p.eyes.white, + iris: p.eyes.iris, + line: p.eyes.line, + irisScale: p.eyes.irisScale, + lookMax: p.eyes.lookMax, + highlight: p.eyes.highlight, + lidWidth: p.eyes.lidWidth, + lowerLid: p.eyes.lowerLid, + brow: p.eyes.brow, + // ほっぺ and 頭の模様 are shared by the whole face, so they travel with + // the other shared eye fields (see `drawEyes` in src/faceArt.js). + cheeks: p.eyes.cheeks, + headMark: p.eyes.headMark, + // 眼鏡 / サングラス are shared by both eyes and drawn into this same + // texture, so they travel with the other shared eye fields. + glasses: p.eyes.glasses, + heartScale: p.eyes.heartScale, + heartColor: p.eyes.heartColor, + heartHideWhite: p.eyes.heartHideWhite === true, + tearColor: p.eyes.tearColor, + // Where the artwork's real edges are, so a brow lifted too far or a + // tear dropped too low can stop inside the artwork (see `canvasLayout`). + limits: layout.limits, + }, + }); + ctx.restore(); + this.eyeTexture.needsUpdate = true; + } + + if (this.mouthSource === 'parametric' && p.mouth.visible) { + const layout = this.mouthLayout; + const ctx = this.mouthCtx; + ctx.clearRect(0, 0, layout.width, layout.height); + ctx.save(); + ctx.translate(layout.offsetX, layout.offsetY); + drawMouth(ctx, { mode, ...p.mouth, limits: layout.limits }); + ctx.restore(); + this.mouthTexture.needsUpdate = true; + } + + this.dirty = false; + } +} + +function makeCanvas({ width, height }) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; +} + +/** + * Rewrite a plate's UVs so its own range maps onto 0..1, and return the canvas + * that keeps the artwork at its original pixel size (`canvasLayout`). + * + * The plate covers the whole front half of the body, so its UVs used to run well + * outside the artwork (v -1.02..1.93 on the eye plate). Everything past the edge + * clamped to the canvas border, which is what stretched a tear or a brow that + * reached it. After this the whole plate samples real canvas, so nothing clamps. + */ +function remapPlate(mesh) { + const uv = mesh?.geometry?.attributes?.uv; + if (!uv) return canvasLayout(null); + let u0 = Infinity; + let u1 = -Infinity; + let v0 = Infinity; + let v1 = -Infinity; + for (let i = 0; i < uv.count; i += 1) { + const u = uv.getX(i); + const v = uv.getY(i); + if (u < u0) u0 = u; + if (u > u1) u1 = u; + if (v < v0) v0 = v; + if (v > v1) v1 = v; + } + const du = Math.max(1e-6, u1 - u0); + const dv = Math.max(1e-6, v1 - v0); + for (let i = 0; i < uv.count; i += 1) { + uv.setXY(i, (uv.getX(i) - u0) / du, (uv.getY(i) - v0) / dv); + } + uv.needsUpdate = true; + return canvasLayout({ u0, u1, v0, v1 }); +} + +/** + * Draw a hand-drawn 1024 x 380 image where the artwork window now sits, on a + * canvas as big as the plate's own UV range. The plates cover far more than the + * window (see `canvasLayout`) and the UVs were rewritten to match, so the window + * is exactly where that image belongs. + */ +function placeInWindow(source, layout) { + const canvas = document.createElement('canvas'); + canvas.width = layout.width; + canvas.height = layout.height; + if (source) { + canvas.getContext('2d').drawImage( + source, layout.offsetX, layout.offsetY, layout.window.width, layout.window.height, + ); + } + return canvas; +} + +/** `placeInWindow` for an existing texture (keeps `null` as `null`). */ +function placeInWindowTexture(texture, layout) { + const image = texture?.image; + if (!image || !image.width || !image.height) return texture ?? null; + return makeTexture(placeInWindow(image, layout)); +} + +function makeTexture(canvas) { + const texture = new THREE.CanvasTexture(canvas); + // glTF puts v = 0 at the top of the image and the loader uploads the model's + // own textures with flipY = false; matching that keeps the artwork aligned + // with the mesh UVs. + texture.flipY = false; + texture.colorSpace = THREE.SRGBColorSpace; + texture.premultiplyAlpha = false; + texture.needsUpdate = true; + return clampToEdge(texture); +} + +/** + * The face plates are shells of the whole body, so their UVs run well past the + * 0..1 of the artwork (a plate corner can sit at v = -1.26). glTF's default + * sampler repeats, which would stamp the drawing back onto the model several + * times - a frown flicked up onto the forehead, a smile's end onto its side. + * Clamping sends everything outside the artwork to the blank edge of the canvas + * instead, where nothing is drawn. + */ +function clampToEdge(texture) { + if (!texture) return texture; + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + texture.needsUpdate = true; + return texture; +} + +/** + * Make the flat colour that fills the background of a hand-drawn texture + * transparent. The edge is feathered rather than hard, so the anti-aliased + * pixels along the artwork do not leave a pale fringe. + */ +function keyOutBackground(texture, layout) { + const image = texture?.image; + if (!image || !image.width || !image.height) return texture; + + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(image, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const pixels = imageData.data; + + // The background is uniform, so the top-left pixel is a reliable sample. + const baseR = pixels[0]; + const baseG = pixels[1]; + const baseB = pixels[2]; + const fullyClear = 30; // at or below this distance: transparent + const fullySolid = 96; // at or above this distance: untouched + + for (let i = 0; i < pixels.length; i += 4) { + const distance = Math.abs(pixels[i] - baseR) + + Math.abs(pixels[i + 1] - baseG) + + Math.abs(pixels[i + 2] - baseB); + if (distance <= fullyClear) { + pixels[i + 3] = 0; + } else if (distance < fullySolid) { + const ratio = (distance - fullyClear) / (fullySolid - fullyClear); + pixels[i + 3] = Math.round(pixels[i + 3] * ratio); + } + } + ctx.putImageData(imageData, 0, 0); + + return makeTexture(placeInWindow(canvas, layout)); +} diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js new file mode 100644 index 0000000..65205fa --- /dev/null +++ b/public/bluebey-studio/src/faceArt.js @@ -0,0 +1,1415 @@ +/** + * The face artwork: everything that used to be a hand-drawn PNG in GIMP is now + * drawn from parameters onto a canvas, which is then used as the texture of the + * two overlay planes that already exist on the model. + * + * The plates have a linear UV mapping fitted to the original artwork, so drawing + * in "texture pixels" lands exactly where the original artwork did. Every constant + * below was measured from the original textures (see README for the numbers), so + * the defaults reproduce the 2022 artwork while every part of it stays editable. + * + * Texture space: the artwork WINDOW, 1024 x 380, origin at the top-left, y grows + * downwards. `canvasLayout` then says where that window sits on the bigger canvas + * a face plate actually needs (see below), and `Face` hands the bounds back as + * `limits`, so the drawing can clamp against the real edge instead of assuming + * the window is all there is. + * + * glTF stores v downwards too, and the textures are uploaded with flipY = false, + * so no flipping is needed anywhere. + */ + +const TAU = Math.PI * 2; +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); +const HUGE = 4000; + +/** + * The artwork window: the rectangle the 2022 hand-drawn textures occupied. Every + * constant below is written in this space. + */ +export const ART_WINDOW = { width: 1024, height: 380 }; + +/** Keep this much clear at the canvas border, so filtering has room to breathe. */ +const EDGE_MARGIN = 8; + +/** What a drawing falls back to when no plate bounds are supplied. */ +const WINDOW_LIMITS = { + top: 0, bottom: ART_WINDOW.height, left: 0, right: ART_WINDOW.width, +}; + +/** + * The canvas a face plate's drawing needs, and where the artwork window sits on it. + * + * WHY: the plates are the *whole* front half of the body, so their UVs run far + * outside the 0..1 the artwork was drawn in (measured on the shipped model: the + * eye plate covers v -1.02..1.93, and 9 of 10 of its vertices sit within a + * twentieth of the border). Everything outside 0..1 used to clamp to the canvas + * edge row, so the moment a drawing reached the border - a tear dropped low, a + * brow lifted, a thick mouth pushed down - that edge row was copied across the + * rest of the plate and the mark stretched into a long smear. + * + * `Face` rewrites each plate's UVs so this range maps onto 0..1 instead, which + * removes the clamp altogether. The map is affine, so the artwork keeps its exact + * pixel size and position and merely shifts by `offset`; the canvas grows by the + * same factor, and those extra rows are the room a moved tear, brow or mouth + * needs (`limits` says where the real edge now is, in window coordinates). + * + * @param {{u0:number,u1:number,v0:number,v1:number}} range the plate's UV range + * @param {{width:number,height:number}} [window] the artwork window + */ +export function canvasLayout(range, window = ART_WINDOW) { + const u0 = Number.isFinite(range?.u0) ? range.u0 : 0; + const u1 = Number.isFinite(range?.u1) ? range.u1 : 1; + const v0 = Number.isFinite(range?.v0) ? range.v0 : 0; + const v1 = Number.isFinite(range?.v1) ? range.v1 : 1; + const du = Math.max(1e-6, u1 - u0); + const dv = Math.max(1e-6, v1 - v0); + const width = Math.max(1, Math.round(window.width * du)); + const height = Math.max(1, Math.round(window.height * dv)); + // `+ 0` turns a `-0` into a plain `0`, so the offsets compare cleanly. + const offsetX = Math.round(-u0 * window.width) + 0; + const offsetY = Math.round(-v0 * window.height) + 0; + return { + width, + height, + offsetX, + offsetY, + window, + // The canvas edges in the artwork's own coordinates. `top` is negative when + // the plate reaches above the window: that is the extra room. + limits: { + top: -offsetY + 0, + bottom: height - offsetY, + left: -offsetX + 0, + right: width - offsetX, + }, + }; +} + +/** Fixed layout of the eye plane, measured from the original `eyes-open.png`. */ +export const EYE_LAYOUT = { + radius: 84, + irisRadius: 62.5, + highlightRadius: 28, + highlightOffset: { x: 29.5, y: -18 }, + // The model's left eye (its own left, +x) sits in the u > 0.5 half. + eyes: [ + { key: 'right', cx: 267.5, cy: 240, towardNose: 1 }, + { key: 'left', cx: 755.5, cy: 240, towardNose: -1 }, + ], + lidRadiusFactor: 1.35, + lidStroke: 14, + // Eyebrows are painted into the same texture as the eyes. The eye plane is a + // flat-ish patch on a round head, so only its lower part is really outside + // the head: a brow high on the forehead would be swallowed by it. The brow + // therefore sits just above the eyeball (row 156) and the eyeball is drawn + // over its lower edge afterwards. + // The brow sits just above the eyeball, and the eyeball is drawn over its + // lower edge. `lift` is that resting height, so `height: 0` is the brow's + // natural place and a POSITIVE height raises it (a negative one sinks it + // towards the eye, where the eyeball will cover it). + brow: { lift: 114, length: 134, thickness: 15, curve: 0.14, offsetX: 6 }, + // A teardrop hangs well below the eye. The plate keeps the whole canvas, so + // the drop can sit low without being clipped (measured with a ruler grid). + tear: { size: 34, offsetX: 66, offsetY: 88 }, + closedLine: { + // The single shut line is the *long* form of the eyelid, the same reach as + // the arms of the "ぎゅっ" below, so switching between 1 and 3 lines does not + // change how wide the eye reads. + length: 205, + offsetX: 6, + offsetY: -10, + slantDeg: 3.9, + bow: 3, + // 2- and 3-line shut eyes, copied from the original `eyes-close-tight` + // artwork: three strokes sharing one vertex that points at the nose, opening + // to a wide bird's foot / arrow shape. `spread` is half the height the arms + // open to at the far end. + armSpan: 205, + spread: 62, + vertex: 87.5, + armX: 104, + }, + arch: { edgesUp: 8, apexUp: 44 }, +}; + +/** + * ほっぺ (a manga blush) and 頭の模様 (a mark on the head), both drawn into the eye + * plate next to the eyes and brows. + * + * They are deliberately *generic* manga devices, not a copy of any one character: + * a pink patch crossed by short diagonal strokes is a stock convention, and the + * head mark is one of a few simple shapes (a hook, a spiral, three strokes, a dot + * row) that starts off. Both stay editable through the panel. The cheek positions + * are tied to `EYE_LAYOUT` so they sit beside the eyes; the head mark sits up in + * the plate's upper area, i.e. on the forehead above the brows. + */ +export const CHEEK_LAYOUT = { + drop: 64, // how far below the eye centre the patch sits (artwork px) + outward: 66, // how far outwards, away from the midline, it sits + radius: 50, // half-width of the patch + squash: 0.62, // half-height, as a fraction of the half-width + lines: 4, // hatch strokes across the patch + lineWidth: 5, + slant: 58, // hatching angle, degrees +}; + +export const HEAD_MARK_LAYOUT = { + cx: (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2, // the face midline + cy: 92, // up on the forehead, above the brows + size: 46, // nominal radius of a shape + lineWidth: 9, +}; + +/** + * 眼鏡 / サングラス, drawn into the same texture as the eyes and brows. + * + * Not measured off any artwork - the character has no glasses - but the numbers + * are tied to the eyeball so a lens is always a little wider than the eye it + * covers. The lens centre sits *below* the eye centre because the brow is drawn + * first, just above the eyeball (see `EYE_LAYOUT.brow`): a lens wide enough to + * read as glasses would otherwise cut straight through it. + * + * The two kinds get separate shapes on purpose. They used to share one ellipse + * and differ only in how dark the lens was filled, which made them hard to tell + * apart; the round 眼鏡 below and the wide, angular サングラス in + * `SUNGLASSES_LAYOUT` now read as different objects at a glance. + */ +const GLASSES_LAYOUT = { + widthFactor: 1.08, // lens half-width, as a multiple of the eyeball radius + heightFactor: 1.0, // a circle, so the round 眼鏡 stays round (a flatter + // ellipse used to drift towards the shades below) + drop: 20, // the lens centre hangs this far below the eye centre (artwork px) + bridgeRise: 8, // how much the bridge arcs up over the nose + bridgeWidth: 0.75, // bridge thickness, as a fraction of the frame stroke + templeLength: 1.5, // temple stub length, as a multiple of the eyeball radius + templeRise: 30, // how far the temple climbs towards the side of the head +}; + +/** + * サングラス: a long, pointed cat-eye rather than the round 眼鏡 lens. Each side is a + * slim wedge whose *outer end starts low*, rises to a *high, pointed outer corner* + * set further out (`tipX`), and whose top edge then sweeps back down towards the + * nose - the "外側が長くとがって上に上がる" shape the reference shows. The inner end is + * short, so the lens tapers inwards. The eye is allowed to poke out above and + * below it, so the lens no longer has to cover the whole eyeball. A level bar + * joins the two inner ends, so the pair still reads as one dark visor. The ratios + * are of the eyeball radius, exactly like `GLASSES_LAYOUT`, so `scale` means the + * same thing for both kinds. + */ +const SUNGLASSES_LAYOUT = { + widthFactor: 1.6, // a long lens: the outer end reaches well past the eyeball + heightFactor: 0.58, // slim, so the eye is free to show above and below it + drop: 8, // sits a little lower than the round pair + corner: 0.05, // corner rounding, as a fraction of the half-height; kept + // tiny so the outer corner stays pointed + topSkew: 1.0, // the pointed corner rises this far above the lens top (x half-height) + innerTop: 0.42, // the inner top is low, which is what makes the top edge climb + innerBottom: 0.34, // the inner bottom is pinched up towards the nose (x half-height) + outerEndX: 0.75, // the low outer end sits this far out (x half-width) + outerBottom: 0.82, // ...and this deep (x half-height) + tipX: 1.22, // the pointed corner juts this far out (x half-width) + bridgeWidth: 1.15, // a short, thick bar - thicker than the frame stroke + bridgeLift: 0.25, // the bar sits this far above the lens centre (x half-height) + templeLength: 1.15, // a short stub, like the round pair's but a little shorter + templeRise: 24, + frameFactor: 0.6, // a thin rim, slimmer than the round pair's stroke + highlightWidth: 0.12, // glass highlight thickness, as a fraction of the half-width +}; + +/** The mouth artwork is drawn on the `mouth-plate` shell, which covers the whole + * 1024 x 380 canvas (see tools/build-face-plates.py), so the drawing no longer + * has to be squeezed into a band. The only limit left is the canvas itself. */ +export const MOUTH_LAYOUT = { + centreX: 511.5, + chordY: 88, + halfChord: 420.5, + sag: 177, + // With the ends and the depth both fixed - which is what the slider promises - + // the only thing left to choose about the curve is *where* it bends. 1/3 is a + // quadratic Bézier (a parabola): it is flattest at the apex and falls away + // fastest near the ends. Pulling the cubic's control points in towards the ends + // makes the middle of the smile straighter still and lets the fall happen near + // the corners, which is what reads as a *gentler* curve at the same depth. + sagBend: 0.2, + thickness: 21, + // Kept for reference: the 2022 plane only showed these rows. + bandTop: 46, + bandBottom: 332, + // Only used to cap the *size* of the round "O" mouth, so the surprise face + // stays a mouth and not a hole. The mouth's travel no longer stops here: the + // drawing is given the plate's real edges through `limits`. + safeBottom: 372, + // The nose is a separate mesh that pokes out in front of the plate. The plate + // is depth tested, so the nose hides whatever is drawn behind it - but a frown + // arcs *up* into that hiding place, so slide the mouth down until the middle + // of the arc clears the nose. Only the middle is checked: a smile curves away + // from the nose, and checking its ends instead is what used to pin the whole + // mouth in place and make the height slider do nothing. + noseClear: 132, + // The original hand-drawn mouth is a *shallow* arc with tall corner strokes + // flicking up at the ends (measured from the artwork: the arc's own sag is + // ~0.13 of its chord, while the corners reach ~90px above it). Keeping that + // split is what makes `smile: 1` read as the original; making the arc itself + // deep instead looked too steep. + // + // The tongue rises from the lip line to a rounded top just above the corner + // strokes. Measured off the original: its crown is a *super-ellipse* - + // `rise = height * (1 - |dx/half|^2.5)` - so it is steep-sided with a smooth, + // almost flat top. It is convex upwards, but it is **not** a point. + tongue: { pos: 0.84, width: 126, height: 115, crown: 2.5 }, + // The tongue *lolling out* of the mouth (舌を出す). The same super-ellipse as + // the tongue above, but the crown hangs below its foot instead of rising over + // it, so the tip droops past the lip. `pos` is its own, because the in-mouth + // tongue sits off to one side and a lolling tongue reads better centred. + tongueOut: { pos: 0.5, width: 104, height: 118, crown: 2.2 }, + corner: { fromX: 26, fromY: 3, toX: 32, toY: 42, width: 11, curve: 26 }, + openRise: 64, + // Was 0.25: opening the mouth used to flatten the smile to stay inside the + // texture band. There is no band to stay inside now. + openFlatten: 0, +}; + +/* ------------------------------------------------------------------ helpers */ + +function circlePath(ctx, cx, cy, r) { + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, TAU); + ctx.closePath(); +} + +function fillCircle(ctx, cx, cy, r, color) { + circlePath(ctx, cx, cy, r); + ctx.fillStyle = color; + ctx.fill(); +} + +/** + * A heart, used for the "love" eyes. The path is wider than it is tall, which + * is what makes it read as a heart rather than a blob at small sizes. + */ +function heartPath(ctx, cx, cy, r) { + ctx.beginPath(); + ctx.moveTo(cx, cy + r * 0.80); + ctx.bezierCurveTo(cx - r * 1.24, cy - r * 0.34, cx - r * 0.50, cy - r * 1.18, cx, cy - r * 0.40); + ctx.bezierCurveTo(cx + r * 0.50, cy - r * 1.18, cx + r * 1.24, cy - r * 0.34, cx, cy + r * 0.80); + ctx.closePath(); +} + +function fillHeart(ctx, cx, cy, r, color) { + heartPath(ctx, cx, cy, r); + ctx.fillStyle = color; + ctx.fill(); +} + +/** A teardrop, used by the crying expression. */ +function dropPath(ctx, cx, cy, size) { + ctx.beginPath(); + ctx.moveTo(cx, cy - size * 1.32); + ctx.bezierCurveTo(cx + size * 0.95, cy - size * 0.34, cx + size * 0.95, cy + size * 0.78, cx, cy + size * 0.78); + ctx.bezierCurveTo(cx - size * 0.95, cy + size * 0.78, cx - size * 0.95, cy - size * 0.34, cx, cy - size * 1.32); + ctx.closePath(); +} + +function strokeCircle(ctx, cx, cy, r, color, width) { + circlePath(ctx, cx, cy, r); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.stroke(); +} + +function tracePolyline(ctx, points, closed) { + if (!points.length) return; + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y); + if (closed) ctx.closePath(); +} + +function strokePolyline(ctx, points, { color, width, closed = false }) { + if (points.length < 2) return; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + tracePolyline(ctx, points, closed); + ctx.stroke(); + ctx.restore(); +} + +function fillPolygon(ctx, points, color) { + if (points.length < 3) return; + ctx.save(); + ctx.fillStyle = color; + tracePolyline(ctx, points, true); + ctx.fill(); + ctx.restore(); +} + +/** + * Trace a closed polygon with rounded corners. Used for the squarish + * サングラス lens: an ellipse cannot be angular, and a plain polygon has cusps. + * Each corner is cut back by `radius` (clamped so short edges cannot overlap) + * and joined with a quadratic through the original vertex. + */ +function traceRoundedPolygon(ctx, points, radius) { + const n = points.length; + ctx.beginPath(); + for (let i = 0; i < n; i++) { + const prev = points[(i + n - 1) % n]; + const cur = points[i]; + const next = points[(i + 1) % n]; + const inLen = Math.hypot(cur.x - prev.x, cur.y - prev.y) || 1; + const outLen = Math.hypot(next.x - cur.x, next.y - cur.y) || 1; + const cut = Math.min(radius, inLen / 2, outLen / 2); + const from = { x: cur.x + ((prev.x - cur.x) / inLen) * cut, y: cur.y + ((prev.y - cur.y) / inLen) * cut }; + const to = { x: cur.x + ((next.x - cur.x) / outLen) * cut, y: cur.y + ((next.y - cur.y) / outLen) * cut }; + if (i === 0) ctx.moveTo(from.x, from.y); + else ctx.lineTo(from.x, from.y); + ctx.quadraticCurveTo(cur.x, cur.y, to.x, to.y); + } + ctx.closePath(); +} + +/** Points along a quadratic Bézier, `steps` segments (steps + 1 points). */ +function quadraticPoints(p0, p1, p2, steps) { + const out = []; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const u = 1 - t; + out.push({ + x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x, + y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y, + }); + } + return out; +} + +/** A point on a cubic Bézier. */ +function cubicPoint(p0, p1, p2, p3, t) { + const u = 1 - t; + const a = u * u * u; + const b = 3 * u * u * t; + const c = 3 * u * t * t; + const d = t * t * t; + return { + x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, + y: a * p0.y + b * p1.y + c * p2.y + d * p3.y, + }; +} + +/** Points along a cubic Bézier, `steps` segments (steps + 1 points). */ +function cubicPoints(p0, p1, p2, p3, steps) { + const out = []; + for (let i = 0; i <= steps; i++) out.push(cubicPoint(p0, p1, p2, p3, i / steps)); + return out; +} + +/** + * The four control points of the mouth's centreline: the same two ends, the same + * depth, but `bend` decides where the curve actually bends (see `sagBend`). + */ +function mouthControls(left, right, sag, bend) { + const width = right.x - left.x; + const y = left.y + (4 / 3) * sag; + return { + p0: left, + p1: { x: left.x + width * bend, y }, + p2: { x: right.x - width * bend, y }, + p3: right, + }; +} + +function rotate(points, pivot, degrees) { + if (!degrees) return points; + const a = (degrees * Math.PI) / 180; + const cos = Math.cos(a); + const sin = Math.sin(a); + return points.map((p) => { + const dx = p.x - pivot.x; + const dy = p.y - pivot.y; + return { x: pivot.x + dx * cos - dy * sin, y: pivot.y + dx * sin + dy * cos }; + }); +} + +/* --------------------------------------------------------------------- eyes */ + +/** + * Intersect the canvas clip with the eyeball disc and the (possibly closed) + * lids. Because the overlay plane is transparent and sits in front of the face, + * clipping is all that is needed: whatever is clipped away shows the real + * shaded face behind it. That is what makes the eyelid blend perfectly without + * baking a face-coloured background into the texture. + */ +function clipEye(ctx, eye, open, lower) { + circlePath(ctx, eye.cx, eye.cy, eye.r); + ctx.clip(); + + const upper = 1 - clamp(open, 0, 1); + if (upper > 0.0005) { + const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; + const lowest = eye.cy - eye.r + 2 * eye.r * upper; + const cy = lowest - rl; + ctx.beginPath(); + ctx.moveTo(eye.cx - HUGE, cy); + ctx.lineTo(eye.cx - rl, cy); + ctx.arc(eye.cx, cy, rl, Math.PI, 0, true); // lower semicircle: bulges down + ctx.lineTo(eye.cx + HUGE, cy); + ctx.lineTo(eye.cx + HUGE, cy + HUGE); + ctx.lineTo(eye.cx - HUGE, cy + HUGE); + ctx.closePath(); + ctx.clip(); + } + + const bottom = clamp(lower, 0, 1); + if (bottom > 0.0005) { + const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; + const highest = eye.cy + eye.r - 2 * eye.r * bottom; + const cy = highest + rl; + ctx.beginPath(); + ctx.moveTo(eye.cx - HUGE, cy); + ctx.lineTo(eye.cx - rl, cy); + ctx.arc(eye.cx, cy, rl, Math.PI, 0, false); // upper semicircle: bulges up + ctx.lineTo(eye.cx + HUGE, cy); + ctx.lineTo(eye.cx + HUGE, cy - HUGE); + ctx.lineTo(eye.cx - HUGE, cy - HUGE); + ctx.closePath(); + ctx.clip(); + } +} + +function lidPath(ctx, eye, amount, lower) { + const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; + if (!lower) { + const lowest = eye.cy - eye.r + 2 * eye.r * (1 - amount); + ctx.beginPath(); + ctx.arc(eye.cx, lowest - rl, rl, Math.PI, 0, true); + } else { + const highest = eye.cy + eye.r - 2 * eye.r * amount; + ctx.beginPath(); + ctx.arc(eye.cx, highest + rl, rl, Math.PI, 0, false); + } +} + +/** The "eyes shut" artwork: a line, a chevron or a happy arch. */ +function drawShutEye(ctx, eye, spec, style) { + const layout = EYE_LAYOUT.closedLine; + const { line, width } = style; + + if (spec.closed === 'chevron') { + // The chevron points *at* the nose, matching the original artwork. + const vertex = { x: eye.cx + eye.towardNose * layout.vertex, y: eye.cy + layout.offsetY - 8 }; + const armX = eye.cx - eye.towardNose * layout.armX; + const spread = layout.spread * (spec.spread ?? 1); + strokePolyline(ctx, [vertex, { x: armX, y: vertex.y - spread }], { color: line, width }); + strokePolyline(ctx, [vertex, { x: armX, y: vertex.y + spread }], { color: line, width }); + return; + } + + if (spec.closed === 'three') { + // A real "3": an upper bowl and a lower bowl that meet at a single pinch on + // the nose side. Stacking two C's instead made the two lobes sit on top of + // each other; joining them at a point is what makes it read as the digit. + // `length` scales the whole shape. + const r = 34 * (spec.length ?? 1); + // Which way the digit faces. It used to follow the nose side, which made the + // pair a mirror image; each eye can now be set either way, because a pair of + // mirrored 3s does not always read the way you want. + const dir = (spec.threeFlip ? -1 : 1) * eye.towardNose; + const sx = eye.cx; + const cy = eye.cy + layout.offsetY; + const at = (x, y) => ({ x: sx + dir * r * x, y: cy + r * y }); + const pinch = at(0.72, 0); + const upper = quadraticPoints(at(0.02, -1.62), at(2.05, -1.40), pinch, 26); + const lower = quadraticPoints(pinch, at(2.05, 1.46), at(0.02, 1.78), 26); + strokePolyline(ctx, upper, { color: line, width }); + strokePolyline(ctx, lower, { color: line, width }); + return; + } + + if (spec.closed === 'arch') { + const edges = { x: eye.r, y: EYE_LAYOUT.arch.edgesUp }; + const points = quadraticPoints( + { x: eye.cx - edges.x, y: eye.cy + edges.y }, + { x: eye.cx, y: eye.cy + edges.y - EYE_LAYOUT.arch.apexUp * 2 }, + { x: eye.cx + edges.x, y: eye.cy + edges.y }, + 24, + ); + strokePolyline(ctx, points, { color: line, width }); + return; + } + + // Default: the gently slanted line of the original artwork, mirrored so it + // always slopes down towards the nose. With `closedLines` set to 2 or 3 the + // strokes share one endpoint instead - a ">" or bird's-foot shape, which is + // how manga draws a happily squeezed-shut eye. + const count = clamp(Math.round(spec.closedLines ?? 1), 1, 3); + if (count >= 2) { + const vertex = { x: eye.cx + eye.towardNose * layout.vertex, y: eye.cy + layout.offsetY - 8 }; + // Reach as far as the single line does, not just a short stub: the arms are + // the *long* form of the same eyelid the one-line version covers. + const outX = -eye.towardNose * layout.armSpan; + const arms = count === 2 + ? [{ x: outX, y: -layout.spread }, { x: outX, y: layout.spread }] + : [ + { x: outX, y: -layout.spread }, + { x: -eye.towardNose * Math.hypot(layout.armSpan, layout.spread), y: 0 }, + { x: outX, y: layout.spread }, + ]; + for (const arm of arms) { + strokePolyline(ctx, [vertex, { x: vertex.x + arm.x, y: vertex.y + arm.y }], { color: line, width }); + } + return; + } + + const half = (layout.length / 2) * (spec.length ?? 1); + const midX = eye.cx - eye.towardNose * layout.offsetX; + const midY = eye.cy + layout.offsetY; + const angle = ((layout.slantDeg * (spec.slant ?? 1) * eye.towardNose) * Math.PI) / 180; + const bow = (layout.bow ?? 0) * (spec.slant ?? 1); + const from = { x: midX - Math.cos(angle) * half, y: midY - Math.sin(angle) * half }; + const to = { x: midX + Math.cos(angle) * half, y: midY + Math.sin(angle) * half }; + const points = quadraticPoints(from, { x: midX, y: midY + bow * 2 }, to, 20); + strokePolyline(ctx, points, { color: line, width }); +} + +/** + * A stroke whose width runs from `w0` at the first point to `w1` at the last. + * + * Used for a ゴルゴ13-style brow: a filled wedge that comes to a point at one end + * reads as a heavy eyebrow, where a constant-width stroke reads as a soft arc. + */ +function taperedStroke(ctx, points, w0, w1, color) { + const count = points.length; + if (count < 2) return; + const side = [[], []]; + for (let i = 0; i < count; i++) { + const t = i / (count - 1); + const w = (w0 + (w1 - w0) * t) / 2; + const before = points[Math.max(0, i - 1)]; + const after = points[Math.min(count - 1, i + 1)]; + const dx = after.x - before.x; + const dy = after.y - before.y; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + side[0].push({ x: points[i].x + nx * w, y: points[i].y + ny * w }); + side[1].push({ x: points[i].x - nx * w, y: points[i].y - ny * w }); + } + ctx.save(); + tracePolyline(ctx, [...side[0], ...side[1].reverse()], true); + ctx.fillStyle = color; + ctx.fill(); + ctx.restore(); +} + +/** An eyebrow: a short arc above the eye, mirrored between the two eyes so a + * positive `angle` always means "inner end down" (an angry brow). */ +function drawBrow(ctx, eye, spec, style, limits = WINDOW_LIMITS) { + const brow = style.brow; + if (!brow?.enabled) return; + const layout = EYE_LAYOUT.brow; + // 0 is allowed: a zero-length stroke with round caps is a dot, which is what a + // length of 0 is asking for. + const length = Math.max(0, (brow.length ?? 1) * layout.length); + const thickness = Math.max(1, (brow.thickness ?? 1) * layout.thickness); + const cy = eye.cy - layout.lift - (brow.height ?? 0) - (spec.browHeight ?? 0); + // `spacing` widens the gap between the two brows: a positive value moves each + // one away from the nose. `layout.offsetX` is the resting inset from the + // artwork (which the old code read off the wrong object, so it never applied). + const cx = eye.cx + eye.towardNose * (layout.offsetX - (brow.spacing ?? 0)); + const angle = (((brow.angle ?? 0) + (spec.browAngle ?? 0)) * eye.towardNose * Math.PI) / 180; + const half = length / 2; + const from = { x: cx - Math.cos(angle) * half, y: cy - Math.sin(angle) * half }; + const to = { x: cx + Math.cos(angle) * half, y: cy + Math.sin(angle) * half }; + const bow = (brow.curve ?? layout.curve) * length; + const points = quadraticPoints(from, { x: cx, y: cy - bow }, to, 16); + // A brow raised with the height slider used to run off the top of the canvas, + // where the clamped edge row was copied across the whole plate and the brow + // smeared upwards. Keep the whole stroke inside the artwork instead. + const top = limits.top + EDGE_MARGIN; + const highest = Math.min(...points.map((q) => q.y)) - thickness / 2; + if (highest < top) for (const q of points) q.y += top - highest; + // `taper` is how wide the *inner* (nose-side) end is: 1 = a plain stroke, and + // lower values turn the brow into a wedge that comes to a point at the nose. + const taper = clamp(brow.taper ?? 1, 0, 1); + const color = brow.color ?? style.line ?? '#55386e'; + // A tapered wedge has no shape at zero length, so a dot always takes the plain + // round-capped stroke. + if (taper >= 0.999 || length < 2) { + strokePolyline(ctx, points, { color, width: thickness }); + return; + } + const innerIsTo = eye.towardNose > 0; + taperedStroke( + ctx, + points, + thickness * (innerIsTo ? 1 : taper), + thickness * (innerIsTo ? taper : 1), + color, + ); +} + +/* ------------------------------------------------------- ほっぺ / 頭の模様 */ + +/** + * ほっぺ: a soft pink patch crossed by short diagonal hatch strokes, the classic + * manga blush. Drawn once per cheek; the caller draws these before the eyes. + */ +function drawCheek(ctx, cx, cy, spec) { + const size = clamp(spec.size ?? 1, 0.2, 3); + const r = CHEEK_LAYOUT.radius * size; + const ry = r * CHEEK_LAYOUT.squash; + + ctx.save(); + ctx.beginPath(); + ctx.ellipse(cx, cy, r, ry, 0, 0, TAU); + ctx.fillStyle = spec.color ?? '#f6a6b8'; + ctx.fill(); + // Clip the hatching to the patch, so the strokes keep a clean edge. + ctx.clip(); + + const lines = Math.max(0, Math.round(spec.lines ?? CHEEK_LAYOUT.lines)); + if (lines > 0) { + const angle = (CHEEK_LAYOUT.slant * Math.PI) / 180; + const dx = Math.cos(angle); + const dy = -Math.sin(angle); + const reach = r * 1.3; + ctx.strokeStyle = spec.hatchColor ?? '#e0708f'; + ctx.lineWidth = Math.max(1, CHEEK_LAYOUT.lineWidth * size); + ctx.lineCap = 'round'; + for (let i = 0; i < lines; i += 1) { + // Spread the strokes evenly across the patch. + const t = lines === 1 ? 0 : i / (lines - 1) - 0.5; + const px = cx + t * r * 1.2; + ctx.beginPath(); + ctx.moveTo(px - dx * reach, cy - dy * reach); + ctx.lineTo(px + dx * reach, cy + dy * reach); + ctx.stroke(); + } + } + ctx.restore(); +} + +/** The pair of cheeks: symmetric about the face's midline (see `EYE_LAYOUT`). */ +function drawCheeks(ctx, spec) { + if (!spec?.enabled) return; + const drop = CHEEK_LAYOUT.drop + clamp(spec.offsetY ?? 0, -400, 400); + const outward = CHEEK_LAYOUT.outward + clamp(spec.spacing ?? 0, -400, 400); + for (const layout of EYE_LAYOUT.eyes) { + // Outwards is away from the midline, i.e. the opposite of `towardNose`. + const cx = layout.cx - layout.towardNose * outward; + drawCheek(ctx, cx, layout.cy + drop, spec); + } +} + +/** + * 頭の模様: one of a few simple marks drawn on the forehead. Kept to a handful of + * shapes on purpose - the point is a generic manga device, not a particular + * character's artwork - and it starts off. + */ +function drawHeadMark(ctx, spec) { + const shape = spec.shape ?? 'off'; + if (shape === 'off' || shape === 'none') return; + const size = clamp(spec.size ?? 1, 0.2, 3); + const s = HEAD_MARK_LAYOUT.size * size; + const cx = HEAD_MARK_LAYOUT.cx + clamp(spec.offsetX ?? 0, -400, 400); + const cy = HEAD_MARK_LAYOUT.cy + clamp(spec.offsetY ?? 0, -400, 400); + + ctx.save(); + ctx.strokeStyle = spec.color ?? '#8a4fe0'; + ctx.fillStyle = spec.color ?? '#8a4fe0'; + ctx.lineWidth = Math.max(1, HEAD_MARK_LAYOUT.lineWidth * size); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + if (shape === 'hook') { + // A short curved hook, like a comma. + ctx.beginPath(); + ctx.moveTo(cx - s * 1.05, cy + s * 0.45); + ctx.quadraticCurveTo(cx - s * 0.15, cy - s * 1.05, cx + s * 0.85, cy + s * 0.15); + ctx.stroke(); + } else if (shape === 'spiral') { + const steps = 60; + ctx.beginPath(); + for (let i = 0; i <= steps; i += 1) { + const t = i / steps; + const angle = t * TAU * 2.2; + const radius = s * (0.14 + 0.86 * t); + const x = cx + Math.cos(angle) * radius; + const y = cy + Math.sin(angle) * radius; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } else if (shape === 'strokes') { + // Three short strokes standing side by side. + for (let i = -1; i <= 1; i += 1) { + ctx.beginPath(); + ctx.moveTo(cx + i * s * 0.62, cy - s * 0.6); + ctx.lineTo(cx + i * s * 0.62, cy + s * 0.6); + ctx.stroke(); + } + } else if (shape === 'dots') { + const radius = Math.max(1.5, ctx.lineWidth * 0.8); + for (let i = -1; i <= 1; i += 1) { + ctx.beginPath(); + ctx.arc(cx + i * s * 0.62, cy, radius, 0, TAU); + ctx.fill(); + } + } + ctx.restore(); +} + +/** + * The shared extras (ほっぺ and 頭の模様) are drawn once per face, before the eyes, + * so the eyes, brows and glasses win any overlap and the cheeks are not doubled. + */ +function drawFaceExtras(ctx, style) { + drawCheeks(ctx, style.cheeks); + drawHeadMark(ctx, style.headMark); +} + +/** + * One lens of a pair of 眼鏡 / サングラス, plus this eye's half of the bridge and its + * temple. + * + * Called once per eye from `drawEyes`, after the eyeball (or the shut-eye artwork) + * so the lens sits in front of the eye, and before that eye's tear so a teardrop + * falls past the lens. Nothing about a pair of glasses is per-eye, so the two + * halves are mirrored from `eye.towardNose` and meet at the face's midline. The + * kind picks the shape: a round 眼鏡 lens, or a wide angular サングラス one. + * + * `style.glasses` is the shared spec. In a line drawing the lens is left empty, so + * the paper behind shows through exactly as the eyeball's white does (see the note + * in `drawEyes`); the frames are then stroked in the line colour. + * + * @param {CanvasRenderingContext2D} ctx + * @param {{cx:number,cy:number,r:number,towardNose:number}} eye + * @param {boolean} line true in 線画 mode + * @param {object} style the bag `drawEyes` received + * @param {{top:number,bottom:number,left:number,right:number}} limits + */ +function drawGlasses(ctx, eye, line, style, limits) { + const spec = style.glasses; + if (!spec?.enabled) return; + + const r = eye.r; + const scale = clamp(spec.scale ?? 1, 0.2, 3); + // The two kinds share everything about *where* they sit but not their shape: + // 眼鏡 is a round lens, サングラス a wide angular one (see the layouts above). + const shades = spec.kind === 'sunglasses'; + const L = shades ? SUNGLASSES_LAYOUT : GLASSES_LAYOUT; + const rx = r * L.widthFactor * scale; + const ry = rx * L.heightFactor; + const frame = Math.max( + 1, + (spec.frameWidth ?? 1) * EYE_LAYOUT.lidStroke * (shades ? L.frameFactor : 1), + ); + const colour = line ? (style.line ?? '#55386e') : (spec.frameColor ?? '#2a1e33'); + // The outward direction (towards the temple) for this eye. The angular lens is + // built in a local frame whose u runs outwards, so the two sides mirror exactly. + const out = -eye.towardNose; + // The pointed outer corner makes the shades taller on that side; clamp against + // the full height so even that point cannot reach the canvas border. + const halfV = shades ? ry * Math.max(L.innerTop + L.topSkew, L.outerBottom) : ry; + + // Keep the lens (and so everything that hangs off it) inside the artwork. The + // rest of the file clamps against `limits` for the same reason: a mark that + // reaches the canvas border gets the edge row copied across the whole plate. + const lensCx = clamp(eye.cx, limits.left + EDGE_MARGIN + rx, limits.right - EDGE_MARGIN - rx); + const lensCy = clamp( + eye.cy + L.drop + (spec.offsetY ?? 0), + limits.top + EDGE_MARGIN + halfV, + limits.bottom - EDGE_MARGIN - halfV, + ); + // The face's midline: the two eyes are symmetric about it, so it is where the + // two halves of the bridge meet and what `tilt` turns the pair about. + const midX = (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2; + // Local (u, v) -> canvas, with u outwards and v downwards. + const at = (u, v) => ({ x: lensCx + out * u, y: lensCy + v }); + + ctx.save(); + // `tilt` leans the whole pair at once. Rotating about the midline keeps the + // bridge centred between the lenses instead of swinging it off to one side. + if (spec.tilt) { + ctx.translate(midX, lensCy); + ctx.rotate((clamp(spec.tilt, -90, 90) * Math.PI) / 180); + ctx.translate(-midX, -lensCy); + } + + // --- lens ------------------------------------------------------------ + // The one path is filled and then stroked, so the fill and the frame can never + // drift apart. + if (shades) { + // A long cat-eye, not an ellipse: the outer end starts low, rises to a + // pointed, lifted outer corner set further out, and the top edge sweeps back + // towards the nose. The inner end is short, so the lens tapers inwards. + const lens = [ + at(-rx, -ry * L.innerTop), // inner top (near the nose) + at(rx * L.tipX, -ry * (L.innerTop + L.topSkew)), // pointed, lifted outer corner + at(rx * L.outerEndX, ry * L.outerBottom), // low outer end + at(-rx, ry * L.innerBottom), // inner bottom (pinched towards the nose) + ]; + traceRoundedPolygon(ctx, lens, ry * L.corner); + if (!line) { + ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1); + ctx.fillStyle = spec.lensColor ?? '#2b2433'; + ctx.fill(); + ctx.globalAlpha = 1; + } + ctx.strokeStyle = colour; + ctx.lineWidth = frame; + ctx.stroke(); + // A light streak inside the glass, clipped to the lens so it can never spill + // over the frame. Shaded drawings only: 線画's lens is empty paper. + if (!line) { + ctx.save(); + traceRoundedPolygon(ctx, lens, ry * L.corner); + ctx.clip(); + strokePolyline(ctx, [at(-rx * 0.6, ry * 0.15), at(rx * 0.4, -ry * 0.85)], { + color: 'rgba(255, 255, 255, 0.5)', + width: Math.max(2, rx * L.highlightWidth), + }); + ctx.restore(); + } + } else { + ctx.beginPath(); + ctx.ellipse(lensCx, lensCy, rx, ry, 0, 0, TAU); + ctx.closePath(); + if (!line) { + ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1); + ctx.fillStyle = spec.lensColor ?? '#2b2433'; + ctx.fill(); + ctx.globalAlpha = 1; + } + ctx.strokeStyle = colour; + ctx.lineWidth = frame; + ctx.stroke(); + } + + // --- bridge ---------------------------------------------------------- + // Each eye draws the half of the bridge from its own lens to the midline. + const innerX = eye.towardNose > 0 + ? Math.min(lensCx + rx, midX) + : Math.max(lensCx - rx, midX); + if (shades) { + // A short, thick, level bar high on the lenses: with the two lenses it reads + // as one continuous visor across the eyes. + const barY = lensCy - ry * L.bridgeLift; + strokePolyline(ctx, [{ x: innerX, y: barY }, { x: midX, y: barY }], { + color: colour, + width: frame * L.bridgeWidth, + }); + } else { + // The two halves meet at the midline with a horizontal tangent, so the join is smooth. + const noseX = lensCy - L.bridgeRise; + strokePolyline(ctx, quadraticPoints( + { x: innerX, y: lensCy }, + { x: (innerX + midX) / 2, y: noseX }, + { x: midX, y: noseX }, + 12, + ), { color: colour, width: frame * L.bridgeWidth }); + } + + // --- temple ---------------------------------------------------------- + // A stub outwards from the outer edge of the lens. It stays short on purpose: + // the plate only reaches so far, and a temple that ran off it would smear. The + // round pair's outer edge is a vertical line at the lens centre height; the + // shades' outer end sits low, so their stub starts down there instead of + // floating below the lens. + const outerX = shades + ? lensCx - eye.towardNose * rx * L.outerEndX + : lensCx - eye.towardNose * rx; + const outerY = shades ? lensCy + ry * L.outerBottom * 0.7 : lensCy; + const reach = clamp( + outerX - eye.towardNose * r * L.templeLength, + limits.left + EDGE_MARGIN, + limits.right - EDGE_MARGIN, + ); + strokePolyline(ctx, [ + { x: outerX, y: outerY }, + { + x: eye.towardNose > 0 ? Math.min(reach, outerX) : Math.max(reach, outerX), + y: clamp(outerY - L.templeRise, limits.top + EDGE_MARGIN, limits.bottom - EDGE_MARGIN), + }, + ], { color: colour, width: frame }); + + ctx.restore(); +} + +/** + * Draw the pair of eyes into a 1024 x 380 canvas. + * + * @param {CanvasRenderingContext2D} ctx + * @param {object} p + * @param {'paint'|'line'} [p.mode] paint = filled cartoon eyes, line = outline only + * @param {object} p.eyes `{ left, right }`, each `{ open, lookX, lookY, closed }` + * @param {object} [p.style] colours and shared shaping parameters + */ +export function drawEyes(ctx, p) { + const mode = p.mode ?? 'paint'; + const line = mode === 'line'; + const style = p.style ?? {}; + const limits = style.limits ?? WINDOW_LIMITS; + const white = style.white ?? '#ffffff'; + const irisColor = style.iris ?? '#150e1b'; + const lineColor = style.line ?? '#55386e'; + const lidWidth = style.lidWidth ?? EYE_LAYOUT.lidStroke; + const irisScale = style.irisScale ?? 1; + const lookMax = style.lookMax ?? 1; + const highlightOn = style.highlight !== false; + const heartScale = style.heartScale ?? 0.85; + const heartColor = style.heartColor ?? '#e0344f'; + const irisRadius = EYE_LAYOUT.irisRadius * irisScale; + const travel = Math.max(0, EYE_LAYOUT.radius - irisRadius); + const heartTravel = EYE_LAYOUT.radius * 0.9; + const lowerLid = clamp(style.lowerLid ?? 0, 0, 1); + + // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here + // rather than inside the per-eye loop below. + drawFaceExtras(ctx, style); + + for (const layout of EYE_LAYOUT.eyes) { + const spec = (p.eyes ?? {})[layout.key] ?? {}; + const open = clamp(spec.open ?? 1, 0, 1); + const eye = { + // `eyeX` slides this eye - and its lid, brow and tear - sideways on its own. + cx: layout.cx + clamp(spec.eyeX ?? 0, -400, 400), + cy: layout.cy, + r: EYE_LAYOUT.radius, + towardNose: layout.towardNose, + }; + const lineStyle = { line: lineColor, width: lidWidth }; + const isHeart = (spec.irisShape ?? 'circle') === 'heart'; + + drawBrow(ctx, eye, spec, style, limits); + + // --- tear ---------------------------------------------------------- + // A teardrop hangs below the eye, so it is drawn on top of everything else + // (the opaque eyeball would otherwise cover it) and for a shut eye too. The + // amount, the height and the tilt are all *per eye*, because one eye crying + // while the other does not is a real expression. + const drawTear = () => { + const amount = clamp(spec.tear ?? 0, 0, 1.6); + if (amount <= 0.01) return; + const size = EYE_LAYOUT.tear.size * amount; + const tearX = eye.cx + clamp(spec.tearX ?? 0, -400, 400) - layout.towardNose * EYE_LAYOUT.tear.offsetX; + let tearY = eye.cy + EYE_LAYOUT.tear.offsetY + (spec.tearY ?? 0); + // The drop hangs from `cy - 1.32r` to `cy + 0.78r`. Keep its lower tip + // inside the artwork: a drop that reached the canvas border used to be + // smeared down the chin by the clamp. + const tearBottom = limits.bottom - EDGE_MARGIN; + if (tearY + size * 0.78 > tearBottom) tearY = tearBottom - size * 0.78; + ctx.save(); + if (spec.tearTilt) { + ctx.translate(tearX, tearY); + ctx.rotate((spec.tearTilt * Math.PI) / 180); + ctx.translate(-tearX, -tearY); + } + dropPath(ctx, tearX, tearY, size); + if (!line) { + ctx.fillStyle = style.tearColor ?? '#8fd8ff'; + ctx.fill(); + } + ctx.strokeStyle = lineColor; + ctx.lineWidth = lidWidth * 0.55; + ctx.stroke(); + ctx.restore(); + }; + + if (open <= 0.02) { + drawShutEye(ctx, eye, spec, lineStyle); + // The glasses sit over the shut-eye artwork too, but under the tear below. + drawGlasses(ctx, eye, line, style, limits); + drawTear(); + continue; + } + + // --- eyeball ------------------------------------------------------- + ctx.save(); + // A heart is not an eyeball, so once the eye is fully open it is not clipped + // to the eyeball circle - that is what lets a heart be much larger than the + // iris. While the lid is partly closed the clip is still needed. + if (!isHeart || open < 0.999) clipEye(ctx, eye, open, lowerLid * (1 - open)); + + const eyeTravel = isHeart ? heartTravel * lookMax : travel * lookMax; + let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel; + let dy = -clamp(spec.lookY ?? 0, -1, 1) * eyeTravel; + const dist = Math.hypot(dx, dy); + if (dist > eyeTravel && dist > 0) { + dx = (dx / dist) * eyeTravel; + dy = (dy / dist) * eyeTravel; + } + + // The eyeball's white is only painted in the shaded styles: in line art the + // paper behind shows through, which is what a line drawing wants. (The SVG + // export traces the alpha mask, so a white fill would close the eye up + // completely.) + if (!line && !(isHeart && style.heartHideWhite)) fillCircle(ctx, eye.cx, eye.cy, eye.r, white); + const irisX = eye.cx + dx; + const irisY = eye.cy + dy; + if (isHeart) { + // A heart is wider than a circle, so cap it at the plane rather than the + // eyeball; a red heart has no pupil highlight (it would sit on top of it). + // A heart is not an eyeball, so it may grow well past the eye: the plate + // keeps the whole canvas, and the eyes plate is drawn over the mouth, so a + // big heart has room. The cap only stops it from running off the canvas. + const heartRadius = Math.min(irisRadius * heartScale, EYE_LAYOUT.radius * 2.1); + fillHeart(ctx, irisX, irisY, heartRadius, line ? lineColor : heartColor); + } else { + fillCircle(ctx, irisX, irisY, irisRadius, line ? lineColor : irisColor); + } + if (highlightOn && !isHeart) { + const hx = irisX + layout.towardNose * EYE_LAYOUT.highlightOffset.x * irisScale; + const hy = irisY + EYE_LAYOUT.highlightOffset.y * irisScale; + const hr = EYE_LAYOUT.highlightRadius * irisScale; + if (line) { + // Punch the sparkle out of the iris rather than painting it white. On + // screen the paper behind shows through, and in the SVG export (which + // only sees alpha) it stays a hole in the pupil - both of which read as + // the white glint of the original artwork. + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + fillCircle(ctx, hx, hy, hr, '#000000'); + ctx.restore(); + } else { + fillCircle(ctx, hx, hy, hr, white); + } + } + ctx.restore(); + + // Between the eyeball and the tear: the lens covers the eye, and a tear still + // falls in front of it. + drawGlasses(ctx, eye, line, style, limits); + + drawTear(); + + // --- lid strokes --------------------------------------------------- + // Faded in as the lid starts to cover the eye, so opening the eye all the + // way leaves the clean original artwork with no extra line. + const lidFade = clamp((0.95 - open) / 0.1, 0, 1); + if (lidFade > 0.01 || lowerLid > 0.01) { + ctx.save(); + circlePath(ctx, eye.cx, eye.cy, eye.r); + ctx.clip(); + ctx.globalAlpha = line ? 1 : lidFade; + ctx.strokeStyle = lineColor; + ctx.lineWidth = lidWidth; + if (lidFade > 0.01) { + lidPath(ctx, eye, open, false); + ctx.stroke(); + } + if (lowerLid > 0.01) { + ctx.globalAlpha = line ? 1 : lowerLid; + lidPath(ctx, eye, lowerLid, true); + ctx.stroke(); + } + ctx.restore(); + } + + // --- outline (line-art mode only) --------------------------------- + if (line) strokeCircle(ctx, eye.cx, eye.cy, eye.r, lineColor, lidWidth * 0.75); + } +} + +/* -------------------------------------------------------------------- mouth */ + +/** + * Draw the mouth into a 1024 x 380 canvas. + * + * @param {CanvasRenderingContext2D} ctx + * @param {object} p + * @param {'paint'|'line'} [p.mode] + */ +export function drawMouth(ctx, p) { + const mode = p.mode ?? 'paint'; + const line = mode === 'line'; + const L = MOUTH_LAYOUT; + const limits = p.limits ?? WINDOW_LIMITS; + // The artwork's real edges, in the artwork's own coordinates, with a margin + // kept clear. `top` is negative when the plate reaches above the window, which + // is the room a moved mouth has to work with. + const edgeLeft = limits.left + EDGE_MARGIN; + const edgeRight = limits.right - EDGE_MARGIN; + const edgeTop = limits.top + EDGE_MARGIN; + const edgeBottom = limits.bottom - EDGE_MARGIN; + + const openAmount = clamp(p.open ?? 0, 0, 1); + const thickness = L.thickness * clamp(p.thickness ?? 1, 0.2, 3); + const cornerAmount = clamp(p.corners ?? 1, 0, 1.6); + // The corner strokes flick out past the ends of the chord, so the ends cannot + // use the whole canvas: a very wide mouth used to smear sideways off the plate. + const cornerReach = L.corner.toX * cornerAmount; + const halfChord = Math.min( + L.halfChord * clamp(p.width ?? 1, 0.2, 1.6), + Math.min(L.centreX - (edgeLeft + cornerReach), (edgeRight - cornerReach) - L.centreX), + ); + + // A negative smile bulges the line upwards, which reads as a frown. + const sagRaw = L.sag * clamp(p.smile ?? 1, -0.55, 1.4); + // A frown's middle is the highest point of the mouth, and that is exactly + // where the nose is in the way, so slide the whole mouth down until the curve + // is in the open. The push is worked out from the *base* position and the + // height slider is added afterwards: adding it first made the push cancel the + // slider exactly, which is why dragging ‟口の高さ” did nothing at all on a + // frowning mouth like むっと. + const base = L.chordY + Math.max(0, L.noseClear - (L.chordY + sagRaw)); + let y0 = base + (p.offsetY ?? 0); + // The smile flattens slightly as the mouth opens, so the whole mouth keeps + // fitting inside the band the mouth plane actually shows. + let sag = sagRaw * (1 - L.openFlatten * openAmount); + // Keep the whole mark on the canvas. The corner strokes sit *above* the ends of + // the chord, so the allowance is not symmetric - padding the bottom by the + // corner's depth used to cost most of the height slider's travel. + const cornerRise = L.corner.toY * cornerAmount + thickness; + const bottomEdge = y0 + Math.max(0, sag) + thickness; + if (bottomEdge > edgeBottom) y0 -= bottomEdge - edgeBottom; + const topEdge = y0 + Math.min(0, sag) - cornerRise; + if (topEdge < edgeTop) y0 += edgeTop - topEdge; + const pivot = { x: L.centreX, y: y0 + sag * 0.5 }; + const tilt = p.tilt ?? 0; + + const strokeColor = line ? (p.line ?? '#3a2a4a') : (p.color ?? '#ff1a44'); + const innerColor = p.innerColor ?? '#4a0f1e'; + const tongueColor = line ? (p.line ?? '#3a2a4a') : (p.tongueColor ?? '#ff2d2d'); + + const left = { x: L.centreX - halfChord, y: y0 }; + const right = { x: L.centreX + halfChord, y: y0 }; + const lip = mouthControls(left, right, sag, L.sagBend); + const arc = cubicPoints(lip.p0, lip.p1, lip.p2, lip.p3, 96); + + const tongueAmount = clamp(p.tongue ?? 1, 0, 1.6); + // How far the tongue hangs *out* of the mouth (below the lip). Separate from + // `tongue`, which is the one that pokes up over the lip of a closed mouth. + const tongueOutAmount = clamp(p.tongueOut ?? 0, 0, 1); + + // --- a round "O" mouth (surprised, singing) --------------------------- + const roundAmount = clamp(p.round ?? 0, 0, 1); + if (roundAmount > 0.004) { + let ry = L.sag * 0.42 * roundAmount * clamp(p.thickness ?? 1, 0.5, 2); + // The nose hides the middle of the upper face, so keep the whole oval below + // it and inside the plate. (`L.safeBottom` is only the round mouth's own size + // limit; it no longer has to stay clear of the canvas edge.) + ry = Math.min(ry, (L.safeBottom - thickness / 2 - L.noseClear) / 2.1); + const rx = Math.min(ry * 1.15 * clamp(p.width ?? 1, 0.2, 1.6), L.halfChord * 0.8); + const centreY = clamp(y0 + sag * 0.5, L.noseClear + ry * 1.02, edgeBottom - thickness / 2 - ry * 1.02); + if (ry > 5) { + ctx.save(); + ctx.translate(pivot.x, pivot.y); + ctx.rotate((tilt * Math.PI) / 180); + ctx.translate(-pivot.x, -pivot.y); + ctx.beginPath(); + ctx.ellipse(L.centreX, centreY, rx, ry, 0, 0, TAU); + if (!line) { + ctx.fillStyle = innerColor; + ctx.fill(); + if (tongueAmount > 0.01) { + ctx.beginPath(); + ctx.ellipse(L.centreX, centreY + ry * 0.46, rx * 0.52, ry * 0.4, 0, 0, TAU); + ctx.fillStyle = tongueColor; + ctx.fill(); + } + if (tongueOutAmount > 0.01) { + // A tongue hanging out below the round mouth. Its foot is a flat line + // inside the lower lip; the tip is clamped to the plate's lower edge. + const drop = Math.min(L.tongueOut.height * tongueOutAmount, Math.max(0, edgeBottom - (centreY + ry * 0.5))); + if (drop > 4) { + const half = (L.tongueOut.width * tongueOutAmount) / 2; + const footY = centreY + ry * 0.5; + ctx.beginPath(); + ctx.moveTo(L.centreX - half, footY); + for (let i = 0; i <= 26; i++) { + const dx = 1 - 2 * (i / 26); + ctx.lineTo(L.centreX + dx * half, footY + drop * (1 - Math.pow(Math.abs(dx), L.tongueOut.crown))); + } + ctx.closePath(); + ctx.fillStyle = tongueColor; + ctx.fill(); + } + } + } + ctx.strokeStyle = strokeColor; + ctx.lineWidth = line ? thickness * 0.8 : thickness; + ctx.stroke(); + ctx.restore(); + } + return; + } + + // The smile line is the upper lip. Opening the mouth drops the lower jaw + // below it, and the room for that is limited by the texture band the mouth + // plane shows - otherwise the jaw is silently clipped away. + // Everything has to fit in the part of the plane that faces the camera. + let rise = L.openRise * openAmount; + const budget = edgeBottom - thickness / 2 - y0; + if (sag + rise > budget) { + rise = Math.max(0, budget - Math.min(sag, budget)); + if (sag + rise > budget) sag = Math.max(-80, budget - rise); + } + const jawCubic = mouthControls(left, right, sag + rise, L.sagBend); + const jaw = cubicPoints(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, 96); + const jawAt = (t) => cubicPoint(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, t); + const lipAt = (t) => cubicPoint(lip.p0, lip.p1, lip.p2, lip.p3, t); + const openShape = rotate([...arc, ...jaw.slice(1, -1).reverse()], pivot, tilt); + + const tonguePos = clamp(p.tonguePos ?? L.tongue.pos, 0.05, 0.95); + const tongueWidth = L.tongue.width * tongueAmount; + const tongueHeight = L.tongue.height * tongueAmount; + + /** + * The tongue, rising from `curve` to `height` above its middle. + * + * `pointAt(t)` is that same curve as a function of t, so the tongue's foot can + * sit along it without this needing to know what kind of curve it is. + */ + function drawTongue(pointAt, curve, height) { + const arcLength = Math.max(1, chordLength(curve)); + const dt = clamp(tongueWidth / 2 / arcLength, 0.01, 0.45); + const t0 = clamp(tonguePos - dt, 0, 1); + const t1 = clamp(tonguePos + dt, 0, 1); + const base = []; + for (let i = 0; i <= 16; i++) base.push(pointAt(t0 + ((t1 - t0) * i) / 16)); + const mid = pointAt(clamp(tonguePos, 0, 1)); + const half = tongueWidth / 2; + // The foot runs left to right; the crown comes back right to left, so the two + // together are already a closed ring. + const footRight = base[base.length - 1]; + const footLeft = base[0]; + const crown = []; + const steps = 26; + for (let i = 0; i <= steps; i++) { + const f = i / steps; + const dx = 1 - 2 * f; + const footY = footRight.y + (footLeft.y - footRight.y) * f; + crown.push({ + x: mid.x + dx * half, + y: footY - height * (1 - Math.pow(Math.abs(dx), L.tongue.crown)), + }); + } + const shaped = rotate([...base, ...crown], pivot, tilt); + if (line) { + strokePolyline(ctx, shaped, { color: strokeColor, width: thickness * 0.8, closed: true }); + return; + } + tracePolyline(ctx, shaped, true); + ctx.fillStyle = tongueColor; + ctx.fill(); + } + + /** + * The tongue lolling *out* of the mouth, hanging below `curve`. + * + * The mirror of `drawTongue`: the foot sits along the lip (or the lower jaw + * when the mouth is open) and the crown drops *below* it. The tip is clamped + * against the plate's real lower edge, so a tongue on a low mouth gets shorter + * instead of being smeared off the chin by the texture's clamp. + */ + function drawTongueOut(pointAt, curve, height) { + const half = (L.tongueOut.width * tongueOutAmount) / 2; + const pos = clamp(p.tongueOutPos ?? L.tongueOut.pos, 0.05, 0.95); + const arcLength = Math.max(1, chordLength(curve)); + const dt = clamp(half / arcLength, 0.01, 0.45); + const t0 = clamp(pos - dt, 0, 1); + const t1 = clamp(pos + dt, 0, 1); + const base = []; + for (let i = 0; i <= 16; i++) base.push(pointAt(t0 + ((t1 - t0) * i) / 16)); + const mid = pointAt(clamp(pos, 0, 1)); + const drop = Math.min(height, Math.max(0, edgeBottom - mid.y)); + if (drop < 2) return; + const footRight = base[base.length - 1]; + const footLeft = base[0]; + const crown = []; + const steps = 26; + for (let i = 0; i <= steps; i++) { + const f = i / steps; + const dx = 1 - 2 * f; + const footY = footRight.y + (footLeft.y - footRight.y) * f; + crown.push({ + x: mid.x + dx * half, + y: footY + drop * (1 - Math.pow(Math.abs(dx), L.tongueOut.crown)), + }); + } + const shaped = rotate([...base, ...crown], pivot, tilt); + if (line) { + strokePolyline(ctx, shaped, { color: strokeColor, width: thickness * 0.8, closed: true }); + return; + } + tracePolyline(ctx, shaped, true); + ctx.fillStyle = tongueColor; + ctx.fill(); + } + + if (openAmount > 0.004) { + if (!line) fillPolygon(ctx, openShape, innerColor); + // The tongue rises from the lower lip *into* the mouth, so it has to arrive + // with the opening: at a hair's width the jaw curve is a sliver, and the + // tongue used to burst straight out of it and sit on the chin. + const tongueOpen = clamp((openAmount - 0.06) / 0.24, 0, 1); + if (tongueOutAmount > 0.01 && tongueOpen > 0.01) { + // A tongue lolling *out* over the lower jaw is not clipped to the mouth: + // it hangs in front of the chin. Drawn before the outline, which then + // crosses its root. + drawTongueOut(jawAt, jaw, L.tongueOut.height * tongueOutAmount * tongueOpen); + } else if (tongueAmount > 0.01 && tongueOpen > 0.01) { + ctx.save(); + tracePolyline(ctx, openShape, true); + ctx.clip(); + // Inside an open mouth the tongue sits on the lower jaw. + drawTongue(jawAt, jaw, tongueHeight * tongueOpen * (1 + openAmount * 0.3)); + ctx.restore(); + } + // One outline around the whole mouth reads as lips; the smile stroke alone + // would leave the lower edge as a bare colour change. + strokePolyline(ctx, openShape, { color: strokeColor, width: thickness, closed: true }); + } else { + // Closed lips - but the tongue still pokes out. That is what the original + // artwork does (the mouth reads as a smile with the tongue showing, like + // ペコちゃん), and it is why a plain line looked wrong there. `tongueOut` + // hangs the tongue the other way, *below* the lip line. The lip line goes on + // afterwards, so the tongue comes out from under it. + if (tongueOutAmount > 0.01) { + drawTongueOut(lipAt, arc, L.tongueOut.height * tongueOutAmount); + } else if (tongueAmount > 0.01) { + drawTongue(lipAt, arc, tongueHeight); + } + strokePolyline(ctx, rotate(arc, pivot, tilt), { color: strokeColor, width: thickness }); + } + + // --- corner marks --------------------------------------------------- + if (cornerAmount > 0.01) { + const C = L.corner; + const cornerAngle = p.cornerAngle ?? 0; + for (const [end, side] of [[left, -1], [right, 1]]) { + const from = { x: end.x + C.fromX * side * cornerAmount, y: end.y - C.fromY * cornerAmount }; + const to = { x: end.x - C.toX * side * cornerAmount, y: end.y - C.toY * cornerAmount }; + // A smooth curve, not a three-point kink. `mid` is the control point, so it + // is pulled twice as far as the bow should reach; both corners bow the same + // way (downwards on screen), which is what the original artwork does. + const control = { + x: (from.x + to.x) / 2, + y: (from.y + to.y) / 2 + (C.curve ?? 26) * cornerAmount, + }; + const curve = quadraticPoints(from, control, to, 16); + // `cornerAngle` tilts the whole mark around the mouth corner, mirrored so + // both sides move together. + const shaped = cornerAngle ? rotate(curve, end, cornerAngle * side) : curve; + strokePolyline(ctx, rotate(shaped, pivot, tilt), { + color: line ? strokeColor : (p.cornerColor ?? '#725497'), + // The *length* of the corner mark follows `corners`, but its thickness + // does not: `corners: 0.71` is the length that matches the original, and + // the original's stroke is the full thickness. + width: C.width, + }); + } + } +} + +/** Approximate length of a polyline. */ +function chordLength(points) { + let total = 0; + for (let i = 1; i < points.length; i++) { + total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y); + } + return total; +} diff --git a/public/bluebey-studio/src/gacha.js b/public/bluebey-studio/src/gacha.js new file mode 100644 index 0000000..2b3877e --- /dev/null +++ b/public/bluebey-studio/src/gacha.js @@ -0,0 +1,466 @@ +/** + * Seeded "おまかせ" rolls for the face and the pose. + * + * The studio is a hundred sliders, and that is exactly the problem: when you sit + * down to make an expression you reach for the same three settings every time. + * This module is the antidote - one press and a stranger picks the numbers for + * you, from ranges a person would actually dial in, so the result is plausible + * rather than a mash of the full slider span. + * + * Two ideas hold it together: + * + * - A roll picks an *emotion* first (ふつう, うれしい, びっくり, ...), then samples + * every part inside that emotion's window. Correlated parts read as one face; + * an independent draw per slider reads as noise. + * + * - The randomness is seeded, and the seed is a short base36 string that fits in + * a URL. `rollAll(seed)` is a pure function of that string, so a look the user + * liked can be reproduced and shared (`?seed=...`) instead of lost. A "seed" + * is either a number or text; text that is already a base36 number is read as + * one, so `decodeSeed(seed)` is always accepted back by `makeRandom`. + * + * The face and pose vocabulary, and every bound quoted below, follows + * `src/presets.js` and the slider ranges in `src/panel.js`. Nothing here touches + * the DOM or the app state: the functions return patches for `applyPatch`. + */ + +/** + * FNV-1a over UTF-16 code units, for text that is not already a seed. + */ +function fnv1a(text) { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +/** + * Normalise any seed to a 32-bit integer. Text that is already a base36 number + * is read as one - that is what `decodeSeed` emits, so it has to come back in + * unchanged - and anything else is hashed. `hashSeed`, `makeRandom` and + * `decodeSeed` all go through this, so the four entry points never disagree. + */ +function toSeedInt(seed) { + if (typeof seed === 'number' && Number.isFinite(seed)) return Math.trunc(seed) >>> 0; + const text = String(seed ?? '').trim(); + if (/^[0-9a-z]+$/.test(text)) { + const parsed = parseInt(text, 36); + if (Number.isFinite(parsed)) return parsed >>> 0; + } + return fnv1a(text); +} + +/** + * Turn text into a stable 32-bit seed (for `?seed=` in a URL). + * + * A word becomes an FNV-1a hash; text that already looks like a base36 seed is + * passed through, so `decodeSeed`'s output round-trips. + */ +export function hashSeed(text) { + return toSeedInt(text); +} + +/** + * A seeded PRNG (mulberry32). Returns `() => number` in `[0, 1)`. + * Accepts a number or a string; strings go through the same rule as `decodeSeed`. + */ +export function makeRandom(seed) { + let state = toSeedInt(seed); + return function random() { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * The short, readable form of a seed (base36), for the "現在のシード" field. + * Idempotent: `decodeSeed(decodeSeed(x)) === decodeSeed(x)`, and the result is + * accepted straight back by `makeRandom`. + */ +export function decodeSeed(seed) { + return (toSeedInt(seed) >>> 0).toString(36); +} + +function rand(random, min, max) { + return min + random() * (max - min); +} + +/** An angle in whole degrees, so the pose matches the step-1 sliders. */ +function deg(random, min, max) { + return Math.round(rand(random, min, max)); +} + +function round2(value) { + return Math.round(value * 100) / 100; +} + +function pick(random, list) { + return list[Math.floor(random() * list.length)]; +} + +function chance(random, probability) { + return random() < probability; +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +/** + * The mood table. Each entry samples only inside a range that reads as that + * emotion, which is what keeps the parts agreeing with each other. + * + * Fields: `eyes` (open/irisScale/lookMax/lookX/lookY, all in slider units), + * `mouth` (smile/open/round/corners/cornerAngle/tilt/offsetY/tongue/width/ + * thickness), plus optional `tear`, `lowerLid`, `heart`, `wink` and `shutBoth`. + */ +const MOODS = [ + { + id: 'normal', + eyes: { + open: [0.92, 1], irisScale: [0.95, 1.06], lookMax: [0.55, 1], + lookX: [-0.28, 0.28], lookY: [-0.16, 0.16], + }, + mouth: { + smile: [0.62, 0.95], open: [0, 0.05], width: [0.9, 1.1], thickness: [0.9, 1.15], + corners: [0.8, 1.2], cornerAngle: [-6, 6], tilt: [-4, 4], offsetY: [-5, 6], + tongue: [0, 1.1], + }, + }, + { + id: 'happy', + eyes: { + open: [0.88, 1], irisScale: [0.96, 1.08], lookMax: [0.5, 0.95], + lookX: [-0.3, 0.3], lookY: [-0.2, 0.05], + }, + mouth: { + smile: [1.05, 1.3], open: [0.02, 0.22], width: [0.95, 1.2], thickness: [0.9, 1.15], + corners: [1.05, 1.4], cornerAngle: [0, 12], tilt: [-4, 6], offsetY: [-8, 2], + tongue: [0.9, 1.35], + }, + }, + { + id: 'surprised', + eyes: { + open: [0.95, 1], irisScale: [0.8, 0.9], lookMax: [0.3, 0.5], + lookX: [-0.15, 0.15], lookY: [-0.1, 0.1], + }, + mouth: { + smile: [0, 0.2], open: [0.1, 0.32], round: [0.55, 0.8], width: [0.82, 0.98], + thickness: [1, 1.2], corners: [0, 0.15], cornerAngle: [-3, 3], tilt: [-3, 3], + offsetY: [-6, 6], tongue: [0, 0.4], + }, + }, + { + id: 'sad', + eyes: { + open: [0.55, 0.75], irisScale: [0.95, 1.05], lookMax: [0.45, 0.85], + lookX: [-0.2, 0.2], lookY: [-0.35, -0.12], + }, + mouth: { + smile: [-0.7, -0.4], open: [0, 0.05], width: [0.68, 0.88], thickness: [1.05, 1.3], + corners: [0, 0.1], cornerAngle: [-4, 4], tilt: [-5, 5], offsetY: [6, 16], + tongue: [0, 0.1], + }, + lowerLid: [0.3, 0.5], + tear: [0.6, 1.2], + tearChance: 1, + }, + { + id: 'angry', + eyes: { + open: [0.55, 0.7], irisScale: [0.82, 0.92], lookMax: [0.5, 0.9], + lookX: [-0.15, 0.15], lookY: [-0.16, 0.02], + }, + mouth: { + smile: [-0.85, -0.5], open: [0, 0.04], width: [0.78, 0.94], thickness: [1.05, 1.3], + corners: [0, 0.05], cornerAngle: [-6, 6], tilt: [-6, 6], offsetY: [8, 18], + tongue: [0, 0.05], + }, + lowerLid: [0.15, 0.35], + }, + { + id: 'love', + eyes: { + open: [1, 1], irisScale: [0.9, 1.02], lookMax: [0.4, 0.8], + lookX: [-0.16, 0.16], lookY: [-0.36, -0.2], + }, + mouth: { + smile: [1.15, 1.3], open: [0.08, 0.3], width: [0.98, 1.18], thickness: [0.98, 1.2], + corners: [1.2, 1.4], cornerAngle: [4, 14], tilt: [2, 8], offsetY: [-2, 6], + tongue: [1.1, 1.4], + }, + heart: true, + }, + { + id: 'sleepy', + eyes: { + open: [0.32, 0.5], irisScale: [0.95, 1.05], lookMax: [0.4, 0.7], + lookX: [-0.18, 0.18], lookY: [-0.26, -0.08], + }, + mouth: { + smile: [0.5, 0.82], open: [0, 0.08], width: [0.82, 1], thickness: [0.92, 1.1], + corners: [0.5, 0.9], cornerAngle: [-4, 4], tilt: [-7, -1], offsetY: [4, 13], + tongue: [0.3, 0.8], + }, + lowerLid: [0.2, 0.45], + tear: [0.2, 0.5], + tearChance: 0.5, + shutBoth: { chance: 0.4, closed: 'line', closedLines: 1 }, + }, + { + id: 'wink', + eyes: { + open: [0.95, 1], irisScale: [0.95, 1.05], lookMax: [0.5, 0.9], + lookX: [-0.2, 0.2], lookY: [-0.15, 0.1], + }, + mouth: { + smile: [1, 1.3], open: [0.02, 0.18], width: [0.95, 1.15], thickness: [0.92, 1.12], + corners: [0.9, 1.2], cornerAngle: [4, 12], tilt: [-5, 5], offsetY: [-6, 4], + tongue: [0.8, 1.25], + }, + wink: true, + }, +]; + +/** + * Sample the eyes. Both eyes normally agree (a tiny jitter keeps the face from + * looking printed); a wink shuts exactly one, and ねむい sometimes shuts both. + * A shut-eye style below 1 line is only ever picked when that eye is closed. + */ +function rollEyes(random, spec) { + const [openMin, openMax] = spec.open; + let leftOpen = round2(rand(random, openMin, openMax)); + let rightOpen = round2(clamp(leftOpen + rand(random, -0.06, 0.06), 0, 1)); + let leftClosed = 'line'; + let rightClosed = 'line'; + let leftLines = 1; + let rightLines = 1; + + if (spec.wink) { + const side = chance(random, 0.5) ? 'left' : 'right'; + const lines = pick(random, [2, 3]); + if (side === 'left') { + leftOpen = 0; + leftLines = lines; + } else { + rightOpen = 0; + rightLines = lines; + } + } else if (spec.shutBoth && chance(random, spec.shutBoth.chance)) { + leftOpen = 0; + rightOpen = 0; + leftClosed = spec.shutBoth.closed; + rightClosed = spec.shutBoth.closed; + leftLines = spec.shutBoth.closedLines; + rightLines = spec.shutBoth.closedLines; + } + + const lookX = round2(rand(random, spec.lookX[0], spec.lookX[1])); + const lookY = round2(rand(random, spec.lookY[0], spec.lookY[1])); + + return { + left: { open: leftOpen, lookX, lookY, closed: leftClosed, closedLines: leftLines, irisShape: 'circle' }, + right: { open: rightOpen, lookX, lookY, closed: rightClosed, closedLines: rightLines, irisShape: 'circle' }, + irisScale: round2(rand(random, spec.irisScale[0], spec.irisScale[1])), + lookMax: round2(rand(random, spec.lookMax[0], spec.lookMax[1])), + highlight: true, + }; +} + +/** Sample the mouth. A round "O" and a smile are mutually exclusive by design. */ +function rollMouth(random, spec) { + return { + visible: true, + smile: round2(rand(random, spec.smile[0], spec.smile[1])), + open: round2(rand(random, spec.open[0], spec.open[1])), + round: spec.round ? round2(rand(random, spec.round[0], spec.round[1])) : 0, + width: round2(rand(random, spec.width[0], spec.width[1])), + thickness: round2(rand(random, spec.thickness[0], spec.thickness[1])), + tilt: Math.round(rand(random, spec.tilt[0], spec.tilt[1])), + offsetY: Math.round(rand(random, spec.offsetY[0], spec.offsetY[1])), + corners: round2(rand(random, spec.corners[0], spec.corners[1])), + cornerAngle: Math.round(rand(random, spec.cornerAngle[0], spec.cornerAngle[1])), + tongue: round2(rand(random, spec.tongue[0], spec.tongue[1])), + }; +} + +/** + * A patch for the `face` section: `{ eyes, mouth }`, ready for + * `applyPatch(state.face, patch)`. + * + * Bounds, in case you are reading this from the tests: + * eyes.open 0..1, irisScale 0.8..1.1, lookMax 0.3..1, + * lookX -0.35..0.35, lookY -0.45..0.2, + * mouth.smile -1..1.4 (negative = frown, only when the eyes agree), + * mouth.open 0..0.35, mouth.round 0 or 0.4..0.8, + * corners 0..1.4, cornerAngle -30..30, tilt -8..8, offsetY -20..30, + * width 0.6..1.3, thickness 0.7..1.4, tongue 0..1.6, + * eyes.left/right.tear 0..1.6. + */ +export function randomFace(random) { + const mood = pick(random, MOODS); + const eyes = rollEyes(random, mood.eyes); + const mouth = rollMouth(random, mood.mouth); + + // Tears only read on a sad or sleepy face: a frown, or eyes that are half shut. + // A fully shut eye never cries. + const halfShut = eyes.left.open > 0 && eyes.left.open < 0.78; + if (mood.tear && eyes.left.open > 0 && (mouth.smile < 0 || halfShut) + && chance(random, mood.tearChance ?? 1)) { + // The tears are per eye, so both have to be set. (A crying face wants both; + // the shape still allows one eye to cry on its own.) + const amount = round2(rand(random, mood.tear[0], mood.tear[1])); + eyes.left.tear = amount; + eyes.right.tear = amount; + } + + if (mood.lowerLid) { + eyes.lowerLid = round2(rand(random, mood.lowerLid[0], mood.lowerLid[1])); + } + + // A heart pupil needs both eyes open, or it reads as a broken iris. + if (mood.heart && eyes.left.open >= 0.9 && eyes.right.open >= 0.9) { + eyes.left.irisShape = 'heart'; + eyes.right.irisShape = 'heart'; + eyes.heartScale = round2(rand(random, 0.9, 1.15)); + eyes.heartColor = '#e0344f'; + eyes.highlight = false; + } + + return { eyes, mouth }; +} + +/** + * The pose moves. Each composes one to three bones (never the whole rig) inside + * safe limits: arms up to ~74°, master lean up to 24°, root offsets small. + * + * Bounds: master [-20..24, -28..28, -14..14], armsupport [-18..76, -12..12, + * -24..24], arm [-14..20, -8..8, -12..12], legsupport [-8..24, -8..8, -12..12], + * root x/z -0.15..0.15 and y 0..0.45. + */ +const POSE_MOVES = [ + // A lean or a nod - one bone, the safest thing in the table. + (random) => ({ + bones: { master: [deg(random, 6, 24), deg(random, -10, 10), deg(random, -6, 6)] }, + }), + // A curious tilt to one side. + (random) => { + const sign = chance(random, 0.5) ? 1 : -1; + return { bones: { master: [deg(random, -6, 8), 0, sign * deg(random, 8, 14)] } }; + }, + // A wave from one arm. + (random) => { + const side = pick(random, ['l', 'r']); + return { + bones: { + [`armsupport.${side}`]: [deg(random, 42, 74), deg(random, -8, 8), deg(random, -14, 14)], + [`arm.${side}`]: [deg(random, 6, 18), 0, 0], + }, + }; + }, + // A two-armed cheer, with a small hop. + (random) => ({ + bones: { + master: [deg(random, -14, -2), 0, 0], + 'armsupport.l': [deg(random, 52, 74), 0, deg(random, 4, 18)], + 'armsupport.r': [deg(random, 52, 74), 0, -deg(random, 4, 18)], + }, + root: [0, round2(rand(random, 0.05, 0.3)), 0], + }), + // Introducing something off to one side. + (random) => { + const side = pick(random, ['l', 'r']); + const sign = side === 'l' ? 1 : -1; + const other = side === 'l' ? 'r' : 'l'; + return { + bones: { + master: [deg(random, 2, 8), sign * deg(random, 16, 26), 0], + [`armsupport.${side}`]: [deg(random, 24, 40), 0, sign * deg(random, 10, 20)], + [`armsupport.${other}`]: [deg(random, 2, 14), 0, 0], + }, + }; + }, + // A little hop on the spot. + (random) => ({ + bones: { + master: [deg(random, -12, -2), 0, 0], + 'legsupport.l': [deg(random, 8, 22), 0, deg(random, -8, 8)], + 'legsupport.r': [deg(random, 8, 22), 0, deg(random, -8, 8)], + }, + root: [0, round2(rand(random, 0.18, 0.42)), 0], + }), + // Both arms up in a simple stretch. + (random) => ({ + bones: { + 'armsupport.l': [deg(random, 46, 74), 0, deg(random, 4, 16)], + 'armsupport.r': [deg(random, 46, 74), 0, -deg(random, 4, 16)], + }, + }), + // A dance sway: one arm leads, the other follows. + (random) => { + const sign = chance(random, 0.5) ? 1 : -1; + return { + bones: { + master: [deg(random, -4, 4), 0, sign * deg(random, 4, 12)], + 'armsupport.l': [deg(random, 30, 46), 0, sign * deg(random, 8, 18)], + 'armsupport.r': [deg(random, 12, 26), 0, -sign * deg(random, 8, 18)], + }, + root: [round2(rand(random, -0.08, 0.08)), 0, 0], + }; + }, +]; + +/** A patch for the `pose` section: `{ bones, root }`. */ +export function randomPose(random) { + const move = pick(random, POSE_MOVES)(random); + return { bones: move.bones, root: move.root ?? [0, 0, 0] }; +} + +/** + * A patch for the `lookAt` section. About half the time it enables a target off + * to one side, so the character glances away instead of staring at the camera. + * Bounds: |x| 1.2..3.2 when enabled, y 1.6..3.4, z 2..4.2, amount 0.5..1. + */ +export function randomLook(random) { + if (chance(random, 0.45)) { + const sign = chance(random, 0.5) ? 1 : -1; + return { + lookAt: { + enabled: true, + x: round2(sign * rand(random, 1.2, 3.2)), + y: round2(rand(random, 1.6, 3.4)), + z: round2(rand(random, 2, 4.2)), + turnBody: chance(random, 0.35), + amount: round2(rand(random, 0.5, 1)), + }, + }; + } + return { + lookAt: { enabled: false, x: 0, y: 2.6, z: 3, turnBody: false, amount: round2(rand(random, 0.6, 1)) }, + }; +} + +/** + * One press of おまかせ: face, pose and look, all from the same seed. + * + * @param {number | string} seed a number, or text that `decodeSeed` also accepts + * @returns {{ seed: string, patch: { face: object, pose: object, lookAt: object } }} + * `seed` is the readable base36 form, for the URL and the seed field. + */ +export function rollAll(seed) { + const random = makeRandom(seed); + return { + seed: decodeSeed(seed), + patch: { + face: randomFace(random), + pose: randomPose(random), + lookAt: randomLook(random).lookAt, + }, + }; +} diff --git a/public/bluebey-studio/src/gion.js b/public/bluebey-studio/src/gion.js new file mode 100644 index 0000000..832313b --- /dev/null +++ b/public/bluebey-studio/src/gion.js @@ -0,0 +1,226 @@ +/** + * 擬音 (マンガのオノマトペ) のスタンプ. + * + * The source images are dense sheets: several sounds, each in a white-outline and + * a solid version, packed so tightly that an automatic slice (outline tracing / + * connected components) tears a single character into fragments. So nothing here + * guesses at a sheet's layout. Instead the user draws a rectangle over the sheet + * in the picker and the app crops exactly that rectangle, which means the feature + * works for any sheet that arrives later. + * + * That is why the crops are stored as *source pixels* (`sx, sy, sw, sh`) taken + * from the bitmap's own `naturalWidth` / `naturalHeight`, never as fractions of + * some assumed grid: the sheet's pixel size is read at runtime. + * + * The module is pure and DOM-free in the same sense as `trace.js` and + * `caption.js`: no globals are touched except a read of the injected + * `__BLUEBEY_GIONS__` lookup, and `drawGion` takes the loaded bitmaps as an + * argument, so the caller owns loading and the tests can drive the geometry in + * Node with a stub context. + */ + +/** + * The sheets the studio offers. It ships with none: the original otarunet sheet + * may not be redistributed with the app, and the images the author draws later are + * added here. So this list starts empty and the 擬音 section stays out of the panel + * until an entry appears. + * + * To add one: put the image in `assets/manga-gion/`, add `{ name, label }` here + * (`name` is the file name *with* its extension, e.g. `dokaan.png`) and add the + * same `name` to `GION_NAMES` in `tools/build-standalone.mjs` for the one-file + * build. Use an image with a transparent background - the crop is drawn as-is, so + * a white background would sit on the picture as a white rectangle. + */ +export const GION_SHEETS = []; + +/** + * The default display width of a stamp, in pixels at 1x. A crop that is tall and + * thin will be narrower than this after the aspect is applied; the user resizes + * it from the panel either way. + */ +export const DEFAULT_STAMP_WIDTH = 240; + +/** + * Where a sheet's image lives. The single-file build inlines every sheet as a + * data URL in `window.__BLUEBEY_GIONS__`; the normal build reads the file. + * + * @param {string} name the sheet file name, with its extension, e.g. `dokaan.png` + * @returns {string} + */ +export function gionSheetUrl(name) { + const inlined = globalThis.__BLUEBEY_GIONS__ ?? {}; + return inlined[name] ?? `assets/manga-gion/${name}`; +} + +/** Clamp `value` into `lo..hi`; a non-finite value becomes `lo`. */ +export function clamp(value, lo, hi) { + if (!Number.isFinite(value)) return lo; + return Math.min(hi, Math.max(lo, value)); +} + +/** True when `image` has pixels to draw (a not-yet-loaded Image has none). */ +export function imageReady(image) { + if (!image) return false; + const width = Number(image.naturalWidth ?? image.width ?? 0); + const height = Number(image.naturalHeight ?? image.height ?? 0); + // `complete` is the browser's own answer; a stub in a test omits it, so only an + // explicit `false` blocks the draw. + return width > 0 && height > 0 && image.complete !== false; +} + +/** The bitmap's width / height. 1 when it is not loaded yet. */ +export function imageAspect(image) { + const width = toPositive(image?.naturalWidth ?? image?.width, 1); + const height = toPositive(image?.naturalHeight ?? image?.height, 1); + return width / height; +} + +/** + * The rectangle a stamp occupies on screen, in pixels, with rotation left out + * (the caller rotates about the returned centre). + * + * `item.x` / `item.y` are the centre as a fraction of the viewport, `item.w` is + * the width in pixels at 1x, and the height follows the bitmap's aspect ratio, + * so a wide crop and a tall crop are both placed by their width alone. + * + * @param {object} item a stamp: `{ x, y, w, ... }` + * @param {object} [viewport] `{ width, height, scale }` of the target surface + * @param {number} [aspect] bitmap width / height + * @returns {{x: number, y: number, w: number, h: number, cx: number, cy: number}} + */ +export function stampRect(item, { width = 0, height = 0, scale = 1 } = {}, aspect = 1) { + const w = toPositive(item?.w, DEFAULT_STAMP_WIDTH) * positiveScale(scale); + const h = w / toPositive(aspect, 1); + const cx = toFinite(item?.x, 0.5) * toNonNegative(width, 0); + const cy = toFinite(item?.y, 0.5) * toNonNegative(height, 0); + return { x: cx - w / 2, y: cy - h / 2, w, h, cx, cy }; +} + +/** Turn two corner points into a rectangle with a positive width and height. */ +export function normalizeRect(a, b) { + const ax = toFinite(a?.x, 0); + const ay = toFinite(a?.y, 0); + const bx = toFinite(b?.x, 0); + const by = toFinite(b?.y, 0); + return { x: Math.min(ax, bx), y: Math.min(ay, by), w: Math.abs(bx - ax), h: Math.abs(by - ay) }; +} + +/** + * Map a marquee (drawn in screen pixels over the fitted sheet) to a pixel crop of + * the source bitmap. + * + * `sheetRect` is where the sheet is currently displayed, so the marquee becomes a + * fraction of the sheet and then a fraction of the bitmap - the sheet's own pixel + * size comes from `natural`, never from a hard-coded number. The crop is clamped + * to the bitmap and is at least 1x1, so it can always be drawn. + * + * @param {{x: number, y: number, w: number, h: number}} marquee screen pixels + * @param {{x: number, y: number, w: number, h: number}} sheetRect screen pixels + * @param {object} natural the sheet image (or any `{ naturalWidth, naturalHeight }`) + * @returns {{sx: number, sy: number, sw: number, sh: number}} + */ +export function cropFromMarquee(marquee, sheetRect, natural) { + const nw = Math.max(1, Math.round(toPositive(natural?.naturalWidth, 1))); + const nh = Math.max(1, Math.round(toPositive(natural?.naturalHeight, 1))); + const rect = { + x: toFinite(marquee?.x, 0), + y: toFinite(marquee?.y, 0), + w: toNonNegative(marquee?.w, 0), + h: toNonNegative(marquee?.h, 0), + }; + const displayW = toNonNegative(sheetRect?.w, 0); + const displayH = toNonNegative(sheetRect?.h, 0); + + const left = displayW > 0 ? clamp((rect.x - sheetRect.x) / displayW, 0, 1) : 0; + const right = displayW > 0 ? clamp((rect.x + rect.w - sheetRect.x) / displayW, 0, 1) : 0; + const top = displayH > 0 ? clamp((rect.y - sheetRect.y) / displayH, 0, 1) : 0; + const bottom = displayH > 0 ? clamp((rect.y + rect.h - sheetRect.y) / displayH, 0, 1) : 0; + + const sx = clamp(Math.round(left * nw), 0, nw - 1); + const sy = clamp(Math.round(top * nh), 0, nh - 1); + return { + sx, + sy, + sw: clamp(Math.round((right - left) * nw), 1, nw - sx), + sh: clamp(Math.round((bottom - top) * nh), 1, nh - sy), + }; +} + +/** + * Fit a bitmap inside a box, keeping its aspect ratio and centring it. The picker + * uses this to show a whole sheet - however large - at a size the user can drag + * over. + * + * @param {object} natural the sheet image + * @param {{width: number, height: number}} box the available area, in pixels + * @param {object} [options] + * @param {number} [options.padding=0] a margin to keep inside the box + * @returns {{x: number, y: number, w: number, h: number}} + */ +export function fitSheet(natural, box, { padding = 0 } = {}) { + const nw = toPositive(natural?.naturalWidth ?? natural?.width, 1); + const nh = toPositive(natural?.naturalHeight ?? natural?.height, 1); + const pad = toNonNegative(padding, 0); + const availW = Math.max(0, toNonNegative(box?.width, 0) - pad * 2); + const availH = Math.max(0, toNonNegative(box?.height, 0) - pad * 2); + const scale = Math.min(availW / nw, availH / nh); + const w = nw * scale; + const h = nh * scale; + return { x: pad + (availW - w) / 2, y: pad + (availH - h) / 2, w, h }; +} + +/** + * Paint every stamp onto a 2D context. + * + * The same function runs over the live viewport overlay (scale 1) and into the + * exported PNG (scale = output pixels / CSS pixels), which is what keeps the + * preview and the file in step. A stamp whose sheet has not loaded yet is simply + * skipped, so the rest of the picture is never held up by one image. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Array<object>} items the stamps + * @param {Map<string, CanvasImageSource>} images loaded sheets, by name + * @param {object} [options] + * @param {number} [options.width=0] + * @param {number} [options.height=0] + * @param {number} [options.scale=1] + */ +export function drawGion(ctx, items, images, { width = 0, height = 0, scale = 1 } = {}) { + if (!ctx || !Array.isArray(items)) return; + const viewport = { width, height, scale: positiveScale(scale) }; + for (const item of items) { + const image = images?.get?.(item?.sheet); + if (!imageReady(image)) continue; + const rect = stampRect(item, viewport, imageAspect(image)); + if (!(rect.w > 0) || !(rect.h > 0)) continue; + const rot = toFinite(item?.rot, 0); + ctx.save(); + ctx.translate(rect.cx, rect.cy); + if (rot !== 0) ctx.rotate((rot * Math.PI) / 180); + // The flip is applied before the draw so the same source rect feeds both. + if (item?.flip) ctx.scale(-1, 1); + ctx.drawImage(image, item.sx, item.sy, item.sw, item.sh, -rect.w / 2, -rect.h / 2, rect.w, rect.h); + ctx.restore(); + } +} + +/* ----------------------------------------------------------------- numbers */ + +function toFinite(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function toPositive(value, fallback) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function toNonNegative(value, fallback) { + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +function positiveScale(value) { + return toPositive(value, 1); +} diff --git a/public/bluebey-studio/src/glbExport.js b/public/bluebey-studio/src/glbExport.js new file mode 100644 index 0000000..6e3e997 --- /dev/null +++ b/public/bluebey-studio/src/glbExport.js @@ -0,0 +1,148 @@ +import * as THREE from 'three'; +import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js'; + +/** + * Hand the posed character back as a 3D file. + * + * WHY: a PNG or a WebM only travels as pixels. Being able to take the pose, the + * expression and the props into Blender (or embed them elsewhere as a model) + * turns the studio into a front end for the character instead of a picture + * maker, and it costs nothing: what is on screen already *is* a glTF scene, so + * the only job here is to hand the live objects to three's exporter with the + * right options and a Blob around the result. + * + * The caller passes exactly what it wants in the file - the character root and + * any props - so no lights, helpers or ground ever enter it. The inverted-hull + * outlines are the one wrinkle: they are visible (they are part of the look) + * but they are duplicates of the body geometry, so exporting them would double + * the file and leave a black shell around the character. `needsTemporaryHide` + * decides what to hide for the duration, and `exportGLB` restores it after. + */ + +const GLTF_BINARY_TYPE = 'model/gltf-binary'; +const GLTF_JSON_TYPE = 'model/gltf+json'; +/** The largest texture a GPU can be assumed to handle everywhere. */ +const MAX_TEXTURE_SIZE = 4096; + +/** + * True for objects the exporter must skip: the inverted-hull outlines (named + * `...:outline`), anything the app tagged as a helper or as excluded from the + * export, and anything already hidden. The name check is the important one: an + * outline is visible on screen, so `onlyVisible` alone would not drop it. + * + * @param {THREE.Object3D} object + * @returns {boolean} + */ +export function needsTemporaryHide(object) { + if (!object) return false; + if (typeof object.name === 'string' && object.name.endsWith(':outline')) return true; + if (object.userData?.isHelper === true) return true; + if (object.userData?.excludeFromExport === true) return true; + return object.visible === false; +} + +/** Triangles in a geometry, ignoring draw ranges (the export does too). */ +function triangleCount(geometry) { + if (!geometry) return 0; + const index = geometry.index; + const count = index ? index.count : geometry.attributes?.position?.count ?? 0; + return Math.floor(count / 3); +} + +/** + * Count what `exportGLB` will write, for the summary the app shows first. + * + * Walks the same pruned tree the exporter sees, so hiding an outline or a + * helper is reflected in the numbers. A `SkinnedMesh` is also a `Mesh`, so it + * counts in both `meshes` and `skinnedMeshes`; a material shared by several + * meshes counts once, and every texture a counted material refers to counts + * once. + * + * @param {THREE.Object3D[]} objects + * @returns {{ meshes: number, skinnedMeshes: number, materials: number, textures: number, triangles: number }} + */ +export function describeScene(objects) { + const materials = new Set(); + const textures = new Set(); + let meshes = 0; + let skinnedMeshes = 0; + let triangles = 0; + + const visit = (object) => { + if (!object || needsTemporaryHide(object)) return; + if (object.isMesh) { + meshes += 1; + if (object.isSkinnedMesh) skinnedMeshes += 1; + const list = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of list) { + if (!material) continue; + materials.add(material); + for (const value of Object.values(material)) { + if (value && value.isTexture) textures.add(value); + } + } + triangles += triangleCount(object.geometry); + } + for (const child of object.children ?? []) visit(child); + }; + + for (const root of Array.isArray(objects) ? objects : []) visit(root); + return { meshes, skinnedMeshes, materials: materials.size, textures: textures.size, triangles }; +} + +/** + * Hide every object the file must not contain, remembering what to restore. + * Stops at the first excluded ancestor: the exporter skips a hidden subtree, so + * there is no need to walk inside one. + */ +function hideExcluded(object, hidden) { + if (needsTemporaryHide(object)) { + hidden.push({ object, visible: object.visible }); + object.visible = false; + return; + } + for (const child of object.children ?? []) hideExcluded(child, hidden); +} + +/** + * Export `objects` as a GLB (or a `.gltf` JSON) Blob. + * + * @param {THREE.Object3D[]} objects the character root and any props + * @param {{ binary?: boolean, name?: string }} [options] + * @returns {Promise<Blob>} `model/gltf-binary`, or `model/gltf+json` when not binary + */ +export async function exportGLB(objects, { binary = true, name = 'bluebey' } = {}) { + const roots = (Array.isArray(objects) ? objects : []).filter(Boolean); + if (roots.length === 0) { + throw new Error('exportGLB: objects is empty, there is nothing to export'); + } + if (describeScene(roots).meshes === 0) { + throw new Error('exportGLB: objects contain no visible meshes to export'); + } + + const hidden = []; + try { + for (const root of roots) hideExcluded(root, hidden); + + // The exporter names a bare array of objects "AuxScene". Wrapping them in a + // named Scene keeps the caller's objects where they are (we push straight + // into `children`, exactly as the exporter does) and gives the file a real + // scene name. + const scene = new THREE.Scene(); + scene.name = name; + for (const root of roots) scene.children.push(root); + + const exporter = new GLTFExporter(); + const result = await exporter.parseAsync(scene, { + binary, + onlyVisible: true, + truncateDrawRange: false, + maxTextureSize: MAX_TEXTURE_SIZE, + }); + + if (binary) return new Blob([result], { type: GLTF_BINARY_TYPE }); + return new Blob([JSON.stringify(result)], { type: GLTF_JSON_TYPE }); + } finally { + for (const { object, visible } of hidden) object.visible = visible; + } +} diff --git a/public/bluebey-studio/src/handDrawn.js b/public/bluebey-studio/src/handDrawn.js new file mode 100644 index 0000000..e7fbce5 --- /dev/null +++ b/public/bluebey-studio/src/handDrawn.js @@ -0,0 +1,391 @@ +import { contoursToPathData } from './trace.js'; + +/** + * Hand-drawn distortion for traced contours. + * + * WHY: `trace.js` recovers the silhouette of the mascot from a rendered alpha + * mask. The result is geometrically faithful but *mechanically* smooth: it reads + * as the outline of a printed sticker, not as a pen stroke. This module nudges + * the traced points along a smooth, seeded wobble so the very same silhouette + * looks inked by hand, without changing its point count or where it sits. + * + * The displacement is value noise interpolated along the contour, so neighbouring + * points move by nearly the same amount and the outline stays a wobbly *line* + * rather than pixel jitter. Everything is seeded (never `Math.random`), so a + * build is reproducible, and the module is pure: it never touches the DOM and + * its only import is the path-data helper in `trace.js`, which keeps the output + * format identical to the un-roughened export. + */ + +/** Lattice cells in the non-wrapping noise table (the pattern repeats after this). */ +const NOISE_PERIOD = 4096; + +const EPSILON = 1e-9; + +/** + * mulberry32: a tiny, fast 32-bit generator. Good enough for visual noise and, + * crucially, fully reproducible; the seed is the only source of variation. + * + * @param {number} seed + * @returns {() => number} values in [0, 1) + */ +function mulberry32(seed) { + let a = seed >>> 0; + return function next() { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Hermite ramp: 0 at t=0, 1 at t=1, flat at both ends (C1 continuity). */ +function smoothstep(t) { + return t * t * (3 - 2 * t); +} + +/** + * A circular table of random values in -1..1. `count` is the number of cells in + * one lap; sampling wraps at `count`, which is what lets a closed stroke's + * wobble meet itself exactly at the seam. + * + * @param {number} seed + * @param {number} count + * @returns {Float64Array} + */ +function buildNoiseTable(seed, count) { + const rng = mulberry32(seed); + const table = new Float64Array(count); + for (let i = 0; i < count; i++) table[i] = rng() * 2 - 1; + return table; +} + +/** + * Smoothly interpolate the table at `t`, wrapping around its ends. `smoothstep` + * makes the value continuous and its slope continuous at every cell boundary, so + * the wobble has no visible kinks. + */ +function sampleTable(table, t) { + const len = table.length; + const base = Math.floor(t); + const f = t - base; + const i0 = ((base % len) + len) % len; + const i1 = (i0 + 1) % len; + const a = table[i0]; + const b = table[i1]; + return a + (b - a) * smoothstep(f); +} + +/** + * One-dimensional value noise, exposed mainly so tests can pin its behaviour. + * The returned function is continuous, roughly in -1..1, deterministic for a + * given seed, and returns 0 for non-finite input. + * + * @param {number} [seed=1] + * @returns {(t: number) => number} + */ +export function makeNoise(seed = 1) { + const table = buildNoiseTable(seed, NOISE_PERIOD); + return function noise(t) { + if (!Number.isFinite(t)) return 0; + return sampleTable(table, t); + }; +} + +/** Euclidean distance between two points. */ +function distance(a, b) { + return Math.hypot(b.x - a.x, b.y - a.y); +} + +/** Cumulative arc length of every point, measured from the first. */ +function arcPositions(points) { + const pos = new Float64Array(points.length); + for (let i = 1; i < points.length; i++) { + pos[i] = pos[i - 1] + distance(points[i - 1], points[i]); + } + return pos; +} + +/** True when a closed contour repeats its first point at the end. */ +function hasClosingDuplicate(points) { + const first = points[0]; + const last = points[points.length - 1]; + return Math.abs(first.x - last.x) <= EPSILON && Math.abs(first.y - last.y) <= EPSILON; +} + +/** + * Neighbour index for each point, honouring the wrap of a closed contour. Open + * ends get -1, which makes the tangent fall back to a one-sided difference. + */ +function neighborIndices(count, closed, duplicate) { + const prev = new Int32Array(count); + const next = new Int32Array(count); + if (!closed) { + for (let i = 0; i < count; i++) { + prev[i] = i > 0 ? i - 1 : -1; + next[i] = i < count - 1 ? i + 1 : -1; + } + return { prev, next }; + } + // A repeated closing point is a copy of point 0, so the ring has one fewer + // distinct vertex and the last index borrows point 0's two neighbours, which + // is what makes its wobble identical to the first point's. + const ring = duplicate ? count - 1 : count; + const firstPrev = (ring - 1) % ring; + const firstNext = ring > 1 ? 1 : 0; + for (let i = 0; i < count; i++) { + if (duplicate && i === count - 1) { + prev[i] = firstPrev; + next[i] = firstNext; + } else { + prev[i] = (i - 1 + ring) % ring; + next[i] = (i + 1) % ring; + } + } + return { prev, next }; +} + +/** + * Unit tangent at `i`, measured from the point before to the point after so the + * wobble follows the stroke instead of the sampling. Returns null for a + * degenerate point, where there is no direction to displace along. + */ +function tangentAt(points, i, prev, next) { + const before = prev[i] >= 0 ? points[prev[i]] : points[i]; + const after = next[i] >= 0 ? points[next[i]] : points[i]; + const dx = after.x - before.x; + const dy = after.y - before.y; + const len = Math.hypot(dx, dy); + if (!(len > EPSILON)) return null; + return { x: dx / len, y: dy / len }; +} + +/** + * Fade the wobble to zero at both ends of an open stroke, over `ramp` units. + * Without this the ends would fly off the traced geometry; a smooth ramp keeps + * the stroke anchored while still looking freehand. + */ +function edgeWindow(s, length, ramp) { + if (!(ramp > 0)) return 1; + const head = Math.min(1, s / ramp); + const tail = Math.min(1, (length - s) / ramp); + return smoothstep(head) * smoothstep(tail); +} + +/** Derive a distinct, deterministic seed for each extra pass. */ +function mixSeed(seed, pass) { + return (seed + pass * 0x9e3779b1) >>> 0; +} + +/** Apply one wobble pass. `roughenPolyline` owns the input copy and the passes. */ +function roughenOnce(points, { amount, seed, closed, scale }) { + const count = points.length; + const copy = () => points.map((p) => ({ x: p.x, y: p.y })); + if (count < 2 || !(amount > 0) || !(scale > 0)) return copy(); + + const duplicate = closed ? hasClosingDuplicate(points) : false; + const pos = arcPositions(points); + const length = pos[count - 1]; + let perimeter = length; + if (closed) perimeter += distance(points[count - 1], points[0]); + + const { prev, next } = neighborIndices(count, closed, duplicate); + + // A closed stroke samples a circular table with a whole number of cells per + // lap, so the last point lands on the first cell and the seam closes. An open + // stroke samples plain (non-wrapping) noise and fades it out near the ends. + let table; + let cells = 0; + if (closed) { + if (!(perimeter > 0)) return copy(); + cells = Math.max(2, Math.round(perimeter / scale)); + table = buildNoiseTable(seed, cells); + } else { + table = buildNoiseTable(seed, NOISE_PERIOD); + } + const ramp = Math.min(scale, length * 0.25); + + const out = new Array(count); + for (let i = 0; i < count; i++) { + const tangent = tangentAt(points, i, prev, next); + const p = points[i]; + if (!tangent) { + out[i] = { x: p.x, y: p.y }; + continue; + } + const t = closed ? (pos[i] / perimeter) * cells : pos[i] / scale; + let weight = sampleTable(table, t); + if (!closed) weight *= edgeWindow(pos[i], length, ramp); + const shift = amount * weight; + if (shift === 0) { + // Keep the original exactly (also avoids turning -0 into 0). + out[i] = { x: p.x, y: p.y }; + continue; + } + // Displace perpendicular to the tangent, i.e. along the local pen normal. + out[i] = { x: p.x - tangent.y * shift, y: p.y + tangent.x * shift }; + } + if (closed && duplicate) { + // Belt and braces: pin the explicit seam shut after any rounding. + out[count - 1] = { x: out[0].x, y: out[0].y }; + } + return out; +} + +/** + * Displace every point of a polyline perpendicular to its local direction by + * smooth noise. The input is never mutated and the point count never changes. + * + * Open polylines keep their first and last point exactly; closed ones wrap, so + * the wobble is continuous across the seam. `amount` is the peak displacement in + * the same units as the points, and `scale` is how much arc length one wobble + * spans (larger = lazier, longer wobble). With `passes > 1` the displacement is + * re-noised a few times at a share of `amount`, so the peak stays within + * `amount` however many passes are used. + * + * @param {Array<{x: number, y: number}>} points + * @param {object} [options] + * @param {number} [options.amount=2] peak displacement, in point units + * @param {number} [options.seed=1] deterministic seed + * @param {boolean} [options.closed=false] treat the polyline as a ring + * @param {number} [options.scale=40] arc length covered by one wobble + * @param {number} [options.passes=1] number of noise layers + * @returns {Array<{x: number, y: number}>} a new array of new points + */ +export function roughenPolyline(points, options = {}) { + const list = Array.isArray(points) ? points : []; + const amount = options.amount ?? 2; + const seed = options.seed ?? 1; + const closed = options.closed ?? false; + const scale = options.scale ?? 40; + const passes = Math.max(1, Math.floor(options.passes ?? 1)); + + let current = list.map((p) => ({ x: p.x, y: p.y })); + if (current.length < 2 || amount === 0) return current; + for (let pass = 0; pass < passes; pass++) { + current = roughenOnce(current, { + amount: amount / passes, + seed: mixSeed(seed, pass), + closed, + scale, + }); + } + return current; +} + +/** + * Roughen a list of contours, with the closed/open choice per contour. Following + * `trace.js`'s convention, a contour is assumed to be a closed ring unless the + * caller says otherwise: pass `options.closed` as a boolean for all of them or + * as an array of flags indexed like `contours`. + * + * @param {Array<Array<{x: number, y: number}>>} contours + * @param {object} [options] see `roughenPolyline`, plus `closed` as an array + * @returns {Array<Array<{x: number, y: number}>>} + */ +export function roughenContours(contours, options = {}) { + const list = Array.isArray(contours) ? contours : []; + const closedOption = options.closed; + const out = []; + for (let i = 0; i < list.length; i++) { + const closed = Array.isArray(closedOption) ? closedOption[i] ?? true : closedOption ?? true; + out.push(roughenPolyline(list[i], { ...options, closed })); + } + return out; +} + +/** + * Roughen a list of contours and return their SVG `d` attribute, using exactly + * the format of `contoursToPathData`: one `M … L … Z` subpath per contour, + * coordinates rounded to `options.round` decimal places (default 2, the same + * meaning as that function's `decimals` argument). + * + * @param {Array<Array<{x: number, y: number}>>} contours + * @param {object} [options] roughening options, plus `round` and `mapPoint` + * @param {number} [options.round=2] decimal places in the output + * @param {(x: number, y: number) => [number, number]} [options.mapPoint] + * @returns {string} + */ +export function handDrawnPathData(contours, options = {}) { + const roughened = roughenContours(contours, options); + const mapPoint = options.mapPoint ?? ((x, y) => [x, y]); + return contoursToPathData(roughened, mapPoint, options.round ?? 2); +} + +/** + * Offset a stroke to both sides by a width that breathes slightly along its + * length, giving the `[left, right]` polylines a pen stroke can be filled + * between. Both sides keep the point count and order of `points`. + * + * The width only varies (it never reaches zero), so the two sides stay well + * defined; `variation` is the fraction of `width` the wobble may add or remove. + * + * @param {Array<{x: number, y: number}>} points + * @param {object} [options] + * @param {number} [options.width=3] full stroke width + * @param {number} [options.seed=1] + * @param {boolean} [options.closed=false] + * @param {number} [options.variation=0.35] relative width wobble + * @param {number} [options.scale=40] arc length covered by one width wobble + * @returns {[Array<{x: number, y: number}>, Array<{x: number, y: number}>]} + */ +export function taperStroke(points, options = {}) { + const list = Array.isArray(points) ? points : []; + const width = options.width ?? 3; + const seed = options.seed ?? 1; + const closed = options.closed ?? false; + const variation = options.variation ?? 0.35; + const scale = options.scale ?? 40; + + const count = list.length; + const left = new Array(count); + const right = new Array(count); + if (count === 0) return [left, right]; + if (count === 1) { + left[0] = { x: list[0].x, y: list[0].y }; + right[0] = { x: list[0].x, y: list[0].y }; + return [left, right]; + } + + const duplicate = closed ? hasClosingDuplicate(list) : false; + const pos = arcPositions(list); + const length = pos[count - 1]; + let perimeter = length; + if (closed) perimeter += distance(list[count - 1], list[0]); + + const { prev, next } = neighborIndices(count, closed, duplicate); + + let table; + let cells = 0; + if (closed && perimeter > 0 && scale > 0) { + cells = Math.max(2, Math.round(perimeter / scale)); + table = buildNoiseTable(seed, cells); + } else { + table = buildNoiseTable(seed, NOISE_PERIOD); + } + const span = scale > 0 ? scale : 1; + const halfWidth = Math.abs(width) / 2; + + for (let i = 0; i < count; i++) { + const tangent = tangentAt(list, i, prev, next); + const p = list[i]; + if (!tangent) { + left[i] = { x: p.x, y: p.y }; + right[i] = { x: p.x, y: p.y }; + continue; + } + const t = closed && cells > 0 ? (pos[i] / perimeter) * cells : pos[i] / span; + // Clamped well above zero so both sides keep a usable offset even when the + // caller asks for a large `variation`. + const factor = Math.max(0.05, 1 + variation * sampleTable(table, t)); + const w = halfWidth * factor; + left[i] = { x: p.x - tangent.y * w, y: p.y + tangent.x * w }; + right[i] = { x: p.x + tangent.y * w, y: p.y - tangent.x * w }; + } + if (closed && duplicate) { + left[count - 1] = { x: left[0].x, y: left[0].y }; + right[count - 1] = { x: right[0].x, y: right[0].y }; + } + return [left, right]; +} diff --git a/public/bluebey-studio/src/history.js b/public/bluebey-studio/src/history.js new file mode 100644 index 0000000..d988b50 --- /dev/null +++ b/public/bluebey-studio/src/history.js @@ -0,0 +1,105 @@ +/** + * Undo / redo for the whole studio state. + * + * The studio is a big pile of sliders, and before this every experiment was + * one-way: nudging the wrong slider meant dialling the old value back by hand. + * A history of whole-state snapshots is the simplest thing that can possibly + * work here, because the state is already the single source of truth and every + * control funnels through `applyState`. + * + * Two details matter for it to feel right rather than merely correct: + * + * - Dragging a slider fires an event per pixel, which would bury the history in + * hundreds of near-identical entries. Entries therefore carry a label, and a + * new entry with the *same* label within `coalesceMs` REPLACES the previous + * one instead of stacking on top of it. So a whole drag becomes one step. + * + * - `view.backgroundImage` can be a multi-megabyte data URL, and `state` also + * holds the caption text. The snapshots copy objects by hand rather than via + * `JSON.parse(JSON.stringify(...))`, because assigning a string in JavaScript + * shares it instead of duplicating it - so a hundred snapshots of a heavy + * state stay cheap. + */ + +const SHALLOW_TYPES = new Set(['string', 'number', 'boolean', 'undefined']); + +/** Deep copy that shares string data (and handles the odd null/array). */ +function copy(value) { + if (value === null || SHALLOW_TYPES.has(typeof value)) return value; + if (Array.isArray(value)) return value.map(copy); + if (typeof value === 'object') { + const out = {}; + for (const [key, inner] of Object.entries(value)) out[key] = copy(inner); + return out; + } + return value; // functions, symbols: not part of the saved state +} + +export class History { + constructor({ limit = 120, coalesceMs = 700, onChange = null } = {}) { + this.limit = Math.max(2, limit); + this.coalesceMs = coalesceMs; + this.onChange = onChange; + /** @type {{ state: object, label: string, at: number }[]} */ + this.entries = []; + this.index = -1; + } + + /** Forget everything and start from `state` (call after load/reset). */ + reset(state, label = 'start') { + this.entries = [{ state: copy(state), label, at: Date.now() }]; + this.index = 0; + this.onChange?.(this); + } + + get canUndo() { return this.index > 0; } + get canRedo() { return this.index >= 0 && this.index < this.entries.length - 1; } + + /** Label of the step undo would jump to, for the button tooltip. */ + get undoLabel() { return this.canUndo ? this.entries[this.index].label : null; } + get redoLabel() { return this.canRedo ? this.entries[this.index + 1].label : null; } + + /** + * Record the state *after* a change. Repeating the same label in quick + * succession (a slider drag) keeps a single entry that follows the value. + */ + push(state, label = '変更') { + const now = Date.now(); + const top = this.entries[this.index]; + const sameDrag = top + && top.label === label + && now - top.at <= this.coalesceMs + && this.index === this.entries.length - 1; + + if (sameDrag) { + this.entries[this.index] = { state: copy(state), label, at: now }; + } else { + this.entries.length = this.index + 1; + this.entries.push({ state: copy(state), label, at: now }); + if (this.entries.length > this.limit) this.entries.shift(); + this.index = this.entries.length - 1; + } + this.onChange?.(this); + return this; + } + + /** The previous snapshot, or `null` when there is nothing to go back to. */ + undo() { + if (!this.canUndo) return null; + this.index -= 1; + this.onChange?.(this); + return copy(this.entries[this.index].state); + } + + redo() { + if (!this.canRedo) return null; + this.index += 1; + this.onChange?.(this); + return copy(this.entries[this.index].state); + } + + /** A plain description of where we are, for tests and debug output. */ + describe() { + return this.entries.map((entry, i) => `${i === this.index ? '*' : ' '}${entry.label}`).join(' | '); + } +} diff --git a/public/bluebey-studio/src/look.js b/public/bluebey-studio/src/look.js new file mode 100644 index 0000000..f68b602 --- /dev/null +++ b/public/bluebey-studio/src/look.js @@ -0,0 +1,180 @@ +import * as THREE from 'three'; +import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; + +/** + * The "look" of the scene, as opposed to the character's shape: body colours, + * mirroring, shadows and the lighting environment. + * + * These all live here rather than in `main.js` because they are a *policy* about + * how the model should be presented, and they need to be re-applied as a group + * whenever any of them changes. `main.js` only has to call `apply()`. + * + * Two things are worth knowing: + * + * - Colours are written onto the model's ORIGINAL materials. The flat/toon + * style caches its own materials derived from them, so `styles.refreshColors()` + * has to be called afterwards or the toon shading keeps the old colour. + * + * - Shadow softness is `LightShadow.radius` with a PCF soft shadow map, plus a + * hand-drawn contact blob for the "ground shadow only" mode. (VSM was tried + * for its wider blur and cut the shadow off in a straight line where it met + * the feet - see the note in `apply`.) + */ + +export const ENVIRONMENTS = [ + { value: 'gradient', label: 'スタジオ(明るい)' }, + { value: 'room', label: '室内(自然な反射)' }, + { value: 'none', label: 'なし(のっぺり)' }, +]; + +/** Material name -> which entry of `render.colors` it takes. */ +const PART_COLORS = { + blb: 'body', + Hand: 'accent', + Foots: 'feet', + Nose: 'nose', + Leaf: 'leaf', + Vein: 'vein', +}; + +const clamp01 = (value) => Math.min(1, Math.max(0, Number(value) || 0)); + +/** + * A soft blob that sits under the feet: the shadow a toy would cast on a table. + * It is a plain plane with a radial gradient, so it is cheap and it works in the + * line-art styles too, where the real shadow map is switched off. + */ +function makeContactShadow(size) { + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 128; + const ctx = canvas.getContext('2d'); + const gradient = ctx.createRadialGradient(64, 64, 4, 64, 64, 62); + gradient.addColorStop(0, 'rgba(0,0,0,0.55)'); + gradient.addColorStop(0.55, 'rgba(0,0,0,0.28)'); + gradient.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 128, 128); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + depthWrite: false, + toneMapped: false, + }); + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material); + mesh.rotation.x = -Math.PI / 2; + mesh.renderOrder = -0.5; + mesh.userData.isHelper = true; + mesh.name = 'contact-shadow'; + mesh.scale.setScalar(Math.max(1, size.x) * 1.15); + return mesh; +} + +export function createLook({ renderer, scene, styles, model, ground, key, character, gradientEnvironment }) { + const pmrem = new THREE.PMREMGenerator(renderer); + const roomEnvironment = pmrem.fromScene(new RoomEnvironment(), 0.06).texture; + + const contact = makeContactShadow(model.size); + scene.add(contact); + + const environmentFor = (name) => { + if (name === 'room') return roomEnvironment; + if (name === 'none') return null; + return gradientEnvironment ?? null; + }; + + function applyColors(colors) { + if (!colors) return; + let touched = false; + for (const mesh of model.parts.body) { + const material = styles.originals.get(mesh) ?? mesh.material; + const part = PART_COLORS[material?.name]; + if (!part || !colors[part]) continue; + if (!material.color) material.color = new THREE.Color(); + material.color.set(colors[part]); + touched = true; + } + // The toon materials are cached copies, so they need the same edit. + if (touched) styles.refreshColors?.(); + return touched; + } + + return { + contact, + + /** + * @param {object} render `state.render` + * @param {{ line?: boolean }} [context] `line` is true in the line-art + * styles, where shadows and reflections are deliberately switched off. + */ + apply(render, { line = false } = {}) { + if (!render) return; + applyColors(render.colors); + + // Mirroring by negative scale is safe: the renderer flips the winding for + // a negative determinant, and the normals go through the inverse-transpose. + character.scale.x = render.mirror ? -1 : 1; + + const wantsShadow = render.shadow !== false && !line; + const blobOnly = render.contactShadow === true; + ground.visible = wantsShadow && !blobOnly; + ground.material.opacity = clamp01(render.shadowOpacity ?? 0.22); + key.castShadow = wantsShadow && !blobOnly; + key.shadow.needsUpdate = true; + + // The blob is drawn in BOTH modes. On its own it *is* the shadow (the + // "ground shadow only" setting); with the shadow map on it fills the gap + // where the body hides its own shadow right at the feet, which otherwise + // reads as "the shadow is cut off". It is the only thing keeping the + // character looking like it is standing on the floor rather than above it. + contact.visible = wantsShadow; + contact.material.opacity = clamp01((render.shadowOpacity ?? 0.22) * (blobOnly ? 2.6 : 1.6)); + const spread = Math.max(0.2, (model.size.x * 1.15) / Math.max(0.05, render.shadowSoftness ?? 1.6)); + contact.scale.setScalar(Math.max(0.5, model.size.x * 1.35 - spread * 0.25)); + contact.position.y = 0.012; + + const softness = Number(render.shadowSoftness) || 1.6; + // PCF, always. three 0.186 removed PCFSoftShadowMap (asking for it warns and + // falls back to this anyway), and VSM needs a depth-variance bias that cut + // the shadow away in a straight line right at the feet. `radius` is what + // softens PCF. + if (renderer.shadowMap.type !== THREE.PCFShadowMap) { + renderer.shadowMap.type = THREE.PCFShadowMap; + // Every shadow-receiving material has to be recompiled for the switch. + ground.material.needsUpdate = true; + for (const mesh of model.parts.body) { + const material = mesh.material; + if (Array.isArray(material)) material.forEach((m) => { m.needsUpdate = true; }); + else if (material) material.needsUpdate = true; + } + } + // PCF's radius is in *shadow map texels*, and the map covers about 15 world + // units, so a radius of 1-2 is invisible. This scales it into something the + // eye can see without the sampling turning into noise. + if ('radius' in key.shadow) key.shadow.radius = Math.max(0.5, softness * 5); + + const environment = line ? null : environmentFor(render.environment); + if (scene.environment !== environment) scene.environment = environment; + if ('environmentIntensity' in scene) { + scene.environmentIntensity = Number(render.envIntensity ?? 1); + } + }, + + /** The colours a theme would set, as a plain `{ part: '#rrggbb' }`. */ + palette(theme) { + return { ...(theme?.colors ?? {}) }; + }, + + dispose() { + contact.geometry.dispose(); + contact.material.map?.dispose(); + contact.material.dispose(); + contact.removeFromParent(); + roomEnvironment.dispose(); + pmrem.dispose(); + }, + }; +} diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js new file mode 100644 index 0000000..a9d27f8 --- /dev/null +++ b/public/bluebey-studio/src/main.js @@ -0,0 +1,2426 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { loadModel } from './model.js'; +import { Face } from './face.js'; +import { Styles, isLineStyle } from './styles.js'; +import { Rig } from './rig.js'; +import { Animator, createRecorder, startRecording, stopRecording } from './animation.js'; +import { defaultState, applyPatch, THEMES } from './presets.js'; +import { buildPanel } from './panel.js'; +import * as exporter from './exporter.js'; +import { toast } from './ui.js'; +import { History } from './history.js'; +import { createLook } from './look.js'; +import { createClipper } from './clip.js'; +import { createBackdrop, backdropStyle } from './background.js'; +import { drawCaption, layoutCaption } from './caption.js'; +import { loadFont, captionFontStack } from './textOutlines.js'; +import { buildProp, PROP_DEFAULTS, disposeProp } from './props.js'; +import { rollAll, decodeSeed } from './gacha.js'; +import { encodeState, decodeState } from './urlState.js'; +import { createMouthFlap, levelToMouth } from './mouthFlap.js'; +import { createZip } from './zip.js'; +import { createScreenOutline, BEHIND_LABEL, SOLID_LABEL, LEAF_LABEL, NOSE_LABEL } from './outline.js'; +import { gionSheetUrl, stampRect, imageAspect, imageReady, drawGion, DEFAULT_STAMP_WIDTH } from './gion.js'; + +const DEG = Math.PI / 180; +/** The other direction: what three's Spherical reports is radians. */ +const RAD_TO_DEG = 180 / Math.PI; +const MODEL_URL = 'assets/bluebey.glb'; + +/** The caption font is only fetched when a bubble is first used. */ +const CAPTION_FONT_TTF = 'assets/fonts/bluebey-caption.ttf'; +const CAPTION_FONT_WOFF2 = 'assets/fonts/bluebey-caption.woff2'; +/** How far the eyes can swing for a look-at target, in radians. */ +const MAX_LOOK_YAW = 0.5; +const MAX_LOOK_PITCH = 0.34; + +/** Undo steps are labelled by the part of the state that changed. */ +const SCOPE_LABELS = { + all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', caption: 'セリフ', gion: '擬音', +}; + +/** + * The single-file build inlines the fonts and the backdrop library, so the same + * code reads a data URL there and a file URL in the normal build. + */ +function assetUrl(file, kind) { + const inlined = globalThis.__BLUEBEY_FONTS__ ?? {}; + if (kind === 'background') { + const backdrops = globalThis.__BLUEBEY_BACKGROUNDS__ ?? {}; + return backdrops[file] ?? `assets/backgrounds/${file}.webp`; + } + return inlined[file] ?? `assets/fonts/${file}`; +} + +/** + * Where the model comes from. The normal build fetches `assets/bluebey.glb`; + * the single-file build (tools/build-standalone.mjs) inlines the same bytes as + * base64 in `window.__BLUEBEY_MODEL__`, which also lets the page be opened + * straight from disk without any web server. + */ +function resolveModelSource() { + const injected = globalThis.__BLUEBEY_MODEL__; + if (!injected) return MODEL_URL; + if (typeof injected !== 'string') return injected; + try { + const binary = atob(injected); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; + } catch (error) { + console.error('inline model could not be decoded, falling back to the file', error); + return MODEL_URL; + } +} + +const state = defaultState(); + +const app = { + state, + model: null, + container: null, + ready: false, + needsRender: true, + /** The 擬音 stamp the panel is editing, so the viewport can outline it. */ + gionSelected: null, + /** Set by init(), used by the panel. */ + actions: {}, +}; + +/* ------------------------------------------------------------------ startup */ + +// The view-rig classes are declared below this point; deferring by a microtask +// guarantees the whole module has finished evaluating before anything is built. +queueMicrotask(() => { + init().catch((error) => { + console.error(error); + showLoadError(error); + }); +}); + +async function init() { + const canvas = document.getElementById('view'); + // #stage owns the layout; the canvas only fills it (see style.css). + const stage = document.getElementById('stage') ?? canvas; + + const renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFShadowMap; + + const scene = new THREE.Scene(); + const environment = makeEnvironment(renderer); + + const key = new THREE.DirectionalLight(0xffffff, 2.1); + key.castShadow = true; + key.shadow.mapSize.set(1536, 1536); + key.shadow.bias = -0.0008; + key.shadow.normalBias = 0.02; + const fill = new THREE.DirectionalLight(0xffffff, 0.5); + const ambient = new THREE.HemisphereLight(0xffffff, 0xd9d0f2, 0.9); + scene.add(key, key.target, fill, ambient); + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(60, 60), + new THREE.ShadowMaterial({ opacity: 0.22, transparent: true }), + ); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + scene.add(ground); + + // The camera lives in two places: `state.view` (what the panel, the saved file + // and a shared link use) and the orbit controls (what the mouse moves). Capture + // the controls' own position back into the state on every change, so the two + // never disagree - otherwise any refresh of the view yanked the camera back to + // wherever the panel had last put it. + const view = new ViewRig(canvas, () => { + view.captureInto(state.view); + app.needsRender = true; + }); + + /** + * The screen-space outline (see src/outline.js). The normal pass must not see + * the ground, the contact shadow or the gizmo, so they are excluded once those + * objects exist (see below). + */ + const outline = createScreenOutline({ + renderer, + scene, + camera: view.camera, + width: canvas.clientWidth || 1280, + height: canvas.clientHeight || 800, + }); + outline.exclude([ground]); + + /** + * The drawing buffer must follow the canvas' CSS size. Without this the + * browser stretches the default 300x150 buffer across the window and the + * whole picture looks coarse (and every exported trace is built from those + * few pixels). + * + * But it must not be *unlimited* either: on a large or high-density display + * the buffer can reach many millions of pixels, and with antialiasing, a + * shadow map and preserveDrawingBuffer on top, a weak GPU can take seconds + * per frame - which looks exactly like "nothing is showing". So the pixel + * count is capped and the ratio is lowered automatically on slow hardware. + */ + const MAX_DRAWING_PIXELS = 2_600_000; + const QUALITY_FLOOR = 0.5; + let quality = Math.min(window.devicePixelRatio || 1, 2); + let autoReductions = 0; + + function fitQuality(width, height, wanted) { + let ratio = wanted; + while (ratio > QUALITY_FLOOR && width * height * ratio * ratio > MAX_DRAWING_PIXELS) { + ratio = Math.round((ratio - 0.1) * 100) / 100; + } + return Math.max(QUALITY_FLOOR, ratio); + } + + let lastResize = 0; + let resizeBurst = 0; + + /** Declared early: the viewport can resize before the model has loaded. */ + let captionCanvas = null; + /** Set while undo/redo replays a snapshot, so it is not recorded again. */ + let suspendHistory = false; + + // ------------------------------------------------------------------- 擬音 + // `redrawCaption` runs from the very first `resizeViewport`, so the sheet cache + // and the drag handles have to exist before it does (a `const` cannot be read + // before it is evaluated). The drawing itself lives further down, next to the + // caption overlay it is layered under. + const gionImages = new Map(); // sheet name -> loaded Image + const gionLoading = new Map(); // sheet name -> in-flight Promise + const gionHandles = new Map(); // stamp id -> transparent drag div + let gionDrag = null; + /** Hands out stamp ids; a counter so removing one never reuses a live id. */ + let gionSeq = 0; + + function resizeViewport() { + const width = stage.clientWidth || window.innerWidth; + const height = stage.clientHeight || window.innerHeight; + + // Safety net: if something ever feeds the viewport size back into itself, + // stop flipping the drawing buffer on and off and say so. + const now = performance.now(); + if (now - lastResize < 60) { + resizeBurst += 1; + if (resizeBurst > 30 && resizeBurst % 30 === 0) { + console.warn('[bluebey] too many resizes in a row; ignoring them', resizeBurst); + } + if (resizeBurst > 30) return; + } else { + resizeBurst = 0; + } + lastResize = now; + + const wanted = Math.min(window.devicePixelRatio || 1, 2); + quality = fitQuality(width, height, wanted); + renderer.setPixelRatio(quality); + renderer.setSize(width, height, false); + outline.setSize(width * quality, height * quality); + view.resize(); + redrawCaption(); + app.needsRender = true; + } + resizeViewport(); + new ResizeObserver(() => resizeViewport()).observe(stage); + + // Losing the WebGL context leaves a permanently blank canvas, so say so + // instead of leaving the user staring at nothing. + canvas.addEventListener('webglcontextlost', (event) => { + event.preventDefault(); + showNotice('描画が止まりました(WebGLコンテキストを失いました)。\n' + + 'ブラウザのウィンドウを小さくするか、再読み込みしてください。'); + }); + canvas.addEventListener('webglcontextrestored', () => { + app.needsRender = true; + }); + + // -------------------------------------------------------------- the model + const model = await loadModel(resolveModelSource(), { + onProgress: (ratio) => setLoadingProgress(ratio), + }); + app.model = model; + + // The character hangs off a mirror group of its own, so "左右反転" flips the + // model without touching the pose offset (`container.position`) or the props. + const mirrorGroup = new THREE.Group(); + mirrorGroup.add(model.root); + const container = new THREE.Group(); + container.add(mirrorGroup); + scene.add(container); + app.container = container; + + // 小物 live outside the mirror group: they are scenery, not part of the body. + const propRoot = new THREE.Group(); + scene.add(propRoot); + + const halfHeight = model.size.y * 0.5; + view.frame(model.size, halfHeight); + + // shadow camera big enough for the whole character + const radius = Math.max(model.size.x, model.size.y, model.size.z) * 1.6; + const shadowCamera = key.shadow.camera; + shadowCamera.left = -radius; + shadowCamera.right = radius; + shadowCamera.top = radius; + shadowCamera.bottom = -radius; + shadowCamera.near = 0.5; + shadowCamera.far = radius * 9; + shadowCamera.updateProjectionMatrix(); + key.target.position.set(0, halfHeight, 0); + + // ------------------------------------------------------------- controllers + const styles = new Styles({ meshes: model.parts.body }); + const face = new Face({ + eyeMesh: model.parts.eyeMesh, + mouthMesh: model.parts.mouthMesh, + originals: model.originals, + }); + const animator = new Animator({ state }); + + // ------------------------------------------------------------------- look + const look = createLook({ + renderer, + scene, + styles, + model, + ground, + key, + character: mirrorGroup, + gradientEnvironment: environment, + }); + + // --------------------------------------------------------------- 見えない壁 + // A clip plane that hides part of the character, so it can look half buried + // in a wall (see src/clip.js). Applied in `refresh`, with the render settings. + const clip = createClipper({ scene, renderer, model }); + + // --------------------------------------------------------------- backdrop + const backdrop = createBackdrop({ + stage, + resolveUrl: (name) => assetUrl(name, 'background'), + onNeedsRender: () => { app.needsRender = true; }, + }); + /** The bitmap the exports paint behind the model (an Image or the camera video). */ + let backdropImage = null; + + /** + * Story-panel previews (keyed by panel id) and the counter that hands out + * those ids. + * + * WHY the previews live outside the state: a thumbnail is an image, and the + * state is exactly what a shared link carries - a data URL per panel would + * push the link past what a URL can hold. They are keyed by id and rebuilt as + * panels are added, so a restored link simply shows panels without previews. + * (Declared up here because `buildPanel` runs - and syncs - before the panel + * functions below are reached.) + */ + const storyThumbs = new Map(); + let storySeq = 0; + + // ------------------------------------------------------- caption overlay + // The bubble is drawn into its own 2D canvas laid over the WebGL one, with the + // very same routine the PNG export uses - so the preview cannot lie. + captionCanvas = document.createElement('canvas'); + captionCanvas.id = 'caption-layer'; + captionCanvas.style.cssText = + 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2'; + stage.append(captionCanvas); + + /** opentype font for outline export and text layout (lazily fetched). */ + let captionFont = null; + let captionFontPromise = null; + /** Where each bubble was last drawn, in CSS pixels (in state order). */ + const CAPTION_KEYS = ['caption', 'caption2']; + const captionBoxes = new Map(); + const captionHandles = new Map(); + /** Set while a bubble is being dragged. */ + let captionDrag = null; + + // The bubbles are draggable, but the caption canvas has to stay + // `pointer-events: none`: it covers the whole viewport, and the orbit controls + // and the bone picking live underneath it. So each bubble gets a separate + // transparent drag box, parked exactly over it by `positionCaptionHandles`. + for (const key of CAPTION_KEYS) { + const handle = document.createElement('div'); + handle.id = `caption-handle-${key}`; + handle.style.cssText = 'position:absolute;display:none;cursor:move;' + + 'touch-action:none;pointer-events:auto;z-index:3'; + handle.addEventListener('pointerdown', (event) => startCaptionDrag(event, key)); + handle.addEventListener('pointermove', moveCaptionDrag); + handle.addEventListener('pointerup', endCaptionDrag); + handle.addEventListener('pointercancel', endCaptionDrag); + handle.dataset.captionHandle = '1'; + stage.append(handle); + captionHandles.set(key, handle); + } + + /** Park each drag target over its bubble (or hide it when it is not shown). */ + function positionCaptionHandles() { + for (const key of CAPTION_KEYS) { + const handle = captionHandles.get(key); + const box = captionBoxes.get(key); + if (!handle) continue; + if (!box) { + handle.style.display = 'none'; + continue; + } + // A few pixels of slack, so the rounded corners are still easy to grab. + handle.style.display = 'block'; + handle.style.left = `${box.x - 4}px`; + handle.style.top = `${box.y - 4}px`; + handle.style.width = `${box.w + 8}px`; + handle.style.height = `${box.h + 8}px`; + } + } + + function startCaptionDrag(event, key) { + const caption = state[key]; + const box = captionBoxes.get(key); + if (caption?.enabled !== true || !box) return; + // Touching a bubble also selects it in the panel, so the sliders edit the one + // you just grabbed. + app.panel?.selectCaption?.(key); + event.preventDefault(); + event.stopPropagation(); + // Not all pointers can be captured (and a synthetic one cannot), so a + // failure here must not stop the drag from starting. + try { + captionHandles.get(key)?.setPointerCapture(event.pointerId); + } catch { + /* capture is a nicety, not a requirement */ + } + captionDrag = { + key, + pointerId: event.pointerId, + fromX: event.clientX, + fromY: event.clientY, + boxX: box.x, + boxY: box.y, + }; + } + + function moveCaptionDrag(event) { + if (!captionDrag || event.pointerId !== captionDrag.pointerId) return; + const caption = state[captionDrag.key]; + if (!caption) return; + event.preventDefault(); + const width = stage.clientWidth || 1280; + const height = stage.clientHeight || 800; + caption.x = (captionDrag.boxX + (event.clientX - captionDrag.fromX)) / width; + caption.y = (captionDrag.boxY + (event.clientY - captionDrag.fromY)) / height; + // Store back what the layout will actually use, so pushing past an edge does + // not pile up an out-of-range value that has to be undone before the bubble + // moves again. `refresh` redraws the bubble and records one undo step (the + // history coalesces a run of the same label). + const layout = layoutCaption(caption, { width, height, scale: 1, font: captionFont }); + if (width > 0) caption.x = layout.box.x / width; + if (height > 0) caption.y = layout.box.y / height; + refresh('caption'); + } + + function endCaptionDrag(event) { + if (!captionDrag || (event && event.pointerId !== captionDrag.pointerId)) return; + captionDrag = null; + // The 横位置 / 縦位置 sliders catch up when the drag ends, not every pixel. + app.actions.syncPanel?.(); + } + + async function ensureCaptionFont() { + if (captionFontPromise) return captionFontPromise; + captionFontPromise = (async () => { + try { + const face = new FontFace('M PLUS Rounded 1c', `url(${assetUrl('bluebey-caption.woff2')})`); + document.fonts.add(await face.load()); + } catch (error) { + console.warn('[bluebey] caption font face failed', error); + } + try { + captionFont = await loadFont(assetUrl('bluebey-caption.ttf')); + } catch (error) { + console.warn('[bluebey] caption outlines unavailable', error); + } + redrawCaption(); + return captionFont; + })(); + return captionFontPromise; + } + + function redrawCaption() { + if (!captionCanvas) return; + const width = stage.clientWidth || 1280; + const height = stage.clientHeight || 800; + const ratio = Math.min(2, window.devicePixelRatio || 1); + const wantW = Math.round(width * ratio); + const wantH = Math.round(height * ratio); + if (captionCanvas.width !== wantW || captionCanvas.height !== wantH) { + captionCanvas.width = wantW; + captionCanvas.height = wantH; + } + const ctx = captionCanvas.getContext('2d'); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + ctx.clearRect(0, 0, width, height); + // 擬音はセリフより背面に置く: the bubbles stay readable on top of them. + const gionItems = state.gion?.items ?? []; + for (const item of gionItems) ensureGionSheet(item.sheet); + drawGion(ctx, gionItems, gionImages, { width, height, scale: 1 }); + for (const key of CAPTION_KEYS) { + const caption = state[key]; + captionBoxes.delete(key); + if (!caption?.enabled) continue; + // The vendored font is only fetched once a bubble is actually used; when it + // arrives `ensureCaptionFont` redraws this canvas. + if (!captionFontPromise) void ensureCaptionFont(); + captionBoxes.set(key, drawCaption(ctx, caption, { width, height, scale: 1, font: captionFont })); + } + positionCaptionHandles(); + ensureGionHandles(gionItems); + positionGionHandles(gionItems); + } + + // ----------------------------------------------------------------- 擬音 + // A stamp is a crop of a sheet, drawn on the caption canvas *under* the bubbles. + // The canvas is `pointer-events: none`, so - exactly like a caption - each stamp + // gets its own transparent drag box, parked over it by `positionGionHandles`. + + /** Load a sheet once, on first use, and redraw when it arrives. */ + function ensureGionSheet(name) { + if (gionImages.has(name)) return Promise.resolve(gionImages.get(name)); + if (gionLoading.has(name)) return gionLoading.get(name); + const image = new Image(); + const promise = new Promise((resolve) => { + image.onload = () => resolve(image); + image.onerror = () => resolve(null); + }).then((loaded) => { + gionLoading.delete(name); + if (loaded) { + gionImages.set(name, loaded); + redrawCaption(); + } + return loaded; + }); + gionLoading.set(name, promise); + image.src = gionSheetUrl(name); + return promise; + } + + /** Create one drag target per stamp, and drop the ones whose stamp is gone. */ + function ensureGionHandles(items) { + const live = new Set(); + for (const item of items) { + live.add(item.id); + if (gionHandles.has(item.id)) continue; + const handle = document.createElement('div'); + handle.className = 'gion-handle'; + handle.addEventListener('pointerdown', (event) => startGionDrag(event, item.id)); + handle.addEventListener('pointermove', moveGionDrag); + handle.addEventListener('pointerup', endGionDrag); + handle.addEventListener('pointercancel', endGionDrag); + handle.dataset.gionHandle = '1'; + stage.append(handle); + gionHandles.set(item.id, handle); + } + for (const [id, handle] of gionHandles) { + if (live.has(id)) continue; + handle.remove(); + gionHandles.delete(id); + } + } + + /** Park each drag target over its stamp (or hide it while the sheet loads). */ + function positionGionHandles(items) { + const width = stage.clientWidth || 1280; + const height = stage.clientHeight || 800; + for (const item of items) { + const handle = gionHandles.get(item.id); + if (!handle) continue; + const image = gionImages.get(item.sheet); + if (!imageReady(image)) { + handle.style.display = 'none'; + continue; + } + const rect = stampRect(item, { width, height, scale: 1 }, imageAspect(image)); + handle.style.display = 'block'; + handle.style.left = `${rect.x}px`; + handle.style.top = `${rect.y}px`; + handle.style.width = `${rect.w}px`; + handle.style.height = `${rect.h}px`; + // Match the drawn stamp, which is rotated about its centre. + handle.style.transformOrigin = 'center'; + handle.style.transform = item.rot ? `rotate(${item.rot}deg)` : 'none'; + handle.classList.toggle('selected', item.id === app.gionSelected); + } + } + + function startGionDrag(event, id) { + const item = (state.gion?.items ?? []).find((entry) => entry.id === id); + if (!item) return; + // Touching a stamp also selects it in the panel, so the sliders edit the one + // you just grabbed. + app.panel?.selectGion?.(id); + event.preventDefault(); + event.stopPropagation(); + try { + gionHandles.get(id)?.setPointerCapture(event.pointerId); + } catch { + /* capture is a nicety, not a requirement */ + } + gionDrag = { + id, + pointerId: event.pointerId, + fromX: event.clientX, + fromY: event.clientY, + x: item.x ?? 0.5, + y: item.y ?? 0.5, + }; + } + + function moveGionDrag(event) { + if (!gionDrag || event.pointerId !== gionDrag.pointerId) return; + const item = (state.gion?.items ?? []).find((entry) => entry.id === gionDrag.id); + if (!item) return; + event.preventDefault(); + const width = stage.clientWidth || 1280; + const height = stage.clientHeight || 800; + item.x = clamp01(gionDrag.x + (event.clientX - gionDrag.fromX) / width); + item.y = clamp01(gionDrag.y + (event.clientY - gionDrag.fromY) / height); + refresh('gion'); + } + + function endGionDrag(event) { + if (!gionDrag || (event && event.pointerId !== gionDrag.pointerId)) return; + gionDrag = null; + app.actions.syncPanel?.(); + } + + /** Add a stamp from a picker crop, centred, and hand it to the panel. */ + function addGionStamp({ sheet, sx, sy, sw, sh }) { + gionSeq += 1; + const item = { + id: `g${gionSeq}`, + sheet, + sx, sy, sw, sh, + x: 0.5, + y: 0.5, + w: DEFAULT_STAMP_WIDTH, + rot: 0, + flip: false, + }; + state.gion = state.gion ?? { items: [] }; + state.gion.items = [...(state.gion.items ?? []), item]; + app.gionSelected = item.id; + void ensureGionSheet(sheet); + applyState({}, { scope: 'gion', sync: true }); + toast('擬音を追加しました'); + } + + // ------------------------------------------------------------------ props + let propSignature = ''; + + function applyProps() { + const items = state.props?.items ?? []; + const signature = JSON.stringify([items, state.render.colors]); + if (signature === propSignature) return; + propSignature = signature; + for (const child of [...propRoot.children]) { + propRoot.remove(child); + disposeProp(child); + } + for (const item of items) { + const def = PROP_DEFAULTS[item.kind] ?? { x: 0, y: 0, z: 0, rotY: 0, scale: 1 }; + let group; + try { + group = buildProp(item.kind, { colors: state.render.colors, scale: item.scale ?? def.scale ?? 1 }); + } catch (error) { + console.warn('[bluebey] unknown prop', item.kind, error); + continue; + } + group.position.set(item.x ?? def.x ?? 0, item.y ?? def.y ?? 0, item.z ?? def.z ?? 0); + group.rotation.y = ((item.rotY ?? def.rotY ?? 0) * DEG); + propRoot.add(group); + } + app.needsRender = true; + } + + // ------------------------------------------------------------ prop dragging + // A prop can be dragged with the mouse. The pointer picks one, then moves it in + // the plane that faces the camera, so it follows the cursor at whatever depth + // it already sits at. (The ground plane is the other obvious choice, but the + // camera sits almost level with the floor, so the floor is nearly edge-on and + // a prop dragged across it would fly off.) + // + // The listener is on `stage` in the CAPTURE phase: that runs before the canvas + // and `view`'s container, so stopping the event there keeps the orbit controls + // and the bone picking out of a prop drag. + const propRaycaster = new THREE.Raycaster(); + const propPointer = new THREE.Vector2(); + const propPlane = new THREE.Plane(); + const propPoint = new THREE.Vector3(); + const propNormal = new THREE.Vector3(); + let propDrag = null; + // The 見えない壁 uses the same machinery: `wallDrag` keeps the grabbed point's + // offset from the wall's anchor so grabbing a corner does not make it jump. + const wallDrag = { pointerId: null, offset: new THREE.Vector3() }; + + /** The pointer in normalised device coordinates, i.e. what a raycaster wants. */ + function pointerNdc(event) { + const rect = canvas.getBoundingClientRect(); + return propPointer.set( + ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1, + -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1, + ); + } + + /** The prop under the pointer as `{ index, group, point }`, or null. */ + function propAt(event) { + if (!propRoot.children.length) return null; + propRaycaster.setFromCamera(pointerNdc(event), view.camera); + const hits = propRaycaster.intersectObjects(propRoot.children, true); + if (!hits.length) return null; + // The meshes hang off the group `propRoot` holds, so walk up to that group. + let node = hits[0].object; + while (node.parent && node.parent !== propRoot) node = node.parent; + const items = state.props?.items ?? []; + const index = propRoot.children.indexOf(node); + if (index < 0 || index >= items.length) return null; + return { index, group: node, point: hits[0].point.clone() }; + } + + /** + * The point on the wall's guide under the pointer, or null. + * + * Only while the wall is on AND its guide is switched on: the guide is both + * what there is to grab and the switch that says "I am placing the wall now", + * so with it off a huge invisible plane cannot swallow every orbit drag. + */ + function wallAt(event) { + if (state.render.wall?.on !== true || state.render.wall.guide !== true) return null; + propRaycaster.setFromCamera(pointerNdc(event), view.camera); + const hits = propRaycaster.intersectObject(clip.guide, false); + return hits.length ? hits[0].point.clone() : null; + } + + function onPropPointerDown(event) { + if (event.button !== 0) return; + if (event.target?.dataset?.captionHandle) return; // a bubble drags itself + if (rig?.controls?.dragging) return; // the rotation gizmo is in charge + const hit = propAt(event); + if (hit) { + // Claim the pointer, so nothing underneath sees it. + event.stopPropagation(); + event.preventDefault(); + view.camera.getWorldDirection(propNormal); + propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), hit.point); + propDrag = { + pointerId: event.pointerId, + index: hit.index, + group: hit.group, + // Grabbing a corner rather than the middle must not make it jump. + offset: hit.group.position.clone().sub(hit.point), + }; + stage.style.cursor = 'grabbing'; + app.needsRender = true; + return; + } + + const onWall = wallAt(event); + if (!onWall) { + // Nothing to grab. Remember the press, and settle on pointer-up whether it + // was a tap (set the 見る先 target) or the start of an orbit. + if (state.lookAt?.enabled === true && !rig?.controls?.dragging) { + lookTap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + } + return; + } + // A prop wins if it is under the pointer; otherwise the wall takes it. + event.stopPropagation(); + event.preventDefault(); + view.camera.getWorldDirection(propNormal); + propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), onWall); + const wall = state.render.wall; + wallDrag.pointerId = event.pointerId; + wallDrag.offset.set(wall.x ?? 0, wall.y ?? 0, wall.z ?? 0).sub(onWall); + stage.style.cursor = 'grabbing'; + app.needsRender = true; + } + + function onPropPointerMove(event) { + if (lookTap && event.pointerId === lookTap.pointerId + && Math.hypot(event.clientX - lookTap.x, event.clientY - lookTap.y) > LOOK_TAP_SLOP) { + lookTap = null; // that was an orbit drag, not a tap + } + if (wallDrag.pointerId != null && event.pointerId === wallDrag.pointerId) { + event.stopPropagation(); + propRaycaster.setFromCamera(pointerNdc(event), view.camera); + if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return; + const next = propPoint.add(wallDrag.offset); + const wall = state.render.wall; + // The same ranges the panel's sliders offer, so a drag and a number always + // describe the same place. + wall.x = round2(clampNumber(next.x, -4, 4)); + wall.y = round2(clampNumber(next.y, -2, 4)); + wall.z = round2(clampNumber(next.z, -4, 4)); + clip.apply(wall); + app.needsRender = true; + return; + } + if (!propDrag || event.pointerId !== propDrag.pointerId) return; + event.stopPropagation(); + propRaycaster.setFromCamera(pointerNdc(event), view.camera); + if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return; + const next = propPoint.add(propDrag.offset); + const item = state.props?.items?.[propDrag.index]; + if (!item) return; + // Clamp to the ranges the panel's sliders offer, so a drag can never put a + // prop somewhere the numbers cannot describe (and never through the floor). + item.x = round2(clampNumber(next.x, -4, 4)); + item.y = round2(clampNumber(next.y, 0, 2)); + item.z = round2(clampNumber(next.z, -4, 4)); + propDrag.group.position.set(item.x, item.y, item.z); + // Keep `applyProps`' signature in step with what is on screen, so a refresh + // during the drag does not tear the group down and rebuild it from the state + // (which would re-create every geometry on every pointer move). + propSignature = JSON.stringify([state.props?.items ?? [], state.render.colors]); + app.needsRender = true; + } + + function onPropPointerUp(event) { + if (lookTap && (!event || event.pointerId === lookTap.pointerId)) { + lookTap = null; + if (event) applyLookTap(event); + return; + } + if (wallDrag.pointerId != null && (!event || event.pointerId === wallDrag.pointerId)) { + wallDrag.pointerId = null; + stage.style.cursor = ''; + history.push(state, '見えない壁'); + app.panel?.sync(); + return; + } + if (!propDrag || (event && event.pointerId !== propDrag.pointerId)) return; + propDrag = null; + stage.style.cursor = ''; + // One undo step for the whole drag, and the panel catches up with the value. + history.push(state, '小物'); + app.panel?.sync(); + } + + // A tap (a press that does not turn into a drag) in the viewport sets the + // 見る先 target, when that mode is on. Cancelled as soon as the pointer moves, + // so an orbit drag never moves the target by accident. + let lookTap = null; + const LOOK_TAP_SLOP = 5; + + stage.addEventListener('pointerdown', onPropPointerDown, { capture: true }); + window.addEventListener('pointermove', onPropPointerMove); + window.addEventListener('pointerup', onPropPointerUp); + window.addEventListener('pointercancel', onPropPointerUp); + + // ---------------------------------------------------------------- look-at + const lookScratch = new THREE.Vector3(); + const headScratch = new THREE.Vector3(); + const lookPlane = new THREE.Plane(); + const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + const lookPlaneNormal = new THREE.Vector3(); + const lookPivot = new THREE.Vector3(); + const lookHit = new THREE.Vector3(); + const lookFloor = new THREE.Vector3(); + /** Where the eyes should aim this frame, from `state.lookAt` (a world point). */ + let aimOffset = { x: 0, y: 0 }; + + /** + * The world point under a viewport tap, for the 見る先 target. + * + * The tap is projected onto the first thing it can sensibly mean: a vertical + * plane through the character that faces the camera (so a tap on the body or + * the backdrop keeps the character's own depth), or the floor, for a tap that + * lands on the ground in front. The nearer of the two wins. + */ + function lookAtPointFrom(event) { + propRaycaster.setFromCamera(pointerNdc(event), view.camera); + const ray = propRaycaster.ray; + const headY = container.position.y + model.size.y * 0.72; + lookPlaneNormal.set(ray.direction.x, 0, ray.direction.z); + if (lookPlaneNormal.lengthSq() < 1e-8) lookPlaneNormal.set(0, 0, 1); + lookPlane.setFromNormalAndCoplanarPoint( + lookPlaneNormal.normalize(), + lookPivot.set(container.position.x, headY, container.position.z), + ); + const onPlane = ray.intersectPlane(lookPlane, lookHit); + const onFloor = ray.direction.y < -1e-4 ? ray.intersectPlane(floorPlane, lookFloor) : null; + if (!onPlane) return onFloor; + if (!onFloor) return onPlane; + return onPlane.distanceToSquared(ray.origin) <= onFloor.distanceToSquared(ray.origin) ? onPlane : onFloor; + } + + /** A tap in the viewport: aim the eyes at whatever was under the pointer. */ + function applyLookTap(event) { + if (state.lookAt?.enabled !== true) return; + const point = lookAtPointFrom(event); + if (!point) return; + const cfg = state.lookAt; + // The same ranges the panel's sliders offer, so a tap and a number describe + // the same place. + cfg.x = round2(clampNumber(point.x, -6, 6)); + cfg.y = round2(clampNumber(point.y, 0, 5)); + cfg.z = round2(clampNumber(point.z, -6, 6)); + history.push(state, '見る先'); + refresh('lookAt'); + app.panel?.sync(); + } + + function applyLookAt() { + const cfg = state.lookAt; + if (!cfg?.enabled) { + // Switching the mode off leaves the character where it was: the body turn + // and the gaze are remembered, not re-derived, so nothing snaps back. + container.rotation.y = (cfg?.bodyYawDeg ?? 0) * DEG; + const frozen = cfg?.freeze; + return frozen + ? { x: clamp(frozen.x ?? 0, -1, 1), y: clamp(frozen.y ?? 0, -1, 1) } + : { x: 0, y: 0 }; + } + + container.updateMatrixWorld(true); + lookScratch.set(cfg.x ?? 0, cfg.y ?? 0, cfg.z ?? 0); + mirrorGroup.worldToLocal(lookScratch); + headScratch.set(0, model.size.y * 0.72, 0); + lookScratch.sub(headScratch); + + const flat = Math.hypot(lookScratch.x, lookScratch.z) || 1e-6; + const yaw = Math.atan2(lookScratch.x, lookScratch.z); + const pitch = Math.atan2(lookScratch.y, flat); + const amount = clamp(cfg.amount ?? 1, 0, 1); + const aim = { + x: clamp((yaw / MAX_LOOK_YAW) * amount, -1, 1), + y: clamp((pitch / MAX_LOOK_PITCH) * amount, -1, 1), + }; + + // Turning the whole body is what a person does to look behind themselves; a + // half-strength turn keeps the feet planted while the body leans round. The + // turn is *stored*, so switching 体も向ける off stops updating it and the body + // stays where it is instead of snapping back to the front. + if (cfg.turnBody) { + cfg.bodyYawDeg = round2(clamp(yaw, -MAX_LOOK_YAW * 1.6, MAX_LOOK_YAW * 1.6) * amount * 0.7 / DEG); + } + container.rotation.y = (cfg.bodyYawDeg ?? 0) * DEG; + + // Keep the gaze too, for the moment the mode is switched off. + cfg.freeze = aim; + return aim; + } + + // -------------------------------------------- history, 口パク, gacha + const history = new History({ limit: 120 }); + /** How far the mouth is open right now because of the 口パク animation. */ + let flapMouth = 0; + const mouthFlap = createMouthFlap({ + onLevel: (level) => { + flapMouth = levelToMouth(level, state.mouthFlap?.mouthGain ?? 1); + app.needsRender = true; + }, + onEnd: () => { flapMouth = 0; app.needsRender = true; }, + }); + + let rig = null; + // The gizmo always belongs to whichever camera is currently active. + view.onCameraChange = (camera, controls) => { + if (!rig) return; + rig.controls.camera = camera; + rig.orbit = controls; + }; + + rig = new Rig({ + bones: model.bones, + scene, + camera: view.camera, + domElement: canvas, + orbit: view.controls, + pickTargets: () => [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body], + onChange: () => { + capturePose(); + app.panel?.onRigChanged?.(); + app.needsRender = true; + }, + }); + + // The ground, the contact shadow and the gizmo are scenery, not character, and + // the two face plates are the *front half* of the body: their open edge would be + // picked up as a silhouette and draw a line across the face, while contributing + // nothing, since they sit exactly on the body. + // + // The outline hulls must stay out too, and that one is easy to miss: they are + // extra copies of every mesh sitting in the scene graph, and this pass swaps the + // material of every visible mesh. A hull is an expanded *back-face* shell, so + // handing it a plain front-side label material puts an expanded copy of the + // whole character in front of itself - it then paints its own label over + // everything, and every leaf edge against the body is inked as if the body were + // empty paper. That is exactly the faint dotted line along the leaves' bases. + function outlineExclusion() { + return [ + ground, + look.contact, + clip.guide, + rig.helper, + model.parts.eyeMesh, + model.parts.mouthMesh, + ...styles.outlineMeshes, + ]; + } + outline.exclude(outlineExclusion()); + // The wall is *not* excluded: it is handed to the pass as an occluder, so the + // labels behind it are culled as well. Excluded, the leaves and the nose + // buried in the wall kept their outlines, because the labels are read before + // its depth is applied. + outline.occlude([clip.occluder]); + + /** + * The parts the screen-space pass draws, with the label each one writes. + * + * The leaves are the hard case: a hull cannot outline a shell that thin. They + * are drawn with the LEAF label, which means the pass inks them where they meet + * the paper and where they fold against each other, but *not* where they run + * into the body - a line there reads as the leaf sinking into the body, and the + * original artwork has none (the leaf simply passes behind the body). + * + * The nose joins them in the line-art styles, with the NOSE label, so its ring + * against the body *is* drawn: it is the only thing that shows the nose in a + * line drawing. In the shaded styles it is left out entirely, and its hull then + * keeps only the part that pokes out of the silhouette - which is what the + * original artwork does (the nose reads by its own colour there). + * + * @param {string} style + * @returns {Array<[import('three').Object3D, number]>} + */ + function screenParts(style) { + const parts = (model.parts.leaves ?? []).map((mesh) => [mesh, LEAF_LABEL]); + if (isLineStyle(style) && model.parts.noseMesh) parts.push([model.parts.noseMesh, NOSE_LABEL]); + return parts; + } + + /** + * The label every part writes (see src/outline.js). + * + * The leaves and the nose are the outlined ones. The body's label is the one + * real choice here, and it is exposed as `render.leafBodyLine`: + * + * - `BEHIND` (the default) makes the body count as paper, so each leaf is + * outlined where it emerges from the body. In a line drawing the two are + * paper on paper, so without this the leaves and the body merge into one + * white shape with no way to tell them apart - which is the greater evil. + * - `SOLID` makes the body block that line, so a leaf simply passes behind it. + * Cleaner where it works, but the leaves lose their outline there. + * + * Everything else - the feet, the hands, the props - is *merely behind*, so it + * stays BEHIND either way: it still hides what is behind it, but a leaf lying + * across it keeps its outline. + */ + function outlineLabels(parts) { + const labels = new Map(parts); + const bodyLabel = state.render.leafBodyLine === false ? SOLID_LABEL : BEHIND_LABEL; + for (const mesh of model.parts.body ?? []) { + if (labels.has(mesh)) continue; + labels.set(mesh, model.parts.kinds?.get(mesh) === 'blb' ? bodyLabel : BEHIND_LABEL); + } + return [...labels]; + } + + /** Point both outline systems at the right parts for `method`. */ + function applyOutlineMethod(style, method = state.render.outlineMethod) { + const hybrid = method === 'screen'; + const parts = hybrid ? screenParts(style) : []; + styles.setHullHidden(parts.map(([mesh]) => mesh)); + outline.only(parts.length ? outlineLabels(parts) : null); + } + + /** + * Run `fn` with the outline pass set up for another style, then put the + * on-screen configuration back. Both the hull/screen split and which parts the + * screen pass may see are shared by the live view and every offscreen render, + * so a pass that draws another style has to say so - the SVG and the "lines + * only" PNG are ink through and through, so they always want the line-art + * arrangement (a hull cannot outline the leaves at all). + */ + async function withOutlineFor({ style, method = 'screen' }, fn) { + applyOutlineMethod(style, method); + try { + return await fn(); + } finally { + applyOutlineMethod(state.render.style); + } + } + + Object.assign(app, { + renderer, scene, camera: view.camera, view, styles, face, rig, animator, + key, ambient, ground, environment, halfHeight, outline, clip, + look, backdrop, history, mouthFlap, propRoot, mirrorGroup, container, + get captionFont() { return captionFont; }, + get backdropImage() { return backdropImage; }, + }); + + const recorder = createRecorder(canvas); + + // -------------------------------------------------------------- the panel + const panel = buildPanel(app, document.getElementById('panel')); + app.panel = panel; + + Object.assign(app.actions, { + capturePose, + applyState, + refresh, + patch: (scope, value) => applyState({ [scope]: value }, { scope }), + syncPanel: () => app.panel?.sync(), + imageCanvas: (options) => captureImage(options), + savePNG: (options = {}) => savePNG(options), + copyPNG: () => copyPNG(), + faceMapCanvas: (kind) => faceMapCanvas(kind), + saveFaceMap: (kind) => saveFaceMap(kind), + addGionStamp: (item) => addGionStamp(item), + saveSettings: () => exporter.downloadText(JSON.stringify(state, null, 2), `bluebey-settings-${exporter.timestamp()}.json`), + loadSettings: (text) => { + const data = JSON.parse(text); + applyState(data, { full: true, sync: true }); + toast('設定を読み込みました'); + }, + toggleRecording: () => toggleRecording(recorder), + resetPose: () => { + rig.reset(); + state.pose = { bones: {}, root: [0, 0, 0] }; + applyState({}, { scope: 'pose', sync: true }); + toast('ポーズをリセットしました'); + }, + setCameraPreset: (id) => setCameraPreset(id), + undo: () => restoreFromHistory('undo'), + redo: () => restoreFromHistory('redo'), + applyTheme: (id) => applyTheme(id), + rollGacha: (seed) => rollGacha(seed), + copyShareLink: () => copyShareLink(), + shareImage: (network) => shareImage(network), + toggleMouthFlap: () => toggleMouthFlap(), + storyThumbs: () => storyThumbs, + toggleCamera: (on) => toggleCamera(on), + addProp: (kind) => addProp(kind), + addStoryPanel: () => addStoryPanel(), + saveStory: (options) => saveStory(options), + loadFromHash: () => loadFromHash(), + toggleAnimation: () => { + state.anim.mode = (state.anim.mode ?? 'off') === 'off' ? 'idle' : 'off'; + app.animator.reset(); + panel.sync(); + app.needsRender = true; + }, + }); + + installKeys(); + installTopbar(); + window.addEventListener('focus', () => { app.needsRender = true; }); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) app.needsRender = true; + }); + + { + const buffer = renderer.getDrawingBufferSize(new THREE.Vector2()); + console.log( + '[bluebey] ready' + + ` canvas=${canvas.clientWidth}x${canvas.clientHeight}` + + ` buffer=${Math.round(buffer.x)}x${Math.round(buffer.y)}` + + ` quality=${quality}` + + ` dpr=${window.devicePixelRatio}` + + ` model=${model.size.toArray().map((v) => v.toFixed(2)).join(',')}` + + ` camera=${view.camera.position.toArray().map((v) => v.toFixed(2)).join(',')}`, + ); + } + window.addEventListener('resize', () => resizeViewport()); + + applyState({}, {}); + await loadFromHash(); + history.reset(state); + rig.select('master', { silent: true }); + panel.sync(); + + let last = performance.now(); + let faceSignature = ''; + let slowFrames = 0; + let framesSinceFps = 0; + let fpsAt = performance.now(); + const warmupUntil = performance.now() + 2500; + + const loop = (now) => { + const rawDelta = now - last; + const dt = Math.min(0.05, rawDelta / 1000); + last = now; + + animator.update(dt); + + const posed = animator.pose(state.pose); + if (!rig.controls.dragging) rig.applyPose(posed); + container.position.set(posed.root[0] ?? 0, posed.root[1] ?? 0, posed.root[2] ?? 0); + + const params = buildFaceParams(); + const signature = JSON.stringify(params); + if (signature !== faceSignature) { + faceSignature = signature; + face.setParams(params, faceMode()); + app.needsRender = true; + } + if (face.flush(now, 26)) app.needsRender = true; + + view.controls.update(dt); + + const animating = (state.anim.mode ?? 'off') !== 'off' + || state.anim.lookAround + || animator.blinkPhase >= 0 + || recorder?.recorder?.state === 'recording'; + // Always paint for the first couple of seconds, so a missed "needs render" + // can never leave an empty window staring back at the user. + if (app.needsRender || animating || view.controlsChanged || now < warmupUntil) { + app.needsRender = false; + + // The screen-space outline needs one extra pass *before* the scene, so it + // wraps the normal render instead of following it. `outline.render` paints + // the scene itself (ink and all), so there must be no second render after + // it - that would clear the canvas and wipe the ink straight back off. + const wantsScreen = state.render.outlineMethod === 'screen' && state.render.outline !== false; + outline.render(() => renderer.render(scene, view.camera), { + enabled: wantsScreen, + camera: view.camera, + color: state.render.outlineColor, + // The label buffer is `quality`x the CSS size, so the radius has to carry + // that factor to keep the leaf/nose line a constant width in CSS pixels - + // otherwise it halves on a HiDPI display and jumps when the app lowers + // the quality on slow frames. + radius: outlineRadiusFor(view, state, quality), + }); + view.controlsChanged = false; + framesSinceFps += 1; + + // Hardware that cannot keep up gets a smaller drawing buffer instead of a + // slideshow (a very large buffer can look like "nothing ever appears"). + if (rawDelta > 90) slowFrames += 1; + else slowFrames = 0; + if (slowFrames >= 8 && quality > QUALITY_FLOOR + 0.01 && autoReductions < 3) { + autoReductions += 1; + quality = Math.max(QUALITY_FLOOR, Math.round((quality - 0.25) * 100) / 100); + renderer.setPixelRatio(quality); + slowFrames = 0; + console.warn('[bluebey] frames are slow, lowering the drawing quality to', quality); + } + } + + if (now - fpsAt > 1000) { + const fps = Math.round((framesSinceFps * 1000) / (now - fpsAt)); + framesSinceFps = 0; + fpsAt = now; + const buffer = renderer.getDrawingBufferSize(new THREE.Vector2()); + panel.setStats?.( + `描画 ${Math.round(buffer.x)}×${Math.round(buffer.y)}(画質 ${quality.toFixed(2)}×)/約 ${fps}fps` + + (autoReductions > 0 ? ' ※重いので自動で軽くしました' : ''), + ); + } + + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + + hideLoading(); + app.ready = true; + window.__bluebeyReady = true; + window.__bluebey = { + app, + state, + set: (patch) => applyState(patch), + face: (patch) => applyState({ face: patch }), + pose: (patch) => applyState({ pose: patch }), + view: (patch) => applyState({ view: patch }), + render: (patch) => applyState({ render: patch }), + selectBone: (name) => rig.select(name), + preset: (id) => app.actions.applyFacePreset?.(id), + posePreset: (id) => app.actions.applyPosePreset?.(id), + png: (options) => captureImage(options), + }; + + /* ------------------------------------------------------------- internals */ + + /** The camera the outline pass should use (kept in step with the view rig). */ + function outlineCamera() { + return view.camera; + } + + /** The gizmo/frame loop re-reads the bones, so store them back into state. */ + function capturePose() { + const pose = rig.getPose(); + state.pose.bones = pose.bones; + } + + function buildFaceParams() { + const eyes = state.face.eyes; + const openScale = animator.eyeOpen; + const drift = animator.look; + + const eyeFor = (key) => { + const own = eyes[key]; + return { + open: clamp01(own.open * openScale), + lookX: clamp(own.lookX + drift.x + aimOffset.x, -1, 1), + lookY: clamp(own.lookY + drift.y + aimOffset.y, -1, 1), + closed: own.closed, + closedLines: own.closedLines, + irisShape: own.irisShape, + threeFlip: own.threeFlip === true, + // The tears are per eye, so one eye can cry on its own. + tear: own.tear ?? 0, + tearY: own.tearY ?? 0, + // Where this eye sits, and where its teardrop hangs, can be nudged one + // side at a time (an asymmetric face). + eyeX: own.eyeX ?? 0, + tearX: own.tearX ?? 0, + tearTilt: own.tearTilt ?? 0, + }; + }; + + // Lip-sync and a look-at target both drive the mouth and the eyes without + // being part of the saved expression. + const mouth = { ...state.face.mouth }; + if (flapMouth > 0.001) mouth.open = Math.max(mouth.open ?? 0, flapMouth); + // A talking mouth has no tongue sticking out. This follows the whole flap + // session rather than the opening: the envelope dips through zero between + // syllables, and keying it off the opening made the tongue flicker back into + // view on every closed frame. + if (mouthFlap.running) mouth.tongue = 0; + + return { + eyes: { + // Everything shared by both eyes (colours, iris scale, brows, glasses, + // ...) has to travel with the per-eye values, because the face renderer + // reads them from here. Spreading `...eyes` is also what makes the face + // redraw when one of them (e.g. `glasses`) changes: the loop compares the + // JSON of this object against the last one it drew. + ...eyes, + left: eyeFor('left'), + right: eyeFor('right'), + }, + mouth, + }; + } + + function faceMode() { + return isLineStyle(state.render.style) ? 'line' : 'paint'; + } + + function applyState(patch, { silent = false, full = false, scope = 'all', sync = false } = {}) { + if (full) { + const fresh = defaultState(); + applyPatch(fresh, patch); + for (const key of Object.keys(fresh)) state[key] = fresh[key]; + } else { + applyPatch(state, patch); + } + refresh(scope); + app.needsRender = true; + if (sync && !silent) app.panel?.sync(); + } + + /** Re-apply the parts of the state named by `scope` ('all' by default). */ + function refresh(scope = 'all') { + app.needsRender = true; + // Where the eyes aim depends on where the character is, so it is recomputed + // whenever anything that moves could change. + if (scope === 'all' || scope === 'view' || scope === 'pose' || scope === 'face' || scope === 'lookAt') { + aimOffset = applyLookAt(); + } + if (scope === 'all' || scope === 'render') { + styles.setStyle(state.render.style); + styles.setPaper(state.render.paper); + styles.setOutlineWidth(state.render.outlineWidth); + styles.setOutlineColor(state.render.outlineColor); + styles.setOutlineEnabled(state.render.outline); + // Which parts each outline method draws, and which hulls stand down for it + // (the leaves, plus the nose in a line drawing). See `applyOutlineMethod`. + applyOutlineMethod(state.render.style); + + const line = isLineStyle(state.render.style); + // Shadows, reflections, body colours and mirroring are the look module's + // job, so that they are applied together and stay consistent. + look.apply(state.render, { line }); + applyProps(); + // 見えない壁: the one setting that changes what is drawn rather than how. + clip.apply(state.render.wall); + + const azimuth = state.render.lightAzimuth * DEG; + const elevation = state.render.lightElevation * DEG; + const distance = model.size.y * 3; + key.position.set( + Math.cos(elevation) * Math.sin(azimuth) * distance, + Math.sin(elevation) * distance + halfHeight, + Math.cos(elevation) * Math.cos(azimuth) * distance, + ); + key.intensity = state.render.lightIntensity; + ambient.intensity = state.render.ambient; + renderer.toneMappingExposure = state.render.exposure; + applyBackground(); + renderer.shadowMap.needsUpdate = true; + } + + if (scope === 'all' || scope === 'view') { + applyBackdrop(); + // The renderer's *clear* depends on the background mode as well: a preset + // photo, a loaded picture and the camera all show through a transparent + // clear (see `backgroundOf`). Without this, switching the background left + // the canvas clearing to the old opaque colour and hid the layer that had + // just been set up behind it - which is why the background only appeared + // after some other change (adding a prop) re-ran this. + applyBackground(); + } + if (scope === 'all' || scope === 'view' || scope === 'caption' || scope === 'gion') redrawCaption(); + + // A style change flips the face between paint and ink (`faceMode`), so the + // 'render' scope has to rebuild the face too - otherwise the old drawing + // stays on the plates until something else (a blink) happens to dirty them. + if (scope === 'all' || scope === 'face' || scope === 'render') { + face.setSource('eyes', state.face.eyes.source); + face.setSource('mouth', state.face.mouth.source); + face.setParams(buildFaceParams(), faceMode()); + } + + if (scope === 'all' || scope === 'view') { + view.setProjection(state.view.projection); + view.apply(state.view, model.size); + view.setAutoRotate(state.view.autoRotate, state.view.autoRotateSpeed); + } + + if (scope === 'all' || scope === 'pose') { + rig.applyPose(state.pose); + const root = state.pose.root ?? [0, 0, 0]; + container.position.set(root[0] ?? 0, root[1] ?? 0, root[2] ?? 0); + } + + // Every user-driven change funnels through here, so this is the one place + // that has to remember a step for undo. Undo itself sets `suspendHistory`. + if (!suspendHistory) history.push(state, SCOPE_LABELS[scope] ?? scope); + } + + /** Paint the backdrop bitmap behind the model, for an export. */ + function backdropSource() { + if (state.view.background === 'camera') { + const video = backdrop.el?.querySelector?.('video'); + return video && video.readyState >= 2 ? video : null; + } + return backdropImage; + } + + /** + * Paint the backdrop *behind* the rendered model. + * + * WHY the scratch canvas and `destination-over`: `ctx` already holds the + * character on a transparent field (see `exporter.renderStill`), so drawing the + * photo straight onto it with the default `source-over` painted OVER the + * character and the export came out as a bare photograph. Building the scene in + * a scratch canvas first also lets the dark veil sit on top of the photo, which + * it could not do once the photo had been slipped underneath. + */ + function drawBackdropInto(ctx, width, height, scale) { + const source = backdropSource(); + if (!source) return false; + const style = backdropStyle(state.view); + if (!style.visible) return false; + const iw = source.naturalWidth || source.videoWidth || 0; + const ih = source.naturalHeight || source.videoHeight || 0; + if (!iw || !ih) return false; + + const back = document.createElement('canvas'); + back.width = width; + back.height = height; + const paint = back.getContext('2d'); + + // The same CSS the live layer uses: blur (bleeding past the edges), the fit, + // the mirror and the scale/offset transform. + const bleed = style.blur > 0 ? style.blur * 2 : 0; + const w = width + bleed * 2; + const h = height + bleed * 2; + paint.save(); + if (style.blur > 0) paint.filter = `blur(${style.blur}px)`; + paint.translate(width / 2 + style.offset.x * width, height / 2 + style.offset.y * height); + paint.scale(style.scale * (style.mirror ? -1 : 1), style.scale); + paint.translate(-width / 2, -height / 2); + + if (style.backgroundSize === 'cover' || style.backgroundSize === 'contain') { + const fit = style.backgroundSize === 'cover' + ? Math.max(w / iw, h / ih) + : Math.min(w / iw, h / ih); + const dw = iw * fit; + const dh = ih * fit; + paint.drawImage(source, (w - dw) / 2 - bleed, (h - dh) / 2 - bleed, dw, dh); + } else if (style.backgroundSize === 'auto') { + // CSS repeats the picture at its own size and anchors the grid on the box + // centre (`background-position: center`). A canvas pattern instead repeats + // at the image's *intrinsic* size, which is `scale`x too small for a + // canvas that is `scale`x the CSS box - so the export tiled `scale`x more + // densely than the preview. Paint the tiles by hand at `scale`, on a grid + // centred on the box centre; the CTM above already carries the offset / + // backgroundScale / mirror, so the tiles must not re-apply them. + const tw = iw * scale; + const th = ih * scale; + // Cover only the user-space area the visible canvas needs (widened by the + // blur bleed), mapped back through the CTM. The CTM holds no rotation, so + // its `a`/`d` scale factors are enough to invert it for the bounds. + const matrix = paint.getTransform(); + const [left, right] = [ + (-bleed - matrix.e) / matrix.a, + (width + bleed - matrix.e) / matrix.a, + ].sort((a, b) => a - b); + const [top, bottom] = [ + (-bleed - matrix.f) / matrix.d, + (height + bleed - matrix.f) / matrix.d, + ].sort((a, b) => a - b); + const cx = width / 2; + const cy = height / 2; + const firstCol = Math.floor((left - cx) / tw); + const lastCol = Math.ceil((right - cx) / tw); + const firstRow = Math.floor((top - cy) / th); + const lastRow = Math.ceil((bottom - cy) / th); + for (let row = firstRow; row <= lastRow; row++) { + for (let col = firstCol; col <= lastCol; col++) { + paint.drawImage(source, cx + col * tw - tw / 2, cy + row * th - th / 2, tw, th); + } + } + } else { + paint.drawImage(source, -bleed, -bleed, w, h); + } + paint.restore(); + + if (style.darken > 0) { + paint.fillStyle = `rgba(0, 0, 0, ${style.darken})`; + paint.fillRect(0, 0, width, height); + } + + ctx.save(); + ctx.globalCompositeOperation = 'destination-over'; + ctx.drawImage(back, 0, 0); + ctx.restore(); + return true; + } + + /** Load (or clear) the backdrop bitmap that the exports composite. */ + function applyBackdrop() { + const viewState = state.view; + backdrop.apply(viewState); + const url = viewState.background === 'preset' + ? assetUrl(viewState.backgroundPreset, 'background') + : viewState.background === 'image' + ? viewState.backgroundImage + : viewState.background === 'effect' + ? backdrop.effectUrl(viewState.backgroundEffect) + : null; + if (!url) { + if (viewState.background !== 'camera') backdropImage = null; + return; + } + if (backdropImage?.dataset?.url === url) return; + const image = new Image(); + image.dataset.url = url; + image.onload = () => { backdropImage = image; app.needsRender = true; }; + image.onerror = () => { backdropImage = null; }; + image.src = url; + } + + function applyBackground() { + exporter.applyBackground(renderer, backgroundOf(state.view)); + } + + async function captureImage({ scale = 2, transparent = null, lines = null } = {}) { + return withHiddenHelpers(async () => { + // The face texture is redrawn on a throttle (every 26 ms) while the render + // loop runs, so a capture taken straight after a change could still hold the + // *previous* expression. That is what made a comic panel occasionally show + // its neighbour's face: force the pending redraw through first. + face.flush(performance.now(), 0); + const wantsTransparent = transparent === true + || (transparent == null && state.view.background === 'transparent'); + // A transparent export of a line drawing drops the paper as well. The whole + // point of transparent line art is to lay the lines over another picture, + // and a white silhouette would just hide it - so this renders the `outline` + // style, which draws the ink and leaves the body out. A shaded style keeps + // its body, because there it is an ordinary cut-out of the character. + const wantsLines = lines ?? (wantsTransparent && isLineStyle(state.render.style)); + const drawn = wantsLines ? 'outline' : state.render.style; + const background = wantsTransparent ? { mode: 'transparent' } : backgroundOf(state.view); + + const onScreen = styles.style; + if (drawn !== onScreen) styles.setStyle(drawn); + let result; + try { + result = await withOutlineFor({ style: drawn, method: wantsLines ? 'screen' : state.render.outlineMethod }, () => exporter.capturePNG({ + renderer, + scene, + camera: view.camera, + outline, + outlineOptions: { + enabled: state.render.outlineMethod === 'screen' && state.render.outline !== false, + color: state.render.outlineColor, + // The export's label buffer is `scale`x the one on screen (renderStill + // sets it to the output size), so its radius grows with it to keep the + // line's weight relative to the model the same as in the preview. + radius: outlineRadiusFor(view, state, scale), + }, + width: canvas.clientWidth || window.innerWidth, + height: canvas.clientHeight || window.innerHeight, + restore: () => { resizeViewport(); applyStateBackgroundAgain(); }, + }, { scale, background })); + } finally { + if (drawn !== onScreen) styles.setStyle(onScreen); + } + + // The backdrop, the 擬音 stamps and the bubbles live in DOM layers behind + // and above the WebGL canvas, so an export has to paint them in itself - in + // the same order as the screen: backdrop, then stamps, then bubbles. + const ctx = result.canvas.getContext('2d'); + // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps and the + // bubbles are drawn at below. The backdrop's tiled fit needs it too, so its + // tiles come out the size the preview's CSS paints them. + const base = canvas.clientWidth || 1280; + const outputScale = result.width / Math.max(1, base); + let changed = false; + if (!wantsTransparent && drawBackdropInto(ctx, result.width, result.height, outputScale)) changed = true; + const gionItems = state.gion?.items ?? []; + if (gionItems.length) { + for (const item of gionItems) await ensureGionSheet(item.sheet); + drawGion(ctx, gionItems, gionImages, { + width: result.width, + height: result.height, + scale: outputScale, + }); + changed = true; + } + const bubbles = CAPTION_KEYS.map((key) => state[key]).filter((caption) => caption?.enabled); + if (bubbles.length) { + await ensureCaptionFont(); + for (const caption of bubbles) { + drawCaption(ctx, caption, { + width: result.width, + height: result.height, + scale: outputScale, + font: captionFont, + }); + } + changed = true; + } + if (changed) result.blob = await exporter.canvasToBlob(result.canvas); + return result; + }); + } + + function applyStateBackgroundAgain() { + exporter.applyBackground(renderer, backgroundOf(state.view)); + } + + async function savePNG({ + scale = state.render.pngScale ?? 2, + transparent = state.render.pngTransparent ? true : undefined, + } = {}) { + const result = await captureImage({ scale, transparent }); + exporter.downloadBlob(result.blob, `bluebey-${exporter.timestamp()}.png`); + toast(`PNGを書き出しました(${result.width}×${result.height})`); + } + + /** + * The canvas the 下地 export paints on, *without* downloading it. Kept separate + * from `saveFaceMap` so the in-page round-trip check can look at exactly the + * pixels the button writes. + */ + function faceMapCanvas(kind) { + // A redraw is throttled while the render loop runs (see the loop's + // `face.flush(now, 26)`), so force any pending one through first or the map + // would show the expression before last. + face.flush(performance.now(), 0); + return exporter.buildFaceMap(face, kind); + } + + async function saveFaceMap(kind) { + const canvas = faceMapCanvas(kind); + const blob = await exporter.canvasToBlob(canvas); + const label = kind === 'eyes' ? '目' : '口'; + exporter.downloadBlob(blob, `bluebey-face-${kind}-${exporter.timestamp()}.png`); + toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。` + + `この画像に描いて「${label}の画像を読み込む(PNG)」で読み込めます`); + } + + /** Hide the gizmo and helpers so they never end up in an export. */ + async function withHiddenHelpers(fn) { + const wasVisible = rig.helper.visible; + const guideWasVisible = clip.guide.visible; + rig.helper.visible = false; + clip.guide.visible = false; + try { + return await fn(); + } finally { + rig.setGizmoVisible(wasVisible); + clip.guide.visible = guideWasVisible; + app.needsRender = true; + } + } + + async function copyPNG() { + const result = await captureImage({ scale: 2 }); + await exporter.copyCanvasToClipboard(result.canvas); + toast('画像をクリップボードにコピーしました'); + } + + /** + * Swap in another GLB. + * + * Kept as the implementation, but no longer wired to anything: the panel's + * 「GLBを差し替える」 button and the window-wide drop handler were removed on + * 2026-09-27. Putting the feature back is a button plus an action that calls + * this (the model returns on reload, so nothing is saved). + */ + async function replaceModel(file) { + if (!file) return; + try { + const buffer = await exporter.readFileAsArrayBuffer(file); + const next = await loadModel(buffer); + styles.dispose(); + rig.dispose(); + container.remove(model.root); + scene.remove(container); + container.add(next.root); + scene.add(container); + app.model = next; + toast(`${file.name} を読み込みました(このモデルは再読込で元に戻ります)`); + app.needsRender = true; + } catch (error) { + console.error(error); + toast('このファイルは読み込めませんでした'); + } + } + + let recording = false; + async function toggleRecording(session) { + if (!session) { + toast('この環境では録画できません'); + return; + } + if (!recording) { + if (!state.anim.idle) { + state.anim.idle = true; + panel.sync(); + } + recording = startRecording(session); + toast('録画を開始しました(もう一度押すと停止)'); + } else { + const blob = await stopRecording(session); + recording = false; + if (blob) { + exporter.downloadBlob(blob, `bluebey-animation-${exporter.timestamp()}.webm`); + toast('録画を保存しました(WebM)'); + } + } + app.panel?.setRecording(recording); + } + + function setCameraPreset(id) { + const presets = { + front: { azimuth: 0, polar: 82 }, + threeQuarter: { azimuth: 34, polar: 78 }, + side: { azimuth: 90, polar: 84 }, + back: { azimuth: 180, polar: 82 }, + top: { azimuth: 24, polar: 26 }, + }; + applyState({ view: presets[id] ?? presets.front }, { scope: 'view', sync: true }); + } + + function restoreFromHistory(kind) { + const snapshot = kind === 'undo' ? history.undo() : history.redo(); + if (!snapshot) { + toast(kind === 'undo' ? 'これ以上戻れません' : 'やり直す操作がありません'); + return; + } + suspendHistory = true; + try { + applyState(snapshot, { full: true, sync: true }); + } finally { + suspendHistory = false; + } + toast(kind === 'undo' ? '1つ戻しました' : 'やり直しました'); + } + + function applyTheme(id) { + const theme = THEMES.find((item) => item.id === id); + if (!theme) return; + state.render.theme = id; + state.render.colors = { ...theme.colors }; + applyState({}, { scope: 'render', sync: true }); + } + + /** おまかせ: a seeded random expression, so it can be reproduced and shared. */ + function rollGacha(seedText) { + const seed = seedText || String(Math.floor(Math.random() * 1e9)); + const roll = rollAll(seed); + suspendHistory = true; + try { + applyState(roll.patch, { scope: 'all' }); + } finally { + suspendHistory = false; + } + history.push(state, 'おまかせ'); + app.panel?.sync(); + const shown = decodeSeed(seed) ?? String(seed); + toast(`おまかせ表情(シード ${shown})`); + return shown; + } + + async function copyShareLink() { + try { + const encoded = await encodeState(state); + const link = `${location.origin}${location.pathname}#s=${encoded}`; + await navigator.clipboard.writeText(link); + toast(`この見た目のリンクをコピーしました(${link.length}文字)`); + } catch (error) { + console.error(error); + toast('リンクを作れませんでした'); + } + } + + /** The tag every shared picture carries. */ + const SHARE_TAG = '#ぶるべースタジオ'; + + /** The studio's own address - the landing page itself, not a state-reproducing + * `#s=` link. Shared alongside the tag so a viewer can find the studio. */ + function studioUrl() { + return `${location.origin}${location.pathname}`; + } + + /** + * Share the current look as a *picture*, not as a link. + * + * Neither X nor Facebook's web dialog can attach an image, so the picture goes + * through the OS share sheet (`navigator.share` with files) wherever the browser + * has one - a single tap on a phone, and it can go to any app. Where there is no + * share sheet (most desktops), the PNG is put on the clipboard and the network's + * compose window is opened with the tag already typed, so the picture only has to + * be pasted. The tag *and* the studio link always travel together: the user asks + * for both to be kept when the picture is shared. + */ + async function shareImage(network) { + let shot; + try { + shot = await captureImage({ scale: 1.5, transparent: false }); + } catch (error) { + console.error(error); + toast('画像を作れませんでした'); + return; + } + const file = new File([shot.blob], `bluebey-${exporter.timestamp()}.png`, { type: 'image/png' }); + + if (!network && navigator.canShare?.({ files: [file] })) { + try { + await navigator.share({ files: [file], text: SHARE_TAG, url: studioUrl() }); + return; + } catch (error) { + if (error?.name === 'AbortError') return; + console.error(error); + } + } + + // No share sheet (or a named network): put the picture on the clipboard and + // open that network's compose window with the tag and the studio link filled in. + let copied = false; + try { + await exporter.copyCanvasToClipboard(shot.canvas); + copied = true; + } catch (error) { + console.error(error); + } + const studio = studioUrl(); + const target = network === 'facebook' + ? `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(studio)}` + : `https://twitter.com/intent/tweet?text=${encodeURIComponent(SHARE_TAG)}&url=${encodeURIComponent(studio)}`; + window.open(target, '_blank'); + toast(copied + ? '画像をコピーしました。開いた画面に貼り付けて投稿してください(Ctrl+V)' + : '投稿画面を開きました(この環境では画像を自動で貼り付けできません)'); + } + + function toggleMouthFlap() { + if (mouthFlap.running) { + mouthFlap.stop(); + toast('口パクを止めました'); + return; + } + const seconds = mouthFlap.start(state.mouthFlap?.text ?? '', { + rate: state.mouthFlap?.rate ?? 1, + }); + toast(`口パクを始めました(音は出ません・約${seconds.toFixed(1)}秒)`); + } + + async function toggleCamera(on) { + if (!on) { + backdrop.stopCamera(); + applyState({ view: { background: 'solid' } }, { scope: 'view', sync: true }); + return; + } + const result = await backdrop.startCamera(state.view.cameraFacing ?? 'environment'); + if (!result?.ok) { + toast(result?.reason ?? 'カメラを使えません'); + return; + } + applyState({ view: { background: 'camera' } }, { scope: 'view', sync: true }); + toast('カメラの映像を背景にしました(そのまま写真に撮れます)'); + } + + function addProp(kind) { + const def = PROP_DEFAULTS[kind] ?? { x: 1.6, y: 0, z: 0, rotY: 0, scale: 1 }; + state.props.items = [...(state.props.items ?? []), { + kind, + x: def.x ?? 0, + y: def.y ?? 0, + z: def.z ?? 0, + rotY: def.rotY ?? 0, + scale: def.scale ?? 1, + }]; + applyState({}, { scope: 'render', sync: true }); + } + + async function addStoryPanel() { + const index = (state.story.panels?.length ?? 0) + 1; + // A counter, not the array length: removing a panel and then adding another + // used to hand out an id that was already taken, which confused the list. + storySeq += 1; + const id = `p${storySeq}`; + state.story.panels = [...(state.story.panels ?? []), { + id, + label: `コマ${index}`, + pose: JSON.parse(JSON.stringify(state.pose)), + face: JSON.parse(JSON.stringify(state.face)), + caption: JSON.parse(JSON.stringify(state.caption)), + caption2: JSON.parse(JSON.stringify(state.caption2)), + // The 擬音 stamps as well, so a panel comes back with everything it showed. + gion: JSON.parse(JSON.stringify(state.gion)), + // The camera as well as the pose: recalling a panel should put you back + // where you were looking when you recorded it. + view: JSON.parse(JSON.stringify(state.view)), + }]; + applyState({}, { scope: 'caption', sync: true }); + + // Then photograph what was just recorded, so the list shows the shot rather + // than only its name. + try { + const shot = await captureImage({ scale: 0.35, transparent: false }); + storyThumbs.set(id, { + version: (storyThumbs.get(id)?.version ?? 0) + 1, + url: thumbDataUrl(shot.canvas), + }); + toast(`コマ${index}を追加しました`); + } catch (error) { + console.warn('[bluebey] story thumbnail failed', error); + toast(`コマ${index}を追加しました(プレビューは作れませんでした)`); + } + app.panel?.sync(); + } + + /** Shrink a capture down to a thumbnail data URL for the panel list. */ + function thumbDataUrl(source, width = 168) { + const scale = width / Math.max(1, source.width); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(source.width * scale)); + canvas.height = Math.max(1, Math.round(source.height * scale)); + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(source, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL('image/png'); + } + + /** + * A number in the corner of a comic panel, for sheets whose reading order is + * not obvious. Drawn with the vendored caption font, so the sheet does not + * depend on whatever fonts the viewer happens to have. + */ + function drawPanelNumber(ctx, number, x, y, cellW, cellH) { + const radius = Math.max(18, Math.min(64, Math.min(cellW, cellH) * 0.075)); + const margin = radius * 0.7; + const cx = x + margin + radius; + const cy = y + margin + radius; + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255, 255, 255, 0.92)'; + ctx.fill(); + ctx.lineWidth = Math.max(2, radius * 0.14); + ctx.strokeStyle = '#3f2b52'; + ctx.stroke(); + ctx.fillStyle = '#3f2b52'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = `bold ${Math.round(radius * 1.25)}px ${captionFontStack()}`; + ctx.fillText(String(number), cx, cy + radius * 0.04); + ctx.restore(); + } + + /** + * まんが: replay the panels, capture each one, and hand back a sheet of them + * plus a zip of the individual frames. + */ + async function saveStory({ scale = 2, includeZip = true } = {}) { + const panels = state.story?.panels ?? []; + if (!panels.length) { + toast('コマがありません。「今の状態をコマに追加」で作ってください'); + return; + } + const saved = JSON.parse(JSON.stringify(state)); + const captures = []; + suspendHistory = true; + try { + for (const panel of panels) { + const pose = panel.pose ?? {}; + state.pose = { bones: pose.bones ?? {}, root: pose.root ?? [0, 0, 0] }; + state.face = JSON.parse(JSON.stringify(saved.face)); + applyPatch(state.face, panel.face ?? {}); + state.caption = { ...saved.caption, ...(panel.caption ?? {}) }; + state.caption2 = { ...saved.caption2, ...(panel.caption2 ?? {}) }; + // The camera the panel was framed with, so a comic keeps its angles. + if (panel.view) state.view = JSON.parse(JSON.stringify(panel.view)); + refresh('all'); + // Two frames: one to apply the pose, one to draw it. + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + captures.push(await captureImage({ scale, transparent: false })); + } + } finally { + suspendHistory = false; + applyState(saved, { full: true, sync: true }); + } + + const columns = Math.max(1, Math.min(4, state.story?.columns ?? 2)); + const gap = state.story?.gap ?? 12; + const padding = state.story?.padding ?? 20; + const rows = Math.ceil(captures.length / columns); + const cellW = Math.max(...captures.map((item) => item.width)); + const cellH = Math.max(...captures.map((item) => item.height)); + + // Once the sheet is more than one column wide, the reading order stops being + // obvious from the layout alone, so the panels are numbered. A single column + // (or two panels) reads top to bottom without help. + const numbered = columns >= 2 && captures.length >= 3; + if (numbered) await ensureCaptionFont(); + + const sheet = document.createElement('canvas'); + sheet.width = padding * 2 + cellW * columns + gap * (columns - 1); + sheet.height = padding * 2 + cellH * rows + gap * (rows - 1); + const ctx = sheet.getContext('2d'); + ctx.fillStyle = state.story?.sheetBackground ?? '#ffffff'; + ctx.fillRect(0, 0, sheet.width, sheet.height); + captures.forEach((capture, index) => { + const x = padding + (index % columns) * (cellW + gap); + const y = padding + Math.floor(index / columns) * (cellH + gap); + ctx.drawImage(capture.canvas, x, y, cellW, cellH); + if (numbered) drawPanelNumber(ctx, index + 1, x, y, cellW, cellH); + }); + + const stamp = exporter.timestamp(); + exporter.downloadBlob(await exporter.canvasToBlob(sheet), `bluebey-comic-${stamp}.png`); + if (includeZip) { + const files = await Promise.all(captures.map(async (capture, index) => ({ + name: `panel-${String(index + 1).padStart(2, '0')}.png`, + data: new Uint8Array(await capture.blob.arrayBuffer()), + }))); + exporter.downloadBlob(createZip(files), `bluebey-comic-${stamp}.zip`); + } + toast(`まんがを書き出しました(${captures.length}コマ)`); + } + + /** A shared link restores the whole look; otherwise this is the start point. */ + async function loadFromHash() { + // 共有ボタンは `?s=`、ボタン「この見た目のリンクをコピー」は `#s=` を使う。 + // Facebook は `#` 以降を落とすので、共有はクエリにする必要がある - どちらでも読む。 + const query = new URLSearchParams(location.search).get('s'); + const hash = location.hash.replace(/^#/, ''); + const encoded = query ?? new URLSearchParams(hash).get('s'); + if (!encoded) return false; + const decoded = await decodeState(encoded); + if (!decoded?.state) return false; + suspendHistory = true; + try { + applyState(decoded.state, { full: true, sync: true }); + } finally { + suspendHistory = false; + } + return true; + } + + function installTopbar() { + // The panel itself is visible from the start, but each of its sections starts + // collapsed (see `section()` in src/ui.js): the panel reads as a short list of + // headings, and you open the one you want instead of scrolling past twelve. + resizeViewport(); + + document.getElementById('btn-shot')?.addEventListener('click', () => app.actions.savePNG()); + document.getElementById('btn-copy')?.addEventListener('click', () => app.actions.copyPNG()); + document.getElementById('btn-help')?.addEventListener('click', toggleHelp); + document.getElementById('btn-panel')?.addEventListener('click', togglePanel); + document.getElementById('help-close')?.addEventListener('click', () => { toggleHelp(false); }); + const help = document.getElementById('help'); + help?.addEventListener('click', (event) => { if (event.target === help) toggleHelp(false); }); + } + + function togglePanel() { + document.body.classList.toggle('panel-hidden'); + resizeViewport(); + } + + function installKeys() { + window.addEventListener('keydown', (event) => { + // Undo/redo are the only shortcuts that need a modifier, so they are dealt + // with before the "no modifiers" rule below. + if ((event.ctrlKey || event.metaKey) && !event.altKey) { + const combo = event.key.toLowerCase(); + if (combo === 'z' || combo === 'y') { + event.preventDefault(); + restoreFromHistory(combo === 'y' || event.shiftKey ? 'redo' : 'undo'); + return; + } + } + if (event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement) return; + switch (event.key) { + case 's': case 'S': savePNG(); break; + case 'c': case 'C': copyPNG(); break; + case 'r': case 'R': app.actions.resetPose(); break; + case 'g': case 'G': { + const visible = !rig.helper.visible; + rig.setGizmoVisible(visible); + toast(visible ? 'ギズモを表示' : 'ギズモを隠しました'); + app.needsRender = true; + break; + } + case ' ': event.preventDefault(); app.actions.toggleAnimation(); break; + case '?': toggleHelp(); break; + case 'Tab': event.preventDefault(); togglePanel(); break; + case '1': setCameraPreset('front'); break; + case '2': setCameraPreset('threeQuarter'); break; + case '3': setCameraPreset('side'); break; + case '4': setCameraPreset('back'); break; + case '5': setCameraPreset('top'); break; + default: break; + } + }); + } + +} + +function toggleHelp(force) { + const help = document.getElementById('help'); + if (help) help.hidden = force === undefined ? !help.hidden : !force; +} + +/** A persistent message for problems that need the user to do something. */ +function showNotice(text) { + let node = document.getElementById('notice'); + if (!node) { + node = document.createElement('div'); + node.id = 'notice'; + document.body.append(node); + } + node.textContent = text; + node.hidden = false; +} + +/* ------------------------------------------------------------------- camera */ + +/** + * Orbiting camera with both a perspective and an orthographic projection. + * Only one of them is enabled at a time; switching copies the framing across so + * the model does not jump. + */ +class ViewRig { + constructor(domElement, onChange) { + this.domElement = domElement; + this.onChange = onChange; + this.projection = 'persp'; + this.controlsChanged = false; + + this.cameras = { + // A tight near/far keeps depth precision high, which matters because the + // outline hull is only a few hundredths of a unit away from the surface. + // The far plane has to sit well past the furthest the camera may go, or + // zooming all the way out slices the character on it - which is exactly + // what the old `far = 120` did, because the zoom limit was also 120. + persp: new THREE.PerspectiveCamera(30, 1, 1, 2000), + ortho: new THREE.OrthographicCamera(-1, 1, 1, -1, -2000, 2000), + }; + this.controlsMap = {}; + for (const [key, camera] of Object.entries(this.cameras)) { + const controls = new OrbitControls(camera, domElement); + controls.enableDamping = false; + controls.enablePan = true; + controls.minDistance = 1; + controls.maxDistance = 400; + controls.addEventListener('change', () => { + this.controlsChanged = true; + this.onChange?.(); + }); + this.controlsMap[key] = controls; + } + this.controlsMap.ortho.enabled = false; + this.camera = this.cameras.persp; + this.controls = this.controlsMap.persp; + this.target = new THREE.Vector3(0, 1, 0); + this.autoDistance = 12; + // `orthoHeight` is where the orthographic camera is *now* - `apply()` writes + // the camera's actual height back into it - so the framing `frame()` chose + // needs its own field to measure the zoom against (see `zoomFactor`). + this.orthoHeight = 6; + this.autoOrthoHeight = 6; + this.resize(); + } + + /** Fit the model into view. */ + frame(size, targetY) { + this.target.set(0, targetY, 0); + for (const controls of Object.values(this.controlsMap)) controls.target.copy(this.target); + const fov = this.cameras.persp.fov * Math.PI / 180; + const heightDistance = size.y / (2 * Math.tan(fov / 2)); + const widthDistance = size.x / (2 * Math.tan(fov / 2) * Math.max(0.4, this.aspect)); + this.autoDistance = Math.max(heightDistance, widthDistance) * 1.45; + this.orthoHeight = size.y * 1.5; + this.autoOrthoHeight = this.orthoHeight; + for (const camera of Object.values(this.cameras)) { + camera.position.set(0, targetY + this.autoDistance * 0.12, this.autoDistance); + } + this.resize(); + } + + get aspect() { + const width = this.domElement.clientWidth || 1; + const height = this.domElement.clientHeight || 1; + return width / height; + } + + resize() { + const width = this.domElement.clientWidth || window.innerWidth; + const height = this.domElement.clientHeight || window.innerHeight; + const aspect = width / height; + this.cameras.persp.aspect = aspect; + this.cameras.persp.updateProjectionMatrix(); + this.setOrthoHeight(this.orthoHeight); + } + + setOrthoHeight(height) { + const ortho = this.cameras.ortho; + const halfHeight = height / 2; + const halfWidth = halfHeight * this.aspect; + ortho.left = -halfWidth; + ortho.right = halfWidth; + ortho.top = halfHeight; + ortho.bottom = -halfHeight; + ortho.zoom = 1; + ortho.updateProjectionMatrix(); + } + + /** + * How large the character looks now, relative to the auto-fit framing. + * + * 1 means "the default framing", which is where the panel's 大きさ sliders sit + * and so where the screen-space outline's pixel width is written against. The + * two projections scale differently - a perspective camera shows 1/distance as + * much per world unit, an orthographic one 1/height, and OrbitControls zooms + * the latter with `camera.zoom` rather than by moving it - so each is + * normalised by its own auto-fit value. + */ + zoomFactor() { + if (this.projection === 'ortho') { + const ortho = this.cameras.ortho; + const height = (ortho.top - ortho.bottom) / Math.max(ortho.zoom, 0.0001); + return this.autoOrthoHeight > 0 && height > 0 ? this.autoOrthoHeight / height : 1; + } + const distance = this.camera.position.distanceTo(this.controls?.target ?? this.target); + return this.autoDistance > 0 && distance > 0 ? this.autoDistance / distance : 1; + } + + setProjection(kind) { + if (kind === this.projection) return; + const previous = this.camera; + const next = kind === 'ortho' ? this.cameras.ortho : this.cameras.persp; + next.position.copy(previous.position); + next.quaternion.copy(previous.quaternion); + this.controlsMap[this.projection].enabled = false; + this.projection = kind; + this.camera = next; + this.controls = this.controlsMap[kind]; + this.controls.enabled = true; + this.controls.target.copy(this.target); + this.resize(); + this.onCameraChange?.(next, this.controls); + this.controlsChanged = true; + this.onChange?.(); + } + + /** Apply the serialisable view state (azimuth/polar/zoom). */ + apply(viewState, size) { + const distance = viewState.distance > 0 ? viewState.distance : this.autoDistance; + const targetY = viewState.targetY || this.target.y; + this.target.set(viewState.targetX || 0, targetY, viewState.targetZ || 0); + const spherical = new THREE.Spherical( + distance, + clamp(viewState.polar, 1, 179) * Math.PI / 180, + viewState.azimuth * Math.PI / 180, + ); + const offset = new THREE.Vector3().setFromSpherical(spherical); + for (const camera of Object.values(this.cameras)) { + camera.position.copy(this.target).add(offset); + camera.lookAt(this.target); + } + if (viewState.orthoHeight > 0) this.setOrthoHeight(viewState.orthoHeight); + else if (size) this.setOrthoHeight(this.orthoHeight); + for (const controls of Object.values(this.controlsMap)) { + controls.target.copy(this.target); + controls.update(); + } + this.orthoHeight = this.cameras.ortho.top - this.cameras.ortho.bottom; + this.cameras.persp.updateProjectionMatrix(); + this.cameras.ortho.updateProjectionMatrix(); + } + + /** + * Write where the camera actually is back into the serialisable view state. + * + * WHY: the mouse orbit and pan move the `controls`, not the state. Without this + * the state kept the last camera the *panel* set, so any refresh of the view + * snapped the camera back to it - which is what made picking a background jump + * the camera. Keeping the two in step also means a shared link carries the + * camera you framed. + */ + captureInto(viewState) { + if (!viewState || typeof viewState !== 'object') return; + const target = this.controls?.target ?? this.target; + const spherical = new THREE.Spherical().setFromVector3( + new THREE.Vector3().subVectors(this.camera.position, target), + ); + viewState.azimuth = Math.round(spherical.theta * RAD_TO_DEG * 100) / 100; + viewState.polar = Math.round(spherical.phi * RAD_TO_DEG * 100) / 100; + viewState.distance = Math.round(spherical.radius * 1000) / 1000; + viewState.targetX = Math.round(target.x * 1000) / 1000; + viewState.targetY = Math.round(target.y * 1000) / 1000; + viewState.targetZ = Math.round(target.z * 1000) / 1000; + if (this.projection === 'ortho') { + const ortho = this.cameras.ortho; + viewState.orthoHeight = Math.round((ortho.top - ortho.bottom) * 1000) / 1000; + } + } + + setAutoRotate(enabled, speed) { + for (const controls of Object.values(this.controlsMap)) { + controls.autoRotate = enabled; + controls.autoRotateSpeed = speed; + } + } +} + +/** + * How far the screen-space outline may follow the zoom (see `outlineRadiusFor`). + * + * A clamp keeps a very deep zoom from turning the line into either a smear or + * nothing at all, which is what the linear factor would do at the extremes of + * the 大きさ slider. + */ +const OUTLINE_ZOOM_MIN = 0.25; +const OUTLINE_ZOOM_MAX = 3; + +/** + * The screen-space outline's radius, in pixels of the label buffer. + * + * The body's lines are inverted hulls, i.e. an offset in *world* units, so they + * thicken as the character grows on screen and thin as it shrinks. This pass + * works in pixels instead, so left alone its lines - the waist leaves and the + * nose - keep one width at every zoom, and the leaf skirt reads as far too heavy + * beside the body the moment you pull back. Scaling the radius by the rig's + * `zoomFactor` makes the two behave the same way, with `outlinePixels` staying + * the width at the auto-fit framing (so the slider keeps its meaning). + * + * `extra` carries whatever else changes the buffer's pixels-per-CSS-pixel: the + * preview's buffer is `quality`x the CSS size (and `quality` itself follows the + * display density and the automatic reductions), and an export renders the label + * buffer at `scale`x. Passing that factor keeps the line a constant *CSS* width, + * so `outlinePixels` reads as CSS pixels whichever buffer it lands in. + */ +function outlineRadiusFor(view, state, extra = 1) { + const zoom = clamp(view.zoomFactor(), OUTLINE_ZOOM_MIN, OUTLINE_ZOOM_MAX); + return (state.render.outlinePixels ?? 2) * zoom * extra; +} + +/* ----------------------------------------------------------------- scenery */ + +function makeEnvironment(renderer) { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 32; + const ctx = canvas.getContext('2d'); + const gradient = ctx.createLinearGradient(0, 0, 0, 32); + gradient.addColorStop(0, '#ffffff'); + gradient.addColorStop(0.5, '#ece7fa'); + gradient.addColorStop(1, '#b9aade'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 64, 32); + const texture = new THREE.CanvasTexture(canvas); + texture.mapping = THREE.EquirectangularReflectionMapping; + texture.colorSpace = THREE.SRGBColorSpace; + const generator = new THREE.PMREMGenerator(renderer); + const environment = generator.fromEquirectangular(texture).texture; + texture.dispose(); + generator.dispose(); + return environment; +} + +/** Round to 2 decimals: the precision the panel's prop sliders show. */ +const round2 = (value) => Math.round(value * 100) / 100; + +const clampNumber = (value, lo, hi) => Math.min(hi, Math.max(lo, value)); + +export function backgroundOf(viewState) { + if (viewState.background === 'transparent') return { mode: 'transparent' }; + // A preset, a loaded photo, a drawn effect line or the phone's camera all + // live in a DOM layer behind the WebGL canvas, so the renderer is left clear + // and the exports paint the bitmap into the picture themselves (see + // drawBackdropInto in main.js). + if (viewState.background === 'preset' || viewState.background === 'image' + || viewState.background === 'effect' || viewState.background === 'camera') { + return { mode: 'transparent' }; + } + return { mode: 'solid', color: viewState.backgroundColor }; +} + +/* ------------------------------------------------------------------- errors */ + +function setLoadingProgress(ratio) { + const node = document.getElementById('loading-text'); + if (node) node.textContent = `ぶるべーを読み込んでいます… ${Math.round(ratio * 100)}%`; +} + +function hideLoading() { + const node = document.getElementById('loading'); + if (node) node.classList.add('done'); +} + +function showLoadError(error) { + const text = document.getElementById('loading-text'); + const detail = document.getElementById('loading-error'); + if (text) text.textContent = '読み込みに失敗しました。'; + if (detail) { + detail.textContent = `${error?.message ?? error}\n\n` + + 'このページはローカルサーバー経由で開く必要があります。\n' + + 'bluebey-studio フォルダで python serve.py を実行し、\n' + + '表示された http://127.0.0.1:8000/ をブラウザで開いてください。'; + } +} + +const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value)); +const clamp01 = (value) => clamp(value, 0, 1); diff --git a/public/bluebey-studio/src/model.js b/public/bluebey-studio/src/model.js new file mode 100644 index 0000000..083c745 --- /dev/null +++ b/public/bluebey-studio/src/model.js @@ -0,0 +1,239 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; + +/** + * Loads bluebey.glb and works out everything the studio needs to know about it: + * the rig, which meshes are the face overlays, and the original hand-drawn + * textures that ship inside the file. + * + * The GLB layout (materials carry the reliable names; meshes are named after + * Blender objects): + * body parts Body, Foots, Leaf, Vein, LeftHand, RightHand, Nose + * overlays eyes-* (eight hand-drawn variants, one texture each) and Mouth + * face plates eyes-plate / mouth-plate, the big shells the studio draws on + * + * The two face plates are what the artwork is painted onto. They are the front + * half of the body, so wherever the drawing lands there is a surface to carry + * it - the old hand-drawn planes only covered a patch of the head, which clipped + * the bottom of a deep smile and most of a teardrop. When a model has no plates + * (the 2022 file), the studio falls back to drawing on the hand-drawn planes. + */ + +/** Hand-drawn texture variants inside the GLB, keyed by their material suffix. */ +export const EYE_VARIANTS = [ + { key: 'opened', label: '開いた目' }, + { key: 'closed', label: '閉じた目' }, + { key: 'close-tight', label: 'ぎゅっと閉じ' }, + { key: 'left-wink', label: '左ウインク' }, + { key: 'look-up', label: '上を見る' }, + { key: 'look-down', label: '下を見る' }, + { key: 'look-left', label: '左を見る' }, + { key: 'look-right', label: '右を見る' }, +]; + +/** + * Friendly names for the eleven bones of the rig. Keys are lower-cased with + * dots removed, because three's GLTFLoader sanitises node names (`armsupport.l` + * arrives as `armsupportl`). + */ +const BONE_LABELS = { + master: '全身', + armsupportl: '左うでの付け根', + arml: '左うで', + handl: '左手先', + armr: '右うで', + handr: '右手先', + legsupportl: '左あしの付け根', + footl: '左足', + toel: '左つまさき', + legsupportr: '右あしの付け根', + footr: '右足', + toer: '右つまさき', +}; + +const normalizeBoneName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase(); + +/** + * The overlay planes hug the head, so nudge them outwards before anything else + * happens. They are pushed along their own normals (not away from the head + * centre): the mouth in particular needs to clear the head's lower surface and + * the ring of leaves around the base, otherwise a low or frowning mouth gets + * swallowed by them. + */ +const OVERLAY_INFLATE = { eyes: 0.004, mouth: 0.004 }; + +export async function loadModel(source, { onProgress } = {}) { + const loader = new GLTFLoader(); + let gltf; + if (typeof source === 'string') { + gltf = await loader.loadAsync(source, (event) => { + if (onProgress && event.lengthComputable) onProgress(event.loaded / event.total); + }); + } else { + // A File/ArrayBuffer, e.g. from drag and drop or the file picker. + gltf = await loader.parseAsync(source, ''); + } + + const root = gltf.scene; + + // The file carries its own camera node; we drive our own. + const cameras = []; + root.traverse((object) => { if (object.isCamera) cameras.push(object); }); + for (const camera of cameras) camera.removeFromParent(); + + const eyes = new Map(); + const body = []; + /** The original material name of every body mesh (`blb`, `leaf`, `vein`, ...). */ + const kinds = new Map(); + /** The plane holding the hand-drawn mouth texture (a texture source only). */ + let mouthOriginal = null; + /** The big shells the artwork is drawn onto, if the file has them. */ + let eyePlate = null; + let mouthPlate = null; + + root.traverse((object) => { + if (!object.isMesh) return; + // Skinned bounds stop matching the pose, so culling would pop meshes away. + object.frustumCulled = false; + const material = Array.isArray(object.material) ? object.material[0] : object.material; + const materialName = (material?.name ?? '').toLowerCase(); + if (materialName === 'eyes-plate') { + eyePlate = object; + return; + } + if (materialName === 'mouth-plate') { + mouthPlate = object; + return; + } + if (materialName.startsWith('eyes-')) { + eyes.set(materialName.slice('eyes-'.length), object); + return; + } + if (materialName === 'mouth') { + mouthOriginal = object; + return; + } + body.push(object); + kinds.set(object, materialName); + }); + + // The waist leaves - the `Leaf` blade and the `Vein` rim that shares its mesh. + // These are the thin, overlapping shells the inverted-hull outline cannot draw + // (see MODEL-GUIDE.md §5), so the screen-space pass takes them over on its own. + const leaves = body.filter((mesh) => kinds.get(mesh) === 'leaf' || kinds.get(mesh) === 'vein'); + + // The drawing surface: the big plate when present, otherwise the 2022 plane. + const eyeMesh = eyePlate ?? eyes.get('opened') ?? eyes.values().next().value; + const mouth = mouthPlate ?? mouthOriginal; + if (!eyeMesh) throw new Error('eye mesh not found in the GLB'); + if (!mouth) throw new Error('mouth mesh not found in the GLB'); + + // The nose ball. It is a bump sitting on the body rather than a part with a + // silhouette of its own, so the outline pass treats it per style - see + // `outlineExclusion` in main.js. + const noseMesh = body.find((mesh) => (mesh.material?.name ?? '').toLowerCase() === 'nose') ?? null; + + // Only the drawing surface stays visible; every other overlay is a texture + // source, so hide it. (`mouthOriginal` may be the drawing surface itself.) + for (const mesh of eyes.values()) mesh.visible = mesh === eyeMesh; + if (mouthOriginal && mouthOriginal !== mouth) mouthOriginal.visible = false; + if (mouthPlate && eyePlate) mouthPlate.visible = true; + + /** @type {{ eyes: Record<string, THREE.Texture>, mouth: THREE.Texture|null }} */ + const originals = { eyes: {}, mouth: mouthOriginal?.material?.map ?? mouth.material.map ?? null }; + for (const [key, mesh] of eyes) { + if (mesh.material.map) originals.eyes[key] = mesh.material.map; + } + + // Push the overlay planes a hair off the body surface, so the two do not + // z-fight. The plates are shells of the body, so the offset is tiny; a large + // one is what made the mouth look like it floated in profile. + const bodyMesh = body.find((mesh) => mesh.geometry?.attributes?.position?.count > 2000) ?? body[0]; + const headCentre = new THREE.Box3() + .setFromBufferAttribute(bodyMesh.geometry.attributes.position) + .getCenter(new THREE.Vector3()); + inflateOverlay(eyeMesh, headCentre, OVERLAY_INFLATE.eyes); + inflateOverlay(mouth, headCentre, OVERLAY_INFLATE.mouth); + + // Recentre: put the character on the origin with its feet on the ground so the + // camera maths stays trivial. + const box = new THREE.Box3().setFromObject(root); + const centre = box.getCenter(new THREE.Vector3()); + root.position.x -= centre.x; + root.position.z -= centre.z; + root.position.y -= box.min.y; + root.updateMatrixWorld(true); + + const bounds = new THREE.Box3().setFromObject(root); + const size = bounds.getSize(new THREE.Vector3()); + const middle = bounds.getCenter(new THREE.Vector3()); + + const skinned = []; + root.traverse((object) => { if (object.isSkinnedMesh && object.skeleton) skinned.push(object); }); + const skeleton = skinned[0]?.skeleton ?? null; + + const bones = []; + if (skeleton) { + for (const bone of skeleton.bones) { + bones.push({ + name: bone.name, + label: BONE_LABELS[normalizeBoneName(bone.name)] ?? bone.name, + bone, + rest: bone.quaternion.clone(), + }); + } + } + + return { + gltf, + root, + size, + middle, + bounds, + skeleton, + bones, + parts: { + body, + kinds, + leaves, + overlays: [eyeMesh, mouth], + eyeMesh, + mouthMesh: mouth, + noseMesh, + hasFacePlates: Boolean(eyePlate && mouthPlate), + }, + originals, + }; +} + +function inflateOverlay(mesh, centre, distance) { + const geometry = mesh.geometry; + const position = geometry?.attributes?.position; + if (!position) return; + const normal = geometry.attributes.normal; + const radial = new THREE.Vector3(); + const offset = new THREE.Vector3(); + const vertexNormal = new THREE.Vector3(); + for (let i = 0; i < position.count; i++) { + radial.fromBufferAttribute(position, i).sub(centre); + if (radial.lengthSq() < 1e-12) continue; + offset.copy(radial).normalize(); + if (normal) { + // The planes are double sided, so the stored normal may point either way. + vertexNormal.fromBufferAttribute(normal, i); + if (vertexNormal.lengthSq() > 1e-12) { + if (vertexNormal.dot(offset) < 0) vertexNormal.negate(); + offset.copy(vertexNormal.normalize()); + } + } + position.setXYZ( + i, + position.getX(i) + offset.x * distance, + position.getY(i) + offset.y * distance, + position.getZ(i) + offset.z * distance, + ); + } + position.needsUpdate = true; + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); +} diff --git a/public/bluebey-studio/src/mouthFlap.js b/public/bluebey-studio/src/mouthFlap.js new file mode 100644 index 0000000..1109398 --- /dev/null +++ b/public/bluebey-studio/src/mouthFlap.js @@ -0,0 +1,267 @@ +/** + * 口パク (mouth flap): open and close the mouth as if talking, with no sound. + * + * WHY no speech: the studio used to read the caption out with the Web Speech + * API, but that voice belongs to the device (it is the OS's own speech engine), + * so a recording of it is not something we are free to hand out. What a clip + * actually needs is the *mouth motion*, and that needs no voice at all: + * `createMouthEnvelope` builds a smooth, seeded signal in the 3-6 + * syllable-per-second band, and `createMouthFlap` ticks it at about 30 Hz for + * as long as the line would take to say. + * + * Nothing here touches `speechSynthesis`, so there is no permission prompt, no + * device dependency, and a recording of the animation is the studio's own work. + */ + +const TAU = Math.PI * 2; +/** The mouth is sampled at ~30 Hz: enough for an animation, cheap to run. */ +const LEVEL_HZ = 30; +/** How long the envelope takes to close after `stop()`, in seconds. */ +const CLOSE_SECONDS = 0.3; + +const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value)); +const clamp01 = (value) => clamp(value, 0, 1); + +/** + * mulberry32, the same tiny generator `handDrawn.js` uses. The mouth must be + * reproducible for a seed, so `Math.random` is not an option. + * + * @param {number} seed + * @returns {() => number} values in [0, 1) + */ +function mulberry32(seed) { + let a = seed >>> 0; + return function next() { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * The mouth source: a deterministic, smooth mouth opening in 0..1. + * + * A syllable is one closed -> open -> closed cycle, so the signal is a cosine + * pulse whose frequency is slowly modulated between 3 and 6 Hz and shaped by a + * slower amplitude wobble. The syllable range and the modulation constants come + * from the seed, so two envelopes with the same seed are exactly identical, and + * the signal is continuous with a bounded slope: it never jumps, which is what + * keeps the mouth from flickering. + * + * @param {{ seed?: number }} [options] + * @returns {{ value: (t: number) => number, start: (at?: number) => void, stop: () => void }} + */ +export function createMouthEnvelope({ seed = 1 } = {}) { + const random = mulberry32(seed); + const phase0 = random() * TAU; + const phase1 = random() * TAU; + // 4.2..4.8 syllables/s on average, swung by up to 1.2 either way, then kept + // inside 3..6 so the read stays in the range of human speech. + const rateMean = 4.2 + random() * 0.6; + const rateSwing = Math.min(rateMean - 3, 6 - rateMean, 0.7 + random() * 0.8); + const rateMod = 0.21 + random() * 0.25; + const ampMod = 0.4 + random() * 0.3; + + let startAt = 0; + let stopAt = Infinity; + let lastAt = 0; + + /** The envelope at `u` seconds after the start, ignoring start and stop. */ + function raw(u) { + // The integral of the modulated rate, so theta stays continuous (a + // modulated sine would kink whenever the rate changed). + const theta = TAU * (rateMean * u + + (rateSwing / (TAU * rateMod)) * (Math.cos(phase0) - Math.cos(TAU * rateMod * u + phase0))); + const pulse = 0.5 - 0.5 * Math.cos(theta); + const amp = 0.55 + 0.2 * Math.sin(TAU * ampMod * u + phase1); + return clamp01(amp * pulse); + } + + return { + /** + * The mouth opening at `t`, in the caller's own time base. Before `start` + * it is 0, and after `stop()` it fades to 0 within `CLOSE_SECONDS`. + * + * @param {number} t + * @returns {number} 0..1 + */ + value(t) { + const time = Number.isFinite(t) ? t : 0; + if (time > lastAt) lastAt = time; + if (time < startAt) return 0; + const fading = time - stopAt; + if (fading > 0) { + const fade = 1 - fading / CLOSE_SECONDS; + if (fade <= 0) return 0; + return raw(time - startAt) * fade; + } + return raw(time - startAt); + }, + + /** Begin (or restart) the envelope at `at`, opening from closed. */ + start(at = 0) { + startAt = Number.isFinite(at) ? at : 0; + stopAt = Infinity; + lastAt = startAt; + }, + + /** Stop: the envelope decays to 0 from wherever it is. */ + stop() { + stopAt = lastAt; + }, + }; +} + +/** + * Map a 0..1 level to the app's mouth-opening range. + * + * WHY not `level` straight through: a half-open mouth is the readable one, and + * a full gape looks like a shout. 0 stays 0 (a closed mouth must stay closed) + * and 1 lands at 0.55. `gain` lets a caller push the mouth wider for a loud + * passage, but the result is clamped to 0..0.9 so it is never a full gape. + * + * @param {number} level + * @param {number} [gain=1] + * @returns {number} 0..0.9 + */ +export function levelToMouth(level, gain = 1) { + if (!Number.isFinite(level)) return 0; + const scale = Number.isFinite(gain) ? gain : 1; + return clamp(clamp01(level) * 0.55 * scale, 0, 0.9); +} + +/** + * Roughly how long a line takes to say, in seconds **at rate 1**. + * + * A Japanese syllable is about 0.155 s, so the count of characters is a good + * enough clock - and it is the only clock available once there is no voice to + * listen to. An empty line still gets a few seconds, so the button never looks + * like it did nothing. + * + * @param {string} text + * @returns {number} seconds, 1.1..40 (or 3 for an empty line) + */ +export function speakingSeconds(text) { + const chars = [...String(text ?? '').trim()].length; + if (chars === 0) return 3; + return clamp(0.55 + chars * 0.155, 1.1, 40); +} + +/** + * The mouth-flap animation. + * + * `start(text, { rate })` opens the mouth in the seeded syllable rhythm and + * stops itself once the line would have been said, then reports `onEnd`. A + * second `start` restarts it, and `stop()` fades the mouth shut early - which is + * what the panel's button does while it is running. + * + * @param {{ + * onLevel?: (level: number) => void, + * onEnd?: () => void, + * }} [options] + */ +export function createMouthFlap({ onLevel, onEnd } = {}) { + const envelope = createMouthEnvelope(); + let handlers = { onLevel, onEnd }; + let timer = null; + let closing = false; + let running = false; + let startedAt = 0; + let lastEmit = 0; + let currentRate = 1; + /** The envelope's own time at which the line is over. */ + let limitAt = Infinity; + + const nowSeconds = () => (globalThis.performance?.now?.() ?? Date.now()) / 1000; + const rafSupported = typeof globalThis.requestAnimationFrame === 'function'; + const schedule = (fn) => (rafSupported + ? globalThis.requestAnimationFrame(fn) + : globalThis.setTimeout(fn, 1000 / LEVEL_HZ)); + const unschedule = (id) => { + if (id == null) return; + if (rafSupported) globalThis.cancelAnimationFrame(id); + else globalThis.clearTimeout(id); + }; + + /** + * One level sample, about 30 times a second. Once the line has run its + * length the loop stops itself, and while the moth is closing it keeps going + * until the mouth is shut - so no timer is ever left behind. + */ + function tick() { + timer = null; + const stamp = nowSeconds(); + const elapsed = (stamp - startedAt) * currentRate; + if (!closing && elapsed >= limitAt) { + closing = true; + envelope.stop(); + } + const level = envelope.value(elapsed); + if (closing || stamp - lastEmit >= 1 / LEVEL_HZ - 0.001) { + lastEmit = stamp; + handlers.onLevel?.(level); + } + if (closing && level <= 0) { + running = false; + handlers.onEnd?.(); + return; + } + timer = schedule(tick); + } + + /** + * Start flapping. `seconds` overrides the length guessed from the text. + * + * @param {string} text + * @param {{ rate?: number, seconds?: number }} [options] + * @returns {number} how long the flap will run, in seconds + */ + function start(text, { rate = 1, seconds = null } = {}) { + stopNow(); + currentRate = clamp(Number.isFinite(rate) ? rate : 1, 0.2, 4); + limitAt = Number.isFinite(seconds) && seconds > 0 ? seconds : speakingSeconds(text); + startedAt = nowSeconds(); + lastEmit = 0; + closing = false; + running = true; + envelope.start(0); + timer = schedule(tick); + return limitAt / currentRate; + } + + /** Clear the timer immediately (no fade). */ + function stopNow() { + unschedule(timer); + timer = null; + closing = false; + running = false; + envelope.stop(); + } + + /** Fade the mouth shut and let the loop clean itself up. */ + function stop() { + if (timer == null) { + running = false; + envelope.stop(); + return; + } + closing = true; + envelope.stop(); + } + + function dispose() { + stopNow(); + handlers = { onLevel: null, onEnd: null }; + } + + return { + get running() { + return running; + }, + start, + stop, + dispose, + }; +} diff --git a/public/bluebey-studio/src/outline.js b/public/bluebey-studio/src/outline.js new file mode 100644 index 0000000..127d576 --- /dev/null +++ b/public/bluebey-studio/src/outline.js @@ -0,0 +1,368 @@ +import * as THREE from 'three'; + +/** + * A screen-space outline: the label version of "line art". + * + * The studio's other outline is an *inverted hull* - a copy of a mesh, expanded + * along its normals, drawn back-faces-only. That is cheap and its line is + * computed from the geometry, so it comes out smooth, but it cannot outline a + * thin closed solid: a leaf blade is 0.026 units thick and the hull expands by + * 0.022 in every direction, so the expanded front and back cross inside the leaf + * and the line breaks up. No amount of mesh fixing removes that; it is the + * technique. + * + * So the thin overlapping parts - the waist leaves, and the nose in the + * line-art styles - are handed to this pass instead, and everything else keeps + * its hull (see MODEL-GUIDE.md §5). This module renders the scene once into a + * buffer holding a *label* per pixel, then draws a full-screen pass that inks + * pixels where the labels disagree. + * + * ## Why labels, not coverage + * + * The parts want different lines: + * + * - a LEAF that runs into the body must NOT get a line along the intersection. + * Such a line reads as the leaf sinking into the body, and the original + * artwork does not draw one either - the leaf simply passes behind the body. + * - the NOSE is the opposite. It is a bump sitting on the body, so the ring + * where it meets the body *is* its outline. In a line drawing there is no + * colour to read the nose by, so that ring is the only thing that shows it. + * + * A single "coverage" cannot express that difference, so each part writes a + * label: + * + * 0 (paper) nothing is there + * BEHIND something that is merely behind: it still writes depth, so it + * hides what is behind *it*, but a leaf in front of it is still + * outlined - a foot behind the skirt does not swallow the leaf's + * edge. This is the default for meshes nobody claimed. + * SOLID the body: the leaves *emerge* from it, so a leaf must not be + * outlined where it meets it (that reads as the leaf sinking into + * the body, and the original artwork draws no line there either). + * LEAF a leaf + * NOSE the nose + * + * The label is read with `NearestFilter`, because it is an identity, not a + * colour: filtering it would blend two labels into a third value that means + * neither. + * + * ## What this pass deliberately does not do + * + * It does not look for folds, and so it needs no normals at all - only the + * labels. Surface shape was tried (a second buffer of filtered normals, with the + * fold test gated to the leaves) and taken out again: it did bring back the + * lines where one leaf lies over the next, but it cost a whole extra scene pass + * and it read as a grainy speckle across the skirt, because a leaf is thin + * enough (0.026) that its own rim is a 145-degree crease and the mesh arrives + * with those edges split into separate vertices. The recipe is written up in + * MODEL-GUIDE.md §5-2c if it is ever wanted back - for instance to make raised + * leaf veins show. + * + * Callers must keep the outline hulls out of this pass: `exclude()` takes them + * (see `outlineExclusion` in main.js). A hull is an expanded copy of a mesh, and + * this pass swaps a front-side material onto everything it sees, so a hull would + * paint an enlarged copy of the character's own label over all of it. + * + * A mesh that should *hide* parts of the character without being outlined itself + * (the invisible wall) is handed to `occlude()` instead: it is drawn for its + * depth alone, with a paper label, so the leaves and the nose buried behind it + * get no label - and therefore no line. + */ + +// Taps around the pixel, averaged into a coverage. 16 gives a smooth ramp. +const TAPS = 16; + +/** Written into the alpha channel; see the note above. */ +export const BEHIND_LABEL = 0.1; +export const SOLID_LABEL = 0.4; +export const LEAF_LABEL = 0.7; +export const NOSE_LABEL = 1; + +const VERTEX = /* glsl */` + varying vec2 vUv; + void main() { + vUv = uv; + // The quad is already in clip space, so no camera maths is involved. + gl_Position = vec4(position.xy, 0.0, 1.0); + } +`; + +const FRAGMENT = /* glsl */` + uniform sampler2D uLabels; + uniform vec2 uTexel; + uniform vec3 uColor; + uniform float uRadius; + varying vec2 vUv; + + const float TAU = 6.28318530718; + + // The labels, read back out of the alpha channel (see the top of this file). + // They are exact values, not a gradient, so the tests are simple comparisons. + // + // "paper" means "does not block a line": the untouched background (0) and the + // parts that are merely behind (0.1). Only the body (0.4) blocks one. + float paper(float a) { return 1.0 - step(0.2, a); } + float drawn(float a) { return step(0.6, a); } + float nose(float a) { return step(0.85, a); } + + void main() { + vec4 here = texture2D(uLabels, vUv); + float ink = 0.0; + + for (int i = 0; i < ${TAPS}; i++) { + float angle = (float(i) / float(${TAPS})) * TAU; + vec2 offset = vec2(cos(angle), sin(angle)) * uTexel * uRadius; + vec4 there = texture2D(uLabels, vUv + offset); + + float edge = 0.0; + // 1. a drawn part against the paper. A leaf against the *body* fires + // nothing here, which is the point: the leaf simply passes behind it. + edge = max(edge, max(drawn(here.a) * paper(there.a), + drawn(there.a) * paper(here.a))); + // 2. the nose against anything that is not the nose, so its whole ring - + // including the part against the body - is inked. + edge = max(edge, abs(nose(here.a) - nose(there.a))); + + ink += edge; + } + + // Averaging the taps turns the flag into a coverage, and that is what takes + // the steps out of the line: a pixel half over an edge gets half the ink, so + // the line gets soft edges instead of landing on the pixel grid. It also + // reads lighter than a hard band of the same width, which is what makes it + // sit next to the hull's line without looking heavier. + ink /= float(${TAPS}); + // The ramp starts at about three taps out of sixteen rather than at one, so an + // isolated tap is not enough to ink a pixel; a real edge has half the taps + // crossing it, so asking for a few costs nothing there. + float line = smoothstep(0.19, 0.45, ink); + if (line < 0.02) discard; + gl_FragColor = vec4(uColor, line); + } +`; + +export function createScreenOutline({ renderer, scene, camera, width = 1280, height = 800 }) { + const target = new THREE.WebGLRenderTarget(width, height, { + // The label is an identity, not a shade: filtering would blend two labels into + // a third value that means neither of them. The softness of the line comes + // from spreading the taps in the shader, not from blurring this buffer. + minFilter: THREE.NearestFilter, + magFilter: THREE.NearestFilter, + depthBuffer: true, + // The alpha channel carries the label, so it must not be filled in. + format: THREE.RGBAFormat, + }); + target.texture.generateMipmaps = false; + + /** + * The label each part writes into the alpha channel. + * + * `transparent: true` keeps three.js from defining `OPAQUE`, which would force + * the alpha to 1, and `blending: NoBlending` makes the fragment *replace* the + * pixel instead of blending into it, so the labels stay exact. `depthWrite` + * stays on, which is what hides the parts that are behind something else. + */ + const labelMaterials = new Map(); + function labelMaterialFor(label) { + let material = labelMaterials.get(label); + if (material) return material; + material = new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: label, + blending: THREE.NoBlending, + depthWrite: true, + }); + material.customProgramCacheKey = () => `bluebey-outline-label-${label}`; + labelMaterials.set(label, material); + return material; + } + + const uniforms = { + uLabels: { value: target.texture }, + uTexel: { value: new THREE.Vector2(1 / width, 1 / height) }, + uColor: { value: new THREE.Color('#2a1e33') }, + uRadius: { value: 1.4 }, + }; + const material = new THREE.ShaderMaterial({ + uniforms, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + transparent: true, + depthTest: false, + depthWrite: false, + toneMapped: false, + }); + + // A single quad in clip space, with the camera taken out of the equation. + const quadScene = new THREE.Scene(); + const quadCamera = new THREE.Camera(); + quadScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material)); + + /** Objects that must not appear in the outline (ground, shadows, gizmos). */ + const hidden = []; + /** Meshes whose material (and render order) is borrowed for the label pass. */ + const swapped = []; + const exclusion = new Set(); + + /** + * Meshes that only *occlude* in the label pass (the 見えない壁). + * + * They are drawn with a paper label and both sides, so their depth hides + * whatever is behind them: a leaf or the nose buried in the wall then gets no + * label at all, and so no line. Their own silhouette inks nothing either, + * because paper against paper is not an edge - which is what keeps the wall + * itself invisible instead of drawing a rectangle. + */ + const occluders = new Set(); + let occluderMaterial = null; + + function occluderMaterialFor() { + if (!occluderMaterial) { + occluderMaterial = new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0, // paper: it never reads as a drawn part + blending: THREE.NoBlending, + depthWrite: true, + side: THREE.DoubleSide, // the wall can be seen from either side + }); + } + return occluderMaterial; + } + + /** Objects to draw in the label pass for their depth alone (see `occluders`). */ + function occlude(objects) { + occluders.clear(); + for (const object of objects) if (object) occluders.add(object); + } + + /** + * When set, these meshes write the given label and everything else writes + * BEHIND. Without it the whole scene shares one label. + * + * @type {Map<THREE.Object3D, number>|null} + */ + let focus = null; + + function setSize(nextWidth, nextHeight) { + const w = Math.max(1, Math.round(nextWidth)); + const h = Math.max(1, Math.round(nextHeight)); + if (target.width === w && target.height === h) return; + target.setSize(w, h); + uniforms.uTexel.value.set(1 / w, 1 / h); + } + + /** Layers/objects to leave out of the label pass (ground, shadow, gizmo, hulls). */ + function exclude(objects) { + exclusion.clear(); + for (const object of objects) if (object) exclusion.add(object); + } + + /** + * Hand the pass a split: these meshes get this label, and everything else is + * drawn as BEHIND - present, so it still hides what is behind it, but not + * blocking a leaf's outline. Callers should therefore name every part that a + * leaf must not be outlined against (the body) as well as the outlined ones + * (the leaves, the nose). + * + * `null` puts the whole scene back on one label. + * + * @param {Array<[THREE.Object3D, number]>|null} parts + */ + function only(parts) { + focus = parts ? new Map(parts) : null; + } + + /** + * Draw one frame. `baseRender` renders the scene the normal way; it is called + * between the label pass and the ink so the ink lands on top of it. + */ + function render(baseRender, options = {}) { + const enabled = options.enabled !== false; + if (!enabled) { + baseRender(); + return; + } + + uniforms.uColor.value.set(options.color ?? '#2a1e33'); + uniforms.uRadius.value = Math.max(0.6, options.radius ?? 1.4); + + // --- 1. the labels pass -------------------------------------------- + const previousOverride = scene.overrideMaterial; + const previousClear = renderer.getClearColor(new THREE.Color()); + const previousAlpha = renderer.getClearAlpha(); + hidden.length = 0; + swapped.length = 0; + const hide = (object) => { + if (object.visible) { + hidden.push(object); + object.visible = false; + } + }; + for (const object of exclusion) hide(object); + + if (focus) { + // A per-mesh material, so the meshes outside `focus` can occlude without + // contributing a line. (`overrideMaterial` would put one label on all of + // them, and a leaf ending on a foot would then be read the same way as a + // leaf ending on the body.) + // + // The occluders are also pushed to the front of the draw order. Every body + // mesh sits at the same origin, so three.js would otherwise fall back to + // insertion order and could draw a far-side leaf *before* the body that + // hides it - and writing a label cannot erase what is already in the + // buffer, only stop it being drawn. Drawn first, the depth test does it. + scene.traverse((object) => { + if (!object.isMesh || !object.visible) return; + swapped.push([object, object.material, object.renderOrder]); + if (occluders.has(object)) { + // Depth only, drawn first: the wall hides the labels behind it. + object.material = occluderMaterialFor(); + object.renderOrder = -1; + return; + } + const label = focus.get(object) ?? BEHIND_LABEL; + object.material = labelMaterialFor(label); + if (label < 0.6) object.renderOrder = -1; + }); + scene.overrideMaterial = null; + } else { + // No split given: nothing is outlined, so no label may read as "drawn". + scene.overrideMaterial = labelMaterialFor(SOLID_LABEL); + } + + renderer.setRenderTarget(target); + renderer.setClearColor(0x000000, 0); + renderer.clear(true, true, false); + renderer.render(scene, options.camera ?? camera); + + renderer.setRenderTarget(null); + scene.overrideMaterial = previousOverride; + for (const object of hidden) object.visible = true; + for (const [object, swappedMaterial, renderOrder] of swapped) { + object.material = swappedMaterial; + object.renderOrder = renderOrder; + } + renderer.setClearColor(previousClear, previousAlpha); + + // --- 2. the scene itself ------------------------------------------- + baseRender(); + + // --- 3. the ink, straight over the top ----------------------------- + renderer.autoClear = false; + renderer.render(quadScene, quadCamera); + renderer.autoClear = true; + } + + function dispose() { + target.dispose(); + for (const material of labelMaterials.values()) material.dispose(); + labelMaterials.clear(); + material.dispose(); + occluderMaterial?.dispose(); + for (const child of quadScene.children) child.geometry.dispose(); + } + + return { render, setSize, exclude, occlude, only, uniforms, target, material, dispose }; +} diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js new file mode 100644 index 0000000..28ea8f9 --- /dev/null +++ b/public/bluebey-studio/src/panel.js @@ -0,0 +1,2508 @@ +import { + h, section, subhead, hint, slider, check, segmented, buttons, + colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast, +} from './ui.js'; +import { + FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS, + defaultState, applyPatch, +} from './presets.js'; +import { STYLE_DEFS } from './styles.js'; +import { EYE_VARIANTS } from './model.js'; +import { ENVIRONMENTS } from './look.js'; +import { BACKGROUND_PRESETS, EFFECT_PRESETS } from './background.js'; +import { PROP_LIBRARY, applyPropFaceScale } from './props.js'; +import { + GION_SHEETS, gionSheetUrl, cropFromMarquee, fitSheet, normalizeRect, clamp, DEFAULT_STAMP_WIDTH, +} from './gion.js'; + +/** Fallbacks for the fields an older settings file may predate. */ +const BODY_COLOR_FALLBACKS = { + body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0', + leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0', +}; +const CAPTION_SLIDER_FALLBACKS = { + fontSize: 34, lineHeight: 1.42, padding: 18, radius: 24, borderWidth: 4, +}; +const CAPTION_COLOR_FALLBACKS = { textColor: '#3f2b52', bubbleColor: '#ffffff', borderColor: '#55386e' }; +const MOUTH_FLAP_FALLBACKS = { rate: 1, mouthGain: 1 }; +const LOOK_AT_FALLBACKS = { x: 0, y: 2.6, z: 3, amount: 1 }; + +/** + * The licence reminder the three image-import buttons show before they open the + * file picker. Kept in one place so the wording can change without hunting for + * each button. + */ +const IMAGE_LICENSE_NOTICE = '読み込む画像は、ご自身で権利をお持ちか、利用許諾のあるものに限ります。ライセンスをご確認ください。'; + +/** True when the user confirmed they have the right to use the image. */ +function confirmImageLicense() { + return window.confirm(IMAGE_LICENSE_NOTICE); +} + +/** + * The panel had no multi-line field, and the two features that take free prose + * (the caption and the spoken text) want the same shape, so it is built once + * here rather than growing the widget kit for a single caller. + */ +function textArea({ label, value = '', rows = 3, onChange }) { + const input = h('textarea', { + rows, + style: { + width: '100%', + boxSizing: 'border-box', + resize: 'vertical', + font: 'inherit', + fontFamily: 'ui-monospace, "Hiragino Kaku Gothic ProN", "Noto Sans JP", monospace', + fontSize: '11.5px', + lineHeight: '1.5', + padding: '6px 8px', + border: '1px solid #e4dff0', + borderRadius: '8px', + background: '#fff', + color: '#2b2433', + }, + }); + input.value = value ?? ''; + input.addEventListener('input', () => onChange?.(input.value)); + return { + el: controlRow(label, input, { wide: true }), + // Assigning the same text again would jump the caret to the end, so only + // write when the value actually differs. + set(v) { const next = v ?? ''; if (input.value !== next) input.value = next; }, + get: () => input.value, + }; +} + +/** + * The placed prop groups, in state order. + * + * WHY this climbs the scene: the app owns the prop rebuild (see `applyProps` in + * src/main.js) and hands `props.js` only the whole-prop scale, so a sign's + * per-item `faceScale` has to be re-applied to the freshly built mesh by the one + * place that knows that value - this panel. There is no direct scene reference + * here, so this walks up from the model's container and collects the props: they + * are the only objects tagged `userData.propId`, and the app adds them in order. + */ +function placedPropGroups(app) { + const scene = app.container?.parent; + if (!scene) return []; + const groups = []; + scene.traverse((object) => { + if (object.userData?.propId) groups.push(object); + }); + return groups; +} + +/** + * Builds the whole control panel and wires it to the app state. + * + * Widgets never call the heavy "apply everything" path while they are being + * dragged; they patch one scope and ask the app to refresh just that part. + */ +export function buildPanel(app, root) { + const state = app.state; + const model = app.model; + const rig = app.rig; + + const syncers = []; + const boneSyncers = []; + const sync = () => { for (const fn of syncers) fn(); }; + const syncBones = () => { for (const fn of boneSyncers) fn(); }; + + /** Patch one scope of the state without re-reading the whole UI. */ + /** Replace the state and re-read every widget (used by presets and loads). */ + const replace = (value, scope = 'all') => app.actions.applyState(value, { scope, sync: true }); + + root.replaceChildren(); + + // Four tabs instead of one long scroll: the panel used to be a wall of + // controls, and everything is easier to find this way. Each tab now carries a + // small stroke icon so the row can be scanned at a glance. + const tabBar = tabs(root, [ + { + id: 'pose', + label: 'ポーズ', + // A standing figure: head, body, arms and legs. + icon: 'M12 3.6a2.1 2.1 0 1 1 0 4.2 2.1 2.1 0 1 1 0-4.2ZM12 7.8v6.4M12 10.3 8.4 12.5M12 10.3l3.6 2.2M12 14.2 9 20.4M12 14.2l3 6.2', + }, + { + id: 'face', + label: '表情', + // A smiley face: an outline, two eyes and a smile. + icon: 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0', + }, + { + id: 'view', + label: '見た目', + // A painter's palette: the outline, the thumb hole and four colour dots. + icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.6-.7 1.6-1.7 0-.4-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1a1.6 1.6 0 0 1 1.7-1.7h2c3 0 5.5-2.5 5.5-5.6C22 6 17.5 2 12 2ZM13.5 6.5h.01M17.5 10.5h.01M8.5 7.5h.01M6.5 12.5h.01', + }, + { + id: 'export', + label: '書き出し', + // A download arrow dropping into a tray. + icon: 'M12 3.8v9.6M8.2 9.8 12 13.6l3.8-3.8M4.5 16.5v1.6a1.8 1.8 0 0 0 1.8 1.8h11.4a1.8 1.8 0 0 0 1.8-1.8v-1.6', + }, + ]); + const tabPose = tabBar.panels.pose; + const tabFace = tabBar.panels.face; + const tabView = tabBar.panels.view; + const tabExport = tabBar.panels.export; + + /* ------------------------------------------------------------------ pose */ + + // ポーズ例: ready-made poses, because most people start by picking one. + const posePresetSection = section(tabPose, 'ポーズ例'); + posePresetSection.add(buttons({ + items: POSE_PRESETS.map((preset) => ({ + id: preset.id, + label: preset.label, + onClick: () => applyPosePreset(preset), + })), + })); + + // 全身とボーンのスライダー: pick a bone, then turn it on the three axes. + const poseSection = section(tabPose, '全身とボーンのスライダー'); + + const boneList = h('div', { class: 'bone-list' }); + const boneButtons = new Map(); + for (const bone of model.bones) { + const button = h('button', { type: 'button', class: 'bone-item' }, + h('span', { text: bone.label }), + h('small', { text: bone.name })); + button.addEventListener('click', () => rig.select(bone.name)); + boneButtons.set(bone.name, button); + boneList.append(button); + } + poseSection.add(controlRow(null, boneList, { wide: true })); + + const axisSliders = { + x: slider({ label: 'よこ(X)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('x', v) }), + y: slider({ label: 'たて(Y)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('y', v) }), + z: slider({ label: 'ねじり(Z)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('z', v) }), + }; + for (const widget of Object.values(axisSliders)) poseSection.add(widget.el); + + function setAxis(axis, value) { + const name = rig.selected; + if (!name) return; + const delta = rig.getDelta(name); + delta[axis] = value; + rig.setDelta(name, delta); + app.actions.capturePose(); + app.needsRender = true; + } + + poseSection.add(hint('モデルをクリックすると、その場所にいちばん近いボーンが選ばれます。スライダーは「休めの姿勢からの差」です。')); + + // こまかい設定: the whole-body nudge and the rotation gizmo are tweaks, not the + // main way in, so they stay folded away inside the bone section. + const rootSliders = ['よこ移動', 'たて移動', 'おくゆき'].map((label, index) => slider({ + label, min: -2, max: 2, step: 0.01, value: 0, + format: (v) => v.toFixed(2), + onInput: (v) => { state.pose.root[index] = v; app.needsRender = true; }, + })); + const poseBox = details(poseSection.body, 'こまかい設定(ぜんたいをずらす・回転ギズモ)'); + for (const widget of rootSliders) poseBox.add(widget.el); + + const gizmoToggle = check({ + label: '回転ギズモを表示', + // Off by default: the rings belong to the model's surface, so having them + // drawn on top of the character is not what you want while composing a + // picture. The 'g' key toggles it too. + value: false, + onChange: (value) => { rig.setGizmoVisible(value); app.needsRender = true; }, + }); + poseBox.add(controlRow(null, gizmoToggle.el, { wide: true })); + + // --- 操作 ----------------------------------------------------------------- + const poseActions = section(tabPose, '操作'); + poseActions.add(buttons({ + items: [ + { id: 'resetBone', label: 'このボーンを戻す', onClick: () => { rig.reset(rig.selected); app.actions.capturePose(); syncBones(); app.needsRender = true; } }, + { id: 'resetAll', label: 'ポーズを全部戻す', onClick: () => app.actions.resetPose() }, + { id: 'random', label: '少しランダムに', onClick: () => randomPose() }, + { id: 'undo', label: '元に戻す', onClick: () => app.actions.undo() }, + { id: 'redo', label: 'やり直す', onClick: () => app.actions.redo() }, + ], + })); + poseActions.add(hint('「元に戻す」「やり直す」は、ポーズだけでなく色や背景など、すべての操作に効きます。')); + + /** Accepts a preset object (from the buttons) or a preset id (from tools). */ + function applyPosePreset(presetOrId) { + const preset = typeof presetOrId === 'string' + ? POSE_PRESETS.find((item) => item.id === presetOrId) + : presetOrId; + if (!preset) return; + state.pose = { bones: {}, root: [0, 0, 0] }; + applyPatch(state.pose, preset.pose ?? {}); + if (!state.pose.root) state.pose.root = [0, 0, 0]; + replace({}, 'pose'); + } + + function randomPose() { + const names = model.bones.map((bone) => bone.name); + for (const name of names) { + if (name === 'master') continue; + const scale = name.startsWith('arm') ? 22 : 12; + rig.setDelta(name, { + x: (Math.random() - 0.5) * scale, + y: (Math.random() - 0.5) * scale, + z: (Math.random() - 0.5) * scale, + }); + } + app.actions.capturePose(); + syncBones(); + app.needsRender = true; + } + + /* ------------------------------------------------------------------ face */ + + let faceSection = section(tabFace, 'プリセット'); + + faceSection.add(buttons({ + items: FACE_PRESETS.map((preset) => ({ + id: preset.id, + label: preset.label, + onClick: () => applyFacePreset(preset.id), + })), + })); + + function applyFacePreset(id) { + const preset = FACE_PRESETS.find((item) => item.id === id); + if (!preset) return; + state.face = defaultState().face; + applyPatch(state.face, preset.face); + app.actions.refresh('face'); + sync(); + } + + // --- eyes + faceSection = section(tabFace, '目'); + + const eyeWidgets = {}; + // The link switch sits directly above the *left* eye's open slider, because that + // is the slider it drives - it was previously below both eyes, where it looked + // like it belonged to whatever came next. + const linkedToggle = check({ + label: '左右を連動させる', + value: true, + onChange: (value) => { + state.face.eyes.linked = value; + if (value) { + // Catch the two eyes up with each other, so switching the link on does + // something visible even if they had drifted apart. + state.face.eyes.right.open = state.face.eyes.left.open; + state.face.eyes.right.closed = state.face.eyes.left.closed; + state.face.eyes.right.closedLines = state.face.eyes.left.closedLines; + // The tears mirror as a pair: the size and the height match, while the + // sideways position and the tilt flip sign. + state.face.eyes.right.tear = state.face.eyes.left.tear ?? 0; + state.face.eyes.right.tearY = state.face.eyes.left.tearY ?? 0; + state.face.eyes.right.tearX = -(state.face.eyes.left.tearX ?? 0); + state.face.eyes.right.tearTilt = -(state.face.eyes.left.tearTilt ?? 0); + } + app.actions.refresh('face'); + sync(); + }, + }); + for (const key of ['left', 'right']) { + const label = key === 'left' ? '左の目' : '右の目'; + const open = slider({ + label: `${label}の開き`, + min: 0, max: 1, step: 0.01, value: 1, + format: (v) => (v < 0.02 ? '閉じ' : v > 0.98 ? '開き' : `${Math.round(v * 100)}%`), + onInput: (value) => { + state.face.eyes[key].open = value; + if (state.face.eyes.linked) state.face.eyes[key === 'left' ? 'right' : 'left'].open = value; + app.actions.refresh('face'); + sync(); + }, + }); + const closed = segmented({ + label: `${label}の閉じ方`, + options: [ + { value: 'line', label: '線' }, + { value: 'three', label: '3の目' }, + { value: 'arch', label: 'わらう' }, + ], + value: 'line', + onChange: (value) => { + state.face.eyes[key].closed = value; + // 線 and わらう are the shut-eye artwork, so shut the eye for the user. + state.face.eyes[key].open = 0; + if (state.face.eyes.linked) { + const other = state.face.eyes[key === 'left' ? 'right' : 'left']; + other.open = 0; + other.closed = value; + } + app.actions.refresh('face'); + sync(); + }, + }); + const closedLines = segmented({ + label: `${label}の線の数`, + options: [ + { value: '1', label: '1本' }, + { value: '2', label: '2本' }, + { value: '3', label: '3本' }, + ], + value: '1', + hint: '2本以上は端点がつながった形になります', + onChange: (value) => { + state.face.eyes[key].closedLines = Number(value); + state.face.eyes[key].closed = 'line'; + state.face.eyes[key].open = 0; + if (state.face.eyes.linked) { + const other = state.face.eyes[key === 'left' ? 'right' : 'left']; + other.open = 0; + other.closed = 'line'; + other.closedLines = Number(value); + } + app.actions.refresh('face'); + sync(); + }, + }); + // The direction of a "3" eye. It used to be tied to the nose side, which made + // the pair a mirror image; now each eye says which way its 3 faces. + const threeFlip = segmented({ + label: `${label}の3の向き`, + options: [ + { value: 'normal', label: '3' }, + { value: 'flip', label: '3(逆)' }, + ], + value: 'normal', + hint: '「3の目」のときの向きです。左右で別々に選べます', + onChange: (value) => { + state.face.eyes[key].threeFlip = value === 'flip'; + state.face.eyes[key].closed = 'three'; + state.face.eyes[key].open = 0; + app.actions.refresh('face'); + sync(); + }, + }); + // Where this eye sits: per eye, so a lopsided face (one eye slid across) is + // possible. + const eyeX = slider({ + label: `${label}のよこ位置`, min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (value) => { state.face.eyes[key].eyeX = value; app.actions.refresh('face'); }, + }); + // Tears are per eye, and so are where they hang and which way they lean. + // When the eyes are linked, a tear edit mirrors onto the other eye: the size + // and the height are copied, but the sideways position and the tilt flip + // sign. Two drops leaning the same way read as a tilted face, not as crying. + const otherKey = key === 'left' ? 'right' : 'left'; + const setTear = (patch) => { + Object.assign(state.face.eyes[key], patch); + if (state.face.eyes.linked) { + const mirrored = { ...patch }; + if (patch.tearX != null) mirrored.tearX = -patch.tearX; + if (patch.tearTilt != null) mirrored.tearTilt = -patch.tearTilt; + Object.assign(state.face.eyes[otherKey], mirrored); + } + app.actions.refresh('face'); + sync(); + }; + const tear = slider({ + label: `${label}の涙`, min: 0, max: 1.6, step: 0.01, value: 0, format: (v) => v.toFixed(2), + onInput: (value) => setTear({ tear: value }), + }); + const tearY = slider({ + label: `${label}の涙の高さ`, min: -80, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (value) => setTear({ tearY: value }), + }); + const tearX = slider({ + label: `${label}の涙のよこ位置`, min: -150, max: 150, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (value) => setTear({ tearX: value }), + }); + const tearTilt = slider({ + label: `${label}の涙の傾き`, min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (value) => setTear({ tearTilt: value }), + }); + eyeWidgets[key] = { open, closed, closedLines, threeFlip, eyeX, tear, tearY, tearX, tearTilt }; + if (key === 'left') faceSection.add(controlRow(null, linkedToggle.el, { wide: true })); + faceSection.add(open.el); + faceSection.add(closed.el); + faceSection.add(closedLines.el); + faceSection.add(threeFlip.el); + faceSection.add(eyeX.el); + faceSection.add(tear.el); + faceSection.add(tearY.el); + faceSection.add(tearX.el); + faceSection.add(tearTilt.el); + } + + // One button to put every eye setting back to the artwork's defaults. The + // artwork *source* is kept: resetting the numbers should not throw away a + // hand-drawn image the user loaded. + faceSection.add(buttons({ + items: [{ + id: 'eyesReset', + label: '目の設定をリセット', + onClick: () => { + const source = state.face.eyes.source; + state.face.eyes = { ...defaultState().face.eyes, source }; + app.actions.refresh('face'); + sync(); + }, + }], + })); + + const irisShapeSegment = segmented({ + label: '瞳の形', + options: [ + { value: 'circle', label: '丸' }, + { value: 'heart', label: 'ハート' }, + ], + value: 'circle', + onChange: (value) => { + state.face.eyes.left.irisShape = value; + state.face.eyes.right.irisShape = value; + app.actions.refresh('face'); + }, + }); + faceSection.add(irisShapeSegment.el); + + const linkedNote = hint('「左右を連動させる」を入れると、開き・閉じ方・線の数・涙が両目でそろいます。涙のよこ位置と傾きは左右で反転し、線対称になります。はずすと片目ずつ変えられるので、ウインクも作れます。'); + faceSection.add(linkedNote); + + const lookPad = xyPad({ + value: { x: 0, y: 0 }, + center: 'eye', + onChange: ({ x, y }) => { + state.face.eyes.left.lookX = x; + state.face.eyes.left.lookY = y; + state.face.eyes.right.lookX = x; + state.face.eyes.right.lookY = y; + app.actions.refresh('face'); + sync(); + }, + }); + faceSection.add(controlRow('目線', h('div', { class: 'pad-wrap' }, + lookPad.el, + h('p', { class: 'hint', text: 'ドラッグで両目が動きます。ダブルクリックで正面に戻ります。' })))); + + const eyeAdvanced = details(faceSection.body, 'こまかい設定(瞳・ハート・涙・まぶた)'); + const irisScaleSlider = slider({ + label: '瞳の大きさ', min: 0.4, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (value) => { state.face.eyes.irisScale = value; app.actions.refresh('face'); }, + }); + const heartScaleSlider = slider({ + label: 'ハートの大きさ', min: 0.4, max: 2.6, step: 0.01, value: 2, format: (v) => v.toFixed(2), + onInput: (value) => { state.face.eyes.heartScale = value; app.actions.refresh('face'); }, + }); + const heartWhiteToggle = check({ + label: 'ハートのとき白目を消す', + value: true, + onChange: (value) => { state.face.eyes.heartHideWhite = value; app.actions.refresh('face'); }, + }); + const heartColorField = colorField({ + label: 'ハートの色', value: '#e0344f', + swatches: ['#e0344f', '#ff5f8f', '#c2185b', '#150e1b'], + onChange: (v) => { state.face.eyes.heartColor = v; app.actions.refresh('face'); }, + }); + const lookMaxSlider = slider({ + label: '目線の振れ幅', min: 0, max: 1.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (value) => { state.face.eyes.lookMax = value; app.actions.refresh('face'); }, + }); + const lidWidthSlider = slider({ + label: 'まぶたの太さ', min: 0, max: 30, step: 1, value: 14, format: (v) => `${Math.round(v)}`, + onInput: (value) => { state.face.eyes.lidWidth = value; app.actions.refresh('face'); }, + }); + const lowerLidSlider = slider({ + label: '下まぶた', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), + onInput: (value) => { state.face.eyes.lowerLid = value; app.actions.refresh('face'); }, + }); + eyeAdvanced.add(irisScaleSlider.el); + eyeAdvanced.add(heartScaleSlider.el); + eyeAdvanced.add(controlRow(null, heartWhiteToggle.el, { wide: true })); + eyeAdvanced.add(heartColorField.el); + eyeAdvanced.add(lookMaxSlider.el); + eyeAdvanced.add(lidWidthSlider.el); + eyeAdvanced.add(lowerLidSlider.el); + + const highlightToggle = check({ + label: '瞳のハイライト', + value: true, + onChange: (value) => { state.face.eyes.highlight = value; app.actions.refresh('face'); }, + }); + eyeAdvanced.add(controlRow(null, highlightToggle.el, { wide: true })); + + const eyeColors = { + white: colorField({ label: '白目', value: '#ffffff', onChange: (v) => { state.face.eyes.white = v; app.actions.refresh('face'); } }), + iris: colorField({ label: '瞳', value: '#150e1b', onChange: (v) => { state.face.eyes.iris = v; app.actions.refresh('face'); } }), + line: colorField({ label: 'まぶたの線', value: '#55386e', onChange: (v) => { state.face.eyes.line = v; app.actions.refresh('face'); } }), + }; + const eyeColorBox = details(faceSection.body, '色(白目・瞳・まぶたの線)'); + for (const widget of Object.values(eyeColors)) eyeColorBox.add(widget.el); + + const eyeSource = selectField({ + label: '目のパーツ', + // 実際にモデルに入っている手描きパーツだけを一覧に出す(消したものが残ると混乱するため) + options: [ + { value: 'parametric', label: 'パラメータで作る(おすすめ)' }, + ...EYE_VARIANTS.filter((variant) => model.originals.eyes[variant.key]) + .map((variant) => ({ value: variant.key, label: `手描き:${variant.label}` })), + ], + value: 'parametric', + onChange: (value) => { state.face.eyes.source = value; app.actions.refresh('face'); }, + }); + faceSection.add(eyeSource.el); + + // 手描き画像の読み込み(GIMP などで描いた PNG をそのまま使えます) + const rebuildEyeOptions = () => { + const select = eyeSource.el.querySelector('select'); + if (!select) return; + const current = select.value; + select.replaceChildren(); + select.append(h('option', { value: 'parametric', text: 'パラメータで作る(おすすめ)' })); + for (const variant of EYE_VARIANTS) { + if (model.originals.eyes[variant.key]) { + select.append(h('option', { value: variant.key, text: `手描き:${variant.label}` })); + } + } + for (const [key, name] of app.face.customNames) { + select.append(h('option', { value: key, text: `読み込み:${name}` })); + } + select.value = current; + }; + rebuildEyeOptions(); + + const eyeImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } }); + eyeImagePicker.addEventListener('change', async () => { + const file = eyeImagePicker.files?.[0]; + eyeImagePicker.value = ''; + if (!file) return; + try { + await app.face.loadImage('eyes', file); + state.face.eyes.source = app.face.eyeSource; + rebuildEyeOptions(); + sync(); + toast(`目の画像「${file.name}」を読み込みました`); + } catch (error) { + console.error(error); + toast('画像を読み込めませんでした(PNG / JPG をお使いください)'); + } + }); + faceSection.add(eyeImagePicker); + faceSection.add(buttons({ + items: [{ id: 'eyeimg', label: '目の画像を読み込む(PNG)', onClick: () => { if (confirmImageLicense()) eyeImagePicker.click(); } }], + })); + + // --- eyebrows (the original character has none; this is extra range) + faceSection = section(tabFace, '眉'); + faceSection.add(hint('ぶるべーには眉がありません。怒った顔など、必要なときだけ出してください。')); + + const browToggle = check({ + label: '眉を表示', + value: false, + onChange: (value) => { state.face.eyes.brow.enabled = value; app.actions.refresh('face'); }, + }); + faceSection.add(controlRow(null, browToggle.el, { wide: true })); + + const browSliders = { + angle: slider({ + label: '角度', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { state.face.eyes.brow.angle = v; app.actions.refresh('face'); }, + }), + height: slider({ + label: '高さ', min: -40, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.brow.height = v; app.actions.refresh('face'); }, + }), + length: slider({ + label: '長さ', min: 0, max: 1.6, step: 0.01, value: 1, + format: (v) => (v < 0.02 ? '点' : v.toFixed(2)), + onInput: (v) => { state.face.eyes.brow.length = v; app.actions.refresh('face'); }, + }), + thickness: slider({ + label: '太さ', min: 0.4, max: 3.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.brow.thickness = v; app.actions.refresh('face'); }, + }), + spacing: slider({ + label: '間隔', min: -220, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.brow.spacing = v; app.actions.refresh('face'); }, + }), + curve: slider({ + label: '曲がり', min: 0, max: 0.4, step: 0.01, value: 0.14, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.brow.curve = v; app.actions.refresh('face'); }, + }), + taper: slider({ + label: '鼻側の細さ', min: 0, max: 1, step: 0.01, value: 1, + format: (v) => (v >= 0.995 ? 'そのまま' : (v <= 0.005 ? 'とがる' : v.toFixed(2))), + onInput: (v) => { state.face.eyes.brow.taper = v; app.actions.refresh('face'); }, + }), + }; + faceSection.add(hint('+の角度で内側が下がり、怒った感じになります。-で困り顔。' + + '高さは + で上がり、- にすると目にかくれて見えなくなります。')); + faceSection.add(browSliders.angle.el); + const browBox = details(faceSection.body, 'こまかい設定(高さ・長さ・太さ・色)'); + browBox.add(browSliders.height.el); + browBox.add(browSliders.length.el); + browBox.add(browSliders.thickness.el); + browBox.add(browSliders.spacing.el); + browBox.add(browSliders.curve.el); + browBox.add(browSliders.taper.el); + const browColor = colorField({ + label: '眉の色', value: '#55386e', + swatches: ['#55386e', '#2a1e33', '#7a5c2e', '#8a4a5e'], + onChange: (v) => { state.face.eyes.brow.color = v; app.actions.refresh('face'); }, + }); + browBox.add(browColor.el); + browBox.add(hint('「間隔」は左右の眉のへだたりです(+で広がり、-で内側に寄って、' + + '大きく-にすると左右がくっつきます)。「長さ」を 0 にすると点になります。' + + '「鼻側の細さ」を 0 にすると鼻側がとがります。')); + browBox.add(buttons({ + items: [ + { + id: 'golgo', + label: 'ゴル風', + onClick: () => { + // ゴルゴ13: a very thick, almost straight black wedge, wide at the outer + // end and tapering to a point at the nose, angled steeply down towards + // it, with the two brows sitting close together. + Object.assign(state.face.eyes.brow, { + enabled: true, + angle: 36, + height: 6, + length: 1.1, + thickness: 3.5, + curve: 0, + taper: 0, + spacing: -10, + color: '#12101a', + }); + app.actions.refresh('face'); + sync(); + }, + }, + { + id: 'ryotsu', + label: '両風', + onClick: () => { + // 両津勘吉: thick げじげじ brows that are joined in the middle, sitting + // low over the eyes. `spacing` is what closes the gap - at this length + // the two inner ends meet over the nose (see the panel's note). + Object.assign(state.face.eyes.brow, { + enabled: true, + angle: 10, + height: 4, + length: 0.6, + thickness: 3.5, + curve: 0.1, + taper: 1, + spacing: -205, + color: '#181220', + }); + app.actions.refresh('face'); + sync(); + }, + }, + ], + })); + + // --- glasses / sunglasses (drawn into the same texture as the eyes and brows) + faceSection = section(tabFace, '眼鏡・サングラス'); + faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。' + + '線画のときはレンズを塗らず、フレームの線だけになります。')); + + const glassesToggle = check({ + label: '眼鏡をかける', + value: false, + onChange: (value) => { state.face.eyes.glasses.enabled = value; app.actions.refresh('face'); }, + }); + faceSection.add(controlRow(null, glassesToggle.el, { wide: true })); + + // 眼鏡とサングラスの違いは、おもにレンズの濃さ。切り替えたらその種類のレンズの + // 既定値(色と濃さ)を一緒に持ってくる。フレームや大きさには触らないので、 + // 作りこんだフレームは種類を変えても失われない。 + const GLASSES_KINDS = { + glasses: { lensColor: '#2b2433', lensOpacity: 0.22 }, + sunglasses: { lensColor: '#1a1620', lensOpacity: 0.75 }, + }; + const setGlassesKind = (kind) => { + Object.assign(state.face.eyes.glasses, { kind, ...GLASSES_KINDS[kind] }); + }; + + const glassesKindSegment = segmented({ + label: '種類', + options: [ + { value: 'glasses', label: '眼鏡' }, + { value: 'sunglasses', label: 'サングラス' }, + ], + value: 'glasses', + onChange: (value) => { + setGlassesKind(value); + app.actions.refresh('face'); + sync(); + }, + }); + faceSection.add(glassesKindSegment.el); + + const glassesSliders = { + scale: slider({ + label: '大きさ', min: 0.7, max: 1.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.glasses.scale = v; app.actions.refresh('face'); }, + }), + lensOpacity: slider({ + label: 'レンズの濃さ', min: 0, max: 1, step: 0.01, value: 0.22, + format: (v) => (v <= 0.005 ? '透明' : v.toFixed(2)), + onInput: (v) => { state.face.eyes.glasses.lensOpacity = v; app.actions.refresh('face'); }, + }), + frameWidth: slider({ + label: 'フレームの太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.glasses.frameWidth = v; app.actions.refresh('face'); }, + }), + offsetY: slider({ + label: '高さ', min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.glasses.offsetY = v; app.actions.refresh('face'); }, + }), + tilt: slider({ + label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { state.face.eyes.glasses.tilt = v; app.actions.refresh('face'); }, + }), + }; + faceSection.add(glassesSliders.scale.el); + faceSection.add(glassesSliders.lensOpacity.el); + const glassesBox = details(faceSection.body, 'こまかい設定(フレーム・位置・色)'); + glassesBox.add(glassesSliders.frameWidth.el); + glassesBox.add(glassesSliders.offsetY.el); + glassesBox.add(glassesSliders.tilt.el); + const glassesFrameColor = colorField({ + label: 'フレームの色', value: '#2a1e33', + swatches: ['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6'], + onChange: (v) => { state.face.eyes.glasses.frameColor = v; app.actions.refresh('face'); }, + }); + const glassesLensColor = colorField({ + label: 'レンズの色', value: '#2b2433', + swatches: ['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e'], + onChange: (v) => { state.face.eyes.glasses.lensColor = v; app.actions.refresh('face'); }, + }); + glassesBox.add(glassesFrameColor.el); + glassesBox.add(glassesLensColor.el); + glassesBox.add(hint('「レンズの濃さ」を 0 にするとレンズは透明になり、フレームだけの眼鏡になります。' + + '「高さ」は眼鏡全体を上下に動かします(+で下がります)。' + + '「傾き」は左右のレンズが一緒に傾きます。')); + glassesBox.add(buttons({ + items: [ + { + id: 'glasses', + label: '眼鏡', + onClick: () => { + setGlassesKind('glasses'); + state.face.eyes.glasses.enabled = true; + app.actions.refresh('face'); + sync(); + }, + }, + { + id: 'sunglasses', + label: 'サングラス', + onClick: () => { + setGlassesKind('sunglasses'); + state.face.eyes.glasses.enabled = true; + app.actions.refresh('face'); + sync(); + }, + }, + ], + })); + + // --- ほっぺ (a manga blush) and 頭の模様 (a mark on the head) + faceSection = section(tabFace, 'ほっぺ・頭の模様'); + faceSection.add(hint('ほっぺ(マンガの赤らみ)と、頭の模様を、目や眉と同じ絵にかけます。' + + 'どちらも決まったキャラクターの絵ではなく、よくあるマンガの記号として用意しました。' + + 'はじめは両方とも出ていません。')); + + const cheeksToggle = check({ + label: 'ほっぺを出す', + value: false, + onChange: (value) => { state.face.eyes.cheeks.enabled = value; app.actions.refresh('face'); }, + }); + faceSection.add(controlRow(null, cheeksToggle.el, { wide: true })); + + const cheekSliders = { + size: slider({ + label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.cheeks.size = v; app.actions.refresh('face'); }, + }), + offsetY: slider({ + label: 'たかさ', min: -80, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.cheeks.offsetY = v; app.actions.refresh('face'); }, + }), + spacing: slider({ + label: 'よこの間隔', min: -60, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.cheeks.spacing = v; app.actions.refresh('face'); }, + }), + lines: slider({ + label: '線の数', min: 0, max: 7, step: 1, value: 4, + format: (v) => (v < 0.5 ? 'なし' : `${Math.round(v)}本`), + onInput: (v) => { state.face.eyes.cheeks.lines = Math.round(v); app.actions.refresh('face'); }, + }), + }; + faceSection.add(cheekSliders.size.el); + const cheekBox = details(faceSection.body, 'こまかい設定(位置・線・色)'); + cheekBox.add(cheekSliders.offsetY.el); + cheekBox.add(cheekSliders.spacing.el); + cheekBox.add(cheekSliders.lines.el); + const cheekColor = colorField({ + label: 'ほっぺの色', value: '#f6a6b8', + swatches: ['#f6a6b8', '#f19ab0', '#f7b9a0', '#e88aa8'], + onChange: (v) => { state.face.eyes.cheeks.color = v; app.actions.refresh('face'); }, + }); + cheekBox.add(cheekColor.el); + cheekBox.add(hint('「線の数」を 0 にすると、線のないほっぺになります。' + + '「よこの間隔」は左右のほっぺのへだたり、「たかさ」は目からの下がり具合です。')); + + const headMarkShape = segmented({ + label: '頭の模様', + options: [ + { value: 'off', label: 'なし' }, + { value: 'hook', label: 'かぎ' }, + { value: 'spiral', label: 'うずまき' }, + { value: 'strokes', label: '三本線' }, + { value: 'dots', label: '点' }, + ], + value: 'off', + onChange: (value) => { state.face.eyes.headMark.shape = value; app.actions.refresh('face'); }, + }); + faceSection.add(headMarkShape.el); + + const headMarkSliders = { + size: slider({ + label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.headMark.size = v; app.actions.refresh('face'); }, + }), + offsetX: slider({ + label: 'よこの位置', min: -260, max: 260, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.headMark.offsetX = v; app.actions.refresh('face'); }, + }), + offsetY: slider({ + label: 'たかさ', min: -60, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.headMark.offsetY = v; app.actions.refresh('face'); }, + }), + lineWidth: slider({ + label: '太さ', min: 0.4, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.headMark.lineWidth = v; app.actions.refresh('face'); }, + }), + }; + faceSection.add(headMarkSliders.size.el); + const headMarkBox = details(faceSection.body, 'こまかい設定(位置・太さ・色)'); + headMarkBox.add(headMarkSliders.offsetX.el); + headMarkBox.add(headMarkSliders.offsetY.el); + headMarkBox.add(headMarkSliders.lineWidth.el); + const headMarkColor = colorField({ + label: '模様の色', value: '#8a4fe0', + swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'], + onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); }, + }); + headMarkBox.add(headMarkColor.el); + headMarkBox.add(hint('「頭の模様」を選ぶと出ます。決まったキャラクターの絵ではなく、' + + 'よくあるマンガの記号です。色・大きさ・位置は自由に変えられます。')); + + // --- mouth + faceSection = section(tabFace, '口'); + + const mouthToggle = check({ + label: '口を表示', + value: true, + onChange: (value) => { state.face.mouth.visible = value; app.actions.refresh('face'); }, + }); + faceSection.add(controlRow(null, mouthToggle.el, { wide: true })); + + // 口角の角度は「笑いの深さ」に比例して動かす(笑いの深さ 1 で -8°、-1 で 70°、 + // その間は直線)。笑いの深さを動かすと追従し、手で微調整したいときのために + // スライダー自体は残してある。 + const cornerAngleFor = (smile) => Math.round(31 - 39 * Math.max(-1, Math.min(1.4, smile))); + + const mouthSliders = { + smile: slider({ label: '笑いの深さ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }), + open: slider({ label: '口の開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }), + round: slider({ label: '丸く開く(Oの口)', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }), + width: slider({ label: '口の幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }), + thickness: slider({ label: '口の太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }), + tilt: slider({ label: '口の傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }), + offsetY: slider({ label: '口の高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }), + tongue: slider({ label: '舌の大きさ', min: 0, max: 1.6, step: 0.01, value: 0.96, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongue = v; app.actions.refresh('face'); } }), + tonguePos: slider({ label: '舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.84, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tonguePos = v; app.actions.refresh('face'); } }), + tongueOut: slider({ label: '舌を外に出す', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongueOut = v; app.actions.refresh('face'); } }), + tongueOutPos: slider({ label: '外に出す舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.5, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongueOutPos = v; app.actions.refresh('face'); } }), + corners: slider({ label: '口角の長さ', min: 0, max: 1.6, step: 0.01, value: 0.71, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.corners = v; app.actions.refresh('face'); } }), + cornerAngle: slider({ label: '口角の角度', min: -70, max: 70, step: 1, value: -8, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.cornerAngle = v; app.actions.refresh('face'); } }), + }; + const mouthVisible = ['smile', 'round', 'width', 'thickness', 'tilt', 'offsetY']; + for (const key of mouthVisible) faceSection.add(mouthSliders[key].el); + const mouthBox = details(faceSection.body, 'こまかい設定(開き・舌・口角)'); + for (const key of ['open', 'tongue', 'tonguePos', 'tongueOut', 'tongueOutPos', 'corners', 'cornerAngle']) mouthBox.add(mouthSliders[key].el); + mouthBox.add(buttons({ + items: [{ + id: 'mouthreset', + label: '口を初期値に戻す', + onClick: () => { + state.face.mouth = defaultState().face.mouth; + app.actions.refresh('face'); + sync(); + }, + }], + })); + + const mouthColors = { + color: colorField({ label: '口の線', value: '#ff1a44', onChange: (v) => { state.face.mouth.color = v; app.actions.refresh('face'); } }), + innerColor: colorField({ label: '口の中', value: '#4a0f1e', onChange: (v) => { state.face.mouth.innerColor = v; app.actions.refresh('face'); } }), + tongueColor: colorField({ label: '舌', value: '#ff2d2d', onChange: (v) => { state.face.mouth.tongueColor = v; app.actions.refresh('face'); } }), + cornerColor: colorField({ label: '口角', value: '#725497', onChange: (v) => { state.face.mouth.cornerColor = v; app.actions.refresh('face'); } }), + }; + const mouthColorBox = details(faceSection.body, '色(口の線・中・舌・口角)'); + for (const widget of Object.values(mouthColors)) mouthColorBox.add(widget.el); + + const mouthSource = selectField({ + label: '口のパーツ', + options: [ + { value: 'parametric', label: 'パラメータで作る(おすすめ)' }, + { value: 'original', label: '手描き(原本)' }, + ], + value: 'parametric', + onChange: (value) => { state.face.mouth.source = value; app.actions.refresh('face'); }, + }); + faceSection.add(mouthSource.el); + + const mouthImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } }); + mouthImagePicker.addEventListener('change', async () => { + const file = mouthImagePicker.files?.[0]; + mouthImagePicker.value = ''; + if (!file) return; + try { + await app.face.loadImage('mouth', file); + state.face.mouth.source = 'original'; + sync(); + toast(`口の画像「${file.name}」を読み込みました`); + } catch (error) { + console.error(error); + toast('画像を読み込めませんでした(PNG / JPG をお使いください)'); + } + }); + faceSection.add(mouthImagePicker); + faceSection.add(buttons({ + items: [{ id: 'mouthimg', label: '口の画像を読み込む(PNG)', onClick: () => { if (confirmImageLicense()) mouthImagePicker.click(); } }], + })); + + /* ---------------------------------------------------------------- caption */ + + const captionSection = section(tabFace, 'セリフ(吹き出し)'); + + // Two bubbles share this one set of controls: the selector says which bubble + // they are editing, and every handler below reads the active one when it runs, + // so switching does not have to rewire anything. + let activeCaption = 'caption'; + const cap = () => state[activeCaption]; + const captionBubbleSegment = segmented({ + label: 'どの吹き出し', + options: [ + { value: 'caption', label: '①' }, + { value: 'caption2', label: '②' }, + ], + value: 'caption', + onChange: (value) => { activeCaption = value; sync(); }, + }); + captionSection.add(captionBubbleSegment.el); + captionSection.add(hint('吹き出しは 2 つまで出せます。①と②を切り替えて、それぞれ別の位置・形・色にできます。')); + + const captionToggle = check({ + label: 'この吹き出しを出す', + value: false, + onChange: (value) => { cap().enabled = value; app.actions.refresh('caption'); sync(); }, + }); + captionSection.add(controlRow(null, captionToggle.el, { wide: true })); + + const captionText = textArea({ + label: 'セリフ', + value: '', + rows: 3, + onChange: (value) => { cap().text = value; app.actions.refresh('caption'); }, + }); + captionSection.add(captionText.el); + + captionSection.add(buttons({ + label: '例', + items: CAPTION_PRESETS.map((preset) => ({ + id: preset.id, + label: preset.label, + onClick: () => { Object.assign(cap(), preset.caption); app.actions.refresh('caption'); sync(); }, + })), + })); + + const bubbleSegment = segmented({ + label: '形', + options: BUBBLE_STYLES, + value: 'round', + onChange: (value) => { cap().bubble = value; app.actions.refresh('caption'); }, + }); + const tailSegment = tailPad({ + label: 'しっぽ', + value: 'left', + onChange: (value) => { cap().tail = value; app.actions.refresh('caption'); }, + }); + captionSection.add(bubbleSegment.el); + captionSection.add(tailSegment.el); + + const captionSliders = { + fontSize: slider({ + label: '文字の大きさ', min: 16, max: 72, step: 1, value: 34, format: (v) => `${Math.round(v)}px`, + onInput: (v) => { cap().fontSize = v; app.actions.refresh('caption'); }, + }), + lineHeight: slider({ + label: '行の高さ', min: 1, max: 2, step: 0.01, value: 1.42, format: (v) => v.toFixed(2), + onInput: (v) => { cap().lineHeight = v; app.actions.refresh('caption'); }, + }), + padding: slider({ + label: '内側の余白', min: 4, max: 40, step: 1, value: 18, format: (v) => `${Math.round(v)}px`, + onInput: (v) => { cap().padding = v; app.actions.refresh('caption'); }, + }), + radius: slider({ + label: '角の丸み', min: 0, max: 60, step: 1, value: 24, format: (v) => `${Math.round(v)}px`, + onInput: (v) => { cap().radius = v; app.actions.refresh('caption'); }, + }), + borderWidth: slider({ + label: '線の太さ', min: 0, max: 12, step: 0.5, value: 4, format: (v) => `${v.toFixed(1)}px`, + onInput: (v) => { cap().borderWidth = v; app.actions.refresh('caption'); }, + }), + }; + for (const widget of Object.values(captionSliders)) captionSection.add(widget.el); + + const captionPosition = { + x: slider({ + label: '横位置', min: 0, max: 1, step: 0.01, value: 0.58, format: (v) => v.toFixed(2), + onInput: (v) => { cap().x = v; app.actions.refresh('caption'); }, + }), + y: slider({ + label: '縦位置', min: 0, max: 1, step: 0.01, value: 0.16, format: (v) => v.toFixed(2), + onInput: (v) => { cap().y = v; app.actions.refresh('caption'); }, + }), + }; + captionSection.add(captionPosition.x.el); + captionSection.add(captionPosition.y.el); + + const captionMaxWidthSlider = slider({ + label: 'はば', min: 0.15, max: 0.8, step: 0.01, value: 0.36, format: (v) => v.toFixed(2), + onInput: (v) => { cap().maxWidth = v; app.actions.refresh('caption'); }, + }); + captionSection.add(captionMaxWidthSlider.el); + + const alignSegment = segmented({ + label: 'そろえ', + options: [ + { value: 'left', label: '左' }, + { value: 'center', label: '中' }, + { value: 'right', label: '右' }, + ], + value: 'left', + onChange: (value) => { cap().align = value; app.actions.refresh('caption'); }, + }); + captionSection.add(alignSegment.el); + + const boldToggle = check({ + label: '太字', value: false, + onChange: (value) => { cap().bold = value; app.actions.refresh('caption'); }, + }); + const systemFontToggle = check({ + label: '標準フォントを使う', value: false, + onChange: (value) => { cap().font = value ? 'system' : 'rounded'; app.actions.refresh('caption'); }, + }); + captionSection.add(controlRow(null, boldToggle.el, { wide: true })); + captionSection.add(controlRow(null, systemFontToggle.el, { wide: true })); + + const captionColors = { + textColor: colorField({ label: '文字の色', value: '#3f2b52', onChange: (v) => { cap().textColor = v; app.actions.refresh('caption'); } }), + bubbleColor: colorField({ label: '吹き出しの色', value: '#ffffff', onChange: (v) => { cap().bubbleColor = v; app.actions.refresh('caption'); } }), + borderColor: colorField({ label: '線の色', value: '#55386e', onChange: (v) => { cap().borderColor = v; app.actions.refresh('caption'); } }), + }; + for (const widget of Object.values(captionColors)) captionSection.add(widget.el); + captionSection.add(hint('セリフはプレビューと書き出し画像の両方に描きこまれます。位置は画像全体を0〜1とした割合です。' + + 'ビューポートで吹き出しをドラッグしても動かせます。')); + + /* -------------------------------------------------------------- 口パク */ + + // 口パク only: the mouth moves as if talking, and no sound is made at all. + // (The studio used to read the line with the device's own speech voices, but + // those belong to the device, so a recording of them is not ours to hand out.) + const flapSection = section(tabFace, '口パク(声は出ません)'); + const flapText = textArea({ + label: '口パクするセリフ', + value: '', + rows: 3, + onChange: (value) => { state.mouthFlap.text = value; }, + }); + flapSection.add(flapText.el); + flapSection.add(buttons({ + items: [{ id: 'flap', label: '口パク/止める', primary: true, onClick: () => app.actions.toggleMouthFlap() }], + })); + + const flapSliders = { + rate: slider({ + label: 'はやさ', min: 0.5, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`, + onInput: (v) => { state.mouthFlap.rate = v; }, + }), + mouthGain: slider({ + label: '口の開き', min: 0.2, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.mouthFlap.mouthGain = v; }, + }), + }; + for (const widget of Object.values(flapSliders)) flapSection.add(widget.el); + flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。' + + '上の「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。')); + + /* ------------------------------------------------------ movement (pose tab) */ + + // This is the pose tab's うごき section; the widgets sit here because they are + // driven by the animator alongside the pose state. + const moveSection = section(tabPose, 'うごき'); + + const animWidgets = { + blink: check({ label: 'まばたきする', value: true, onChange: (v) => { state.anim.blink = v; app.animator.reset(); } }), + lookAround: check({ label: '目線がうごく', value: false, onChange: (v) => { state.anim.lookAround = v; app.animator.reset(); } }), + }; + + const motionSegment = segmented({ + label: 'からだのうごき', + options: [ + { value: 'off', label: 'とまる' }, + { value: 'idle', label: 'ゆれる' }, + { value: 'walk', label: '歩く' }, + ], + value: 'off', + onChange: (value) => { state.anim.mode = value; app.animator.reset(); app.needsRender = true; }, + }); + moveSection.add(motionSegment.el); + for (const widget of Object.values(animWidgets)) moveSection.add(controlRow(null, widget.el, { wide: true })); + + moveSection.add(slider({ + label: 'まばたきの間隔', min: 1, max: 8, step: 0.1, value: 3.4, format: (v) => `${v.toFixed(1)}秒`, + onInput: (v) => { state.anim.blinkInterval = v; }, + })); + moveSection.add(slider({ + label: '動きの速さ', min: 0.2, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`, + onInput: (v) => { state.anim.speed = v; }, + })); + + const recordButton = buttons({ + items: [{ id: 'record', label: '録画を開始', primary: true, onClick: () => app.actions.toggleRecording() }], + }); + moveSection.add(recordButton.el); + moveSection.add(hint('スペースキーで演出の開始・停止。録画はWebM(動画)として保存されます。')); + + // --- 見る先 (moved here, next to the face controls, from the pose tab) ---- + const lookAtSection = section(tabFace, '見る先'); + // `lookAt` moves the eyes and the face only, so the face has to be rebuilt too. + const refreshLookAt = () => { + app.actions.refresh('lookAt'); + app.actions.refresh('face'); + sync(); + }; + const lookAtToggle = check({ + label: '指定した場所を見る', + value: false, + onChange: (value) => { state.lookAt.enabled = value; refreshLookAt(); }, + }); + lookAtSection.add(controlRow(null, lookAtToggle.el, { wide: true })); + const lookAtSliders = { + x: slider({ + label: 'よこ(X)', min: -6, max: 6, step: 0.05, value: 0, format: (v) => v.toFixed(2), + onInput: (v) => { state.lookAt.x = v; refreshLookAt(); }, + }), + y: slider({ + label: 'たかさ(Y)', min: 0, max: 5, step: 0.05, value: 2.6, format: (v) => v.toFixed(2), + onInput: (v) => { state.lookAt.y = v; refreshLookAt(); }, + }), + z: slider({ + label: 'おくゆき(Z)', min: -6, max: 6, step: 0.05, value: 3, format: (v) => v.toFixed(2), + onInput: (v) => { state.lookAt.z = v; refreshLookAt(); }, + }), + amount: slider({ + label: '強さ', min: 0, max: 1, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.lookAt.amount = v; refreshLookAt(); }, + }), + }; + for (const widget of Object.values(lookAtSliders)) lookAtSection.add(widget.el); + const turnBodyToggle = check({ + label: '体も向ける', + value: false, + onChange: (value) => { state.lookAt.turnBody = value; refreshLookAt(); }, + }); + lookAtSection.add(controlRow(null, turnBodyToggle.el, { wide: true })); + lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目と髪が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。')); + + /* ---------------------------------------------------------------- display */ + + const displaySection = section(tabView, 'スタイル'); + + const styleSegment = segmented({ + label: 'スタイル', + options: STYLE_DEFS.map((style) => ({ value: style.value, label: style.label })), + value: 'real', + onChange: (value) => replace({ render: { style: value } }, 'render'), + }); + displaySection.add(styleSegment.el); + displaySection.add(hint('線画は輪郭線+白ぬりです(体に色がつきません)。線だけを他の絵に重ねたいときは、「書き出し」タブで「PNGの背景を透明にする」を入れてください。')); + + const outlineToggle = check({ + label: '輪郭線を出す', value: true, + onChange: (value) => { state.render.outline = value; app.actions.refresh('render'); }, + }); + displaySection.add(controlRow(null, outlineToggle.el, { wide: true })); + const outlineWidthSlider = slider({ + label: '線の太さ(体・手足)', min: 0, max: 0.08, step: 0.001, value: 0.022, format: (v) => v.toFixed(3), + onInput: (v) => { state.render.outlineWidth = v; app.actions.refresh('render'); }, + }); + displaySection.add(outlineWidthSlider.el); + const outlinePixelsSlider = slider({ + label: '線の太さ(葉っぱ・鼻)', min: 1, max: 5, step: 0.1, value: 2, + format: (v) => `${v.toFixed(1)} px`, + onInput: (v) => { state.render.outlinePixels = v; app.actions.refresh('render'); }, + }); + displaySection.add(outlinePixelsSlider.el); + displaySection.add(colorField({ + label: '線の色', value: '#2a1e33', + swatches: ['#2a1e33', '#111111', '#55386e', '#0f5c8c', '#8c4a0f'], + onChange: (v) => { state.render.outlineColor = v; app.actions.refresh('render'); }, + })); + displaySection.add(colorField({ + label: '紙の色', value: '#ffffff', + swatches: ['#ffffff', '#fdf8ee', '#f4f0fb'], + onChange: (v) => { state.render.paper = v; app.actions.refresh('render'); }, + })); + const leafBodyLineCheck = check({ + label: '葉っぱと体の境目に線を出す', value: true, + onChange: (v) => { state.render.leafBodyLine = v; app.actions.refresh('render'); }, + title: '線画では体も葉っぱも紙なので、切ると葉っぱと体の区別がつかなくなります。' + + '「葉が体にめり込んで見える」のが気になるときに切ってください', + }); + displaySection.add(controlRow(null, leafBodyLineCheck.el, { wide: true })); + displaySection.add(hint('「葉っぱと体の境目に線を出す」を切ると、葉は体の後ろに回り込むだけになります(すっきりしますが、線画では葉と体が同じ白になって見分けにくくなります)。')); + + /* ------------------------------------------------------------- body colour */ + + const colourSection = section(tabView, '体の色'); + const themeSegment = segmented({ + label: 'テーマ', + options: THEMES.map((theme) => ({ value: theme.id, label: theme.label })), + value: 'original', + onChange: (id) => { app.actions.applyTheme(id); sync(); }, + }); + colourSection.add(themeSegment.el); + colourSection.add(hint('テーマは出発点です。選んだあとでパーツごとに色を変えられます。')); + + /** Write one part colour, creating `colors` if an old settings file lacked it. */ + const setBodyColor = (part, value) => { + state.render.colors = state.render.colors ?? {}; + state.render.colors[part] = value; + app.actions.refresh('render'); + }; + const bodyColors = { + body: colorField({ label: '体', value: '#c8b0f0', onChange: (v) => setBodyColor('body', v) }), + accent: colorField({ label: '手', value: '#8a4fe0', onChange: (v) => setBodyColor('accent', v) }), + nose: colorField({ label: '鼻', value: '#7a4fb0', onChange: (v) => setBodyColor('nose', v) }), + leaf: colorField({ label: '葉', value: '#a6dd6a', onChange: (v) => setBodyColor('leaf', v) }), + vein: colorField({ label: '葉脈', value: '#7fbf3f', onChange: (v) => setBodyColor('vein', v) }), + feet: colorField({ label: '足', value: '#7a4fb0', onChange: (v) => setBodyColor('feet', v) }), + }; + for (const widget of Object.values(bodyColors)) colourSection.add(widget.el); + + const envIntensitySlider = slider({ + label: '環境の映りこみ', min: 0, max: 2, step: 0.05, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.envIntensity = v; app.actions.refresh('render'); }, + }); + colourSection.add(envIntensitySlider.el); + colourSection.add(buttons({ + items: [{ + id: 'resetColors', + label: 'プリセットの色に戻す', + onClick: () => { app.actions.applyTheme(state.render.theme ?? 'original'); sync(); }, + }], + })); + + /* -------------------------------------------------------------------- look */ + + const lookSection = section(tabView, '見た目の調整'); + const mirrorToggle = check({ + label: '左右反転', + value: false, + onChange: (value) => { state.render.mirror = value; app.actions.refresh('render'); sync(); }, + }); + lookSection.add(controlRow(null, mirrorToggle.el, { wide: true })); + lookSection.add(hint('体だけでなく、目や口の位置までふくめて反転します。文字と並べるときの向きを決めるときに使います。')); + + const shadowCheck = check({ + label: '影を落とす', + value: true, + onChange: (value) => { state.render.shadow = value; app.actions.refresh('render'); }, + }); + lookSection.add(controlRow(null, shadowCheck.el, { wide: true })); + const shadowOpacitySlider = slider({ + label: '濃さ', min: 0, max: 0.6, step: 0.01, value: 0.22, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.shadowOpacity = v; app.actions.refresh('render'); }, + }); + const shadowSoftnessSlider = slider({ + label: 'ぼかし', min: 0, max: 3, step: 0.05, value: 1.6, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.shadowSoftness = v; app.actions.refresh('render'); }, + }); + lookSection.add(shadowOpacitySlider.el); + lookSection.add(shadowSoftnessSlider.el); + const contactShadowToggle = check({ + label: '接地影だけ', + value: false, + onChange: (value) => { state.render.contactShadow = value; app.actions.refresh('render'); }, + }); + lookSection.add(controlRow(null, contactShadowToggle.el, { wide: true })); + lookSection.add(hint('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。「接地影だけ」は足もとの丸い影だけにします。')); + + const environmentSegment = segmented({ + label: '照明の種類', + options: ENVIRONMENTS.map((env) => ({ value: env.value, label: env.label })), + value: 'gradient', + onChange: (value) => { state.render.environment = value; app.actions.refresh('render'); }, + }); + lookSection.add(environmentSegment.el); + + /* -------------------------------------------------------------- background */ + + const backdropSection = section(tabView, '背景'); + // 素材 is one button for two state values (a photo or an effect line). This + // remembers which of the two was picked last, so leaving 素材 and coming back + // returns to it instead of always jumping to the photos. + let lastMaterial = 'preset'; + const backgroundModeSegment = segmented({ + label: '背景', + options: [ + { value: 'solid', label: '単色' }, + { value: 'transparent', label: '透明' }, + { value: 'material', label: '素材' }, + { value: 'image', label: '自分の画像' }, + { value: 'camera', label: 'カメラ=AR' }, + ], + value: 'solid', + onChange: (value) => { + if (value === 'camera') { + // The action owns the stream, so let it decide and re-read the state after. + Promise.resolve(app.actions.toggleCamera(true)).then(() => sync()); + return; + } + if (state.view.background === 'camera') app.actions.toggleCamera(false); + state.view.background = value === 'material' ? lastMaterial : value; + app.actions.refresh('view'); + sync(); + }, + }); + backdropSection.add(backgroundModeSegment.el); + // 背景の色 only shows in the 単色 mode; it used to live in the スタイル section. + const backgroundColour = colorField({ + label: '背景の色', value: '#ffffff', + swatches: ['#ffffff', '#f4f0fb', '#d9f0ff', '#fff0f4', '#1b1230'], + onChange: (v) => { state.view.backgroundColor = v; app.actions.refresh('view'); }, + }); + backdropSection.add(backgroundColour.el); + + const photoButtons = buttons({ + label: '写真', + items: BACKGROUND_PRESETS.map((preset) => ({ + id: preset.name, + label: preset.label, + onClick: () => { + state.view.backgroundPreset = preset.name; + lastMaterial = 'preset'; + state.view.background = 'preset'; + app.actions.refresh('view'); + sync(); + }, + })), + }); + + const effectButtons = buttons({ + label: '効果線(まんが)', + items: EFFECT_PRESETS.map((effect) => ({ + id: `effect-${effect.name}`, + label: effect.label, + onClick: () => { + state.view.backgroundEffect = effect.name; + lastMaterial = 'effect'; + state.view.background = 'effect'; + app.actions.refresh('view'); + sync(); + }, + })), + }); + + // The two rows are shown only while 素材 is the active mode (see the syncer + // below); the border groups them so they read as one choice, not two. + const materialGroup = h('div', { + class: 'material-group', + style: { display: 'none', paddingLeft: '8px', borderLeft: '2px solid #e4dff0' }, + }, photoButtons.el, effectButtons.el, hint('効果線はアプリが描いています(画像ファイルなし・ネット不要)。集中線=放射、落ち込み線=上から下、疾走線=左右です。')); + backdropSection.add(materialGroup); + + const backgroundImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } }); + backgroundImagePicker.addEventListener('change', async () => { + const file = backgroundImagePicker.files?.[0]; + backgroundImagePicker.value = ''; + if (!file) return; + try { + const dataUrl = await app.backdrop.loadImageFile(file); + state.view.backgroundImage = dataUrl; + state.view.background = 'image'; + app.actions.refresh('view'); + sync(); + toast(`背景の画像「${file.name}」を読み込みました`); + } catch (error) { + console.error(error); + toast('画像を読み込めませんでした(PNG / JPG をお使いください)'); + } + }); + backdropSection.add(backgroundImagePicker); + backdropSection.add(buttons({ + items: [{ id: 'bgimg', label: '画像を読み込む', onClick: () => { if (confirmImageLicense()) backgroundImagePicker.click(); } }], + })); + + const backgroundFitSegment = segmented({ + label: '合わせ方', + options: [ + { value: 'cover', label: '全体' }, + { value: 'contain', label: '収める' }, + { value: 'stretch', label: '伸ばす' }, + { value: 'tile', label: '並べる' }, + ], + value: 'cover', + onChange: (value) => { state.view.backgroundFit = value; app.actions.refresh('view'); }, + }); + const backgroundBlurSlider = slider({ + label: 'ぼかし', min: 0, max: 20, step: 0.5, value: 0, format: (v) => `${v.toFixed(1)}px`, + onInput: (v) => { state.view.backgroundBlur = v; app.actions.refresh('view'); }, + }); + const backgroundDarkenSlider = slider({ + label: '暗さ', min: 0, max: 0.8, step: 0.01, value: 0, format: (v) => v.toFixed(2), + onInput: (v) => { state.view.backgroundDarken = v; app.actions.refresh('view'); }, + }); + const backgroundScaleSlider = slider({ + label: '大きさ', min: 0.5, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.view.backgroundScale = v; app.actions.refresh('view'); }, + }); + backdropSection.add(backgroundFitSegment.el); + backdropSection.add(backgroundBlurSlider.el); + backdropSection.add(backgroundDarkenSlider.el); + backdropSection.add(backgroundScaleSlider.el); + + const cameraFacingSegment = segmented({ + label: 'カメラ', + options: [ + { value: 'user', label: '前' }, + { value: 'environment', label: '後ろ' }, + ], + value: 'environment', + onChange: (value) => { + state.view.cameraFacing = value; + // Only a live stream has to be restarted on the other lens. + if (state.view.background === 'camera') { + Promise.resolve(app.actions.toggleCamera(true)).then(() => sync()); + return; + } + app.actions.refresh('view'); + }, + }); + const cameraMirrorToggle = check({ + label: '左右反転', + value: false, + onChange: (value) => { state.view.cameraMirror = value; app.actions.refresh('view'); }, + }); + backdropSection.add(cameraFacingSegment.el); + backdropSection.add(controlRow(null, cameraMirrorToggle.el, { wide: true })); + + // A gyro reading is a delta from the pose the device held when the switch was + // turned on, so the camera does not jump when it starts listening. + let gyroBase = null; + let gyroOff = null; + const gyroToggle = check({ + label: '端末を傾けて見る', + value: false, + onChange: (value) => { + state.view.gyro = value; + if (!value) { + gyroBase = null; + if (gyroOff) { gyroOff(); gyroOff = null; } + return; + } + gyroBase = { azimuth: Number(state.view.azimuth) || 0, polar: Number(state.view.polar) || 76 }; + app.backdrop?.requestGyro?.(); + gyroOff = gyroOff ?? app.backdrop?.onGyro?.((reading) => { + if (!gyroBase) return; + const yaw = reading?.yaw ?? 0; + const pitch = reading?.pitch ?? 0; + state.view.azimuth = Math.max(-180, Math.min(180, gyroBase.azimuth - yaw)); + state.view.polar = Math.max(1, Math.min(179, gyroBase.polar - pitch)); + app.actions.refresh('view'); + }) ?? null; + }, + }); + backdropSection.add(controlRow(null, gyroToggle.el, { wide: true })); + backdropSection.add(hint('カメラを使うには https か localhost で開いてください(カメラの使用の許可も必要です)。')); + backdropSection.add(buttons({ + items: [{ id: 'bgshot', label: 'この背景で写真を撮る', primary: true, onClick: () => app.actions.savePNG() }], + })); + + /* ------------------------------------------------------------------- props */ + + // 舞台: the things you put the character *among* - the props and the wall - in + // one place, since they are set up together. + const stageSection = section(tabView, '舞台'); + const propSection = details(stageSection.body, '小物'); + propSection.add(buttons({ + label: '追加', + items: PROP_LIBRARY.map((entry) => ({ + id: entry.id, + label: entry.label, + onClick: () => { app.actions.addProp(entry.id); sync(); }, + })), + })); + propSection.add(hint('小物はキャラクターのまわりに置かれ、背景と同じくそのまま写真に写ります。' + + 'ビューポートで小物をドラッグすると移動できます(数値でも動かせます)。')); + const propListBox = h('div', { class: 'prop-list' }); + propSection.add(controlRow(null, propListBox, { wide: true })); + + const propWidgets = []; + let propShape = ''; + + /** + * Rebuild the list only when the *set* of props changes: a rebuild mid-drag + * would replace the slider the user is holding. The handlers read the item by + * index rather than keeping a reference, so a state swap (undo, load) is safe. + */ + const renderProps = () => { + const items = state.props?.items ?? []; + const shape = items.map((item) => item.kind).join('|'); + if (shape === propShape && propWidgets.length === items.length) return; + propShape = shape; + propWidgets.length = 0; + propListBox.replaceChildren(); + items.forEach((item, index) => { + const label = PROP_LIBRARY.find((entry) => entry.id === item.kind)?.label ?? item.kind; + const block = h('div', { + class: 'prop-item', + style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' }, + }, subhead(items.length > 1 ? `${label} ${index + 1}` : label)); + const patch = (key) => (value) => { + const target = state.props?.items?.[index]; + if (target) target[key] = value; + app.actions.refresh('render'); + sync(); + }; + const sliders = { + x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('x') }), + y: slider({ label: 'たかさ(Y)', min: 0, max: 2, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('y') }), + z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('z') }), + rotY: slider({ label: '回転', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotY') }), + scale: slider({ label: '大きさ', min: 0.4, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: patch('scale') }), + }; + // The numbers are only a fallback for the drag, so they stay folded away. + const numbers = details(block, '数値で調整'); + for (const widget of Object.values(sliders)) numbers.add(widget.el); + // 看板 only: the writing surface can be enlarged on its own, so the frame + // and posts stay put. The app rebuilds the props from the state and carries + // only the whole-prop scale, so the props syncer re-applies this to the + // fresh mesh (see `applyPropFaceScale`). + let faceScale = null; + if (item.kind === 'sign') { + faceScale = slider({ + label: '文字部分の大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1, + format: (v) => v.toFixed(2), onInput: patch('faceScale'), + }); + block.insertBefore(faceScale.el, numbers.el); + } + block.append(buttons({ + items: [{ + id: 'remove', + label: '削除', + onClick: () => { + state.props = state.props ?? { items: [] }; + state.props.items = (state.props.items ?? []).filter((_, i) => i !== index); + app.actions.refresh('render'); + renderProps(); + sync(); + }, + }], + }).el); + propListBox.append(block); + propWidgets.push({ index, sliders, faceScale }); + }); + }; + renderProps(); + + /* ------------------------------------------------------------- 擬音 */ + + // 擬音: manga onomatopoeia as stickers over the picture. The sheets are dense, + // so the user picks the crop with a marquee instead of the app slicing them + // (see src/gion.js for why). Only the sheet name and the crop rectangle live in + // the state - never the bitmap - so a shared link stays small. + const gionSection = details(stageSection.body, '擬音(マンガのオノマトペ)'); + gionSection.add(hint('擬音(ドドド、バン!など)の画像から、四角く切り出したスタンプを画面に貼れます。' + + '「擬音をえらぶ」で画像を開き、ドラッグで四角を描いて「追加」を押してください。' + + '貼ったあとはビューポートでドラッグして動かせます。')); + + const gionSheetSelect = selectField({ + label: 'シート', + options: GION_SHEETS.map((sheet) => ({ value: sheet.name, label: sheet.label })), + value: GION_SHEETS[0]?.name ?? '', + onChange: () => {}, + }); + if (GION_SHEETS.length) { + gionSection.add(gionSheetSelect.el); + gionSection.add(buttons({ + items: [{ + id: 'pick', + label: '擬音をえらぶ', + primary: true, + onClick: () => openGionPicker(gionSheetSelect.get()), + }], + })); + } else { + // No sheet ships with the studio (see src/gion.js), so there is nothing to + // pick yet - the section stays, but only with a note on how to add one. + gionSection.add(hint('擬音の画像がまだありません。assets/manga-gion/ に画像を置き、' + + 'src/gion.js の GION_SHEETS に登録すると使えます。')); + } + + const gionListBox = h('div', { class: 'gion-list' }); + gionSection.add(controlRow(null, gionListBox, { wide: true })); + + let gionShape = ''; + const gionWidgets = []; + + /** Select a stamp: the viewport outlines it and the list highlights it. */ + const selectGion = (id) => { + app.gionSelected = id ?? null; + app.actions.refresh('gion'); + sync(); + }; + + /** + * A little canvas preview of a stamp's crop, drawn from the sheet once it has + * loaded. The sheet itself is only fetched when a stamp first needs it. + */ + const gionPreviews = new Map(); + function gionPreview(item) { + const canvas = h('canvas', { class: 'gion-thumb', width: 84, height: 56 }); + const draw = (image) => { + if (!image?.naturalWidth) return; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + const scale = Math.min(canvas.width / item.sw, canvas.height / item.sh); + const w = item.sw * scale; + const h = item.sh * scale; + ctx.drawImage(image, item.sx, item.sy, item.sw, item.sh, + (canvas.width - w) / 2, (canvas.height - h) / 2, w, h); + }; + const known = gionPreviews.get(item.sheet); + if (known) { + draw(known); + } else { + const image = new Image(); + image.onload = () => draw(image); + image.src = gionSheetUrl(item.sheet); + gionPreviews.set(item.sheet, image); + } + return canvas; + } + + /** Rebuild the list only when the *set* of stamps changes (see `renderProps`). */ + const renderGion = () => { + const items = state.gion?.items ?? []; + const shape = items + .map((item) => `${item.id}:${item.sheet}:${item.sx},${item.sy},${item.sw},${item.sh}`) + .join('|'); + if (shape === gionShape && gionWidgets.length === items.length) return; + gionShape = shape; + gionWidgets.length = 0; + gionListBox.replaceChildren(); + if (!items.some((item) => item.id === app.gionSelected)) app.gionSelected = null; + if (items.length === 0) { + gionListBox.append(h('p', { class: 'hint', text: 'まだ擬音は貼られていません。' })); + return; + } + items.forEach((item, index) => { + const label = GION_SHEETS.find((sheet) => sheet.name === item.sheet)?.label ?? item.sheet; + const block = h('div', { + class: 'gion-item', + style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' }, + }); + const preview = gionPreview(item); + preview.title = 'クリックで選択'; + preview.addEventListener('click', () => selectGion(item.id)); + const head = h('div', { class: 'gion-item-head' }, preview, + subhead(`擬音 ${index + 1}(${label})`)); + block.append(head); + + const patch = (key) => (value) => { + const target = state.gion?.items?.[index]; + if (target) target[key] = value; + app.actions.refresh('gion'); + sync(); + }; + const sliders = { + w: slider({ + label: '大きさ', min: 40, max: 900, step: 1, value: DEFAULT_STAMP_WIDTH, + format: (v) => `${Math.round(v)}px`, onInput: patch('w'), + }), + rot: slider({ + label: '回転', min: -180, max: 180, step: 1, value: 0, + format: (v) => `${Math.round(v)}°`, onInput: patch('rot'), + }), + }; + const flip = check({ label: '左右反転', value: false, onChange: patch('flip') }); + block.append(sliders.w.el, sliders.rot.el, controlRow(null, flip.el, { wide: true })); + block.append(buttons({ + items: [ + { id: 'select', label: 'この擬音を選ぶ', onClick: () => selectGion(item.id) }, + { + id: 'remove', + label: '削除', + onClick: () => { + state.gion = state.gion ?? { items: [] }; + state.gion.items = (state.gion.items ?? []).filter((_, i) => i !== index); + if (app.gionSelected === item.id) app.gionSelected = null; + app.actions.refresh('gion'); + renderGion(); + sync(); + }, + }, + ], + }).el); + gionListBox.append(block); + gionWidgets.push({ id: item.id, sliders, flip, block }); + }); + }; + renderGion(); + + /** + * The picker: a full-screen overlay showing the whole sheet, on which the user + * drags a rectangle. "追加" turns that rectangle into a stamp in the middle of + * the picture; Esc or キャンセル closes without adding anything. + */ + const openGionPicker = (sheetName) => { + const sheet = GION_SHEETS.find((entry) => entry.name === sheetName) ?? GION_SHEETS[0]; + if (!sheet) { toast('擬音のシートがありません'); return; } + + const image = h('img', { class: 'gion-picker-sheet', alt: sheet.label, src: gionSheetUrl(sheet.name) }); + const marquee = h('div', { class: 'gion-picker-marquee' }); + const board = h('div', { class: 'gion-picker-board' }, image, marquee); + const addButton = h('button', { type: 'button', class: 'btn primary', text: '追加', disabled: true }); + const cancelButton = h('button', { type: 'button', class: 'btn', text: 'キャンセル' }); + const overlay = h('div', { class: 'gion-picker' }, + h('div', { class: 'gion-picker-bar' }, + h('div', { class: 'gion-picker-title', text: `「${sheet.label}」から擬音をえらぶ` }), + h('div', { class: 'gion-picker-actions' }, + h('span', { class: 'hint', text: 'ドラッグで四角を描く/Escで閉じる' }), + addButton, + cancelButton)), + board); + + /** Where the sheet is currently displayed, in board pixels. */ + let sheetRect = null; + /** The marquee the user is drawing, or the last one drawn. */ + let current = null; + let start = null; + + const layoutSheet = () => { + if (!image.naturalWidth) return; + const rect = fitSheet(image, { width: board.clientWidth, height: board.clientHeight }, { padding: 12 }); + image.style.left = `${rect.x}px`; + image.style.top = `${rect.y}px`; + image.style.width = `${rect.w}px`; + image.style.height = `${rect.h}px`; + sheetRect = rect; + }; + + const updateAdd = () => { + addButton.disabled = !(current && current.w >= 6 && current.h >= 6 && sheetRect); + }; + + const drawMarquee = () => { + if (!current) { marquee.style.display = 'none'; return; } + marquee.style.display = 'block'; + marquee.style.left = `${current.x}px`; + marquee.style.top = `${current.y}px`; + marquee.style.width = `${current.w}px`; + marquee.style.height = `${current.h}px`; + }; + + const pointIn = (event, bounds) => ({ + x: clamp(event.clientX - bounds.left, 0, bounds.width), + y: clamp(event.clientY - bounds.top, 0, bounds.height), + }); + + board.addEventListener('pointerdown', (event) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + start = pointIn(event, board.getBoundingClientRect()); + current = null; + drawMarquee(); + updateAdd(); + try { board.setPointerCapture(event.pointerId); } catch { /* a nicety, not a need */ } + }); + board.addEventListener('pointermove', (event) => { + if (!start) return; + current = normalizeRect(start, pointIn(event, board.getBoundingClientRect())); + drawMarquee(); + updateAdd(); + }); + const endMarquee = (event) => { + if (!start) return; + start = null; + try { if (board.hasPointerCapture(event.pointerId)) board.releasePointerCapture(event.pointerId); } catch { /* done anyway */ } + updateAdd(); + }; + board.addEventListener('pointerup', endMarquee); + board.addEventListener('pointercancel', endMarquee); + image.addEventListener('load', () => { layoutSheet(); updateAdd(); }); + + addButton.addEventListener('click', () => { + if (!current || !sheetRect) return; + const crop = cropFromMarquee(current, sheetRect, image); + app.actions.addGionStamp({ sheet: sheet.name, ...crop }); + close(); + }); + cancelButton.addEventListener('click', () => close()); + overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); }); + + const onKey = (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + close(); + }; + const onResize = () => layoutSheet(); + + let closed = false; + const close = () => { + if (closed) return; + closed = true; + document.removeEventListener('keydown', onKey); + window.removeEventListener('resize', onResize); + overlay.remove(); + }; + + document.addEventListener('keydown', onKey); + window.addEventListener('resize', onResize); + document.body.append(overlay); + // A cached sheet is already loaded, so the `load` event never fires for it. + if (image.complete && image.naturalWidth) { layoutSheet(); updateAdd(); } + }; + + /* ------------------------------------------------------------- 見えない壁 */ + + // 見えない壁: place a clip plane and bury the character in it, so only the + // part in front of the wall still shows. It is the same cut the camera's own + // far plane makes, put where you want it (see src/clip.js). + const wallSection = details(stageSection.body, '見えない壁'); + const wallConfig = () => (state.render.wall = state.render.wall ?? defaultState().render.wall); + wallSection.add(hint('見えない板(有限の壁)を置くと、その向こう側が隠れます。' + + 'ぶるべーを壁に埋め込むと、体の一部だけが見えるようになります。' + + '板そのものは透明です(下の「板を表示」をオンにしたときだけ見えます)。')); + + const wallToggle = check({ + label: '見えない壁で一部を隠す', + value: false, + onChange: (value) => { wallConfig().on = value; app.actions.refresh('render'); sync(); }, + }); + const wallSliders = { + x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { wallConfig().x = v; app.actions.refresh('render'); sync(); } }), + y: slider({ label: 'たかさ(Y)', min: -2, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { wallConfig().y = v; app.actions.refresh('render'); sync(); } }), + z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { wallConfig().z = v; app.actions.refresh('render'); sync(); } }), + yaw: slider({ label: '向き(左右)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { wallConfig().yaw = v; app.actions.refresh('render'); sync(); } }), + tilt: slider({ label: '傾き(前後)', min: -90, max: 90, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { wallConfig().tilt = v; app.actions.refresh('render'); sync(); } }), + size: slider({ label: '大きさ', min: 0.2, max: 8, step: 0.1, value: 1, format: (v) => v.toFixed(1), onInput: (v) => { wallConfig().size = v; app.actions.refresh('render'); sync(); } }), + }; + wallSection.add(wallToggle.el); + for (const widget of Object.values(wallSliders)) wallSection.add(widget.el); + + // The wall writes depth but no colour, so it hides what is behind it without + // being visible. The guide is the tinted copy of that same rectangle, shown + // only while placing - and it is also the switch that says "I am placing the + // wall now", so with it off the wall cannot swallow an orbit drag. + const wallGuideToggle = check({ + label: '板を表示(ビューポートでドラッグして動かせます)', + value: false, + onChange: (value) => { wallConfig().guide = value; app.actions.refresh('render'); sync(); }, + }); + wallSection.add(controlRow(null, wallGuideToggle.el, { wide: true })); + wallSection.add(hint('板は「大きさ」の四角い壁そのもので、**その後ろ側が隠れます**' + + '(カメラから見て板の後ろになる部分が隠れるので、カメラを回すと見え方も変わります)。' + + '「板を表示」を切ると板は完全に透明になり、カメラ操作のじゃまもしません。')); + wallSection.add(buttons({ + items: [{ + id: 'wallReset', + label: '見えない壁をリセット', + onClick: () => { + state.render.wall = defaultState().render.wall; + app.actions.refresh('render'); + sync(); + }, + }], + })); + + /* ------------------------------------------------------------------ light */ + + // 画面・光: the light rig and the camera, which together decide how the picture + // is lit and framed. + const screenLightSection = section(tabView, '画面・光'); + const lightSection = details(screenLightSection.body, 'ひかり'); + const lightPad = xyPad({ + value: { x: -0.2, y: 0.45 }, + onChange: ({ x, y }) => { + state.render.lightAzimuth = x * 180; + state.render.lightElevation = 5 + Math.max(0, y) * 80; + app.actions.refresh('render'); + }, + }); + lightSection.add(controlRow('光の向き', h('div', { class: 'pad-wrap' }, + lightPad.el, + h('p', { class: 'hint', text: 'ドラッグで光源の向きを変えられます。' })))); + const lightIntensitySlider = slider({ + label: '明るさ', min: 0, max: 5, step: 0.05, value: 2.1, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.lightIntensity = v; app.actions.refresh('render'); }, + }); + const ambientSlider = slider({ + label: '環境光', min: 0, max: 3, step: 0.05, value: 0.9, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.ambient = v; app.actions.refresh('render'); }, + }); + const exposureSlider = slider({ + label: '露光', min: 0.4, max: 2, step: 0.02, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.exposure = v; app.actions.refresh('render'); }, + }); + lightSection.add(lightIntensitySlider.el); + lightSection.add(ambientSlider.el); + lightSection.add(exposureSlider.el); + + /* ----------------------------------------------------------------- camera */ + + const cameraSection = details(screenLightSection.body, 'カメラ'); + + const projectionSegment = segmented({ + label: '映し方', + options: [ + { value: 'persp', label: '遠近あり' }, + { value: 'ortho', label: '正投影' }, + ], + value: 'persp', + onChange: (value) => replace({ view: { projection: value } }, 'view'), + }); + cameraSection.add(projectionSegment.el); + cameraSection.add(hint('正投影にすると遠近のゆがみが消え、図面のような絵になります。')); + + cameraSection.add(buttons({ + label: '向き', + items: [ + { id: 'front', label: '正面', onClick: () => app.actions.setCameraPreset('front') }, + { id: 'threeQuarter', label: '斜め', onClick: () => app.actions.setCameraPreset('threeQuarter') }, + { id: 'side', label: '横', onClick: () => app.actions.setCameraPreset('side') }, + { id: 'back', label: '後ろ', onClick: () => app.actions.setCameraPreset('back') }, + { id: 'top', label: '見下ろし', onClick: () => app.actions.setCameraPreset('top') }, + ], + })); + + const cameraSliders = { + azimuth: slider({ + label: '回転', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { state.view.azimuth = v; app.actions.refresh('view'); }, + }), + polar: slider({ + label: '高さ', min: 1, max: 179, step: 1, value: 76, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { state.view.polar = v; app.actions.refresh('view'); }, + }), + size: slider({ + label: '大きさ', min: 2, max: 200, step: 0.1, value: 12, format: (v) => v.toFixed(1), + onInput: (v) => { + if (state.view.projection === 'ortho') state.view.orthoHeight = v; + else state.view.distance = v; + app.actions.refresh('view'); + }, + }), + targetY: slider({ + label: '見る高さ', min: 0, max: Math.max(2, model.size.y * 1.2), step: 0.05, value: model.size.y * 0.5, format: (v) => v.toFixed(2), + onInput: (v) => { state.view.targetY = v; app.actions.refresh('view'); }, + }), + }; + for (const widget of Object.values(cameraSliders)) cameraSection.add(widget.el); + + const autoRotateToggle = check({ + label: '自動で回す', value: false, + onChange: (v) => { state.view.autoRotate = v; app.actions.refresh('view'); }, + }); + cameraSection.add(controlRow(null, autoRotateToggle.el, { wide: true })); + cameraSection.add(slider({ + label: '回す速さ', min: 0.2, max: 4, step: 0.1, value: 0.8, format: (v) => v.toFixed(1), + onInput: (v) => { state.view.autoRotateSpeed = v; app.actions.refresh('view'); }, + })); + + /* ------------------------------------------------------------------ export */ + + const exportSection = section(tabExport, '画像'); + + exportSection.add(segmented({ + label: 'PNGの大きさ', + options: [ + { value: '1', label: '1倍' }, + { value: '2', label: '2倍' }, + { value: '3', label: '3倍' }, + { value: '4', label: '4倍' }, + ], + value: '2', + onChange: (value) => { state.render.pngScale = Number(value); }, + })); + exportSection.add(buttons({ + items: [ + { id: 'png', label: 'PNGを保存', primary: true, onClick: () => app.actions.savePNG() }, + { id: 'copy', label: 'クリップボードにコピー', onClick: () => app.actions.copyPNG() }, + ], + })); + const transparentCheck = check({ + label: 'PNGの背景を透明にする', value: false, + onChange: (v) => { state.render.pngTransparent = v; }, + }); + exportSection.add(transparentCheck.el); + exportSection.add(hint('線画のときに透明にすると、体の白ぬりも透明になって線だけが残ります(他の絵に重ねられます)。ふつう・フラットでは体の色はそのまま残ります。')); + + // まんが(コマ)gets its own section: it is a different kind of output. + const comicSection = section(tabExport, 'まんが'); + const storyScaleSegment = segmented({ + label: '書き出しの大きさ', + options: [ + { value: '1', label: '1×(軽い)' }, + { value: '1.5', label: '1.5×' }, + { value: '2', label: '2×(きれい)' }, + ], + value: '1.5', + hint: 'コマ1枚の画素数は「いまの画面の大きさ × この倍率」です。' + + '2× だと台紙のPNGがとても大きくなります(重いときは 1× か 1.5×)', + onChange: (value) => { state.story.scale = Number(value); }, + }); + comicSection.add(storyScaleSegment.el); + const storyColumnsSlider = slider({ + label: '列数', min: 1, max: 4, step: 1, value: 1, format: (v) => `${Math.round(v)}列`, + onInput: (v) => { state.story.columns = Math.round(v); }, + }); + const storyGapSlider = slider({ + label: '間隔', min: 0, max: 40, step: 1, value: 12, format: (v) => `${Math.round(v)}px`, + onInput: (v) => { state.story.gap = v; }, + }); + const storyBackgroundField = colorField({ + label: '台紙の色', value: '#ffffff', + onChange: (v) => { state.story.sheetBackground = v; }, + }); + comicSection.add(storyColumnsSlider.el); + comicSection.add(storyGapSlider.el); + comicSection.add(storyBackgroundField.el); + comicSection.add(buttons({ + items: [ + { id: 'addPanel', label: '今の状態をコマに追加', onClick: () => { app.actions.addStoryPanel(); sync(); } }, + { id: 'saveStory', label: 'まんがを書き出す', primary: true, onClick: () => app.actions.saveStory({ scale: state.story?.scale ?? 1.5 }) }, + ], + })); + comicSection.add(hint('2列以上で3コマ以上のときは、読む順にコーナーへ番号を付けます。')); + const storyPanelBox = h('div', { class: 'story-panels', style: { display: 'grid', gap: '6px' } }); + comicSection.add(controlRow(null, storyPanelBox, { wide: true })); + let storyShape = ''; + + /** Bring back everything a panel saved: pose, face, both bubbles and the camera. */ + const recallStoryPanel = (index) => { + const panel = state.story?.panels?.[index]; + if (!panel) return; + // Replace the bones outright: a merged pose would keep bones the panel never had. + state.pose = { bones: {}, root: [0, 0, 0] }; + app.actions.applyState({ + pose: panel.pose ?? {}, + face: panel.face ?? {}, + caption: panel.caption ?? {}, + caption2: panel.caption2 ?? {}, + // The 擬音 on the shot. A panel saved before this existed clears them, so + // recalling it shows what it actually recorded rather than a leftover. + gion: panel.gion ?? { items: [] }, + // The camera it was recorded with, so the view comes back too. A panel + // saved before this existed simply keeps the current camera. + ...(panel.view ? { view: panel.view } : {}), + }, { scope: 'all', sync: true }); + }; + + const removeStoryPanel = (index) => { + state.story = state.story ?? {}; + state.story.panels = (state.story.panels ?? []).filter((_, i) => i !== index); + // The empty patch is only here to route the edit through the app's own apply + // path: that is what records an undo step and re-reads the panel. + app.actions.applyState({}, { scope: 'caption', sync: true }); + }; + + /** + * Same rebuild policy as the prop list: only when the set of panels changes. + * + * A preview arrives a moment *after* its panel does (it needs a render), so its + * version is part of the identity: when one turns up, the row is drawn again + * with it. + */ + const renderStoryPanels = () => { + const panels = state.story?.panels ?? []; + const thumbs = app.actions.storyThumbs?.() ?? null; + const shape = `${panels.length}:${panels + .map((panel) => `${panel.id ?? ''}:${thumbs?.get?.(panel.id)?.version ?? 0}`) + .join('|')}`; + if (shape === storyShape) return; + storyShape = shape; + storyPanelBox.replaceChildren(); + panels.forEach((panel, index) => { + const thumb = thumbs?.get?.(panel.id)?.url; + const preview = thumb + ? h('img', { + class: 'story-thumb', + alt: panel.label ?? `コマ${index + 1}`, + src: thumb, + title: 'クリックでこのコマを呼び出す', + style: { + width: '100%', height: '76px', objectFit: 'contain', cursor: 'pointer', + background: '#f3f0fa', border: '1px solid #e4dff0', borderRadius: '8px', + }, + onClick: () => recallStoryPanel(index), + }) + : h('div', { + text: '(プレビューなし)', + style: { + padding: '6px 8px', borderRadius: '8px', background: '#f7f5fd', + color: '#8a80a0', fontSize: '11px', + }, + }); + const block = h('div', { + class: 'story-panel', + style: { borderTop: '1px dashed #e4dff0', paddingTop: '6px', display: 'grid', gap: '4px' }, + }, subhead(panel.label ?? `コマ${index + 1}`), preview); + block.append(buttons({ + items: [ + { id: 'recall', label: 'このコマを呼び出す', onClick: () => recallStoryPanel(index) }, + { id: 'remove', label: '削除', onClick: () => removeStoryPanel(index) }, + ], + }).el); + storyPanelBox.append(block); + }); + }; + renderStoryPanels(); + + // The share section comes after まんが, so the reading order is 画像 → まんが → + // 共有 (the order the outputs are usually wanted in). + /* ------------------------------------------------------------ extra export */ + + const extraExportSection = section(tabExport, '共有'); + extraExportSection.add(buttons({ + items: [ + { id: 'share-image', label: '画像をシェア', primary: true, onClick: () => app.actions.shareImage() }, + { id: 'share-x', label: 'Xで投稿', onClick: () => app.actions.shareImage('x') }, + { id: 'share-fb', label: 'Facebookでシェア', onClick: () => app.actions.shareImage('facebook') }, + { id: 'share-link', label: 'この見た目のリンクをコピー', onClick: () => app.actions.copyShareLink() }, + ], + })); + extraExportSection.add(hint('いまの画面を画像にして「#ぶるべースタジオ」を付けて投稿します。' + + 'スマートフォンでは端末の共有画面が開きます。パソコンでは画像をコピーして投稿画面を開くので、貼り付けて投稿してください(Ctrl+V)。')); + + // 手描きの顔パーツを描くための下地。画面を切り取る他の書き出しと違って、これは + // 「スタジオが読み込む大きさ・位置そのまま」の素材なので、別のセクションにして + // 共有のあとに置く(素材を作る → 見せる、の順)。 + const faceMapSection = section(tabExport, 'テクスチャの下地を書き出す'); + faceMapSection.add(buttons({ + items: [ + { id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') }, + { id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') }, + ], + })); + faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。' + + 'そのまま絵を描いて(たとえば目にゴルゴ風の眉を描く)「目の画像を読み込む(PNG)」や' + + '「口の画像を読み込む(PNG)」で読み込むと、描いた場所にそのまま入ります。' + + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。')); + + /* --------------------------------------------------------------- settings */ + + const settingsSection = section(tabExport, '設定'); + + settingsSection.add(buttons({ + items: [ + { id: 'save', label: '設定を保存(JSON)', onClick: () => app.actions.saveSettings() }, + { id: 'load', label: '設定を読み込む', onClick: () => settingsPicker.click() }, + ], + })); + + const settingsPicker = h('input', { type: 'file', accept: '.json,application/json', style: { display: 'none' } }); + settingsPicker.addEventListener('change', async () => { + const file = settingsPicker.files?.[0]; + settingsPicker.value = ''; + if (!file) return; + try { + const text = await file.text(); + app.actions.loadSettings(text); + } catch (error) { + console.error(error); + toast('設定ファイルを読み込めませんでした'); + } + }); + settingsSection.add(settingsPicker); + + /* -------------------------------------------------------------------- sync */ + + // A tiny read-out of what the renderer is actually doing: if the picture ever + // looks wrong or never appears, this says why (buffer size, quality, fps). + const infoSection = section(tabView, '画面の情報'); + const statsLine = h('p', { class: 'hint', text: '計測中…' }); + infoSection.add(statsLine); + infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。')); + + boneSyncers.push(() => { + const name = rig.selected; + for (const [key, button] of boneButtons) button.classList.toggle('active', key === name); + const delta = name ? rig.getDelta(name) : { x: 0, y: 0, z: 0 }; + axisSliders.x.set(delta.x); + axisSliders.y.set(delta.y); + axisSliders.z.set(delta.z); + const root = state.pose.root ?? [0, 0, 0]; + rootSliders.forEach((widget, index) => widget.set(root[index] ?? 0)); + }); + + syncers.push(...boneSyncers); + + syncers.push(() => { + for (const key of ['left', 'right']) { + eyeWidgets[key].open.set(state.face.eyes[key].open); + eyeWidgets[key].closed.set(state.face.eyes[key].closed); + eyeWidgets[key].closedLines.set(String(state.face.eyes[key].closedLines ?? 1)); + eyeWidgets[key].threeFlip.set(state.face.eyes[key].threeFlip ? 'flip' : 'normal'); + // The direction only means anything for the "3" eye. + eyeWidgets[key].threeFlip.el.style.display = state.face.eyes[key].closed === 'three' ? '' : 'none'; + eyeWidgets[key].tear.set(state.face.eyes[key].tear ?? 0); + eyeWidgets[key].tearY.set(state.face.eyes[key].tearY ?? 0); + eyeWidgets[key].tearX.set(state.face.eyes[key].tearX ?? 0); + eyeWidgets[key].tearTilt.set(state.face.eyes[key].tearTilt ?? 0); + eyeWidgets[key].eyeX.set(state.face.eyes[key].eyeX ?? 0); + } + irisShapeSegment.set(state.face.eyes.left.irisShape ?? 'circle'); + + lookPad.set({ x: state.face.eyes.left.lookX, y: state.face.eyes.left.lookY }); + highlightToggle.set(state.face.eyes.highlight); + irisScaleSlider.set(state.face.eyes.irisScale); + heartScaleSlider.set(state.face.eyes.heartScale ?? 0.85); + heartWhiteToggle.set(state.face.eyes.heartHideWhite === true); + heartColorField.set(state.face.eyes.heartColor ?? '#e0344f'); + lookMaxSlider.set(state.face.eyes.lookMax ?? 1); + lidWidthSlider.set(state.face.eyes.lidWidth ?? 14); + lowerLidSlider.set(state.face.eyes.lowerLid ?? 0); + browToggle.set(state.face.eyes.brow.enabled); + browSliders.angle.set(state.face.eyes.brow.angle); + browSliders.height.set(state.face.eyes.brow.height); + browSliders.length.set(state.face.eyes.brow.length); + browSliders.thickness.set(state.face.eyes.brow.thickness); + browSliders.spacing.set(state.face.eyes.brow.spacing ?? 0); + browSliders.curve.set(state.face.eyes.brow.curve); + browSliders.taper.set(state.face.eyes.brow.taper ?? 1); + browColor.set(state.face.eyes.brow.color); + glassesToggle.set(state.face.eyes.glasses.enabled === true); + glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses'); + for (const [key, widget] of Object.entries(glassesSliders)) widget.set(state.face.eyes.glasses[key]); + glassesFrameColor.set(state.face.eyes.glasses.frameColor); + glassesLensColor.set(state.face.eyes.glasses.lensColor); + eyeColors.white.set(state.face.eyes.white); + eyeColors.iris.set(state.face.eyes.iris); + eyeColors.line.set(state.face.eyes.line); + eyeSource.set(state.face.eyes.source); + }); + + syncers.push(() => { + cheeksToggle.set(state.face.eyes.cheeks.enabled === true); + for (const [key, widget] of Object.entries(cheekSliders)) widget.set(state.face.eyes.cheeks[key]); + cheekColor.set(state.face.eyes.cheeks.color); + headMarkShape.set(state.face.eyes.headMark.shape ?? 'off'); + for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]); + headMarkColor.set(state.face.eyes.headMark.color); + }); + + syncers.push(() => { + mouthToggle.set(state.face.mouth.visible); + for (const [key, widget] of Object.entries(mouthSliders)) widget.set(state.face.mouth[key]); + for (const [key, widget] of Object.entries(mouthColors)) widget.set(state.face.mouth[key]); + mouthSource.set(state.face.mouth.source); + }); + + syncers.push(() => { + motionSegment.set(state.anim.mode ?? 'off'); + animWidgets.blink.set(state.anim.blink); + animWidgets.lookAround.set(state.anim.lookAround); + }); + + syncers.push(() => { + styleSegment.set(state.render.style); + outlineToggle.set(state.render.outline); + outlinePixelsSlider.set(state.render.outlinePixels ?? 2); + leafBodyLineCheck.set(state.render.leafBodyLine !== false); + transparentCheck.set(state.render.pngTransparent); + lightPad.set({ + x: state.render.lightAzimuth / 180, + y: Math.max(0, (state.render.lightElevation - 5) / 80), + }); + lightIntensitySlider.set(state.render.lightIntensity ?? 2.1); + ambientSlider.set(state.render.ambient ?? 0.9); + exposureSlider.set(state.render.exposure ?? 1); + const line = ['lineart', 'outline'].includes(state.render.style); + outlineToggle.el.querySelector('input').disabled = line; + }); + + syncers.push(() => { + projectionSegment.set(state.view.projection); + cameraSliders.azimuth.set(state.view.azimuth); + cameraSliders.polar.set(state.view.polar); + const ortho = state.view.projection === 'ortho'; + cameraSliders.size.set(ortho ? (state.view.orthoHeight || 7.5) : (state.view.distance || 12)); + cameraSliders.targetY.set(state.view.targetY || model.size.y * 0.5); + autoRotateToggle.set(state.view.autoRotate); + }); + + syncers.push(() => { + lookAtToggle.set(state.lookAt?.enabled === true); + for (const [key, widget] of Object.entries(lookAtSliders)) { + widget.set(state.lookAt?.[key] ?? LOOK_AT_FALLBACKS[key]); + } + turnBodyToggle.set(state.lookAt?.turnBody === true); + }); + + syncers.push(() => { + themeSegment.set(state.render?.theme ?? 'original'); + for (const [key, widget] of Object.entries(bodyColors)) { + widget.set(state.render?.colors?.[key] ?? BODY_COLOR_FALLBACKS[key]); + } + envIntensitySlider.set(state.render?.envIntensity ?? 1); + }); + + syncers.push(() => { + mirrorToggle.set(state.render?.mirror === true); + shadowCheck.set(state.render?.shadow !== false); + shadowOpacitySlider.set(state.render?.shadowOpacity ?? 0.22); + shadowSoftnessSlider.set(state.render?.shadowSoftness ?? 1.6); + contactShadowToggle.set(state.render?.contactShadow === true); + environmentSegment.set(state.render?.environment ?? 'gradient'); + }); + + syncers.push(() => { + const background = state.view?.background ?? 'solid'; + // 素材 is active for either half of it, and its rows show only then. + const isMaterial = background === 'preset' || background === 'effect'; + backgroundModeSegment.set(isMaterial ? 'material' : background); + materialGroup.style.display = isMaterial ? '' : 'none'; + backgroundColour.set(state.view?.backgroundColor ?? '#ffffff'); + backgroundFitSegment.set(state.view?.backgroundFit ?? 'cover'); + backgroundBlurSlider.set(state.view?.backgroundBlur ?? 0); + backgroundDarkenSlider.set(state.view?.backgroundDarken ?? 0); + backgroundScaleSlider.set(state.view?.backgroundScale ?? 1); + cameraFacingSegment.set(state.view?.cameraFacing ?? 'environment'); + cameraMirrorToggle.set(state.view?.cameraMirror === true); + gyroToggle.set(state.view?.gyro === true); + }); + + syncers.push(() => { + renderProps(); + const items = state.props?.items ?? []; + // A rebuild only carries the whole-prop scale, so re-apply each sign's face + // size to the freshly built mesh here (see `placedPropGroups`). + const groups = placedPropGroups(app); + for (const entry of propWidgets) { + const item = items[entry.index] ?? {}; + entry.sliders.x.set(item.x ?? 0); + entry.sliders.y.set(item.y ?? 0); + entry.sliders.z.set(item.z ?? 0); + entry.sliders.rotY.set(item.rotY ?? 0); + entry.sliders.scale.set(item.scale ?? 1); + entry.faceScale?.set(item.faceScale ?? 1); + applyPropFaceScale(groups[entry.index], item.faceScale); + } + }); + + syncers.push(() => { + renderGion(); + const items = state.gion?.items ?? []; + for (const entry of gionWidgets) { + const item = items.find((candidate) => candidate.id === entry.id); + if (!item) continue; + entry.sliders.w.set(item.w ?? DEFAULT_STAMP_WIDTH); + entry.sliders.rot.set(item.rot ?? 0); + entry.flip.set(item.flip === true); + entry.block.classList.toggle('active', item.id === app.gionSelected); + } + }); + + syncers.push(() => { + const wall = state.render?.wall ?? defaultState().render.wall; + wallToggle.set(wall.on === true); + for (const [key, widget] of Object.entries(wallSliders)) widget.set(wall[key] ?? (key === 'size' ? 1 : 0)); + wallGuideToggle.set(wall.guide === true); + }); + + syncers.push(() => { + captionBubbleSegment.set(activeCaption); + const bubble = cap() ?? {}; + captionToggle.set(bubble.enabled === true); + captionText.set(bubble.text ?? ''); + bubbleSegment.set(bubble.bubble ?? 'round'); + tailSegment.set(bubble.tail ?? 'left'); + for (const [key, widget] of Object.entries(captionSliders)) { + widget.set(bubble[key] ?? CAPTION_SLIDER_FALLBACKS[key]); + } + captionPosition.x.set(bubble.x ?? 0.58); + captionPosition.y.set(bubble.y ?? 0.16); + captionMaxWidthSlider.set(bubble.maxWidth ?? 0.36); + alignSegment.set(bubble.align ?? 'left'); + boldToggle.set(bubble.bold === true); + systemFontToggle.set(bubble.font === 'system'); + for (const [key, widget] of Object.entries(captionColors)) { + widget.set(bubble[key] ?? CAPTION_COLOR_FALLBACKS[key]); + } + }); + + syncers.push(() => { + flapText.set(state.mouthFlap?.text ?? ''); + for (const [key, widget] of Object.entries(flapSliders)) { + widget.set(state.mouthFlap?.[key] ?? MOUTH_FLAP_FALLBACKS[key]); + } + }); + + syncers.push(() => { + storyScaleSegment.set(String(state.story?.scale ?? 1.5)); + storyColumnsSlider.set(state.story?.columns ?? 1); + storyGapSlider.set(state.story?.gap ?? 12); + storyBackgroundField.set(state.story?.sheetBackground ?? '#ffffff'); + renderStoryPanels(); + }); + + app.actions.applyFacePreset = applyFacePreset; + app.actions.applyPosePreset = applyPosePreset; + + sync(); + + // The 'g' key toggles the gizmo behind the panel's back, so the checkbox + // follows the rig rather than the other way round. + syncers.push(() => gizmoToggle.set(rig.helper.visible === true)); + + return { + sync, + onRigChanged: syncBones, + /** Which bubble the caption controls are editing (a drag on it calls this). */ + selectCaption(key) { + if (key !== 'caption' && key !== 'caption2') return; + activeCaption = key; + sync(); + }, + /** Which 擬音 stamp the viewport should outline (a drag on it calls this). */ + selectGion(id) { + app.gionSelected = id ?? null; + sync(); + }, + /** Open the crop picker for a sheet (the 擬音 section's button calls this). */ + openGionPicker(sheet) { + openGionPicker(sheet ?? gionSheetSelect.get()); + }, + setStats(text) { statsLine.textContent = text; }, + setRecording(recording) { + recordButton.setLabel('record', recording ? '録画を停止' : '録画を開始'); + }, + }; +} diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js new file mode 100644 index 0000000..3146e0b --- /dev/null +++ b/public/bluebey-studio/src/presets.js @@ -0,0 +1,698 @@ +/** + * The complete, serialisable studio state plus the preset libraries. + * + * Everything here is plain JSON: "設定を保存" writes exactly this object, and + * "設定を読み込む" merges it back in, so a look can be archived and shared. + * + * Eye parameters are named after the character's own left/right, NOT the + * viewer's: `left` is the eye on the model's +x side, which is the half of the + * eye texture with u > 0.5. In a front view that eye appears on the right of + * the screen. + */ + +/** + * One speech bubble's defaults. + * + * A factory rather than a constant, because there are two bubbles (see + * `caption2`) and they must not share one object - editing one would edit both. + */ +function captionDefaults() { + return { + enabled: false, + text: 'こんにちは、ぶるべーです。', + // Anchor of the bubble's box, as a fraction of the image (0..1). + x: 0.58, + y: 0.16, + maxWidth: 0.36, // fraction of the image width for the text column + bubble: 'round', // round | rect | shout | none + tail: 'left', // left | right | top | bottom | topLeft | topRight | bottomLeft | bottomRight | none + fontSize: 34, // pixels at 1x; scales with the export + lineHeight: 1.42, + padding: 18, + radius: 24, + textColor: '#3f2b52', + bubbleColor: '#ffffff', + borderColor: '#55386e', + borderWidth: 4, + bold: false, + align: 'left', // left | center | right + font: 'rounded', // rounded (vendored) | system + }; +} + +export function defaultState() { + return { + version: 1, + view: { + projection: 'persp', // persp | ortho + fov: 30, + azimuth: 0, // degrees, 0 = looking at the face + polar: 76, // degrees from +y, 90 = level with the target + distance: 0, // 0 = fit to the model automatically + orthoHeight: 0, // 0 = fit to the model automatically + targetY: 0, // 0 = centre of the model + // The mouse orbit and pan write their own target back here (see + // ViewRig.captureInto), so refreshing the view - or sharing a link - keeps + // the camera the user actually framed. + targetX: 0, + targetZ: 0, + autoRotate: false, + autoRotateSpeed: 0.8, + // solid | transparent | preset | image | effect | camera + // preset = one of the CC0 backdrops in assets/backgrounds + // image = something the user loaded (data URL) + // effect = a procedurally drawn manga effect line (no file at all) + // camera = the phone's camera, for the AR mode + background: 'solid', + backgroundColor: '#ffffff', + backgroundPreset: 'autumn_park', + backgroundEffect: 'focus', // focus | fall | speed | ellipse + backgroundImage: null, // data URL, set by 「画像を読み込む」 + backgroundFit: 'cover', // cover | contain | stretch | tile + backgroundBlur: 0, // px + backgroundDarken: 0, // 0..1 dark veil, helps the character read + backgroundScale: 1, + backgroundOffset: { x: 0, y: 0 }, + // AR: which camera, and whether to mirror (front camera feels mirrored) + cameraFacing: 'environment', // environment | user + cameraMirror: false, + gyro: false, // look around by turning the phone + }, + render: { + style: 'real', // real | flat | lineart | outline + outline: true, + outlineWidth: 0.022, + outlineColor: '#2a1e33', + paper: '#ffffff', + shadow: true, + pngScale: 2, + pngTransparent: false, + svgWidth: 2048, + lightAzimuth: -38, + lightElevation: 40, + lightIntensity: 2.1, + ambient: 0.9, + exposure: 1, + // Body colours. A theme fills these in; the colour pickers then adjust + // them one part at a time, so a theme is a starting point, not a mode. + theme: 'original', + colors: { + body: '#c8b0f0', + accent: '#8a4fe0', + nose: '#7a4fb0', + leaf: '#a6dd6a', + vein: '#7fbf3f', + feet: '#7a4fb0', + }, + // Mirror the whole character, for laying out a panel that faces its text. + mirror: false, + // Shadows: the soft blob under the feet, plus the cast shadow. + shadowOpacity: 0.22, + shadowSoftness: 1.6, + shadowOffset: 0, + contactShadow: false, // blob only, no cast shadow + // Lighting environment. 'room' is three's RoomEnvironment; 'gradient' is + // the tiny procedural sky the studio always had. + environment: 'gradient', // gradient | room | none + envIntensity: 1, + // Hand-drawn line work (SVG export and the outline pass). + handDrawn: 0, // 0 = off, 1 = a lot + handDrawnSeed: 7, + handDrawnScale: 40, + handDrawnPasses: 1, + // How the outline is drawn - the two methods take the parts they are good + // at. See MODEL-GUIDE.md §5 and src/outline.js. + // + // 'screen' (the default) hands the leaves to the screen-space edge pass, + // because a hull cannot outline a shell that thin: the expanded + // front and back cross inside it and the line breaks up. Every + // other part keeps its hull, whose line is computed from the + // geometry and so comes out smooth, where the pixel grid would + // make it stepped. + // 'hull' the inverted-hull copy everywhere - cheap, and the way it was + // done before, but the leaves then come out as a tangle. + outlineMethod: 'screen', + outlinePixels: 2.4, // 'screen' only: line width in screen pixels + // Whether a leaf gets a line where it meets the *body*. There is no right + // answer here, so it is a setting: + // on - the body counts as paper, so the leaves are outlined where they + // emerge from it. Without this a line drawing cannot tell the + // leaves and the body apart at all (both are paper). + // off - the body blocks the line, so the leaf simply passes behind it and + // nothing is drawn along the meeting. Cleaner, but the leaves and + // the body merge into one white shape. + leafBodyLine: true, + // 見えない壁 (the invisible wall). A placeable clipping plane that hides + // whatever is behind it, so the character can be buried in the wall and + // only the rest of the body shows. See src/clip.js. + wall: { + on: false, + x: 0, y: 0, z: 0, // a point the plane passes through (world units) + yaw: 0, // turn about Y, degrees + tilt: 0, // lean about X, degrees + // The guide is the only part of the wall that is ever *drawn* (the wall + // itself writes depth but no colour, so it hides what is behind it + // without being visible). `guide` shows that rectangle while placing, + // which is also when it can be dragged in the viewport. + guide: false, + size: 1, // the wall's size (a real width and height, not a clip) + }, + }, + pose: { + bones: {}, // { boneName: [x, y, z] } in degrees, rest = 0,0,0 + root: [0, 0, 0], // whole-body offset in model units + }, + anim: { + mode: 'off', // off | idle | walk + blink: true, + blinkInterval: 3.4, // seconds between blinks + lookAround: false, + speed: 1, + }, + face: { + eyes: { + source: 'parametric', // parametric | opened | closed | ... (hand-drawn) + linked: true, // move both eyes together + left: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0 }, + right: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0 }, + irisScale: 1, + lookMax: 1, + highlight: true, + lidWidth: 14, + lowerLid: 0, + heartScale: 2, + heartColor: '#e0344f', + heartHideWhite: true, + // The tears live on each eye (see `eyes.left.tear`), so one eye can cry + // on its own; only the colour is shared. + tearColor: '#8fd8ff', + // ほっぺ (a manga blush) and 頭の模様 (a mark on the head) are drawn into + // the same plate as the eyes and brows. Both are generic manga devices, + // not a copy of any one character, and both start off. The mark's shape + // is one of `off`, `hook`, `spiral`, `strokes`, `dots`. + cheeks: { + enabled: false, + size: 1, + spacing: 0, // extra gap outwards from the eyes + offsetY: 0, // extra drop below the eyes + lines: 4, // hatch strokes; 0 leaves a plain patch + color: '#f6a6b8', + }, + headMark: { + shape: 'off', + size: 1, + offsetX: 0, + offsetY: 0, + lineWidth: 1, + color: '#8a4fe0', + }, + // Eyebrows are drawn into the eye texture. Off by default, because the + // original character has no eyebrows. + brow: { + enabled: false, + angle: 0, + height: 0, + length: 1, + thickness: 1, + curve: 0.14, + // How wide the nose-side end is: 1 = a plain stroke, 0 = a wedge that + // comes to a point (the ゴル風 look). `spacing` widens the gap between + // the two brows. + taper: 1, + spacing: 0, + color: '#55386e', + }, + // 眼鏡 / サングラス are drawn into the same texture as the eyes and brows, + // so they travel with the head instead of being a separate 3D mesh. Off + // by default. `kind` only picks which lens defaults the panel offers; the + // drawing itself reads the colours and opacities below. + glasses: { + enabled: false, + kind: 'glasses', // glasses | sunglasses + frameColor: '#2a1e33', + frameWidth: 1, // multiplier on the frame stroke (EYE_LAYOUT.lidStroke) + lensColor: '#2b2433', + lensOpacity: 0.22, // 0..1; 眼鏡 is nearly clear, サングラス dark + scale: 1, // lens size multiplier + offsetY: 0, // px, artwork units (positive = down) + tilt: 0, // degrees, both lenses turn together + }, + white: '#ffffff', + iris: '#150e1b', + line: '#55386e', + }, + mouth: { + source: 'parametric', // parametric | original + visible: true, + smile: 1, // 0 = straight line, 1 = the original smile, <0 = frown + width: 1, + thickness: 1, + open: 0, // 0 = closed lips, 1 = wide open + tilt: 0, // degrees + offsetY: 0, // texture pixels + tongue: 0.96, // 0 hides it; scales the whole tongue + tonguePos: 0.84, // 0..1 along the smile line + tongueOut: 0, // 0..1; >0 hangs the tongue *out*, below the lip + tongueOutPos: 0.5, // 0..1 along the lip, for the tongue that hangs out + corners: 0.71, // length of the corner marks, 0 hides them + cornerAngle: -8, // degrees, tilts the corner marks + round: 0, // >0 turns the mouth into a round "O" + color: '#ff1a44', + innerColor: '#4a0f1e', + tongueColor: '#ff2d2d', + cornerColor: '#725497', + line: '#55386e', + }, + }, + // ---------------------------------------------------------------- caption + // The speech bubbles drawn next to the character when exporting. They live in + // the state so they are saved, shared and undone like everything else. There + // are two of them, so a line and a reply (or a narrator and a speaker) fit. + caption: captionDefaults(), + // The second bubble starts on the other side, so the two do not overlap. + caption2: { ...captionDefaults(), text: '', x: 0.08, y: 0.44 }, + // -------------------------------------------------------- 口パク (mouth flap) + // The mouth moves as if talking, with no sound at all: the device's own + // speech voices belong to the device, so a recording of them is not ours to + // hand out. Only the *length* of the text matters here. + mouthFlap: { + text: 'こんにちは。小平市のぶるべーです。', + rate: 1, // how fast the syllables come + mouthGain: 1, // how wide the mouth opens + }, + // ----------------------------------------------------------------- look at + // Where the character should look (world units). + lookAt: { + enabled: false, + x: 0, + y: 2.6, + z: 3, + turnBody: false, // also rotate the whole body a little + amount: 1, + // The turn and the gaze are *remembered* rather than derived, so switching + // the mode (or just 体も向ける) off leaves the character where it is instead + // of snapping it back to the front. See `applyLookAt` in main.js. + bodyYawDeg: 0, + freeze: null, + }, + // ------------------------------------------------------------------- props + // 小物. Each entry is a procedural prop from src/props.js. + props: { + items: [], + }, + // -------------------------------------------------------------------- 擬音 + // マンガのオノマトペ (擬音) as stickers over the picture. Each item is a crop + // of a sheet - `{ id, sheet, sx, sy, sw, sh, x, y, w, rot, flip }` - and only + // the sheet *name* is stored, never the bitmap, so a shared link stays small + // (see src/gion.js for the geometry). + gion: { + items: [], + }, + // ------------------------------------------------------------------- story + // 4コマ / 紙芝居. Each panel is a small state patch (pose, face, caption). + story: { + columns: 1, + gap: 12, + padding: 20, + sheetBackground: '#ffffff', + // How big each captured panel is, as a multiple of the viewport. 2x doubles + // each side, so the sheet PNG gets four times the pixels per panel. + scale: 1.5, + panels: [], + }, + }; +} + +/** Expression presets. Each patches a copy of the default state. */ +export const FACE_PRESETS = [ + { + id: 'normal', + label: 'ふつう', + face: { + eyes: { + left: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + right: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + irisScale: 1, + highlight: true, + }, + // Depth 1 = the hand-drawn original; the corners and tongue below are also + // the settings that reproduce it (measured against the artwork). + mouth: { smile: 1, width: 1, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0.96, tonguePos: 0.84, corners: 0.71, cornerAngle: -8 }, + }, + }, + { + id: 'laugh', + label: 'にっこり', + face: { + eyes: { + left: { open: 0, lookX: 0, lookY: 0, closed: 'arch' }, + right: { open: 0, lookX: 0, lookY: 0, closed: 'arch' }, + highlight: false, + }, + // A laugh is a wide smile with squeezed-shut eyes. An *open* mouth on a + // round mascot reads as a shout, so only びっくり keeps the round O. + mouth: { smile: 1.15, width: 1.14, thickness: 1.05, open: 0, tilt: 0, offsetY: 0, round: 0, tongue: 0.96, corners: 1.25 }, + }, + }, + { + id: 'eating', + label: 'たべている', + face: { + eyes: { + left: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' }, + right: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' }, + irisScale: 1, + highlight: true, + }, + // A small, cute round mouth (the other round mouth, with びっくり). + mouth: { smile: 0.2, width: 0.8, thickness: 1.1, open: 0, tilt: 0, offsetY: 6, round: 0.58, tongue: 0.7, corners: 0 }, + }, + }, + { + id: 'wink', + label: 'ウインク', + face: { + eyes: { + // The shut eye is the original `eyes-close-tight` artwork: three strokes + // sharing one vertex, pointing at the nose. + left: { open: 0, lookX: 0, lookY: 0, closed: 'line', closedLines: 3 }, + right: { open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 1 }, + irisScale: 1, + highlight: true, + }, + mouth: { smile: 1.0, width: 1.02, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 1 }, + }, + }, + { + id: 'surprised', + label: 'びっくり', + face: { + eyes: { + left: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + right: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + irisScale: 0.74, + lookMax: 0.35, + highlight: true, + }, + mouth: { smile: 0.2, width: 0.85, thickness: 1.1, open: 0, tilt: 0, offsetY: 0, round: 0.62, tongue: 0.25, corners: 0 }, + }, + }, + { + id: 'sleepy', + label: 'ねぼけ', + face: { + eyes: { + left: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' }, + right: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' }, + irisScale: 1, + highlight: true, + }, + mouth: { smile: 0.62, width: 0.88, thickness: 1, open: 0, tilt: -3, offsetY: 6, tongue: 0, corners: 0.8 }, + }, + }, + { + id: 'love', + label: 'すき', + face: { + eyes: { + left: { open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart' }, + right: { open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart' }, + irisScale: 1, + // Size 2 with the eyeball's white hidden is the size that reads best: + // any white behind the heart pokes out around its lobes. + heartScale: 2, + heartColor: '#e0344f', + heartHideWhite: true, + highlight: false, + }, + mouth: { smile: 1.15, width: 1, thickness: 1.05, open: 0.16, tilt: 4, offsetY: 0, tongue: 0.9, corners: 1.3 }, + }, + }, + { + id: 'grumpy', + label: 'むっと', + face: { + eyes: { + left: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 }, + right: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 }, + irisScale: 0.86, + highlight: true, + }, + mouth: { smile: -0.62, width: 0.84, thickness: 1.15, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 }, + }, + }, + { + id: 'cry', + label: 'ないてる', + face: { + eyes: { + // Big tears that hang low and lean outwards - the classic "crying hard" + // look. The sideways position and the tilt are mirrored between the eyes, + // so the pair stays symmetric. + left: { open: 0.72, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearY: 26, tearTilt: -20 }, + right: { open: 0.72, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearY: 26, tearTilt: 20 }, + irisScale: 1.02, + lowerLid: 0.42, + highlight: true, + }, + mouth: { smile: -0.5, width: 0.72, thickness: 1.25, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 }, + }, + }, +]; + +/** + * Poses. `bones` holds per-bone XYZ degrees relative to the rest pose. + * + * The axes were measured (turn one bone 40° and watch the hand's centre move), + * and the two sides are NOT the same. That is what used to send the left hand + * behind while the right one came forward: + * + * arm / hand + * X lift. Up on both sides, so the SAME value is symmetric. + * Y roll about the flipper - which way the palm faces. + * Z forward/back. +Z is BACK on the left and FORWARD on the right, so a + * symmetric pose writes the right-hand Z with the opposite sign, while + * a walk (opposite arms) writes the SAME sign on both. + * legsupport + * X out to the side and up (same value on both sides is symmetric) + * Y twist (which way the toe points) + * Z forward/back, mirrored like `arm` + * It pivots at the middle of the body, so the feet travel a long way for + * a small angle - keep it under about 15 degrees. + * master + * X lean forward Y spin around Z roll sideways + * + * `armsupport` still exists in the rig but it is a control bone that pivots at + * the body's centre, so it swings a hand in a wide arc across the face. Every + * preset below drives `arm`/`hand` instead. + */ +export const POSE_PRESETS = [ + { id: 'stand', label: '立ち', pose: { bones: {} } }, + { id: 'bow', label: 'おじぎ', pose: { bones: { master: [22, 0, 0] } } }, + { id: 'deep-bow', label: 'ふかくおじぎ', pose: { bones: { master: [42, 0, 0] } } }, + { id: 'tilt', label: 'くびかしげ', pose: { bones: { master: [0, 0, 16] } } }, + { + id: 'wave', + label: 'てをふる', + pose: { + bones: { + master: [0, 0, -4], + // The left flipper goes up and slightly forward. Y is the roll about the + // flipper's own length, which is what turns the palm to face outwards. + 'arm.l': [84, 0, -6], + 'hand.l': [0, -30, 0], + }, + }, + }, + { + id: 'banzai', + label: 'ばんざい', + pose: { + bones: { + master: [-8, 0, 0], + // Both flippers as far up as the shoulder allows (the pivot sits at the + // middle of the body, so they cannot reach above the head). + 'arm.l': [106, 0, -12], + 'arm.r': [106, 0, 12], + }, + root: [0, 0.2, 0], + }, + }, + { + id: 'stretch', + label: 'のびをする', + pose: { + bones: { + master: [-16, 0, 0], + 'arm.l': [112, 0, -8], + 'arm.r': [112, 0, 8], + }, + root: [0, 0.08, 0], + }, + }, + { + id: 'jump', + label: 'ジャンプ', + pose: { + bones: { + master: [-8, 0, 0], + 'arm.l': [68, 0, -10], + 'arm.r': [68, 0, 10], + 'legsupport.l': [10, 0, 0], + 'legsupport.r': [10, 0, 0], + }, + root: [0, 0.62, 0], + }, + }, + { + id: 'leap', + label: 'とびはねる', + pose: { + bones: { + master: [-18, 0, 0], + 'arm.l': [92, 0, -12], + 'arm.r': [92, 0, 12], + 'legsupport.l': [14, 0, 0], + 'legsupport.r': [14, 0, 0], + }, + root: [0, 1.0, 0], + }, + }, + { + id: 'dance', + label: 'おどる', + pose: { + bones: { + master: [-4, 0, -12], + // Deliberately uneven: the left flipper is up, the right one is out in + // front, and the feet step the other way. + 'arm.l': [78, 0, -14], + 'arm.r': [14, 0, 34], + 'legsupport.l': [0, 0, 8], + 'legsupport.r': [0, 0, 8], + }, + root: [0, 0.14, 0], + }, + }, + { + id: 'tip-over', + label: 'こてん', + pose: { + bones: { + master: [0, 0, 68], + 'arm.l': [26, 0, -18], + 'arm.r': [10, 0, 18], + }, + root: [0, 0.25, 0], + }, + }, + { id: 'side', label: 'よこむき', pose: { bones: { master: [0, 92, 0] } } }, + { id: 'turn', label: 'うしろむき', pose: { bones: { master: [0, 180, 0] } } }, + { + id: 'present', + label: '右を紹介', + pose: { + bones: { + master: [6, -26, 0], + // The right flipper sweeps forward to point at whatever is on its right + // (+Z is forwards on the right); the left one just steps aside. + 'arm.r': [16, 0, 52], + 'arm.l': [10, 0, -6], + }, + }, + }, + { + id: 'present-left', + label: '左を紹介', + pose: { + bones: { + master: [6, 26, 0], + 'arm.l': [16, 0, -52], + 'arm.r': [10, 0, 6], + }, + }, + }, +]; + +/** Deep-merge `patch` into `target` (plain objects only). */ +export function applyPatch(target, patch) { + for (const [key, value] of Object.entries(patch ?? {})) { + if (value && typeof value === 'object' && !Array.isArray(value) + && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) { + applyPatch(target[key], value); + } else { + target[key] = Array.isArray(value) ? value.slice() : value; + } + } + return target; +} + +export function cloneState(state) { + return JSON.parse(JSON.stringify(state)); +} + +/** + * Body colour themes (C-1). Each is a full set of part colours, so picking one + * is a single assignment; the individual pickers in the panel then let you drift + * away from it without losing the rest. + * + * ぶるべー is a leaf-sprout mascot, so the themes are named after plants and + * weather rather than after colours. + */ +export const THEMES = [ + { + id: 'original', + label: 'オリジナル', + colors: { body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0', leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0' }, + }, + { + id: 'sakura', + label: 'さくら', + colors: { body: '#f7c6d9', accent: '#e0709b', nose: '#c25b7e', leaf: '#b8e08a', vein: '#8dbb54', feet: '#c25b7e' }, + }, + { + id: 'matcha', + label: 'まっちゃ', + colors: { body: '#cfe3ac', accent: '#6f9c3a', nose: '#5c8030', leaf: '#9fd463', vein: '#79ab3c', feet: '#5c8030' }, + }, + { + id: 'sora', + label: 'そら', + colors: { body: '#bcd9f7', accent: '#4a7fd4', nose: '#3f6cae', leaf: '#a8e08a', vein: '#7cbb58', feet: '#3f6cae' }, + }, + { + id: 'lemon', + label: 'レモン', + colors: { body: '#f8e9a6', accent: '#d8a520', nose: '#b3871a', leaf: '#b9de74', vein: '#8fbb45', feet: '#b3871a' }, + }, + { + id: 'momo', + label: 'もも', + colors: { body: '#f9cbae', accent: '#e07a4a', nose: '#bd6238', leaf: '#aede7c', vein: '#84b84e', feet: '#bd6238' }, + }, + { + id: 'sumi', + label: 'すみ', + colors: { body: '#9aa2b8', accent: '#4b5468', nose: '#3c4356', leaf: '#8fbf7a', vein: '#6d9a58', feet: '#3c4356' }, + }, + { + id: 'yozora', + label: 'よぞら', + colors: { body: '#8f8fd0', accent: '#403f8f', nose: '#34336f', leaf: '#7fc7b0', vein: '#5aa08c', feet: '#34336f' }, + }, +]; + +/** Bubble shapes offered for captions. */ +export const BUBBLE_STYLES = [ + { value: 'round', label: 'まる' }, + { value: 'rect', label: 'しかく' }, + { value: 'shout', label: 'さけぶ' }, + { value: 'none', label: 'なし' }, +]; + +/** A couple of ready-made captions, so the feature is discoverable. */ +export const CAPTION_PRESETS = [ + { id: 'greeting', label: 'あいさつ', caption: { enabled: true, text: 'こんにちは、ぶるべーです。' } }, + { id: 'explain', label: '説明', caption: { enabled: true, text: 'ここがポイントです。', bubble: 'rect', tail: 'left' } }, + { id: 'surprise', label: 'びっくり', caption: { enabled: true, text: 'えっ!?', bubble: 'shout', tail: 'left', fontSize: 40, bold: true } }, +]; diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js new file mode 100644 index 0000000..5ad958a --- /dev/null +++ b/public/bluebey-studio/src/props.js @@ -0,0 +1,459 @@ +import * as THREE from 'three'; + +/** + * Procedural 小物 (props) that can be placed on the stage next to the character: + * a lectern, a desk, a microphone stand, a signboard, a potted plant and a + * cardboard box. + * + * Everything is built from three's primitives instead of loaded from a file, for + * three reasons. The character is about 4.15 units tall with its feet on y = 0 + * and its body a sphere of radius ~1.6 centred near y = 2.4, so every generator + * below works from that scale, and every group's origin is its *base centre* - + * dropping one at a ground position and rotating it about y is all the app has + * to do. The studio also re-skins props with the same toon/flat materials and + * the same inverted-hull outline as the body, so the geometry stays chunky: no + * plate thinner than ~0.05 units (a thin plate's offset hull turns into a smear) + * and no textures anywhere. And props must be identical on every run, so every + * size, position and angle here is a literal - no Math.random(). + * + * Colours are never hard-coded on a mesh. Each material is tagged with + * `userData.part`, and `applyPropColors` fills the colour in from the app's + * render.colors palette, which is what lets the colour-theme feature recolour a + * prop that was built minutes ago. The mapping keeps lightness apart from hue, + * so a prop still reads in the near-monochrome すみ theme: + * + * wood the body colour, darkened - painted wood, cardboard, soil + * paper the body colour, nearly white - the blank sign face + * accent the accent colour - legs, frames, pots, mic bodies + * leaf the leaf colour - foliage + * metal a fixed neutral grey - stands, booms, microphone heads + * + * `userData.shade` multiplies a part's colour, so one prop can use two tones of + * the same part - a cardboard box and its darker inner flaps - without adding a + * sixth palette entry. + */ + +/** Reused when something has to be aimed along a direction vector. */ +const UP = new THREE.Vector3(0, 1, 0); +const WHITE = new THREE.Color(0xffffff); +/** A fixed grey: a metal stand has to read in every theme, warm or grey. */ +const METAL_GREY = '#b9bec7'; +/** Enough of the palette to build with when the app has not sent one yet. */ +const FALLBACK_COLORS = { body: '#c8b0f0', accent: '#8a4fe0', leaf: '#a6dd6a' }; + +const PART_NAMES = ['wood', 'metal', 'paper', 'accent', 'leaf']; + +const clamp01 = (value) => Math.min(1, Math.max(0, value)); + +/** The lectern's reading surface tilts up towards the audience, i.e. towards +z. */ +const SLAB_TILT = -0.32; + +// --------------------------------------------------------------------------- +// Building blocks +// --------------------------------------------------------------------------- + +/** + * A material per (part, shade) inside one prop. Sharing them keeps the draw + * calls down and, more importantly, lets `disposeProp` release each one once. + */ +function materialCache() { + const cache = new Map(); + return (part, shade = 1) => { + if (!PART_NAMES.includes(part)) throw new Error(`unknown prop material part: ${part}`); + const key = `${part}:${shade}`; + let material = cache.get(key); + if (!material) { + const metal = part === 'metal'; + material = new THREE.MeshStandardMaterial({ + color: 0xffffff, // filled in by applyPropColors + roughness: 0.75, + metalness: metal ? 0.6 : 0, + }); + material.userData.part = part; + material.userData.shade = shade; + cache.set(key, material); + } + return material; + }; +} + +/** + * Wraps a generator so each prop gets its own material cache. The generator + * receives `(group, material, options)`, fills the group and returns nothing. + */ +function prop(generator) { + return (options = {}) => { + const group = new THREE.Group(); + generator(group, materialCache(), options); + return group; + }; +} + +/** Adds a mesh, matching the body's shadow flags, and returns it. */ +function addMesh(parent, geometry, material, x = 0, y = 0, z = 0) { + const mesh = new THREE.Mesh(geometry, material); + mesh.position.set(x, y, z); + mesh.castShadow = true; + mesh.receiveShadow = false; + parent.add(mesh); + return mesh; +} + +/** A cuboid, sized as [width, height, depth] and centred on `at`. */ +function addBox(parent, material, size, at = [0, 0, 0]) { + const geometry = new THREE.BoxGeometry(size[0], size[1], size[2]); + return addMesh(parent, geometry, material, at[0], at[1], at[2]); +} + +/** A cylinder (or cone, if the two radii differ) centred on `at`. */ +function addCylinder(parent, material, radiusTop, radiusBottom, height, at, segments = 12) { + const geometry = new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments); + return addMesh(parent, geometry, material, at[0], at[1], at[2]); +} + +/** A rounded blob. The caller usually scales it into a leaf or an ellipsoid. */ +function addSphere(parent, material, radius, at, segments = [10, 8]) { + const geometry = new THREE.SphereGeometry(radius, segments[0], segments[1]); + return addMesh(parent, geometry, material, at[0], at[1], at[2]); +} + +/** A rod from `from` to `to`: stems, booms and struts are all just this. */ +function addStrut(parent, material, from, to, radius, segments = 8) { + const start = new THREE.Vector3(from[0], from[1], from[2]); + const end = new THREE.Vector3(to[0], to[1], to[2]); + const axis = end.clone().sub(start); + const length = axis.length(); + const mesh = addMesh( + parent, + new THREE.CylinderGeometry(radius, radius, length, segments), + material, + (start.x + end.x) / 2, + (start.y + end.y) / 2, + (start.z + end.z) / 2, + ); + mesh.quaternion.setFromUnitVectors(UP, axis.normalize()); + return mesh; +} + +// --------------------------------------------------------------------------- +// The props +// --------------------------------------------------------------------------- + +/** + * 演台. A foot plate, a pedestal, a slab that tilts up towards the audience and + * a gooseneck microphone on the corner nearest the camera. The mic is on the + * audience edge on purpose: on the far edge the slab would hide it from a + * slightly-above camera. + */ +const buildPodium = prop((group, material) => { + addBox(group, material('accent'), [1.30, 0.10, 0.98], [0, 0.05, 0]); + addBox(group, material('wood'), [1.10, 1.30, 0.78], [0, 0.75, 0]); + + const slab = addBox(group, material('wood'), [1.42, 0.12, 0.86], [0, 1.42, 0.10]); + slab.rotation.x = SLAB_TILT; + // A lip along the slab's low edge, so the silhouette reads as a lectern top. + const lip = addBox(group, material('accent'), [1.02, 0.10, 0.10], [0, 1.498, 0.527]); + lip.rotation.x = SLAB_TILT; + + const stem = addCylinder(group, material('metal'), 0.032, 0.032, 0.60, [-0.48, 1.80, 0.26], 8); + stem.rotation.x = SLAB_TILT; + const head = addSphere(group, material('metal'), 0.09, [-0.48, 2.11, 0.15], [10, 8]); + head.scale.set(1, 1.25, 1); +}); + +/** + * 机. A top, four legs and a pair of side rails. The legs and rails take the + * darker `accent` tone so the table keeps its shape in a flat or greyscale + * render, where a single flat colour would turn it into a silhouette. + */ +const buildDesk = prop((group, material) => { + addBox(group, material('wood'), [2.30, 0.12, 1.20], [0, 1.44, 0]); + for (const x of [-1.00, 1.00]) { + for (const z of [-0.46, 0.46]) { + addBox(group, material('accent'), [0.14, 1.38, 0.14], [x, 0.69, z]); + } + addBox(group, material('accent'), [0.12, 0.10, 1.00], [x, 0.42, 0]); + } +}); + +/** + * マイク. A round weighted base, a pole, a short boom and a body with a grille + * head, standing about as high as the character's hands. + */ +const buildMic = prop((group, material) => { + addCylinder(group, material('metal'), 0.40, 0.42, 0.10, [0, 0.05, 0], 16); + addCylinder(group, material('metal'), 0.048, 0.058, 1.35, [0, 0.775, 0], 10); + addStrut(group, material('metal'), [0, 1.42, 0], [0, 1.62, 0.28], 0.038); + + const body = addCylinder(group, material('accent'), 0.13, 0.13, 0.44, [0, 1.80, 0.36], 12); + body.rotation.x = 0.30; + const head = addSphere(group, material('metal'), 0.145, [0, 2.05, 0.42], [12, 8]); + head.scale.set(1, 0.9, 1); +}); + +/** + * 看板. A post, a frame and a blank face. The face is a separate, named child + * tagged `userData.canvasTexture`, so a later feature can paint a CanvasTexture + * onto it without touching the frame - and `applyPropFaceScale` can grow just + * that surface, about its own centre, leaving the frame and posts alone. It is a + * thin box rather than a plane: the outline pass offsets geometry along its + * normals, and a zero-thickness plane gets a visibly doubled rim. + */ +const buildSign = prop((group, material) => { + addBox(group, material('accent'), [0.52, 0.10, 0.52], [0, 0.05, 0]); + addBox(group, material('wood'), [0.16, 1.62, 0.16], [0, 0.91, 0]); + addBox(group, material('accent'), [1.42, 0.98, 0.12], [0, 2.21, 0]); + + const face = addBox(group, material('paper'), [1.18, 0.76, 0.08], [0, 2.21, 0.06]); + face.name = 'sign-face'; + face.userData.canvasTexture = true; +}); + +/** The branches of 観葉植物: where a stem ends, which way its leaf points. */ +const PLANT_BRANCHES = [ + { end: [0.05, 1.12, 0.04], dir: [0.10, 0.96, 0.14] }, + { end: [-0.30, 0.98, -0.06], dir: [-0.52, 0.76, -0.30] }, + { end: [0.28, 1.00, 0.14], dir: [0.48, 0.74, 0.40] }, + { end: [-0.40, 0.80, -0.16], dir: [-0.86, 0.36, -0.28] }, + { end: [0.38, 0.82, 0.16], dir: [0.88, 0.32, 0.26] }, + { end: [-0.04, 1.06, -0.20], dir: [-0.14, 0.95, -0.28] }, +]; + +/** + * 観葉植物. A tapered pot with a rim, dark soil, and leaves that are just + * squashed spheres aimed along each branch's direction - the cheapest way to get + * a soft, rounded leaf that still takes a clean outline. + */ +const buildPlant = prop((group, material) => { + addCylinder(group, material('accent'), 0.40, 0.28, 0.56, [0, 0.28, 0], 14); + addCylinder(group, material('accent', 0.88), 0.44, 0.42, 0.12, [0, 0.56, 0], 14); + addCylinder(group, material('wood', 0.75), 0.37, 0.37, 0.06, [0, 0.61, 0], 14); + + for (const branch of PLANT_BRANCHES) { + addStrut(group, material('leaf', 0.6), [0, 0.56, 0], branch.end, 0.035); + + const direction = new THREE.Vector3(branch.dir[0], branch.dir[1], branch.dir[2]).normalize(); + const leaf = addSphere(group, material('leaf'), 0.30, [0, 0, 0], [8, 6]); + leaf.scale.set(0.46, 1, 0.22); + leaf.position.set(branch.end[0], branch.end[1], branch.end[2]).addScaledVector(direction, 0.26); + leaf.quaternion.setFromUnitVectors(UP, direction); + } +}); + +/** + * 段ボール箱. The body and two lid flaps, plus a tape strip when the lid is + * shut. The flaps are children of pivot groups sitting on the box's top edges, + * so `open: true` is a single rotation each. The flaps are a darker shade of the + * same wood part, which is what makes the box read as cardboard rather than a + * solid block. + */ +const buildBox = prop((group, material, options) => { + const flapsDown = options.open !== true; + addBox(group, material('wood'), [1.24, 1.00, 1.24], [0, 0.50, 0]); + + const back = new THREE.Group(); + back.position.set(0, 1.00, -0.62); + back.rotation.x = flapsDown ? 0 : -1.05; + group.add(back); + addBox(back, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, 0.31]); + + const front = new THREE.Group(); + front.position.set(0, 1.00, 0.62); + front.rotation.x = flapsDown ? 0 : 1.05; + group.add(front); + addBox(front, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, -0.31]); + + if (flapsDown) addBox(group, material('paper', 0.95), [0.18, 0.05, 1.26], [0, 1.095, 0]); +}); + +/** + * 空いた缶詰. A short metal can, opened: a foot bead, the body, a label band and + * a mouth rim, plus the single lid peeled back on a hinge at the back of the + * mouth so it stands up behind the opening. It is deliberately + * small next to the 4-unit-tall character - something to be pointed at rather + * than stood behind - so every size here stays under a unit, and the whole can + * leans a little so it does not read as a diagram. The label is the one coloured + * part; everything else is the neutral `metal` grey, so it still reads as a tin. + */ +const buildCan = prop((group, material) => { + addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.025, 0], 16); + addCylinder(group, material('metal'), 0.145, 0.145, 0.40, [0, 0.20, 0], 16); + addCylinder(group, material('accent', 0.85), 0.148, 0.148, 0.16, [0, 0.21, 0], 16); + addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.40, 0], 16); + // A darker disc just inside the rim so the mouth reads as empty, not capped. + addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16); + + // The one lid, peeled back on a hinge at the back of the mouth so its + // underside shows. It is twisted slightly sideways so it does not read as a + // knob sitting straight up on the can. + const hinge = new THREE.Group(); + hinge.position.set(0, 0.42, -0.135); + hinge.rotation.x = -1.05; + hinge.rotation.z = 0.4; + group.add(hinge); + addCylinder(hinge, material('metal', 0.9), 0.132, 0.132, 0.018, [0, 0.12, 0], 18); + + group.rotation.z = 0.05; +}); + +// --------------------------------------------------------------------------- +// The library +// --------------------------------------------------------------------------- + +/** + * `height` is the approximate height in units, measured from the base, so the UI + * can offer a sensible scale. Where a prop has options, it is the height of the + * default variant: an open cardboard box stands about a third taller again, so + * the offered scale stays sensible either way. + */ +export const PROP_LIBRARY = [ + { id: 'podium', label: '演台', height: 2.2, build: buildPodium }, + { id: 'desk', label: '机', height: 1.5, build: buildDesk }, + { id: 'mic', label: 'マイク', height: 2.2, build: buildMic }, + { id: 'sign', label: '看板', height: 2.7, build: buildSign }, + { id: 'plant', label: '観葉植物', height: 1.7, build: buildPlant }, + { id: 'box', label: '段ボール箱', height: 1.15, build: buildBox }, + { id: 'can', label: '空いた缶詰', height: 0.65, build: buildCan }, +]; + +/** + * Where a freshly added prop goes. The character's body is a sphere of radius + * ~1.6 around x = 0, z = 0, so anything closer than about 2.2 units would push + * into it; each prop therefore sits 2.6-3.0 units out, in the quadrant that + * suits its height, turned only slightly - a prop square to the camera reads as + * a diagram, and one turned all the way shows its side. + */ +export const PROP_DEFAULTS = { + podium: { x: -2.1, y: 0, z: 1.9, rotY: 0.30, scale: 1 }, + desk: { x: -2.6, y: 0, z: 0.5, rotY: 0.40, scale: 1 }, + mic: { x: 2.0, y: 0, z: 1.8, rotY: -0.40, scale: 1 }, + // `faceScale` only means something for a 看板: it grows the writing surface + // alone, so the frame and posts stay put. The panel owns that control. + sign: { x: 2.9, y: 0, z: 0.4, rotY: -0.25, scale: 1, faceScale: 1 }, + plant: { x: -2.0, y: 0, z: -1.8, rotY: 0.25, scale: 1 }, + box: { x: 2.0, y: 0, z: -1.7, rotY: 0.55, scale: 1 }, + can: { x: 1.7, y: 0, z: 1.5, rotY: -0.5, scale: 1 }, +}; + +/** The palette entry each part reads, derived from the app's colours. */ +function derivePalette(colors = {}) { + const body = new THREE.Color(colors.body ?? FALLBACK_COLORS.body); + const accent = new THREE.Color(colors.accent ?? FALLBACK_COLORS.accent); + const leaf = new THREE.Color(colors.leaf ?? FALLBACK_COLORS.leaf); + return { + // Darkened rather than mixed towards black, so the surface keeps a hint of + // the theme's hue instead of turning into a grey. + wood: body.clone().multiplyScalar(0.72), + paper: body.clone().lerp(WHITE, 0.85), + accent, + leaf, + metal: new THREE.Color(METAL_GREY), + }; +} + +/** + * Builds one prop. + * + * @param {string} id one of PROP_LIBRARY's ids + * @param {object} [options] + * @param {object} [options.colors] the app's render.colors palette + * @param {string} [options.outlineColor] kept on the group for the outline pass + * @param {number} [options.scale] uniform scale for the whole prop + * @returns {THREE.Group} the prop, origin at its base centre, facing +z + */ +export function buildProp(id, options = {}) { + const spec = PROP_LIBRARY.find((entry) => entry.id === id); + if (!spec) { + const known = PROP_LIBRARY.map((entry) => entry.id).join(', '); + throw new Error(`unknown prop id ${JSON.stringify(id)}; known props: ${known}`); + } + + const group = spec.build(options); + group.name = `prop:${id}`; + group.userData.propId = id; + group.userData.outlineColor = options.outlineColor ?? null; + if (Number.isFinite(options.scale) && options.scale !== 1) { + group.scale.setScalar(options.scale); + } + return applyPropColors(group, options.colors); +} + +/** + * Recolours a built prop in place, for the colour-theme feature. Every mesh + * material this module creates carries `userData.part`, so the walk only has to + * look at those; anything else (a future prop built by hand) is left alone. + */ +export function applyPropColors(group, colors = {}) { + const palette = derivePalette(colors); + const seen = new Set(); + + group.traverse((object) => { + const list = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of list) { + if (!material || seen.has(material)) continue; + seen.add(material); + const part = material.userData?.part; + if (!part) continue; + const shade = Number.isFinite(material.userData.shade) ? material.userData.shade : 1; + material.color.copy(palette[part] ?? palette.wood).multiplyScalar(shade); + material.color.r = clamp01(material.color.r); + material.color.g = clamp01(material.color.g); + material.color.b = clamp01(material.color.b); + } + }); + + return group; +} + +/** + * The writing surface of a prop: the mesh tagged `userData.canvasTexture`, i.e. + * the 看板's face. A prop without one returns `null`, so callers can treat every + * prop the same. + */ +export function propFace(group) { + let face = null; + group?.traverse?.((object) => { + if (!face && object.userData?.canvasTexture) face = object; + }); + return face; +} + +/** + * Scale the writing surface about its own centre, so only the 看板's face grows + * and its frame and posts stay where they are. `addBox` puts the mesh's origin at + * the box centre, which is what makes this a scale about the centre rather than + * about the prop's base. + * + * WHY this is applied from the panel: the app rebuilds every prop from the state + * (`applyProps` in src/main.js) and hands `buildProp` only the whole-prop scale, + * so a per-item `faceScale` has to be re-applied to the fresh mesh afterwards. + */ +export function applyPropFaceScale(group, faceScale) { + const face = propFace(group); + if (!face) return; + const value = Number(faceScale); + const scale = Number.isFinite(value) ? Math.min(4, Math.max(0.2, value)) : 1; + face.scale.setScalar(scale); +} + +/** The world-space box the prop occupies; the app drops its contact shadow from it. */ +export function propBounds(group) { + group.updateMatrixWorld(true); + return new THREE.Box3().setFromObject(group); +} + +/** Releases every geometry and material in the prop and empties the group. */ +export function disposeProp(group) { + const geometries = new Set(); + const materials = new Set(); + + group.traverse((object) => { + if (object.geometry) geometries.add(object.geometry); + const list = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of list) if (material) materials.add(material); + }); + + for (const geometry of geometries) geometry.dispose(); + for (const material of materials) material.dispose(); + group.clear(); +} diff --git a/public/bluebey-studio/src/rig.js b/public/bluebey-studio/src/rig.js new file mode 100644 index 0000000..2922dfa --- /dev/null +++ b/public/bluebey-studio/src/rig.js @@ -0,0 +1,196 @@ +import * as THREE from 'three'; +import { TransformControls } from 'three/addons/controls/TransformControls.js'; + +/** + * Posing. Each bone is driven as a rotation *relative to its rest pose*, so + * every bone reads 0/0/0 in the original stance and a saved pose stays valid + * even if the model is re-exported with different bone orientations. + * + * `bone.quaternion = rest * delta` and `delta = rest⁻¹ * quaternion`, with the + * delta expressed as XYZ Euler angles in degrees for the UI. + */ + +const RAD = Math.PI / 180; +const DEG = 180 / Math.PI; + +/** three sanitises node names, so `armsupport.l` arrives as `armsupportl`. */ +const normalizeName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase(); + +export class Rig { + constructor({ bones, scene, camera, domElement, orbit, pickTargets, onChange = null }) { + this.bones = bones; + this.byName = new Map(); + for (const entry of bones) { + this.byName.set(entry.name, entry); + const alias = normalizeName(entry.name); + if (!this.byName.has(alias)) this.byName.set(alias, entry); + } + this.scene = scene; + this.domElement = domElement; + this.orbit = orbit; + this.pickTargets = pickTargets; + this.onChange = onChange; + this.selected = null; + + this.controls = new TransformControls(camera, domElement); + this.controls.setMode('rotate'); + this.controls.setSpace('local'); + this.controls.setSize(0.8); + this.helper = this.controls.getHelper(); + this.helper.visible = false; + // Whether the user wants the gizmo at all. It starts OFF: the model is the + // point, and the rings sat on top of it until you went looking for them. + // Selecting a bone must not switch it back on by itself, so `select()` + // consults this instead of always showing the helper. + this.gizmoWanted = false; + scene.add(this.helper); + + this.controls.addEventListener('dragging-changed', (event) => { + this.orbit.enabled = !event.value; + }); + this.controls.addEventListener('objectChange', () => { + if (this.selected) this.onChange?.(this.selected); + }); + + this.raycaster = new THREE.Raycaster(); + this.pointer = new THREE.Vector2(); + this.pressAt = null; + this.onPointerDown = this.handlePointerDown.bind(this); + this.onPointerUp = this.handlePointerUp.bind(this); + domElement.addEventListener('pointerdown', this.onPointerDown); + domElement.addEventListener('pointerup', this.onPointerUp); + } + + /* ------------------------------------------------------------ selection */ + + select(name, { silent = false } = {}) { + if (name && !this.byName.has(name)) return; + this.selected = name ?? null; + if (name) { + this.controls.attach(this.byName.get(name).bone); + this.helper.visible = this.gizmoWanted; + } else { + this.controls.detach(); + this.helper.visible = false; + } + if (!silent) this.onChange?.(this.selected); + } + + selectAt(event) { + const rect = this.domElement.getBoundingClientRect(); + this.pointer.set( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + ); + this.raycaster.setFromCamera(this.pointer, this.controls.camera); + + const targets = this.pickTargets().filter((mesh) => mesh.visible); + const hits = this.raycaster.intersectObjects(targets, false); + if (!hits.length) return null; + + const point = hits[0].point; + let best = null; + let bestDistance = Infinity; + const world = new THREE.Vector3(); + for (const entry of this.bones) { + entry.bone.getWorldPosition(world); + const distance = world.distanceToSquared(point); + if (distance < bestDistance) { + bestDistance = distance; + best = entry.name; + } + } + if (best) this.select(best); + return best; + } + + handlePointerDown(event) { + // Remember where the press started so a drag never changes the selection. + this.pressAt = { x: event.clientX, y: event.clientY, axis: this.controls.axis }; + } + + handlePointerUp(event) { + const press = this.pressAt; + this.pressAt = null; + if (!press || press.axis) return; // pressed on the gizmo, not the model + if (Math.hypot(event.clientX - press.x, event.clientY - press.y) > 4) return; + this.selectAt(event); + } + + /* ----------------------------------------------------------------- pose */ + + getDelta(name) { + const entry = this.byName.get(name); + if (!entry) return { x: 0, y: 0, z: 0 }; + const delta = entry.rest.clone().invert().multiply(entry.bone.quaternion); + const euler = new THREE.Euler().setFromQuaternion(delta, 'XYZ'); + return { x: euler.x * DEG, y: euler.y * DEG, z: euler.z * DEG }; + } + + setDelta(name, { x = 0, y = 0, z = 0 }) { + const entry = this.byName.get(name); + if (!entry) return; + const delta = new THREE.Quaternion().setFromEuler( + new THREE.Euler(x * RAD, y * RAD, z * RAD, 'XYZ'), + ); + entry.bone.quaternion.copy(entry.rest).multiply(delta); + } + + /** `pose.bones` maps a bone name to `[x, y, z]` degrees; `pose.root` is a translation. */ + applyPose(pose = {}) { + // Presets and saved files use the glTF bone names (`armsupport.l`) while + // three sanitises them to `armsupportl`, so compare on the normalised form - + // otherwise every preset that touches an arm or leg is silently ignored. + const rotations = new Map(); + for (const [key, value] of Object.entries(pose.bones ?? {})) { + rotations.set(normalizeName(key), value); + } + for (const entry of this.bones) { + const value = rotations.get(normalizeName(entry.name)); + if (Array.isArray(value)) this.setDelta(entry.name, { x: value[0], y: value[1], z: value[2] }); + else this.setDelta(entry.name, { x: 0, y: 0, z: 0 }); + } + } + + getPose({ onlyMoved = true } = {}) { + const rotations = {}; + for (const entry of this.bones) { + const { x, y, z } = this.getDelta(entry.name); + const rounded = [round1(x), round1(y), round1(z)]; + if (onlyMoved && rounded.every((value) => value === 0)) continue; + rotations[entry.name] = rounded; + } + return { bones: rotations }; + } + + reset(name) { + if (name) { + this.setDelta(name, { x: 0, y: 0, z: 0 }); + return; + } + for (const entry of this.bones) this.setDelta(entry.name, { x: 0, y: 0, z: 0 }); + } + + /** True when every bone sits at its rest rotation. */ + isRestPose() { + return this.bones.every((entry) => { + const { x, y, z } = this.getDelta(entry.name); + return Math.abs(x) < 0.01 && Math.abs(y) < 0.01 && Math.abs(z) < 0.01; + }); + } + + setGizmoVisible(visible) { + this.gizmoWanted = visible !== false; + this.helper.visible = this.gizmoWanted && this.selected != null; + } + + dispose() { + this.domElement.removeEventListener('pointerdown', this.onPointerDown); + this.domElement.removeEventListener('pointerup', this.onPointerUp); + this.controls.detach(); + this.controls.dispose(); + this.helper.removeFromParent(); + } +} + +const round1 = (value) => Math.round(value * 10) / 10; diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css new file mode 100644 index 0000000..2ca7148 --- /dev/null +++ b/public/bluebey-studio/src/style.css @@ -0,0 +1,673 @@ +:root { + --accent: #7b53d1; + --accent-soft: #efe8fd; + --accent-2: #b498ff; + --text: #2b2433; + --muted: #6f6682; + --border: #e4dff0; + --card: rgba(255, 255, 255, 0.94); + --radius: 12px; + --panel-width: 344px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + overflow: hidden; +} + +body { + font-family: system-ui, -apple-system, "Segoe UI", "Hiragino Kaku Gothic ProN", "Noto Sans JP", Meiryo, sans-serif; + color: var(--text); + background: radial-gradient(120% 120% at 30% 0%, #f7f5fc 0%, #ece8f4 60%, #e4e0ee 100%); + -webkit-font-smoothing: antialiased; +} + +/* ------------------------------------------------------------------ viewport */ + +/* + * The canvas gets its size from #stage, never from its own width/height + * attributes: a canvas is a replaced element, so `width: auto` would make its + * layout follow those attributes - and since the renderer writes them, that + * becomes a resize feedback loop that leaves the picture blank. + */ +#stage { + position: fixed; + inset: 0; +} + +body:not(.panel-hidden) #stage { + right: var(--panel-width); +} + +#view { + display: block; + width: 100%; + height: 100%; + touch-action: none; + outline: none; +} + +/* ------------------------------------------------------------------- topbar */ + +#topbar { + position: fixed; + top: 14px; + left: 16px; + z-index: 20; + display: flex; + align-items: center; + gap: 14px; + max-width: calc(100vw - var(--panel-width) - 48px); +} + +#topbar h1 { + margin: 0; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.02em; + color: #4b3a72; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); +} + +.topbar-actions { display: flex; flex-wrap: wrap; gap: 6px; } + +#topbar button { + font: inherit; + font-size: 12px; + padding: 6px 11px; + border-radius: 999px; + border: 1px solid rgba(123, 83, 209, 0.22); + background: rgba(255, 255, 255, 0.86); + color: #5a4a7d; + cursor: pointer; + backdrop-filter: blur(6px); + transition: background 0.15s, transform 0.1s; +} + +#topbar button:hover { background: #fff; transform: translateY(-1px); } +#topbar button:active { transform: translateY(0); } +#topbar button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); } + +#viewport-hint { + position: fixed; + left: 18px; + bottom: 14px; + z-index: 10; + margin: 0; + font-size: 11.5px; + color: #8a80a0; + pointer-events: none; +} + +/* -------------------------------------------------------------------- panel */ + +#panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: var(--panel-width); + padding: 14px 14px 40px; + overflow-y: auto; + overflow-x: hidden; + background: var(--card); + border-left: 1px solid var(--border); + backdrop-filter: blur(10px); + z-index: 30; + scrollbar-width: thin; +} + +#panel::-webkit-scrollbar { width: 9px; } +#panel::-webkit-scrollbar-thumb { background: #d8d1e8; border-radius: 9px; } + +body.panel-hidden #panel { display: none; } + +body.panel-hidden #topbar { max-width: calc(100vw - 48px); } + +@media (max-width: 780px) { + :root { --panel-width: min(88vw, 344px); } + #topbar { max-width: calc(100vw - 60px); } +} + +/* ----------------------------------------------------------------- sections */ + +.tabs { + position: sticky; + top: 0; + z-index: 3; + display: flex; + gap: 4px; + padding: 2px 0 10px; + margin: -2px 0 4px; + background: linear-gradient(var(--card) 72%, rgba(255, 255, 255, 0)); +} + +.tab { + flex: 1; + font: inherit; + font-size: 12px; + padding: 8px 2px; + border: 1px solid var(--border); + border-radius: 9px; + background: #fff; + color: var(--muted); + cursor: pointer; + white-space: nowrap; +} + +.tab:hover { border-color: var(--accent-2); } +.tab.active { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } +.tab-panel[hidden] { display: none; } + +.details { + border: 1px dashed var(--border); + border-radius: 9px; + padding: 0 9px; + background: #fdfcff; +} +.details > summary { + font-size: 11px; + color: var(--muted); + cursor: pointer; + padding: 6px 0; + list-style-position: inside; +} +.details[open] > summary { color: var(--accent); } +.details-body { display: grid; gap: 8px; padding: 2px 0 9px; } + +.sec { + background: #fff; + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 10px; + overflow: hidden; + box-shadow: 0 1px 2px rgba(53, 38, 90, 0.04); +} + +.sec-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 10px 12px; + margin: 0; + border: 0; + background: #fff; + font: inherit; + font-size: 12.5px; + font-weight: 700; + color: #4b3a72; + text-align: left; + cursor: pointer; +} + +.sec-head .chev { color: var(--muted); font-size: 10px; transition: transform 0.18s; } +.sec.closed .chev { transform: rotate(-90deg); } +.sec-body { padding: 2px 12px 12px; display: grid; gap: 9px; } +.sec.closed .sec-body { display: none; } + +/* -------------------------------------------------------------------- rows */ + +.row { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 8px; align-items: center; } +.row.wide { grid-template-columns: 1fr; gap: 5px; } +.row > .label { font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.control { display: flex; align-items: center; gap: 7px; min-width: 0; } +.hint { font-size: 11px; line-height: 1.55; color: var(--muted); } +.hint b { color: #5a4a7d; } +.subhead { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.06em; + color: #8d80ab; + text-transform: uppercase; + margin-top: 3px; +} +.select { + flex: 1; + min-width: 0; + font: inherit; + font-size: 11.5px; + padding: 5px 6px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: var(--text); +} +.stack { display: grid; gap: 6px; } +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; } + +input[type="range"] { + flex: 1; + min-width: 0; + height: 18px; + accent-color: var(--accent); + cursor: pointer; +} + +.num { + flex: 0 0 auto; + min-width: 40px; + font-size: 11px; + color: #5a4a7d; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.seg { display: inline-flex; flex-wrap: wrap; padding: 2px; gap: 2px; background: #f2eff9; border-radius: 999px; } +.seg button { + font: inherit; + font-size: 11px; + padding: 4px 9px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); + cursor: pointer; + white-space: nowrap; +} +.seg button.active { background: #fff; color: var(--accent); box-shadow: 0 1px 3px rgba(53, 38, 90, 0.16); } + +.btn { + font: inherit; + font-size: 11.5px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: var(--text); + cursor: pointer; + white-space: nowrap; +} +.btn:hover { border-color: var(--accent-2); } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn.primary:hover { background: #6b45c2; } +.btn:disabled { opacity: 0.45; cursor: default; } +.buttons { display: flex; flex-wrap: wrap; gap: 6px; } + +.chk { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: #4b3a72; cursor: pointer; } +.chk input { accent-color: var(--accent); width: 14px; height: 14px; } + +.pad-wrap { display: flex; gap: 10px; align-items: center; } +.pad { + position: relative; + flex: 0 0 auto; + width: 118px; + height: 118px; + border-radius: 14px; + border: 1px solid var(--border); + background: + linear-gradient(#f0ecfa, #f0ecfa) 50% 0 / 1px 100% no-repeat, + linear-gradient(#f0ecfa, #f0ecfa) 0 50% / 100% 1px no-repeat, + #faf8ff; + cursor: crosshair; + touch-action: none; +} +.pad-dot { + position: absolute; + width: 13px; + height: 13px; + margin: -7px 0 0 -7px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 1px 4px rgba(53, 38, 90, 0.35); + pointer-events: none; +} +.pad-eye { + position: absolute; + inset: 0; + margin: auto; + width: 46px; + height: 46px; + border-radius: 50%; + border: 1px dashed #d9d1ec; + pointer-events: none; +} + +/* Tail picker: a 3x3 grid where a dot's place is the direction it means, so + the whole pad fits next to an 82px label even in a narrow panel. */ +.tail-pad { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + place-items: center; + flex: 0 0 auto; + width: 128px; + height: 128px; + padding: 10px; + border-radius: 14px; + border: 1px solid var(--border); + background: #faf8ff; +} +.tail-dot { + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 50%; + background: #ded6f0; + cursor: pointer; + transition: background 0.12s, transform 0.1s; +} +.tail-dot:hover { background: var(--accent-2); transform: scale(1.1); } +.tail-dot.active { + background: var(--accent); + box-shadow: 0 1px 4px rgba(53, 38, 90, 0.35); +} + +.swatches { display: flex; gap: 5px; flex-wrap: wrap; } +.swatch { width: 17px; height: 17px; border-radius: 5px; border: 1px solid rgba(43, 36, 51, 0.18); cursor: pointer; } +input[type="color"] { + width: 30px; + height: 22px; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: #fff; + cursor: pointer; +} + +.bone-list { + max-height: 168px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 9px; + padding: 4px; + display: grid; + gap: 2px; + background: #fcfbff; +} +.bone-item { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 4px 8px; + border: 0; + border-radius: 6px; + background: transparent; + font: inherit; + font-size: 11.5px; + color: var(--text); + text-align: left; + cursor: pointer; +} +.bone-item:hover { background: #f4f0fd; } +.bone-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } +.bone-item small { color: var(--muted); font-size: 10.5px; } + +/* --------------------------------------------------------------------- 擬音 */ + +/* + * The stamps are painted onto the caption canvas, which is `pointer-events: + * none`, so each one gets its own transparent drag box on top - the same trick + * the bubbles use. The box follows the stamp's rotation. Its z-index sits below + * the caption handles (3), because the bubbles are drawn over the stamps and so + * must stay grabbable where the two overlap. + */ +.gion-handle { + position: absolute; + display: none; + cursor: move; + touch-action: none; + pointer-events: auto; + z-index: 2; + border: 1px dashed transparent; + border-radius: 4px; +} +.gion-handle.selected { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(123, 83, 209, 0.18); +} + +.gion-list { display: grid; gap: 10px; } +.gion-item-head { display: flex; align-items: center; gap: 8px; } +.gion-thumb { + flex: 0 0 auto; + width: 84px; + height: 56px; + background: #f3f0fa; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; +} +.gion-item.active .gion-thumb { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } + +/* The picker covers the whole window, so even a big sheet has room to draw on. */ +.gion-picker { + position: fixed; + inset: 0; + z-index: 95; + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + background: rgba(24, 16, 44, 0.86); + backdrop-filter: blur(4px); +} +.gion-picker-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; + color: #fff; +} +.gion-picker-title { font-size: 13.5px; font-weight: 700; } +.gion-picker-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.gion-picker-actions .hint { color: #cfc6e6; } +.gion-picker-board { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + border-radius: 12px; + /* A checkerboard reads as "transparent", so a PNG sheet's holes are obvious. */ + background: + linear-gradient(45deg, #2b2344 25%, transparent 25%) 0 0 / 22px 22px, + linear-gradient(-45deg, #2b2344 25%, transparent 25%) 0 11px / 22px 22px, + linear-gradient(45deg, transparent 75%, #2b2344 75%) 11px -11px / 22px 22px, + linear-gradient(-45deg, transparent 75%, #2b2344 75%) -11px 0 / 22px 22px, + #241d3a; + cursor: crosshair; + touch-action: none; +} +.gion-picker-sheet { + position: absolute; + display: block; + user-select: none; + -webkit-user-drag: none; + pointer-events: none; +} +.gion-picker-marquee { + position: absolute; + display: none; + border: 1.5px solid #fff; + background: rgba(180, 152, 255, 0.28); + box-shadow: 0 0 0 9999px rgba(20, 12, 40, 0.42); + pointer-events: none; +} + +/* -------------------------------------------------------------------- toast */ + +#notice { + position: fixed; + left: 50%; + top: 14px; + transform: translateX(-50%); + z-index: 95; + max-width: min(560px, 90vw); + padding: 10px 16px; + border-radius: 12px; + background: #fff4e5; + border: 1px solid #f0c48a; + color: #7a4a12; + font-size: 12px; + line-height: 1.65; + white-space: pre-line; + box-shadow: 0 6px 20px rgba(43, 36, 51, 0.15); +} + +#notice[hidden] { display: none; } + +/* ------------------------------------------------ 起動時のご利用について */ +#start-notice { + position: fixed; + inset: 0; + z-index: 96; + display: grid; + place-items: center; + padding: 24px; + background: rgba(43, 36, 51, 0.5); + backdrop-filter: blur(3px); +} +#start-notice[hidden] { display: none; } + +.start-sheet { + width: min(560px, 100%); + max-height: 86vh; + overflow: auto; + padding: 22px 26px; + border-radius: 16px; + background: #fff; + color: #2b2433; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35); + font-size: 13.5px; + line-height: 1.75; +} +.start-sheet h2 { margin: 0 0 12px; font-size: 17px; } +.start-sheet p { margin: 0 0 10px; } +.start-sheet ul { margin: 0 0 12px; padding-left: 1.3em; } +.start-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-top: 16px; +} +.start-skip { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #6b5d7a; } +.start-ok { + border: 0; + border-radius: 8px; + padding: 9px 22px; + background: #6b4ea8; + color: #fff; + font-size: 13.5px; + cursor: pointer; +} + +#toast { + position: fixed; + left: 50%; + bottom: 26px; + transform: translate(-50%, 14px); + z-index: 70; + padding: 8px 16px; + border-radius: 999px; + background: rgba(43, 36, 51, 0.9); + color: #fff; + font-size: 12px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; +} +#toast.show { opacity: 1; transform: translate(-50%, 0); } + +/* ------------------------------------------------------------------ loading */ + +#loading { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + background: rgba(244, 242, 250, 0.94); + transition: opacity 0.35s; +} +#loading.done { opacity: 0; pointer-events: none; } + +.loading-box { text-align: center; max-width: 520px; padding: 24px; } +.loading-spinner { + width: 34px; + height: 34px; + margin: 0 auto 14px; + border-radius: 50%; + border: 3px solid #e0d8f2; + border-top-color: var(--accent); + animation: spin 0.9s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +#loading-text { margin: 0; font-size: 13px; color: #5a4a7d; } +#loading-error { + margin: 12px 0 0; + font-size: 12px; + line-height: 1.7; + color: #b3261e; + white-space: pre-wrap; + text-align: left; +} + +/* --------------------------------------------------------------------- help */ + +#help { + position: fixed; + inset: 0; + z-index: 90; + display: grid; + place-items: center; + padding: 24px; + background: rgba(43, 36, 51, 0.42); + backdrop-filter: blur(3px); +} +#help[hidden] { display: none; } + +.help-sheet { + position: relative; + width: min(760px, 100%); + max-height: min(84vh, 900px); + overflow-y: auto; + padding: 26px 30px 32px; + background: #fff; + border-radius: 18px; + box-shadow: 0 24px 60px rgba(20, 12, 40, 0.3); + font-size: 13px; + line-height: 1.75; +} +.help-sheet h2 { margin: 0 0 14px; font-size: 17px; color: #4b3a72; } +.help-sheet h3 { margin: 20px 0 6px; font-size: 13.5px; color: var(--accent); } +.help-sheet ul { margin: 0; padding-left: 1.25em; } +.help-sheet li { margin: 3px 0; } +.help-sheet code { background: #f3f0fa; padding: 1px 5px; border-radius: 5px; font-size: 12px; } +.help-sheet .keys { list-style: none; padding: 0; display: grid; gap: 4px; } +.help-close { + position: absolute; + top: 12px; + right: 14px; + width: 32px; + height: 32px; + border: 0; + border-radius: 50%; + background: #f3f0fa; + color: #5a4a7d; + font-size: 19px; + line-height: 1; + cursor: pointer; +} +.help-close:hover { background: #e8e2f7; } + +kbd { + display: inline-block; + min-width: 20px; + padding: 1px 6px; + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 5px; + background: #faf9fe; + font: inherit; + font-size: 11px; + text-align: center; +} diff --git a/public/bluebey-studio/src/styles.js b/public/bluebey-studio/src/styles.js new file mode 100644 index 0000000..d7bd7b9 --- /dev/null +++ b/public/bluebey-studio/src/styles.js @@ -0,0 +1,350 @@ +import * as THREE from 'three'; + +/** + * Render styles for the body meshes, plus the outline pass. + * + * real the PBR materials exactly as authored in the GLB + * flat two-tone toon shading, the classic mascot look + * lineart white paper + lines, the face artwork drawn as strokes + * outline nothing but the lines, kept invisible via colorWrite = false so the + * result composites on top of anything (used by the SVG export) + * + * The lines come from two systems, each used where it is strong (see + * MODEL-GUIDE.md §5): every mesh gets an inverted-hull copy, and the thin + * overlapping leaves are handed to the screen-space pass instead (outline.js), + * which the caller arranges with `setHullHidden`. + */ + +export const STYLE_DEFS = [ + { value: 'real', label: 'リアル' }, + { value: 'flat', label: 'フラット' }, + { value: 'lineart', label: '線画' }, +]; + +// 'outline' is still used internally by the SVG export (lines on a transparent +// background), it is just no longer offered as a viewing style. +const LINE_STYLES = new Set(['lineart', 'outline']); + +export const isLineStyle = (value) => LINE_STYLES.has(value); + +/** Parts that need no outline at all. */ +const OUTLINE_SKIP = new Set(); + +/** + * Parts whose outline is only useful in the line-art styles. + * + * Empty now: the leaves used to be listed here, but the line styles get their + * leaf lines from the `Vein` faces themselves (see OUTLINE_SHADED_ONLY), so the + * hull is the wrong tool for them in either direction. + */ +const OUTLINE_LINE_ONLY = new Set(); + +/** + * Parts that should be outlined in the SHADED styles only. + * + * The inverted hull draws an outline by expanding a copy of the mesh along its + * normals and rendering the back faces. That works on one big round body, but + * the leaf skirt is a dozen thin shells lying on top of each other: each hull's + * far side shows through its neighbours, which is what produced the faint + * overlapping hairlines. In the SVG pass the leaves get their line from their + * own `Vein` faces (filled with the ink colour) instead, so the hull is dropped + * there and kept for the cartoon styles, where it does read as an edge. + */ +const OUTLINE_SHADED_ONLY = new Set(['vein']); + +/** + * Per-part outline thickness, as a fraction of the global width. + * + * The hull is offset by a fixed distance in world units, so the right factor + * depends on how fine a part's mesh is compared with that offset: + * + * - the nose is only ~0.24 units across, and the offset that looks right on the + * body reads as a thick ring on it, hence the small factor. In the line-art + * styles it is the other way round (a hairline looks like dust on paper), so + * the nose gets a fuller line there - see OUTLINE_SCALE_LINE. + * - the leaves need a *larger* factor, which only shows up in the "hull only" + * style: the 13 blades overlap and interpenetrate, so each one's expanded + * shell cuts across its neighbours and the line breaks up. A thicker hull + * merges those scratches back into a band. Making the leaves thicker does NOT + * fix it - measured, see MODEL-GUIDE.md §5-4 - because the overlaps are the + * blocker, not the leaf's own thickness. + */ +const OUTLINE_SCALE = { nose: 0.4, leaf: 1.7, vein: 2.2 }; + +/** Fuller lines for the paper-and-ink styles, where a hairline reads as dust. */ +const OUTLINE_SCALE_LINE = { nose: 0.9, leaf: 1.9, vein: 2.4 }; + +/** + * Parts whose outline should only survive where the part itself sticks out past + * the body. Their hull is drawn first with depth testing off, so everything the + * body covers paints over it: the nose then only gets a line in the views where + * it actually pokes out of the silhouette, instead of a ring around it - which is + * what the original artwork does, the nose reads by its own colour there. + * + * In the line-art styles there is no colour to read it by (the nose is paper on + * paper), so this is switched off and the nose goes to the screen-space pass + * instead - see `screenParts` in main.js. + */ +const OUTLINE_SILHOUETTE_ONLY = new Set(['nose']); + +export class Styles { + constructor({ meshes, paper = '#ffffff', outlineColor = '#2a1e33', outlineWidth = 0.022 }) { + this.meshes = meshes; + this.paper = paper; + this.style = 'real'; + this.outlineEnabled = true; + /** + * Source meshes whose hull outline must stay off, because another method + * (the screen-space pass) is drawing them. Empty = every hull is available. + * Only the leaves ever land here: see MODEL-GUIDE.md §5. + */ + this.hullHidden = new Set(); + + this.originals = new Map(); + for (const mesh of meshes) { + this.originals.set(mesh, mesh.material); + mesh.castShadow = true; + mesh.receiveShadow = false; + } + + this.gradientMap = makeGradientMap(); + this.toonMaterials = new Map(); + this.paperMaterial = new THREE.MeshBasicMaterial({ color: paper, toneMapped: false }); + this.invisibleMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true }); + + this.outlineWidth = outlineWidth; + this.outlineColor = outlineColor; + /** scale -> { material, uniform }, so the width slider updates all of them. */ + this.hullMaterials = new Map(); + this.outlineMeshes = this.createOutlines(); + this.setStyle('real'); + } + + /** + * The part kind, taken from the material the GLB shipped with. + * + * It must be read from `originals`, not from `mesh.material`: `setStyle` + * replaces the materials, and the replacements (paper, ink, toon) carry no + * name, so after the first swap every part would look like an unknown one and + * the leaf/nose special cases would silently stop applying. + */ + kindOf(mesh) { + return (this.originals.get(mesh)?.name ?? '').toLowerCase(); + } + + hullMaterialFor(scale, overlay = false) { + const key = `${scale}:${overlay ? 'overlay' : 'solid'}`; + let entry = this.hullMaterials.get(key); + if (!entry) { + const uniform = { value: this.outlineWidth * scale }; + const material = makeHullMaterial(this.outlineColor, uniform); + if (overlay) { + // Depth-*tested*, but writing none: the hull is drawn before the body + // (renderOrder -1), so the body still paints over its interior and the + // nose only gets a line where it pokes out of the silhouette. Testing + // (rather than ignoring) depth is what lets the 見えない壁 hide it too - + // with the test off, a nose buried in the wall left a filled blob, + // because nothing was left to paint over the hull's inside. + material.depthTest = true; + material.depthWrite = false; + } + entry = { uniform, material, scale }; + this.hullMaterials.set(key, entry); + } + return entry.material; + } + + createOutlines() { + const hulls = []; + for (const mesh of this.meshes) { + const kind = this.kindOf(mesh); + if (OUTLINE_SKIP.has(kind)) continue; + const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind); + const material = this.hullMaterialFor(this.scaleFor(kind), overlay); + const hull = mesh.isSkinnedMesh + ? new THREE.SkinnedMesh(mesh.geometry, material) + : new THREE.Mesh(mesh.geometry, material); + hull.name = `${mesh.name || 'mesh'}:outline`; + hull.position.copy(mesh.position); + hull.quaternion.copy(mesh.quaternion); + hull.scale.copy(mesh.scale); + hull.frustumCulled = false; + hull.castShadow = false; + hull.receiveShadow = false; + if (overlay) hull.renderOrder = -1; + hull.userData.kind = kind; + hull.userData.source = mesh; + hull.userData.lineOnly = OUTLINE_LINE_ONLY.has(kind); + hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind); + if (hull.isSkinnedMesh) hull.bind(mesh.skeleton, mesh.bindMatrix); + mesh.parent.add(hull); + hulls.push(hull); + } + return hulls; + } + + /** The outline factor for a part in the current style (line art wants more). */ + scaleFor(kind) { + const line = LINE_STYLES.has(this.style); + const table = line ? OUTLINE_SCALE_LINE : OUTLINE_SCALE; + return table[kind] ?? OUTLINE_SCALE[kind] ?? 1; + } + + setStyle(value) { + this.style = value; + const line = LINE_STYLES.has(value); + for (const mesh of this.meshes) { + const original = this.originals.get(mesh); + let material; + if (line) { + // Paper on paper in `lineart`; invisible in `outline`, which is the pass + // the SVG trace reads. Either way every edge - the body, the nose, and + // the leaves - is drawn by the screen-space outline, so no part needs a + // material trick of its own any more. + material = value === 'lineart' ? this.paperMaterial : this.invisibleMaterial; + } else if (value === 'flat') { + material = this.toonFor(original); + } else { + material = original; + } + if (mesh.material !== material) mesh.material = material; + } + + // The nose only keeps a line where it pokes out of the body in the shaded + // styles; in a line drawing it is handed to the screen-space pass instead + // (see `screenParts` in main.js), because a hull cannot ring a bump that sits + // flush on the surface it is drawn on - the expanded shell lands *inside* the + // body and loses the depth test. + for (const hull of this.outlineMeshes) { + const kind = hull.userData.kind; + const silhouetteOnly = OUTLINE_SILHOUETTE_ONLY.has(kind) && !line; + hull.material = this.hullMaterialFor(this.scaleFor(kind), silhouetteOnly); + hull.renderOrder = silhouetteOnly ? -1 : 0; + } + + // Lines are the whole point of the line-art styles. + this.setOutlineVisible(line ? true : this.outlineEnabled); + } + + /** + * Hand a set of parts to the screen-space outline, or take them back. + * + * The hull and the screen-space pass each have a shape they cannot draw: a hull + * cannot outline a thin closed shell (the leaves), and the screen-space pass + * draws a stepped line because it works on the pixel grid. So the leaves go to + * the screen-space pass and everything else keeps its hull, which is drawn from + * the geometry and therefore comes out smooth. + */ + setHullHidden(meshes) { + this.hullHidden = new Set(meshes ?? []); + this.setStyle(this.style); + } + + toonFor(original) { + let material = this.toonMaterials.get(original); + if (!material) { + material = new THREE.MeshToonMaterial({ + color: original.color ? original.color.clone() : new THREE.Color(0xffffff), + map: original.map ?? null, + vertexColors: original.vertexColors === true, + gradientMap: this.gradientMap, + side: original.side, + transparent: original.transparent === true, + alphaTest: original.alphaTest ?? 0, + depthWrite: original.depthWrite !== false, + }); + this.toonMaterials.set(original, material); + } + return material; + } + + setPaper(color) { + this.paper = color; + this.paperMaterial.color.set(color); + } + + setOutlineVisible(visible) { + const lineMode = LINE_STYLES.has(this.style); + for (const hull of this.outlineMeshes) { + const lineOnly = hull.userData.lineOnly === true; + const shadedOnly = hull.userData.shadedOnly === true; + hull.visible = visible + && (!lineOnly || lineMode) + && !(shadedOnly && lineMode) + && !this.hullHidden.has(hull.userData.source); + } + } + + setOutlineEnabled(enabled) { + this.outlineEnabled = enabled; + if (!LINE_STYLES.has(this.style)) this.setOutlineVisible(enabled); + } + + setOutlineWidth(width) { + this.outlineWidth = width; + for (const entry of this.hullMaterials.values()) entry.uniform.value = width * entry.scale; + } + + setOutlineColor(color) { + this.outlineColor = color; + for (const entry of this.hullMaterials.values()) entry.material.color.set(color); + } + + /** + * The toon materials are cached copies of the originals, so a colour theme that + * edits `original.color` has to be copied across or the flat style keeps + * showing the old colour. See src/look.js. + */ + refreshColors() { + for (const [mesh, original] of this.originals) { + const toon = this.toonMaterials.get(original); + if (toon && original.color) toon.color.copy(original.color); + // The outline hull is a colour of its own, so it is left alone. + void mesh; + } + return this; + } + + dispose() { + for (const hull of this.outlineMeshes) { + hull.removeFromParent(); + hull.skeleton = null; + } + this.outlineMeshes = []; + for (const entry of this.hullMaterials.values()) entry.material.dispose(); + this.hullMaterials.clear(); + this.paperMaterial.dispose(); + this.invisibleMaterial.dispose(); + this.gradientMap.dispose(); + for (const material of this.toonMaterials.values()) material.dispose(); + this.toonMaterials.clear(); + } +} + +/** A 3-step ramp gives crisper cartoon bands than the shader's default. */ +function makeGradientMap() { + const steps = new Uint8Array([90, 165, 255]); + const texture = new THREE.DataTexture(steps, steps.length, 1, THREE.RedFormat); + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + texture.generateMipmaps = false; + texture.needsUpdate = true; + return texture; +} + +function makeHullMaterial(color, uniform) { + const material = new THREE.MeshBasicMaterial({ + color, + side: THREE.BackSide, + toneMapped: false, + }); + material.onBeforeCompile = (shader) => { + shader.uniforms.uOutline = uniform; + shader.vertexShader = `uniform float uOutline;\n${shader.vertexShader}`.replace( + '#include <begin_vertex>', + '#include <begin_vertex>\n\ttransformed += normal * uOutline;', + ); + }; + material.customProgramCacheKey = () => 'bluebey-outline-hull'; + return material; +} diff --git a/public/bluebey-studio/src/textOutlines.js b/public/bluebey-studio/src/textOutlines.js new file mode 100644 index 0000000..334a92d --- /dev/null +++ b/public/bluebey-studio/src/textOutlines.js @@ -0,0 +1,432 @@ +import opentype from 'opentype.js'; + +/** + * Caption text as outlines. + * + * A caption drawn with <text> changes shape in every viewer, because the glyphs + * come from whatever font that viewer happens to have. The studio ships one + * subset font (M PLUS Rounded 1c) and this module turns the caption into plain + * SVG paths instead, so an exported SVG looks the same everywhere and stays + * editable as vector art. + * + * The module runs unchanged in the browser and in Node: it touches no DOM and no + * Node built-in at module scope, so the tests can parse the font straight from + * disk. `loadFont` is the only asynchronous export; everything else is a pure + * function of the font, the text and the options. + * + * Widths are summed one character at a time, without kerning between them. That + * costs a fraction of a pixel on Latin pairs but keeps measuring and wrapping in + * exact agreement, which matters more for a short caption. + */ + +/** Families tried in order when a caption falls back to live <text>. */ +const FALLBACK_FAMILIES = [ + 'M PLUS Rounded 1c', + 'Hiragino Maru Gothic ProN', + 'Yu Gothic', + 'Meiryo', + 'sans-serif', +]; + +/** CSS keywords that must stay unquoted inside a font stack. */ +const GENERIC_FAMILIES = new Set([ + 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', + 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'math', 'emoji', + 'fangsong', +]); + +/** + * Closing marks and brackets that should not start a line. + * + * A full kinsoku table needs per-font metrics; this short list covers the marks + * a caption actually uses, and dragging the preceding character down is enough + * to fix the rest. + */ +const CLOSING_PUNCTUATION = new Set([ + '、', '。', ',', '.', ':', ';', '!', '?', + ')', ']', '}', '〕', '〉', '》', '」', '』', '】', '〗', '〙', '〟', + '”', '’', '⦆', '»', +]); + +const SPACE = /\s/; +const LINE_BREAK = /\r\n|\r|\n/; + +const DEFAULT_FONT_SIZE = 16; +const DEFAULT_LINE_HEIGHT = 1.4; + +/** + * Parsed fonts, keyed by URL. The stored value is the in-flight promise, so two + * callers asking for the same URL share one request instead of loading twice. + */ +const fontCache = new Map(); + +/** Family name of the most recent font from `loadFont`, read by `captionFontStack`. */ +let loadedFamily = null; + +/** + * Load a font and remember it under `url`. + * + * Resolves to an `opentype.Font`; rejects if the font cannot be fetched or + * parsed, and does not cache that failure, so a later call can retry. + * + * @param {string} url URL (browser) or file path (Node) of the font + * @returns {Promise<import('opentype.js').Font>} + */ +export async function loadFont(url) { + if (!url) throw new Error('loadFont: a font URL is required'); + if (fontCache.has(url)) return fontCache.get(url); + + const pending = opentype.load(url).then((font) => { + loadedFamily = familyNameOf(font) ?? loadedFamily; + return font; + }); + // A rejected promise left in the cache would make every retry fail forever. + pending.catch(() => fontCache.delete(url)); + fontCache.set(url, pending); + return pending; +} + +/** + * Synchronous twin of `loadFont` for tests and offline use. + * + * @param {ArrayBuffer} arrayBuffer font bytes; a typed-array view is accepted too + * @returns {import('opentype.js').Font} + */ +export function parseFont(arrayBuffer) { + // `opentype.parse` needs a real ArrayBuffer, so unwrap a view (for example a + // Node Buffer) before handing it over. + const view = ArrayBuffer.isView(arrayBuffer) ? arrayBuffer : null; + const buffer = view + ? view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) + : arrayBuffer; + return opentype.parse(buffer); +} + +/** + * Does the font really cover every character of `text`? + * + * Callers use this to decide between outlines and live text: a single missing + * glyph means the caption would come out with a hole in it, so the answer is + * then `false`. Newlines carry no glyph and are ignored. + * + * @param {import('opentype.js').Font} font + * @param {string} text + * @returns {boolean} + */ +export function hasGlyphs(font, text) { + if (!font || typeof text !== 'string') return false; + for (const ch of text) { + if (ch === '\n' || ch === '\r') continue; + if (font.charToGlyphIndex(ch) === 0) return false; // 0 is .notdef + } + return true; +} + +/** + * Size one string in pixels. + * + * The width is the widest line, so the same call works for a single line and for + * text that already contains breaks. `ascent` is above the baseline and + * `descent` below it, both in the font's own sign convention. + * + * @param {import('opentype.js').Font} font + * @param {string} text + * @param {number} [fontSize] + * @returns {{width: number, ascent: number, descent: number}} + */ +export function measureText(font, text, fontSize = DEFAULT_FONT_SIZE) { + const size = Number.isFinite(fontSize) ? fontSize : DEFAULT_FONT_SIZE; + const scale = size / (font.unitsPerEm || 1000); + + let width = 0; + for (const line of String(text ?? '').split(LINE_BREAK)) { + width = Math.max(width, measureLine(font, line, size)); + } + return { width, ascent: font.ascender * scale, descent: font.descender * scale }; +} + +/** + * Wrap `text` into lines and report the block's size. + * + * Rules, in the order they apply: + * - an explicit `\n` always ends a line; + * - a Latin word breaks only at a space, never in the middle; + * - CJK characters break anywhere, one break opportunity per character; + * - a piece that is too wide on its own still gets a line (nothing is dropped); + * - a line is not allowed to start with closing punctuation when dragging the + * previous character down would avoid it. + * + * @param {import('opentype.js').Font} font + * @param {string} text + * @param {object} [options] + * @param {number} [options.fontSize=16] + * @param {number|null} [options.maxWidth=0] 0/null/undefined means "no wrapping" + * @param {number} [options.lineHeight=1.4] multiplier of `fontSize` + * @param {'left'|'center'|'right'} [options.align='left'] + * @returns {{ + * lines: Array<{text: string, width: number}>, + * width: number, height: number, lineHeight: number, + * fontSize: number, align: string, + * }} + */ +export function layoutText(font, text, options = {}) { + const opts = options ?? {}; + const fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : DEFAULT_FONT_SIZE; + const lineHeight = Number.isFinite(opts.lineHeight) ? opts.lineHeight : DEFAULT_LINE_HEIGHT; + const align = opts.align === 'center' || opts.align === 'right' ? opts.align : 'left'; + const maxWidth = opts.maxWidth; + const wrapWidth = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : Infinity; + + const lines = []; + for (const paragraph of String(text ?? '').split(LINE_BREAK)) { + const atoms = tokenise(font, paragraph, fontSize); + for (const wrapped of wrapAtoms(atoms, wrapWidth)) { + lines.push({ + text: wrapped.map((atom) => atom.text).join(''), + width: wrapped.reduce((sum, atom) => sum + atom.width, 0), + }); + } + } + + let width = 0; + for (const line of lines) width = Math.max(width, line.width); + + const height = lines.length * fontSize * lineHeight; + return { lines, width, height, lineHeight, fontSize, align }; +} + +/** + * Convert a layout to one SVG path `d` string. + * + * `x`/`y` is the top-left corner of the text block. Each line sits on its own + * baseline, placed with half-leading so a line box of `fontSize * lineHeight` + * surrounds the glyphs evenly, and shifted sideways by `layout.align`. + * + * Characters the font does not cover are skipped rather than drawn as .notdef, + * and a line whose outline cannot be built cleanly is dropped, so the result + * never carries `NaN` into the document. + * + * @param {import('opentype.js').Font} font + * @param {object} layout value returned by `layoutText` + * @param {object} [options] + * @param {number} [options.x=0] + * @param {number} [options.y=0] + * @param {number} [options.round=2] decimal places in the output + * @returns {string} + */ +export function textToPathData(font, layout, options = {}) { + const opts = options ?? {}; + const x = Number.isFinite(opts.x) ? opts.x : 0; + const y = Number.isFinite(opts.y) ? opts.y : 0; + const round = Number.isFinite(opts.round) ? Math.max(0, Math.floor(opts.round)) : 2; + + if (!font || !layout || !Array.isArray(layout.lines) || layout.lines.length === 0) return ''; + + const fontSize = Number.isFinite(layout.fontSize) ? layout.fontSize : DEFAULT_FONT_SIZE; + const lineHeight = Number.isFinite(layout.lineHeight) ? layout.lineHeight : DEFAULT_LINE_HEIGHT; + const blockWidth = Number.isFinite(layout.width) ? layout.width : 0; + + const step = fontSize * lineHeight; + const scale = fontSize / (font.unitsPerEm || 1000); + const ascent = font.ascender * scale; + const descent = -font.descender * scale; // depth below the baseline, positive + const halfLeading = (step - (ascent + descent)) / 2; + + const parts = []; + for (let i = 0; i < layout.lines.length; i++) { + const line = layout.lines[i]; + const drawable = drawableText(font, line.text); + if (!drawable) continue; + + const baseline = y + i * step + halfLeading + ascent; + const lineX = x + alignOffset(layout.align, blockWidth, line.width); + const d = font.getPath(drawable, lineX, baseline, fontSize).toPathData(round); + if (!d || d.includes('NaN') || d.includes('Infinity')) continue; + parts.push(d); + } + return parts.join(' '); +} + +/** + * The CSS `font-family` the app should use for live `<text>` or canvas captions. + * + * Starts with the family of the most recently loaded font, so the fallback text + * looks as close as possible to the outlines, and ends with Japanese-safe + * families that exist on the machines the studio runs on. + * + * @returns {string} + */ +export function captionFontStack() { + const families = [loadedFamily ?? FALLBACK_FAMILIES[0]]; + const seen = new Set(families.map((name) => name.toLowerCase())); + for (const name of FALLBACK_FAMILIES) { + if (seen.has(name.toLowerCase())) continue; + seen.add(name.toLowerCase()); + families.push(name); + } + return families.map(cssFamily).join(', '); +} + +// --- internals --------------------------------------------------------------- + +/** Width of one line of text, in pixels. */ +function measureLine(font, line, fontSize) { + let width = 0; + for (const ch of line) width += font.getAdvanceWidth(ch, fontSize); + return width; +} + +/** Family name of a font, or `null` when it does not carry one. */ +function familyNameOf(font) { + const names = font?.names?.fontFamily; + if (!names) return null; + // A parsed font stores one entry per language tag; a font built from scratch + // stores a plain string. Accept both. + const value = typeof names === 'string' ? names : names.en ?? Object.values(names)[0]; + return typeof value === 'string' && value.trim() !== '' ? value.trim() : null; +} + +/** Only the characters the font can actually draw, newlines removed. */ +function drawableText(font, text) { + let out = ''; + for (const ch of String(text ?? '')) { + if (ch === '\n' || ch === '\r') continue; + if (font.charToGlyphIndex(ch) === 0) continue; // no outline to draw + out += ch; + } + return out; +} + +/** Sideways shift of a line inside the block, for the block's alignment. */ +function alignOffset(align, blockWidth, lineWidth) { + const slack = blockWidth - lineWidth; + if (align === 'center') return slack / 2; + if (align === 'right') return slack; + return 0; +} + +/** Quote a family for CSS unless it is a generic keyword. */ +function cssFamily(name) { + if (GENERIC_FAMILIES.has(name.toLowerCase())) return name; + return `'${name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +/** `true` for scripts that may break between any two characters. */ +function isCjk(ch) { + const c = ch.codePointAt(0); + return ( + (c >= 0x1100 && c <= 0x11ff) || // Hangul Jamo + (c >= 0x2e80 && c <= 0x303f) || // radicals, CJK punctuation + (c >= 0x3040 && c <= 0x30ff) || // hiragana, katakana + (c >= 0x3130 && c <= 0x318f) || // Hangul compatibility Jamo + (c >= 0x3400 && c <= 0x4dbf) || // CJK extension A + (c >= 0x4e00 && c <= 0x9fff) || // CJK unified ideographs + (c >= 0xa960 && c <= 0xa97f) || // Hangul Jamo extended A + (c >= 0xac00 && c <= 0xd7ff) || // Hangul syllables + (c >= 0xf900 && c <= 0xfaff) || // CJK compatibility ideographs + (c >= 0xfe10 && c <= 0xfe4f) || // vertical and compatibility forms + (c >= 0xff00 && c <= 0xffef) || // fullwidth and halfwidth forms + (c >= 0x1f200 && c <= 0x1f2ff) || // enclosed ideographic supplement + (c >= 0x20000 && c <= 0x2fa1f) // CJK extensions B onwards + ); +} + +/** One breakable piece of text together with its advance width. */ +function makeAtom(font, text, fontSize, space, closing) { + return { text, width: font.getAdvanceWidth(text, fontSize), space, closing }; +} + +/** + * Split a paragraph into the smallest pieces a line may break between. + * + * A Latin run stays one atom so it can never be split; every CJK character is + * its own atom so a break may fall on either side of it. + * + * @returns {Array<{text: string, width: number, space: boolean, closing: boolean}>} + */ +function tokenise(font, paragraph, fontSize) { + const atoms = []; + let word = null; + + const endWord = () => { + if (word !== null) { + atoms.push(word); + word = null; + } + }; + + for (const ch of paragraph) { + if (SPACE.test(ch)) { + endWord(); + atoms.push(makeAtom(font, ch, fontSize, true, false)); + } else if (isCjk(ch)) { + endWord(); + atoms.push(makeAtom(font, ch, fontSize, false, CLOSING_PUNCTUATION.has(ch))); + } else { + if (word === null) word = makeAtom(font, '', fontSize, false, false); + word.text += ch; + word.width += font.getAdvanceWidth(ch, fontSize); + } + } + endWord(); + return atoms; +} + +/** + * Greedy line breaker over atoms. Always returns at least one line, so an empty + * paragraph comes back as one empty line and explicit breaks are preserved. + * + * @param {Array<object>} atoms + * @param {number} maxWidth `Infinity` disables wrapping + * @returns {Array<Array<object>>} + */ +function wrapAtoms(atoms, maxWidth) { + const lines = []; + let current = []; + + const currentWidth = () => current.reduce((sum, atom) => sum + atom.width, 0); + + const finish = () => { + // A break swallows the spaces next to it, so no line ends or starts blank. + while (current.length > 0 && current[current.length - 1].space) current.pop(); + while (current.length > 0 && current[0].space) current.shift(); + lines.push(current); + current = []; + }; + + for (const atom of atoms) { + if (current.length === 0) { + if (atom.space) continue; // never start a line with a space + current.push(atom); + continue; + } + if (currentWidth() + atom.width <= maxWidth) { + current.push(atom); + continue; + } + + // The atom does not fit. Keep closing punctuation off the start of the next + // line by dragging the previous character down, but only when that leaves + // something behind and the pair still respects the maximum width. + const carried = current[current.length - 1]; + const visible = current.filter((a) => !a.space); + if ( + atom.closing && + visible.length > 1 && + !carried.space && + carried.width + atom.width <= maxWidth + ) { + current.pop(); + finish(); + current.push(carried); + } else { + finish(); + } + // The break already stands in for a space, so it does not start the new line. + if (atom.space) continue; + current.push(atom); + } + + finish(); + return lines; +} diff --git a/public/bluebey-studio/src/trace.js b/public/bluebey-studio/src/trace.js new file mode 100644 index 0000000..9b61d6d --- /dev/null +++ b/public/bluebey-studio/src/trace.js @@ -0,0 +1,410 @@ +/** + * Zero-dependency marching-squares contour tracer. + * + * Runs unchanged in the browser and in Node: the module has no imports at all, + * never touches the DOM and never writes to the console. + * + * Coordinate system + * ----------------- + * Pixel (x, y) is the unit square whose top-left corner sits at (x, y), so the + * centre of pixel (x, y) is at (x + 0.5, y + 0.5) and y grows downwards + * (row 0 is the top row of the input). + * + * Contours + * -------- + * Every returned contour is a *closed* polyline of sub-pixel points; the first + * point is not repeated at the end. Contours are oriented so that the inside of + * the shape (the region where the sample is at or above the threshold) lies to + * the left of the direction of travel. See `contourArea` for the sign this + * implies. + */ + +const EDGE_TOP = 0; +const EDGE_RIGHT = 1; +const EDGE_BOTTOM = 2; +const EDGE_LEFT = 3; + +/** + * Directed segments emitted by each unambiguous marching-squares case. + * + * The case index is built from the cell corners as + * `tl | tr << 1 | br << 2 | bl << 3`. Entries are flat `[from, to, from, to]` + * pairs of edge ids; the direction keeps the inside region on the left. + */ +const CELL_SEGMENTS = [ + null, // 0 - nothing inside + [EDGE_LEFT, EDGE_TOP, -1, -1], // 1 - top-left only + [EDGE_TOP, EDGE_RIGHT, -1, -1], // 2 - top-right only + [EDGE_LEFT, EDGE_RIGHT, -1, -1], // 3 - top row + [EDGE_RIGHT, EDGE_BOTTOM, -1, -1], // 4 - bottom-right only + null, // 5 - saddle (top-left + bottom-right) + [EDGE_TOP, EDGE_BOTTOM, -1, -1], // 6 - right column + [EDGE_LEFT, EDGE_BOTTOM, -1, -1], // 7 - everything but bottom-left + [EDGE_BOTTOM, EDGE_LEFT, -1, -1], // 8 - bottom-left only + [EDGE_BOTTOM, EDGE_TOP, -1, -1], // 9 - left column + null, // 10 - saddle (top-right + bottom-left) + [EDGE_BOTTOM, EDGE_RIGHT, -1, -1], // 11 - everything but bottom-right + [EDGE_RIGHT, EDGE_LEFT, -1, -1], // 12 - bottom row + [EDGE_RIGHT, EDGE_TOP, -1, -1], // 13 - everything but top-right + [EDGE_TOP, EDGE_LEFT, -1, -1], // 14 - everything but top-left + null, // 15 - everything inside +]; + +// Case 5 (top-left + bottom-right inside). When the centre of the cell is +// inside, the two inside corners are joined through the middle and the two +// outside corners are separated; otherwise the inside corners are separated. +const SADDLE_5_CONNECTED = [EDGE_LEFT, EDGE_BOTTOM, EDGE_RIGHT, EDGE_TOP]; +const SADDLE_5_SPLIT = [EDGE_LEFT, EDGE_TOP, EDGE_RIGHT, EDGE_BOTTOM]; + +// Case 10 (top-right + bottom-left inside): the mirror image of case 5. +const SADDLE_10_CONNECTED = [EDGE_TOP, EDGE_LEFT, EDGE_BOTTOM, EDGE_RIGHT]; +const SADDLE_10_SPLIT = [EDGE_TOP, EDGE_RIGHT, EDGE_BOTTOM, EDGE_LEFT]; + +const DUPLICATE_EPS = 1e-9; + +/** + * Signed area of a closed polyline, via the shoelace formula. The polyline is + * implicitly closed (the last point is joined back to the first), so an open + * ring is fine. + * + * Sign convention: this is the plain shoelace sum `Σ (x_i·y_{i+1} − x_{i+1}·y_i) / 2` + * evaluated in the tracer's y-down pixel coordinates. A ring that runs + * clockwise *as seen on screen* is therefore positive, and a counter-clockwise + * one is negative. Because `traceAlphaContours` keeps the inside on the left, + * the outer boundary of a filled region comes out negative and a hole in it + * comes out positive. + * + * @param {Array<{x: number, y: number}>} points + * @returns {number} signed area in square pixels + */ +export function contourArea(points) { + const n = points.length; + if (!points || n < 3) return 0; + let sum = 0; + for (let i = 0; i < n; i++) { + const a = points[i]; + const b = i + 1 === n ? points[0] : points[i + 1]; + sum += a.x * b.y - b.x * a.y; + } + return sum / 2; +} + +/** + * Build an SVG path `d` attribute with one closed subpath per contour. + * + * `mapPoint(x, y)` returns the `[X, Y]` pair written to the output, which is + * what makes it possible to flip the y axis or apply a scale without touching + * the tracer. Coordinates are rounded to `decimals` places. + * + * @param {Array<Array<{x: number, y: number}>>} contours + * @param {(x: number, y: number) => [number, number]} mapPoint + * @param {number} [decimals] + * @returns {string} + */ +export function contoursToPathData(contours, mapPoint, decimals = 2) { + const places = Math.max(0, Math.floor(decimals)); + const factor = Math.pow(10, places); + const parts = []; + for (let c = 0; c < contours.length; c++) { + const points = contours[c]; + if (!points || points.length < 2) continue; + for (let i = 0; i < points.length; i++) { + const mapped = mapPoint(points[i].x, points[i].y); + // Rounding before formatting keeps `-0.00` out of the output. + const rx = Math.round(mapped[0] * factor) / factor; + const ry = Math.round(mapped[1] * factor) / factor; + parts.push((i === 0 ? 'M' : 'L') + rx.toFixed(places) + ' ' + ry.toFixed(places)); + } + parts.push('Z'); + } + return parts.join(' '); +} + +// --- closed-ring simplification helpers ------------------------------------ + +/** Drop points that repeat their predecessor (including across the wrap). */ +function removeConsecutiveDuplicates(points) { + const out = []; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + const last = out[out.length - 1]; + if (last && Math.abs(last.x - p.x) <= DUPLICATE_EPS && Math.abs(last.y - p.y) <= DUPLICATE_EPS) { + continue; + } + out.push(p); + } + while (out.length > 1) { + const first = out[0]; + const last = out[out.length - 1]; + if (Math.abs(first.x - last.x) <= DUPLICATE_EPS && Math.abs(first.y - last.y) <= DUPLICATE_EPS) { + out.pop(); + } else { + break; + } + } + return out; +} + +/** Drop points that sit on the straight segment between their two neighbours. */ +function removeCollinear(points) { + let list = points; + let changed = true; + while (changed && list.length > 3) { + changed = false; + const n = list.length; + const out = []; + for (let i = 0; i < n; i++) { + const a = list[i === 0 ? n - 1 : i - 1]; + const b = list[i]; + const c = list[i + 1 === n ? 0 : i + 1]; + const abx = b.x - a.x; + const aby = b.y - a.y; + const bcx = c.x - b.x; + const bcy = c.y - b.y; + const cross = abx * bcy - aby * bcx; + // |cross| / (|ab| * |bc|) is sin(turn angle); a small value means a + // straight-through point, which carries no shape information. + const scale = Math.sqrt((abx * abx + aby * aby) * (bcx * bcx + bcy * bcy)); + const straight = scale <= DUPLICATE_EPS || Math.abs(cross) <= 1e-9 * scale; + if (straight && abx * bcx + aby * bcy >= 0) { + changed = true; + } else { + out.push(b); + } + } + list = out; + } + return list; +} + +/** + * Iterative Douglas–Peucker for an *open* polyline. The two end points are + * always kept; the recursion uses an explicit stack so long contours cannot + * overflow the call stack. + */ +function douglasPeucker(points, tolerance) { + const n = points.length; + if (n <= 2) return points.slice(); + const keep = new Uint8Array(n); + keep[0] = 1; + keep[n - 1] = 1; + const stack = [0, n - 1]; + while (stack.length > 0) { + const i1 = stack.pop(); + const i0 = stack.pop(); + if (i1 <= i0 + 1) continue; + const a = points[i0]; + const b = points[i1]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.sqrt(dx * dx + dy * dy); + let maxDistance = -1; + let maxIndex = -1; + if (len <= DUPLICATE_EPS) { + // Degenerate segment: fall back to the distance from the anchor point. + for (let i = i0 + 1; i < i1; i++) { + const px = points[i].x - a.x; + const py = points[i].y - a.y; + const d = Math.sqrt(px * px + py * py); + if (d > maxDistance) { + maxDistance = d; + maxIndex = i; + } + } + } else { + for (let i = i0 + 1; i < i1; i++) { + const p = points[i]; + const d = Math.abs(dy * (p.x - a.x) - dx * (p.y - a.y)) / len; + if (d > maxDistance) { + maxDistance = d; + maxIndex = i; + } + } + } + if (maxDistance > tolerance && maxIndex > i0) { + keep[maxIndex] = 1; + stack.push(i0, maxIndex, maxIndex, i1); + } + } + const out = []; + for (let i = 0; i < n; i++) { + if (keep[i]) out.push(points[i]); + } + return out; +} + +/** + * Simplify a closed ring, wrap-around segment included. + * + * The ring is cut at the point farthest from `points[0]`, which gives two open + * polylines that together cover every segment of the loop exactly once; each + * half is then simplified with Douglas–Peucker and the halves are stitched + * back together (without duplicating the shared anchors). + */ +function simplifyClosedRing(points, tolerance) { + const n = points.length; + if (n <= 3) return points.slice(); + let far = 0; + let farDistance = -1; + const first = points[0]; + for (let i = 1; i < n; i++) { + const dx = points[i].x - first.x; + const dy = points[i].y - first.y; + const d = dx * dx + dy * dy; + if (d > farDistance) { + farDistance = d; + far = i; + } + } + // A ring whose points all coincide carries no shape; leave it to minArea. + if (far <= 0 || farDistance <= DUPLICATE_EPS) return points.slice(); + + const head = douglasPeucker(points.slice(0, far + 1), tolerance); + const tail = douglasPeucker(points.slice(far).concat([first]), tolerance); + + // `head` ends and `tail` starts on the same anchor, and both end on + // `points[0]`; drop the duplicated join so every point appears once. + return head.slice(0, -1).concat(tail.slice(0, -1)); +} + +/** + * Trace the iso-contour of a scalar field at `options.threshold`. + * + * @param {ArrayLike<number>} alpha width*height samples, row-major, row 0 on top + * @param {number} width + * @param {number} height + * @param {object} [options] + * @param {number} [options.threshold=0.5] inside when `alpha/255 >= threshold` + * @param {number} [options.simplifyTolerance=0.35] Douglas-Peucker tolerance, px + * @param {number} [options.minArea=2] drop rings smaller than this, px² + * @returns {Array<Array<{x: number, y: number}>>} + */ +export function traceAlphaContours(alpha, width, height, options = {}) { + const threshold = options.threshold ?? 0.5; + const tolerance = options.simplifyTolerance ?? 0.35; + const minArea = options.minArea ?? 2; + + const w = Math.floor(width); + const h = Math.floor(height); + if (!alpha || w < 1 || h < 1 || alpha.length < w * h) return []; + if (w < 2 && h < 2) return []; + + // Work on a signed field, padded with a 1px outside border: `s >= 0` is + // inside. The padding guarantees every crossing is strictly interior, so the + // marching always yields closed rings and never touches the array edges. + // The border holds the same value a fully transparent pixel maps to, which + // makes shapes that run off the image close exactly on the image edge. + const pw = w + 2; + const ph = h + 2; + const s = new Float32Array(pw * ph); + s.fill(-threshold); + for (let y = 0; y < h; y++) { + const src = y * w; + const dst = (y + 1) * pw + 1; + for (let x = 0; x < w; x++) s[dst + x] = alpha[src + x] / 255 - threshold; + } + const sample = (x, y) => s[y * pw + x]; + + // Crossing points, keyed by the grid edge they sit on. Both cells sharing an + // edge call these with the same sample pair in the same order (top→bottom, + // left→right), so the coordinates come out bit-identical and can be matched + // by key alone. + const points = new Map(); + function crossing(key, a, b, x0, y0, dx, dy) { + let p = points.get(key); + if (p === undefined) { + const t = a / (a - b); + p = { x: x0 + dx * t, y: y0 + dy * t }; + points.set(key, p); + } + return p; + } + // Horizontal edge of the padded grid at row `py`, spanning columns px..px+1. + const hKey = (px, py) => `h:${px}:${py}`; + const hPoint = (px, py) => crossing(hKey(px, py), sample(px, py), sample(px + 1, py), px - 0.5, py - 0.5, 1, 0); + // Vertical edge of the padded grid at column `px`, spanning rows py..py+1. + const vKey = (px, py) => `v:${px}:${py}`; + const vPoint = (px, py) => crossing(vKey(px, py), sample(px, py), sample(px, py + 1), px - 0.5, py - 0.5, 0, 1); + + const edgeKey = [ + (px, py) => hKey(px, py), // EDGE_TOP + (px, py) => vKey(px + 1, py), // EDGE_RIGHT + (px, py) => hKey(px, py + 1), // EDGE_BOTTOM + (px, py) => vKey(px, py), // EDGE_LEFT + ]; + const edgePoint = [ + (px, py) => hPoint(px, py), // EDGE_TOP + (px, py) => vPoint(px + 1, py), // EDGE_RIGHT + (px, py) => hPoint(px, py + 1), // EDGE_BOTTOM + (px, py) => vPoint(px, py), // EDGE_LEFT + ]; + + // Directed segments: `from` -> `to`, inside region on the left of travel. + const fromKeys = []; + const toKeys = []; + const fromPoints = []; + + for (let py = 0; py < ph - 1; py++) { + for (let px = 0; px < pw - 1; px++) { + const tl = sample(px, py); + const tr = sample(px + 1, py); + const br = sample(px + 1, py + 1); + const bl = sample(px, py + 1); + const inside = (v) => (v >= 0 ? 1 : 0); + const code = inside(tl) | (inside(tr) << 1) | (inside(br) << 2) | (inside(bl) << 3); + let segments = CELL_SEGMENTS[code]; + if (code === 5) { + // With the cell centre inside, the two inside corners join through the + // middle; otherwise each is cut off on its own. + segments = (tl + tr + br + bl) / 4 >= 0 ? SADDLE_5_CONNECTED : SADDLE_5_SPLIT; + } else if (code === 10) { + segments = (tl + tr + br + bl) / 4 >= 0 ? SADDLE_10_CONNECTED : SADDLE_10_SPLIT; + } + if (!segments) continue; + for (let i = 0; i < segments.length; i += 2) { + const a = segments[i]; + const b = segments[i + 1]; + if (a < 0 || b < 0) continue; // padding of the single-segment cases + fromKeys.push(edgeKey[a](px, py)); + fromPoints.push(edgePoint[a](px, py)); + toKeys.push(edgeKey[b](px, py)); + edgePoint[b](px, py); // make sure the shared crossing exists + } + } + } + + // Every crossing has exactly one incoming and one outgoing segment, so the + // segments can be walked into closed rings without any ambiguity. + const outgoing = new Map(); + for (let i = 0; i < fromKeys.length; i++) outgoing.set(fromKeys[i], i); + + const segmentCount = fromKeys.length; + const used = new Uint8Array(segmentCount); + const contours = []; + + for (let start = 0; start < segmentCount; start++) { + if (used[start]) continue; + const ring = []; + let current = start; + for (let guard = 0; guard <= segmentCount; guard++) { + used[current] = 1; + ring.push(fromPoints[current]); + const next = outgoing.get(toKeys[current]); + if (next === undefined || next === start) break; + if (used[next]) break; + current = next; + } + if (ring.length >= 3) contours.push(ring); + } + + const result = []; + for (let i = 0; i < contours.length; i++) { + let ring = removeConsecutiveDuplicates(contours[i]); + ring = simplifyClosedRing(ring, tolerance); + ring = removeCollinear(ring); + if (ring.length < 3) continue; + if (Math.abs(contourArea(ring)) < minArea) continue; + result.push(ring); + } + return result; +}
\ No newline at end of file diff --git a/public/bluebey-studio/src/ui.js b/public/bluebey-studio/src/ui.js new file mode 100644 index 0000000..71f79b4 --- /dev/null +++ b/public/bluebey-studio/src/ui.js @@ -0,0 +1,326 @@ +/** + * Small DOM widget kit for the control panel: hyperscript, collapsible sections + * and a handful of labelled controls. Everything returns `{ el, set, get }` so + * callers can push state back into the widgets when presets are applied. + */ + +export function h(tag, props = {}, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(props ?? {})) { + if (value == null || value === false) continue; + if (key === 'class') node.className = value; + else if (key === 'text') node.textContent = value; + else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value); + else if (key === 'dataset' && typeof value === 'object') Object.assign(node.dataset, value); + else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value); + else if (value === true) node.setAttribute(key, ''); + else node.setAttribute(key, String(value)); + } + for (const child of children.flat()) { + if (child == null || child === false) continue; + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +} + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); + +let toastTimer = 0; + +export function toast(message) { + const node = document.getElementById('toast'); + if (!node) return; + node.textContent = message; + node.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => node.classList.remove('show'), 2400); +} + +export function section(parent, title, { open = false } = {}) { + const body = h('div', { class: 'sec-body' }); + const head = h('button', { type: 'button', class: 'sec-head' }, + h('span', { text: title }), + h('span', { class: 'chev', text: '▾' })); + const el = h('section', { class: `sec${open ? '' : ' closed'}` }, head, body); + head.addEventListener('click', () => el.classList.toggle('closed')); + parent.append(el); + // `add` accepts either an element or a widget object (`{ el }`), so callers + // cannot accidentally append `[object Object]`. + return { el, body, add: (child) => (body.append(child?.el ?? child), child), setOpen: (open) => el.classList.toggle('closed', !open) }; +} + +/** A label + control row. Pass `label` as null for a full-width control. */ +export function controlRow(label, control, { wide = false } = {}) { + if (label == null || wide) return h('div', { class: 'row wide' }, label ? h('span', { class: 'label', text: label }) : null, control); + return h('div', { class: 'row' }, + h('span', { class: 'label', text: label, title: label }), + control); +} + +/** + * A tab's optional icon: one 24x24 stroke path, drawn in `currentColor` so it + * follows the tab's text colour (and turns white when the tab is active). + */ +function tabIcon(d) { + const SVG_NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('width', '18'); + svg.setAttribute('height', '18'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '1.8'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('aria-hidden', 'true'); + // Sit the icon on the label's text baseline; the span and the text node keep + // the two from touching. + svg.style.verticalAlign = 'baseline'; + svg.style.flex = '0 0 auto'; + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', d); + svg.append(path); + return svg; +} + +/** + * Tabbed container: returns the per-tab panels to fill in. Each definition may + * carry an `icon` (an SVG path `d`); the label stays visible next to it. + */ +export function tabs(parent, definitions) { + const bar = h('div', { class: 'tabs' }); + const buttons = new Map(); + const panels = {}; + let active = definitions[0]?.id ?? null; + + const select = (id) => { + if (!panels[id]) return; + active = id; + for (const [key, button] of buttons) button.classList.toggle('active', key === id); + for (const [key, panel] of Object.entries(panels)) panel.hidden = key !== id; + }; + + for (const definition of definitions) { + const button = h('button', { type: 'button', class: 'tab' }, + definition.icon ? tabIcon(definition.icon) : null, + definition.icon ? ' ' : null, + h('span', { class: 'tab-label', text: definition.label })); + button.addEventListener('click', () => select(definition.id)); + buttons.set(definition.id, button); + bar.append(button); + panels[definition.id] = h('div', { class: 'tab-panel' }); + } + + parent.append(bar); + for (const definition of definitions) parent.append(panels[definition.id]); + select(active); + return { select, panels, get active() { return active; } }; +} + +/** A collapsed "more options" block, using the native details element. */ +export function details(parent, summary, { open = false } = {}) { + const body = h('div', { class: 'details-body' }); + const el = h('details', { class: 'details' }, h('summary', { text: summary }), body); + if (open) el.open = true; + parent.append(el); + return { el, body, add: (child) => (body.append(child?.el ?? child), child) }; +} + +export function subhead(text) { + return h('div', { class: 'subhead', text }); +} + +export function hint(...lines) { + return h('p', { class: 'hint' }, ...lines.flat().map((line) => (line instanceof Node ? line : line))); +} + +export function slider({ label, min = 0, max = 1, step = 0.01, value = 0, format, onInput, onCommit, wide = false }) { + const input = h('input', { type: 'range', min, max, step, value }); + const num = h('span', { class: 'num' }); + const fmt = format ?? ((v) => (step >= 1 ? String(Math.round(v)) : v.toFixed(2))); + const sync = () => { num.textContent = fmt(Number(input.value)); }; + input.addEventListener('input', () => { sync(); onInput?.(Number(input.value)); }); + input.addEventListener('change', () => onCommit?.(Number(input.value))); + sync(); + const control = h('div', { class: 'control' }, input, num); + return { + el: controlRow(label, control, { wide }), + set(v) { input.value = String(v); sync(); }, + get: () => Number(input.value), + }; +} + +export function check({ label, value = false, onChange, title }) { + const input = h('input', { type: 'checkbox' }); + input.checked = !!value; + input.addEventListener('change', () => onChange?.(input.checked)); + const el = h('label', { class: 'chk', title: title ?? '' }, input, h('span', { text: label })); + return { el, set: (v) => { input.checked = !!v; }, get: () => input.checked }; +} + +export function segmented({ label, options, value, onChange, wide = false }) { + const el = h('div', { class: 'seg' }); + const nodes = new Map(); + let current = value; + const sync = () => { for (const [key, node] of nodes) node.classList.toggle('active', key === current); }; + const build = (list) => { + el.replaceChildren(); + nodes.clear(); + for (const option of list) { + const node = h('button', { type: 'button', title: option.title ?? option.label, text: option.label }); + node.addEventListener('click', () => { + if (current === option.value) return; + current = option.value; + sync(); + onChange?.(option.value); + }); + nodes.set(option.value, node); + el.append(node); + } + sync(); + }; + build(options); + return { + el: controlRow(label, el, { wide }), + set(v) { current = v; sync(); }, + get: () => current, + setOptions: build, + }; +} + +export function buttons({ label, items, wide = true }) { + const el = h('div', { class: 'buttons' }); + const nodes = new Map(); + for (const item of items) { + const node = h('button', { + type: 'button', + class: `btn${item.primary ? ' primary' : ''}`, + title: item.title ?? '', + text: item.label, + }); + node.addEventListener('click', () => item.onClick?.()); + const key = item.id ?? item.label; + nodes.set(key, node); + el.append(node); + } + return { + el: controlRow(label, el, { wide }), + button: (key) => nodes.get(key)?.el, + setDisabled(key, disabled) { const node = nodes.get(key); if (node) node.disabled = disabled; }, + setLabel(key, text) { const node = nodes.get(key); if (node) node.textContent = text; }, + }; +} + +export function colorField({ label, value = '#ffffff', onChange, swatches = [], wide = false }) { + const input = h('input', { type: 'color', value }); + input.addEventListener('input', () => onChange?.(input.value)); + const control = h('div', { class: 'control' }, input); + if (swatches.length) { + const strip = h('div', { class: 'swatches' }); + for (const color of swatches) { + const chip = h('button', { type: 'button', class: 'swatch', title: color, style: { background: color } }); + chip.addEventListener('click', () => { input.value = color; onChange?.(color); }); + strip.append(chip); + } + control.append(strip); + } + return { + el: controlRow(label, control, { wide }), + set(v) { input.value = v; }, + get: () => input.value, + }; +} + +/** + * Two-axis drag pad. `value` is `{ x, y }` with both components in -1..1 and + * `y` positive upwards (screen-like, but flipped so up = up). + */ +export function xyPad({ value = { x: 0, y: 0 }, onChange, onCommit, center = null }) { + const dot = h('span', { class: 'pad-dot' }); + const el = h('div', { class: 'pad' }, center === 'eye' ? h('span', { class: 'pad-eye' }) : null, dot); + let current = { x: value.x ?? 0, y: value.y ?? 0 }; + let dragging = false; + + const place = () => { + dot.style.left = `${((current.x + 1) / 2) * 100}%`; + dot.style.top = `${((1 - (current.y + 1) / 2)) * 100}%`; + }; + const fromEvent = (event) => { + const rect = el.getBoundingClientRect(); + const x = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1); + const y = clamp(1 - ((event.clientY - rect.top) / rect.height) * 2, -1, 1); + current = { x, y }; + place(); + onChange?.({ ...current }); + }; + + el.addEventListener('pointerdown', (event) => { + dragging = true; + el.setPointerCapture(event.pointerId); + fromEvent(event); + }); + el.addEventListener('pointermove', (event) => { if (dragging) fromEvent(event); }); + const end = (event) => { + if (!dragging) return; + dragging = false; + if (el.hasPointerCapture(event.pointerId)) el.releasePointerCapture(event.pointerId); + onCommit?.({ ...current }); + }; + el.addEventListener('pointerup', end); + el.addEventListener('pointercancel', end); + el.addEventListener('dblclick', () => { current = { x: 0, y: 0 }; place(); onChange?.({ ...current }); onCommit?.({ ...current }); }); + + place(); + return { + el, + set(v) { current = { x: v.x ?? 0, y: v.y ?? 0 }; place(); }, + get: () => ({ ...current }), + }; +} + +/** + * Tail-direction picker: a 3x3 grid of dots whose *position* is the direction, + * which reads at a glance in a way a flat row of "左上/右上" buttons does not. + * The centre dot means no tail. Same `{ el, set, get }` contract as the rest. + */ +export function tailPad({ label, value = 'left', onChange }) { + // Row-major, so the array order is what the user sees on screen. + const CELLS = [ + ['topLeft', '左上'], ['top', '上'], ['topRight', '右上'], + ['left', '左'], ['none', 'なし'], ['right', '右'], + ['bottomLeft', '左下'], ['bottom', '下'], ['bottomRight', '右下'], + ]; + const el = h('div', { class: 'tail-pad' }); + const nodes = new Map(); + let current = value; + const sync = () => { for (const [key, node] of nodes) node.classList.toggle('active', key === current); }; + for (const [cell, text] of CELLS) { + const node = h('button', { type: 'button', class: 'tail-dot', title: text, 'aria-label': text }); + node.addEventListener('click', () => { + if (current === cell) return; + current = cell; + sync(); + onChange?.(cell); + }); + nodes.set(cell, node); + el.append(node); + } + sync(); + return { + el: controlRow(label, el), + set(v) { current = v; sync(); }, + get: () => current, + }; +} + +export function selectField({ label, options, value, onChange, wide = false }) { + const select = h('select', { class: 'select' }); + for (const option of options) select.append(h('option', { value: option.value, text: option.label })); + select.value = value; + select.addEventListener('change', () => onChange?.(select.value)); + return { + el: controlRow(label, select, { wide }), + set(v) { select.value = v; }, + get: () => select.value, + }; +} diff --git a/public/bluebey-studio/src/urlState.js b/public/bluebey-studio/src/urlState.js new file mode 100644 index 0000000..a3a7e48 --- /dev/null +++ b/public/bluebey-studio/src/urlState.js @@ -0,0 +1,162 @@ +/** + * Shareable links: the whole look, packed into the URL fragment. + * + * A studio session is a lot of state, and "save a JSON file and send it" is a + * poor answer to "how do I show you what I made" - one link you can paste into + * chat is much better. So the state is JSON-encoded, deflated and + * base64url-encoded into something short enough to sit in a `#` fragment. + * + * Three details make it survive the trip: + * + * - `view.backgroundImage` can be a multi-megabyte data URL (a photo the user + * loaded). No link can carry that, so `stripForUrl` replaces just that one + * field with `null` and keeps everything else - caption text, story panels, + * props. Nothing else is ever dropped. + * + * - The payload starts with a version tag ('1' = raw deflate, '0' = plain + * bytes) so the decoder knows whether to inflate. A browser without + * `CompressionStream` falls back to the uncompressed form rather than + * failing to produce a link at all. + * + * - base64url uses `-` and `_` and carries no `=` padding, so the fragment is + * safe to paste into chat, Markdown or HTML without being mangled or escaped. + * + * `decodeState` is deliberately total: any malformed, truncated or hostile input + * returns `null` rather than throwing, because it is fed whatever came out of + * the address bar. + */ + +/** Above this length a `data:` background image is a photo, not a link. */ +const MAX_INLINE_IMAGE = 2048; + +/** Version tags. '1' is the deflated body, '0' the fallback plain body. */ +const TAG_DEFLATE = '1'; +const TAG_PLAIN = '0'; + +const BASE64URL = /^[A-Za-z0-9_-]+$/; + +/** + * A copy of `state` that is safe to put in a URL. Only the inline background + * image is dropped (replaced with `null`), and only when it is a `data:` URL + * long enough to blow up the link. + */ +export function stripForUrl(state) { + // A JSON round-trip gives the copy for free and drops anything that could not + // be serialised anyway, so the link and the live state cannot diverge. + const copy = JSON.parse(JSON.stringify(state ?? null)); + if (!copy || typeof copy !== 'object' || Array.isArray(copy)) return copy; + + const image = copy.view && copy.view.backgroundImage; + if (typeof image === 'string' && image.startsWith('data:') && image.length > MAX_INLINE_IMAGE) { + copy.view.backgroundImage = null; + } + return copy; +} + +/** True when the encoded fragment is bigger than a URL can comfortably hold. */ +export function isTooLong(text, limit = 1800) { + return typeof text === 'string' && text.length > limit; +} + +/** + * base64url, without padding. Chunked so a large payload does not blow the + * argument limit of `String.fromCharCode`. + */ +function toBase64Url(bytes) { + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** The inverse of `toBase64Url`. Throws on text that is not valid base64. */ +function fromBase64Url(text) { + const base64 = text.replace(/-/g, '+').replace(/_/g, '/'); + const pad = (4 - (base64.length % 4)) % 4; + const binary = atob(base64 + '='.repeat(pad)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/** Read a whole ReadableStream into one Uint8Array. */ +async function drain(stream) { + const chunks = []; + let length = 0; + for await (const chunk of stream) { + const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); + chunks.push(bytes); + length += bytes.length; + } + const out = new Uint8Array(length); + let offset = 0; + for (const bytes of chunks) { + out.set(bytes, offset); + offset += bytes.length; + } + return out; +} + +async function deflateRaw(bytes) { + const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw')); + return drain(stream); +} + +async function inflateRaw(bytes) { + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')); + return drain(stream); +} + +/** + * Serialise, deflate and base64url-encode a state. The result is a fragment + * payload: it survives a URL, a chat message and an HTML attribute. + * + * @param {object} state the studio state + * @returns {Promise<string>} a URL-safe string (tag + base64url) + */ +export async function encodeState(state) { + const json = JSON.stringify(stripForUrl(state) ?? null); + const bytes = new TextEncoder().encode(json); + + if (typeof CompressionStream === 'function') { + try { + return TAG_DEFLATE + toBase64Url(await deflateRaw(bytes)); + } catch { + // Fall through: an unusable compressing stream should not cost the link. + } + } + return TAG_PLAIN + toBase64Url(bytes); +} + +/** + * The inverse of `encodeState`. Never throws: a bad tag, bad base64, a failed + * inflate or JSON that is not a plain object all come back as `null`. + * + * @param {string} text the fragment payload + * @returns {Promise<{ state: object } | null>} + */ +export async function decodeState(text) { + try { + if (typeof text !== 'string' || text.length < 2) return null; + + const tag = text[0]; + if (tag !== TAG_DEFLATE && tag !== TAG_PLAIN) return null; + + const body = text.slice(1); + if (!BASE64URL.test(body)) return null; + + let bytes = fromBase64Url(body); + if (tag === TAG_DEFLATE) { + if (typeof DecompressionStream !== 'function') return null; + bytes = await inflateRaw(bytes); + } + + const parsed = JSON.parse(new TextDecoder().decode(bytes)); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return { state: parsed }; + } catch { + return null; + } +} diff --git a/public/bluebey-studio/src/zip.js b/public/bluebey-studio/src/zip.js new file mode 100644 index 0000000..21a1955 --- /dev/null +++ b/public/bluebey-studio/src/zip.js @@ -0,0 +1,218 @@ +/** + * Store-only ZIP writer. + * + * The studio exports a whole batch of images at once (one PNG per comic panel + * or sticker), and a single download is far nicer to handle than a dozen files, + * so the batch is packed into one archive here. + * + * PNG payloads are already DEFLATE-compressed, so running deflate over them a + * second time costs time and saves nothing measurable: every entry is stored + * verbatim (compression method 0, `compressedSize === uncompressedSize`) and no + * data descriptor is needed. + * + * The module has no imports, never touches the DOM beyond `Blob`, and stamps + * entries with a fixed DOS date unless one is passed in, so the same input + * always produces a byte-for-byte identical archive. That keeps the output safe + * to cache, diff, hash and test. + * + * Archive layout (every integer little-endian, no extra fields, no comments, no + * directory entries): + * + * [local file header + stored data] one per file + * [central directory entry] one per file + * [end of central directory record] + */ + +/** Signature of a local file header ("PK\x03\x04"). */ +const LOCAL_SIGNATURE = 0x04034b50; +/** Signature of a central directory entry ("PK\x01\x02"). */ +const CENTRAL_SIGNATURE = 0x02014b50; +/** Signature of the end of central directory record ("PK\x05\x06"). */ +const EOCD_SIGNATURE = 0x06054b50; + +/** ZIP 2.0 is the oldest version that covers everything this writer emits. */ +const VERSION_NEEDED = 20; +/** General purpose bit 11: the entry name is UTF-8, not CP437. */ +const FLAG_UTF8 = 0x0800; +/** Compression method 0: stored. */ +const METHOD_STORE = 0; + +/** Largest value a 32-bit ZIP field can hold. Nothing may reach 4 GiB. */ +const MAX_FIELD = 0xffffffff; +/** Largest value a 16-bit ZIP field can hold (entry count, name length). */ +const MAX_SHORT = 0xffff; + +const LOCAL_HEADER_SIZE = 30; +const CENTRAL_HEADER_SIZE = 46; +const EOCD_SIZE = 22; + +/** 1980-01-01 00:00 in DOS form: year offset 0, month 1, day 1, midnight. */ +const DEFAULT_TIME = 0; +const DEFAULT_DATE = (1 << 5) | 1; + +/** + * CRC-32 (polynomial 0xEDB88320, reflected), the checksum every ZIP entry must + * carry. The table is built once at module load; eight table-driven bits per + * byte is fast enough that a several-megabyte PNG batch stays imperceptible. + */ +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let bit = 0; bit < 8; bit++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[i] = c >>> 0; + } + return table; +})(); + +/** + * @param {Uint8Array} bytes + * @returns {number} unsigned 32-bit CRC-32 + */ +export function crc32(bytes) { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +/** Clip a Date into the DOS date/time pair stored in the headers. */ +function toDosDateTime(date) { + const year = date.getFullYear(); + // The DOS epoch starts in 1980; anything older is clamped to the epoch. + if (year < 1980) return { time: DEFAULT_TIME, date: DEFAULT_DATE }; + const time = (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1); + const day = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + return { time: time & MAX_SHORT, date: day & MAX_SHORT }; +} + +/** + * Entry names live inside the archive, where the separator is always "/": a + * backslash (what Windows paths use) would be taken as part of the name, and a + * leading slash would look like an absolute path to some extractors. + */ +function normalizeName(name) { + return String(name ?? '') + .replace(/\\/g, '/') + .replace(/^\/+/, ''); +} + +/** Accept any byte source, plus plain strings meaning UTF-8 text. */ +function toBytes(data, encoder) { + if (typeof data === 'string') return encoder.encode(data); + if (data instanceof Uint8Array) return data; + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + if (data instanceof ArrayBuffer) return new Uint8Array(data); + throw new TypeError('ZIP: file data must be a Uint8Array, an ArrayBuffer or a string'); +} + +function writeU16(view, offset, value) { + view.setUint16(offset, value & MAX_SHORT, true); +} + +function writeU32(view, offset, value) { + view.setUint32(offset, value >>> 0, true); +} + +/** + * Pack `files` into a stored (uncompressed) ZIP archive. + * + * @param {Array<{name: string, data: Uint8Array | string}>} files + * a string `data` is encoded as UTF-8 text + * @param {object} [options] + * @param {Date} [options.date] + * timestamp for every entry; defaults to 1980-01-01 00:00 so that the same + * input always yields the same bytes + * @returns {Blob} an `application/zip` blob + */ +export function createZip(files, { date } = {}) { + const encoder = new TextEncoder(); + const stamp = date ? toDosDateTime(date) : { time: DEFAULT_TIME, date: DEFAULT_DATE }; + + // First pass: normalise the input and measure it, so the output buffer can be + // allocated exactly once instead of being grown and copied. + const entries = []; + let localTotal = 0; + let centralTotal = 0; + + for (const file of files ?? []) { + const nameBytes = encoder.encode(normalizeName(file.name)); + const data = toBytes(file.data, encoder); + + if (data.length > MAX_FIELD) { + throw new Error('ZIP: a file is 4 GiB or larger; zip64 is not supported'); + } + if (nameBytes.length > MAX_SHORT) { + throw new Error('ZIP: a file name is longer than 65535 bytes'); + } + + const length = LOCAL_HEADER_SIZE + nameBytes.length + data.length; + localTotal += length; + centralTotal += CENTRAL_HEADER_SIZE + nameBytes.length; + entries.push({ nameBytes, data, crc: crc32(data), length, offset: 0 }); + } + + if (entries.length > MAX_SHORT) { + throw new Error('ZIP: more than 65535 files; zip64 is not supported'); + } + if (localTotal + centralTotal + EOCD_SIZE > MAX_FIELD) { + throw new Error('ZIP: the archive is 4 GiB or larger; zip64 is not supported'); + } + + const bytes = new Uint8Array(localTotal + centralTotal + EOCD_SIZE); + const view = new DataView(bytes.buffer); + + let offset = 0; + for (const entry of entries) { + entry.offset = offset; + writeU32(view, offset + 0, LOCAL_SIGNATURE); + writeU16(view, offset + 4, VERSION_NEEDED); + writeU16(view, offset + 6, FLAG_UTF8); + writeU16(view, offset + 8, METHOD_STORE); + writeU16(view, offset + 10, stamp.time); + writeU16(view, offset + 12, stamp.date); + writeU32(view, offset + 14, entry.crc); + writeU32(view, offset + 18, entry.data.length); + writeU32(view, offset + 22, entry.data.length); + writeU16(view, offset + 26, entry.nameBytes.length); + writeU16(view, offset + 28, 0); // extra field length + bytes.set(entry.nameBytes, offset + LOCAL_HEADER_SIZE); + bytes.set(entry.data, offset + LOCAL_HEADER_SIZE + entry.nameBytes.length); + offset += entry.length; + } + + const centralOffset = offset; + for (const entry of entries) { + writeU32(view, offset + 0, CENTRAL_SIGNATURE); + writeU16(view, offset + 4, VERSION_NEEDED); // version made by (host 0 = MS-DOS) + writeU16(view, offset + 6, VERSION_NEEDED); + writeU16(view, offset + 8, FLAG_UTF8); + writeU16(view, offset + 10, METHOD_STORE); + writeU16(view, offset + 12, stamp.time); + writeU16(view, offset + 14, stamp.date); + writeU32(view, offset + 16, entry.crc); + writeU32(view, offset + 20, entry.data.length); + writeU32(view, offset + 24, entry.data.length); + writeU16(view, offset + 28, entry.nameBytes.length); + writeU16(view, offset + 30, 0); // extra field length + writeU16(view, offset + 32, 0); // file comment length + writeU16(view, offset + 34, 0); // disk number start + writeU16(view, offset + 36, 0); // internal attributes + writeU32(view, offset + 38, 0); // external attributes + writeU32(view, offset + 42, entry.offset); + bytes.set(entry.nameBytes, offset + CENTRAL_HEADER_SIZE); + offset += CENTRAL_HEADER_SIZE + entry.nameBytes.length; + } + + writeU32(view, offset + 0, EOCD_SIGNATURE); + writeU16(view, offset + 4, 0); // number of this disk + writeU16(view, offset + 6, 0); // disk holding the central directory + writeU16(view, offset + 8, entries.length); + writeU16(view, offset + 10, entries.length); + writeU32(view, offset + 12, offset - centralOffset); + writeU32(view, offset + 16, centralOffset); + writeU16(view, offset + 20, 0); // archive comment length + + return new Blob([bytes], { type: 'application/zip' }); +} |
