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