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 ? `\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: ` ぶるべー 線画 ${rect} ${captionSvg} `, }; } /** 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())}`; }