aboutsummaryrefslogtreecommitdiffhomepage
path: root/public
diff options
context:
space:
mode:
Diffstat (limited to 'public')
-rw-r--r--public/bluebey-studio/index.html4
-rw-r--r--public/bluebey-studio/src/exporter.js17
-rw-r--r--public/bluebey-studio/src/main.js154
-rw-r--r--public/bluebey-studio/src/panel.js23
-rw-r--r--public/bluebey-studio/src/presets.js4
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,