382 lines
11 KiB
JavaScript
382 lines
11 KiB
JavaScript
import * as THREE from 'three';
|
|
import { OrbitControls } from 'https://unpkg.com/three@0.161.0/examples/jsm/controls/OrbitControls.js';
|
|
|
|
const COLORS = {
|
|
bg: '#0f1116',
|
|
grid: '#1a2230',
|
|
floor: '#3a465d',
|
|
body: '#ea0000',
|
|
link: '#948585',
|
|
leg: '#4aa8ff',
|
|
tendon: '#ff8f3f',
|
|
crank: '#00ff00',
|
|
pivot: '#ffff00',
|
|
traceNear: '#6fd2ff',
|
|
traceFar: '#b483ff',
|
|
};
|
|
|
|
const SIDE_LINKS = [
|
|
['A', 'B', COLORS.body],
|
|
['O', 'C', COLORS.crank],
|
|
['A', 'D', COLORS.tendon],
|
|
['C', 'D', COLORS.link],
|
|
['C', 'F', COLORS.leg],
|
|
['B', 'E', COLORS.tendon],
|
|
['C', 'E', COLORS.link],
|
|
['C', 'G', COLORS.leg],
|
|
];
|
|
|
|
const JOINT_KEYS = ['A', 'B', 'O', 'C', 'D', 'E', 'F', 'G'];
|
|
const INITIAL_CAMERA_POSITION = { x: 22.744, y: -83.162, z: 120.597 };
|
|
const INITIAL_CAMERA_TARGET = { x: 98.958, y: -134.091, z: 0 };
|
|
|
|
function toVec3(point, z = 0) {
|
|
return new THREE.Vector3(point.x, -point.y, z);
|
|
}
|
|
|
|
function mujocoToVec3(point, units = 'meters') {
|
|
const unitScale = units === 'meters' ? 100 : 1;
|
|
return new THREE.Vector3(point.x * unitScale, point.z * unitScale, point.y * unitScale);
|
|
}
|
|
|
|
function createLine(color) {
|
|
const geometry = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(),
|
|
new THREE.Vector3(1, 0, 0),
|
|
]);
|
|
const material = new THREE.LineBasicMaterial({ color });
|
|
return new THREE.Line(geometry, material);
|
|
}
|
|
|
|
function setLinePoints(line, a, b) {
|
|
const pos = line.geometry.attributes.position;
|
|
pos.setXYZ(0, a.x, a.y, a.z);
|
|
pos.setXYZ(1, b.x, b.y, b.z);
|
|
pos.needsUpdate = true;
|
|
line.geometry.computeBoundingSphere();
|
|
}
|
|
|
|
function computePoseCenter(near, far) {
|
|
const points = [];
|
|
for (const key of JOINT_KEYS) {
|
|
const np = near?.[key];
|
|
const fp = far?.[key];
|
|
if (np && Number.isFinite(np.x) && Number.isFinite(np.y)) points.push(np);
|
|
if (fp && Number.isFinite(fp.x) && Number.isFinite(fp.y)) points.push(fp);
|
|
}
|
|
if (!points.length) return null;
|
|
|
|
let sx = 0;
|
|
let sy = 0;
|
|
for (const p of points) {
|
|
sx += p.x;
|
|
sy += p.y;
|
|
}
|
|
return { x: sx / points.length, y: sy / points.length };
|
|
}
|
|
|
|
function computePlaybackCenter(near, far, units = 'meters') {
|
|
const points = [];
|
|
for (const key of JOINT_KEYS) {
|
|
const np = near?.[key];
|
|
const fp = far?.[key];
|
|
if (np && Number.isFinite(np.x) && Number.isFinite(np.y) && Number.isFinite(np.z)) points.push(np);
|
|
if (fp && Number.isFinite(fp.x) && Number.isFinite(fp.y) && Number.isFinite(fp.z)) points.push(fp);
|
|
}
|
|
if (!points.length) return null;
|
|
const unitScale = units === 'meters' ? 100 : 1;
|
|
let sx = 0;
|
|
let sy = 0;
|
|
let sz = 0;
|
|
for (const p of points) {
|
|
sx += p.x * unitScale;
|
|
sy += p.y * unitScale;
|
|
sz += p.z * unitScale;
|
|
}
|
|
return {
|
|
x: sx / points.length,
|
|
y: sy / points.length,
|
|
z: sz / points.length,
|
|
};
|
|
}
|
|
|
|
function vec3ToDebug(vec) {
|
|
return {
|
|
x: Number(vec.x.toFixed(3)),
|
|
y: Number(vec.y.toFixed(3)),
|
|
z: Number(vec.z.toFixed(3)),
|
|
};
|
|
}
|
|
|
|
export function create3DRenderer(mountEl) {
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(COLORS.bg);
|
|
|
|
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 2000);
|
|
camera.position.set(
|
|
INITIAL_CAMERA_POSITION.x,
|
|
INITIAL_CAMERA_POSITION.y,
|
|
INITIAL_CAMERA_POSITION.z,
|
|
);
|
|
|
|
let renderer = null;
|
|
try {
|
|
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
|
} catch (err) {
|
|
console.warn('[3D] WebGL renderer unavailable; disabling 3D pane.', err);
|
|
return {
|
|
available: false,
|
|
resize() {},
|
|
render() {},
|
|
setVisible() {},
|
|
resetCamera() {},
|
|
};
|
|
}
|
|
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
mountEl.appendChild(renderer.domElement);
|
|
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.target.set(
|
|
INITIAL_CAMERA_TARGET.x,
|
|
INITIAL_CAMERA_TARGET.y,
|
|
INITIAL_CAMERA_TARGET.z,
|
|
);
|
|
controls.update();
|
|
|
|
function logCameraDebug(reason) {
|
|
console.debug('[3D Camera]', {
|
|
reason,
|
|
position: vec3ToDebug(camera.position),
|
|
target: vec3ToDebug(controls.target),
|
|
});
|
|
}
|
|
|
|
controls.addEventListener('end', () => {
|
|
logCameraDebug('controls-end');
|
|
});
|
|
logCameraDebug('init');
|
|
|
|
const trackedTarget = new THREE.Vector3(0, 0, 0);
|
|
let hasTrackedTarget = false;
|
|
|
|
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
|
|
|
|
const keyLight = new THREE.DirectionalLight(0xffffff, 0.55);
|
|
keyLight.position.set(90, 120, 90);
|
|
scene.add(keyLight);
|
|
|
|
const fillLight = new THREE.DirectionalLight(0x9ab6ff, 0.25);
|
|
fillLight.position.set(-120, 80, -40);
|
|
scene.add(fillLight);
|
|
|
|
const grid = new THREE.GridHelper(320, 32, COLORS.grid, COLORS.grid);
|
|
grid.position.y = 0;
|
|
scene.add(grid);
|
|
|
|
const floorMat = new THREE.MeshStandardMaterial({
|
|
color: COLORS.floor,
|
|
transparent: true,
|
|
opacity: 0.55,
|
|
roughness: 0.85,
|
|
metalness: 0.05,
|
|
});
|
|
const floor = new THREE.Mesh(new THREE.PlaneGeometry(360, 240), floorMat);
|
|
floor.rotation.x = -Math.PI / 2;
|
|
scene.add(floor);
|
|
|
|
const jointGeom = new THREE.SphereGeometry(1.35, 14, 10);
|
|
const nearJointMat = new THREE.MeshStandardMaterial({ color: COLORS.pivot });
|
|
const farJointMat = new THREE.MeshStandardMaterial({
|
|
color: COLORS.pivot,
|
|
transparent: true,
|
|
opacity: 0.55,
|
|
});
|
|
|
|
const nearJoints = {};
|
|
const farJoints = {};
|
|
|
|
for (const key of JOINT_KEYS) {
|
|
nearJoints[key] = new THREE.Mesh(jointGeom, nearJointMat);
|
|
scene.add(nearJoints[key]);
|
|
farJoints[key] = new THREE.Mesh(jointGeom, farJointMat);
|
|
scene.add(farJoints[key]);
|
|
}
|
|
|
|
const nearGroup = [];
|
|
const farGroup = [];
|
|
for (const [from, to, color] of SIDE_LINKS) {
|
|
const nearLine = createLine(color);
|
|
nearLine.material.opacity = 1;
|
|
nearLine.material.transparent = false;
|
|
scene.add(nearLine);
|
|
nearGroup.push({ from, to, line: nearLine });
|
|
|
|
const farLine = createLine(color);
|
|
farLine.material.opacity = 0.55;
|
|
farLine.material.transparent = true;
|
|
scene.add(farLine);
|
|
farGroup.push({ from, to, line: farLine });
|
|
}
|
|
|
|
const sideOffset = 8;
|
|
|
|
function setVisible(visible) {
|
|
renderer.domElement.style.display = visible ? 'block' : 'none';
|
|
}
|
|
|
|
function resize() {
|
|
const width = Math.max(1, mountEl.clientWidth || 1);
|
|
const height = Math.max(1, mountEl.clientHeight || 1);
|
|
renderer.setSize(width, height, false);
|
|
|
|
const aspect = width / height;
|
|
camera.aspect = aspect;
|
|
|
|
// Adjust FOV for portrait mode to keep the subject in view
|
|
// Base FOV is 42 degrees for landscape (aspect >= 1)
|
|
// For portrait, we increase FOV to maintain horizontal coverage
|
|
if (aspect < 1) {
|
|
const baseFovRad = (42 * Math.PI) / 180;
|
|
// Calculate horizontal FOV for aspect=1
|
|
const hFov = 2 * Math.atan(Math.tan(baseFovRad / 2) * 1);
|
|
// Calculate new vertical FOV to match that horizontal coverage
|
|
const newVFovRad = 2 * Math.atan(Math.tan(hFov / 2) / aspect);
|
|
camera.fov = (newVFovRad * 180) / Math.PI;
|
|
} else {
|
|
camera.fov = 42;
|
|
}
|
|
|
|
camera.updateProjectionMatrix();
|
|
}
|
|
|
|
function resetCamera() {
|
|
camera.position.set(
|
|
INITIAL_CAMERA_POSITION.x,
|
|
INITIAL_CAMERA_POSITION.y,
|
|
INITIAL_CAMERA_POSITION.z,
|
|
);
|
|
controls.target.set(
|
|
INITIAL_CAMERA_TARGET.x,
|
|
INITIAL_CAMERA_TARGET.y,
|
|
INITIAL_CAMERA_TARGET.z,
|
|
);
|
|
controls.update();
|
|
logCameraDebug('reset');
|
|
}
|
|
|
|
function render(state) {
|
|
const playbackPose = state.mujocoPlayback?.enabled ? state.mujocoPlayback.currentPose : null;
|
|
const near = playbackPose?.near ?? state.pose?.near;
|
|
const far = playbackPose?.far ?? state.pose?.far;
|
|
if (!near || !far) return;
|
|
|
|
const usePlayback = Boolean(playbackPose);
|
|
const playbackUnits = state.mujocoPlayback?.units || 'meters';
|
|
|
|
for (const key of JOINT_KEYS) {
|
|
if (usePlayback) {
|
|
const np = near[key];
|
|
const fp = far[key];
|
|
if (np) {
|
|
nearJoints[key].position.copy(mujocoToVec3(np, playbackUnits));
|
|
}
|
|
if (fp) {
|
|
farJoints[key].position.copy(mujocoToVec3(fp, playbackUnits));
|
|
}
|
|
nearJoints[key].visible = state.showNear && Boolean(np);
|
|
farJoints[key].visible = state.showFar && Boolean(fp);
|
|
} else {
|
|
nearJoints[key].position.copy(toVec3(near[key], -sideOffset));
|
|
farJoints[key].position.copy(toVec3(far[key], sideOffset));
|
|
nearJoints[key].visible = state.showNear;
|
|
farJoints[key].visible = state.showFar;
|
|
}
|
|
}
|
|
|
|
for (const item of nearGroup) {
|
|
const ap = near[item.from];
|
|
const bp = near[item.to];
|
|
if (!ap || !bp) {
|
|
item.line.visible = false;
|
|
continue;
|
|
}
|
|
if (usePlayback) {
|
|
const a = mujocoToVec3(ap, playbackUnits);
|
|
const b = mujocoToVec3(bp, playbackUnits);
|
|
setLinePoints(item.line, a, b);
|
|
} else {
|
|
const a = toVec3(ap, -sideOffset);
|
|
const b = toVec3(bp, -sideOffset);
|
|
setLinePoints(item.line, a, b);
|
|
}
|
|
item.line.visible = state.showNear;
|
|
}
|
|
|
|
for (const item of farGroup) {
|
|
const ap = far[item.from];
|
|
const bp = far[item.to];
|
|
if (!ap || !bp) {
|
|
item.line.visible = false;
|
|
continue;
|
|
}
|
|
if (usePlayback) {
|
|
const a = mujocoToVec3(ap, playbackUnits);
|
|
const b = mujocoToVec3(bp, playbackUnits);
|
|
setLinePoints(item.line, a, b);
|
|
} else {
|
|
const a = toVec3(ap, sideOffset);
|
|
const b = toVec3(bp, sideOffset);
|
|
setLinePoints(item.line, a, b);
|
|
}
|
|
item.line.visible = state.showFar;
|
|
}
|
|
|
|
if (usePlayback) {
|
|
floor.position.y = 0;
|
|
grid.position.y = 0;
|
|
} else {
|
|
const floorY = state.ground?.floorY;
|
|
if (Number.isFinite(floorY)) {
|
|
floor.position.y = -floorY;
|
|
grid.position.y = -floorY;
|
|
}
|
|
}
|
|
|
|
const center = usePlayback
|
|
? computePlaybackCenter(near, far, playbackUnits)
|
|
: computePoseCenter(near, far);
|
|
if (center) {
|
|
const nextTarget = usePlayback
|
|
? new THREE.Vector3(center.x, center.z, center.y)
|
|
: new THREE.Vector3(center.x, -center.y, 0);
|
|
if (!hasTrackedTarget) {
|
|
trackedTarget.copy(nextTarget);
|
|
controls.target.copy(nextTarget);
|
|
hasTrackedTarget = true;
|
|
logCameraDebug('auto-track-init');
|
|
} else {
|
|
const delta = nextTarget.clone().sub(trackedTarget);
|
|
trackedTarget.copy(nextTarget);
|
|
camera.position.add(delta);
|
|
controls.target.lerp(nextTarget, 0.2);
|
|
}
|
|
}
|
|
|
|
controls.update();
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
resize();
|
|
|
|
return {
|
|
available: true,
|
|
resize,
|
|
render,
|
|
setVisible,
|
|
resetCamera,
|
|
};
|
|
}
|