Spaces:
Running
Running
| <html lang="ja"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>BVH Foot Mirror Editor</title> | |
| <style> | |
| :root { | |
| --bg: #0f172a; | |
| --panel: #111827; | |
| --panel2: #1f2937; | |
| --text: #e5e7eb; | |
| --muted: #9ca3af; | |
| --accent: #60a5fa; | |
| --accent2: #34d399; | |
| --border: #334155; | |
| --danger: #f87171; | |
| --warn: #fbbf24; | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } | |
| body { display: grid; grid-template-columns: 380px 1fr; overflow: hidden; } | |
| .sidebar { | |
| background: linear-gradient(180deg, #0b1220 0%, #0f172a 100%); | |
| border-right: 1px solid var(--border); | |
| padding: 14px; | |
| overflow: auto; | |
| } | |
| .main { display: grid; grid-template-rows: auto 1fr auto; min-width: 0; } | |
| .topbar, .bottombar { | |
| background: rgba(15, 23, 42, 0.96); | |
| border-bottom: 1px solid var(--border); | |
| padding: 10px 14px; | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| .bottombar { border-top: 1px solid var(--border); border-bottom: 0; } | |
| .viewport { position: relative; background: radial-gradient(circle at 50% 30%, #16213b 0%, #0b1020 60%, #050814 100%); } | |
| canvas { display: block; width: 100%; height: 100%; } | |
| .card { | |
| background: rgba(17, 24, 39, 0.92); | |
| border: 1px solid var(--border); | |
| border-radius: 14px; | |
| padding: 12px; | |
| margin-bottom: 12px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.18); | |
| } | |
| .card h2, .card h3 { margin: 0 0 10px 0; font-size: 15px; } | |
| .hint { color: var(--muted); font-size: 12px; line-height: 1.45; } | |
| .row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } | |
| .row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; } | |
| .row4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } | |
| label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 6px; } | |
| input[type="file"], input[type="text"], input[type="number"], select, button, textarea { | |
| width: 100%; | |
| background: #0b1220; | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 10px 10px; | |
| font: inherit; | |
| } | |
| input[type="range"] { width: 100%; } | |
| textarea { min-height: 140px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } | |
| button { cursor: pointer; transition: 0.15s transform, 0.15s background; } | |
| button:hover { transform: translateY(-1px); background: #12203a; } | |
| button.primary { background: #17335c; border-color: #2b5aa0; } | |
| button.good { background: #123326; border-color: #226a4e; } | |
| button.warn { background: #33260e; border-color: #845f17; } | |
| button.danger { background: #33161a; border-color: #7f2d38; } | |
| .toggle-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } | |
| .toggle { | |
| display: flex; align-items: center; gap: 8px; | |
| background: #0b1220; border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; font-size: 12px; | |
| user-select: none; | |
| } | |
| .toggle input { width: auto; } | |
| .split { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } | |
| .status { font-size: 12px; color: var(--muted); line-height: 1.5; } | |
| .value { color: var(--text); font-weight: 600; } | |
| .pill { display: inline-block; padding: 4px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); font-size: 12px; } | |
| .warning { color: #fca5a5; } | |
| .small { font-size: 11px; } | |
| .foot-box { border: 1px dashed #475569; border-radius: 12px; padding: 10px; background: rgba(15, 23, 42, 0.55); } | |
| .sticky { position: sticky; top: 0; z-index: 1; } | |
| .joint-list { | |
| max-height: 150px; | |
| overflow: auto; | |
| padding: 8px; | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| background: #0b1220; | |
| font-size: 12px; | |
| line-height: 1.4; | |
| } | |
| .joint-item { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } | |
| .joint-item input { width: auto; } | |
| .footer-note { color: var(--muted); font-size: 11px; } | |
| .canvas-overlay { | |
| position: absolute; left: 12px; top: 12px; display: flex; gap: 8px; flex-wrap: wrap; pointer-events: none; | |
| } | |
| .overlay-item { background: rgba(17,24,39,0.8); border: 1px solid var(--border); border-radius: 999px; padding: 6px 10px; font-size: 12px; } | |
| </style> | |
| </head> | |
| <body> | |
| <aside class="sidebar"> | |
| <div class="card sticky"> | |
| <h2>BVH Foot Mirror Editor</h2> | |
| <div class="hint">BVHを読み込んで、足の向きが逆になる原因を見ながら、位置・回転の反転を試せる単体HTMLです。</div> | |
| </div> | |
| <div class="card"> | |
| <h3>1. BVHを読み込む</h3> | |
| <input id="fileInput" type="file" accept=".bvh,.txt" /> | |
| <div class="hint" style="margin-top:8px;">読み込んだBVHは自動で解析されます。ファイルが大きい場合は少し時間がかかります。</div> | |
| <div id="loadStatus" class="status" style="margin-top:8px;">未読み込み</div> | |
| </div> | |
| <div class="card"> | |
| <h3>2. 再生</h3> | |
| <div class="row"> | |
| <div> | |
| <label>フレーム</label> | |
| <input id="frameSlider" type="range" min="0" max="0" value="0" step="1" /> | |
| </div> | |
| <div> | |
| <label>速度</label> | |
| <input id="speedInput" type="number" min="0.1" max="5" step="0.1" value="1" /> | |
| </div> | |
| </div> | |
| <div class="row" style="margin-top:8px;"> | |
| <button id="playBtn" class="primary">再生</button> | |
| <button id="resetViewBtn">カメラ初期化</button> | |
| </div> | |
| <div class="row" style="margin-top:8px;"> | |
| <button id="prevFrameBtn">前へ</button> | |
| <button id="nextFrameBtn">次へ</button> | |
| </div> | |
| <div class="status" style="margin-top:8px;"> | |
| 現在フレーム: <span id="frameLabel" class="value">0</span> / <span id="frameMaxLabel" class="value">0</span><br /> | |
| 時間: <span id="timeLabel" class="value">0.000</span>s | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h3>3. 反転オプション</h3> | |
| <div class="hint">全体座標の反転と、回転チャンネルの符号反転を組み合わせて試せます。足だけに絞りたい場合は下の対象骨を選びます。</div> | |
| <div class="toggle-grid" style="margin-top:10px;"> | |
| <label class="toggle"><input type="checkbox" id="flipPosX">位置X反転</label> | |
| <label class="toggle"><input type="checkbox" id="flipPosY">位置Y反転</label> | |
| <label class="toggle"><input type="checkbox" id="flipPosZ">位置Z反転</label> | |
| <label class="toggle"><input type="checkbox" id="flipRotX">回転X反転</label> | |
| <label class="toggle"><input type="checkbox" id="flipRotY">回転Y反転</label> | |
| <label class="toggle"><input type="checkbox" id="flipRotZ">回転Z反転</label> | |
| </div> | |
| <div class="split" style="margin-top:10px;"> | |
| <button id="applyMirrorBtn" class="good">反転を試す</button> | |
| <button id="applyFeetBtn" class="warn">足だけ対象にする</button> | |
| </div> | |
| <div class="split" style="margin-top:8px;"> | |
| <button id="clearMirrorBtn">反転設定クリア</button> | |
| <button id="exportBtn" class="primary">JSON書き出し</button> | |
| </div> | |
| <div class="split" style="margin-top:8px;"> | |
| <button id="exportBVHBtn" class="good">BVH書き出し</button> | |
| <div></div> | |
| </div> | |
| <div class="hint" style="margin-top:8px;">※ 書き出しはBVHを直接編集しません。現状の調整値をJSONで保存します。</div> | |
| </div> | |
| <div class="card"> | |
| <h3>4. 足の対象骨</h3> | |
| <div class="row"> | |
| <div> | |
| <label>検索</label> | |
| <input id="jointSearch" type="text" placeholder="例: foot, toe, ankle" /> | |
| </div> | |
| <div> | |
| <label>操作</label> | |
| <select id="jointAction"> | |
| <option value="toggle">選択を切り替え</option> | |
| <option value="select">絞り込み対象に追加</option> | |
| <option value="clear">全解除</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div style="margin-top:8px;"> | |
| <button id="refreshJointsBtn">候補を更新</button> | |
| </div> | |
| <div id="jointList" class="joint-list" style="margin-top:8px;">BVH読み込み後に候補が表示されます。</div> | |
| <div class="hint" style="margin-top:8px;">足首・足先・つま先などを選んで、反転の効きを比較できます。</div> | |
| </div> | |
| <div class="card"> | |
| <h3>5. パラメーター確認</h3> | |
| <div class="foot-box"> | |
| <div class="status" id="bvhStats">未読み込み</div> | |
| </div> | |
| <div class="hint" style="margin-top:8px;">ジョイント名、チャンネル順、フレーム数、サンプル値をここで確認できます。</div> | |
| </div> | |
| <div class="card"> | |
| <h3>6. 生データ</h3> | |
| <textarea id="rawOutput" readonly placeholder="BVHの解析結果や補正情報を表示します"></textarea> | |
| </div> | |
| </aside> | |
| <main class="main"> | |
| <div class="topbar"> | |
| <span class="pill" id="fileNamePill">No file</span> | |
| <span class="pill" id="jointCountPill">Joints: 0</span> | |
| <span class="pill" id="frameCountPill">Frames: 0</span> | |
| <span class="pill" id="statusPill">Idle</span> | |
| </div> | |
| <div class="viewport"> | |
| <canvas id="viewer"></canvas> | |
| <div class="canvas-overlay"> | |
| <div class="overlay-item">ドラッグ: 回転</div> | |
| <div class="overlay-item">ホイール: ズーム</div> | |
| <div class="overlay-item">右ドラッグ: 平行移動</div> | |
| </div> | |
| </div> | |
| <div class="bottombar"> | |
| <span class="status" id="runtimeStatus">BVHを読み込むとプレビューが表示されます。</span> | |
| </div> | |
| </main> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> | |
| <script> | |
| const el = id => document.getElementById(id); | |
| const ui = { | |
| fileInput: el('fileInput'), | |
| loadStatus: el('loadStatus'), | |
| frameSlider: el('frameSlider'), | |
| speedInput: el('speedInput'), | |
| playBtn: el('playBtn'), | |
| resetViewBtn: el('resetViewBtn'), | |
| prevFrameBtn: el('prevFrameBtn'), | |
| nextFrameBtn: el('nextFrameBtn'), | |
| frameLabel: el('frameLabel'), | |
| frameMaxLabel: el('frameMaxLabel'), | |
| timeLabel: el('timeLabel'), | |
| flipPosX: el('flipPosX'), | |
| flipPosY: el('flipPosY'), | |
| flipPosZ: el('flipPosZ'), | |
| flipRotX: el('flipRotX'), | |
| flipRotY: el('flipRotY'), | |
| flipRotZ: el('flipRotZ'), | |
| applyMirrorBtn: el('applyMirrorBtn'), | |
| applyFeetBtn: el('applyFeetBtn'), | |
| clearMirrorBtn: el('clearMirrorBtn'), | |
| exportBtn: el('exportBtn'), | |
| exportBVHBtn: el('exportBVHBtn'), | |
| jointSearch: el('jointSearch'), | |
| jointAction: el('jointAction'), | |
| refreshJointsBtn: el('refreshJointsBtn'), | |
| jointList: el('jointList'), | |
| bvhStats: el('bvhStats'), | |
| rawOutput: el('rawOutput'), | |
| fileNamePill: el('fileNamePill'), | |
| jointCountPill: el('jointCountPill'), | |
| frameCountPill: el('frameCountPill'), | |
| statusPill: el('statusPill'), | |
| runtimeStatus: el('runtimeStatus'), | |
| }; | |
| const canvas = el('viewer'); | |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); | |
| renderer.setSize(window.innerWidth - 380, window.innerHeight, false); | |
| renderer.setClearColor(0x000000, 1); | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(45, (window.innerWidth - 380) / window.innerHeight, 0.01, 1000); | |
| camera.position.set(0, 140, 240); | |
| const controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.target.set(0, 90, 0); | |
| controls.update(); | |
| const ambient = new THREE.AmbientLight(0xffffff, 0.85); | |
| scene.add(ambient); | |
| const dir = new THREE.DirectionalLight(0xffffff, 1.0); | |
| dir.position.set(1, 2, 1); | |
| scene.add(dir); | |
| const grid = new THREE.GridHelper(400, 40, 0x334155, 0x1e293b); | |
| grid.position.y = 0; | |
| scene.add(grid); | |
| const axes = new THREE.AxesHelper(50); | |
| axes.position.set(0, 1, 0); | |
| scene.add(axes); | |
| let bvh = null; | |
| let skeletonGroup = null; | |
| let playing = false; | |
| let lastTime = performance.now(); | |
| let elapsed = 0; | |
| let selectedJoints = new Set(); | |
| let jointMeshes = []; | |
| function setStatus(text) { | |
| ui.runtimeStatus.textContent = text; | |
| ui.statusPill.textContent = text.length > 18 ? text.slice(0, 18) + '…' : text; | |
| } | |
| function resetScene() { | |
| if (skeletonGroup) scene.remove(skeletonGroup); | |
| skeletonGroup = new THREE.Group(); | |
| scene.add(skeletonGroup); | |
| jointMeshes = []; | |
| } | |
| function resize() { | |
| const w = window.innerWidth - 380; | |
| const h = window.innerHeight; | |
| renderer.setSize(w, h, false); | |
| camera.aspect = w / h; | |
| camera.updateProjectionMatrix(); | |
| } | |
| window.addEventListener('resize', resize); | |
| function parseBVH(text) { | |
| const lines = text.replace(/\r/g, '').split('\n'); | |
| let idx = 0; | |
| function nextLine() { return lines[idx++]; } | |
| function tokenize(line) { return line.trim().split(/\s+/).filter(Boolean); } | |
| let root = null; | |
| const stack = []; | |
| let current = null; | |
| let channelCursor = 0; | |
| let motion = { frames: 0, frameTime: 0, data: [] }; | |
| while (idx < lines.length) { | |
| const line = nextLine(); | |
| if (!line || !line.trim()) continue; | |
| const t = tokenize(line); | |
| if (t[0] === 'HIERARCHY') continue; | |
| if (t[0] === 'ROOT' || t[0] === 'JOINT') { | |
| const node = { | |
| name: t.slice(1).join(' '), | |
| offset: new THREE.Vector3(), | |
| channels: [], | |
| channelOffset: 0, | |
| children: [], | |
| parent: current | |
| }; | |
| // ROOT を実体として保持 | |
| if (t[0] === 'ROOT') { | |
| root = node; | |
| } | |
| if (current) { | |
| current.children.push(node); | |
| } | |
| current = node; | |
| stack.push(node); | |
| continue; | |
| } | |
| if (t[0] === '{') continue; | |
| if (t[0] === '}') { | |
| stack.pop(); | |
| current = stack[stack.length - 1] || null; | |
| continue; | |
| } | |
| if (t[0] === 'OFFSET') { | |
| current.offset.set(parseFloat(t[1]), parseFloat(t[2]), parseFloat(t[3])); | |
| continue; | |
| } | |
| if (t[0] === 'CHANNELS') { | |
| current.channelOffset = channelCursor; | |
| const count = parseInt(t[1], 10); | |
| current.channels = t.slice(2, 2 + count); | |
| channelCursor += count; | |
| continue; | |
| } | |
| if (t[0] === 'End') { | |
| const endNode = { | |
| name: (current ? current.name : 'End') + '_EndSite', | |
| offset: new THREE.Vector3(), | |
| channels: [], | |
| channelOffset: channelCursor, | |
| children: [], | |
| parent: current, | |
| endSite: true | |
| }; | |
| current.children.push(endNode); | |
| // { | |
| nextLine(); | |
| // OFFSET ... | |
| const offsetLine = tokenize(nextLine()); | |
| if (offsetLine[0] === 'OFFSET') { | |
| endNode.offset.set( | |
| parseFloat(offsetLine[1]), | |
| parseFloat(offsetLine[2]), | |
| parseFloat(offsetLine[3]) | |
| ); | |
| } | |
| // } | |
| nextLine(); | |
| continue; | |
| } | |
| if (t[0] === 'MOTION') break; | |
| } | |
| while (idx < lines.length) { | |
| const line = nextLine(); | |
| if (!line || !line.trim()) continue; | |
| const t = tokenize(line); | |
| if (t[0] === 'Frames:') motion.frames = parseInt(t[1], 10); | |
| else if (t[0] === 'Frame' && t[1] === 'Time:') motion.frameTime = parseFloat(t[2]); | |
| else if (/^[-+0-9.]/.test(t[0])) { | |
| const nums = line.trim().split(/\s+/).map(Number).filter(v => !Number.isNaN(v)); | |
| if (nums.length) motion.data.push(nums); | |
| } | |
| } | |
| const nodes = []; | |
| function walk(node) { | |
| nodes.push(node); | |
| node.children.forEach(walk); | |
| } | |
| walk(root); | |
| return { root, nodes, motion }; | |
| } | |
| function buildSkeleton(bvhObj) { | |
| resetScene(); | |
| const nodes = bvhObj.nodes; | |
| const material = new THREE.LineBasicMaterial({ color: 0x93c5fd }); | |
| const jointMat = new THREE.MeshStandardMaterial({ color: 0x38bdf8, metalness: 0.1, roughness: 0.8 }); | |
| const selectedMat = new THREE.MeshStandardMaterial({ color: 0xf59e0b, metalness: 0.1, roughness: 0.8 }); | |
| const footMat = new THREE.MeshStandardMaterial({ color: 0x34d399, metalness: 0.1, roughness: 0.8 }); | |
| nodes.forEach(node => { | |
| if (node === bvhObj.root) return; | |
| if (node.endSite) return; | |
| const geom = new THREE.SphereGeometry(1.8, 10, 10); | |
| const mesh = new THREE.Mesh(geom, jointMat); | |
| mesh.userData.node = node; | |
| skeletonGroup.add(mesh); | |
| jointMeshes.push(mesh); | |
| }); | |
| function makeLine(a, b) { | |
| const geom = new THREE.BufferGeometry().setFromPoints([a, b]); | |
| const line = new THREE.Line(geom, material); | |
| skeletonGroup.add(line); | |
| return line; | |
| } | |
| const connections = []; | |
| function traverse(node, parentWorld) { | |
| const world = parentWorld.clone().add(node.offset); | |
| if (node.parent) connections.push([parentWorld.clone(), world.clone(), node]); | |
| node.children.forEach(child => traverse(child, world)); | |
| } | |
| traverse(bvhObj.root, new THREE.Vector3(0, 0, 0)); | |
| connections.forEach(([a, b, node]) => { | |
| const line = makeLine(a, b); | |
| line.userData.node = node; | |
| }); | |
| for (const mesh of jointMeshes) { | |
| const name = mesh.userData.node.name.toLowerCase(); | |
| if ( | |
| name.includes('foot') || | |
| name.includes('ankle') || | |
| name.includes('toe') || | |
| name.includes('heel') || | |
| name.includes('ball') | |
| ) { | |
| mesh.material = footMat; | |
| } | |
| } | |
| updateJointList(); | |
| updateStats(); | |
| } | |
| function eulerFromChannels(order, values, sign) { | |
| const x = values.x * sign.x; | |
| const y = values.y * sign.y; | |
| const z = values.z * sign.z; | |
| const e = new THREE.Euler(); | |
| const deg = Math.PI / 180; | |
| const ord = order.map(s => s[0].toLowerCase()).join(''); | |
| const rx = x * deg, ry = y * deg, rz = z * deg; | |
| switch (ord) { | |
| case 'xyz': e.set(rx, ry, rz, 'XYZ'); break; | |
| case 'xzy': e.set(rx, rz, ry, 'XZY'); break; | |
| case 'yxz': e.set(ry, rx, rz, 'YXZ'); break; | |
| case 'yzx': e.set(ry, rz, rx, 'YZX'); break; | |
| case 'zxy': e.set(rz, rx, ry, 'ZXY'); break; | |
| case 'zyx': e.set(rz, ry, rx, 'ZYX'); break; | |
| default: e.set(rx, ry, rz, 'XYZ'); break; | |
| } | |
| return e; | |
| } | |
| function getFrameValues(frameIndex) { | |
| if (!bvh) return null; | |
| return bvh.motion.data[frameIndex] || []; | |
| } | |
| function updateSkeleton() { | |
| if (!bvh || !skeletonGroup) return; | |
| const frameIndex = parseInt(ui.frameSlider.value, 10) || 0; | |
| const frameData = getFrameValues(frameIndex); | |
| if (!frameData) return; | |
| const worldMap = new Map(); | |
| const rotMap = new Map(); | |
| function walk(node, parentPos = new THREE.Vector3(0, 0, 0), parentRot = new THREE.Quaternion()) { | |
| const ch = node.channels || []; | |
| const start = node.channelOffset || 0; | |
| const values = { x: 0, y: 0, z: 0 }; | |
| const rotValues = { x: 0, y: 0, z: 0 }; | |
| for (let i = 0; i < ch.length; i++) { | |
| const c = ch[i].toLowerCase(); | |
| const v = frameData[start + i] ?? 0; | |
| if (c.includes('position')) { | |
| if (c[0] === 'x') values.x = v; | |
| if (c[0] === 'y') values.y = v; | |
| if (c[0] === 'z') values.z = v; | |
| } else if (c.includes('rotation')) { | |
| if (c[0] === 'x') rotValues.x = v; | |
| if (c[0] === 'y') rotValues.y = v; | |
| if (c[0] === 'z') rotValues.z = v; | |
| } | |
| } | |
| const isRoot = !node.parent; | |
| const targetJoint = selectedJoints.size === 0 || selectedJoints.has(node.name.toLowerCase()); | |
| const posSign = targetJoint ? { | |
| x: ui.flipPosX.checked ? -1 : 1, | |
| y: ui.flipPosY.checked ? -1 : 1, | |
| z: ui.flipPosZ.checked ? -1 : 1, | |
| } : { x: 1, y: 1, z: 1 }; | |
| const rotSign = targetJoint ? { | |
| x: ui.flipRotX.checked ? -1 : 1, | |
| y: ui.flipRotY.checked ? -1 : 1, | |
| z: ui.flipRotZ.checked ? -1 : 1, | |
| } : { x: 1, y: 1, z: 1 }; | |
| const localPos = node.offset.clone().add(isRoot ? new THREE.Vector3(values.x * posSign.x, values.y * posSign.y, values.z * posSign.z) : new THREE.Vector3()); | |
| const euler = eulerFromChannels(ch.filter(c => /rotation/i.test(c)), rotValues, rotSign); | |
| const localRot = new THREE.Quaternion().setFromEuler(euler); | |
| const worldPos = parentPos.clone().add(localPos.clone().applyQuaternion(parentRot)); | |
| const worldRot = parentRot.clone().multiply(localRot); | |
| worldMap.set(node, worldPos); | |
| rotMap.set(node, worldRot); | |
| node.children.forEach(child => walk(child, worldPos, worldRot)); | |
| } | |
| walk(bvh.root); | |
| jointMeshes.forEach(mesh => { | |
| const node = mesh.userData.node; | |
| const p = worldMap.get(node); | |
| if (p) mesh.position.copy(p); | |
| const name = node.name.toLowerCase(); | |
| mesh.scale.setScalar(selectedJoints.size && selectedJoints.has(name) ? 1.55 : 1.0); | |
| if (name.includes('foot') || name.includes('toe') || name.includes('ankle')) { | |
| mesh.material.color.setHex(selectedJoints.has(name) ? 0xf59e0b : 0x34d399); | |
| } else { | |
| mesh.material.color.setHex(selectedJoints.has(name) ? 0xf59e0b : 0x38bdf8); | |
| } | |
| }); | |
| skeletonGroup.children.forEach(obj => { | |
| if (obj.type !== 'Line') return; | |
| const node = obj.userData.node; | |
| const parent = node.parent; | |
| if (!parent) return; | |
| const a = worldMap.get(parent); | |
| const b = worldMap.get(node); | |
| if (!a || !b) return; | |
| const pos = obj.geometry.attributes.position; | |
| pos.setXYZ(0, a.x, a.y, a.z); | |
| pos.setXYZ(1, b.x, b.y, b.z); | |
| pos.needsUpdate = true; | |
| }); | |
| ui.frameLabel.textContent = frameIndex.toString(); | |
| ui.timeLabel.textContent = ((frameIndex * bvh.motion.frameTime) || 0).toFixed(3); | |
| ui.rawOutput.value = buildDebugText(frameIndex, frameData); | |
| } | |
| function buildDebugText(frameIndex, frameData) { | |
| if (!bvh) return ''; | |
| const lines = []; | |
| lines.push(`Frame: ${frameIndex}`); | |
| lines.push(`Frame Time: ${bvh.motion.frameTime}`); | |
| lines.push(`Selected joints: ${selectedJoints.size ? Array.from(selectedJoints).join(', ') : '(all joints)'}`); | |
| lines.push(''); | |
| lines.push('Joint Summary:'); | |
| for (const node of bvh.nodes.slice(0, 25)) { | |
| if (!node.channels || !node.channels.length) continue; | |
| const start = node.channelOffset || 0; | |
| const vals = node.channels.map((ch, i) => `${ch}:${(frameData[start + i] ?? 0).toFixed(3)}`).join(' | '); | |
| lines.push(`${node.name} -> ${vals}`); | |
| } | |
| return lines.join('\n'); | |
| } | |
| function updateStats() { | |
| if (!bvh) { | |
| ui.bvhStats.textContent = '未読み込み'; | |
| ui.loadStatus.textContent = '未読み込み'; | |
| ui.fileNamePill.textContent = 'No file'; | |
| ui.jointCountPill.textContent = 'Joints: 0'; | |
| ui.frameCountPill.textContent = 'Frames: 0'; | |
| ui.frameMaxLabel.textContent = '0'; | |
| return; | |
| } | |
| const totalChannels = bvh.nodes.reduce((sum, n) => sum + (n.channels ? n.channels.length : 0), 0); | |
| const firstMotion = bvh.motion.data[0] || []; | |
| const sample = bvh.nodes.find(n => n.channels && n.channels.length) || null; | |
| let sampleText = 'N/A'; | |
| if (sample) { | |
| const start = sample.channelOffset || 0; | |
| const vals = sample.channels.map((ch, i) => `${ch}=${(firstMotion[start + i] ?? 0).toFixed(2)}`).join(', '); | |
| sampleText = `${sample.name}: ${vals}`; | |
| } | |
| const text = [ | |
| `Joints: ${bvh.nodes.filter(n => !n.endSite).length}`, | |
| `Channels: ${totalChannels}`, | |
| `Frames: ${bvh.motion.frames}`, | |
| `Frame Time: ${bvh.motion.frameTime}`, | |
| `Sample: ${sampleText}` | |
| ].join('\n'); | |
| ui.bvhStats.textContent = text; | |
| ui.loadStatus.textContent = `読み込み完了: ${bvh.nodes.filter(n => !n.endSite).length} joints / ${bvh.motion.frames} frames`; | |
| ui.fileNamePill.textContent = ui.fileInput.files[0] ? ui.fileInput.files[0].name : 'Loaded BVH'; | |
| ui.jointCountPill.textContent = `Joints: ${bvh.nodes.filter(n => !n.endSite).length}`; | |
| ui.frameCountPill.textContent = `Frames: ${bvh.motion.frames}`; | |
| ui.frameMaxLabel.textContent = String(Math.max(0, bvh.motion.frames - 1)); | |
| ui.frameSlider.max = String(Math.max(0, bvh.motion.frames - 1)); | |
| ui.frameSlider.value = '0'; | |
| ui.frameLabel.textContent = '0'; | |
| ui.timeLabel.textContent = '0.000'; | |
| setStatus('BVH loaded'); | |
| } | |
| function updateJointList() { | |
| if (!bvh) { | |
| ui.jointList.textContent = 'BVH読み込み後に候補が表示されます。'; | |
| return; | |
| } | |
| const q = ui.jointSearch.value.trim().toLowerCase(); | |
| const list = bvh.nodes.filter(n => !n.endSite && n.name.toLowerCase() !== 'hips'); | |
| const filtered = q ? list.filter(n => n.name.toLowerCase().includes(q)) : list; | |
| ui.jointList.innerHTML = ''; | |
| if (!filtered.length) { | |
| ui.jointList.textContent = '候補がありません。'; | |
| return; | |
| } | |
| filtered.forEach(node => { | |
| const row = document.createElement('label'); | |
| row.className = 'joint-item'; | |
| const cb = document.createElement('input'); | |
| cb.type = 'checkbox'; | |
| cb.checked = selectedJoints.has(node.name.toLowerCase()); | |
| cb.addEventListener('change', () => { | |
| const key = node.name.toLowerCase(); | |
| if (cb.checked) selectedJoints.add(key); else selectedJoints.delete(key); | |
| updateSkeleton(); | |
| }); | |
| const txt = document.createElement('span'); | |
| txt.textContent = node.name + (node.channels && node.channels.length ? ` [${node.channels.join(' ')}]` : ''); | |
| row.appendChild(cb); | |
| row.appendChild(txt); | |
| ui.jointList.appendChild(row); | |
| }); | |
| } | |
| function applyFeetSelection() { | |
| if (!bvh) return; | |
| selectedJoints.clear(); | |
| bvh.nodes.forEach(node => { | |
| const name = node.name.toLowerCase(); | |
| if (name.includes('foot') || name.includes('ankle') || name.includes('toe') || name.includes('heel') || name.includes('leg')) { | |
| selectedJoints.add(name); | |
| } | |
| }); | |
| updateJointList(); | |
| updateSkeleton(); | |
| } | |
| function clearMirrors() { | |
| ui.flipPosX.checked = false; | |
| ui.flipPosY.checked = false; | |
| ui.flipPosZ.checked = false; | |
| ui.flipRotX.checked = false; | |
| ui.flipRotY.checked = false; | |
| ui.flipRotZ.checked = false; | |
| selectedJoints.clear(); | |
| updateJointList(); | |
| updateSkeleton(); | |
| } | |
| function exportJSON() { | |
| if (!bvh) return; | |
| const payload = { | |
| file: ui.fileInput.files[0] ? ui.fileInput.files[0].name : 'loaded.bvh', | |
| frame: parseInt(ui.frameSlider.value, 10) || 0, | |
| mirror: { | |
| position: { x: ui.flipPosX.checked, y: ui.flipPosY.checked, z: ui.flipPosZ.checked }, | |
| rotation: { x: ui.flipRotX.checked, y: ui.flipRotY.checked, z: ui.flipRotZ.checked }, | |
| selectedJoints: Array.from(selectedJoints) | |
| }, | |
| stats: { | |
| joints: bvh.nodes.filter(n => !n.endSite).length, | |
| frames: bvh.motion.frames, | |
| frameTime: bvh.motion.frameTime | |
| } | |
| }; | |
| const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'bvh_mirror_settings.json'; | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| } | |
| function exportBVH() { | |
| if (!bvh) return; | |
| function shouldApply(node) { | |
| return selectedJoints.size === 0 || selectedJoints.has(node.name.toLowerCase()); | |
| } | |
| function writeNode(node, depth) { | |
| const pad = ' '.repeat(depth); | |
| let out = ''; | |
| if (!node.parent) { | |
| out += `${pad}ROOT ${node.name}\n`; | |
| } else if (node.endSite) { | |
| out += `${pad}End Site\n`; | |
| } else { | |
| out += `${pad}JOINT ${node.name}\n`; | |
| } | |
| out += `${pad}{\n`; | |
| out += `${pad} OFFSET ${node.offset.x} ${node.offset.y} ${node.offset.z}\n`; | |
| if (!node.endSite) { | |
| out += `${pad} CHANNELS ${node.channels.length} ${node.channels.join(' ')}\n`; | |
| } | |
| node.children.forEach(child => { | |
| out += writeNode(child, depth + 1); | |
| }); | |
| out += `${pad}}\n`; | |
| return out; | |
| } | |
| const lines = []; | |
| lines.push('HIERARCHY'); | |
| lines.push(writeNode(bvh.root, 0)); | |
| lines.push('MOTION'); | |
| lines.push(`Frames: ${bvh.motion.frames}`); | |
| lines.push(`Frame Time: ${bvh.motion.frameTime}`); | |
| for (let frameIndex = 0; frameIndex < bvh.motion.frames; frameIndex++) { | |
| const src = (bvh.motion.data[frameIndex] || []).slice(); | |
| bvh.nodes.forEach(node => { | |
| if (!node.channels || !node.channels.length) return; | |
| if (!shouldApply(node)) return; | |
| for (let i = 0; i < node.channels.length; i++) { | |
| const ch = node.channels[i]; | |
| const idx = node.channelOffset + i; | |
| if (ch === 'Xposition' && ui.flipPosX.checked) src[idx] *= -1; | |
| if (ch === 'Yposition' && ui.flipPosY.checked) src[idx] *= -1; | |
| if (ch === 'Zposition' && ui.flipPosZ.checked) src[idx] *= -1; | |
| if (ch === 'Xrotation' && ui.flipRotX.checked) src[idx] *= -1; | |
| if (ch === 'Yrotation' && ui.flipRotY.checked) src[idx] *= -1; | |
| if (ch === 'Zrotation' && ui.flipRotZ.checked) src[idx] *= -1; | |
| } | |
| }); | |
| lines.push(src.join(' ')); | |
| } | |
| const blob = new Blob([lines.join('\n')], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| const original = | |
| ui.fileInput.files && ui.fileInput.files[0] | |
| ? ui.fileInput.files[0].name.replace(/\.bvh$/i, '') | |
| : 'mirrored'; | |
| a.download = `${original}_mirrored.bvh`; | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| setStatus('BVH exported'); | |
| } | |
| ui.fileInput.addEventListener('change', async () => { | |
| const file = ui.fileInput.files && ui.fileInput.files[0]; | |
| if (!file) return; | |
| const text = await file.text(); | |
| try { | |
| setStatus('Parsing...'); | |
| bvh = parseBVH(text); | |
| buildSkeleton(bvh); | |
| ui.rawOutput.value = text.slice(0, 5000); | |
| setStatus('Preview ready'); | |
| } catch (err) { | |
| console.error(err); | |
| ui.loadStatus.textContent = '読み込み失敗'; | |
| setStatus('Parse error'); | |
| ui.rawOutput.value = String(err && err.stack ? err.stack : err); | |
| } | |
| }); | |
| ui.frameSlider.addEventListener('input', updateSkeleton); | |
| ui.speedInput.addEventListener('change', () => { | |
| const v = Math.max(0.1, Math.min(5, parseFloat(ui.speedInput.value || '1'))); | |
| ui.speedInput.value = String(v); | |
| }); | |
| [ui.flipPosX, ui.flipPosY, ui.flipPosZ, ui.flipRotX, ui.flipRotY, ui.flipRotZ].forEach(inp => inp.addEventListener('change', updateSkeleton)); | |
| ui.applyMirrorBtn.addEventListener('click', updateSkeleton); | |
| ui.applyFeetBtn.addEventListener('click', applyFeetSelection); | |
| ui.clearMirrorBtn.addEventListener('click', clearMirrors); | |
| ui.exportBtn.addEventListener('click', exportJSON); | |
| ui.exportBVHBtn.addEventListener('click', exportBVH); | |
| ui.refreshJointsBtn.addEventListener('click', updateJointList); | |
| ui.jointSearch.addEventListener('input', updateJointList); | |
| ui.playBtn.addEventListener('click', () => { | |
| playing = !playing; | |
| ui.playBtn.textContent = playing ? '停止' : '再生'; | |
| if (playing) lastTime = performance.now(); | |
| }); | |
| ui.resetViewBtn.addEventListener('click', () => { | |
| camera.position.set(0, 140, 240); | |
| controls.target.set(0, 90, 0); | |
| controls.update(); | |
| }); | |
| ui.prevFrameBtn.addEventListener('click', () => { | |
| ui.frameSlider.value = String(Math.max(0, (parseInt(ui.frameSlider.value, 10) || 0) - 1)); | |
| updateSkeleton(); | |
| }); | |
| ui.nextFrameBtn.addEventListener('click', () => { | |
| const max = parseInt(ui.frameSlider.max, 10) || 0; | |
| ui.frameSlider.value = String(Math.min(max, (parseInt(ui.frameSlider.value, 10) || 0) + 1)); | |
| updateSkeleton(); | |
| }); | |
| function animate(now) { | |
| requestAnimationFrame(animate); | |
| const dt = (now - lastTime) / 1000; | |
| lastTime = now; | |
| if (playing && bvh && bvh.motion.frames > 0) { | |
| const speed = Math.max(0.1, parseFloat(ui.speedInput.value || '1')); | |
| elapsed += dt * speed; | |
| const total = bvh.motion.frames * bvh.motion.frameTime; | |
| if (total > 0) { | |
| const t = elapsed % total; | |
| const frame = Math.min(bvh.motion.frames - 1, Math.floor(t / bvh.motion.frameTime)); | |
| ui.frameSlider.value = String(frame); | |
| } | |
| updateSkeleton(); | |
| } | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| requestAnimationFrame(animate); | |
| resetScene(); | |
| setStatus('Idle'); | |
| </script> | |
| </body> | |
| </html> |