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: `
`,
};
}
/** 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())}`;
}