Files
walkersim/src/renderer3d.js
ro 4869bb175a fix(mobile): improve viewport framing and ignore screenshots
Adjust 2D bounds padding and portrait 3D FOV behavior to reduce mobile cropping, and ignore local screenshots artifacts in git.
2026-02-13 12:48:57 +02:00

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,
};
}