From 09ab25113c299b8f3208a717cee9e9b8c7b07fe9 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:24:54 +0900 Subject: bluebey-studio: ドラッグの役割を入れ替え、回転中心をぶるべーに固定 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/bluebey-studio/src/main.js | 154 ++++++++++++++++++++++++++++++-------- 1 file changed, 122 insertions(+), 32 deletions(-) (limited to 'public/bluebey-studio/src/main.js') 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; -- cgit v1.3.1