diff options
Diffstat (limited to 'public/bluebey-studio')
| -rw-r--r-- | public/bluebey-studio/index.html | 4 | ||||
| -rw-r--r-- | public/bluebey-studio/src/exporter.js | 17 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 154 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 23 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 4 |
5 files changed, 162 insertions, 40 deletions
diff --git a/public/bluebey-studio/index.html b/public/bluebey-studio/index.html index 3e4eb86..59e8b74 100644 --- a/public/bluebey-studio/index.html +++ b/public/bluebey-studio/index.html @@ -104,7 +104,7 @@ if (window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coa <aside id="panel" aria-label="コントロールパネル"></aside> <p id="viewport-hint" class="viewport-hint"> - ドラッグ:回転 / ホイール:ズーム / 右ドラッグ:移動 / ぶるべーをドラッグ:移動 + ドラッグ:回転 / ホイール:ズーム / 右ドラッグ:カメラ移動 / ぶるべーをドラッグ:カメラ移動 / Shift+ドラッグ:ぶるべーを移動 </p> <p id="viewport-hint-touch" class="viewport-hint"> 2本指でドラッグ:カメラ移動(ぶるべーは動きません)/ 2本指をつまむ:ズーム<br> @@ -136,7 +136,7 @@ if (window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coa <h3>ポーズ</h3> <ul> <li>右パネルの「ポーズ」でボーンを選び、X/Y/Zのスライダーを動かします。「ポーズ例」から始めると早いです。</li> - <li>ぶるべーをつかんでドラッグすると、ぶるべーを移動できます。Shiftを押しながら左右にドラッグすると影と一緒に左右に動き、上下にドラッグするとカメラ(見ている基準点)が上下に動きます。回転の中心をぶるべーに合わせたいときは、ぶるべーをダブルクリックします。</li> + <li>ぶるべーをドラッグすると、カメラの中心が動いてぶるべーが画面の中を動きます。Shiftを押しながらドラッグすると、ぶるべー自体を移動できます。どちらも回転の中心はぶるべーのままなので、そのままドラッグで回転させてもぶるべーは画面から外れません。</li> <li>モデルをクリックすると、その場所にいちばん近いボーンが選ばれます。</li> <li>ギズモ(回転の輪)を直接ドラッグしても回せます。</li> <li>スライダーの値は「休めの姿勢(レストポーズ)」からの差分です。<code>0,0,0</code> で元の姿勢。</li> diff --git a/public/bluebey-studio/src/exporter.js b/public/bluebey-studio/src/exporter.js index cc9c1ca..cad9ef0 100644 --- a/public/bluebey-studio/src/exporter.js +++ b/public/bluebey-studio/src/exporter.js @@ -10,13 +10,22 @@ import { roughenContours } from './handDrawn.js'; */ /** Render the current view into an offscreen 2D canvas at an arbitrary size. */ -export function renderStill(view, { width, height, background }) { +export function renderStill(view, { width, height, background, viewOffset = null }) { const { renderer, scene, camera } = view; const previousAspect = camera.aspect; + // The live camera may already carry a projection shift (the pan). It is stored + // in CSS pixels against the viewport, so it has to be re-applied for the + // export's own pixel size - and restored afterwards. + const previousView = camera.view ? { ...camera.view } : null; renderer.setPixelRatio(1); renderer.setSize(width, height, false); camera.aspect = width / height; + if (viewOffset && (viewOffset.x || viewOffset.y)) { + camera.setViewOffset(width, height, -viewOffset.x * width, -viewOffset.y * height, width, height); + } else { + camera.clearViewOffset(); + } camera.updateProjectionMatrix(); applyBackground(renderer, background); @@ -37,6 +46,8 @@ export function renderStill(view, { width, height, background }) { canvas.getContext('2d').drawImage(renderer.domElement, 0, 0); camera.aspect = previousAspect; + if (previousView) camera.view = previousView; + else camera.clearViewOffset(); camera.updateProjectionMatrix(); view.restore?.(); @@ -53,12 +64,12 @@ export function applyBackground(renderer, background) { } /** A PNG data URL of the current view, scaled up by `scale`. */ -export async function capturePNG(view, { scale = 2, background, width, height }) { +export async function capturePNG(view, { scale = 2, background, width, height, viewOffset = null }) { const baseWidth = view.width || view.renderer.domElement.clientWidth || 1280; const baseHeight = view.height || view.renderer.domElement.clientHeight || 800; const outWidth = Math.min(8192, Math.max(64, Math.round((width ?? baseWidth) * scale))); const outHeight = Math.min(8192, Math.max(64, Math.round((height ?? baseHeight) * scale))); - const canvas = renderStill(view, { width: outWidth, height: outHeight, background }); + const canvas = renderStill(view, { width: outWidth, height: outHeight, background, viewOffset }); const blob = await canvasToBlob(canvas); return { blob, canvas, width: outWidth, height: outHeight }; } diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index 7847355..57f0140 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -1298,6 +1298,9 @@ async function init() { state.view.targetX = round2(root[0] ?? 0); state.view.targetY = round2(halfHeight + (root[1] ?? 0)); state.view.targetZ = round2(root[2] ?? 0); + // The pan is a projection shift, so clearing it puts the character back in + // the middle of the frame as well. + state.view.viewOffset = { x: 0, y: 0 }; refresh('view'); app.panel?.sync(); } @@ -1326,6 +1329,7 @@ async function init() { resetPose: () => { rig.reset(); state.pose = { bones: {}, root: [0, 0, 0] }; + app.lastPoseRootOffset = [0, 0, 0]; applyState({}, { scope: 'pose', sync: true }); toast('ポーズをリセットしました'); }, @@ -1998,7 +2002,7 @@ async function init() { width: canvas.clientWidth || window.innerWidth, height: canvas.clientHeight || window.innerHeight, restore: () => { resizeViewport(); applyStateBackgroundAgain(); }, - }, { scale, background })); + }, { scale, background, viewOffset: state.view.viewOffset })); } finally { if (drawn !== onScreen) styles.setStyle(onScreen); } @@ -2462,12 +2466,13 @@ async function init() { 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 - // slides it horizontally too but moves the camera's reference point up and - // down. On touch, dragging the model always moves the character (the phone - // gestures). 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. + // Plain drag on the character pans the view (the orbit target does not move, + // so rotating still turns around ぶるべー). Shift+drag - or a finger, which + // has no Shift - moves ぶるべー itself, carrying the camera with it so the + // pivot stays on its centre. + // 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) => { @@ -2489,7 +2494,9 @@ async function init() { const distance = camera.position.distanceTo(view.controls.target); return (2 * distance * Math.tan((camera.fov * Math.PI) / 360)) / height; }; + const clampOffset = (value) => Math.min(2, Math.max(-2, value)); let modelDrag = null; + let viewPan = null; canvas.addEventListener('pointerdown', (event) => { if (event.button !== 0 || event.target !== canvas) return; @@ -2498,15 +2505,14 @@ async function init() { return; } if (!dragHitAt(event)) return; - const root = state.pose.root ?? [0, 0, 0]; modelDrag = { pointerId: event.pointerId, - pan: event.shiftKey, + move: event.shiftKey || event.pointerType === 'touch', x: event.clientX, y: event.clientY, lastX: event.clientX, lastY: event.clientY, - root: [...root], + offset: { ...(state.view.viewOffset ?? { x: 0, y: 0 }) }, }; view.controls.enabled = false; stage.style.cursor = 'grabbing'; @@ -2515,50 +2521,57 @@ async function init() { canvas.addEventListener('pointermove', (event) => { if (!modelDrag || event.pointerId !== modelDrag.pointerId) return; + const rect = canvas.getBoundingClientRect(); + const width = Math.max(1, rect.width); + const height = Math.max(1, rect.height); const dx = event.clientX - modelDrag.x; const dy = event.clientY - modelDrag.y; - const k = dragScale(); - if (modelDrag.pan) { - // Shift+drag: horizontal moves the character with its shadow (like a - // plain drag); vertical moves the camera's reference point up/down. + if (modelDrag.move) { + const k = dragScale(); const stepX = event.clientX - modelDrag.lastX; const stepY = event.clientY - modelDrag.lastY; modelDrag.lastX = event.clientX; modelDrag.lastY = event.clientY; const root = state.pose.root ?? (state.pose.root = [0, 0, 0]); - root[0] = (root[0] ?? 0) + stepX * k; - const lift = stepY * k; - view.target.y += lift; - for (const controls of Object.values(view.controlsMap ?? {})) controls.target.y += lift; - for (const cam of Object.values(view.cameras ?? {})) cam.position.y += lift; - view.captureInto(state.view); - } else { - // Shift+drag (or any touch drag on the character): slide the character, - // leaving the camera's reference point alone. - state.pose.root = [ - round2(modelDrag.root[0] + dx * k), - round2(modelDrag.root[1] - dy * k), - modelDrag.root[2], - ]; + const delta = new THREE.Vector3(stepX * k, -stepY * k, 0); + root[0] = (root[0] ?? 0) + delta.x; + root[1] = (root[1] ?? 0) + delta.y; + // The camera travels with the character so the orbit pivot stays on its + // centre; the projection shift below then moves it with the pointer (and + // leaves the floor where it was on screen). + view.panBy(delta); + state.view.viewOffset = { + x: clampOffset(modelDrag.offset.x + dx / width), + y: clampOffset(modelDrag.offset.y + dy / height), + }; + view.setViewOffset(state.view.viewOffset); app.panel?.onRigChanged?.(); + } else { + // Plain drag: pan the view only. The character keeps its world position + // and the orbit target stays on it. + state.view.viewOffset = { + x: clampOffset(modelDrag.offset.x + dx / width), + y: clampOffset(modelDrag.offset.y + dy / height), + }; + view.setViewOffset(state.view.viewOffset); } app.needsRender = true; }); const endModelDrag = (event) => { if (!modelDrag || (event && event.pointerId !== modelDrag.pointerId)) return; - const wasPan = modelDrag.pan === true; + const wasMove = modelDrag.move === true; modelDrag = null; view.controls.enabled = true; stage.style.cursor = ''; if (event) { try { canvas.releasePointerCapture(event.pointerId); } catch { /* ignore */ } } - if (wasPan) { + view.captureInto(state.view); + if (wasMove) { if (state.pose.root) { state.pose.root = state.pose.root.map((value) => Math.round((value ?? 0) * 1000) / 1000); } - app.actions.syncPanel?.(); - } else { capturePose(); + app.actions.syncPanel?.(); } app.panel?.onRigChanged?.(); app.needsRender = true; @@ -2566,6 +2579,41 @@ async function init() { canvas.addEventListener('pointerup', endModelDrag); canvas.addEventListener('pointercancel', endModelDrag); + // Right-drag pans too, through the same projection shift, so the pivot never + // drifts away from the character the way OrbitControls' own pan makes it. + canvas.addEventListener('pointerdown', (event) => { + if (event.button !== 2 || event.target !== canvas) return; + event.preventDefault(); + event.stopImmediatePropagation(); + viewPan = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + offset: { ...(state.view.viewOffset ?? { x: 0, y: 0 }) }, + }; + stage.style.cursor = 'grabbing'; + try { canvas.setPointerCapture(event.pointerId); } catch { /* ignore */ } + }, true); + canvas.addEventListener('pointermove', (event) => { + if (!viewPan || event.pointerId !== viewPan.pointerId) return; + const rect = canvas.getBoundingClientRect(); + state.view.viewOffset = { + x: clampOffset(viewPan.offset.x + (event.clientX - viewPan.x) / Math.max(1, rect.width)), + y: clampOffset(viewPan.offset.y + (event.clientY - viewPan.y) / Math.max(1, rect.height)), + }; + view.setViewOffset(state.view.viewOffset); + app.needsRender = true; + }, true); + const endViewPan = (event) => { + if (!viewPan || (event && event.pointerId !== viewPan.pointerId)) return; + viewPan = null; + stage.style.cursor = ''; + if (event) { try { canvas.releasePointerCapture(event.pointerId); } catch { /* ignore */ } } + app.needsRender = true; + }; + canvas.addEventListener('pointerup', endViewPan, true); + canvas.addEventListener('pointercancel', endViewPan, true); + canvas.addEventListener('dblclick', (event) => { if (!dragHitAt(event)) return; recenterCamera(); @@ -2687,6 +2735,8 @@ class ViewRig { this.camera = this.cameras.persp; this.controls = this.controlsMap.persp; this.target = new THREE.Vector3(0, 1, 0); + // A projection shift in fractions of the canvas (see `setViewOffset`). + this.viewOffset = { x: 0, y: 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 @@ -2725,6 +2775,41 @@ class ViewRig { this.cameras.persp.aspect = aspect; this.cameras.persp.updateProjectionMatrix(); this.setOrthoHeight(this.orthoHeight); + this.applyViewOffsetToCameras(); + } + + /** + * Slide the rendered window inside a larger virtual image, in fractions of the + * canvas. The camera and its target do not move, so the orbit pivot stays on + * ぶるべー while the character slides across the frame - which a camera pan + * cannot do, because OrbitControls' pan moves the target too. + */ + setViewOffset(offset) { + const x = Number(offset?.x); + const y = Number(offset?.y); + this.viewOffset = { + x: Number.isFinite(x) ? x : 0, + y: Number.isFinite(y) ? y : 0, + }; + this.applyViewOffsetToCameras(); + } + + applyViewOffsetToCameras() { + const width = this.domElement.clientWidth || 1; + const height = this.domElement.clientHeight || 1; + const px = Math.round(this.viewOffset.x * width); + const py = Math.round(this.viewOffset.y * height); + for (const camera of Object.values(this.cameras)) { + if (px || py) camera.setViewOffset(width, height, -px, -py, width, height); + else camera.clearViewOffset(); + } + } + + /** Move the camera and its reference point together, in world space. */ + panBy(delta) { + this.target.add(delta); + for (const controls of Object.values(this.controlsMap)) controls.target.add(delta); + for (const camera of Object.values(this.cameras)) camera.position.add(delta); } setOrthoHeight(height) { @@ -2801,6 +2886,7 @@ class ViewRig { this.orthoHeight = this.cameras.ortho.top - this.cameras.ortho.bottom; this.cameras.persp.updateProjectionMatrix(); this.cameras.ortho.updateProjectionMatrix(); + this.setViewOffset(viewState.viewOffset); } /** @@ -2824,6 +2910,10 @@ class ViewRig { viewState.targetX = Math.round(target.x * 1000) / 1000; viewState.targetY = Math.round(target.y * 1000) / 1000; viewState.targetZ = Math.round(target.z * 1000) / 1000; + viewState.viewOffset = { + x: Math.round(this.viewOffset.x * 100000) / 100000, + y: Math.round(this.viewOffset.y * 100000) / 100000, + }; if (this.projection === 'ortho') { const ortho = this.cameras.ortho; viewState.orthoHeight = Math.round((ortho.top - ortho.bottom) * 1000) / 1000; diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index 69c8454..ae45e48 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -298,7 +298,7 @@ export function buildPanel(app, root) { } poseSection.add(hint('ボーンは上の一覧から選びます。スライダーは「休めの姿勢からの差」です。' - + 'ビューポートのぶるべーは、ドラッグで移動、Shift+ドラッグで向きを変えられます。')); + + 'ビューポートのぶるべーは、ドラッグでカメラ移動、Shift+ドラッグでぶるべーを移動できます。どちらも回転の中心はぶるべーです。')); // こまかい設定: the whole-body nudge and the rotation gizmo are tweaks, not the // main way in, so they stay folded away inside the bone section. @@ -351,8 +351,25 @@ export function buildPanel(app, root) { app.animator.reset(); app.needsRender = true; } - state.pose = { bones: {}, root: [0, 0, 0] }; - applyPatch(state.pose, preset.pose ?? {}); + // Keep where the user put ぶるべー: a pose changes the limbs and may lift the + // body off the ground, but it must not move the character back to the origin. + // `app.lastPoseRootOffset` remembers how much the previous pose added, so + // switching poses does not stack those lifts. + const previous = app.lastPoseRootOffset ?? [0, 0, 0]; + const current = state.pose.root ?? [0, 0, 0]; + const offset = preset.pose?.root ?? [0, 0, 0]; + const base = [ + (current[0] ?? 0) - (previous[0] ?? 0), + (current[1] ?? 0) - (previous[1] ?? 0), + (current[2] ?? 0) - (previous[2] ?? 0), + ]; + const { root: _ignore, ...poseWithoutRoot } = preset.pose ?? {}; + state.pose = { + bones: {}, + root: [base[0] + offset[0], base[1] + offset[1], base[2] + offset[2]], + }; + applyPatch(state.pose, poseWithoutRoot); + app.lastPoseRootOffset = [...offset]; if (!state.pose.root) state.pose.root = [0, 0, 0]; replace({}, 'pose'); sync(); diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index 75a8373..d9ed3e4 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -63,6 +63,10 @@ export function defaultState() { backgroundDarken: 0, // 0..1 dark veil, helps the character read backgroundScale: 1, backgroundOffset: { x: 0, y: 0 }, + // Screen-space shift of the whole view, in fractions of the canvas. This is + // the "move the camera centre" pan: the orbit target stays on ぶるべー, so + // rotating still turns around the character. + viewOffset: { x: 0, y: 0 }, // "Hide ぶるべー behind a tree" mask, made by tapping the photo. `mask` is // opaque where the object is; the export punches the character out there. occlusionMask: null, |
