diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
| commit | e332019acb312ec64893c26cf3d797d5ce472f26 (patch) | |
| tree | d97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/exporter.js | |
| parent | 99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff) | |
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/exporter.js')
| -rw-r--r-- | public/bluebey-studio/src/exporter.js | 323 |
1 files changed, 323 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/exporter.js b/public/bluebey-studio/src/exporter.js new file mode 100644 index 0000000..147af1c --- /dev/null +++ b/public/bluebey-studio/src/exporter.js @@ -0,0 +1,323 @@ +import { traceAlphaContours, contoursToPathData } from './trace.js'; +import { roughenContours } from './handDrawn.js'; +import { captionToSvg } from './caption.js'; +import { EYE_LAYOUT, MOUTH_LAYOUT } from './faceArt.js'; + +/** + * Export helpers. + * + * Everything renders through the live renderer at a temporary resolution, so an + * export always matches what is on screen (same camera, same framing) and the + * only difference is the pixel size. + */ + +/** Render the current view into an offscreen 2D canvas at an arbitrary size. */ +export function renderStill(view, { width, height, background }) { + const { renderer, scene, camera } = view; + const previousAspect = camera.aspect; + + renderer.setPixelRatio(1); + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + + applyBackground(renderer, background); + // The screen-space outline renders the scene itself, so it replaces the plain + // render rather than following it. (buildSVG passes outlineOptions of its own: + // it wants the ink and nothing else, because the ink is what it traces.) + if (view.outline && view.outlineOptions?.enabled) { + view.outline.setSize(width, height); + view.outline.render(() => renderer.render(scene, camera), { camera, ...view.outlineOptions }); + } else { + renderer.render(scene, camera); + } + + // Copy before restoring: resizing the renderer throws the frame away. + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvas.getContext('2d').drawImage(renderer.domElement, 0, 0); + + camera.aspect = previousAspect; + camera.updateProjectionMatrix(); + view.restore?.(); + + return canvas; +} + +/** `background` is `{ mode: 'transparent' }` or `{ mode: 'solid', color }`. */ +export function applyBackground(renderer, background) { + if (!background || background.mode === 'transparent') { + renderer.setClearColor(0x000000, 0); + } else { + renderer.setClearColor(background.color ?? '#ffffff', 1); + } +} + +/** A PNG data URL of the current view, scaled up by `scale`. */ +export async function capturePNG(view, { scale = 2, background, width, height }) { + const baseWidth = view.width || view.renderer.domElement.clientWidth || 1280; + const baseHeight = view.height || view.renderer.domElement.clientHeight || 800; + const outWidth = Math.min(8192, Math.max(64, Math.round((width ?? baseWidth) * scale))); + const outHeight = Math.min(8192, Math.max(64, Math.round((height ?? baseHeight) * scale))); + const canvas = renderStill(view, { width: outWidth, height: outHeight, background }); + const blob = await canvasToBlob(canvas); + return { blob, canvas, width: outWidth, height: outHeight }; +} + +/** + * Encode a canvas as a PNG blob. Exported because the caller composites the + * backdrop and the caption onto the rendered canvas afterwards, and then has to + * re-encode it. + */ +export function canvasToBlob(canvas) { + return new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); +} + +export async function copyCanvasToClipboard(canvas) { + if (!navigator.clipboard || typeof ClipboardItem === 'undefined') { + throw new Error('この環境ではクリップボードにコピーできません'); + } + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); +} + +/** + * Vector line art: render the outline-only style, trace the coverage mask of + * the result and emit one even-odd path. Rendering at a high resolution keeps + * the traced curves smooth. + */ +export function buildSVG(view, { + styles, + face, + faceParams, + width = 2048, + threshold = 0.4, + lineColor = '#111111', + lineWidth = null, + background = null, + handDrawn = null, + captions = [], + captionFont = null, + outlinePixels = 2, +} = {}) { + const previousStyle = styles.style; + const previousMode = face.styleMode; + + // The `outline` style keeps every body mesh invisible (colorWrite off) and lets + // the outline passes draw the lines, exactly as on screen: the hulls ink the + // body, the screen-space pass inks the leaves. What reaches the trace is + // therefore the ink alone - and a traced stroke comes out as its own outline, + // i.e. two nested contours that `fill-rule="evenodd"` fills as a line of the + // same width. That is what finally gives the leaves, which no inverted hull can + // outline, clean even lines in the vector file too. + // + // `withOutlineFor` in main.js sets that split up for this call, and guards it so + // a renderer without the outline pass still falls back to a hull-only trace. + const screenSpace = Boolean(view.outline); + + styles.setStyle('outline'); + face.setParams(faceParams, 'line'); + face.flush(performance.now(), 0); + + const aspect = (view.height || 800) / (view.width || 1280); + const height = Math.max(64, Math.round(width * aspect)); + // `radius` is in screen pixels, so it has to grow with the render: the SVG is + // drawn far larger than the viewport, and a 2px line would become a hairline. + const scale = width / Math.max(1, view.width || 1280); + const canvas = renderStill( + { + ...view, + outlineOptions: { + enabled: screenSpace, + color: lineColor, + radius: outlinePixels * scale, + }, + }, + { width, height, background: { mode: 'transparent' } }, + ); + + styles.setStyle(previousStyle); + face.setParams(faceParams, previousMode); + face.flush(performance.now(), 0); + + const pixels = canvas.getContext('2d').getImageData(0, 0, width, height).data; + const alpha = new Uint8Array(width * height); + for (let i = 0; i < alpha.length; i++) alpha[i] = pixels[i * 4 + 3]; + + let contours = traceAlphaContours(alpha, width, height, { + threshold, + simplifyTolerance: 0.6, + minArea: 5, + }); + // 手描き風: nudge the traced outlines so they read as pen strokes instead of + // the mathematically smooth curves a mask trace produces. + if (handDrawn && handDrawn.amount > 0) { + contours = roughenContours(contours, { + amount: handDrawn.amount, + seed: handDrawn.seed ?? 1, + scale: handDrawn.scale ?? 40, + passes: handDrawn.passes ?? 1, + }); + } + const d = contoursToPathData(contours, (x, y) => [x, y], 2); + + const rect = background + ? `<rect width="${width}" height="${height}" fill="${background}"/>\n` + : ''; + const widthAttr = lineWidth ? ` stroke="${lineColor}" stroke-width="${lineWidth}"` : ''; + + // The caption is authored against the viewport size, so it scales with the + // requested SVG width. Its bubble and text are emitted as real vector shapes. + // Every bubble is emitted as its own real vector shapes, one after the other. + let captionSvg = ''; + let captionUsedOutlines = captions.some(Boolean); + for (const caption of captions) { + if (!caption) continue; + const scale = width / Math.max(1, view.width || 1280); + const result = captionToSvg(caption, { width, height, scale, font: captionFont }); + captionSvg += result.svg; + if (result.usedOutlines === false) captionUsedOutlines = false; + } + + return { + contours: contours.length, + captionUsedOutlines, + svg: `<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"> +<title>ぶるべー 線画</title> +${rect}<path fill="${lineColor}"${widthAttr} fill-rule="evenodd" d="${d}"/> +${captionSvg}</svg> +`, + }; +} + +/** Alignment guides: blue on the white paper, light enough to paint over. */ +const FACE_MAP_GUIDE = 'rgba(90, 140, 220, 0.55)'; +/** Half-length of the centre ticks, in artwork-window pixels. */ +const FACE_MAP_TICK = 16; + +/** + * Build the「下地」image an author paints a custom face texture on. + * + * The studio loads a hand-drawn image by drawing it at the artwork window's own + * offset and size (see `Face.loadImage` / `placeInWindow`), so an image the size + * of the *plate canvas*, with its artwork aligned to `offsetX/offsetY`, imports + * 1:1. That is exactly what this hands out: the live drawing as a reference, an + * opaque white window to paint on, and faint guides for the eye centres, the lid + * line or the mouth chord. + * + * The white and the guides go on a **copy**: the plate canvas *is* the live + * texture, and painting it would show up in the view. + * + * @param {import('./face.js').Face} face + * @param {'eyes'|'mouth'} kind + * @returns {HTMLCanvasElement} + */ +export function buildFaceMap(face, kind) { + const eyes = kind === 'eyes'; + const layout = eyes ? face.eyeLayout : face.mouthLayout; + const plate = eyes ? face.eyeCanvas : face.mouthCanvas; + + const canvas = document.createElement('canvas'); + canvas.width = layout.width; + canvas.height = layout.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(plate, 0, 0); + + const { offsetX, offsetY, window: win } = layout; + // `destination-over` so the white paper lands *under* the copy: filling it + // normally would erase the very drawing the author lines the new art up against. + ctx.globalCompositeOperation = 'destination-over'; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(offsetX, offsetY, win.width, win.height); + ctx.globalCompositeOperation = 'source-over'; + + // The plate canvas keeps the artwork at its original pixel size (only the canvas + // grows around it), so a fixed-width stroke reads the same on both parts. + ctx.strokeStyle = FACE_MAP_GUIDE; + ctx.fillStyle = FACE_MAP_GUIDE; + ctx.lineWidth = 2; + + // Where the drawing has to fit, and where its middle is. The cross uses short + // ticks, not full-width lines, so it cannot be mistaken for artwork. + ctx.strokeRect(offsetX, offsetY, win.width, win.height); + const cx = offsetX + win.width / 2; + const cy = offsetY + win.height / 2; + guideLine(ctx, cx - FACE_MAP_TICK, cy, cx + FACE_MAP_TICK, cy); + guideLine(ctx, cx, cy - FACE_MAP_TICK, cx, cy + FACE_MAP_TICK); + + if (eyes) { + // Both eyeballs (`radius`) and the lid line through their centres, so a + // hand-drawn brow or eye can be placed against the parametric ones. + const [a, b] = EYE_LAYOUT.eyes; + const y = offsetY + a.cy; + guideLine(ctx, offsetX + a.cx, y, offsetX + b.cx, y); + for (const eye of EYE_LAYOUT.eyes) { + const ex = offsetX + eye.cx; + const ey = offsetY + eye.cy; + ctx.beginPath(); + ctx.arc(ex, ey, EYE_LAYOUT.radius, 0, Math.PI * 2); + ctx.stroke(); + // A small dot marks the exact centre, where the eyeball pivots. + ctx.beginPath(); + ctx.arc(ex, ey, ctx.lineWidth * 1.5, 0, Math.PI * 2); + ctx.fill(); + } + } else { + // The lip line the mouth is drawn around: end to end, plus a tick at the centre. + const y = offsetY + MOUTH_LAYOUT.chordY; + const { centreX, halfChord } = MOUTH_LAYOUT; + guideLine(ctx, offsetX + centreX - halfChord, y, offsetX + centreX + halfChord, y); + guideLine(ctx, offsetX + centreX, y - FACE_MAP_TICK, offsetX + centreX, y + FACE_MAP_TICK); + } + + return canvas; +} + +/** One guide segment, kept out of `buildFaceMap` so the placements stay readable. */ +function guideLine(ctx, x0, y0, x1, y1) { + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); +} + +export function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 4000); +} + +export function downloadText(text, filename, type = 'application/json') { + downloadBlob(new Blob([text], { type }), filename); +} + +export function readFileAsText(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + +export function readFileAsArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(file); + }); +} + +export function timestamp() { + const now = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; +} |
