From bc2821adb11a30244fc4663f4fafb756877d9508 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:09:40 +0900 Subject: bluebey-studio: public/ の外へ移動し非公開化 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bluebey-studio/src/main.js | 3056 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3056 insertions(+) create mode 100644 bluebey-studio/src/main.js (limited to 'bluebey-studio/src/main.js') diff --git a/bluebey-studio/src/main.js b/bluebey-studio/src/main.js new file mode 100644 index 0000000..20c6c7d --- /dev/null +++ b/bluebey-studio/src/main.js @@ -0,0 +1,3056 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { loadModel } from './model.js'; +import { Face } from './face.js'; +import { Styles, isLineStyle } from './styles.js'; +import { Rig } from './rig.js'; +import { Animator, createRecorder, startRecording, stopRecording } from './animation.js'; +import { defaultState, applyPatch, THEMES } from './presets.js'; +import { buildPanel } from './panel.js'; +import * as exporter from './exporter.js'; +import { toast } from './ui.js'; +import { History } from './history.js'; +import { createLook } from './look.js'; +import { createClipper } from './clip.js'; +import { buildHat } from './hats.js'; +import { createBackdrop, backdropStyle } from './background.js'; +import { drawCaption, layoutCaption } from './caption.js'; +import { loadFont, captionFontStack } from './textOutlines.js'; +import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js'; +import { rollAll, decodeSeed } from './gacha.js'; +import { encodeState, decodeState } from './urlState.js'; +import { createMouthFlap, levelToMouth } from './mouthFlap.js'; +import { createScreenOutline, BEHIND_LABEL, SOLID_LABEL, LEAF_LABEL, NOSE_LABEL } from './outline.js'; +import { gionSheetUrl, stampRect, imageAspect, imageReady, drawGion, DEFAULT_STAMP_WIDTH } from './gion.js'; + +const DEG = Math.PI / 180; +/** The other direction: what three's Spherical reports is radians. */ +const RAD_TO_DEG = 180 / Math.PI; +const MODEL_URL = 'assets/bluebey.glb'; + +/** The caption font is only fetched when a bubble is first used. */ +const CAPTION_FONT_TTF = 'assets/fonts/bluebey-caption.ttf'; +const CAPTION_FONT_WOFF2 = 'assets/fonts/bluebey-caption.woff2'; +/** How far the eyes can swing for a look-at target, in radians. */ +const MAX_LOOK_YAW = 0.5; +const MAX_LOOK_PITCH = 0.34; + +/** Undo steps are labelled by the part of the state that changed. */ +const SCOPE_LABELS = { + all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', caption: 'セリフ', gion: '擬音', +}; + +/** + * The single-file build inlines the fonts and the backdrop library, so the same + * code reads a data URL there and a file URL in the normal build. + */ +function assetUrl(file, kind) { + const inlined = globalThis.__BLUEBEY_FONTS__ ?? {}; + if (kind === 'background') { + const backdrops = globalThis.__BLUEBEY_BACKGROUNDS__ ?? {}; + return backdrops[file] ?? `assets/backgrounds/${file}.webp`; + } + if (kind === 'beard') { + const beards = globalThis.__BLUEBEY_BEARDS__ ?? {}; + return beards[file] ?? `assets/beards/${file}`; + } + if (kind === 'brow') { + const brows = globalThis.__BLUEBEY_BROWS__ ?? {}; + return brows[file] ?? `assets/brows/${file}`; + } + return inlined[file] ?? `assets/fonts/${file}`; +} + +/** + * Where the model comes from. The normal build fetches `assets/bluebey.glb`; + * the single-file build (tools/build-standalone.mjs) inlines the same bytes as + * base64 in `window.__BLUEBEY_MODEL__`, which also lets the page be opened + * straight from disk without any web server. + */ +function resolveModelSource() { + const injected = globalThis.__BLUEBEY_MODEL__; + if (!injected) return MODEL_URL; + if (typeof injected !== 'string') return injected; + try { + const binary = atob(injected); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; + } catch (error) { + console.error('inline model could not be decoded, falling back to the file', error); + return MODEL_URL; + } +} + +const state = defaultState(); + +const app = { + state, + model: null, + container: null, + ready: false, + needsRender: true, + /** The 擬音 stamp the panel is editing, so the viewport can outline it. */ + gionSelected: null, + /** Set by init(), used by the panel. */ + actions: {}, +}; + +/* ------------------------------------------------------------------ startup */ + +// The view-rig classes are declared below this point; deferring by a microtask +// guarantees the whole module has finished evaluating before anything is built. +queueMicrotask(() => { + init().catch((error) => { + console.error(error); + showLoadError(error); + }); +}); + +async function init() { + const canvas = document.getElementById('view'); + // #stage owns the layout; the canvas only fills it (see style.css). + const stage = document.getElementById('stage') ?? canvas; + + const renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFShadowMap; + + const scene = new THREE.Scene(); + const environment = makeEnvironment(renderer); + + const key = new THREE.DirectionalLight(0xffffff, 2.1); + key.castShadow = true; + key.shadow.mapSize.set(1536, 1536); + key.shadow.bias = -0.0008; + key.shadow.normalBias = 0.02; + const fill = new THREE.DirectionalLight(0xffffff, 0.5); + const ambient = new THREE.HemisphereLight(0xffffff, 0xd9d0f2, 0.9); + scene.add(key, key.target, fill, ambient); + + // A shadow catcher, not a solid floor: `depthWrite: false` keeps the plane from + // ever hiding the character, so moving ぶるべー below it no longer makes the body + // (or its textures) vanish - the shadow still falls on it, but it never occludes. + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(200, 200), + new THREE.ShadowMaterial({ opacity: 0.22, transparent: true, depthWrite: false }), + ); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + scene.add(ground); + + // The camera lives in two places: `state.view` (what the panel, the saved file + // and a shared link use) and the orbit controls (what the mouse moves). Capture + // the controls' own position back into the state on every change, so the two + // never disagree - otherwise any refresh of the view yanked the camera back to + // wherever the panel had last put it. + const view = new ViewRig(canvas, () => { + view.captureInto(state.view); + app.needsRender = true; + }); + + /** + * The screen-space outline (see src/outline.js). The normal pass must not see + * the ground, the contact shadow or the gizmo, so they are excluded once those + * objects exist (see below). + */ + const outline = createScreenOutline({ + renderer, + scene, + camera: view.camera, + width: canvas.clientWidth || 1280, + height: canvas.clientHeight || 800, + }); + outline.exclude([ground]); + + /** + * The drawing buffer must follow the canvas' CSS size. Without this the + * browser stretches the default 300x150 buffer across the window and the + * whole picture looks coarse (and every exported trace is built from those + * few pixels). + * + * But it must not be *unlimited* either: on a large or high-density display + * the buffer can reach many millions of pixels, and with antialiasing, a + * shadow map and preserveDrawingBuffer on top, a weak GPU can take seconds + * per frame - which looks exactly like "nothing is showing". So the pixel + * count is capped and the ratio is lowered automatically on slow hardware. + */ + const MAX_DRAWING_PIXELS = 2_600_000; + const QUALITY_FLOOR = 0.5; + let quality = Math.min(window.devicePixelRatio || 1, 2); + let autoReductions = 0; + + function fitQuality(width, height, wanted) { + let ratio = wanted; + while (ratio > QUALITY_FLOOR && width * height * ratio * ratio > MAX_DRAWING_PIXELS) { + ratio = Math.round((ratio - 0.1) * 100) / 100; + } + return Math.max(QUALITY_FLOOR, ratio); + } + + let lastResize = 0; + let resizeBurst = 0; + + /** Declared early: the viewport can resize before the model has loaded. */ + let captionCanvas = null; + /** Set while undo/redo replays a snapshot, so it is not recorded again. */ + let suspendHistory = false; + + // ------------------------------------------------------------------- 擬音 + // `redrawCaption` runs from the very first `resizeViewport`, so the sheet cache + // and the drag handles have to exist before it does (a `const` cannot be read + // before it is evaluated). The drawing itself lives further down, next to the + // caption overlay it is layered under. + const gionImages = new Map(); // sheet name -> loaded Image + const gionLoading = new Map(); // sheet name -> in-flight Promise + const gionHandles = new Map(); // stamp id -> transparent drag div + let gionDrag = null; + /** Hands out stamp ids; a counter so removing one never reuses a live id. */ + let gionSeq = 0; + + function resizeViewport() { + const width = stage.clientWidth || window.innerWidth; + const height = stage.clientHeight || window.innerHeight; + + // Safety net: if something ever feeds the viewport size back into itself, + // stop flipping the drawing buffer on and off and say so. + const now = performance.now(); + if (now - lastResize < 60) { + resizeBurst += 1; + if (resizeBurst > 30 && resizeBurst % 30 === 0) { + console.warn('[bluebey] too many resizes in a row; ignoring them', resizeBurst); + } + if (resizeBurst > 30) return; + } else { + resizeBurst = 0; + } + lastResize = now; + + const wanted = Math.min(window.devicePixelRatio || 1, 2); + quality = fitQuality(width, height, wanted); + renderer.setPixelRatio(quality); + renderer.setSize(width, height, false); + outline.setSize(width * quality, height * quality); + view.resize(); + redrawCaption(); + app.needsRender = true; + } + resizeViewport(); + new ResizeObserver(() => resizeViewport()).observe(stage); + + // Losing the WebGL context leaves a permanently blank canvas, so say so + // instead of leaving the user staring at nothing. + canvas.addEventListener('webglcontextlost', (event) => { + event.preventDefault(); + showNotice('描画が止まりました(WebGLコンテキストを失いました)。\n' + + 'ブラウザのウィンドウを小さくするか、再読み込みしてください。'); + }); + canvas.addEventListener('webglcontextrestored', () => { + app.needsRender = true; + }); + + // -------------------------------------------------------------- the model + const model = await loadModel(resolveModelSource(), { + onProgress: (ratio) => setLoadingProgress(ratio), + }); + app.model = model; + + // The character hangs off a mirror group of its own, so "左右反転" flips the + // model without touching the pose offset (`container.position`) or the props. + const mirrorGroup = new THREE.Group(); + mirrorGroup.add(model.root); + const container = new THREE.Group(); + container.add(mirrorGroup); + scene.add(container); + app.container = container; + + // 小物 live outside the mirror group: they are scenery, not part of the body. + const propRoot = new THREE.Group(); + scene.add(propRoot); + + // --- 鼻ちょうちん: a modelled bubble hung under the nose -------------------- + // The nose is a *skinned* part of the body, so a child of the nose mesh would + // sit at the origin instead of on the face. The whole body (nose included) + // rides the `master` bone, so the bubble is parented there, placed at the nose's + // rest position in that bone's local frame, and read from the face state each + // frame in the render loop. + function applySnotBubble(group, snot) { + if (!group) return; + group.visible = snot?.enabled === true; + if (!group.visible) return; + // The offsets are applied in the *bone's* frame, along the world axes measured + // once at rest, so the bubble travels with the body. Recomputing them against + // the live world matrix made it snap back whenever the body had moved. + const k = 0.01; // artwork px -> world units, enough to nudge the bubble + const { localBase, axes } = group.userData; + group.position.copy(localBase) + .addScaledVector(axes.x, (snot.offsetX ?? 0) * k) + .addScaledVector(axes.y, (snot.offsetY ?? 0) * k) + .addScaledVector(axes.z, (snot.offsetZ ?? 0) * k); + group.userData.baseScale = Number.isFinite(snot.size) ? snot.size : 1; + group.scale.setScalar(group.userData.baseScale); + group.userData.material.color.set(snot.color ?? '#dfe8ff'); + } + + const snotBubble = (() => { + const nose = model.parts.noseMesh; + const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master'); + if (!nose || !master || !nose.isSkinnedMesh) return null; + model.root.updateMatrixWorld(true); + // The nose is a *skinned* mesh, so its raw geometry sits in bind space (at the + // back of the model, in fact) - sample the skinned positions instead, which is + // where the nose actually ends up. + const attr = nose.geometry.attributes.position; + const step = Math.max(1, Math.floor(attr.count / 400)); + const point = new THREE.Vector3(); + const centre = new THREE.Vector3(); + let samples = 0; + for (let i = 0; i < attr.count; i += step) { + point.fromBufferAttribute(attr, i); + nose.applyBoneTransform(i, point); + centre.add(point); + samples += 1; + } + if (!samples) return null; + centre.multiplyScalar(1 / samples); + nose.localToWorld(centre); + let radius = 0; + for (let i = 0; i < attr.count; i += step) { + point.fromBufferAttribute(attr, i); + nose.applyBoneTransform(i, point); + nose.localToWorld(point); + radius = Math.max(radius, point.distanceTo(centre)); + } + if (!Number.isFinite(radius) || radius <= 0) radius = 0.25; + const material = new THREE.MeshStandardMaterial({ + color: 0xdfe8ff, roughness: 0.45, metalness: 0, transparent: true, opacity: 0.92, + }); + const group = new THREE.Group(); + group.name = 'snot-bubble'; + const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.15, 28, 20), material); + group.add(bubble); + const bone = master.bone; + const baseWorld = centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7)); + const localBase = bone.worldToLocal(baseWorld.clone()); + // World-axis directions expressed in the bone's frame, captured at rest: the + // sliders keep meaning "right / up / towards the viewer" while the bubble rides + // with the body. + const boneQuat = new THREE.Quaternion(); + bone.getWorldQuaternion(boneQuat); + const invQuat = boneQuat.clone().invert(); + const axes = { + x: new THREE.Vector3(1, 0, 0).applyQuaternion(invQuat), + y: new THREE.Vector3(0, 1, 0).applyQuaternion(invQuat), + z: new THREE.Vector3(0, 0, 1).applyQuaternion(invQuat), + }; + group.position.copy(localBase); + group.userData = { bubble, material, bone, localBase, axes, baseScale: 1 }; + group.visible = false; + bone.add(group); + return group; + })(); + const snotMeshes = snotBubble ? [...snotBubble.children] : []; + applySnotBubble(snotBubble, state.face.eyes.snot); + + // The hat rides the `master` bone like the snot bubble does: it is parented + // there, placed at the top of the head in that bone's local frame, and turned + // so it is world-aligned at rest (the bone's own rest rotation cancelled once). + // The character's front is +z, so the hats are modelled facing +z. + const hatMount = (() => { + const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master'); + if (!master) return null; + model.root.updateMatrixWorld(true); + const bone = master.bone; + const group = new THREE.Group(); + group.name = 'hat'; + group.position.copy(bone.worldToLocal(new THREE.Vector3(0, model.bounds.max.y - 0.06, 0))); + group.quaternion.copy(bone.getWorldQuaternion(new THREE.Quaternion()).invert()); + bone.add(group); + return group; + })(); + + let hatKindCurrent = null; + let hatObject = null; + /** A GLB hat the user loaded; kept so it can be re-chosen without re-importing. */ + let customHatObject = null; + const hatMeshes = []; + /** Last transform applied, so the render loop only wakes when it changes. */ + const hatXform = { x: null, z: null, height: null, tiltX: null, tiltZ: null }; + /** Set once the outline pass exists, so a hat change can refresh the exclude set. */ + let refreshOutlineExclusion = null; + /** Set once the style system exists, so a hat can register for styles + outline. */ + let hatStyleRegister = null; + let hatStyleUnregister = null; + + /** + * Swap the mounted hat for `built` (or clear it), keeping the style system and + * the outline exclusion in step. + * + * The old meshes are collected BEFORE they are unregistered: `removeMesh` + * deletes the outline hull (a child of the hat group) while we traverse, which + * used to shorten `children` mid-loop and call `.traverse` on `undefined`. + */ + function mountHat(built) { + if (hatObject) { + const old = []; + hatObject.traverse((o) => { if (o.isMesh) old.push(o); }); + hatMount.remove(hatObject); + for (const mesh of old) hatStyleUnregister?.(mesh); + // The custom model is kept so it can be re-chosen; a built hat is dropped. + if (hatObject !== customHatObject) { + for (const mesh of old) { + mesh.geometry?.dispose(); + if (Array.isArray(mesh.material)) mesh.material.forEach((m) => m.dispose()); + else mesh.material?.dispose(); + } + } + hatObject = null; + } + hatMeshes.length = 0; + if (built) { + hatMount.add(built); + hatObject = built; + const meshes = []; + built.traverse((o) => { if (o.isMesh) meshes.push(o); }); + for (const mesh of meshes) { + hatMeshes.push(mesh); + hatStyleRegister?.(mesh); + } + } + refreshOutlineExclusion?.(); + } + + function applyHat(kind) { + if (!hatMount) return; + const custom = state.face.hat?.custom === true && customHatObject; + const key = custom ? 'custom' : kind; + if (key !== hatKindCurrent) { + hatKindCurrent = key; + mountHat(custom ? customHatObject : buildHat(kind)); + } + + // 高さ and 傾き. The mount is world-aligned (see above), so +y is up, +z is the + // character's front and +x its right: a plain translation and rotation read + // the way the sliders say. + const hat = state.face.hat ?? {}; + const x = hat.x ?? 0; + const z = hat.z ?? 0; + const height = hat.height ?? 0; + const tiltX = hat.tiltX ?? 0; + const tiltZ = hat.tiltZ ?? 0; + if (hatObject) { + hatObject.position.set(x, height, z); + hatObject.rotation.set((tiltX * Math.PI) / 180, 0, (tiltZ * Math.PI) / 180); + } + if (x !== hatXform.x || z !== hatXform.z || height !== hatXform.height || tiltX !== hatXform.tiltX || tiltZ !== hatXform.tiltZ) { + hatXform.x = x; + hatXform.z = z; + hatXform.height = height; + hatXform.tiltX = tiltX; + hatXform.tiltZ = tiltZ; + app.needsRender = true; + } + } + /** + * Import a GLB hat. It is centred sideways, dropped so its base sits at y = 0 + * (the mount sits on the head), and scaled to about the head's width, then + * mounted like any built hat. + */ + async function loadHatModel(file) { + if (!hatMount) return; + const buffer = await exporter.readFileAsArrayBuffer(file); + const loader = new GLTFLoader(); + const gltf = await new Promise((resolve, reject) => loader.parse(buffer, '', resolve, reject)); + const root = gltf.scene; + const box = new THREE.Box3().setFromObject(root); + const size = box.getSize(new THREE.Vector3()); + const target = Math.max(0.2, model.size.x * 0.95); + const scale = target / Math.max(size.x, size.z, 0.001); + root.scale.setScalar(scale); + box.setFromObject(root); + const center = box.getCenter(new THREE.Vector3()); + root.position.x -= center.x; + root.position.z -= center.z; + root.position.y -= box.min.y; + root.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = false; } }); + customHatObject = root; + state.face.hat.custom = true; + state.face.hat.kind = 'none'; + hatKindCurrent = null; + refresh('all'); + app.panel?.sync(); + } + + applyHat(state.face.hat?.kind ?? 'none'); + + const halfHeight = model.size.y * 0.5; + view.frame(model.size, halfHeight); + + // shadow camera big enough for the whole character + const radius = Math.max(model.size.x, model.size.y, model.size.z) * 1.6; + const shadowCamera = key.shadow.camera; + shadowCamera.left = -radius; + shadowCamera.right = radius; + shadowCamera.top = radius; + shadowCamera.bottom = -radius; + shadowCamera.near = 0.5; + shadowCamera.far = radius * 9; + shadowCamera.updateProjectionMatrix(); + key.target.position.set(0, halfHeight, 0); + + // The light's offset from the character, so the key light (and therefore its + // shadow camera) can follow the character around: moving far from the origin + // used to leave the shadow behind and cut it off at the shadow camera's edge. + const keyOffset = new THREE.Vector3(0, model.size.y * 3, model.size.y * 3); + function updateKeyLight() { + key.target.position.set(container.position.x, halfHeight + container.position.y, container.position.z); + key.position.copy(key.target.position).add(keyOffset); + } + + // ------------------------------------------------------------- controllers + const styles = new Styles({ meshes: model.parts.body }); + // Hats are built at runtime, so they are not in the GLB's mesh list. Register + // them with the style system (and the current hat) so they follow flat/line art + // and get an outline hull like every other part. + hatStyleRegister = (mesh) => styles.addMesh(mesh, { tone: true, lineOnly: true }); + hatStyleUnregister = (mesh) => styles.removeMesh(mesh); + for (const mesh of hatMeshes) styles.addMesh(mesh, { tone: true, lineOnly: true }); + const face = new Face({ + eyeMesh: model.parts.eyeMesh, + mouthMesh: model.parts.mouthMesh, + hairMesh: model.parts.hairMesh, + originals: model.originals, + }); + const animator = new Animator({ state }); + + // Beard drawings supplied as files (`assets/beards/.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/.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); -- cgit v1.3.1