aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/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 /public/bluebey-studio/src/main.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'public/bluebey-studio/src/main.js')
-rw-r--r--public/bluebey-studio/src/main.js3056
1 files changed, 0 insertions, 3056 deletions
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
deleted file mode 100644
index 20c6c7d..0000000
--- a/public/bluebey-studio/src/main.js
+++ /dev/null
@@ -1,3056 +0,0 @@
-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);