1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
|
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())}`;
}
|