Adjust 2D bounds padding and portrait 3D FOV behavior to reduce mobile cropping, and ignore local screenshots artifacts in git.
302 lines
8.1 KiB
JavaScript
302 lines
8.1 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 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 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 near = state.pose?.near;
|
|
const far = state.pose?.far;
|
|
if (!near || !far) return;
|
|
|
|
for (const key of JOINT_KEYS) {
|
|
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 a = toVec3(near[item.from], -sideOffset);
|
|
const b = toVec3(near[item.to], -sideOffset);
|
|
setLinePoints(item.line, a, b);
|
|
item.line.visible = state.showNear;
|
|
}
|
|
|
|
for (const item of farGroup) {
|
|
const a = toVec3(far[item.from], sideOffset);
|
|
const b = toVec3(far[item.to], sideOffset);
|
|
setLinePoints(item.line, a, b);
|
|
item.line.visible = state.showFar;
|
|
}
|
|
|
|
const floorY = state.ground?.floorY;
|
|
if (Number.isFinite(floorY)) {
|
|
floor.position.y = -floorY;
|
|
grid.position.y = -floorY;
|
|
}
|
|
|
|
const center = computePoseCenter(near, far);
|
|
if (center) {
|
|
const nextTarget = 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,
|
|
};
|
|
}
|