diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
| commit | bc2821adb11a30244fc4663f4fafb756877d9508 (patch) | |
| tree | fea8d30d2eb1e67f895097dbf7ec4d152361243e /public/bluebey-studio/src/exporter.js | |
| parent | 0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff) | |
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'public/bluebey-studio/src/exporter.js')
| -rw-r--r-- | public/bluebey-studio/src/exporter.js | 370 |
1 files changed, 0 insertions, 370 deletions
diff --git a/public/bluebey-studio/src/exporter.js b/public/bluebey-studio/src/exporter.js deleted file mode 100644 index b34b6f6..0000000 --- a/public/bluebey-studio/src/exporter.js +++ /dev/null @@ -1,370 +0,0 @@ -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'; - -/** - * 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; -} - -/** - * 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(); - 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())}`; -} |
