aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/main.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
commitbc2821adb11a30244fc4663f4fafb756877d9508 (patch)
treefea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/main.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/main.js')
-rw-r--r--bluebey-studio/src/main.js3056
1 files changed, 3056 insertions, 0 deletions
diff --git a/bluebey-studio/src/main.js b/bluebey-studio/src/main.js
new file mode 100644
index 0000000..20c6c7d
--- /dev/null
+++ b/bluebey-studio/src/main.js
@@ -0,0 +1,3056 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+import { loadModel } from './model.js';
+import { Face } from './face.js';
+import { Styles, isLineStyle } from './styles.js';
+import { Rig } from './rig.js';
+import { Animator, createRecorder, startRecording, stopRecording } from './animation.js';
+import { defaultState, applyPatch, THEMES } from './presets.js';
+import { buildPanel } from './panel.js';
+import * as exporter from './exporter.js';
+import { toast } from './ui.js';
+import { History } from './history.js';
+import { createLook } from './look.js';
+import { createClipper } from './clip.js';
+import { buildHat } from './hats.js';
+import { createBackdrop, backdropStyle } from './background.js';
+import { drawCaption, layoutCaption } from './caption.js';
+import { loadFont, captionFontStack } from './textOutlines.js';
+import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js';
+import { rollAll, decodeSeed } from './gacha.js';
+import { encodeState, decodeState } from './urlState.js';
+import { createMouthFlap, levelToMouth } from './mouthFlap.js';
+import { createScreenOutline, BEHIND_LABEL, SOLID_LABEL, LEAF_LABEL, NOSE_LABEL } from './outline.js';
+import { gionSheetUrl, stampRect, imageAspect, imageReady, drawGion, DEFAULT_STAMP_WIDTH } from './gion.js';
+
+const DEG = Math.PI / 180;
+/** The other direction: what three's Spherical reports is radians. */
+const RAD_TO_DEG = 180 / Math.PI;
+const MODEL_URL = 'assets/bluebey.glb';
+
+/** The caption font is only fetched when a bubble is first used. */
+const CAPTION_FONT_TTF = 'assets/fonts/bluebey-caption.ttf';
+const CAPTION_FONT_WOFF2 = 'assets/fonts/bluebey-caption.woff2';
+/** How far the eyes can swing for a look-at target, in radians. */
+const MAX_LOOK_YAW = 0.5;
+const MAX_LOOK_PITCH = 0.34;
+
+/** Undo steps are labelled by the part of the state that changed. */
+const SCOPE_LABELS = {
+ all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', caption: 'セリフ', gion: '擬音',
+};
+
+/**
+ * The single-file build inlines the fonts and the backdrop library, so the same
+ * code reads a data URL there and a file URL in the normal build.
+ */
+function assetUrl(file, kind) {
+ const inlined = globalThis.__BLUEBEY_FONTS__ ?? {};
+ if (kind === 'background') {
+ const backdrops = globalThis.__BLUEBEY_BACKGROUNDS__ ?? {};
+ return backdrops[file] ?? `assets/backgrounds/${file}.webp`;
+ }
+ if (kind === 'beard') {
+ const beards = globalThis.__BLUEBEY_BEARDS__ ?? {};
+ return beards[file] ?? `assets/beards/${file}`;
+ }
+ if (kind === 'brow') {
+ const brows = globalThis.__BLUEBEY_BROWS__ ?? {};
+ return brows[file] ?? `assets/brows/${file}`;
+ }
+ return inlined[file] ?? `assets/fonts/${file}`;
+}
+
+/**
+ * Where the model comes from. The normal build fetches `assets/bluebey.glb`;
+ * the single-file build (tools/build-standalone.mjs) inlines the same bytes as
+ * base64 in `window.__BLUEBEY_MODEL__`, which also lets the page be opened
+ * straight from disk without any web server.
+ */
+function resolveModelSource() {
+ const injected = globalThis.__BLUEBEY_MODEL__;
+ if (!injected) return MODEL_URL;
+ if (typeof injected !== 'string') return injected;
+ try {
+ const binary = atob(injected);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
+ return bytes.buffer;
+ } catch (error) {
+ console.error('inline model could not be decoded, falling back to the file', error);
+ return MODEL_URL;
+ }
+}
+
+const state = defaultState();
+
+const app = {
+ state,
+ model: null,
+ container: null,
+ ready: false,
+ needsRender: true,
+ /** The 擬音 stamp the panel is editing, so the viewport can outline it. */
+ gionSelected: null,
+ /** Set by init(), used by the panel. */
+ actions: {},
+};
+
+/* ------------------------------------------------------------------ startup */
+
+// The view-rig classes are declared below this point; deferring by a microtask
+// guarantees the whole module has finished evaluating before anything is built.
+queueMicrotask(() => {
+ init().catch((error) => {
+ console.error(error);
+ showLoadError(error);
+ });
+});
+
+async function init() {
+ const canvas = document.getElementById('view');
+ // #stage owns the layout; the canvas only fills it (see style.css).
+ const stage = document.getElementById('stage') ?? canvas;
+
+ const renderer = new THREE.WebGLRenderer({
+ canvas,
+ antialias: true,
+ alpha: true,
+ preserveDrawingBuffer: true,
+ });
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFShadowMap;
+
+ const scene = new THREE.Scene();
+ const environment = makeEnvironment(renderer);
+
+ const key = new THREE.DirectionalLight(0xffffff, 2.1);
+ key.castShadow = true;
+ key.shadow.mapSize.set(1536, 1536);
+ key.shadow.bias = -0.0008;
+ key.shadow.normalBias = 0.02;
+ const fill = new THREE.DirectionalLight(0xffffff, 0.5);
+ const ambient = new THREE.HemisphereLight(0xffffff, 0xd9d0f2, 0.9);
+ scene.add(key, key.target, fill, ambient);
+
+ // A shadow catcher, not a solid floor: `depthWrite: false` keeps the plane from
+ // ever hiding the character, so moving ぶるべー below it no longer makes the body
+ // (or its textures) vanish - the shadow still falls on it, but it never occludes.
+ const ground = new THREE.Mesh(
+ new THREE.PlaneGeometry(200, 200),
+ new THREE.ShadowMaterial({ opacity: 0.22, transparent: true, depthWrite: false }),
+ );
+ ground.rotation.x = -Math.PI / 2;
+ ground.receiveShadow = true;
+ scene.add(ground);
+
+ // The camera lives in two places: `state.view` (what the panel, the saved file
+ // and a shared link use) and the orbit controls (what the mouse moves). Capture
+ // the controls' own position back into the state on every change, so the two
+ // never disagree - otherwise any refresh of the view yanked the camera back to
+ // wherever the panel had last put it.
+ const view = new ViewRig(canvas, () => {
+ view.captureInto(state.view);
+ app.needsRender = true;
+ });
+
+ /**
+ * The screen-space outline (see src/outline.js). The normal pass must not see
+ * the ground, the contact shadow or the gizmo, so they are excluded once those
+ * objects exist (see below).
+ */
+ const outline = createScreenOutline({
+ renderer,
+ scene,
+ camera: view.camera,
+ width: canvas.clientWidth || 1280,
+ height: canvas.clientHeight || 800,
+ });
+ outline.exclude([ground]);
+
+ /**
+ * The drawing buffer must follow the canvas' CSS size. Without this the
+ * browser stretches the default 300x150 buffer across the window and the
+ * whole picture looks coarse (and every exported trace is built from those
+ * few pixels).
+ *
+ * But it must not be *unlimited* either: on a large or high-density display
+ * the buffer can reach many millions of pixels, and with antialiasing, a
+ * shadow map and preserveDrawingBuffer on top, a weak GPU can take seconds
+ * per frame - which looks exactly like "nothing is showing". So the pixel
+ * count is capped and the ratio is lowered automatically on slow hardware.
+ */
+ const MAX_DRAWING_PIXELS = 2_600_000;
+ const QUALITY_FLOOR = 0.5;
+ let quality = Math.min(window.devicePixelRatio || 1, 2);
+ let autoReductions = 0;
+
+ function fitQuality(width, height, wanted) {
+ let ratio = wanted;
+ while (ratio > QUALITY_FLOOR && width * height * ratio * ratio > MAX_DRAWING_PIXELS) {
+ ratio = Math.round((ratio - 0.1) * 100) / 100;
+ }
+ return Math.max(QUALITY_FLOOR, ratio);
+ }
+
+ let lastResize = 0;
+ let resizeBurst = 0;
+
+ /** Declared early: the viewport can resize before the model has loaded. */
+ let captionCanvas = null;
+ /** Set while undo/redo replays a snapshot, so it is not recorded again. */
+ let suspendHistory = false;
+
+ // ------------------------------------------------------------------- 擬音
+ // `redrawCaption` runs from the very first `resizeViewport`, so the sheet cache
+ // and the drag handles have to exist before it does (a `const` cannot be read
+ // before it is evaluated). The drawing itself lives further down, next to the
+ // caption overlay it is layered under.
+ const gionImages = new Map(); // sheet name -> loaded Image
+ const gionLoading = new Map(); // sheet name -> in-flight Promise
+ const gionHandles = new Map(); // stamp id -> transparent drag div
+ let gionDrag = null;
+ /** Hands out stamp ids; a counter so removing one never reuses a live id. */
+ let gionSeq = 0;
+
+ function resizeViewport() {
+ const width = stage.clientWidth || window.innerWidth;
+ const height = stage.clientHeight || window.innerHeight;
+
+ // Safety net: if something ever feeds the viewport size back into itself,
+ // stop flipping the drawing buffer on and off and say so.
+ const now = performance.now();
+ if (now - lastResize < 60) {
+ resizeBurst += 1;
+ if (resizeBurst > 30 && resizeBurst % 30 === 0) {
+ console.warn('[bluebey] too many resizes in a row; ignoring them', resizeBurst);
+ }
+ if (resizeBurst > 30) return;
+ } else {
+ resizeBurst = 0;
+ }
+ lastResize = now;
+
+ const wanted = Math.min(window.devicePixelRatio || 1, 2);
+ quality = fitQuality(width, height, wanted);
+ renderer.setPixelRatio(quality);
+ renderer.setSize(width, height, false);
+ outline.setSize(width * quality, height * quality);
+ view.resize();
+ redrawCaption();
+ app.needsRender = true;
+ }
+ resizeViewport();
+ new ResizeObserver(() => resizeViewport()).observe(stage);
+
+ // Losing the WebGL context leaves a permanently blank canvas, so say so
+ // instead of leaving the user staring at nothing.
+ canvas.addEventListener('webglcontextlost', (event) => {
+ event.preventDefault();
+ showNotice('描画が止まりました(WebGLコンテキストを失いました)。\n'
+ + 'ブラウザのウィンドウを小さくするか、再読み込みしてください。');
+ });
+ canvas.addEventListener('webglcontextrestored', () => {
+ app.needsRender = true;
+ });
+
+ // -------------------------------------------------------------- the model
+ const model = await loadModel(resolveModelSource(), {
+ onProgress: (ratio) => setLoadingProgress(ratio),
+ });
+ app.model = model;
+
+ // The character hangs off a mirror group of its own, so "左右反転" flips the
+ // model without touching the pose offset (`container.position`) or the props.
+ const mirrorGroup = new THREE.Group();
+ mirrorGroup.add(model.root);
+ const container = new THREE.Group();
+ container.add(mirrorGroup);
+ scene.add(container);
+ app.container = container;
+
+ // 小物 live outside the mirror group: they are scenery, not part of the body.
+ const propRoot = new THREE.Group();
+ scene.add(propRoot);
+
+ // --- 鼻ちょうちん: a modelled bubble hung under the nose --------------------
+ // The nose is a *skinned* part of the body, so a child of the nose mesh would
+ // sit at the origin instead of on the face. The whole body (nose included)
+ // rides the `master` bone, so the bubble is parented there, placed at the nose's
+ // rest position in that bone's local frame, and read from the face state each
+ // frame in the render loop.
+ function applySnotBubble(group, snot) {
+ if (!group) return;
+ group.visible = snot?.enabled === true;
+ if (!group.visible) return;
+ // The offsets are applied in the *bone's* frame, along the world axes measured
+ // once at rest, so the bubble travels with the body. Recomputing them against
+ // the live world matrix made it snap back whenever the body had moved.
+ const k = 0.01; // artwork px -> world units, enough to nudge the bubble
+ const { localBase, axes } = group.userData;
+ group.position.copy(localBase)
+ .addScaledVector(axes.x, (snot.offsetX ?? 0) * k)
+ .addScaledVector(axes.y, (snot.offsetY ?? 0) * k)
+ .addScaledVector(axes.z, (snot.offsetZ ?? 0) * k);
+ group.userData.baseScale = Number.isFinite(snot.size) ? snot.size : 1;
+ group.scale.setScalar(group.userData.baseScale);
+ group.userData.material.color.set(snot.color ?? '#dfe8ff');
+ }
+
+ const snotBubble = (() => {
+ const nose = model.parts.noseMesh;
+ const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master');
+ if (!nose || !master || !nose.isSkinnedMesh) return null;
+ model.root.updateMatrixWorld(true);
+ // The nose is a *skinned* mesh, so its raw geometry sits in bind space (at the
+ // back of the model, in fact) - sample the skinned positions instead, which is
+ // where the nose actually ends up.
+ const attr = nose.geometry.attributes.position;
+ const step = Math.max(1, Math.floor(attr.count / 400));
+ const point = new THREE.Vector3();
+ const centre = new THREE.Vector3();
+ let samples = 0;
+ for (let i = 0; i < attr.count; i += step) {
+ point.fromBufferAttribute(attr, i);
+ nose.applyBoneTransform(i, point);
+ centre.add(point);
+ samples += 1;
+ }
+ if (!samples) return null;
+ centre.multiplyScalar(1 / samples);
+ nose.localToWorld(centre);
+ let radius = 0;
+ for (let i = 0; i < attr.count; i += step) {
+ point.fromBufferAttribute(attr, i);
+ nose.applyBoneTransform(i, point);
+ nose.localToWorld(point);
+ radius = Math.max(radius, point.distanceTo(centre));
+ }
+ if (!Number.isFinite(radius) || radius <= 0) radius = 0.25;
+ const material = new THREE.MeshStandardMaterial({
+ color: 0xdfe8ff, roughness: 0.45, metalness: 0, transparent: true, opacity: 0.92,
+ });
+ const group = new THREE.Group();
+ group.name = 'snot-bubble';
+ const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.15, 28, 20), material);
+ group.add(bubble);
+ const bone = master.bone;
+ const baseWorld = centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7));
+ const localBase = bone.worldToLocal(baseWorld.clone());
+ // World-axis directions expressed in the bone's frame, captured at rest: the
+ // sliders keep meaning "right / up / towards the viewer" while the bubble rides
+ // with the body.
+ const boneQuat = new THREE.Quaternion();
+ bone.getWorldQuaternion(boneQuat);
+ const invQuat = boneQuat.clone().invert();
+ const axes = {
+ x: new THREE.Vector3(1, 0, 0).applyQuaternion(invQuat),
+ y: new THREE.Vector3(0, 1, 0).applyQuaternion(invQuat),
+ z: new THREE.Vector3(0, 0, 1).applyQuaternion(invQuat),
+ };
+ group.position.copy(localBase);
+ group.userData = { bubble, material, bone, localBase, axes, baseScale: 1 };
+ group.visible = false;
+ bone.add(group);
+ return group;
+ })();
+ const snotMeshes = snotBubble ? [...snotBubble.children] : [];
+ applySnotBubble(snotBubble, state.face.eyes.snot);
+
+ // The hat rides the `master` bone like the snot bubble does: it is parented
+ // there, placed at the top of the head in that bone's local frame, and turned
+ // so it is world-aligned at rest (the bone's own rest rotation cancelled once).
+ // The character's front is +z, so the hats are modelled facing +z.
+ const hatMount = (() => {
+ const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master');
+ if (!master) return null;
+ model.root.updateMatrixWorld(true);
+ const bone = master.bone;
+ const group = new THREE.Group();
+ group.name = 'hat';
+ group.position.copy(bone.worldToLocal(new THREE.Vector3(0, model.bounds.max.y - 0.06, 0)));
+ group.quaternion.copy(bone.getWorldQuaternion(new THREE.Quaternion()).invert());
+ bone.add(group);
+ return group;
+ })();
+
+ let hatKindCurrent = null;
+ let hatObject = null;
+ /** A GLB hat the user loaded; kept so it can be re-chosen without re-importing. */
+ let customHatObject = null;
+ const hatMeshes = [];
+ /** Last transform applied, so the render loop only wakes when it changes. */
+ const hatXform = { x: null, z: null, height: null, tiltX: null, tiltZ: null };
+ /** Set once the outline pass exists, so a hat change can refresh the exclude set. */
+ let refreshOutlineExclusion = null;
+ /** Set once the style system exists, so a hat can register for styles + outline. */
+ let hatStyleRegister = null;
+ let hatStyleUnregister = null;
+
+ /**
+ * Swap the mounted hat for `built` (or clear it), keeping the style system and
+ * the outline exclusion in step.
+ *
+ * The old meshes are collected BEFORE they are unregistered: `removeMesh`
+ * deletes the outline hull (a child of the hat group) while we traverse, which
+ * used to shorten `children` mid-loop and call `.traverse` on `undefined`.
+ */
+ function mountHat(built) {
+ if (hatObject) {
+ const old = [];
+ hatObject.traverse((o) => { if (o.isMesh) old.push(o); });
+ hatMount.remove(hatObject);
+ for (const mesh of old) hatStyleUnregister?.(mesh);
+ // The custom model is kept so it can be re-chosen; a built hat is dropped.
+ if (hatObject !== customHatObject) {
+ for (const mesh of old) {
+ mesh.geometry?.dispose();
+ if (Array.isArray(mesh.material)) mesh.material.forEach((m) => m.dispose());
+ else mesh.material?.dispose();
+ }
+ }
+ hatObject = null;
+ }
+ hatMeshes.length = 0;
+ if (built) {
+ hatMount.add(built);
+ hatObject = built;
+ const meshes = [];
+ built.traverse((o) => { if (o.isMesh) meshes.push(o); });
+ for (const mesh of meshes) {
+ hatMeshes.push(mesh);
+ hatStyleRegister?.(mesh);
+ }
+ }
+ refreshOutlineExclusion?.();
+ }
+
+ function applyHat(kind) {
+ if (!hatMount) return;
+ const custom = state.face.hat?.custom === true && customHatObject;
+ const key = custom ? 'custom' : kind;
+ if (key !== hatKindCurrent) {
+ hatKindCurrent = key;
+ mountHat(custom ? customHatObject : buildHat(kind));
+ }
+
+ // 高さ and 傾き. The mount is world-aligned (see above), so +y is up, +z is the
+ // character's front and +x its right: a plain translation and rotation read
+ // the way the sliders say.
+ const hat = state.face.hat ?? {};
+ const x = hat.x ?? 0;
+ const z = hat.z ?? 0;
+ const height = hat.height ?? 0;
+ const tiltX = hat.tiltX ?? 0;
+ const tiltZ = hat.tiltZ ?? 0;
+ if (hatObject) {
+ hatObject.position.set(x, height, z);
+ hatObject.rotation.set((tiltX * Math.PI) / 180, 0, (tiltZ * Math.PI) / 180);
+ }
+ if (x !== hatXform.x || z !== hatXform.z || height !== hatXform.height || tiltX !== hatXform.tiltX || tiltZ !== hatXform.tiltZ) {
+ hatXform.x = x;
+ hatXform.z = z;
+ hatXform.height = height;
+ hatXform.tiltX = tiltX;
+ hatXform.tiltZ = tiltZ;
+ app.needsRender = true;
+ }
+ }
+ /**
+ * Import a GLB hat. It is centred sideways, dropped so its base sits at y = 0
+ * (the mount sits on the head), and scaled to about the head's width, then
+ * mounted like any built hat.
+ */
+ async function loadHatModel(file) {
+ if (!hatMount) return;
+ const buffer = await exporter.readFileAsArrayBuffer(file);
+ const loader = new GLTFLoader();
+ const gltf = await new Promise((resolve, reject) => loader.parse(buffer, '', resolve, reject));
+ const root = gltf.scene;
+ const box = new THREE.Box3().setFromObject(root);
+ const size = box.getSize(new THREE.Vector3());
+ const target = Math.max(0.2, model.size.x * 0.95);
+ const scale = target / Math.max(size.x, size.z, 0.001);
+ root.scale.setScalar(scale);
+ box.setFromObject(root);
+ const center = box.getCenter(new THREE.Vector3());
+ root.position.x -= center.x;
+ root.position.z -= center.z;
+ root.position.y -= box.min.y;
+ root.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = false; } });
+ customHatObject = root;
+ state.face.hat.custom = true;
+ state.face.hat.kind = 'none';
+ hatKindCurrent = null;
+ refresh('all');
+ app.panel?.sync();
+ }
+
+ applyHat(state.face.hat?.kind ?? 'none');
+
+ const halfHeight = model.size.y * 0.5;
+ view.frame(model.size, halfHeight);
+
+ // shadow camera big enough for the whole character
+ const radius = Math.max(model.size.x, model.size.y, model.size.z) * 1.6;
+ const shadowCamera = key.shadow.camera;
+ shadowCamera.left = -radius;
+ shadowCamera.right = radius;
+ shadowCamera.top = radius;
+ shadowCamera.bottom = -radius;
+ shadowCamera.near = 0.5;
+ shadowCamera.far = radius * 9;
+ shadowCamera.updateProjectionMatrix();
+ key.target.position.set(0, halfHeight, 0);
+
+ // The light's offset from the character, so the key light (and therefore its
+ // shadow camera) can follow the character around: moving far from the origin
+ // used to leave the shadow behind and cut it off at the shadow camera's edge.
+ const keyOffset = new THREE.Vector3(0, model.size.y * 3, model.size.y * 3);
+ function updateKeyLight() {
+ key.target.position.set(container.position.x, halfHeight + container.position.y, container.position.z);
+ key.position.copy(key.target.position).add(keyOffset);
+ }
+
+ // ------------------------------------------------------------- controllers
+ const styles = new Styles({ meshes: model.parts.body });
+ // Hats are built at runtime, so they are not in the GLB's mesh list. Register
+ // them with the style system (and the current hat) so they follow flat/line art
+ // and get an outline hull like every other part.
+ hatStyleRegister = (mesh) => styles.addMesh(mesh, { tone: true, lineOnly: true });
+ hatStyleUnregister = (mesh) => styles.removeMesh(mesh);
+ for (const mesh of hatMeshes) styles.addMesh(mesh, { tone: true, lineOnly: true });
+ const face = new Face({
+ eyeMesh: model.parts.eyeMesh,
+ mouthMesh: model.parts.mouthMesh,
+ hairMesh: model.parts.hairMesh,
+ originals: model.originals,
+ });
+ const animator = new Animator({ state });
+
+ // Beard drawings supplied as files (`assets/beards/<kind>.png`): a kind that has
+ // one is drawn from the file instead of the built-in strokes. A missing file is
+ // skipped, so the app works before any are added.
+ void (async () => {
+ // Each entry: a beard kind and the drawing in `assets/beards/`. A file ending
+ // `-right.png` is one side of a moustache; it is drawn twice (mirrored) with a
+ // `spacing` gap between them. A kind with no entry uses the built-in strokes.
+ const files = [
+ { kind: 'scotch', file: 'scotch.png' },
+ { kind: 'kaiser', file: 'kaiser-right.png' },
+ ];
+ const loaded = {};
+ await Promise.all(files.map(async ({ kind, file }) => {
+ try {
+ const response = await fetch(assetUrl(file, 'beard'));
+ if (!response.ok) return;
+ loaded[kind] = {
+ image: await createImageBitmap(await response.blob()),
+ half: file.endsWith('-right.png'),
+ };
+ } catch {
+ /* a missing drawing falls back to the built-in strokes */
+ }
+ }));
+ if (Object.keys(loaded).length) {
+ face.setBeardImages(loaded);
+ app.needsRender = true;
+ }
+ })();
+
+ // Brow drawings supplied as files (`assets/brows/<kind>.png`). A file ending
+ // `-right.png` is one side; it is drawn twice (mirrored) at each eye. Missing
+ // files are skipped, so the built-in ゴル風 strokes are the fallback.
+ void (async () => {
+ const files = [{ kind: 'gol', file: 'gol-right.png' }];
+ const loaded = {};
+ await Promise.all(files.map(async ({ kind, file }) => {
+ try {
+ const response = await fetch(assetUrl(file, 'brow'));
+ if (!response.ok) return;
+ loaded[kind] = {
+ image: await createImageBitmap(await response.blob()),
+ half: file.endsWith('-right.png'),
+ };
+ } catch {
+ /* a missing drawing falls back to the built-in strokes */
+ }
+ }));
+ if (Object.keys(loaded).length) {
+ face.setBrowImages(loaded);
+ app.needsRender = true;
+ }
+ })();
+
+ // ------------------------------------------------------------------- look
+ const look = createLook({
+ renderer,
+ scene,
+ styles,
+ model,
+ ground,
+ key,
+ character: mirrorGroup,
+ gradientEnvironment: environment,
+ });
+
+ // --------------------------------------------------------------- 見えない壁
+ // A clip plane that hides part of the character, so it can look half buried
+ // in a wall (see src/clip.js). Applied in `refresh`, with the render settings.
+ const clip = createClipper({ scene, renderer, model });
+ // A wall's position is stored relative to the character, so every time the
+ // walls are applied they are shifted by the body's own offset - that is what
+ // makes them travel with ぶるべー when it is moved.
+ const characterOffset = () => {
+ const root = state.pose?.root;
+ return { x: root?.[0] ?? 0, y: root?.[1] ?? 0, z: root?.[2] ?? 0 };
+ };
+ const applyWalls = () => clip.apply(
+ [state.render.wall, state.render.wall2],
+ characterOffset(),
+ wallYaw(),
+ );
+ // The character's own turn about Y: the `master` bone's Y (Shift+drag) plus any
+ // body yaw the 見る先 mode added. The walls take this too, so turning the
+ // character with Shift+drag no longer slides the hidden region around.
+ const wallYaw = () => {
+ const masterY = state.pose?.bones?.master?.[1] ?? 0;
+ return (masterY + (container.rotation.y * 180) / Math.PI) * DEG;
+ };
+
+ // --------------------------------------------------------------- backdrop
+ const backdrop = createBackdrop({
+ stage,
+ resolveUrl: (name) => assetUrl(name, 'background'),
+ onNeedsRender: () => { app.needsRender = true; },
+ });
+ /** The bitmap the exports paint behind the model (an Image or the camera video). */
+ let backdropImage = null;
+
+ /**
+ * Story-panel previews (keyed by panel id) and the counter that hands out
+ * those ids.
+ *
+ * WHY the previews live outside the state: a thumbnail is an image, and the
+ * state is exactly what a shared link carries - a data URL per panel would
+ * push the link past what a URL can hold. They are keyed by id and rebuilt as
+ * panels are added, so a restored link simply shows panels without previews.
+ * (Declared up here because `buildPanel` runs - and syncs - before the panel
+ * functions below are reached.)
+ */
+ const storyThumbs = new Map();
+ let storySeq = 0;
+
+ // ------------------------------------------------------- caption overlay
+ // The bubble is drawn into its own 2D canvas laid over the WebGL one, with the
+ // very same routine the PNG export uses - so the preview cannot lie.
+ captionCanvas = document.createElement('canvas');
+ captionCanvas.id = 'caption-layer';
+ captionCanvas.style.cssText =
+ 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2';
+ stage.append(captionCanvas);
+
+ /** opentype font for outline export and text layout (lazily fetched). */
+ let captionFont = null;
+ let captionFontPromise = null;
+ /** Where each bubble was last drawn, in CSS pixels (in state order). */
+ const CAPTION_KEYS = ['caption', 'caption2', 'narration'];
+ const captionBoxes = new Map();
+ const captionHandles = new Map();
+ /** Set while a bubble is being dragged. */
+ let captionDrag = null;
+
+ // The bubbles are draggable, but the caption canvas has to stay
+ // `pointer-events: none`: it covers the whole viewport, and the orbit controls
+ // and the bone picking live underneath it. So each bubble gets a separate
+ // transparent drag box, parked exactly over it by `positionCaptionHandles`.
+ for (const key of CAPTION_KEYS) {
+ const handle = document.createElement('div');
+ handle.id = `caption-handle-${key}`;
+ handle.style.cssText = 'position:absolute;display:none;cursor:move;'
+ + 'touch-action:none;pointer-events:auto;z-index:3';
+ handle.addEventListener('pointerdown', (event) => startCaptionDrag(event, key));
+ handle.addEventListener('pointermove', moveCaptionDrag);
+ handle.addEventListener('pointerup', endCaptionDrag);
+ handle.addEventListener('pointercancel', endCaptionDrag);
+ handle.dataset.captionHandle = '1';
+ stage.append(handle);
+ captionHandles.set(key, handle);
+ }
+
+ /** Park each drag target over its bubble (or hide it when it is not shown). */
+ function positionCaptionHandles() {
+ for (const key of CAPTION_KEYS) {
+ const handle = captionHandles.get(key);
+ const box = captionBoxes.get(key);
+ if (!handle) continue;
+ if (!box) {
+ handle.style.display = 'none';
+ continue;
+ }
+ // A few pixels of slack, so the rounded corners are still easy to grab.
+ handle.style.display = 'block';
+ handle.style.left = `${box.x - 4}px`;
+ handle.style.top = `${box.y - 4}px`;
+ handle.style.width = `${box.w + 8}px`;
+ handle.style.height = `${box.h + 8}px`;
+ }
+ }
+
+ function startCaptionDrag(event, key) {
+ const caption = state[key];
+ const box = captionBoxes.get(key);
+ if (caption?.enabled !== true || !box) return;
+ // Touching a bubble also selects it in the panel, so the sliders edit the one
+ // you just grabbed.
+ app.panel?.selectCaption?.(key);
+ event.preventDefault();
+ event.stopPropagation();
+ // Not all pointers can be captured (and a synthetic one cannot), so a
+ // failure here must not stop the drag from starting.
+ try {
+ captionHandles.get(key)?.setPointerCapture(event.pointerId);
+ } catch {
+ /* capture is a nicety, not a requirement */
+ }
+ captionDrag = {
+ key,
+ pointerId: event.pointerId,
+ fromX: event.clientX,
+ fromY: event.clientY,
+ boxX: box.x,
+ boxY: box.y,
+ };
+ }
+
+ function moveCaptionDrag(event) {
+ if (!captionDrag || event.pointerId !== captionDrag.pointerId) return;
+ const caption = state[captionDrag.key];
+ if (!caption) return;
+ event.preventDefault();
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ caption.x = (captionDrag.boxX + (event.clientX - captionDrag.fromX)) / width;
+ caption.y = (captionDrag.boxY + (event.clientY - captionDrag.fromY)) / height;
+ // Store back what the layout will actually use, so pushing past an edge does
+ // not pile up an out-of-range value that has to be undone before the bubble
+ // moves again. `refresh` redraws the bubble and records one undo step (the
+ // history coalesces a run of the same label).
+ const layout = layoutCaption(caption, { width, height, scale: 1, font: captionFont });
+ if (width > 0) caption.x = layout.box.x / width;
+ if (height > 0) caption.y = layout.box.y / height;
+ refresh('caption');
+ }
+
+ function endCaptionDrag(event) {
+ if (!captionDrag || (event && event.pointerId !== captionDrag.pointerId)) return;
+ captionDrag = null;
+ // The 横位置 / 縦位置 sliders catch up when the drag ends, not every pixel.
+ app.actions.syncPanel?.();
+ }
+
+ async function ensureCaptionFont() {
+ if (captionFontPromise) return captionFontPromise;
+ captionFontPromise = (async () => {
+ try {
+ const face = new FontFace('M PLUS Rounded 1c', `url(${assetUrl('bluebey-caption.woff2')})`);
+ document.fonts.add(await face.load());
+ } catch (error) {
+ console.warn('[bluebey] caption font face failed', error);
+ }
+ try {
+ captionFont = await loadFont(assetUrl('bluebey-caption.ttf'));
+ } catch (error) {
+ console.warn('[bluebey] caption outlines unavailable', error);
+ }
+ redrawCaption();
+ return captionFont;
+ })();
+ return captionFontPromise;
+ }
+
+ function redrawCaption() {
+ if (!captionCanvas) return;
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ const ratio = Math.min(2, window.devicePixelRatio || 1);
+ const wantW = Math.round(width * ratio);
+ const wantH = Math.round(height * ratio);
+ if (captionCanvas.width !== wantW || captionCanvas.height !== wantH) {
+ captionCanvas.width = wantW;
+ captionCanvas.height = wantH;
+ }
+ const ctx = captionCanvas.getContext('2d');
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+ // 擬音はセリフより背面に置く: the bubbles stay readable on top of them.
+ const gionItems = state.gion?.items ?? [];
+ for (const item of gionItems) ensureGionSheet(item.sheet);
+ drawGion(ctx, gionItems, gionImages, { width, height, scale: 1 });
+ for (const key of CAPTION_KEYS) {
+ const caption = state[key];
+ captionBoxes.delete(key);
+ if (!caption?.enabled) continue;
+ // The vendored font is only fetched once a bubble is actually used; when it
+ // arrives `ensureCaptionFont` redraws this canvas.
+ if (!captionFontPromise) void ensureCaptionFont();
+ captionBoxes.set(key, drawCaption(ctx, caption, { width, height, scale: 1, font: captionFont }));
+ }
+ positionCaptionHandles();
+ ensureGionHandles(gionItems);
+ positionGionHandles(gionItems);
+ }
+
+ // ----------------------------------------------------------------- 擬音
+ // A stamp is a crop of a sheet, drawn on the caption canvas *under* the bubbles.
+ // The canvas is `pointer-events: none`, so - exactly like a caption - each stamp
+ // gets its own transparent drag box, parked over it by `positionGionHandles`.
+
+ /** Load a sheet once, on first use, and redraw when it arrives. */
+ function ensureGionSheet(name) {
+ if (gionImages.has(name)) return Promise.resolve(gionImages.get(name));
+ if (gionLoading.has(name)) return gionLoading.get(name);
+ const image = new Image();
+ const promise = new Promise((resolve) => {
+ image.onload = () => resolve(image);
+ image.onerror = () => resolve(null);
+ }).then((loaded) => {
+ gionLoading.delete(name);
+ if (loaded) {
+ gionImages.set(name, loaded);
+ redrawCaption();
+ }
+ return loaded;
+ });
+ gionLoading.set(name, promise);
+ image.src = gionSheetUrl(name);
+ return promise;
+ }
+
+ /** Create one drag target per stamp, and drop the ones whose stamp is gone. */
+ function ensureGionHandles(items) {
+ const live = new Set();
+ for (const item of items) {
+ live.add(item.id);
+ if (gionHandles.has(item.id)) continue;
+ const handle = document.createElement('div');
+ handle.className = 'gion-handle';
+ handle.addEventListener('pointerdown', (event) => startGionDrag(event, item.id));
+ handle.addEventListener('pointermove', moveGionDrag);
+ handle.addEventListener('pointerup', endGionDrag);
+ handle.addEventListener('pointercancel', endGionDrag);
+ handle.dataset.gionHandle = '1';
+ stage.append(handle);
+ gionHandles.set(item.id, handle);
+ }
+ for (const [id, handle] of gionHandles) {
+ if (live.has(id)) continue;
+ handle.remove();
+ gionHandles.delete(id);
+ }
+ }
+
+ /** Park each drag target over its stamp (or hide it while the sheet loads). */
+ function positionGionHandles(items) {
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ for (const item of items) {
+ const handle = gionHandles.get(item.id);
+ if (!handle) continue;
+ const image = gionImages.get(item.sheet);
+ if (!imageReady(image)) {
+ handle.style.display = 'none';
+ continue;
+ }
+ const rect = stampRect(item, { width, height, scale: 1 }, imageAspect(image));
+ handle.style.display = 'block';
+ handle.style.left = `${rect.x}px`;
+ handle.style.top = `${rect.y}px`;
+ handle.style.width = `${rect.w}px`;
+ handle.style.height = `${rect.h}px`;
+ // Match the drawn stamp, which is rotated about its centre.
+ handle.style.transformOrigin = 'center';
+ handle.style.transform = item.rot ? `rotate(${item.rot}deg)` : 'none';
+ handle.classList.toggle('selected', item.id === app.gionSelected);
+ }
+ }
+
+ function startGionDrag(event, id) {
+ const item = (state.gion?.items ?? []).find((entry) => entry.id === id);
+ if (!item) return;
+ // Touching a stamp also selects it in the panel, so the sliders edit the one
+ // you just grabbed.
+ app.panel?.selectGion?.(id);
+ event.preventDefault();
+ event.stopPropagation();
+ try {
+ gionHandles.get(id)?.setPointerCapture(event.pointerId);
+ } catch {
+ /* capture is a nicety, not a requirement */
+ }
+ gionDrag = {
+ id,
+ pointerId: event.pointerId,
+ fromX: event.clientX,
+ fromY: event.clientY,
+ x: item.x ?? 0.5,
+ y: item.y ?? 0.5,
+ };
+ }
+
+ function moveGionDrag(event) {
+ if (!gionDrag || event.pointerId !== gionDrag.pointerId) return;
+ const item = (state.gion?.items ?? []).find((entry) => entry.id === gionDrag.id);
+ if (!item) return;
+ event.preventDefault();
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ item.x = clamp01(gionDrag.x + (event.clientX - gionDrag.fromX) / width);
+ item.y = clamp01(gionDrag.y + (event.clientY - gionDrag.fromY) / height);
+ refresh('gion');
+ }
+
+ function endGionDrag(event) {
+ if (!gionDrag || (event && event.pointerId !== gionDrag.pointerId)) return;
+ gionDrag = null;
+ app.actions.syncPanel?.();
+ }
+
+ /** Add a stamp from a picker crop, centred, and hand it to the panel. */
+ function addGionStamp({ sheet, sx, sy, sw, sh }) {
+ gionSeq += 1;
+ const item = {
+ id: `g${gionSeq}`,
+ sheet,
+ sx, sy, sw, sh,
+ x: 0.5,
+ y: 0.5,
+ w: DEFAULT_STAMP_WIDTH,
+ rot: 0,
+ flip: false,
+ };
+ state.gion = state.gion ?? { items: [] };
+ state.gion.items = [...(state.gion.items ?? []), item];
+ app.gionSelected = item.id;
+ void ensureGionSheet(sheet);
+ applyState({}, { scope: 'gion', sync: true });
+ toast('擬音を追加しました');
+ }
+
+ // ------------------------------------------------------------------ props
+ let propSignature = '';
+
+ function applyProps() {
+ const items = state.props?.items ?? [];
+ const signature = JSON.stringify([items, state.render.colors]);
+ if (signature === propSignature) return;
+ propSignature = signature;
+ for (const child of [...propRoot.children]) {
+ // Hand every mesh back to the style system before the prop is disposed.
+ // `removeMesh` restores each mesh's own material (a prop under 線画 is
+ // wearing the shared paper material, which must not be disposed). Collect
+ // the meshes first: `removeMesh` deletes the outline hull, a child of the
+ // prop group.
+ const meshes = [];
+ child.traverse((object) => {
+ if (styles.originals.has(object)) meshes.push(object);
+ });
+ for (const mesh of meshes) styles.removeMesh(mesh);
+ propRoot.remove(child);
+ disposeProp(child);
+ }
+ for (const item of items) {
+ const def = PROP_DEFAULTS[item.kind] ?? { x: 0, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
+ let group;
+ try {
+ group = buildProp(item.kind, { colors: state.render.colors, scale: item.scale ?? def.scale ?? 1 });
+ } catch (error) {
+ console.warn('[bluebey] unknown prop', item.kind, error);
+ continue;
+ }
+ group.position.set(item.x ?? def.x ?? 0, item.y ?? def.y ?? 0, item.z ?? def.z ?? 0);
+ group.rotation.set(
+ (item.rotX ?? def.rotX ?? 0) * DEG,
+ (item.rotY ?? def.rotY ?? 0) * DEG,
+ (item.rotZ ?? def.rotZ ?? 0) * DEG,
+ );
+ propRoot.add(group);
+ // Register with the style system, so フラット and 線画 reach the props too
+ // and each part gets an outline hull like the body and the hats. `lineOnly`
+ // keeps that outline out of リアル/フラット, where the props read by shading.
+ group.traverse((object) => {
+ if (object.isMesh) styles.addMesh(object, { lineOnly: true });
+ });
+ // 看板 only: paint the saved text onto the freshly built face (no-op for
+ // every other prop, which has no writing surface).
+ applyPropText(group, item.text);
+ }
+ refreshOutlineExclusion?.();
+ app.needsRender = true;
+ }
+
+ // ------------------------------------------------------------ prop dragging
+ // A prop can be dragged with the mouse. The pointer picks one, then moves it in
+ // the plane that faces the camera, so it follows the cursor at whatever depth
+ // it already sits at. (The ground plane is the other obvious choice, but the
+ // camera sits almost level with the floor, so the floor is nearly edge-on and
+ // a prop dragged across it would fly off.)
+ //
+ // The listener is on `stage` in the CAPTURE phase: that runs before the canvas
+ // and `view`'s container, so stopping the event there keeps the orbit controls
+ // and the bone picking out of a prop drag.
+ const propRaycaster = new THREE.Raycaster();
+ const propPointer = new THREE.Vector2();
+ const propPlane = new THREE.Plane();
+ const propPoint = new THREE.Vector3();
+ const propNormal = new THREE.Vector3();
+ let propDrag = null;
+ // The 見えない壁 uses the same machinery: `wallDrag` keeps the grabbed point's
+ // offset from the wall's anchor so grabbing a corner does not make it jump.
+ const wallDrag = { pointerId: null, key: 'wall', offset: new THREE.Vector3() };
+
+ /** The pointer in normalised device coordinates, i.e. what a raycaster wants. */
+ function pointerNdc(event) {
+ const rect = canvas.getBoundingClientRect();
+ return propPointer.set(
+ ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1,
+ -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1,
+ );
+ }
+
+ /** The prop under the pointer as `{ index, group, point }`, or null. */
+ function propAt(event) {
+ if (!propRoot.children.length) return null;
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ const hits = propRaycaster.intersectObjects(propRoot.children, true);
+ if (!hits.length) return null;
+ // The meshes hang off the group `propRoot` holds, so walk up to that group.
+ let node = hits[0].object;
+ while (node.parent && node.parent !== propRoot) node = node.parent;
+ const items = state.props?.items ?? [];
+ const index = propRoot.children.indexOf(node);
+ if (index < 0 || index >= items.length) return null;
+ return { index, group: node, point: hits[0].point.clone() };
+ }
+
+ /**
+ * The point on the wall's guide under the pointer, or null.
+ *
+ * Only while the wall is on AND its guide is switched on: the guide is both
+ * what there is to grab and the switch that says "I am placing the wall now",
+ * so with it off a huge invisible plane cannot swallow every orbit drag.
+ */
+ function wallAt(event) {
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ let best = null;
+ for (let index = 0; index < clip.guides.length; index += 1) {
+ const key = index === 0 ? 'wall' : 'wall2';
+ const wall = state.render[key];
+ if (wall?.on !== true || wall?.guide !== true) continue;
+ const hits = propRaycaster.intersectObject(clip.guides[index], false);
+ if (!hits.length) continue;
+ if (!best || hits[0].distance < best.distance) {
+ best = { index, key, point: hits[0].point.clone() };
+ }
+ }
+ return best;
+ }
+
+ function onPropPointerDown(event) {
+ if (event.button !== 0) return;
+ if (event.target?.dataset?.captionHandle) return; // a bubble drags itself
+ if (rig?.controls?.dragging) return; // the rotation gizmo is in charge
+ const hit = propAt(event);
+ if (hit) {
+ // Claim the pointer, so nothing underneath sees it.
+ event.stopPropagation();
+ event.preventDefault();
+ view.camera.getWorldDirection(propNormal);
+ propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), hit.point);
+ propDrag = {
+ pointerId: event.pointerId,
+ index: hit.index,
+ group: hit.group,
+ // Grabbing a corner rather than the middle must not make it jump.
+ offset: hit.group.position.clone().sub(hit.point),
+ };
+ stage.style.cursor = 'grabbing';
+ app.needsRender = true;
+ return;
+ }
+
+ const onWall = wallAt(event);
+ if (!onWall) {
+ // Nothing to grab. Remember the press, and settle on pointer-up whether it
+ // was a tap (set the 見る先 target) or the start of an orbit.
+ if (state.lookAt?.enabled === true && !rig?.controls?.dragging) {
+ lookTap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
+ }
+ return;
+ }
+ // A prop wins if it is under the pointer; otherwise the wall takes it.
+ event.stopPropagation();
+ event.preventDefault();
+ view.camera.getWorldDirection(propNormal);
+ propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), onWall.point);
+ const wall = state.render[onWall.key] ?? (state.render[onWall.key] = defaultState().render[onWall.key]);
+ const offset = characterOffset();
+ const yaw = wallYaw();
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ const px = onWall.point.x - offset.x;
+ const pz = onWall.point.z - offset.z;
+ wallDrag.pointerId = event.pointerId;
+ wallDrag.key = onWall.key;
+ // Keep the grab point's offset *in the wall's own (character-relative) frame*,
+ // so grabbing a corner does not make the wall jump.
+ wallDrag.offset.set(
+ (wall.x ?? 0) - (cos * px - sin * pz),
+ (wall.y ?? 0) - (onWall.point.y - offset.y),
+ (wall.z ?? 0) - (sin * px + cos * pz),
+ );
+ stage.style.cursor = 'grabbing';
+ app.needsRender = true;
+ }
+
+ function onPropPointerMove(event) {
+ if (lookTap && event.pointerId === lookTap.pointerId
+ && Math.hypot(event.clientX - lookTap.x, event.clientY - lookTap.y) > LOOK_TAP_SLOP) {
+ lookTap = null; // that was an orbit drag, not a tap
+ }
+ if (wallDrag.pointerId != null && event.pointerId === wallDrag.pointerId) {
+ event.stopPropagation();
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return;
+ const offset = characterOffset();
+ const yaw = wallYaw();
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ const px = propPoint.x - offset.x;
+ const pz = propPoint.z - offset.z;
+ const wall = state.render[wallDrag.key] ?? (state.render[wallDrag.key] = defaultState().render[wallDrag.key]);
+ // Back into the wall's character-relative frame, then the same ranges the
+ // panel's sliders offer, so a drag and a number always describe the same
+ // place.
+ wall.x = round2(clampNumber(cos * px - sin * pz + wallDrag.offset.x, -4, 4));
+ wall.y = round2(clampNumber(propPoint.y - offset.y + wallDrag.offset.y, -2, 4));
+ wall.z = round2(clampNumber(sin * px + cos * pz + wallDrag.offset.z, -4, 4));
+ applyWalls();
+ app.needsRender = true;
+ return;
+ }
+ if (!propDrag || event.pointerId !== propDrag.pointerId) return;
+ event.stopPropagation();
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return;
+ const next = propPoint.add(propDrag.offset);
+ const item = state.props?.items?.[propDrag.index];
+ if (!item) return;
+ // Clamp to the ranges the panel's sliders offer, so a drag can never put a
+ // prop somewhere the numbers cannot describe (and never through the floor).
+ item.x = round2(clampNumber(next.x, -4, 4));
+ item.y = round2(clampNumber(next.y, 0, 2));
+ item.z = round2(clampNumber(next.z, -4, 4));
+ propDrag.group.position.set(item.x, item.y, item.z);
+ // Keep `applyProps`' signature in step with what is on screen, so a refresh
+ // during the drag does not tear the group down and rebuild it from the state
+ // (which would re-create every geometry on every pointer move).
+ propSignature = JSON.stringify([state.props?.items ?? [], state.render.colors]);
+ app.needsRender = true;
+ }
+
+ function onPropPointerUp(event) {
+ if (lookTap && (!event || event.pointerId === lookTap.pointerId)) {
+ lookTap = null;
+ if (event) applyLookTap(event);
+ return;
+ }
+ if (wallDrag.pointerId != null && (!event || event.pointerId === wallDrag.pointerId)) {
+ wallDrag.pointerId = null;
+ stage.style.cursor = '';
+ history.push(state, '見えない壁');
+ app.panel?.sync();
+ return;
+ }
+ if (!propDrag || (event && event.pointerId !== propDrag.pointerId)) return;
+ propDrag = null;
+ stage.style.cursor = '';
+ // One undo step for the whole drag, and the panel catches up with the value.
+ history.push(state, '小物');
+ app.panel?.sync();
+ }
+
+ // A tap (a press that does not turn into a drag) in the viewport sets the
+ // 見る先 target, when that mode is on. Cancelled as soon as the pointer moves,
+ // so an orbit drag never moves the target by accident.
+ let lookTap = null;
+ const LOOK_TAP_SLOP = 5;
+
+ stage.addEventListener('pointerdown', onPropPointerDown, { capture: true });
+ window.addEventListener('pointermove', onPropPointerMove);
+ window.addEventListener('pointerup', onPropPointerUp);
+ window.addEventListener('pointercancel', onPropPointerUp);
+
+ // ---------------------------------------------------------------- look-at
+ const lookScratch = new THREE.Vector3();
+ const headScratch = new THREE.Vector3();
+ const lookPlane = new THREE.Plane();
+ const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
+ const lookPlaneNormal = new THREE.Vector3();
+ const lookPivot = new THREE.Vector3();
+ const lookHit = new THREE.Vector3();
+ const lookFloor = new THREE.Vector3();
+ /** Where the eyes should aim this frame, from `state.lookAt` (a world point). */
+ let aimOffset = { x: 0, y: 0 };
+
+ /**
+ * The world point under a viewport tap, for the 見る先 target.
+ *
+ * The tap is projected onto the first thing it can sensibly mean: a vertical
+ * plane through the character that faces the camera (so a tap on the body or
+ * the backdrop keeps the character's own depth), or the floor, for a tap that
+ * lands on the ground in front. The nearer of the two wins.
+ */
+ function lookAtPointFrom(event) {
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ const ray = propRaycaster.ray;
+ const headY = container.position.y + model.size.y * 0.72;
+ lookPlaneNormal.set(ray.direction.x, 0, ray.direction.z);
+ if (lookPlaneNormal.lengthSq() < 1e-8) lookPlaneNormal.set(0, 0, 1);
+ lookPlane.setFromNormalAndCoplanarPoint(
+ lookPlaneNormal.normalize(),
+ lookPivot.set(container.position.x, headY, container.position.z),
+ );
+ const onPlane = ray.intersectPlane(lookPlane, lookHit);
+ const onFloor = ray.direction.y < -1e-4 ? ray.intersectPlane(floorPlane, lookFloor) : null;
+ if (!onPlane) return onFloor;
+ if (!onFloor) return onPlane;
+ return onPlane.distanceToSquared(ray.origin) <= onFloor.distanceToSquared(ray.origin) ? onPlane : onFloor;
+ }
+
+ /** A tap in the viewport: aim the eyes at whatever was under the pointer. */
+ function applyLookTap(event) {
+ if (state.lookAt?.enabled !== true) return;
+ const point = lookAtPointFrom(event);
+ if (!point) return;
+ const cfg = state.lookAt;
+ // The same ranges the panel's sliders offer, so a tap and a number describe
+ // the same place.
+ cfg.x = round2(clampNumber(point.x, -6, 6));
+ cfg.y = round2(clampNumber(point.y, 0, 5));
+ cfg.z = round2(clampNumber(point.z, -6, 6));
+ history.push(state, '見る先');
+ refresh('lookAt');
+ app.panel?.sync();
+ }
+
+ function applyLookAt() {
+ const cfg = state.lookAt;
+ if (!cfg?.enabled) {
+ // Switching the mode off leaves the character where it was: the body turn
+ // and the gaze are remembered, not re-derived, so nothing snaps back.
+ container.rotation.y = (cfg?.bodyYawDeg ?? 0) * DEG;
+ const frozen = cfg?.freeze;
+ return frozen
+ ? { x: clamp(frozen.x ?? 0, -1, 1), y: clamp(frozen.y ?? 0, -1, 1) }
+ : { x: 0, y: 0 };
+ }
+
+ container.updateMatrixWorld(true);
+ lookScratch.set(cfg.x ?? 0, cfg.y ?? 0, cfg.z ?? 0);
+ mirrorGroup.worldToLocal(lookScratch);
+ headScratch.set(0, model.size.y * 0.72, 0);
+ lookScratch.sub(headScratch);
+
+ const flat = Math.hypot(lookScratch.x, lookScratch.z) || 1e-6;
+ const yaw = Math.atan2(lookScratch.x, lookScratch.z);
+ const pitch = Math.atan2(lookScratch.y, flat);
+ const amount = clamp(cfg.amount ?? 1, 0, 1);
+ const aim = {
+ x: clamp((yaw / MAX_LOOK_YAW) * amount, -1, 1),
+ y: clamp((pitch / MAX_LOOK_PITCH) * amount, -1, 1),
+ };
+
+ // Turning the whole body is what a person does to look behind themselves; a
+ // half-strength turn keeps the feet planted while the body leans round. The
+ // turn is *stored*, so switching 体も向ける off stops updating it and the body
+ // stays where it is instead of snapping back to the front.
+ if (cfg.turnBody) {
+ cfg.bodyYawDeg = round2(clamp(yaw, -MAX_LOOK_YAW * 1.6, MAX_LOOK_YAW * 1.6) * amount * 0.7 / DEG);
+ }
+ container.rotation.y = (cfg.bodyYawDeg ?? 0) * DEG;
+
+ // Keep the gaze too, for the moment the mode is switched off.
+ cfg.freeze = aim;
+ return aim;
+ }
+
+ // -------------------------------------------- history, 口パク, gacha
+ const history = new History({ limit: 120 });
+ /** How far the mouth is open right now because of the 口パク animation. */
+ let flapMouth = 0;
+ const mouthFlap = createMouthFlap({
+ onLevel: (level) => {
+ flapMouth = levelToMouth(level, state.mouthFlap?.mouthGain ?? 1);
+ app.needsRender = true;
+ },
+ onEnd: () => { flapMouth = 0; app.needsRender = true; },
+ });
+
+ let rig = null;
+ // The gizmo always belongs to whichever camera is currently active.
+ view.onCameraChange = (camera, controls) => {
+ if (!rig) return;
+ rig.controls.camera = camera;
+ rig.orbit = controls;
+ };
+
+ rig = new Rig({
+ bones: model.bones,
+ scene,
+ camera: view.camera,
+ domElement: canvas,
+ orbit: view.controls,
+ pickTargets: () => [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body],
+ onChange: () => {
+ capturePose();
+ app.panel?.onRigChanged?.();
+ app.needsRender = true;
+ },
+ });
+
+ // The ground, the contact shadow and the gizmo are scenery, not character, and
+ // the two face plates are the *front half* of the body: their open edge would be
+ // picked up as a silhouette and draw a line across the face, while contributing
+ // nothing, since they sit exactly on the body.
+ //
+ // The outline hulls must stay out too, and that one is easy to miss: they are
+ // extra copies of every mesh sitting in the scene graph, and this pass swaps the
+ // material of every visible mesh. A hull is an expanded *back-face* shell, so
+ // handing it a plain front-side label material puts an expanded copy of the
+ // whole character in front of itself - it then paints its own label over
+ // everything, and every leaf edge against the body is inked as if the body were
+ // empty paper. That is exactly the faint dotted line along the leaves' bases.
+ function outlineExclusion() {
+ return [
+ ground,
+ look.contact,
+ ...clip.guides,
+ rig.helper,
+ model.parts.eyeMesh,
+ model.parts.mouthMesh,
+ ...(model.parts.hairMesh ? [model.parts.hairMesh] : []),
+ ...snotMeshes,
+ ...hatMeshes,
+ ...styles.outlineMeshes,
+ ];
+ }
+ outline.exclude(outlineExclusion());
+ refreshOutlineExclusion = () => outline.exclude(outlineExclusion());
+ // The wall is *not* excluded: it is handed to the pass as an occluder, so the
+ // labels behind it are culled as well. Excluded, the leaves and the nose
+ // buried in the wall kept their outlines, because the labels are read before
+ // its depth is applied.
+ outline.occlude(clip.occluders);
+
+ /**
+ * The parts the screen-space pass draws, with the label each one writes.
+ *
+ * The leaves are the hard case: a hull cannot outline a shell that thin. They
+ * are drawn with the LEAF label, which means the pass inks them where they meet
+ * the paper and where they fold against each other, but *not* where they run
+ * into the body - a line there reads as the leaf sinking into the body, and the
+ * original artwork has none (the leaf simply passes behind the body).
+ *
+ * The nose joins them in the line-art styles, with the NOSE label, so its ring
+ * against the body *is* drawn: it is the only thing that shows the nose in a
+ * line drawing. In the shaded styles it is left out entirely, and its hull then
+ * keeps only the part that pokes out of the silhouette - which is what the
+ * original artwork does (the nose reads by its own colour there).
+ *
+ * @param {string} style
+ * @returns {Array<[import('three').Object3D, number]>}
+ */
+ function screenParts(style) {
+ const parts = (model.parts.leaves ?? []).map((mesh) => [mesh, LEAF_LABEL]);
+ if (isLineStyle(style) && model.parts.noseMesh) parts.push([model.parts.noseMesh, NOSE_LABEL]);
+ return parts;
+ }
+
+ /**
+ * The label every part writes (see src/outline.js).
+ *
+ * The leaves and the nose are the outlined ones. The body's label is the one
+ * real choice here, and it is exposed as `render.leafBodyLine`:
+ *
+ * - `BEHIND` (the default) makes the body count as paper, so each leaf is
+ * outlined where it emerges from the body. In a line drawing the two are
+ * paper on paper, so without this the leaves and the body merge into one
+ * white shape with no way to tell them apart - which is the greater evil.
+ * - `SOLID` makes the body block that line, so a leaf simply passes behind it.
+ * Cleaner where it works, but the leaves lose their outline there.
+ *
+ * Everything else - the feet, the hands, the props - is *merely behind*, so it
+ * stays BEHIND either way: it still hides what is behind it, but a leaf lying
+ * across it keeps its outline.
+ */
+ function outlineLabels(parts) {
+ const labels = new Map(parts);
+ const bodyLabel = state.render.leafBodyLine === false ? SOLID_LABEL : BEHIND_LABEL;
+ for (const mesh of model.parts.body ?? []) {
+ if (labels.has(mesh)) continue;
+ labels.set(mesh, model.parts.kinds?.get(mesh) === 'blb' ? bodyLabel : BEHIND_LABEL);
+ }
+ return [...labels];
+ }
+
+ /** Point both outline systems at the right parts for `method`. */
+ function applyOutlineMethod(style, method = state.render.outlineMethod) {
+ const hybrid = method === 'screen';
+ const parts = hybrid ? screenParts(style) : [];
+ styles.setHullHidden(parts.map(([mesh]) => mesh));
+ outline.only(parts.length ? outlineLabels(parts) : null);
+ }
+
+ /**
+ * Run `fn` with the outline pass set up for another style, then put the
+ * on-screen configuration back. Both the hull/screen split and which parts the
+ * screen pass may see are shared by the live view and every offscreen render,
+ * so a pass that draws another style has to say so - the SVG and the "lines
+ * only" PNG are ink through and through, so they always want the line-art
+ * arrangement (a hull cannot outline the leaves at all).
+ */
+ async function withOutlineFor({ style, method = 'screen' }, fn) {
+ applyOutlineMethod(style, method);
+ try {
+ return await fn();
+ } finally {
+ applyOutlineMethod(state.render.style);
+ }
+ }
+
+ Object.assign(app, {
+ renderer, scene, camera: view.camera, view, styles, face, rig, animator,
+ key, ambient, ground, environment, halfHeight, outline, clip,
+ look, backdrop, history, mouthFlap, propRoot, mirrorGroup, container,
+ get captionFont() { return captionFont; },
+ get backdropImage() { return backdropImage; },
+ });
+
+ const recorder = createRecorder(canvas);
+
+ // -------------------------------------------------------------- the panel
+ const panel = buildPanel(app, document.getElementById('panel'));
+ app.panel = panel;
+
+ // On a phone the panel is a bottom sheet. Its grab handle drags the sheet's top
+ // edge up and down, so the menu can be pulled out of the way of the character.
+ const panelEl = document.getElementById('panel');
+ const panelGrip = document.createElement('div');
+ panelGrip.className = 'panel-grip';
+ panelGrip.setAttribute('aria-hidden', 'true');
+ panelEl.prepend(panelGrip);
+ installSheetDrag(panelEl, panelGrip);
+
+ /** Drag on the phone sheet's handle; a no-op anywhere else. */
+ function installSheetDrag(sheet, grip) {
+ const phone = window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)');
+ let drag = null;
+ const minHeight = () => Math.round(window.innerHeight * 0.16);
+ const maxHeight = () => Math.round(window.innerHeight * 0.94);
+
+ grip.addEventListener('pointerdown', (event) => {
+ if (!phone.matches) return;
+ const rect = sheet.getBoundingClientRect();
+ // Start from the height the sheet has now, so the first move does not jump.
+ drag = { id: event.pointerId, fromY: event.clientY, startH: rect.height };
+ sheet.style.maxHeight = 'none';
+ sheet.style.height = `${rect.height}px`;
+ try { grip.setPointerCapture(event.pointerId); } catch { /* capture is optional */ }
+ event.preventDefault();
+ });
+ grip.addEventListener('pointermove', (event) => {
+ if (!drag || event.pointerId !== drag.id) return;
+ // Dragging up grows the sheet (its top edge climbs); dragging down shrinks it.
+ const next = drag.startH + (drag.fromY - event.clientY);
+ sheet.style.height = `${Math.min(maxHeight(), Math.max(minHeight(), next))}px`;
+ event.preventDefault();
+ });
+ const end = (event) => {
+ if (!drag || (event && event.pointerId !== drag.id)) return;
+ drag = null;
+ };
+ grip.addEventListener('pointerup', end);
+ grip.addEventListener('pointercancel', end);
+
+ // A window grown to the desktop layout keeps the side column: drop the height
+ // the drag had fixed, so the panel goes back to filling the screen.
+ const clearOnDesktop = () => {
+ if (phone.matches) return;
+ sheet.style.height = '';
+ sheet.style.maxHeight = '';
+ };
+ window.addEventListener('resize', clearOnDesktop);
+ }
+
+ Object.assign(app.actions, {
+ capturePose,
+ applyState,
+ refresh,
+ loadHatModel,
+ patch: (scope, value) => applyState({ [scope]: value }, { scope }),
+ syncPanel: () => app.panel?.sync(),
+ imageCanvas: (options) => captureImage(options),
+ savePNG: (options = {}) => savePNG(options),
+ copyPNG: () => copyPNG(),
+ faceMapCanvas: (kind) => faceMapCanvas(kind),
+ saveFaceMap: (kind) => saveFaceMap(kind),
+ addGionStamp: (item) => addGionStamp(item),
+ saveSettings: () => exporter.downloadText(JSON.stringify(state, null, 2), `bluebey-settings-${exporter.timestamp()}.json`),
+ loadSettings: (text) => {
+ const data = JSON.parse(text);
+ applyState(data, { full: true, sync: true });
+ toast('設定を読み込みました');
+ },
+ toggleRecording: () => toggleRecording(recorder),
+ resetPose: () => {
+ rig.reset();
+ state.pose = { bones: {}, root: [0, 0, 0] };
+ applyState({}, { scope: 'pose', sync: true });
+ toast('ポーズをリセットしました');
+ },
+ setCameraPreset: (id) => setCameraPreset(id),
+ undo: () => restoreFromHistory('undo'),
+ redo: () => restoreFromHistory('redo'),
+ applyTheme: (id) => applyTheme(id),
+ rollGacha: (seed) => rollGacha(seed),
+ copyShareLink: () => copyShareLink(),
+ shareImage: (network) => shareImage(network),
+ toggleMouthFlap: () => toggleMouthFlap(),
+ storyThumbs: () => storyThumbs,
+ toggleCamera: (on) => toggleCamera(on),
+ addProp: (kind) => addProp(kind),
+ addStoryPanel: () => addStoryPanel(),
+ saveStory: (options) => saveStory(options),
+ loadFromHash: () => loadFromHash(),
+ toggleAnimation: () => {
+ state.anim.mode = (state.anim.mode ?? 'off') === 'off' ? 'idle' : 'off';
+ app.animator.reset();
+ panel.sync();
+ app.needsRender = true;
+ },
+ });
+
+ installKeys();
+ installTopbar();
+ window.addEventListener('focus', () => { app.needsRender = true; });
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden) app.needsRender = true;
+ });
+
+ {
+ const buffer = renderer.getDrawingBufferSize(new THREE.Vector2());
+ console.log(
+ '[bluebey] ready'
+ + ` canvas=${canvas.clientWidth}x${canvas.clientHeight}`
+ + ` buffer=${Math.round(buffer.x)}x${Math.round(buffer.y)}`
+ + ` quality=${quality}`
+ + ` dpr=${window.devicePixelRatio}`
+ + ` model=${model.size.toArray().map((v) => v.toFixed(2)).join(',')}`
+ + ` camera=${view.camera.position.toArray().map((v) => v.toFixed(2)).join(',')}`,
+ );
+ }
+ window.addEventListener('resize', () => resizeViewport());
+
+ applyState({}, {});
+ await loadFromHash();
+ history.reset(state);
+ rig.select('master', { silent: true });
+ panel.sync();
+
+ let last = performance.now();
+ let faceSignature = '';
+ let slowFrames = 0;
+ let framesSinceFps = 0;
+ let fpsAt = performance.now();
+ const warmupUntil = performance.now() + 2500;
+
+ let snotSignature = null;
+ const loop = (now) => {
+ const rawDelta = now - last;
+ const dt = Math.min(0.05, rawDelta / 1000);
+ last = now;
+
+ animator.update(dt);
+
+ const posed = animator.pose(state.pose);
+ if (!rig.controls.dragging) rig.applyPose(posed);
+ container.position.set(posed.root[0] ?? 0, posed.root[1] ?? 0, posed.root[2] ?? 0);
+ updateKeyLight();
+ // The contact blob rides under the character, so it never lingers at the origin.
+ look.contact.position.x = container.position.x;
+ look.contact.position.z = container.position.z;
+
+ applyHat(state.face.hat?.kind ?? 'none');
+
+ if (snotBubble) {
+ const snot = state.face.eyes.snot;
+ const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.offsetZ}|${snot.color}`;
+ if (sig !== snotSignature) {
+ snotSignature = sig;
+ applySnotBubble(snotBubble, snot);
+ app.needsRender = true;
+ }
+ // The sleeping breath swells the bubble; `snotScale` is 1 otherwise.
+ if (snotBubble.visible) {
+ const scale = (snotBubble.userData.baseScale ?? 1) * (animator.snotScale ?? 1);
+ if (snotBubble.scale.x !== scale) {
+ snotBubble.scale.setScalar(scale);
+ app.needsRender = true;
+ }
+ }
+ }
+
+ const params = buildFaceParams();
+ const signature = JSON.stringify(params);
+ if (signature !== faceSignature) {
+ faceSignature = signature;
+ face.setParams(params, faceMode());
+ app.needsRender = true;
+ }
+ if (face.flush(now, 26)) app.needsRender = true;
+
+ view.controls.update(dt);
+
+ const animating = (state.anim.mode ?? 'off') !== 'off'
+ || state.anim.lookAround
+ || animator.blinkPhase >= 0
+ || recorder?.recorder?.state === 'recording';
+ // Always paint for the first couple of seconds, so a missed "needs render"
+ // can never leave an empty window staring back at the user.
+ if (app.needsRender || animating || view.controlsChanged || now < warmupUntil) {
+ app.needsRender = false;
+
+ // The screen-space outline needs one extra pass *before* the scene, so it
+ // wraps the normal render instead of following it. `outline.render` paints
+ // the scene itself (ink and all), so there must be no second render after
+ // it - that would clear the canvas and wipe the ink straight back off.
+ const wantsScreen = state.render.outlineMethod === 'screen' && state.render.outline !== false;
+ outline.render(() => renderer.render(scene, view.camera), {
+ enabled: wantsScreen,
+ camera: view.camera,
+ color: state.render.outlineColor,
+ // The label buffer is `quality`x the CSS size, so the radius has to carry
+ // that factor to keep the leaf/nose line a constant width in CSS pixels -
+ // otherwise it halves on a HiDPI display and jumps when the app lowers
+ // the quality on slow frames.
+ radius: outlineRadiusFor(view, state, quality),
+ });
+ view.controlsChanged = false;
+ framesSinceFps += 1;
+
+ // Hardware that cannot keep up gets a smaller drawing buffer instead of a
+ // slideshow (a very large buffer can look like "nothing ever appears").
+ if (rawDelta > 90) slowFrames += 1;
+ else slowFrames = 0;
+ if (slowFrames >= 8 && quality > QUALITY_FLOOR + 0.01 && autoReductions < 3) {
+ autoReductions += 1;
+ quality = Math.max(QUALITY_FLOOR, Math.round((quality - 0.25) * 100) / 100);
+ renderer.setPixelRatio(quality);
+ slowFrames = 0;
+ console.warn('[bluebey] frames are slow, lowering the drawing quality to', quality);
+ }
+ }
+
+ if (now - fpsAt > 1000) {
+ const fps = Math.round((framesSinceFps * 1000) / (now - fpsAt));
+ framesSinceFps = 0;
+ fpsAt = now;
+ const buffer = renderer.getDrawingBufferSize(new THREE.Vector2());
+ panel.setStats?.(
+ `描画 ${Math.round(buffer.x)}×${Math.round(buffer.y)}(画質 ${quality.toFixed(2)}×)/約 ${fps}fps`
+ + (autoReductions > 0 ? ' ※重いので自動で軽くしました' : ''),
+ );
+ }
+
+ requestAnimationFrame(loop);
+ };
+ requestAnimationFrame(loop);
+
+ hideLoading();
+ app.ready = true;
+ window.__bluebeyReady = true;
+ window.__bluebey = {
+ app,
+ state,
+ set: (patch) => applyState(patch),
+ face: (patch) => applyState({ face: patch }),
+ pose: (patch) => applyState({ pose: patch }),
+ view: (patch) => applyState({ view: patch }),
+ render: (patch) => applyState({ render: patch }),
+ selectBone: (name) => rig.select(name),
+ preset: (id) => app.actions.applyFacePreset?.(id),
+ posePreset: (id) => app.actions.applyPosePreset?.(id),
+ png: (options) => captureImage(options),
+ };
+
+ /* ------------------------------------------------------------- internals */
+
+ /** The camera the outline pass should use (kept in step with the view rig). */
+ function outlineCamera() {
+ return view.camera;
+ }
+
+ /** The gizmo/frame loop re-reads the bones, so store them back into state. */
+ function capturePose() {
+ const pose = rig.getPose();
+ state.pose.bones = pose.bones;
+ }
+
+ function buildFaceParams() {
+ const eyes = state.face.eyes;
+ const openScale = animator.eyeOpen;
+ const drift = animator.look;
+
+ const eyeFor = (key) => {
+ const own = eyes[key];
+ // A shut artwork (1〜3線 / 3の目 / わらう) does not blink: its lid stays where
+ // the user put it, so the blink animation must not scale its `open`.
+ const shutArt = own.shape === 'three' || own.shape === 'arch'
+ || (typeof own.shape === 'string' && own.shape.startsWith('line'));
+ return {
+ // Which artwork this eye shows (ふつう / 1〜3線 / 3の目 / わらう / ハート).
+ // The renderer chooses the shut artwork from this, so it must travel.
+ shape: own.shape,
+ open: clamp01(own.open * (shutArt ? 1 : openScale)),
+ lookX: clamp(own.lookX + drift.x + aimOffset.x, -1, 1),
+ lookY: clamp(own.lookY + drift.y + aimOffset.y, -1, 1),
+ closed: own.closed,
+ closedLines: own.closedLines,
+ irisShape: own.irisShape,
+ threeFlip: own.threeFlip === true,
+ // 白目 / 光彩 are per eye and tri-state: `null` means "the usual" (open
+ // eyes show their white; the shared 光彩 switch decides the glint).
+ white: own.white ?? null,
+ highlight: own.highlight ?? null,
+ // 形の大きさ scales the drawn eye shape (the shut lines, the 3, the arch)
+ // and the heart.
+ shapeScale: own.shapeScale ?? 1,
+ // The tears are per eye, and only drawn when their own switch is on.
+ tearOn: own.tearOn === true,
+ tear: own.tear ?? 0.3,
+ tearY: own.tearY ?? 0,
+ // Where this eye sits, and where its teardrop hangs, can be nudged one
+ // side at a time (an asymmetric face).
+ eyeX: own.eyeX ?? 0,
+ tearX: own.tearX ?? 0,
+ tearTilt: own.tearTilt ?? 0,
+ // The lower lid can differ per eye (falling back to the shared value).
+ lowerLid: own.lowerLid ?? eyes.lowerLid ?? 0,
+ // The rest of the lids are per eye too now (null = use the shared value).
+ lidShape: own.lidShape ?? null,
+ lidWidth: own.lidWidth ?? null,
+ lidTilt: own.lidTilt ?? null,
+ lashes: own.lashes ?? null,
+ lashAngle: own.lashAngle ?? null,
+ lashPos: own.lashPos ?? null,
+ };
+ };
+
+ // Lip-sync and a look-at target both drive the mouth and the eyes without
+ // being part of the saved expression.
+ const mouth = { ...state.face.mouth };
+ if (flapMouth > 0.001) mouth.open = Math.max(mouth.open ?? 0, flapMouth);
+ // A talking mouth has no tongue sticking out. This follows the whole flap
+ // session rather than the opening: the envelope dips through zero between
+ // syllables, and keying it off the opening made the tongue flicker back into
+ // view on every closed frame.
+ if (mouthFlap.running) mouth.tongue = 0;
+
+ return {
+ eyes: {
+ // Everything shared by both eyes (colours, iris scale, brows, glasses,
+ // ...) has to travel with the per-eye values, because the face renderer
+ // reads them from here. Spreading `...eyes` is also what makes the face
+ // redraw when one of them (e.g. `glasses`) changes: the loop compares the
+ // JSON of this object against the last one it drew.
+ ...eyes,
+ left: eyeFor('left'),
+ right: eyeFor('right'),
+ },
+ mouth,
+ };
+ }
+
+ function faceMode() {
+ return isLineStyle(state.render.style) ? 'line' : 'paint';
+ }
+
+ function applyState(patch, { silent = false, full = false, scope = 'all', sync = false } = {}) {
+ if (full) {
+ const fresh = defaultState();
+ applyPatch(fresh, patch);
+ for (const key of Object.keys(fresh)) state[key] = fresh[key];
+ } else {
+ applyPatch(state, patch);
+ }
+ refresh(scope);
+ app.needsRender = true;
+ if (sync && !silent) app.panel?.sync();
+ }
+
+ /** Re-apply the parts of the state named by `scope` ('all' by default). */
+ function refresh(scope = 'all') {
+ app.needsRender = true;
+ // Where the eyes aim depends on where the character is, so it is recomputed
+ // whenever anything that moves could change.
+ if (scope === 'all' || scope === 'view' || scope === 'pose' || scope === 'face' || scope === 'lookAt') {
+ aimOffset = applyLookAt();
+ }
+ if (scope === 'all' || scope === 'render') {
+ styles.setStyle(state.render.style);
+ styles.setPaper(state.render.paper);
+ styles.setOutlineWidth(state.render.outlineWidth);
+ styles.setOutlineColor(state.render.outlineColor);
+ styles.setOutlineEnabled(state.render.outline);
+ // Which parts each outline method draws, and which hulls stand down for it
+ // (the leaves, plus the nose in a line drawing). See `applyOutlineMethod`.
+ applyOutlineMethod(state.render.style);
+
+ const line = isLineStyle(state.render.style);
+ // Shadows, reflections, body colours and mirroring are the look module's
+ // job, so that they are applied together and stay consistent.
+ look.apply(state.render, { line });
+ applyProps();
+ // 見えない壁: the one setting that changes what is drawn rather than how.
+ applyWalls();
+
+ const azimuth = state.render.lightAzimuth * DEG;
+ const elevation = state.render.lightElevation * DEG;
+ const distance = model.size.y * 3;
+ keyOffset.set(
+ Math.cos(elevation) * Math.sin(azimuth) * distance,
+ Math.sin(elevation) * distance,
+ Math.cos(elevation) * Math.cos(azimuth) * distance,
+ );
+ updateKeyLight();
+ key.intensity = state.render.lightIntensity;
+ ambient.intensity = state.render.ambient;
+ key.color.set(state.render.lightColor ?? '#ffffff');
+ ambient.color.set(state.render.ambientColor ?? '#ffffff');
+ renderer.toneMappingExposure = state.render.exposure;
+ applyBackground();
+ renderer.shadowMap.needsUpdate = true;
+ }
+
+ if (scope === 'all' || scope === 'view') {
+ applyBackdrop();
+ // The renderer's *clear* depends on the background mode as well: a preset
+ // photo, a loaded picture and the camera all show through a transparent
+ // clear (see `backgroundOf`). Without this, switching the background left
+ // the canvas clearing to the old opaque colour and hid the layer that had
+ // just been set up behind it - which is why the background only appeared
+ // after some other change (adding a prop) re-ran this.
+ applyBackground();
+ }
+ if (scope === 'all' || scope === 'view' || scope === 'caption' || scope === 'gion') redrawCaption();
+
+ // A style change flips the face between paint and ink (`faceMode`), so the
+ // 'render' scope has to rebuild the face too - otherwise the old drawing
+ // stays on the plates until something else (a blink) happens to dirty them.
+ if (scope === 'all' || scope === 'face' || scope === 'render') {
+ face.setSource('eyes', state.face.eyes.source);
+ face.setSource('mouth', state.face.mouth.source);
+ face.setParams(buildFaceParams(), faceMode());
+ }
+
+ if (scope === 'all' || scope === 'view') {
+ view.setProjection(state.view.projection);
+ view.apply(state.view, model.size);
+ view.setAutoRotate(state.view.autoRotate, state.view.autoRotateSpeed);
+ }
+
+ if (scope === 'all' || scope === 'pose') {
+ rig.applyPose(state.pose);
+ const root = state.pose.root ?? [0, 0, 0];
+ container.position.set(root[0] ?? 0, root[1] ?? 0, root[2] ?? 0);
+ }
+
+ // Every user-driven change funnels through here, so this is the one place
+ // that has to remember a step for undo. Undo itself sets `suspendHistory`.
+ if (!suspendHistory) history.push(state, SCOPE_LABELS[scope] ?? scope);
+ }
+
+ /** Paint the backdrop bitmap behind the model, for an export. */
+ function backdropSource() {
+ if (state.view.background === 'camera') {
+ const video = backdrop.el?.querySelector?.('video');
+ return video && video.readyState >= 2 ? video : null;
+ }
+ return backdropImage;
+ }
+
+ /**
+ * Freeze the current camera frame into a canvas.
+ *
+ * The AR preview is a live video, so by the time the PNG finishes encoding the
+ * feed has moved on and the saved picture no longer matches what was on screen
+ * when the shutter was pressed. Snapping the frame first keeps the two in step.
+ */
+ function snapshotBackdrop() {
+ if (state.view.background !== 'camera') return undefined;
+ const video = backdrop.el?.querySelector?.('video');
+ if (!video || video.readyState < 2 || !video.videoWidth || !video.videoHeight) return undefined;
+ const frame = document.createElement('canvas');
+ frame.width = video.videoWidth;
+ frame.height = video.videoHeight;
+ try {
+ frame.getContext('2d').drawImage(video, 0, 0);
+ } catch {
+ return undefined;
+ }
+ return frame;
+ }
+
+ /**
+ * Paint the backdrop *behind* the rendered model.
+ *
+ * WHY the scratch canvas and `destination-over`: `ctx` already holds the
+ * character on a transparent field (see `exporter.renderStill`), so drawing the
+ * photo straight onto it with the default `source-over` painted OVER the
+ * character and the export came out as a bare photograph. Building the scene in
+ * a scratch canvas first also lets the dark veil sit on top of the photo, which
+ * it could not do once the photo had been slipped underneath.
+ */
+ function drawBackdropInto(ctx, width, height, scale, sourceOverride) {
+ const source = sourceOverride ?? backdropSource();
+ if (!source) return false;
+ const style = backdropStyle(state.view);
+ if (!style.visible) return false;
+ const iw = source.naturalWidth || source.videoWidth || source.width || 0;
+ const ih = source.naturalHeight || source.videoHeight || source.height || 0;
+ if (!iw || !ih) return false;
+
+ const back = document.createElement('canvas');
+ back.width = width;
+ back.height = height;
+ const paint = back.getContext('2d');
+
+ // The same CSS the live layer uses: blur (bleeding past the edges), the fit,
+ // the mirror and the scale/offset transform. The offset is in the element's
+ // own (pre-scale) space, so it is multiplied by `scale` here, exactly as
+ // `transform: scale(s) translate(...)` does in CSS.
+ const bleed = style.blur > 0 ? style.blur * 2 : 0;
+ const w = width + bleed * 2;
+ const h = height + bleed * 2;
+ paint.save();
+ if (style.blur > 0) paint.filter = `blur(${style.blur}px)`;
+ paint.translate(
+ width / 2 + style.scale * style.offset.x * width,
+ height / 2 + style.scale * style.offset.y * height,
+ );
+ paint.scale(style.scale * (style.mirror ? -1 : 1), style.scale);
+ paint.translate(-width / 2, -height / 2);
+
+ if (style.backgroundSize === 'cover' || style.backgroundSize === 'contain') {
+ const fit = style.backgroundSize === 'cover'
+ ? Math.max(w / iw, h / ih)
+ : Math.min(w / iw, h / ih);
+ const dw = iw * fit;
+ const dh = ih * fit;
+ paint.drawImage(source, (w - dw) / 2 - bleed, (h - dh) / 2 - bleed, dw, dh);
+ } else if (style.backgroundSize === 'auto') {
+ // CSS repeats the picture at its own size and anchors the grid on the box
+ // centre (`background-position: center`). A canvas pattern instead repeats
+ // at the image's *intrinsic* size, which is `scale`x too small for a
+ // canvas that is `scale`x the CSS box - so the export tiled `scale`x more
+ // densely than the preview. Paint the tiles by hand at `scale`, on a grid
+ // centred on the box centre; the CTM above already carries the offset /
+ // backgroundScale / mirror, so the tiles must not re-apply them.
+ const tw = iw * scale;
+ const th = ih * scale;
+ // Cover only the user-space area the visible canvas needs (widened by the
+ // blur bleed), mapped back through the CTM. The CTM holds no rotation, so
+ // its `a`/`d` scale factors are enough to invert it for the bounds.
+ const matrix = paint.getTransform();
+ const [left, right] = [
+ (-bleed - matrix.e) / matrix.a,
+ (width + bleed - matrix.e) / matrix.a,
+ ].sort((a, b) => a - b);
+ const [top, bottom] = [
+ (-bleed - matrix.f) / matrix.d,
+ (height + bleed - matrix.f) / matrix.d,
+ ].sort((a, b) => a - b);
+ const cx = width / 2;
+ const cy = height / 2;
+ const firstCol = Math.floor((left - cx) / tw);
+ const lastCol = Math.ceil((right - cx) / tw);
+ const firstRow = Math.floor((top - cy) / th);
+ const lastRow = Math.ceil((bottom - cy) / th);
+ for (let row = firstRow; row <= lastRow; row++) {
+ for (let col = firstCol; col <= lastCol; col++) {
+ paint.drawImage(source, cx + col * tw - tw / 2, cy + row * th - th / 2, tw, th);
+ }
+ }
+ } else {
+ paint.drawImage(source, -bleed, -bleed, w, h);
+ }
+ paint.restore();
+
+ if (style.darken > 0) {
+ paint.fillStyle = `rgba(0, 0, 0, ${style.darken})`;
+ paint.fillRect(0, 0, width, height);
+ }
+
+ ctx.save();
+ ctx.globalCompositeOperation = 'destination-over';
+ ctx.drawImage(back, 0, 0);
+ ctx.restore();
+ return true;
+ }
+
+ /** Load (or clear) the backdrop bitmap that the exports composite. */
+ function applyBackdrop() {
+ const viewState = state.view;
+ backdrop.apply(viewState);
+ // The AR shutter rides the viewport, not the panel, so it shows/hides here.
+ if (app.arShutter) app.arShutter.hidden = viewState.background !== 'camera';
+ // The touch hint would sit under the shutter, so it steps aside in AR mode.
+ const touchHint = document.getElementById('viewport-hint-touch');
+ if (touchHint) touchHint.style.visibility = viewState.background === 'camera' ? 'hidden' : '';
+ const url = viewState.background === 'preset'
+ ? assetUrl(viewState.backgroundPreset, 'background')
+ : viewState.background === 'image'
+ ? viewState.backgroundImage
+ : viewState.background === 'effect'
+ ? backdrop.effectUrl(viewState.backgroundEffect)
+ : null;
+ if (!url) {
+ if (viewState.background !== 'camera') backdropImage = null;
+ return;
+ }
+ if (backdropImage?.dataset?.url === url) return;
+ const image = new Image();
+ image.dataset.url = url;
+ image.onload = () => { backdropImage = image; app.needsRender = true; };
+ image.onerror = () => { backdropImage = null; };
+ image.src = url;
+ }
+
+ function applyBackground() {
+ exporter.applyBackground(renderer, backgroundOf(state.view));
+ }
+
+ async function captureImage({ scale = 2, transparent = null, lines = null } = {}) {
+ // Freeze the live camera frame up front, so the saved picture holds the frame
+ // that was on screen when the button was pressed (see `snapshotBackdrop`).
+ const frozenBackdrop = snapshotBackdrop();
+ return withHiddenHelpers(async () => {
+ // The face texture is redrawn on a throttle (every 26 ms) while the render
+ // loop runs, so a capture taken straight after a change could still hold the
+ // *previous* expression. That is what made a comic panel occasionally show
+ // its neighbour's face: force the pending redraw through first.
+ face.flush(performance.now(), 0);
+ const wantsTransparent = transparent === true
+ || (transparent == null && state.view.background === 'transparent');
+ // A transparent export of a line drawing drops the paper as well. The whole
+ // point of transparent line art is to lay the lines over another picture,
+ // and a white silhouette would just hide it - so this renders the `outline`
+ // style, which draws the ink and leaves the body out. A shaded style keeps
+ // its body, because there it is an ordinary cut-out of the character.
+ const wantsLines = lines ?? (wantsTransparent && isLineStyle(state.render.style));
+ const drawn = wantsLines ? 'outline' : state.render.style;
+ const background = wantsTransparent ? { mode: 'transparent' } : backgroundOf(state.view);
+
+ const onScreen = styles.style;
+ if (drawn !== onScreen) styles.setStyle(drawn);
+ let result;
+ try {
+ result = await withOutlineFor({ style: drawn, method: wantsLines ? 'screen' : state.render.outlineMethod }, () => exporter.capturePNG({
+ renderer,
+ scene,
+ camera: view.camera,
+ outline,
+ outlineOptions: {
+ enabled: state.render.outlineMethod === 'screen' && state.render.outline !== false,
+ color: state.render.outlineColor,
+ // The export's label buffer is `scale`x the one on screen (renderStill
+ // sets it to the output size), so its radius grows with it to keep the
+ // line's weight relative to the model the same as in the preview.
+ radius: outlineRadiusFor(view, state, scale),
+ },
+ width: canvas.clientWidth || window.innerWidth,
+ height: canvas.clientHeight || window.innerHeight,
+ restore: () => { resizeViewport(); applyStateBackgroundAgain(); },
+ }, { scale, background }));
+ } finally {
+ if (drawn !== onScreen) styles.setStyle(onScreen);
+ }
+
+ // The backdrop, the 擬音 stamps and the bubbles live in DOM layers behind
+ // and above the WebGL canvas, so an export has to paint them in itself - in
+ // the same order as the screen: backdrop, then stamps, then bubbles.
+ const ctx = result.canvas.getContext('2d');
+ // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps and the
+ // bubbles are drawn at below. The backdrop's tiled fit needs it too, so its
+ // tiles come out the size the preview's CSS paints them.
+ const base = canvas.clientWidth || 1280;
+ const outputScale = result.width / Math.max(1, base);
+ let changed = false;
+ if (!wantsTransparent && drawBackdropInto(ctx, result.width, result.height, outputScale, frozenBackdrop)) changed = true;
+ const gionItems = state.gion?.items ?? [];
+ if (gionItems.length) {
+ for (const item of gionItems) await ensureGionSheet(item.sheet);
+ drawGion(ctx, gionItems, gionImages, {
+ width: result.width,
+ height: result.height,
+ scale: outputScale,
+ });
+ changed = true;
+ }
+ const bubbles = CAPTION_KEYS.map((key) => state[key]).filter((caption) => caption?.enabled);
+ if (bubbles.length) {
+ await ensureCaptionFont();
+ for (const caption of bubbles) {
+ drawCaption(ctx, caption, {
+ width: result.width,
+ height: result.height,
+ scale: outputScale,
+ font: captionFont,
+ });
+ }
+ changed = true;
+ }
+ if (changed) result.blob = await exporter.canvasToBlob(result.canvas);
+ return result;
+ });
+ }
+
+ function applyStateBackgroundAgain() {
+ exporter.applyBackground(renderer, backgroundOf(state.view));
+ }
+
+ async function savePNG({
+ scale = state.render.pngScale ?? 2,
+ transparent = state.render.pngTransparent ? true : undefined,
+ } = {}) {
+ const result = await captureImage({ scale, transparent });
+ exporter.downloadBlob(result.blob, `bluebey-${exporter.timestamp()}.png`);
+ toast(`PNGを書き出しました(${result.width}×${result.height})`);
+ }
+
+ /**
+ * The canvas the 下地 export paints on, *without* downloading it. Kept separate
+ * from `saveFaceMap` so the in-page round-trip check can look at exactly the
+ * pixels the button writes.
+ */
+ function faceMapCanvas(kind) {
+ // A redraw is throttled while the render loop runs (see the loop's
+ // `face.flush(now, 26)`), so force any pending one through first or the map
+ // would show the expression before last.
+ face.flush(performance.now(), 0);
+ return kind === 'hair' ? exporter.buildHairMap(face) : exporter.buildFaceMap(face, kind);
+ }
+
+ async function saveFaceMap(kind) {
+ const canvas = faceMapCanvas(kind);
+ if (!canvas) {
+ toast('このモデルには髪のプレートがありません');
+ return;
+ }
+ const blob = await exporter.canvasToBlob(canvas);
+ const label = kind === 'eyes' ? '目' : kind === 'mouth' ? '口' : '髪';
+ exporter.downloadBlob(blob, `bluebey-face-${kind}-${exporter.timestamp()}.png`);
+ if (kind === 'hair') {
+ toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。`
+ + 'この画像に前髪を描いて渡してください');
+ return;
+ }
+ toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。`
+ + `この画像に描いて「${label}の画像を読み込む(PNG)」で読み込めます`);
+ }
+
+ /** Hide the gizmo and helpers so they never end up in an export. */
+ async function withHiddenHelpers(fn) {
+ const wasVisible = rig.helper.visible;
+ const guidesWereVisible = clip.guides.map((guide) => guide.visible);
+ rig.helper.visible = false;
+ clip.guides.forEach((guide) => { guide.visible = false; });
+ try {
+ return await fn();
+ } finally {
+ rig.setGizmoVisible(wasVisible);
+ clip.guides.forEach((guide, index) => { guide.visible = guidesWereVisible[index]; });
+ app.needsRender = true;
+ }
+ }
+
+ async function copyPNG() {
+ const result = await captureImage({ scale: 2 });
+ await exporter.copyCanvasToClipboard(result.canvas);
+ toast('画像をクリップボードにコピーしました');
+ }
+
+ /**
+ * Swap in another GLB.
+ *
+ * Kept as the implementation, but no longer wired to anything: the panel's
+ * 「GLBを差し替える」 button and the window-wide drop handler were removed on
+ * 2026-09-27. Putting the feature back is a button plus an action that calls
+ * this (the model returns on reload, so nothing is saved).
+ */
+ async function replaceModel(file) {
+ if (!file) return;
+ try {
+ const buffer = await exporter.readFileAsArrayBuffer(file);
+ const next = await loadModel(buffer);
+ styles.dispose();
+ rig.dispose();
+ container.remove(model.root);
+ scene.remove(container);
+ container.add(next.root);
+ scene.add(container);
+ app.model = next;
+ toast(`${file.name} を読み込みました(このモデルは再読込で元に戻ります)`);
+ app.needsRender = true;
+ } catch (error) {
+ console.error(error);
+ toast('このファイルは読み込めませんでした');
+ }
+ }
+
+ let recording = false;
+ async function toggleRecording(session) {
+ if (!session) {
+ toast('この環境では録画できません');
+ return;
+ }
+ if (!recording) {
+ if (!state.anim.idle) {
+ state.anim.idle = true;
+ panel.sync();
+ }
+ recording = startRecording(session);
+ toast('録画を開始しました(もう一度押すと停止)');
+ } else {
+ const blob = await stopRecording(session);
+ recording = false;
+ if (blob) {
+ exporter.downloadBlob(blob, `bluebey-animation-${exporter.timestamp()}.webm`);
+ toast('録画を保存しました(WebM)');
+ }
+ }
+ app.panel?.setRecording(recording);
+ }
+
+ function setCameraPreset(id) {
+ const presets = {
+ front: { azimuth: 0, polar: 82 },
+ threeQuarter: { azimuth: 34, polar: 78 },
+ side: { azimuth: 90, polar: 84 },
+ back: { azimuth: 180, polar: 82 },
+ top: { azimuth: 24, polar: 26 },
+ };
+ applyState({ view: presets[id] ?? presets.front }, { scope: 'view', sync: true });
+ }
+
+ function restoreFromHistory(kind) {
+ const snapshot = kind === 'undo' ? history.undo() : history.redo();
+ if (!snapshot) {
+ toast(kind === 'undo' ? 'これ以上戻れません' : 'やり直す操作がありません');
+ return;
+ }
+ suspendHistory = true;
+ try {
+ applyState(snapshot, { full: true, sync: true });
+ } finally {
+ suspendHistory = false;
+ }
+ toast(kind === 'undo' ? '1つ戻しました' : 'やり直しました');
+ }
+
+ function applyTheme(id) {
+ const theme = THEMES.find((item) => item.id === id);
+ if (!theme) return;
+ state.render.theme = id;
+ state.render.colors = { ...theme.colors };
+ applyState({}, { scope: 'render', sync: true });
+ }
+
+ /** おまかせ: a seeded random expression, so it can be reproduced and shared. */
+ function rollGacha(seedText) {
+ const seed = seedText || String(Math.floor(Math.random() * 1e9));
+ const roll = rollAll(seed);
+ suspendHistory = true;
+ try {
+ applyState(roll.patch, { scope: 'all' });
+ } finally {
+ suspendHistory = false;
+ }
+ history.push(state, 'おまかせ');
+ app.panel?.sync();
+ const shown = decodeSeed(seed) ?? String(seed);
+ toast(`おまかせ表情(シード ${shown})`);
+ return shown;
+ }
+
+ async function copyShareLink() {
+ try {
+ const encoded = await encodeState(state);
+ const link = `${location.origin}${location.pathname}#s=${encoded}`;
+ await navigator.clipboard.writeText(link);
+ toast(`この見た目のリンクをコピーしました(${link.length}文字)`);
+ } catch (error) {
+ console.error(error);
+ toast('リンクを作れませんでした');
+ }
+ }
+
+ /** The tag every shared picture carries. */
+ const SHARE_TAG = '#ぶるべースタジオ';
+
+ /** The studio's own address - the landing page itself, not a state-reproducing
+ * `#s=` link. Shared alongside the tag so a viewer can find the studio. */
+ function studioUrl() {
+ return `${location.origin}${location.pathname}`;
+ }
+
+ /**
+ * Share the current look as a *picture*, not as a link.
+ *
+ * Neither X nor Facebook's web dialog can attach an image, so the picture goes
+ * through the OS share sheet (`navigator.share` with files) wherever the browser
+ * has one - a single tap on a phone, and it can go to any app. Where there is no
+ * share sheet (most desktops), the PNG is put on the clipboard and the network's
+ * compose window is opened with the tag already typed, so the picture only has to
+ * be pasted. The tag *and* the studio link always travel together: the user asks
+ * for both to be kept when the picture is shared.
+ */
+ async function shareImage(network) {
+ let shot;
+ try {
+ shot = await captureImage({ scale: 1.5, transparent: false });
+ } catch (error) {
+ console.error(error);
+ toast('画像を作れませんでした');
+ return;
+ }
+ const file = new File([shot.blob], `bluebey-${exporter.timestamp()}.png`, { type: 'image/png' });
+
+ if (!network && navigator.canShare?.({ files: [file] })) {
+ try {
+ await navigator.share({ files: [file], text: SHARE_TAG, url: studioUrl() });
+ return;
+ } catch (error) {
+ if (error?.name === 'AbortError') return;
+ console.error(error);
+ }
+ }
+
+ // No share sheet (or a named network): put the picture on the clipboard and
+ // open that network's compose window with the tag and the studio link filled in.
+ let copied = false;
+ try {
+ await exporter.copyCanvasToClipboard(shot.canvas);
+ copied = true;
+ } catch (error) {
+ console.error(error);
+ }
+ const studio = studioUrl();
+ const target = network === 'facebook'
+ ? `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(studio)}`
+ : `https://twitter.com/intent/tweet?text=${encodeURIComponent(SHARE_TAG)}&url=${encodeURIComponent(studio)}`;
+ window.open(target, '_blank');
+ toast(copied
+ ? '画像をコピーしました。開いた画面に貼り付けて投稿してください(Ctrl+V)'
+ : '投稿画面を開きました(この環境では画像を自動で貼り付けできません)');
+ }
+
+ function toggleMouthFlap() {
+ if (mouthFlap.running) {
+ mouthFlap.stop();
+ toast('口パクを止めました');
+ return;
+ }
+ const seconds = mouthFlap.start(state.mouthFlap?.text ?? '', {
+ rate: state.mouthFlap?.rate ?? 1,
+ });
+ toast(`口パクを始めました(音は出ません・約${seconds.toFixed(1)}秒)`);
+ }
+
+ async function toggleCamera(on) {
+ if (!on) {
+ backdrop.stopCamera();
+ applyState({ view: { background: 'solid' } }, { scope: 'view', sync: true });
+ return;
+ }
+ const result = await backdrop.startCamera(state.view.cameraFacing ?? 'environment');
+ if (!result?.ok) {
+ toast(result?.reason ?? 'カメラを使えません');
+ return;
+ }
+ applyState({ view: { background: 'camera' } }, { scope: 'view', sync: true });
+ toast('カメラの映像を背景にしました(そのまま写真に撮れます)');
+ }
+
+ function addProp(kind) {
+ const def = PROP_DEFAULTS[kind] ?? { x: 1.6, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
+ state.props.items = [...(state.props.items ?? []), {
+ kind,
+ x: def.x ?? 0,
+ y: def.y ?? 0,
+ z: def.z ?? 0,
+ rotX: def.rotX ?? 0,
+ rotY: def.rotY ?? 0,
+ rotZ: def.rotZ ?? 0,
+ scale: def.scale ?? 1,
+ }];
+ applyState({}, { scope: 'render', sync: true });
+ }
+
+ async function addStoryPanel() {
+ const index = (state.story.panels?.length ?? 0) + 1;
+ // A counter, not the array length: removing a panel and then adding another
+ // used to hand out an id that was already taken, which confused the list.
+ storySeq += 1;
+ const id = `p${storySeq}`;
+ state.story.panels = [...(state.story.panels ?? []), {
+ id,
+ label: `コマ${index}`,
+ pose: JSON.parse(JSON.stringify(state.pose)),
+ face: JSON.parse(JSON.stringify(state.face)),
+ caption: JSON.parse(JSON.stringify(state.caption)),
+ caption2: JSON.parse(JSON.stringify(state.caption2)),
+ // The 擬音 stamps as well, so a panel comes back with everything it showed.
+ gion: JSON.parse(JSON.stringify(state.gion)),
+ // The camera as well as the pose: recalling a panel should put you back
+ // where you were looking when you recorded it.
+ view: JSON.parse(JSON.stringify(state.view)),
+ }];
+ applyState({}, { scope: 'caption', sync: true });
+
+ // Then photograph what was just recorded, so the list shows the shot rather
+ // than only its name.
+ try {
+ const shot = await captureImage({ scale: 0.35, transparent: false });
+ storyThumbs.set(id, {
+ version: (storyThumbs.get(id)?.version ?? 0) + 1,
+ url: thumbDataUrl(shot.canvas),
+ });
+ toast(`コマ${index}を追加しました`);
+ } catch (error) {
+ console.warn('[bluebey] story thumbnail failed', error);
+ toast(`コマ${index}を追加しました(プレビューは作れませんでした)`);
+ }
+ app.panel?.sync();
+ }
+
+ /** Shrink a capture down to a thumbnail data URL for the panel list. */
+ function thumbDataUrl(source, width = 168) {
+ const scale = width / Math.max(1, source.width);
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.max(1, Math.round(source.width * scale));
+ canvas.height = Math.max(1, Math.round(source.height * scale));
+ const ctx = canvas.getContext('2d');
+ ctx.imageSmoothingQuality = 'high';
+ ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
+ return canvas.toDataURL('image/png');
+ }
+
+ /**
+ * A number in the corner of a comic panel, for sheets whose reading order is
+ * not obvious. Drawn with the vendored caption font, so the sheet does not
+ * depend on whatever fonts the viewer happens to have.
+ */
+ function drawPanelNumber(ctx, number, x, y, cellW, cellH) {
+ const radius = Math.max(18, Math.min(64, Math.min(cellW, cellH) * 0.075));
+ const margin = radius * 0.7;
+ const cx = x + margin + radius;
+ const cy = y + margin + radius;
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.92)';
+ ctx.fill();
+ ctx.lineWidth = Math.max(2, radius * 0.14);
+ ctx.strokeStyle = '#3f2b52';
+ ctx.stroke();
+ ctx.fillStyle = '#3f2b52';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.font = `bold ${Math.round(radius * 1.25)}px ${captionFontStack()}`;
+ ctx.fillText(String(number), cx, cy + radius * 0.04);
+ ctx.restore();
+ }
+
+ /**
+ * まんが: replay the panels, capture each one, and hand back a framed sheet.
+ */
+ async function saveStory({ scale = 2 } = {}) {
+ const panels = state.story?.panels ?? [];
+ if (!panels.length) {
+ toast('コマがありません。「今の状態をコマに追加」で作ってください');
+ return;
+ }
+ const saved = JSON.parse(JSON.stringify(state));
+ const captures = [];
+ suspendHistory = true;
+ try {
+ for (const panel of panels) {
+ const pose = panel.pose ?? {};
+ state.pose = { bones: pose.bones ?? {}, root: pose.root ?? [0, 0, 0] };
+ state.face = JSON.parse(JSON.stringify(saved.face));
+ applyPatch(state.face, panel.face ?? {});
+ state.caption = { ...saved.caption, ...(panel.caption ?? {}) };
+ state.caption2 = { ...saved.caption2, ...(panel.caption2 ?? {}) };
+ // The camera the panel was framed with, so a comic keeps its angles.
+ if (panel.view) state.view = JSON.parse(JSON.stringify(panel.view));
+ refresh('all');
+ // Two frames: one to apply the pose, one to draw it.
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ captures.push(await captureImage({ scale, transparent: false }));
+ }
+ } finally {
+ suspendHistory = false;
+ applyState(saved, { full: true, sync: true });
+ }
+
+ const columns = Math.max(1, Math.min(4, state.story?.columns ?? 2));
+ const gap = state.story?.gap ?? 12;
+ const padding = state.story?.padding ?? 20;
+ const rows = Math.ceil(captures.length / columns);
+ const cellW = Math.max(...captures.map((item) => item.width));
+ const cellH = Math.max(...captures.map((item) => item.height));
+
+ // Once the sheet is more than one column wide, the reading order stops being
+ // obvious from the layout alone, so the panels are numbered. A single column
+ // (or two panels) reads top to bottom without help.
+ const numbered = columns >= 2 && captures.length >= 3;
+ if (numbered) await ensureCaptionFont();
+
+ // A manga page reads by its panel frames. They are drawn inside each cell so
+ // the artwork keeps its full size, and are thick enough to survive the 1×
+ // export.
+ const frame = Math.max(3, Math.round(Math.min(cellW, cellH) * 0.012));
+
+ const sheet = document.createElement('canvas');
+ sheet.width = padding * 2 + cellW * columns + gap * (columns - 1);
+ sheet.height = padding * 2 + cellH * rows + gap * (rows - 1);
+ const ctx = sheet.getContext('2d');
+ ctx.fillStyle = state.story?.sheetBackground ?? '#ffffff';
+ ctx.fillRect(0, 0, sheet.width, sheet.height);
+ captures.forEach((capture, index) => {
+ const x = padding + (index % columns) * (cellW + gap);
+ const y = padding + Math.floor(index / columns) * (cellH + gap);
+ ctx.drawImage(capture.canvas, x, y, cellW, cellH);
+ ctx.lineWidth = frame;
+ ctx.strokeStyle = '#2a1e33';
+ ctx.strokeRect(x + frame / 2, y + frame / 2, cellW - frame, cellH - frame);
+ if (numbered) drawPanelNumber(ctx, index + 1, x, y, cellW, cellH);
+ });
+
+ const stamp = exporter.timestamp();
+ exporter.downloadBlob(await exporter.canvasToBlob(sheet), `bluebey-comic-${stamp}.png`);
+ toast(`まんがを書き出しました(${captures.length}コマ)`);
+ }
+
+ /** A shared link restores the whole look; otherwise this is the start point. */
+ async function loadFromHash() {
+ // 共有ボタンは `?s=`、ボタン「この見た目のリンクをコピー」は `#s=` を使う。
+ // Facebook は `#` 以降を落とすので、共有はクエリにする必要がある - どちらでも読む。
+ const query = new URLSearchParams(location.search).get('s');
+ const hash = location.hash.replace(/^#/, '');
+ const encoded = query ?? new URLSearchParams(hash).get('s');
+ if (!encoded) return false;
+ const decoded = await decodeState(encoded);
+ if (!decoded?.state) return false;
+ suspendHistory = true;
+ try {
+ applyState(decoded.state, { full: true, sync: true });
+ } finally {
+ suspendHistory = false;
+ }
+ return true;
+ }
+
+ function installTopbar() {
+ // The panel itself is visible from the start, but each of its sections starts
+ // collapsed (see `section()` in src/ui.js): the panel reads as a short list of
+ // headings, and you open the one you want instead of scrolling past twelve.
+ resizeViewport();
+
+ // AR (カメラ=AR) get a camera-app style shutter over the viewport, so a photo
+ // is one tap away. It is a DOM layer over the canvas, so it never lands in the
+ // export; its visibility follows the background mode (see `applyBackdrop`).
+ const shutter = document.createElement('button');
+ shutter.type = 'button';
+ shutter.id = 'ar-shutter';
+ shutter.className = 'ar-shutter';
+ shutter.title = '写真を撮る';
+ shutter.setAttribute('aria-label', '写真を撮る');
+ shutter.hidden = true;
+ shutter.addEventListener('click', () => { savePNG(); });
+ stage.append(shutter);
+ app.arShutter = shutter;
+
+ document.getElementById('btn-shot')?.addEventListener('click', () => app.actions.savePNG());
+ document.getElementById('btn-copy')?.addEventListener('click', () => app.actions.copyPNG());
+ document.getElementById('btn-help')?.addEventListener('click', toggleHelp);
+ document.getElementById('btn-panel')?.addEventListener('click', togglePanel);
+ document.getElementById('help-close')?.addEventListener('click', () => { toggleHelp(false); });
+ const help = document.getElementById('help');
+ help?.addEventListener('click', (event) => { if (event.target === help) toggleHelp(false); });
+
+ // --- the mouse on the character ----------------------------------------
+ // Dragging the model slides it across the view; holding Shift and dragging
+ // turns it around. Bone posing lives in the panel, so the mouse never grabs a
+ // bone (see `Rig` in src/rig.js). On a small screen the panel is a sheet over
+ // the view, so a tap on the view first just dismisses the sheet.
+ const dragRay = new THREE.Raycaster();
+ const dragNdc = new THREE.Vector2();
+ const dragHitAt = (event) => {
+ const rect = canvas.getBoundingClientRect();
+ dragNdc.set(
+ ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1,
+ -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1,
+ );
+ dragRay.setFromCamera(dragNdc, view.camera);
+ const targets = [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body]
+ .filter((mesh) => mesh?.visible);
+ return dragRay.intersectObjects(targets, false)[0] ?? null;
+ };
+ // World units per screen pixel, so the model keeps up with the cursor.
+ const dragScale = () => {
+ const camera = view.camera;
+ const height = Math.max(1, canvas.clientHeight);
+ if (camera.isOrthographicCamera) return (camera.top - camera.bottom) / camera.zoom / height;
+ const distance = camera.position.distanceTo(view.controls.target);
+ return (2 * distance * Math.tan((camera.fov * Math.PI) / 360)) / height;
+ };
+ let modelDrag = null;
+
+ canvas.addEventListener('pointerdown', (event) => {
+ if (event.button !== 0 || event.target !== canvas) return;
+ if (narrowScreenLayout.matches && !document.body.classList.contains('panel-hidden')) {
+ togglePanel();
+ return;
+ }
+ if (!dragHitAt(event)) return;
+ const root = state.pose.root ?? [0, 0, 0];
+ const master = state.pose.bones?.master ?? [0, 0, 0];
+ modelDrag = {
+ pointerId: event.pointerId,
+ spin: event.shiftKey,
+ x: event.clientX,
+ y: event.clientY,
+ root: [...root],
+ masterY: master[1] ?? 0,
+ };
+ view.controls.enabled = false;
+ stage.style.cursor = 'grabbing';
+ try { canvas.setPointerCapture(event.pointerId); } catch { /* ignore */ }
+ });
+
+ canvas.addEventListener('pointermove', (event) => {
+ if (!modelDrag || event.pointerId !== modelDrag.pointerId) return;
+ const dx = event.clientX - modelDrag.x;
+ const dy = event.clientY - modelDrag.y;
+ if (modelDrag.spin) {
+ const bones = { ...(state.pose.bones ?? {}) };
+ const master = bones.master ?? [0, 0, 0];
+ bones.master = [master[0], round2(modelDrag.masterY + dx * 0.7), master[2]];
+ state.pose.bones = bones;
+ } else {
+ const k = dragScale();
+ state.pose.root = [
+ round2(modelDrag.root[0] + dx * k),
+ round2(modelDrag.root[1] - dy * k),
+ modelDrag.root[2],
+ ];
+ }
+ app.panel?.onRigChanged?.();
+ app.needsRender = true;
+ });
+
+ const endModelDrag = (event) => {
+ if (!modelDrag || (event && event.pointerId !== modelDrag.pointerId)) return;
+ modelDrag = null;
+ view.controls.enabled = true;
+ stage.style.cursor = '';
+ if (event) { try { canvas.releasePointerCapture(event.pointerId); } catch { /* ignore */ } }
+ capturePose();
+ app.panel?.onRigChanged?.();
+ app.needsRender = true;
+ };
+ canvas.addEventListener('pointerup', endModelDrag);
+ canvas.addEventListener('pointercancel', endModelDrag);
+ }
+
+ function togglePanel() {
+ document.body.classList.toggle('panel-hidden');
+ resizeViewport();
+ }
+
+ // The panel is a side column on a wide screen and a bottom sheet on a small
+ // one, where it starts closed so the 3D view fills the screen (see the
+ // small-screen rules in src/style.css). When the window is resized across that
+ // breakpoint, match the layout the other side expects instead of leaving the
+ // panel stuck in the previous state.
+ const narrowScreenLayout = window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)');
+ narrowScreenLayout.addEventListener('change', (event) => {
+ document.body.classList.toggle('panel-hidden', event.matches);
+ resizeViewport();
+ });
+
+ function installKeys() {
+ window.addEventListener('keydown', (event) => {
+ // Undo/redo are the only shortcuts that need a modifier, so they are dealt
+ // with before the "no modifiers" rule below.
+ if ((event.ctrlKey || event.metaKey) && !event.altKey) {
+ const combo = event.key.toLowerCase();
+ if (combo === 'z' || combo === 'y') {
+ event.preventDefault();
+ restoreFromHistory(combo === 'y' || event.shiftKey ? 'redo' : 'undo');
+ return;
+ }
+ }
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
+ const target = event.target;
+ if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement) return;
+ switch (event.key) {
+ case 's': case 'S': savePNG(); break;
+ case 'c': case 'C': copyPNG(); break;
+ case 'r': case 'R': app.actions.resetPose(); break;
+ case 'g': case 'G': {
+ const visible = !rig.helper.visible;
+ rig.setGizmoVisible(visible);
+ toast(visible ? 'ギズモを表示' : 'ギズモを隠しました');
+ app.needsRender = true;
+ break;
+ }
+ case ' ': event.preventDefault(); app.actions.toggleAnimation(); break;
+ case '?': toggleHelp(); break;
+ case 'Tab': event.preventDefault(); togglePanel(); break;
+ case '1': setCameraPreset('front'); break;
+ case '2': setCameraPreset('threeQuarter'); break;
+ case '3': setCameraPreset('side'); break;
+ case '4': setCameraPreset('back'); break;
+ case '5': setCameraPreset('top'); break;
+ default: break;
+ }
+ });
+ }
+
+}
+
+function toggleHelp(force) {
+ const help = document.getElementById('help');
+ if (help) help.hidden = force === undefined ? !help.hidden : !force;
+}
+
+/** A persistent message for problems that need the user to do something. */
+function showNotice(text) {
+ let node = document.getElementById('notice');
+ if (!node) {
+ node = document.createElement('div');
+ node.id = 'notice';
+ document.body.append(node);
+ }
+ node.textContent = text;
+ node.hidden = false;
+}
+
+/* ------------------------------------------------------------------- camera */
+
+/**
+ * Orbiting camera with both a perspective and an orthographic projection.
+ * Only one of them is enabled at a time; switching copies the framing across so
+ * the model does not jump.
+ */
+class ViewRig {
+ constructor(domElement, onChange) {
+ this.domElement = domElement;
+ this.onChange = onChange;
+ this.projection = 'persp';
+ this.controlsChanged = false;
+
+ this.cameras = {
+ // A tight near/far keeps depth precision high, which matters because the
+ // outline hull is only a few hundredths of a unit away from the surface.
+ // The far plane has to sit well past the furthest the camera may go, or
+ // zooming all the way out slices the character on it - which is exactly
+ // what the old `far = 120` did, because the zoom limit was also 120.
+ persp: new THREE.PerspectiveCamera(30, 1, 1, 2000),
+ ortho: new THREE.OrthographicCamera(-1, 1, 1, -1, -2000, 2000),
+ };
+ this.controlsMap = {};
+ for (const [key, camera] of Object.entries(this.cameras)) {
+ const controls = new OrbitControls(camera, domElement);
+ controls.enableDamping = false;
+ controls.enablePan = true;
+ controls.minDistance = 1;
+ controls.maxDistance = 400;
+ controls.addEventListener('change', () => {
+ this.controlsChanged = true;
+ this.onChange?.();
+ });
+ this.controlsMap[key] = controls;
+ }
+ this.controlsMap.ortho.enabled = false;
+ this.camera = this.cameras.persp;
+ this.controls = this.controlsMap.persp;
+ this.target = new THREE.Vector3(0, 1, 0);
+ this.autoDistance = 12;
+ // `orthoHeight` is where the orthographic camera is *now* - `apply()` writes
+ // the camera's actual height back into it - so the framing `frame()` chose
+ // needs its own field to measure the zoom against (see `zoomFactor`).
+ this.orthoHeight = 6;
+ this.autoOrthoHeight = 6;
+ this.resize();
+ }
+
+ /** Fit the model into view. */
+ frame(size, targetY) {
+ this.target.set(0, targetY, 0);
+ for (const controls of Object.values(this.controlsMap)) controls.target.copy(this.target);
+ const fov = this.cameras.persp.fov * Math.PI / 180;
+ const heightDistance = size.y / (2 * Math.tan(fov / 2));
+ const widthDistance = size.x / (2 * Math.tan(fov / 2) * Math.max(0.4, this.aspect));
+ this.autoDistance = Math.max(heightDistance, widthDistance) * 1.45;
+ this.orthoHeight = size.y * 1.5;
+ this.autoOrthoHeight = this.orthoHeight;
+ for (const camera of Object.values(this.cameras)) {
+ camera.position.set(0, targetY + this.autoDistance * 0.12, this.autoDistance);
+ }
+ this.resize();
+ }
+
+ get aspect() {
+ const width = this.domElement.clientWidth || 1;
+ const height = this.domElement.clientHeight || 1;
+ return width / height;
+ }
+
+ resize() {
+ const width = this.domElement.clientWidth || window.innerWidth;
+ const height = this.domElement.clientHeight || window.innerHeight;
+ const aspect = width / height;
+ this.cameras.persp.aspect = aspect;
+ this.cameras.persp.updateProjectionMatrix();
+ this.setOrthoHeight(this.orthoHeight);
+ }
+
+ setOrthoHeight(height) {
+ const ortho = this.cameras.ortho;
+ const halfHeight = height / 2;
+ const halfWidth = halfHeight * this.aspect;
+ ortho.left = -halfWidth;
+ ortho.right = halfWidth;
+ ortho.top = halfHeight;
+ ortho.bottom = -halfHeight;
+ ortho.zoom = 1;
+ ortho.updateProjectionMatrix();
+ }
+
+ /**
+ * How large the character looks now, relative to the auto-fit framing.
+ *
+ * 1 means "the default framing", which is where the panel's 大きさ sliders sit
+ * and so where the screen-space outline's pixel width is written against. The
+ * two projections scale differently - a perspective camera shows 1/distance as
+ * much per world unit, an orthographic one 1/height, and OrbitControls zooms
+ * the latter with `camera.zoom` rather than by moving it - so each is
+ * normalised by its own auto-fit value.
+ */
+ zoomFactor() {
+ if (this.projection === 'ortho') {
+ const ortho = this.cameras.ortho;
+ const height = (ortho.top - ortho.bottom) / Math.max(ortho.zoom, 0.0001);
+ return this.autoOrthoHeight > 0 && height > 0 ? this.autoOrthoHeight / height : 1;
+ }
+ const distance = this.camera.position.distanceTo(this.controls?.target ?? this.target);
+ return this.autoDistance > 0 && distance > 0 ? this.autoDistance / distance : 1;
+ }
+
+ setProjection(kind) {
+ if (kind === this.projection) return;
+ const previous = this.camera;
+ const next = kind === 'ortho' ? this.cameras.ortho : this.cameras.persp;
+ next.position.copy(previous.position);
+ next.quaternion.copy(previous.quaternion);
+ this.controlsMap[this.projection].enabled = false;
+ this.projection = kind;
+ this.camera = next;
+ this.controls = this.controlsMap[kind];
+ this.controls.enabled = true;
+ this.controls.target.copy(this.target);
+ this.resize();
+ this.onCameraChange?.(next, this.controls);
+ this.controlsChanged = true;
+ this.onChange?.();
+ }
+
+ /** Apply the serialisable view state (azimuth/polar/zoom). */
+ apply(viewState, size) {
+ const distance = viewState.distance > 0 ? viewState.distance : this.autoDistance;
+ const targetY = viewState.targetY || this.target.y;
+ this.target.set(viewState.targetX || 0, targetY, viewState.targetZ || 0);
+ const spherical = new THREE.Spherical(
+ distance,
+ clamp(viewState.polar, 1, 179) * Math.PI / 180,
+ viewState.azimuth * Math.PI / 180,
+ );
+ const offset = new THREE.Vector3().setFromSpherical(spherical);
+ for (const camera of Object.values(this.cameras)) {
+ camera.position.copy(this.target).add(offset);
+ camera.lookAt(this.target);
+ }
+ if (viewState.orthoHeight > 0) this.setOrthoHeight(viewState.orthoHeight);
+ else if (size) this.setOrthoHeight(this.orthoHeight);
+ for (const controls of Object.values(this.controlsMap)) {
+ controls.target.copy(this.target);
+ controls.update();
+ }
+ this.orthoHeight = this.cameras.ortho.top - this.cameras.ortho.bottom;
+ this.cameras.persp.updateProjectionMatrix();
+ this.cameras.ortho.updateProjectionMatrix();
+ }
+
+ /**
+ * Write where the camera actually is back into the serialisable view state.
+ *
+ * WHY: the mouse orbit and pan move the `controls`, not the state. Without this
+ * the state kept the last camera the *panel* set, so any refresh of the view
+ * snapped the camera back to it - which is what made picking a background jump
+ * the camera. Keeping the two in step also means a shared link carries the
+ * camera you framed.
+ */
+ captureInto(viewState) {
+ if (!viewState || typeof viewState !== 'object') return;
+ const target = this.controls?.target ?? this.target;
+ const spherical = new THREE.Spherical().setFromVector3(
+ new THREE.Vector3().subVectors(this.camera.position, target),
+ );
+ viewState.azimuth = Math.round(spherical.theta * RAD_TO_DEG * 100) / 100;
+ viewState.polar = Math.round(spherical.phi * RAD_TO_DEG * 100) / 100;
+ viewState.distance = Math.round(spherical.radius * 1000) / 1000;
+ viewState.targetX = Math.round(target.x * 1000) / 1000;
+ viewState.targetY = Math.round(target.y * 1000) / 1000;
+ viewState.targetZ = Math.round(target.z * 1000) / 1000;
+ if (this.projection === 'ortho') {
+ const ortho = this.cameras.ortho;
+ viewState.orthoHeight = Math.round((ortho.top - ortho.bottom) * 1000) / 1000;
+ }
+ }
+
+ setAutoRotate(enabled, speed) {
+ for (const controls of Object.values(this.controlsMap)) {
+ controls.autoRotate = enabled;
+ controls.autoRotateSpeed = speed;
+ }
+ }
+}
+
+/**
+ * How far the screen-space outline may follow the zoom (see `outlineRadiusFor`).
+ *
+ * A clamp keeps a very deep zoom from turning the line into either a smear or
+ * nothing at all, which is what the linear factor would do at the extremes of
+ * the 大きさ slider.
+ */
+const OUTLINE_ZOOM_MIN = 0.25;
+const OUTLINE_ZOOM_MAX = 3;
+
+/**
+ * The screen-space outline's radius, in pixels of the label buffer.
+ *
+ * The body's lines are inverted hulls, i.e. an offset in *world* units, so they
+ * thicken as the character grows on screen and thin as it shrinks. This pass
+ * works in pixels instead, so left alone its lines - the waist leaves and the
+ * nose - keep one width at every zoom, and the leaf skirt reads as far too heavy
+ * beside the body the moment you pull back. Scaling the radius by the rig's
+ * `zoomFactor` makes the two behave the same way, with `outlinePixels` staying
+ * the width at the auto-fit framing (so the slider keeps its meaning).
+ *
+ * `extra` carries whatever else changes the buffer's pixels-per-CSS-pixel: the
+ * preview's buffer is `quality`x the CSS size (and `quality` itself follows the
+ * display density and the automatic reductions), and an export renders the label
+ * buffer at `scale`x. Passing that factor keeps the line a constant *CSS* width,
+ * so `outlinePixels` reads as CSS pixels whichever buffer it lands in.
+ */
+function outlineRadiusFor(view, state, extra = 1) {
+ const zoom = clamp(view.zoomFactor(), OUTLINE_ZOOM_MIN, OUTLINE_ZOOM_MAX);
+ return (state.render.outlinePixels ?? 2) * zoom * extra;
+}
+
+/* ----------------------------------------------------------------- scenery */
+
+function makeEnvironment(renderer) {
+ const canvas = document.createElement('canvas');
+ canvas.width = 64;
+ canvas.height = 32;
+ const ctx = canvas.getContext('2d');
+ const gradient = ctx.createLinearGradient(0, 0, 0, 32);
+ gradient.addColorStop(0, '#ffffff');
+ gradient.addColorStop(0.5, '#ece7fa');
+ gradient.addColorStop(1, '#b9aade');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, 64, 32);
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.mapping = THREE.EquirectangularReflectionMapping;
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const generator = new THREE.PMREMGenerator(renderer);
+ const environment = generator.fromEquirectangular(texture).texture;
+ texture.dispose();
+ generator.dispose();
+ return environment;
+}
+
+/** Round to 2 decimals: the precision the panel's prop sliders show. */
+const round2 = (value) => Math.round(value * 100) / 100;
+
+const clampNumber = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
+
+export function backgroundOf(viewState) {
+ if (viewState.background === 'transparent') return { mode: 'transparent' };
+ // A preset, a loaded photo, a drawn effect line or the phone's camera all
+ // live in a DOM layer behind the WebGL canvas, so the renderer is left clear
+ // and the exports paint the bitmap into the picture themselves (see
+ // drawBackdropInto in main.js).
+ if (viewState.background === 'preset' || viewState.background === 'image'
+ || viewState.background === 'effect' || viewState.background === 'camera') {
+ return { mode: 'transparent' };
+ }
+ return { mode: 'solid', color: viewState.backgroundColor };
+}
+
+/* ------------------------------------------------------------------- errors */
+
+function setLoadingProgress(ratio) {
+ const node = document.getElementById('loading-text');
+ if (node) node.textContent = `ぶるべーを読み込んでいます… ${Math.round(ratio * 100)}%`;
+}
+
+function hideLoading() {
+ const node = document.getElementById('loading');
+ if (node) node.classList.add('done');
+}
+
+function showLoadError(error) {
+ const text = document.getElementById('loading-text');
+ const detail = document.getElementById('loading-error');
+ if (text) text.textContent = '読み込みに失敗しました。';
+ if (detail) {
+ detail.textContent = `${error?.message ?? error}\n\n`
+ + 'このページはローカルサーバー経由で開く必要があります。\n'
+ + 'bluebey-studio フォルダで python serve.py を実行し、\n'
+ + '表示された http://127.0.0.1:8000/ をブラウザで開いてください。';
+ }
+}
+
+const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
+const clamp01 = (value) => clamp(value, 0, 1);