From bc2821adb11a30244fc4663f4fafb756877d9508 Mon Sep 17 00:00:00 2001
From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>
Date: Wed, 7 Oct 2026 23:09:40 +0900
Subject: bluebey-studio: public/ の外へ移動し非公開化
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
public/bluebey-studio/src/exporter.js | 370 ----------------------------------
1 file changed, 370 deletions(-)
delete mode 100644 public/bluebey-studio/src/exporter.js
(limited to 'public/bluebey-studio/src/exporter.js')
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
- ? `\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;
-}
-
-/**
- * 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())}`;
-}
--
cgit v1.3.1