@@ -3,6 +3,7 @@ import * as THREE from './vendor/three.module.js';
33import { GLTFLoader } from './vendor/GLTFLoader.js' ;
44import { OrbitControls } from './vendor/OrbitControls.js' ;
55import { 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