aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/main.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/main.js')
-rw-r--r--public/bluebey-studio/src/main.js154
1 files changed, 122 insertions, 32 deletions
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;