aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--bluebey-studio/src/caption.js1321
-rw-r--r--bluebey-studio/src/exporter.js72
-rw-r--r--bluebey-studio/src/face.js92
-rw-r--r--bluebey-studio/src/faceArt.js576
-rw-r--r--bluebey-studio/src/main.js340
-rw-r--r--bluebey-studio/src/mouthFlap.js267
-rw-r--r--bluebey-studio/src/panel.js722
-rw-r--r--bluebey-studio/src/presets.js238
8 files changed, 103 insertions, 3525 deletions
diff --git a/bluebey-studio/src/caption.js b/bluebey-studio/src/caption.js
deleted file mode 100644
index 71759ea..0000000
--- a/bluebey-studio/src/caption.js
+++ /dev/null
@@ -1,1321 +0,0 @@
-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],
-];
-
-/**
- * Punctuation that may hang past the bottom of a vertical column.
- *
- * Japanese line breaking (禁則処理) forbids starting a line with a closing
- * mark, so when one of these would land at the top of a new column it is kept
- * on the previous column and drawn past that column's last cell instead.
- */
-const HANGING_PUNCTUATION = new Set(['、', '。', ',', '.', ',', '.']);
-
-/** Small kana, which sit toward the upper-right of their cell when stacked. */
-const SMALL_KANA = 'ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮ';
-
-/**
- * Characters that may not open a column (行頭禁則).
- *
- * A closing mark such as `、` simply hangs off the previous column; `ー`, small
- * kana and closing brackets are kept company by handing the preceding
- * character over to the next column as well, so a column never starts with
- * something that has nothing to attach to.
- */
-const COLUMN_START_FORBIDDEN = new Set([
- '」', '』', ')', '〕', '】', '〉', '》', '”', '’', '・', 'ー',
- ...SMALL_KANA,
- ...HANGING_PUNCTUATION,
-]);
-
-/** Characters that may not close a column (行末禁則). */
-const COLUMN_END_FORBIDDEN = new Set(['「', '『', '(', '〔', '【', '〈', '《', '“', '‘']);
-
-/**
- * Per-character offset for vertical (縦書き) text, in em.
- *
- * A column draws one glyph per cell, so a few characters need a nudge to read
- * the way a Japanese typesetter would place them: the prolonged sound mark
- * becomes an upright tick, small kana tuck into the upper-right, and the
- * brackets lean toward the ends of the span they enclose. Everything else
- * stays dead-centre. Sharing this table is what keeps the canvas and the SVG
- * export in step.
- */
-const VERTICAL_OFFSETS = new Map();
-for (const ch of SMALL_KANA) VERTICAL_OFFSETS.set(ch, { dx: 0.12, dy: -0.12 });
-// 縦書きの句読点は、横書きの左下ではなく、字枠の右上に置く。
-for (const ch of HANGING_PUNCTUATION) VERTICAL_OFFSETS.set(ch, { dx: 0.68, dy: -0.55 });
-
-/**
- * Characters drawn turned a quarter turn when stacked, so they read the way
- * 縦書き sets them: the prolonged sound mark `ー` becomes an upright bar, and the
- * brackets `「」『』[]()` turn so they open down the column.
- */
-const VERTICAL_ROTATED = new Set(['ー', '~', '〜', '―']);
-const VERTICAL_BRACKETS = new Set([
- '「', '」', '『', '』', '(', ')', '[', ']', '【', '】', '〔', '〕', '〈', '〉', '《', '》',
-]);
-
-/**
- * How far a rotated glyph is nudged down inside its cell, in em. The rotated `ー`
- * is centred otherwise, which leaves it hugging the character above it; the
- * brackets stay centred.
- */
-const VERTICAL_ROTATED_DROP = 0.14;
-
-/**
- * The downward nudge (em) for a glyph that is turned a quarter turn in 縦書き, or
- * `null` when the glyph is drawn the ordinary way.
- */
-function verticalRotation(ch) {
- if (VERTICAL_ROTATED.has(ch)) return VERTICAL_ROTATED_DROP;
- if (VERTICAL_BRACKETS.has(ch)) return 0;
- return null;
-}
-
-/** Offset used for the characters that need no special placement. */
-const NO_OFFSET = Object.freeze({ dx: 0, dy: 0 });
-
-/**
- * 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 vertical = state.vertical === true;
- const block = vertical
- ? layoutVertical(text, {
- fontSize,
- lineHeight,
- maxHeight: resolveColumn(state, outHeight, requested),
- })
- : 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,
- vertical,
- 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';
- // A whisper is the rounded bubble with a broken outline.
- if (bubbleOf(state) === 'whisper' && typeof ctx.setLineDash === 'function') {
- ctx.setLineDash([borderWidth * 3, borderWidth * 2]);
- }
- 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);
- // Vertical text falls back to `<text>`: the outline path lays glyphs out
- // horizontally, and a column of glyphs reads fine as live text.
- if (layout.vertical) usedOutlines = false;
-
- 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"`
- + (bubbleOf(state) === 'whisper'
- ? ` stroke-dasharray="${formatNumber(borderWidth * 3, places)} ${formatNumber(borderWidth * 2, places)}"`
- : '')
- : '';
- 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';
-
- if (layout.vertical) {
- // Columns run right to left; each glyph sits in its own cell. `ー` and its
- // like are turned a quarter turn so they read as vertical strokes.
- for (let i = 0; i < lines.length; i++) {
- const chars = [...lines[i].text];
- const x = left + (lines.length - 1 - i) * step;
- for (let j = 0; j < chars.length; j++) {
- const ch = chars[j];
- const drop = verticalRotation(ch);
- if (drop !== null) {
- ctx.save();
- ctx.translate(x + fontSize / 2, top + j * fontSize + fontSize / 2 + drop * fontSize);
- ctx.rotate(Math.PI / 2);
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.fillText(ch, 0, 0);
- ctx.restore();
- continue;
- }
- const offset = verticalOffsetOf(ch);
- const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize;
- ctx.fillText(ch, x + offset.dx * fontSize, y);
- }
- }
- return;
- }
-
- 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;
-
- if (layout.vertical) {
- // The stacked glyphs share one left-anchored <text>. The rotated ones (`ー`)
- // get a <text> of their own, centred on the cell and turned a quarter turn.
- const base = [
- `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`,
- `font-size="${formatNumber(fontSize, places)}"`,
- state.bold ? 'font-weight="bold"' : '',
- `fill="${escapeAttribute(fill)}"`,
- ].filter(Boolean).join(' ');
- const tspans = [];
- const rotated = [];
- for (let i = 0; i < lines.length; i++) {
- const chars = [...lines[i].text];
- const x = left + (lines.length - 1 - i) * step;
- for (let j = 0; j < chars.length; j++) {
- const ch = chars[j];
- const drop = verticalRotation(ch);
- if (drop !== null) {
- const cx = x + fontSize / 2;
- const cy = top + j * fontSize + fontSize / 2 + drop * fontSize;
- rotated.push(`<text ${base} text-anchor="middle" dy="0.36em"`
- + ` transform="rotate(90 ${formatNumber(cx, places)} ${formatNumber(cy, places)})"`
- + ` x="${formatNumber(cx, places)}" y="${formatNumber(cy, places)}">${escapeText(ch)}</text>`);
- continue;
- }
- const offset = verticalOffsetOf(ch);
- const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize;
- tspans.push(`<tspan x="${formatNumber(x + offset.dx * fontSize, places)}" y="${formatNumber(y, places)}">${escapeText(ch)}</tspan>`);
- }
- }
- const main = tspans.length ? `<text ${base} text-anchor="start">${tspans.join('')}</text>` : '';
- return `${main}${rotated.join('')}`;
- }
-
- 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);
-}
-
-/**
- * Offset of one character inside its vertical cell, in em.
- *
- * Shared by `drawText` and `liveText` so a glyph that needs a nudge lands in
- * the same place on screen and in the export.
- */
-function verticalOffsetOf(ch) {
- return VERTICAL_OFFSETS.get(ch) ?? NO_OFFSET;
-}
-
-/**
- * Vertical (縦書き) layout: each paragraph becomes one column, characters stacked
- * top to bottom, columns running right to left. A paragraph longer than the
- * available height is split into more columns.
- *
- * Closing punctuation hangs: when `、` or `。` (or `,`/`.`) would fall at the top
- * of a new column, it stays on the previous one and is drawn just below that
- * column's last cell instead. Such a hanging character does not count toward
- * the column's length, so it never makes the block a cell taller or adds a
- * column of its own. `lines[i].hang` reports how many characters on that line
- * are hanging.
- *
- * The other 禁則 cases are repaired too: ー, small kana and closing brackets are
- * kept off the top of a column by handing the preceding character down with
- * them (追い出し), and an opening bracket is never left at the bottom of one.
- */
-function layoutVertical(text, { fontSize, lineHeight, maxHeight }) {
- const limit = Number.isFinite(maxHeight) && maxHeight > 0
- ? Math.max(1, Math.floor(maxHeight / fontSize))
- : Infinity;
- const columns = [];
- for (const paragraph of String(text).split(/\r\n|\r|\n/)) {
- const chars = [...paragraph];
- if (chars.length === 0) {
- columns.push({ text: '', count: 0 });
- continue;
- }
- let i = 0;
- while (i < chars.length) {
- // Fill the column cell by cell, then repair its two ends for 禁則処理.
- const cells = [];
- const hanging = [];
- while (i < chars.length && cells.length < limit) {
- cells.push(chars[i]);
- i += 1;
- }
- // 行末禁則: an opening bracket may not close a column, so hand it over to
- // the next one (it will open that column instead).
- while (cells.length > 1 && COLUMN_END_FORBIDDEN.has(cells[cells.length - 1])) {
- cells.pop();
- i -= 1;
- }
- // 行頭禁則 (追い出し): ー, small kana and closing brackets may not open a
- // column; move the preceding character down to keep them company.
- if (i < chars.length && cells.length > 1
- && COLUMN_START_FORBIDDEN.has(chars[i]) && !HANGING_PUNCTUATION.has(chars[i])) {
- cells.pop();
- i -= 1;
- }
- // 行頭禁則 (ぶら下げ): a closing mark that would open the next column
- // stays on this one, drawn just below its last cell.
- while (i < chars.length && HANGING_PUNCTUATION.has(chars[i])) {
- hanging.push(chars[i]);
- i += 1;
- }
- columns.push({ text: cells.join('') + hanging.join(''), count: cells.length });
- }
- }
- const longest = columns.reduce((max, column) => Math.max(max, column.count), 0);
- return {
- lines: columns.map((column) => ({
- text: column.text,
- width: fontSize,
- hang: [...column.text].length - column.count,
- })),
- // The block is exactly as wide as the columns that are drawn: `step` apart,
- // each cell one em wide (not one whole `step`, which would leave a leading
- // of dead space on the right and push the text off-centre).
- width: columns.length === 0
- ? 0
- : (columns.length - 1) * fontSize * lineHeight + fontSize,
- height: longest * fontSize,
- vertical: true,
- };
-}
-
-/** 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 [];
-
- if (shape === 'think') return thinkCommands(rect, state, scale);
-
- 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]));
-}
-
-/**
- * Cloud outline for a thought bubble, plus how far its scallops bulge out.
- *
- * The four sides of the box are replaced by a ring of outward scallops: a
- * rounded rectangle is sampled evenly by arc length and each pair of samples is
- * joined by a half-ellipse bulging away from the centre.
- */
-function thinkEdges(box, scale) {
- const { x, y, w, h } = box;
- const cx = x + w / 2;
- const cy = y + h / 2;
- const ring = roundedRectRing(box, Math.min(w, h) * 0.28);
- const perimeter = ringLength(ring);
- // Fluffier: more scallops, each bulging further, so it reads as a soft cloud.
- const pitch = Math.max(scale * 6, Math.min(w, h) * 0.30);
- const count = Math.max(6, Math.round(perimeter / pitch));
- const points = sampleRing(ring, count);
- const amp = Math.min((perimeter / count) * 0.55, Math.min(w, h) * 0.26);
-
- const edges = [];
- let outer = amp;
- for (let i = 0; i < points.length; i++) {
- const a = points[i];
- const b = points[(i + 1) % points.length];
- const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
- // Bulge perpendicular to the edge, not away from the centre: on a wide box
- // the centre direction points sideways along the top and bottom edges, which
- // is what tilted those scallops. The sign is flipped so it always faces out,
- // which makes every edge scallop the same way the left and right ones do.
- const dx = b.x - a.x;
- const dy = b.y - a.y;
- const length = Math.hypot(dx, dy) || 1;
- let nx = -dy / length;
- let ny = dx / length;
- if (nx * (mid.x - cx) + ny * (mid.y - cy) < 0) {
- nx = -nx;
- ny = -ny;
- }
- // A small, *deterministic* wobble so the scallops are not all identical - the
- // even size read as too regular. Same index, same bubble, every render.
- const scallopAmp = amp * (0.72 + 0.56 * scallopJitter(i));
- if (scallopAmp > outer) outer = scallopAmp;
- edges.push(scallopEdge(a, b, { x: nx, y: ny }, scallopAmp));
- }
- return { edges, amp: outer };
-}
-
-/** A closed cloud, followed by the tail: two or three shrinking puffs. */
-function thinkCommands(box, state, scale) {
- const cloud = thinkEdges(box, scale);
- const commands = edgesToCommands(cloud.edges);
- for (const puff of thoughtTrail(box, state, scale, cloud.amp)) {
- commands.push(...circleCommands(puff.x, puff.y, puff.r));
- }
- return commands;
-}
-
-/**
- * The circles that trail away from a thought bubble instead of a pointed tail.
- *
- * They start just outside the cloud (`outer`) and shrink as they go, ending
- * inside the room `layoutCaption` reserves for the tail.
- */
-function thoughtTrail(box, state, scale, outer) {
- const tail = tailOf(state);
- if (tail === 'none') return [];
- const dir = tailDirection(tail);
- const diagonal = dir.x !== 0 && dir.y !== 0;
- const nx = dir.x * (diagonal ? Math.SQRT1_2 : 1);
- const ny = dir.y * (diagonal ? Math.SQRT1_2 : 1);
- 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,
- };
- // Sized from the font, not the leftover room, so the puffs stay visible at any
- // bubble size; they start just outside the cloud (`outer`) and shrink away.
- const unit = resolveFontSize(state, scale);
- // 80% of the first cut, which read a touch large.
- const r1 = unit * 0.48;
- const r2 = unit * 0.34;
- const r3 = unit * 0.21;
- const gap = unit * 0.5;
- const d1 = outer + r1;
- const d2 = d1 + r1 + gap;
- const d3 = d2 + r2 + gap;
- return [
- { x: anchor.x + nx * d1, y: anchor.y + ny * d1, r: r1 },
- { x: anchor.x + nx * d2, y: anchor.y + ny * d2, r: r2 },
- { x: anchor.x + nx * d3, y: anchor.y + ny * d3, r: r3 },
- ];
-}
-
-/** One outward scallop: a half-ellipse from `from` to `to` bulging along `normal`. */
-function scallopEdge(from, to, normal, amp) {
- const k = (4 / 3) * amp;
- return cubicEdge(
- from,
- { x: from.x + normal.x * k, y: from.y + normal.y * k },
- { x: to.x + normal.x * k, y: to.y + normal.y * k },
- to,
- );
-}
-
-/**
- * A small deterministic wobble in 0..1 for the i-th scallop of a cloud.
- *
- * It is derived from the index alone, so a bubble draws its scallops exactly
- * the same in the live preview, a screenshot, and the SVG export.
- */
-function scallopJitter(i) {
- const x = Math.sin((i + 1) * 12.9898) * 43758.5453;
- return x - Math.floor(x);
-}
-
-/** A circle as `M`/`C`/`Z` commands, so it can join the bubble's one path. */
-function circleCommands(cx, cy, r) {
- const k = r * KAPPA;
- return [
- { type: 'M', x: cx + r, y: cy },
- { type: 'C', x1: cx + r, y1: cy + k, x2: cx + k, y2: cy + r, x: cx, y: cy + r },
- { type: 'C', x1: cx - k, y1: cy + r, x2: cx - r, y2: cy + k, x: cx - r, y: cy },
- { type: 'C', x1: cx - r, y1: cy - k, x2: cx - k, y2: cy - r, x: cx, y: cy - r },
- { type: 'C', x1: cx + k, y1: cy - r, x2: cx + r, y2: cy - k, x: cx + r, y: cy },
- { type: 'Z' },
- ];
-}
-
-/** Points around a rounded rectangle, clockwise from the top-left arc. */
-function roundedRectRing(box, radius) {
- const { x, y, w, h } = box;
- const r = clamp(radius, 0, Math.min(w, h) / 2);
- const points = [];
- const arc = (cx, cy, from, to) => {
- const steps = 6;
- for (let i = 1; i <= steps; i++) {
- const t = from + (to - from) * (i / steps);
- points.push({ x: cx + Math.cos(t) * r, y: cy + Math.sin(t) * r });
- }
- };
-
- points.push({ x: x + r, y });
- points.push({ x: x + w - r, y });
- arc(x + w - r, y + r, -Math.PI / 2, 0);
- points.push({ x: x + w, y: y + h - r });
- arc(x + w - r, y + h - r, 0, Math.PI / 2);
- points.push({ x: x + r, y: y + h });
- arc(x + r, y + h - r, Math.PI / 2, Math.PI);
- points.push({ x, y: y + r });
- arc(x + r, y + r, Math.PI, Math.PI * 1.5);
- return points;
-}
-
-/** Total length of a closed polyline's perimeter. */
-function ringLength(points) {
- let total = 0;
- for (let i = 0; i < points.length; i++) {
- total += distance(points[i], points[(i + 1) % points.length]);
- }
- return total;
-}
-
-/** `count` points spaced evenly by arc length around a closed polyline. */
-function sampleRing(points, count) {
- const n = points.length;
- const total = ringLength(points);
- if (!(total > 0) || !(count > 0)) return [];
- const out = [];
- let seg = 0;
- let start = 0;
- let length = n > 1 ? distance(points[0], points[1 % n]) : 0;
- for (let i = 0; i < count; i++) {
- const target = (total * i) / count;
- while (seg < n - 1 && start + length < target) {
- start += length;
- seg += 1;
- length = distance(points[seg], points[(seg + 1) % n]);
- }
- const t = length > 0 ? clamp((target - start) / length, 0, 1) : 0;
- out.push(lerp(points[seg], points[(seg + 1) % n], t));
- }
- return out;
-}
-
-/**
- * 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 === 'whisper' || value === 'think' || value === 'none') return value;
- return 'round';
-}
-
-function tailOf(caption) {
- const value = caption?.tail;
- return TAIL_VALUES.has(value) ? value : 'left';
-}
-
-const TAIL_VALUES = new Set([
- 'left', 'right', 'top', 'bottom',
- 'topLeft', 'topRight', 'bottomLeft', 'bottomRight',
- 'none',
-]);
-
-/**
- * Which way a tail leaves the box, as a direction where each axis is -1, 0 or
- * 1 (0 for `none` or anything unknown). A diagonal tail carries a sign on both
- * axes, so `bottomLeft` is `{ x: -1, y: 1 }`.
- */
-function tailDirection(tail) {
- switch (tail) {
- case 'left': return { x: -1, y: 0 };
- case 'right': return { x: 1, y: 0 };
- case 'top': return { x: 0, y: -1 };
- case 'bottom': return { x: 0, y: 1 };
- case 'topLeft': return { x: -1, y: -1 };
- case 'topRight': return { x: 1, y: -1 };
- case 'bottomLeft': return { x: -1, y: 1 };
- case 'bottomRight': return { x: 1, y: 1 };
- default: return { x: 0, y: 0 };
- }
-}
-
-function alignOf(caption) {
- const value = caption?.align;
- return value === 'center' || value === 'right' ? value : 'left';
-}
-
-function colorOf(value, fallback) {
- return typeof value === 'string' && value !== '' ? value : fallback;
-}
-
-/** Scale is a multiplier: anything that is not a positive number means 1. */
-function positiveScale(value) {
- return Number.isFinite(value) && value > 0 ? value : 1;
-}
-
-function toFinite(value, fallback) {
- return Number.isFinite(value) ? value : fallback;
-}
-
-function toPositive(value, fallback) {
- return Number.isFinite(value) && value > 0 ? value : fallback;
-}
-
-function toNonNegative(value, fallback) {
- return Number.isFinite(value) && value >= 0 ? value : fallback;
-}
-
-function clamp(value, low, high) {
- if (high < low) return low;
- return Math.min(Math.max(value, low), high);
-}
-
-/** Decimal string for both `ctx.font` and SVG, never `NaN` and never `-0`. */
-function formatNumber(value, places = 2) {
- if (!Number.isFinite(value)) return '0';
- const decimals = Number.isFinite(places) ? Math.max(0, Math.min(8, Math.floor(places))) : 2;
- const factor = Math.pow(10, decimals);
- const rounded = Math.round(value * factor) / factor;
- return Object.is(rounded, -0) ? '0' : String(rounded);
-}
-
-/**
- * Escape character data.
- *
- * Quotes are escaped too even though element content allows them: the caption
- * is user text, and the extra entities cost nothing next to never emitting a
- * document a stricter parser could object to.
- */
-function escapeText(value) {
- return String(value ?? '')
- .replace(/&/g, '&amp;')
- .replace(/</g, '&lt;')
- .replace(/>/g, '&gt;')
- .replace(/"/g, '&quot;')
- .replace(/'/g, '&apos;');
-}
-
-/**
- * Escape an attribute value.
- *
- * Only the double quote matters inside a double-quoted attribute, so single
- * quotes survive and a family stack such as `'Hiragino Maru Gothic ProN',
- * sans-serif` stays readable in the file.
- */
-function escapeAttribute(value) {
- return String(value ?? '')
- .replace(/&/g, '&amp;')
- .replace(/</g, '&lt;')
- .replace(/>/g, '&gt;')
- .replace(/"/g, '&quot;');
-}
-
-function distance(a, b) {
- return Math.hypot(b.x - a.x, b.y - a.y);
-}
-
-function lerp(a, b, t) {
- return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
-}
diff --git a/bluebey-studio/src/exporter.js b/bluebey-studio/src/exporter.js
index b34b6f6..ea9356e 100644
--- a/bluebey-studio/src/exporter.js
+++ b/bluebey-studio/src/exporter.js
@@ -1,7 +1,6 @@
import { traceAlphaContours, contoursToPathData } from './trace.js';
import { roughenContours } from './handDrawn.js';
-import { captionToSvg } from './caption.js';
-import { EYE_LAYOUT, MOUTH_LAYOUT, HAIR_WRAP_LAYOUT } from './faceArt.js';
+import { EYE_LAYOUT, MOUTH_LAYOUT } from './faceArt.js';
/**
* Export helpers.
@@ -67,8 +66,8 @@ export async function capturePNG(view, { scale = 2, background, width, height })
/**
* 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.
+ * backdrop and the 擬音 stamps onto the rendered canvas afterwards, and then has
+ * to re-encode it.
*/
export function canvasToBlob(canvas) {
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
@@ -97,8 +96,6 @@ export function buildSVG(view, {
lineWidth = null,
background = null,
handDrawn = null,
- captions = [],
- captionFont = null,
outlinePixels = 2,
} = {}) {
const previousStyle = styles.style;
@@ -167,27 +164,13 @@ export function buildSVG(view, {
: '';
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>
+</svg>
`,
};
}
@@ -275,53 +258,6 @@ export function buildFaceMap(face, kind) {
return canvas;
}
-/**
- * The 髪の下地 (a base to draw hair on).
- *
- * The hair plate is a cylinder unrolled: `x` is the angle round the head, with
- * the face at the middle of the artwork window and the seam at the back at the
- * two edges, and `y` is the height, top of the head first. The current hair is
- * copied in as a faint reference, and guides mark the window, the front centre
- * and the height the default まえがみ hairline reaches.
- *
- * @param {import('./face.js').Face} face
- * @returns {HTMLCanvasElement|null}
- */
-export function buildHairMap(face) {
- const layout = face.hairLayout;
- const plate = face.hairCanvas;
- if (!layout || !plate) return null;
- // The plate's UVs were rewritten to fill 0..1, so the whole canvas is the
- // texture (unlike the eye/mouth maps, which keep a window inside a wider
- // canvas). Every guide is therefore measured straight against the canvas.
- const canvas = document.createElement('canvas');
- canvas.width = plate.width;
- canvas.height = plate.height;
- const ctx = canvas.getContext('2d');
- ctx.drawImage(plate, 0, 0);
-
- // White paper under the copy, so a redraw starts from an opaque base.
- ctx.globalCompositeOperation = 'destination-over';
- ctx.fillStyle = '#ffffff';
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- ctx.globalCompositeOperation = 'source-over';
-
- ctx.strokeStyle = FACE_MAP_GUIDE;
- ctx.fillStyle = FACE_MAP_GUIDE;
- ctx.lineWidth = 3;
-
- // The front of the head sits at the middle of the artwork window, which is
- // where `drawHeadCoverWrap` puts the face, so this vertical line is the
- // nose-to-backbone plane. The canvas's own left/right edges are the seam at
- // the back of the head.
- const fx = (layout.offsetX + layout.window.width * 0.5) * (canvas.width / layout.width);
- guideLine(ctx, fx, 0, fx, canvas.height);
- // The default hairline (大きさ 1) runs across the whole wrap at this height.
- guideLine(ctx, 0, HAIR_WRAP_LAYOUT.base * canvas.height, canvas.width, HAIR_WRAP_LAYOUT.base * canvas.height);
-
- return canvas;
-}
-
/** One guide segment, kept out of the map builders so the placements stay readable. */
function guideLine(ctx, x0, y0, x1, y1) {
ctx.beginPath();
diff --git a/bluebey-studio/src/face.js b/bluebey-studio/src/face.js
index cec7619..f42b823 100644
--- a/bluebey-studio/src/face.js
+++ b/bluebey-studio/src/face.js
@@ -1,11 +1,5 @@
import * as THREE from 'three';
-import { drawEyes, drawMouth, drawHeadCoverWrap, canvasLayout } from './faceArt.js';
-
-// The hair plate's texture is drawn at this multiple of the plate's natural UV
-// size. The hairline crosses the head over only a fraction of that height, so at
-// 1x its edge comes out visibly jagged; doubling it (with the finer curve
-// subdivision in `drawHeadCoverWrap`) keeps the edge smooth.
-const HAIR_SCALE = 2;
+import { drawEyes, drawMouth, canvasLayout } from './faceArt.js';
/**
* Owns the two procedural textures (eyes and mouth) and keeps them in sync with
@@ -15,8 +9,8 @@ const HAIR_SCALE = 2;
* 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).
+ * clamp-to-edge smear at the border and what gives a moved tear or mouth room to
+ * move (see `canvasLayout` in src/faceArt.js).
*
* Both parts can independently fall back to the original hand-drawn texture
* that ships inside the GLB.
@@ -25,16 +19,16 @@ export class Face {
constructor({ eyeMesh, mouthMesh, hairMesh = null, originals }) {
this.eyeMesh = eyeMesh;
this.mouthMesh = mouthMesh;
- // 頭の模様 can be carried by a plate that wraps right round the head (the
- // `hair-plate`), so hair is visible from behind as well. Older models have no
- // such plate and draw the covering on the front one instead.
+ // The `hair-plate`, if the model has one, is never drawn: ぶるべー has no
+ // hair. Hiding the mesh outright (rather than showing an empty texture) keeps
+ // it from tinting the head on platforms where transparent textures do.
this.hairMesh = hairMesh;
+ if (hairMesh) hairMesh.visible = false;
// Rewrite each plate's UVs so its own range fills 0..1, and work out the
// canvas that keeps the artwork exactly where the 2022 textures put it.
this.eyeLayout = remapPlate(eyeMesh);
this.mouthLayout = remapPlate(mouthMesh);
- this.hairLayout = hairMesh ? remapPlate(hairMesh) : null;
// 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
@@ -49,21 +43,13 @@ export class Face {
};
this.customNames = new Map();
- // Drawings supplied for a beard kind (assets/beards/<shape>.png). Loaded by the
- // app; a kind without one falls back to the built-in drawing (see drawBeard).
- this.beardImages = {};
- // Drawings for the brows (assets/brows/<kind>.png), loaded the same way.
- this.browImages = {};
this.eyeCanvas = makeCanvas(this.eyeLayout);
this.mouthCanvas = makeCanvas(this.mouthLayout);
- this.hairCanvas = this.hairLayout ? makeCanvas(this.hairLayout, HAIR_SCALE) : null;
this.eyeCtx = this.eyeCanvas.getContext('2d');
this.mouthCtx = this.mouthCanvas.getContext('2d');
- this.hairCtx = this.hairCanvas ? this.hairCanvas.getContext('2d') : null;
this.eyeTexture = makeTexture(this.eyeCanvas);
this.mouthTexture = makeTexture(this.mouthCanvas);
- this.hairTexture = this.hairCanvas ? makeTexture(this.hairCanvas) : null;
this.params = null;
this.styleMode = 'paint';
@@ -123,10 +109,6 @@ export class Face {
this.mouthMesh.material.map = mouthMap;
this.mouthMesh.material.needsUpdate = true;
}
- if (this.hairMesh && this.hairMesh.material.map !== this.hairTexture) {
- this.hairMesh.material.map = this.hairTexture;
- this.hairMesh.material.needsUpdate = true;
- }
}
/**
@@ -155,25 +137,6 @@ export class Face {
return 'original';
}
- /**
- * Set the drawings used for the beards, keyed by beard kind (`scotch`, `kaiser`,
- * `apron`, `cat`). Called by the app once the files are loaded; the next redraw
- * picks them up.
- */
- setBeardImages(map) {
- this.beardImages = map ?? {};
- this.dirty = true;
- }
-
- /**
- * Set the drawings used for the brows, keyed by brow kind (`gol`). Called by the
- * app once the files are loaded; the next redraw picks them up.
- */
- setBrowImages(map) {
- this.browImages = map ?? {};
- this.dirty = true;
- }
-
/** `kind` is `'eyes'` or `'mouth'`; `source` is `'parametric'` or a variant key. */
setSource(kind, source) {
if (kind === 'eyes') {
@@ -248,19 +211,6 @@ export class Face {
lidShape: p.eyes.lidShape,
lidTilt: p.eyes.lidTilt,
lashes: p.eyes.lashes,
- 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,
- // When the model has a wrapping hair plate the covering is drawn there
- // instead (below), so it is switched off on the front plate to keep it
- // from being drawn twice.
- headMark: this.hairMesh ? { shape: 'off' } : p.eyes.headMark,
- // ひげ is shared by the whole face as well. (鼻ちょうちん is a real 3D
- // object hung off the nose - see src/main.js - so it is not drawn here.)
- beard: p.eyes.beard,
- beards: this.beardImages,
- brows: this.browImages,
// 眼鏡 / サングラス are shared by both eyes and drawn into this same
// texture, so they travel with the other shared eye fields.
glasses: p.eyes.glasses,
@@ -287,34 +237,6 @@ export class Face {
this.mouthTexture.needsUpdate = true;
}
- if (this.hairMesh) {
- const hairShape = p.eyes.headMark?.shape ?? 'off';
- // Shapes that draw nothing (the retired front-plate ones, or なし). A fully
- // transparent texture still tinted the head on iOS, so the plate is hidden
- // outright when there is no hair rather than left showing an empty texture.
- const hairShown = !(hairShape === 'off' || hairShape === 'none'
- || hairShape === 'split' || hairShape === 'wave' || hairShape === 'side');
- this.hairMesh.visible = hairShown;
- if (hairShown) {
- // `overEyes` decides the draw order: the hair plate sits in front of the
- // eyes (bangs over the face) or behind them.
- this.hairMesh.renderOrder = p.eyes.headMark?.overEyes === false ? 0 : 3;
- // The colour lives on the material (the texture is a white mask), so it
- // follows the same colour management as the body on every platform.
- this.hairMesh.material.color.set(p.eyes.headMark?.color ?? '#8a4fe0');
- const layout = this.hairLayout;
- const ctx = this.hairCtx;
- ctx.setTransform(1, 0, 0, 1, 0, 0);
- ctx.clearRect(0, 0, layout.width * HAIR_SCALE, layout.height * HAIR_SCALE);
- ctx.scale(HAIR_SCALE, HAIR_SCALE);
- // `drawHeadCoverWrap` works in canvas coordinates (its x axis is the UV
- // angle already normalised), so it is not shifted by the window offset.
- drawHeadCoverWrap(ctx, p.eyes.headMark ?? {}, layout);
- ctx.setTransform(1, 0, 0, 1, 0, 0);
- this.hairTexture.needsUpdate = true;
- }
- }
-
this.dirty = false;
}
}
diff --git a/bluebey-studio/src/faceArt.js b/bluebey-studio/src/faceArt.js
index 8537fd8..4aaa2d6 100644
--- a/bluebey-studio/src/faceArt.js
+++ b/bluebey-studio/src/faceArt.js
@@ -98,16 +98,6 @@ export const EYE_LAYOUT = {
],
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 },
@@ -137,90 +127,13 @@ export const EYE_LAYOUT = {
irisInward: 7,
};
-/**
- * ほっぺ (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.55, // half-height, as a fraction of the half-width (a flat ellipse)
- // The blush is *always* four strokes - the reference artwork has four - so
- // there is no line-count control any more. The strokes are horizontal and
- // hand-drawn: each wobbles a little and tapers to a point at both ends.
- lines: 4,
- lineWidth: 10, // the stroke thickness at its widest (artwork px)
- spread: 0.62, // how far the outermost stroke sits from the centre (x half-height)
- margin: 0.18, // clear gap left between a stroke's end and the patch outline
- wobble: 0.32, // sideways wander, as a fraction of the stroke thickness
-};
-
-/**
- * 頭の模様: a *hair-like covering* over the whole head, not a small forehead symbol.
- *
- * The face plate is a shell of the *whole* front half of the body, so the plate's
- * own edge is the head's front silhouette. Filling the plate from its top edge
- * down to an adjustable boundary therefore makes the covering's outline follow
- * the silhouette for free - there is no separate ellipse to keep in step.
- *
- * It is a generic device: a colour cap, a fringe, a side part, or a left/right
- * two-tone split. It is deliberately *not* a copy of any one character's hair.
- * It starts off.
- */
-export const HEAD_MARK_LAYOUT = {
- // Where the boundary sits by default: on the forehead, just above the brows
- // (EYE_LAYOUT.eyes[0].cy is the eye row and the brows reach ~114px above it).
- cy: 120,
- reach: 190, // artwork px the boundary moves per unit of `size`
-};
-
-/**
- * 頭の模様 on the *hair plate*: the head's cover when the model carries a plate
- * that wraps all the way round (material `hair-plate`, cylindrical UV).
- *
- * The front plate only reaches the head's silhouette, so hair drawn on it stops
- * at the front half. The hair plate's UV is `u` = angle round the head (front at
- * 0.5, back at the seam 0/1) and `v` = height (0 at the model's feet, 1 at the
- * top of the head), so the covering is everything *above* a boundary line: a cap
- * that is visible from behind as well.
- */
-export const HAIR_WRAP_LAYOUT = {
- // The wrap's v runs top-of-head (0) to the model's feet (1) after the GLB's
- // V-flip, so `base` is how far down from the top the hairline sits at size 1.
- // `reach` is how much a unit of `size` moves it; `offsetScale` converts the
- // `offsetY` slider (artwork px) to the same units, so one slider drives both.
- base: 0.30,
- reach: 0.30,
- offsetScale: 0.30 / 190,
-};
-
-/**
- * ひげ: the mustaches and beards that hang under the nose. Drawn on the eye plate
- * at the nose, the same spot the snot bubble uses. `shape` picks a well-known
- * kind; `size` scales it about the nose, and the colours are adjustable.
- */
-export const BEARD_LAYOUT = {
- cx: 511.5,
- cy: 352, // just under the nose
- width: 240, // base width of the widest beard at size 1
-};
/**
* 眼鏡 / サングラス, 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.
+ * covers.
*
* The two kinds get separate shapes on purpose. They used to share one ellipse
* and differ only in how dark the lens was filled, which made them hard to tell
@@ -679,489 +592,10 @@ function drawShutEye(ctx, eye, spec, style, shapeScale = 1, maxHalf = Infinity)
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));
-
- // A supplied drawing (assets/brows/gol-right.png, the model's right brow only)
- // replaces the strokes: like the textured ひげ it is drawn twice, mirrored, at
- // `cx`, and tinted with the brow colour.
- const entry = style.brows?.gol;
- if (brow.image && entry?.image) {
- const sprite = tintedSprite(entry.image, brow.color ?? '#2a1e33');
- const sw = sprite.width || 1;
- const sh = sprite.height || 1;
- const drawW = Math.max(4, length);
- const drawH = drawW * (sh / sw);
- ctx.save();
- ctx.translate(cx, cy);
- // The drawing is the model's right brow, so the other eye gets it mirrored.
- ctx.scale(eye.towardNose >= 0 ? 1 : -1, 1);
- ctx.drawImage(sprite, -drawW / 2, -drawH / 2, drawW, drawH);
- ctx.restore();
- return;
- }
- 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 a wobbly stroke keeps a clean edge.
- ctx.clip();
-
- // Four horizontal strokes, hand-drawn rather than ruled: each tapers to a
- // point at both ends and wanders a little. Each one stops short of the patch
- // outline - the margin is measured against the patch's own width at that
- // height, so a stroke on the narrow top or bottom cannot reach the edge.
- const thickness = Math.max(1.5, CHEEK_LAYOUT.lineWidth * size);
- ctx.fillStyle = spec.hatchColor ?? '#e0708f';
- for (let i = 0; i < CHEEK_LAYOUT.lines; i += 1) {
- const t = CHEEK_LAYOUT.lines === 1 ? 0 : (i / (CHEEK_LAYOUT.lines - 1)) * 2 - 1; // -1..1
- const dy = t * ry * CHEEK_LAYOUT.spread;
- const chord = r * Math.sqrt(Math.max(0, 1 - (dy / ry) ** 2));
- const half = chord - r * CHEEK_LAYOUT.margin;
- if (half < 4) continue;
- traceHandHatch(ctx, cx, cy + dy, half, thickness, i * 1.9);
- }
- ctx.restore();
-}
-
-/**
- * One hand-drawn hatch stroke: a lens-shaped mark that tapers to a point at both
- * ends, with a gentle wobble so it reads as drawn rather than printed.
- */
-function traceHandHatch(ctx, cx, cy, half, thickness, seed) {
- const steps = 12;
- const top = [];
- const bottom = [];
- for (let i = 0; i <= steps; i += 1) {
- const u = i / steps;
- const x = cx + (u - 0.5) * 2 * half;
- const wobble = Math.sin(u * Math.PI * 1.6 + seed) * thickness * CHEEK_LAYOUT.wobble;
- const w = (thickness / 2) * Math.sin(u * Math.PI); // 0 at both ends
- top.push({ x, y: cy + wobble - w });
- bottom.push({ x, y: cy + wobble + w });
- }
- ctx.beginPath();
- ctx.moveTo(top[0].x, top[0].y);
- for (const point of top.slice(1)) ctx.lineTo(point.x, point.y);
- for (let i = bottom.length - 1; i >= 0; i -= 1) ctx.lineTo(bottom[i].x, bottom[i].y);
- ctx.closePath();
- ctx.fill();
-}
-
-/** 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);
- }
-}
-
-/**
- * 頭の模様: a hair-like covering over the whole head.
- *
- * The face plate is a shell of the front half of the body, so its own edge is
- * the head's silhouette. This fills the plate from its top edge down to an
- * adjustable boundary, which is what makes the covering follow the head outline
- * without knowing the head's shape ahead of time. The boundary style, colour and
- * size are all adjustable, and one style (`split`) divides the head into two
- * colours along a vertical curve - a generic "two-tone head" device. It is not a
- * copy of any particular character's hair.
- */
-function drawHeadCover(ctx, spec, limits) {
- const shape = spec?.shape ?? 'off';
- if (shape === 'off' || shape === 'none' || shape === 'wave' || shape === 'side') return;
-
- const size = clamp(spec.size ?? 1, 0.02, 3);
- const left = limits.left;
- const right = limits.right;
- const top = limits.top;
- const bottom = limits.bottom;
- const width = right - left;
- const cx = (left + right) / 2 + clamp(spec.offsetX ?? 0, -600, 600);
- const color = spec.color ?? '#8a4fe0';
- const color2 = spec.color2 ?? '#ffffff';
-
- ctx.save();
- // Stay on the plate: nothing may spill past the artwork's real edges.
- ctx.beginPath();
- ctx.rect(left, top, width, bottom - top);
- ctx.clip();
-
- if (shape === 'split') {
- // Retired: the two-tone head was dropped, but an old saved state may still
- // carry the shape, so treat it as off rather than drawing a two-tone head.
- ctx.restore();
- return;
- }
-
- // The boundary curve. `t` runs -1..1 across the plate, so each style is a
- // small shape function; the covering is everything above it.
- const base = HEAD_MARK_LAYOUT.cy + (size - 1) * HEAD_MARK_LAYOUT.reach
- + clamp(spec.offsetY ?? 0, -600, 600);
- const halfSpan = width / 2;
- const boundary = (x) => {
- const t = clamp((x - cx) / halfSpan, -1, 1);
- // ぎざぎざ: a jagged, saw-toothed hairline. A triangle wave across the front
- // gives the teeth; `teeth` is how far they reach down (its own slider), so
- // the 大きさ slider only moves the hairline up and down.
- if (shape === 'fringe') {
- const teeth = 8;
- const saw = Math.abs((((t * teeth) % 2) + 2) % 2 - 1); // 0..1 triangle
- return base + (spec.teeth ?? 0.12) * 600 * saw;
- }
- // はちわれ: the same V upside down - a widow's peak.
- if (shape === 'fringe-up') return base - 320 * Math.max(0, 1 - Math.abs(t) * 1.5);
- // カーブ: a plain shallow curve, higher (more hair) round the sides.
- if (shape === 'curve') return base + 160 * t * t;
- // `cap`: a rounded helmet, lowest in the middle.
- return base - 100 * (1 - t * t);
- };
-
- const steps = 48;
- ctx.beginPath();
- ctx.moveTo(left, top);
- ctx.lineTo(right, top);
- for (let i = steps; i >= 0; i -= 1) {
- const x = left + (width * i) / steps;
- ctx.lineTo(x, boundary(x));
- }
- ctx.closePath();
- ctx.fillStyle = color;
- ctx.fill();
- ctx.restore();
-}
-
-/**
- * 頭の模様 drawn onto the hair plate, which wraps right round the head.
- *
- * The plate's canvas is the cylindrical UV unrolled: `x` is the angle (front at
- * the middle, the seam at the back at both ends) and `y` is the height. The GLB
- * flips V on export, so the top of the head lands at the top of the canvas and
- * the covering is everything above the boundary line - the same direction as
- * `drawHeadCover`, which fills down from the top edge of the front plate.
- *
- * The same `shape` names are reused, with their front-to-back modulation tied to
- * how far the column leans towards the front (`t`).
- */
-export function drawHeadCoverWrap(ctx, spec, layout) {
- const shape = spec?.shape ?? 'off';
- // `split`, `wave` and `side` are retired front-plate shapes; they have no wrap
- // equal. An old saved state may still carry one, so it simply draws no hair.
- if (shape === 'off' || shape === 'none' || shape === 'split' || shape === 'wave' || shape === 'side') return;
- const size = clamp(spec.size ?? 1, 0.02, 3);
- const W = layout.width;
- const H = layout.height;
- // The horizontal axis is the angle. The *front* sits at u = 0.5, which is the
- // middle of the artwork window (1024), not the middle of the canvas: after the
- // seam heal the canvas is wider than the window, so using W/2 put the pattern
- // a good way off centre.
- const span = (layout.window ?? ART_WINDOW).width;
- // After the GLB's V-flip the wrap's v is 0 at the top of the head and 1 at the
- // model's feet, so a *larger* boundary means more hair (it reaches further
- // down) - the same direction as `size` and `offsetY` on the front plate.
- const base = HAIR_WRAP_LAYOUT.base
- + (size - 1) * HAIR_WRAP_LAYOUT.reach
- + clamp(spec.offsetY ?? 0, -600, 600) * HAIR_WRAP_LAYOUT.offsetScale;
- ctx.save();
- ctx.beginPath();
- ctx.rect(0, 0, W, H);
- ctx.clip();
- // `t` is -1..1 across the front (+-90 deg around the front); everything behind
- // is pinned to +-1, so the hairline runs flat round the back of the head.
- const half = span * 0.25;
- const cx = layout.offsetX + span * 0.5 + clamp(spec.offsetX ?? 0, -600, 600);
- const vBound = (x) => {
- const t = clamp((x - cx) / half, -1, 1);
- // まえがみ: 富士額 / M字 (a widow's peak). The hairline comes down at the
- // centre (the peak) and at the temples, with the corners between receded -
- // the three peaks and two valleys of an "M". `t` is -1..1 across the front.
- if (shape === 'fringe') {
- // ぎざぎざ: a jagged, saw-toothed hairline. A triangle wave across the front
- // gives the teeth; `teeth` is how far they reach down (its own slider), so
- // the 大きさ slider only moves the hairline up and down.
- const teeth = 8;
- const saw = Math.abs((((t * teeth) % 2) + 2) % 2 - 1); // 0..1 triangle
- return base + (spec.teeth ?? 0.12) * saw;
- }
- // はちわれ: the same V upside down - a widow's peak.
- if (shape === 'fringe-up') return base - 0.18 * Math.max(0, 1 - Math.abs(t) * 1.5);
- // カーブ: a deep round cover - low at the sides, high in the middle, like the
- // blue of a certain robot cat's head.
- if (shape === 'curve') return base + 0.42 * t * t;
- return base - 0.07 * (1 - t * t); // `cap`
- };
- const steps = Math.max(96, Math.ceil(W / 2));
- ctx.beginPath();
- ctx.moveTo(0, 0);
- for (let i = 0; i <= steps; i += 1) {
- const x = (W * i) / steps;
- ctx.lineTo(x, clamp(vBound(x), 0, 1) * H);
- }
- ctx.lineTo(W, 0);
- ctx.closePath();
- // A white mask: the colour is applied by the hair plate's material, so it goes
- // through the same colour path as the body instead of being baked into the
- // canvas (which shaded differently on iOS).
- ctx.fillStyle = '#ffffff';
- 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, lineOnly) {
- drawCheeks(ctx, style.cheeks);
- // The head covering fills out to the plate's real edges, so it needs `limits`.
- drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS);
- drawBeard(ctx, style.beard, lineOnly, style.beards);
-}
-
-const BEARD_TINT_IDS = new WeakMap();
-const BEARD_TINT_CACHE = new Map();
-let beardTintSeq = 0;
-
-/**
- * A copy of a beard drawing in the chosen colour.
- *
- * The supplied files are drawn in near-black, so repainting the ink with the
- * beard colour (`source-in` keeps the anti-aliased alpha) is what lets the colour
- * picker work on a drawing. Cached per (sprite, colour).
- */
-function tintedSprite(image, color) {
- let id = BEARD_TINT_IDS.get(image);
- if (!id) {
- beardTintSeq += 1;
- id = beardTintSeq;
- BEARD_TINT_IDS.set(image, id);
- }
- const key = `${id}|${color}`;
- const cached = BEARD_TINT_CACHE.get(key);
- if (cached) return cached;
- const canvas = document.createElement('canvas');
- canvas.width = image.width;
- canvas.height = image.height;
- const ctx = canvas.getContext('2d');
- ctx.drawImage(image, 0, 0);
- ctx.globalCompositeOperation = 'source-in';
- ctx.fillStyle = color;
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- BEARD_TINT_CACHE.set(key, canvas);
- return canvas;
-}
-
-/**
- * ひげ: a mustache or beard under the nose. Each kind is a small shape built on
- * the spot; `scotch` and `cat` are drawn with round-capped strokes, and `kaiser`
- * is a thick waving band with a curl at each tip. A kind that ships a drawing in
- * `assets/beards/` is drawn from that instead (see `textures`), tinted with the
- * beard colour. In a line drawing nothing is filled and only the outlines are inked.
- */
-function drawBeard(ctx, spec, lineOnly = false, textures = null) {
- const shape = spec?.shape ?? 'off';
- if (shape === 'off' || shape === 'none') return;
- const size = clamp(spec.size ?? 1, 0.2, 3);
- const cx = BEARD_LAYOUT.cx;
- const cy = BEARD_LAYOUT.cy + clamp(spec.offsetY ?? 0, -600, 600);
- const w = BEARD_LAYOUT.width * size;
- const color = spec.color ?? '#3a2a4a';
-
- // A supplied drawing for this kind (assets/beards/<shape>.png) replaces the
- // built-in strokes. The drawings are trimmed to their ink, so `w` is the
- // beard's real width; it is centred on the nose and scaled by `size`, and it is
- // tinted with the beard colour (the files are drawn in near-black).
- const entry = textures?.[shape];
- if (entry?.image) {
- const sprite = tintedSprite(entry.image, color);
- // Slightly wider than the built-in base: a moustache drawn to the same width
- // as a beard's stroke sat mostly behind the nose.
- const drawW = w * 1.4;
- if (entry.half) {
- // One side, mirrored to make the pair. The drawing is the model's own right
- // side, which sits in the artwork's left half; `spacing` opens the middle
- // (a negative value brings the two halves together).
- const halfW = drawW / 2;
- const dh = (halfW * (sprite.height || 1)) / (sprite.width || 1);
- const gap = clamp(spec.spacing ?? 0, -400, 800);
- const y = cy - dh / 2;
- ctx.drawImage(sprite, cx - gap - halfW, y, halfW, dh);
- ctx.save();
- ctx.translate(cx + gap, cy);
- ctx.scale(-1, 1);
- // Draw into [-halfW, 0] so that, mirrored, it lands in [cx+gap, cx+gap+halfW].
- ctx.drawImage(sprite, -halfW, -dh / 2, halfW, dh);
- ctx.restore();
- return;
- }
- const dh = (drawW * (sprite.height || 1)) / (sprite.width || 1);
- ctx.drawImage(sprite, cx - drawW / 2, cy - dh / 2, drawW, dh);
- return;
- }
-
- const line = spec.line ?? '#55386e';
- const lw = Math.max(2, 9 * size);
- const stroke = (points, width) => strokePolyline(ctx, points, {
- color: lineOnly ? line : color, width,
- });
-
- if (shape === 'scotch') {
- // ちょび髭: two short dashes under the nose, angled down and out so their tips
- // clear the nose mesh (which hides anything drawn straight behind it).
- stroke([{ x: cx - w * 0.07, y: cy + w * 0.02 }, { x: cx - w * 0.5, y: cy + w * 0.17 }], lw * 1.8);
- stroke([{ x: cx + w * 0.07, y: cy + w * 0.02 }, { x: cx + w * 0.5, y: cy + w * 0.17 }], lw * 1.8);
- return;
- }
- if (shape === 'kaiser') {
- // カイゼル: a full, thick mustache whose ends turn up.
- stroke([
- { x: cx - w * 0.5, y: cy - w * 0.02 },
- { x: cx - w * 0.28, y: cy + w * 0.07 },
- { x: cx, y: cy + w * 0.09 },
- { x: cx + w * 0.28, y: cy + w * 0.07 },
- { x: cx + w * 0.5, y: cy - w * 0.02 },
- ], lw * 2.6);
- const curlAt = (dir) => {
- // A little inward-turning spiral at the tip: the Dalí curl.
- const ex = cx + dir * w * 0.5;
- const ey = cy - w * 0.02;
- const r0 = lw * 0.5;
- const r1 = w * 0.17;
- const steps = 22;
- const points = [];
- for (let i = 0; i <= steps; i += 1) {
- const t = i / steps;
- const a = -Math.PI / 2 + dir * Math.PI * 2 * 1.4 * t;
- const r = r0 + (r1 - r0) * t;
- points.push({ x: ex + dir * Math.cos(a) * r, y: ey + Math.sin(a) * r });
- }
- stroke(points, lw * 1.25);
- };
- curlAt(-1);
- curlAt(1);
- return;
- }
- if (shape === 'apron') {
- // A filled bib was once a kind here; kept as a no-op so a saved state that
- // still names it simply shows nothing rather than throwing.
- return;
- }
- // ねこ: three whiskers a side, growing out of the cheeks with a gap left in the
- // middle (like a cat's). The thickness is fixed, so `size` makes the whiskers
- // longer and wider-spread without making them fatter, and `spacing` opens the
- // gap between the left and right.
- const whisker = 8;
- const spacing = clamp(spec.spacing ?? 0, 0, 800);
- const inner = w * 0.30 + spacing;
- // `length` scales how far each whisker reaches out; `lineGap` opens the spacing
- // between the three lines of one side. Neither changes the line's thickness.
- const length = clamp(spec.length ?? 1, 0.2, 3);
- const gap = w * 0.16 * clamp(spec.lineGap ?? 1, 0.2, 3);
- const outer = inner + w * 0.38 * length;
- for (const dir of [-1, 1]) {
- for (let i = 0; i < 3; i += 1) {
- stroke([
- { x: cx + dir * inner, y: cy + (i - 1) * gap },
- { x: cx + dir * outer, y: cy + (i - 1) * gap * 1.5 },
- ], whisker);
- }
- }
-}
/**
* One lens of a pair of 眼鏡 / サングラス, plus this eye's half of the bridge and its
@@ -1382,14 +816,10 @@ export function drawEyes(ctx, p) {
const lensOpaque = style.glasses?.enabled === true
&& clamp(style.glasses.lensOpacity ?? 0, 0, 1) >= 0.99;
- // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here
- // rather than inside the per-eye loop below. 鼻ちょうちん comes along too.
- drawFaceExtras(ctx, style, line);
-
for (const layout of EYE_LAYOUT.eyes) {
const spec = (p.eyes ?? {})[layout.key] ?? {};
const eye = {
- // `eyeX` slides this eye - and its lid, brow and tear - sideways on its own.
+ // `eyeX` slides this eye - and its lid and tear - sideways on its own.
cx: layout.cx + clamp(spec.eyeX ?? 0, -400, 400),
cy: layout.cy,
r: EYE_LAYOUT.radius,
@@ -1436,8 +866,6 @@ export function drawEyes(ctx, p) {
// scaled back inside it; `shapeHalfLimit` is the half-size it may reach.
const shapeHalfLimit = showWhite ? eye.r * 0.92 : Infinity;
- 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
diff --git a/bluebey-studio/src/main.js b/bluebey-studio/src/main.js
index 20c6c7d..f861ade 100644
--- a/bluebey-studio/src/main.js
+++ b/bluebey-studio/src/main.js
@@ -6,7 +6,7 @@ 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 { defaultState, applyPatch } from './presets.js';
import { buildPanel } from './panel.js';
import * as exporter from './exporter.js';
import { toast } from './ui.js';
@@ -15,12 +15,10 @@ import { createLook } from './look.js';
import { createClipper } from './clip.js';
import { buildHat } from './hats.js';
import { createBackdrop, backdropStyle } from './background.js';
-import { drawCaption, layoutCaption } from './caption.js';
-import { loadFont, captionFontStack } from './textOutlines.js';
+import { captionFontStack } from './textOutlines.js';
import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js';
import { rollAll, decodeSeed } from './gacha.js';
import { encodeState, decodeState } from './urlState.js';
-import { createMouthFlap, levelToMouth } from './mouthFlap.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';
@@ -29,16 +27,13 @@ const DEG = Math.PI / 180;
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: '擬音',
+ all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', gion: '擬音',
};
/**
@@ -55,10 +50,6 @@ function assetUrl(file, kind) {
const beards = globalThis.__BLUEBEY_BEARDS__ ?? {};
return beards[file] ?? `assets/beards/${file}`;
}
- if (kind === 'brow') {
- const brows = globalThis.__BLUEBEY_BROWS__ ?? {};
- return brows[file] ?? `assets/brows/${file}`;
- }
return inlined[file] ?? `assets/fonts/${file}`;
}
@@ -201,15 +192,14 @@ async function init() {
let resizeBurst = 0;
/** Declared early: the viewport can resize before the model has loaded. */
- let captionCanvas = null;
+ let overlayCanvas = 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
+ // `redrawOverlay` runs from the very first `resizeViewport`, so the sheet cache
// and the drag handles have to exist before it does (a `const` cannot be read
- // before it is evaluated). The drawing itself lives further down, next to the
- // caption overlay it is layered under.
+ // before it is evaluated). The drawing itself lives further down.
const gionImages = new Map(); // sheet name -> loaded Image
const gionLoading = new Map(); // sheet name -> in-flight Promise
const gionHandles = new Map(); // stamp id -> transparent drag div
@@ -241,7 +231,7 @@ async function init() {
renderer.setSize(width, height, false);
outline.setSize(width * quality, height * quality);
view.resize();
- redrawCaption();
+ redrawOverlay();
app.needsRender = true;
}
resizeViewport();
@@ -532,60 +522,6 @@ async function init() {
});
const animator = new Animator({ state });
- // Beard drawings supplied as files (`assets/beards/<kind>.png`): a kind that has
- // one is drawn from the file instead of the built-in strokes. A missing file is
- // skipped, so the app works before any are added.
- void (async () => {
- // Each entry: a beard kind and the drawing in `assets/beards/`. A file ending
- // `-right.png` is one side of a moustache; it is drawn twice (mirrored) with a
- // `spacing` gap between them. A kind with no entry uses the built-in strokes.
- const files = [
- { kind: 'scotch', file: 'scotch.png' },
- { kind: 'kaiser', file: 'kaiser-right.png' },
- ];
- const loaded = {};
- await Promise.all(files.map(async ({ kind, file }) => {
- try {
- const response = await fetch(assetUrl(file, 'beard'));
- if (!response.ok) return;
- loaded[kind] = {
- image: await createImageBitmap(await response.blob()),
- half: file.endsWith('-right.png'),
- };
- } catch {
- /* a missing drawing falls back to the built-in strokes */
- }
- }));
- if (Object.keys(loaded).length) {
- face.setBeardImages(loaded);
- app.needsRender = true;
- }
- })();
-
- // Brow drawings supplied as files (`assets/brows/<kind>.png`). A file ending
- // `-right.png` is one side; it is drawn twice (mirrored) at each eye. Missing
- // files are skipped, so the built-in ゴル風 strokes are the fallback.
- void (async () => {
- const files = [{ kind: 'gol', file: 'gol-right.png' }];
- const loaded = {};
- await Promise.all(files.map(async ({ kind, file }) => {
- try {
- const response = await fetch(assetUrl(file, 'brow'));
- if (!response.ok) return;
- loaded[kind] = {
- image: await createImageBitmap(await response.blob()),
- half: file.endsWith('-right.png'),
- };
- } catch {
- /* a missing drawing falls back to the built-in strokes */
- }
- }));
- if (Object.keys(loaded).length) {
- face.setBrowImages(loaded);
- app.needsRender = true;
- }
- })();
-
// ------------------------------------------------------------------- look
const look = createLook({
renderer,
@@ -645,170 +581,39 @@ async function init() {
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 =
+ // ------------------------------------------------------- 擬音 overlay
+ // The 擬音 stamps are drawn into their own 2D canvas laid over the WebGL one.
+ overlayCanvas = document.createElement('canvas');
+ overlayCanvas.id = 'overlay-layer';
+ overlayCanvas.style.cssText =
'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2';
- stage.append(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', 'narration'];
- 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;
- }
+ stage.append(overlayCanvas);
- function redrawCaption() {
- if (!captionCanvas) return;
+ function redrawOverlay() {
+ if (!overlayCanvas) return;
const width = stage.clientWidth || 1280;
const height = stage.clientHeight || 800;
const ratio = Math.min(2, window.devicePixelRatio || 1);
const wantW = Math.round(width * ratio);
const wantH = Math.round(height * ratio);
- if (captionCanvas.width !== wantW || captionCanvas.height !== wantH) {
- captionCanvas.width = wantW;
- captionCanvas.height = wantH;
+ if (overlayCanvas.width !== wantW || overlayCanvas.height !== wantH) {
+ overlayCanvas.width = wantW;
+ overlayCanvas.height = wantH;
}
- const ctx = captionCanvas.getContext('2d');
+ const ctx = overlayCanvas.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`.
+ // A stamp is a crop of a sheet, drawn on the overlay canvas.
+ // The canvas is `pointer-events: none`, so each stamp gets its own transparent
+ // drag box, parked over it by `positionGionHandles`.
/** Load a sheet once, on first use, and redraw when it arrives. */
function ensureGionSheet(name) {
@@ -822,7 +627,7 @@ async function init() {
gionLoading.delete(name);
if (loaded) {
gionImages.set(name, loaded);
- redrawCaption();
+ redrawOverlay();
}
return loaded;
});
@@ -1062,7 +867,6 @@ async function init() {
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) {
@@ -1289,17 +1093,8 @@ async function init() {
return aim;
}
- // -------------------------------------------- history, 口パク, gacha
+ // ---------------------------------------------------- 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.
@@ -1436,8 +1231,7 @@ async function init() {
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; },
+ look, backdrop, history, propRoot, mirrorGroup, container,
get backdropImage() { return backdropImage; },
});
@@ -1526,11 +1320,9 @@ async function init() {
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),
@@ -1761,20 +1553,14 @@ async function init() {
};
};
- // Lip-sync and a look-at target both drive the mouth and the eyes without
- // being part of the saved expression.
+ // A look-at target drives the eyes without being part of the saved
+ // expression; the mouth is used as-is.
const mouth = { ...state.face.mouth };
- 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
+ // Everything shared by both eyes (colours, iris scale, glasses, ...) has
+ // to travel with the per-eye values, because the face renderer
// reads them from here. Spreading `...eyes` is also what makes the face
// redraw when one of them (e.g. `glasses`) changes: the loop compares the
// JSON of this object against the last one it drew.
@@ -1857,7 +1643,7 @@ async function init() {
// after some other change (adding a prop) re-ran this.
applyBackground();
}
- if (scope === 'all' || scope === 'view' || scope === 'caption' || scope === 'gion') redrawCaption();
+ if (scope === 'all' || scope === 'view' || scope === 'gion') redrawOverlay();
// A style change flips the face between paint and ink (`faceMode`), so the
// 'render' scope has to rebuild the face too - otherwise the old drawing
@@ -2091,13 +1877,13 @@ async function init() {
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.
+ // The backdrop and the 擬音 stamps live in DOM layers behind the WebGL
+ // canvas, so an export has to paint them in itself - in the same order as
+ // the screen: backdrop, then stamps.
const ctx = result.canvas.getContext('2d');
- // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps 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.
+ // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps are
+ // drawn at below. The backdrop's tiled fit needs it too, so its tiles come
+ // out the size the preview's CSS paints them.
const base = canvas.clientWidth || 1280;
const outputScale = result.width / Math.max(1, base);
let changed = false;
@@ -2112,19 +1898,6 @@ async function init() {
});
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;
});
@@ -2153,23 +1926,18 @@ async function init() {
// `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 kind === 'hair' ? exporter.buildHairMap(face) : exporter.buildFaceMap(face, kind);
+ return exporter.buildFaceMap(face, kind);
}
async function saveFaceMap(kind) {
const canvas = faceMapCanvas(kind);
if (!canvas) {
- toast('このモデルには髪のプレートがありません');
+ toast('この下地は書き出せませんでした');
return;
}
const blob = await exporter.canvasToBlob(canvas);
- const label = kind === 'eyes' ? '目' : kind === 'mouth' ? '口' : '髪';
+ const label = kind === 'eyes' ? '目' : '口';
exporter.downloadBlob(blob, `bluebey-face-${kind}-${exporter.timestamp()}.png`);
- if (kind === 'hair') {
- toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。`
- + 'この画像に前髪を描いて渡してください');
- return;
- }
toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。`
+ `この画像に描いて「${label}の画像を読み込む(PNG)」で読み込めます`);
}
@@ -2273,14 +2041,6 @@ async function init() {
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));
@@ -2370,18 +2130,6 @@ async function init() {
: '投稿画面を開きました(この環境では画像を自動で貼り付けできません)');
}
- 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();
@@ -2423,15 +2171,13 @@ async function init() {
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 });
+ applyState({}, { scope: 'gion', sync: true });
// Then photograph what was just recorded, so the list shows the shot rather
// than only its name.
@@ -2463,8 +2209,7 @@ async function init() {
/**
* 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.
+ * not obvious.
*/
function drawPanelNumber(ctx, number, x, y, cellW, cellH) {
const radius = Math.max(18, Math.min(64, Math.min(cellW, cellH) * 0.075));
@@ -2505,8 +2250,6 @@ async function init() {
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');
@@ -2530,7 +2273,6 @@ async function init() {
// 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();
// A manga page reads by its panel frames. They are drawn inside each cell so
// the artwork keeps its full size, and are thick enough to survive the 1×
diff --git a/bluebey-studio/src/mouthFlap.js b/bluebey-studio/src/mouthFlap.js
deleted file mode 100644
index 1109398..0000000
--- a/bluebey-studio/src/mouthFlap.js
+++ /dev/null
@@ -1,267 +0,0 @@
-/**
- * 口パク (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/bluebey-studio/src/panel.js b/bluebey-studio/src/panel.js
index 0ee94ac..a6e971d 100644
--- a/bluebey-studio/src/panel.js
+++ b/bluebey-studio/src/panel.js
@@ -1,10 +1,9 @@
import {
h, section as uiSection, subhead, hint, slider, check, segmented, buttons,
- colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast, icon, svgIcon,
+ colorField, xyPad, selectField, controlRow, tabs, details, toast, icon, svgIcon,
} from './ui.js';
import {
- FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS,
- defaultState, applyPatch, BEARD_KIND_DEFAULTS, HEAD_MARK_KIND_DEFAULTS,
+ FACE_PRESETS, POSE_PRESETS, defaultState, applyPatch,
} from './presets.js';
import { STYLE_DEFS } from './styles.js';
import { ENVIRONMENTS } from './look.js';
@@ -26,21 +25,14 @@ const SECTION_ICONS = {
'うごき': 'M8 5l10 7-10 7V5Z',
'プリセット': 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0',
'目': 'M2.5 12s3.6-6 9.5-6 9.5 6 9.5 6-3.6 6-9.5 6-9.5-6-9.5-6ZM12 14.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2Z',
- '眉': 'M4 11q3.2-3.2 7-2M13 9q3.8-1.2 7 2',
'眼鏡・サングラス': 'M8 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM16 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM11.2 13h1.6M4.8 11.7 3 10.3M19.2 11.7 21 10.3',
- '髪': 'M4 14c0-4.8 3.6-7.6 8-7.6s8 2.8 8 7.6M4 14c2.6-1.4 5.3-1.9 8-1.8M20 14c-2.6-1.4-5.3-1.9-8-1.8',
- 'ほっぺ': 'M6.4 14.6a2 1.4 0 1 0 0 .01ZM17.6 14.6a2 1.4 0 1 0 0 .01ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Z',
'鼻ちょうちん': 'M15 4.6a4.6 4.6 0 1 0 0 9.2 4.6 4.6 0 0 0 0-9.2ZM10.6 9.6 4.8 11.4',
- 'ひげ': 'M4.4 11.2c2.6-1.2 5.2-.6 7.6 2 2.4-2.6 5-3.2 7.6-2',
'口': 'M4.6 10c3 4.8 11.8 4.8 14.8 0',
- '口パク(声は出ません)': 'M4 9v6h3l4 3V6L7 9H4ZM15.5 9.5l4 5M19.5 9.5l-4 5',
'見る先': 'M12 3.5v3M12 17.5v3M3.5 12h3M17.5 12h3M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6Z',
'スタイル': 'M4 20l3.4-.8L20 6.6 17.4 4 4.8 16.6 4 20Z',
- '体の色': 'M12 3.4s6 6.2 6 10.1a6 6 0 0 1-12 0C6 9.6 12 3.4 12 3.4Z',
'見た目の調整': 'M4 8h16M4 16h16M9.5 8h.01M15 16h.01',
'背景': 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01',
'舞台': 'M4 20V9l8-5 8 5v11M4 20h16M9.5 20v-5h5v5',
- 'セリフ(フキダシ)': 'M4 5h16v10H9l-4 4v-4H4V5Z',
'画面・光': 'M12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9ZM12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4 5.2 18.8',
'画面の情報': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM12 11v5M12 8h.01',
'画像': 'M4 5h16v14H4zM4 15l5-4 4 3 4-3.5 3 2.5M15.5 9h.01',
@@ -54,15 +46,6 @@ const SECTION_ICONS = {
const section = (parent, title, options) => uiSection(parent, title, { icon: SECTION_ICONS[title], ...options });
/** 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 };
/**
@@ -78,9 +61,8 @@ function confirmImageLicense() {
}
/**
- * 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.
+ * The panel's multi-line field. The 看板 prop's lettering is the only caller, but
+ * the shape (a control row wrapping a textarea) is worth keeping in one place.
*/
function textArea({ label, value = '', rows = 3, onChange }) {
const input = h('textarea', {
@@ -147,18 +129,6 @@ export function buildPanel(app, root) {
const sync = () => { for (const fn of syncers) fn(); };
const syncBones = () => { for (const fn of boneSyncers) fn(); };
- /**
- * Grey out (and disable) a control whose setting has no effect in the current
- * mode - e.g. the brow's angle while the brow is a loaded picture.
- */
- const setRowEnabled = (widget, on) => {
- const row = widget.el.closest?.('.row') ?? widget.el;
- const input = row.querySelector?.('input, select, button');
- if (input) input.disabled = !on;
- row.classList?.toggle('is-off', !on);
- };
-
- /** 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 });
@@ -190,55 +160,25 @@ export function buildPanel(app, root) {
tilt: Math.round(rnd(-12, 12)),
};
};
- const randomBeard = (shape) => {
- const base = BEARD_KIND_DEFAULTS[shape] ?? { size: 1, offsetY: 0, spacing: 0 };
- if (shape === 'off') return { shape };
- return {
- shape,
- ...base,
- size: +((base.size ?? 1) * rnd(0.8, 1.25)).toFixed(2),
- offsetY: Math.round((base.offsetY ?? 0) + rnd(-16, 16)),
- };
- };
- const randomHeadMark = () => {
- const shape = pick(['off', 'cap', 'fringe', 'fringe-up', 'curve']);
- return { shape, ...(HEAD_MARK_KIND_DEFAULTS[shape] ?? {}) };
- };
const randomFacePatch = () => {
- const beardShape = pick(['off', 'scotch', 'kaiser', 'cat']);
return {
eyes: {
left: randomEye(),
right: randomEye(),
- cheeks: { enabled: Math.random() < 0.5 },
// 眼鏡・サングラスも要素に含める(かける/かけない、各種設定)。
glasses: Math.random() < 0.5 ? { enabled: false } : randomGlasses(),
- beard: randomBeard(beardShape),
- headMark: randomHeadMark(),
- brow: {
- enabled: true,
- // Random brows are line drawings, never ゴル風's loaded picture.
- image: false,
- angle: Math.round(rnd(-30, 40)),
- height: Math.round(rnd(-10, 50)),
- length: +rnd(0.6, 2.6).toFixed(2),
- thickness: +rnd(1.2, 3.4).toFixed(2),
- curve: +rnd(0, 0.3).toFixed(2),
- spacing: Math.round(rnd(-120, 30)),
- taper: +rnd(0, 1).toFixed(2),
},
- },
- // The mouth takes the height and the tilt too, so the face really changes.
- mouth: {
- smile: +rnd(-1, 1.2).toFixed(2),
- // ときどき丸く開く口(O)にする。
- round: Math.random() < 0.3 ? +rnd(0.35, 1).toFixed(2) : 0,
- open: +rnd(0, 0.5).toFixed(2),
- width: +rnd(0.7, 1.3).toFixed(2),
- thickness: +rnd(0.7, 1.6).toFixed(2),
- tilt: Math.round(rnd(-12, 12)),
- offsetY: Math.round(rnd(-16, 16)),
- },
+ // The mouth takes the height and the tilt too, so the face really changes.
+ mouth: {
+ smile: +rnd(-1, 1.2).toFixed(2),
+ // ときどき丸く開く口(O)にする。
+ round: Math.random() < 0.3 ? +rnd(0.35, 1).toFixed(2) : 0,
+ open: +rnd(0, 0.5).toFixed(2),
+ width: +rnd(0.7, 1.3).toFixed(2),
+ thickness: +rnd(0.7, 1.6).toFixed(2),
+ tilt: Math.round(rnd(-12, 12)),
+ offsetY: Math.round(rnd(-16, 16)),
+ },
};
};
const randomFace = () => replace({ face: randomFacePatch() }, 'face');
@@ -249,10 +189,9 @@ export function buildPanel(app, root) {
return Math.abs(m[0]) <= 45 && Math.abs(m[1]) <= 40 && Math.abs(m[2]) <= 40;
});
const pose = pick(poses.length ? poses : POSE_PRESETS);
- const theme = pick(THEMES);
app.actions.applyPosePreset?.(pose.id);
- app.actions.applyTheme?.(theme.id);
- // The viewer style (実写/フラット/線画) and 左右反転 are deliberately left alone.
+ // The viewer style (実写/フラット/線画), the body colours, and 左右反転 are
+ // deliberately left alone.
replace({ face: randomFacePatch() }, 'face');
sync();
};
@@ -441,18 +380,16 @@ export function buildPanel(app, root) {
let faceSection = section(tabFace, 'プリセット');
// The face tab's sections are created here in the order they should read on
- // screen (プリセット -> 髪 -> 眉 -> 目 -> 見る先 -> ...), but filled further
- // down where the widgets are built: the DOM order is fixed at creation.
+ // screen (プリセット -> 目 -> 見る先 -> 口 -> 眼鏡 -> 鼻ちょうちん -> 帽子),
+ // but filled further down where the widgets are built: the DOM order is fixed
+ // at creation.
const faceTabSections = {
- hair: section(tabFace, '髪'),
- brow: section(tabFace, '眉'),
eye: section(tabFace, '目'),
look: section(tabFace, '見る先'),
+ mouth: section(tabFace, '口'),
glasses: section(tabFace, '眼鏡・サングラス'),
- cheeks: section(tabFace, 'ほっぺ'),
snot: section(tabFace, '鼻ちょうちん'),
- beard: section(tabFace, 'ひげ'),
- mouth: section(tabFace, '口'),
+ hat: section(tabFace, '帽子'),
};
faceSection.add(buttons({
@@ -860,128 +797,9 @@ export function buildPanel(app, root) {
const tearBox = details(faceSection.body, '横位置(左右べつ)');
for (const el of tearWidgets) tearBox.add(el);
- // --- eyebrows (the original character has none; this is extra range)
- faceSection = faceTabSections.brow;
- 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: 4, 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,
- image: true,
- angle: 34,
- height: 33,
- length: 2.82,
- thickness: 3.6,
- curve: 0,
- taper: 0,
- spacing: -59,
- color: '#2a1e33',
- });
- 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). Turning
- // `image` off is what makes the line drawing take over from ゴル風's
- // loaded picture.
- Object.assign(state.face.eyes.brow, {
- enabled: true,
- image: false,
- angle: 10,
- height: 4,
- length: 2.86,
- thickness: 3.29,
- curve: 0.4,
- taper: 1,
- spacing: -50,
- color: '#181220',
- });
- app.actions.refresh('face');
- sync();
- },
- },
- ],
- }));
-
- faceSection.add(hint('「ゴル風(画像)」は読み込んだ画像(assets/brows/gol-right.png)で眉を描きます。'
- + '画像のときは角度・太さ・曲がり・鼻側の細さが効かないので、灰色になります。'
- + '「両風(線の眉)」など線で描く眉に切り替えると、この画像は使われません。'));
-
- // --- glasses / sunglasses (drawn into the same texture as the eyes and brows)
+ // --- glasses / sunglasses (drawn into the same texture as the eyes)
faceSection = faceTabSections.glasses;
- faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。'
+ faceSection.add(hint('眼鏡やサングラスを、目と同じ絵にかけます。'
+ '線画のときはレンズを塗らず、フレームの線だけになります。'));
// 眼鏡とサングラスの違いは、レンズの形だけではない。サングラスはレンズが不透明
@@ -1114,121 +932,6 @@ export function buildPanel(app, root) {
],
}));
- // --- ほっぺ (a manga blush)
- faceSection = faceTabSections.cheeks;
- 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'); },
- }),
- };
- faceSection.add(cheekSliders.size.el);
- const cheekBox = details(faceSection.body, 'こまかい設定(位置・色)');
- cheekBox.add(cheekSliders.offsetY.el);
- cheekBox.add(cheekSliders.spacing.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('線はいつも4本です。「よこの間隔」は左右のほっぺのへだたり、'
- + '「たかさ」は目からの下がり具合です。'));
-
- // --- 髪 (a hair-like covering over the whole head; its own section, above 眉)
- faceSection = faceTabSections.hair;
- faceSection.add(hint('髪を頭にかぶせます。決まったキャラクターの髪ではなく、'
- + 'よくあるマンガの形として用意しました。はじめは出ていません。'));
-
- const headMarkShape = segmented({
- label: '髪',
- options: [
- { value: 'off', label: 'なし' },
- { value: 'cap', label: 'かぶせ' },
- { value: 'fringe', label: 'ぎざぎざ' },
- { value: 'fringe-up', label: 'はちわれ' },
- { value: 'curve', label: 'カーブ' },
- ],
- value: 'off',
- onChange: (value) => {
- const mark = state.face.eyes.headMark;
- mark.shape = value;
- // Entering a shape applies its own starting size (ぎざぎざ is smaller and
- // shallower than the shared defaults). Other shapes just keep the values.
- Object.assign(mark, HEAD_MARK_KIND_DEFAULTS[value] ?? {});
- app.actions.refresh('face');
- sync();
- },
- });
- faceSection.add(headMarkShape.el);
-
- const headMarkOverEyes = check({
- label: '髪を目の前に出す',
- value: true,
- onChange: (value) => { state.face.eyes.headMark.overEyes = value; app.actions.refresh('face'); },
- });
- faceSection.add(controlRow(null, headMarkOverEyes.el, { wide: true }));
-
- const headMarkSliders = {
- size: slider({
- label: '大きさ', min: 0.05, 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'); },
- }),
- teeth: slider({
- label: '歯の深さ', min: 0.02, max: 0.4, step: 0.01, value: 0.12, format: (v) => v.toFixed(2),
- onInput: (v) => { state.face.eyes.headMark.teeth = 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: -300, max: 300, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
- onInput: (v) => { state.face.eyes.headMark.offsetY = v; app.actions.refresh('face'); },
- }),
- };
- faceSection.add(headMarkSliders.size.el);
- faceSection.add(headMarkSliders.teeth.el);
- //「歯の深さ」はぎざぎざのときだけ意味があるので、それ以外では隠します。
- const headMarkBox = details(faceSection.body, 'こまかい設定(位置・色)');
- headMarkBox.add(headMarkSliders.offsetX.el);
- headMarkBox.add(headMarkSliders.offsetY.el);
- const headMarkColor = colorField({
- label: '髪の色', value: '#8a4fe0',
- swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'],
- onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); },
- });
- const headMarkColor2 = colorField({
- label: '2色目', value: '#ffffff',
- swatches: ['#ffffff', '#f3ead9', '#ffe08a', '#7fd8ff'],
- onChange: (v) => { state.face.eyes.headMark.color2 = v; app.actions.refresh('face'); },
- });
- headMarkBox.add(headMarkColor.el);
- headMarkBox.add(headMarkColor2.el);
- headMarkBox.add(hint('「かぶせ」は頭にかぶせる丸い髪、「ぎざぎざ」は生え際がギザギザにとがった髪(「歯の深さ」でギザギザの深さを変えられます)、'
- + '「はちわれ」は中央が鋭く持ち上がった生え際、「カーブ」はドラえもんのように単純にカーブした髪です。'
- + '「たかさ」で生え際を目の中心あたりまで下げられます。'
- + '「髪を目の前に出す」を切ると、髪が目の後ろに回ります。'));
-
// --- 鼻ちょうちん: the snot bubble of a sleeping face.
faceSection = faceTabSections.snot;
const snotToggle = check({
@@ -1268,67 +971,6 @@ export function buildPanel(app, root) {
+ '「ねている」ポーズと組み合わせると、いねむりしているように見えます。'
+ '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。'));
- // --- ひげ: mustaches and beards, hung under the nose.
- faceSection = faceTabSections.beard;
- const beardShape = segmented({
- label: 'ひげ',
- options: [
- { value: 'off', label: 'なし' },
- { value: 'scotch', label: 'ちょびひげ' },
- { value: 'kaiser', label: 'ダリ' },
- { value: 'cat', label: 'ねこ' },
- ],
- value: 'off',
- onChange: (value) => {
- const beard = state.face.eyes.beard;
- beard.shape = value;
- // Each kind starts at its own size and height (a textured kind is drawn at a
- // different scale from the built-in strokes). A kind with no entry resets to
- // the neutral values, so switching kinds cannot leave a stray size behind.
- Object.assign(beard, BEARD_KIND_DEFAULTS[value] ?? { size: 1, offsetY: 0, spacing: 0 });
- app.actions.refresh('face');
- sync();
- },
- });
- faceSection.add(beardShape.el);
- const beardSliders = {
- size: slider({
- label: '大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1, format: (v) => v.toFixed(2),
- onInput: (v) => { state.face.eyes.beard.size = v; app.actions.refresh('face'); },
- }),
- spacing: slider({
- label: '左右の間隔', min: 0, max: 250, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
- // Shown 10 higher than the value stored, so the default (stored -10) reads 0.
- onInput: (v) => { state.face.eyes.beard.spacing = v - 10; app.actions.refresh('face'); },
- }),
- offsetY: slider({
- label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
- onInput: (v) => { state.face.eyes.beard.offsetY = v; app.actions.refresh('face'); },
- }),
- length: slider({
- label: 'ひげの長さ(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
- onInput: (v) => { state.face.eyes.beard.length = v; app.actions.refresh('face'); },
- }),
- lineGap: slider({
- label: '線の間隔(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
- onInput: (v) => { state.face.eyes.beard.lineGap = v; app.actions.refresh('face'); },
- }),
- };
- faceSection.add(beardSliders.size.el);
- const beardBox = details(faceSection.body, 'こまかい設定(位置・色)');
- beardBox.add(beardSliders.spacing.el);
- beardBox.add(beardSliders.offsetY.el);
- beardBox.add(beardSliders.length.el);
- beardBox.add(beardSliders.lineGap.el);
- const beardColor = colorField({
- label: 'ひげの色', value: '#1c1624',
- swatches: ['#1c1624', '#000000', '#3a2a4a', '#6b4a2a', '#8a8a94'],
- onChange: (v) => { state.face.eyes.beard.color = v; app.actions.refresh('face'); },
- });
- beardBox.add(beardColor.el);
- beardBox.add(hint('鼻の下に、ちょびひげ・ダリ・ねこ、の瓢を描きます。'
- + '「左右の間隔」「瓢の長さ」「線の間隔」は「ねこ」で使います。'));
-
// --- mouth
faceSection = faceTabSections.mouth;
@@ -1408,153 +1050,6 @@ export function buildPanel(app, root) {
const mouthColorBox = details(faceSection.body, '色(口の線・中・舌・口角)');
for (const widget of Object.values(mouthColors)) mouthColorBox.add(widget.el);
- /* ---------------------------------------------------------------- caption */
-
- const captionSection = section(tabScene, 'セリフ(フキダシ)');
-
- // 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: 'narration', label: 'ナレーション' },
- ],
- value: 'caption',
- onChange: (value) => { activeCaption = value; sync(); },
- });
- captionSection.add(captionBubbleSegment.el);
- captionSection.add(hint('吹き出しは①と②の2つ、ナレーション枠が1つ出せます。切り替えて、それぞれ別の位置・形・色・縦書きにできます。'));
-
- 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);
-
- const verticalToggle = check({
- label: '縦書きにする',
- value: false,
- onChange: (v) => { cap().vertical = v; app.actions.refresh('caption'); sync(); },
- });
- captionSection.add(controlRow(null, verticalToggle.el, { wide: true }));
-
- 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とした割合です。'
- + 'ビューポートで吹き出しをドラッグしても動かせます。'));
-
- /* -------------------------------------------------------------- 口パク */
-
- // 口パク lives inside うごき on the pose tab (see below): talking is something
- // the character *does*, and it is usually recorded together with the motion.
-
/* ------------------------------------------------------ movement (pose tab) */
// This is the pose tab's うごき section; the widgets sit here because they are
@@ -1607,35 +1102,6 @@ export function buildPanel(app, root) {
moveSection.add(recordButton.el);
moveSection.add(hint('スペースキーで演出の開始・停止。録画はWebM(動画)として保存されます。'));
- // 口パク: the mouth moves as if talking, and no sound is made at all. It lives
- // in うごき because talking is motion - and it is usually recorded with it.
- // (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 = details(moveSection.body, '口パク(声は出ません)');
- 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('音は出ません。セリフの長さぶん口が動いて、自動で止まります。'
- + '「シーン」タブの「セリフ(フキダシ)」の文とは別で、こちらは長さにだけ使います。'));
-
// --- 見る先 (moved here, next to the face controls, from the pose tab) ----
const lookAtSection = faceTabSections.look;
// `lookAt` moves the eyes and the face only, so the face has to be rebuilt too.
@@ -1675,7 +1141,7 @@ export function buildPanel(app, root) {
onChange: (value) => { state.lookAt.turnBody = value; refreshLookAt(); },
});
lookAtSection.add(controlRow(null, turnBodyToggle.el, { wide: true }));
- lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目と髪が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。'));
+ lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。'));
/* ---------------------------------------------------------------- display */
@@ -1745,9 +1211,9 @@ export function buildPanel(app, root) {
}],
}));
- /* ------------------------------------------------------------- 帽子 */
+ /* ------------------------------------------------------------- 帽子 (顔タブ) */
- const hatSection = section(tabView, '帽子');
+ const hatSection = faceTabSections.hat;
hatSection.add(hint('ぶるべーの頭に帽子をかぶせます。「なし」を選ぶと外れます。'));
const hatSegment = segmented({
label: '帽子',
@@ -1817,46 +1283,15 @@ export function buildPanel(app, root) {
hatSliders.tiltZ.set(state.face.hat?.tiltZ ?? 0);
});
- /* ------------------------------------------------------------- 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);
+ /* ---------------------------------------------------------- 環境の映りこみ */
+ // The body colours are fixed (see `render.colors` in presets.js), so the 体の色
+ // section is gone. The strength of the environment reflection is still a look
+ // setting, so it lives in 見た目の調整 below.
const envIntensitySlider = slider({
label: '環境の映りこみ', min: 0, max: 2, step: 0.05, value: 1, format: (v) => v.toFixed(2),
onInput: (v) => { state.render.envIntensity = v; app.actions.refresh('render'); },
});
- colourSection.add(envIntensitySlider.el);
- colourSection.add(buttons({
- items: [{
- id: 'resetColors',
- label: 'プリセットの色に戻す',
- onClick: () => { app.actions.applyTheme(state.render.theme ?? 'original'); sync(); },
- }],
- }));
/* -------------------------------------------------------------------- look */
@@ -1900,6 +1335,9 @@ export function buildPanel(app, root) {
lookSection.add(hint('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。'
+ '「接地影だけ」は足もとの丸い影だけにします。'
+ '「足もとの丸い影」は、足もとに薄く敷く丸い影です(はじめは出ていません)。'));
+ // 環境の映りこみ: the strength of the environment reflection, moved here from
+ // the old 体の色 section (the body colours themselves are fixed now).
+ lookSection.add(envIntensitySlider.el);
const environmentSegment = segmented({
label: '照明の種類',
@@ -2709,7 +2147,7 @@ export function buildPanel(app, root) {
comicSection.add(controlRow(null, storyPanelBox, { wide: true }));
let storyShape = '';
- /** Bring back everything a panel saved: pose, face, both bubbles and the camera. */
+ /** Bring back everything a panel saved: pose, face, 擬音 and the camera. */
const recallStoryPanel = (index) => {
const panel = state.story?.panels?.[index];
if (!panel) return;
@@ -2718,8 +2156,6 @@ export function buildPanel(app, root) {
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: [] },
@@ -2734,7 +2170,7 @@ export function buildPanel(app, root) {
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 });
+ app.actions.applyState({}, { scope: 'all', sync: true });
};
/**
@@ -2813,16 +2249,11 @@ export function buildPanel(app, root) {
items: [
{ id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') },
{ id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') },
- { id: 'facemap-hair', label: '髪の下地を書き出す', onClick: () => app.actions.saveFaceMap('hair') },
],
}));
faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。'
- + 'そのまま絵を描いて(たとえば目にゴルゴ風の眉を描く)「目の画像を読み込む(PNG)」や'
- + '「口の画像を読み込む(PNG)」で読み込むと、描いた場所にそのまま入ります。'
- + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。'
- + '「髪の下地」は、頭をぐるりと巻いたテクスチャを開いたもので、横が頭の角度、'
- + 'たてが高さです。左右の端が頭の後ろ(合わせ目)、青いたて線が顔の正面、'
- + '青いよこ線が既定の生え際です。'));
+ + 'そのまま絵を描いて「目の画像を読み込む(PNG)」や「口の画像を読み込む(PNG)」で読み込むと、'
+ + '描いた場所にそのまま入ります。青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。'));
/* --------------------------------------------------------------- settings */
@@ -2859,13 +2290,12 @@ export function buildPanel(app, root) {
infoSection.add(statsLine);
infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。'));
- // シーン: the scene controls are built wherever they belong in the code (the
- // speech bubble sits with the face controls), so put them on the tab in the
- // order the user meets them: what is behind the character, what is beside it,
- // the speech bubble, then the light and the read-out.
+ // シーン: the scene controls are built wherever they belong in the code, so put
+ // them on the tab in the order the user meets them: what is behind the
+ // character, what is beside it, then the light and the read-out.
tabScene.append(
backdropSection.el, propSection.el, gionSection.el, wallSection.el,
- captionSection.el, screenLightSection.el, infoSection.el,
+ screenLightSection.el, infoSection.el,
);
boneSyncers.push(() => {
@@ -2896,21 +2326,6 @@ export function buildPanel(app, root) {
w.eyeX.set(eye.eyeX ?? 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);
- // A loaded ゴル風 picture ignores the shape sliders, so grey those out rather
- // than leave them looking like they should do something.
- const browIsImage = state.face.eyes.brow.image === true;
- for (const key of ['angle', 'thickness', 'curve', 'taper']) {
- setRowEnabled(browSliders[key], !browIsImage);
- }
- 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]);
@@ -2922,24 +2337,10 @@ export function buildPanel(app, root) {
});
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');
- headMarkSliders.teeth.el.style.display = (state.face.eyes.headMark.shape === 'fringe') ? '' : 'none';
- headMarkOverEyes.set(state.face.eyes.headMark.overEyes !== false);
- for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]);
- headMarkColor.set(state.face.eyes.headMark.color);
- headMarkColor2.set(state.face.eyes.headMark.color2 ?? '#ffffff');
snotToggle.set(state.face.eyes.snot.enabled === true);
snotSize.set(state.face.eyes.snot.size);
for (const [key, widget] of Object.entries(snotSliders)) widget.set(state.face.eyes.snot[key]);
snotColor.set(state.face.eyes.snot.color);
- beardShape.set(state.face.eyes.beard.shape ?? 'off');
- for (const [key, widget] of Object.entries(beardSliders)) widget.set(state.face.eyes.beard[key]);
- // 「左右の間隔」は内部値より+10して表示する(既定の -10 が 0 に見える)。
- beardSliders.spacing.set((state.face.eyes.beard.spacing ?? 0) + 10);
- beardColor.set(state.face.eyes.beard.color);
});
syncers.push(() => {
@@ -2996,10 +2397,6 @@ export function buildPanel(app, root) {
});
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);
});
@@ -3073,35 +2470,6 @@ export function buildPanel(app, root) {
});
syncers.push(() => {
- captionBubbleSegment.set(activeCaption);
- const bubble = cap() ?? {};
- captionToggle.set(bubble.enabled === true);
- captionText.set(bubble.text ?? '');
- verticalToggle.set(bubble.vertical === true);
- 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);
@@ -3121,12 +2489,6 @@ export function buildPanel(app, root) {
return {
sync,
onRigChanged: syncBones,
- /** Which bubble the caption controls are editing (a drag on it calls this). */
- selectCaption(key) {
- if (key !== 'caption' && key !== 'caption2' && key !== 'narration') return;
- activeCaption = key;
- sync();
- },
/** Which 擬音 stamp the viewport should outline (a drag on it calls this). */
selectGion(id) {
app.gionSelected = id ?? null;
diff --git a/bluebey-studio/src/presets.js b/bluebey-studio/src/presets.js
index e4fe49f..a31c952 100644
--- a/bluebey-studio/src/presets.js
+++ b/bluebey-studio/src/presets.js
@@ -11,85 +11,6 @@
*/
/**
- * 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
- vertical: false, // 縦書き (columns run right to left)
- font: 'rounded', // rounded (vendored) | system
- };
-}
-
-/**
- * ナレーション (a narration box): a plain rectangular panel of vertical text, the
- * manga narration style. Same shape as a bubble, with a rect box, no tail, and
- * 縦書き on by default.
- */
-function narrationDefaults() {
- return {
- enabled: false,
- text: 'ここで、ぶるべーは考えた。',
- x: 0.06,
- y: 0.08,
- maxWidth: 0.5,
- bubble: 'rect',
- tail: 'none',
- fontSize: 30,
- lineHeight: 1.35,
- padding: 16,
- radius: 8,
- textColor: '#2b2433',
- bubbleColor: '#ffffff',
- borderColor: '#2b2433',
- borderWidth: 3,
- bold: false,
- align: 'left',
- vertical: true,
- font: 'rounded',
- };
-}
-
-/**
- * Per-kind starting size and height for ひげ, applied when a kind is picked. The
- * textured kinds are drawings, so they are drawn at a different scale from the
- * built-in strokes; a kind with no entry resets to the neutral values.
- */
-export const BEARD_KIND_DEFAULTS = {
- scotch: { size: 0.46, offsetY: 74 },
- kaiser: { size: 1, offsetY: 80, spacing: -10 },
-};
-
-/**
- * Per-shape starting values for 髪 (headMark), applied when a shape is picked.
- * `teeth` only matters for ぎざぎざ, so its default is set here rather than on
- * the shared `size`/`teeth` fields.
- */
-export const HEAD_MARK_KIND_DEFAULTS = {
- fringe: { size: 0.83, teeth: 0.06 },
-};
-
-/**
* 見えない壁 (the invisible wall) settings. The studio offers two, so a corner (or
* two opposite cuts) can be made. A wall's position is kept *relative to the
* character*, so moving ぶるべー carries its walls along (see src/clip.js).
@@ -168,9 +89,9 @@ export function defaultState() {
lightColor: '#ffffff',
ambientColor: '#ffffff',
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',
+ // The fixed body palette. ぶるべー keeps its colours, so there is no theme
+ // picker and no per-part editor; the parts live here so the styles and the
+ // model have one place to read them from.
colors: {
body: '#c8b0f0',
accent: '#8a4fe0',
@@ -262,31 +183,6 @@ export function defaultState() {
// 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 hair-like head covering) are drawn
- // into the same plate as the eyes and brows. Both are generic devices,
- // not a copy of any one character, and both start off. The blush is always
- // four horizontal strokes, so there is no line-count field.
- cheeks: {
- enabled: false,
- size: 1,
- spacing: 0, // extra gap outwards from the eyes
- offsetY: 0, // extra drop below the eyes
- color: '#f6a6b8',
- },
- // The hair covering's `shape` is one of `off`, `cap`, `fringe`,
- // `fringe-up` or `curve`. `overEyes` puts the hair in front of the eyes
- // (bangs over the face); off leaves the eyes on top.
- headMark: {
- shape: 'off',
- size: 1,
- // ぎざぎざ only: how far the saw teeth reach down.
- teeth: 0.12,
- offsetX: 0,
- offsetY: 0,
- overEyes: true,
- color: '#8a4fe0',
- color2: '#ffffff',
- },
// 鼻ちょうちん: the snot bubble someone sleeps with. Drawn into the same
// plate as the eyes, out to the side of the nose. Starts off.
snot: {
@@ -297,42 +193,9 @@ export function defaultState() {
offsetZ: 16,
color: '#e3ecff',
},
- // ひげ: a mustache or beard worn under the nose. `shape` is one of `off`,
- // `scotch`, `kaiser`, `apron` or `cat`. `spacing` widens the gap between
- // the cat's left and right whiskers. Starts off.
- beard: {
- shape: 'off',
- size: 1,
- offsetY: 0,
- spacing: 0,
- // ねこ only: how far each whisker reaches (`length`) and how far apart the
- // three lines of one side sit (`lineGap`). Neither changes the thickness.
- length: 1,
- lineGap: 1,
- color: '#1c1624',
- },
- // Eyebrows are drawn into the eye texture. Off by default, because the
- // original character has no eyebrows.
- brow: {
- enabled: false,
- // When true and a drawing (assets/brows/gol-right.png) was loaded, the
- // brow is painted from that file (mirrored) instead of the strokes.
- image: 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
+ // 眼鏡 / サングラス are drawn into the same texture as the eyes, so they
+ // travel with the head instead of being a separate 3D mesh. Off by
+ // default. `kind` only picks which lens defaults the panel offers; the
// drawing itself reads the colours and opacities below.
glasses: {
enabled: false,
@@ -371,24 +234,6 @@ export function defaultState() {
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 },
- // A ナレーション box (vertical text, rect, no tail).
- narration: narrationDefaults(),
- // -------------------------------------------------------- 口パク (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: {
@@ -418,7 +263,7 @@ export function defaultState() {
items: [],
},
// ------------------------------------------------------------------- story
- // 4コマ / 紙芝居. Each panel is a small state patch (pose, face, caption).
+ // 4コマ / 紙芝居. Each panel is a small state patch (pose, face).
story: {
columns: 1,
gap: 12,
@@ -529,7 +374,6 @@ export const FACE_PRESETS = [
// Blushing, eyes half shut and looking away.
left: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' },
right: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' },
- cheeks: { enabled: true },
irisScale: 1,
highlight: true,
},
@@ -822,71 +666,3 @@ export function applyPatch(target, patch) {
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: 'whisper', label: 'ささやき' },
- { value: 'think', 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 } },
-];