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
|
import { traceAlphaContours, contoursToPathData } from './trace.js';
import { roughenContours } from './handDrawn.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 擬音 stamps 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,
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}"` : '';
return {
contours: contours.length,
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}"/>
</svg>
`,
};
}
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())}`;
}
|