Skip to content

Commit 9263038

Browse files
authored
Merge pull request #1 from ketle-man/feature/pose-library
feat: ポーズライブラリ・左右反転・UI英語化 (v0.3.0)
2 parents d39733c + 6e7e239 commit 9263038

9 files changed

Lines changed: 1355 additions & 119 deletions

README.md

Lines changed: 205 additions & 114 deletions
Large diffs are not rendered by default.

__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .pose_editor_node_3d import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
2+
from . import pose_library_server # noqa: F401 — デコレータでルートを登録する
23

34
WEB_DIRECTORY = "./js"
45

docs/screenshot_pose_library.png

204 KB
Loading

docs/screenshot_workflow.png

-225 KB
Loading

js/pose_editor_3d.js

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as THREE from './vendor/three.module.js';
33
import { GLTFLoader } from './vendor/GLTFLoader.js';
44
import { OrbitControls } from './vendor/OrbitControls.js';
55
import { VRMLoaderPlugin, VRMUtils } from './vendor/three-vrm.module.js';
6+
import { openPoseLibrary } from './pose_library.js';
67

78
// ノードIDごとのモデルバッファキャッシュ(タブ切り替えによる再作成対策)
89
// { nodeId: { buffer: ArrayBuffer|null, isDefault: bool, url: string|null } }
@@ -49,6 +50,7 @@ app.registerExtension({
4950

5051
const captureBtn = makeSmallButton("📸 Capture", "#4a90d9", "Send pose to output");
5152
const resetBtn = makeSmallButton("RP", "#6c757d", "Reset Pose");
53+
const mirrorBtn = makeSmallButton("↔", "#5a6a7a", "Mirror Pose (flip left/right)");
5254
const cameraResetBtn = makeSmallButton("RC", "#5a7a5a", "Reset Camera");
5355
const camModeBtn = makeSmallButton("OT", "#444", "Camera: Perspective (click to toggle Orthographic)");
5456

@@ -59,14 +61,19 @@ app.registerExtension({
5961
vrmInput.style.display = "none";
6062
vrmBtn.onclick = () => vrmInput.click();
6163

62-
const savePoseBtn = makeSmallButton("💾", "#4a7a4a", "Save pose as JSON");
63-
const loadPoseBtn = makeSmallButton("📂", "#7a6a3a", "Load pose from JSON");
64+
const savePoseBtn = makeSmallButton("⬇️", "#4a7a4a", "Download the pose");
65+
const saveToPosesBtn = makeSmallButton("💾", "#4a6a8a", "Save pose to poses/");
66+
const loadPoseBtn = makeSmallButton("📂", "#7a6a3a", "Load pose from JSON");
6467
const poseInput = document.createElement("input");
6568
poseInput.type = "file";
6669
poseInput.accept = ".json,.vroidpose";
6770
poseInput.style.display = "none";
6871
loadPoseBtn.onclick = () => poseInput.click();
6972

73+
// ポーズライブラリボタン
74+
const libraryBtn = makeSmallButton("📚", "#4a4a8a", "Open Pose Library");
75+
let _currentVrmBuffer = null; // VRMバッファへの参照(ライブラリ内サムネイル生成用)
76+
7077
let colorCorrectOn = false;
7178
const ccBtn = makeSmallButton("CC", "#444", "Color Correct: OFF");
7279
ccBtn.onclick = () => {
@@ -94,14 +101,17 @@ app.registerExtension({
94101

95102
btnRow.appendChild(captureBtn);
96103
btnRow.appendChild(resetBtn);
104+
btnRow.appendChild(mirrorBtn);
97105
btnRow.appendChild(cameraResetBtn);
98106
btnRow.appendChild(camModeBtn);
99107
btnRow.appendChild(vrmBtn);
100108
btnRow.appendChild(ccBtn);
101109
btnRow.appendChild(bgBtn);
102110
btnRow.appendChild(bgClearBtn);
103111
btnRow.appendChild(savePoseBtn);
112+
btnRow.appendChild(saveToPosesBtn);
104113
btnRow.appendChild(loadPoseBtn);
114+
btnRow.appendChild(libraryBtn);
105115
btnRow.appendChild(vrmInput);
106116
btnRow.appendChild(bgInput);
107117
btnRow.appendChild(poseInput);
@@ -423,7 +433,12 @@ app.registerExtension({
423433
}, 1500);
424434
};
425435

426-
resetBtn.onclick = () => editor.resetPose();
436+
libraryBtn.onclick = () => {
437+
openPoseLibrary(editor, _currentVrmBuffer);
438+
};
439+
440+
resetBtn.onclick = () => editor.resetPose();
441+
mirrorBtn.onclick = () => editor.mirrorPose();
427442
cameraResetBtn.onclick = () => editor.resetCamera();
428443
camModeBtn.onclick = () => {
429444
const toOrtho = camModeBtn.dataset.mode !== "ortho";
@@ -459,6 +474,8 @@ app.registerExtension({
459474
const buffer = e.target.result;
460475
// キャッシュに保存(タブ切り替えによる再作成時に復元)
461476
_nodeModelCache[node.id] = { buffer, isDefault: false };
477+
// ライブラリのサムネイル生成用に保持
478+
_currentVrmBuffer = buffer;
462479
const url = URL.createObjectURL(new Blob([buffer]));
463480
editor.loadVRMFromBuffer(buffer, url, () => {
464481
URL.revokeObjectURL(url);
@@ -469,7 +486,7 @@ app.registerExtension({
469486
reader.readAsArrayBuffer(file);
470487
}
471488

472-
// ---- ポーズJSON 保存 ----
489+
// ---- ポーズJSON ダウンロード ----
473490
savePoseBtn.onclick = () => {
474491
const json = editor.exportPose();
475492
if (!json) return;
@@ -481,6 +498,25 @@ app.registerExtension({
481498
URL.revokeObjectURL(a.href);
482499
};
483500

501+
// ---- ポーズJSON を poses/ に保存 ----
502+
saveToPosesBtn.onclick = async () => {
503+
const json = editor.exportPose();
504+
if (!json) return;
505+
try {
506+
const res = await fetch("/pose_library/save_pose", {
507+
method: "POST",
508+
headers: { "Content-Type": "application/json" },
509+
body: JSON.stringify({ json }),
510+
});
511+
const data = await res.json();
512+
if (!res.ok) throw new Error(data.error ?? res.status);
513+
saveToPosesBtn.textContent = "✅";
514+
setTimeout(() => { saveToPosesBtn.textContent = "💾"; }, 1500);
515+
} catch (e) {
516+
alert("poses/ への保存に失敗しました: " + e.message);
517+
}
518+
};
519+
484520
// ---- ポーズJSON 読み込み ----
485521
function loadPoseFile(file) {
486522
const reader = new FileReader();
@@ -1269,6 +1305,66 @@ function initPoseEditor3D(canvas, gizmoCanvas, baseUrl, onMorphKeysReady, isMode
12691305
bone.rotation.z = bones[key].z ?? 0;
12701306
});
12711307
},
1308+
mirrorPose() {
1309+
// 現在のポーズをversion2形式でエクスポートし、左右ミラーして再インポート
1310+
const json = this.exportPose();
1311+
if (!json) return;
1312+
const parsed = JSON.parse(json);
1313+
if (parsed.version !== 2 || !parsed.bones) return;
1314+
1315+
// Left↔Right ボーン名の対応表(VRM humanoid bone名ベース)
1316+
const MIRROR_PAIRS = [
1317+
["leftShoulder", "rightShoulder"],
1318+
["leftUpperArm", "rightUpperArm"],
1319+
["leftLowerArm", "rightLowerArm"],
1320+
["leftHand", "rightHand"],
1321+
["leftUpperLeg", "rightUpperLeg"],
1322+
["leftLowerLeg", "rightLowerLeg"],
1323+
["leftFoot", "rightFoot"],
1324+
["leftToes", "rightToes"],
1325+
["leftThumbMetacarpal", "rightThumbMetacarpal"],
1326+
["leftThumbProximal", "rightThumbProximal"],
1327+
["leftThumbDistal", "rightThumbDistal"],
1328+
["leftIndexProximal", "rightIndexProximal"],
1329+
["leftIndexIntermediate", "rightIndexIntermediate"],
1330+
["leftIndexDistal", "rightIndexDistal"],
1331+
["leftMiddleProximal", "rightMiddleProximal"],
1332+
["leftMiddleIntermediate","rightMiddleIntermediate"],
1333+
["leftMiddleDistal", "rightMiddleDistal"],
1334+
["leftRingProximal", "rightRingProximal"],
1335+
["leftRingIntermediate", "rightRingIntermediate"],
1336+
["leftRingDistal", "rightRingDistal"],
1337+
["leftLittleProximal", "rightLittleProximal"],
1338+
["leftLittleIntermediate","rightLittleIntermediate"],
1339+
["leftLittleDistal", "rightLittleDistal"],
1340+
];
1341+
1342+
// クォータニオンを左右ミラー変換: YZ軸を反転 (x, -y, -z, w)
1343+
function mirrorQuat(bd) {
1344+
return { qx: bd.qx, qy: -bd.qy, qz: -bd.qz, qw: bd.qw };
1345+
}
1346+
1347+
const src = parsed.bones;
1348+
const mirrored = {};
1349+
1350+
// ペアボーンを左右入れ替えてミラー変換
1351+
for (const [left, right] of MIRROR_PAIRS) {
1352+
if (src[left]) mirrored[right] = mirrorQuat(src[left]);
1353+
if (src[right]) mirrored[left] = mirrorQuat(src[right]);
1354+
}
1355+
1356+
// ペア以外(中央ボーン: hips / spine / chest 等)もミラー変換
1357+
for (const [key, bd] of Object.entries(src)) {
1358+
if (!(key in mirrored)) mirrored[key] = mirrorQuat(bd);
1359+
}
1360+
1361+
const mirroredJson = JSON.stringify({
1362+
version: 2,
1363+
vrmVersion: parsed.vrmVersion,
1364+
bones: mirrored,
1365+
});
1366+
this.importPose(mirroredJson);
1367+
},
12721368
setColorCorrect(enabled) {
12731369
renderer.outputColorSpace = enabled ? THREE.SRGBColorSpace : THREE.LinearSRGBColorSpace;
12741370
renderer.toneMapping = enabled ? THREE.ACESFilmicToneMapping : THREE.NoToneMapping;

0 commit comments

Comments
 (0)