player / test /vbhmodel.html
izuemon's picture
Update test/vbhmodel.html
533194e verified
Raw
History Blame Contribute Delete
34.1 kB
<!DOCTYPE html>
<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>