Files
threedee/src/index.js
sebseb7 44736e8061 u
2025-08-25 10:30:11 +02:00

1156 lines
42 KiB
JavaScript

import * as THREE from 'three';
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import './styles.css';
// Global variables
let scene, camera, renderer, controls;
let cube;
let cameraConfig = null;
// FPV Mode variables
let fpvMode = false;
let fpvControls = {
moveForward: false,
moveBackward: false,
moveLeft: false,
moveRight: false,
jump: false,
crouch: false,
canJump: true,
velocity: new THREE.Vector3(),
direction: new THREE.Vector3(),
rotation: { x: 0, y: 0 },
verticalVelocity: 0,
isJumping: false,
isCrouching: false,
currentHeight: 0,
targetHeight: 0
};
let modelBounds = null;
let groundLevel = 0;
let ceilingLevel = 0;
let eyeHeight = 0;
// Load camera configuration from config file
async function loadCameraConfig() {
try {
const response = await fetch('./camera-config.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
cameraConfig = await response.json();
console.log('✅ Camera config loaded successfully:', cameraConfig);
return cameraConfig;
} catch (error) {
console.warn('⚠️ Could not load camera-config.json, using original defaults.');
console.log('📝 To use custom camera settings, ensure camera-config.json is accessible from your web server.');
// Return original simple defaults
return {
camera: {
position: { x: 20, y: 20, z: 20 },
target: { x: 0, y: 0, z: 0 },
distance: 34.64
}
};
}
}
// Initialize the 3D scene
async function init() {
// Load camera configuration first
cameraConfig = await loadCameraConfig();
// Create container
const container = document.getElementById('container');
// Create scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x2c3e50);
// Create camera with extended far clipping plane for better zoom out
camera = new THREE.PerspectiveCamera(
75, // Field of view
window.innerWidth / window.innerHeight, // Aspect ratio
0.1, // Near clipping plane
10000 // Extended far clipping plane for better zoom out
);
// Use camera config values or defaults
const camPos = cameraConfig.camera.position;
const camTarget = cameraConfig.camera.target;
camera.position.set(camPos.x, camPos.y, camPos.z);
camera.lookAt(camTarget.x, camTarget.y, camTarget.z);
// Create renderer
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.5;
// Enable proper transparency rendering
renderer.sortObjects = true;
renderer.autoClear = false;
container.appendChild(renderer.domElement);
// Add orbit controls with enhanced touch support
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.enableZoom = true;
controls.enablePan = true;
controls.enableRotate = true;
// Touch-specific settings
controls.touches = {
ONE: THREE.TOUCH.ROTATE, // Single finger touch for rotation
TWO: THREE.TOUCH.DOLLY_PAN // Two fingers for zoom and pan
};
// Enhanced touch sensitivity and limits
controls.rotateSpeed = 1.0; // Rotation speed
controls.zoomSpeed = 2.0; // Increased zoom speed for faster mouse wheel
controls.panSpeed = 0.8; // Pan speed
// Sync zoom limits with camera clipping planes
controls.minDistance = 0.1; // Match camera near plane
controls.maxDistance = 8000; // Stay within camera far plane (10000) with buffer
// Smooth touch interactions
controls.enableDamping = true;
controls.dampingFactor = 0.08; // Slightly higher for smoother touch
// Add custom touch event handlers
setupCustomTouchControls();
// Add lighting
setupLighting();
// Load the cube model
loadCubeModel();
// Handle window resize
window.addEventListener('resize', onWindowResize, false);
// Handle print events
setupPrintHandlers();
// Setup manual print button
setupPrintButton();
// Setup FPV button
setupFPVButton();
// Setup FPV controls
setupFPVControls();
// Start render loop
animate();
}
function setupCustomTouchControls() {
const canvas = renderer.domElement;
// Add touch-friendly CSS for better interaction
canvas.style.touchAction = 'none';
canvas.style.userSelect = 'none';
canvas.style.webkitUserSelect = 'none';
canvas.style.mozUserSelect = 'none';
canvas.style.msUserSelect = 'none';
// Prevent context menu on long press
canvas.addEventListener('contextmenu', function(event) {
event.preventDefault();
}, { passive: false });
console.log('Touch controls enabled - OrbitControls handles all touch gestures natively');
}
function setupPrintHandlers() {
const originalBackgroundColor = scene.background;
const originalRendererClearColor = renderer.getClearColor(new THREE.Color());
const originalRendererClearAlpha = renderer.getClearAlpha();
let isTestMode = false;
let testTimeout = null;
// Before print - change background to white and make renderer opaque
const beforePrint = () => {
if (isTestMode) {
console.log('🚫 Print event ignored - test mode is active');
return;
}
console.log('🖨️ PRINT MODE ACTIVATED - Changing background to white');
// Force scene background to white
scene.background = new THREE.Color(0xffffff);
// Set renderer clear color to white
renderer.setClearColor(0xffffff, 1.0);
// Force canvas background to white with multiple methods
const canvas = renderer.domElement;
canvas.style.setProperty('background', 'white', 'important');
canvas.style.setProperty('background-color', 'white', 'important');
canvas.style.setProperty('background-image', 'none', 'important');
// Also set body and html backgrounds
document.body.style.setProperty('background', 'white', 'important');
document.body.style.setProperty('background-color', 'white', 'important');
document.documentElement.style.setProperty('background', 'white', 'important');
document.documentElement.style.setProperty('background-color', 'white', 'important');
// Force a render to apply changes immediately
renderer.render(scene, camera);
console.log('✅ Print background forcibly set to white');
};
// After print - restore original background
const afterPrint = () => {
if (isTestMode) {
console.log('🚫 Print restore ignored - test mode is active');
return;
}
console.log('🖨️ PRINT MODE DEACTIVATED - Restoring original background');
// Restore original settings
scene.background = originalBackgroundColor;
renderer.setClearColor(originalRendererClearColor, originalRendererClearAlpha);
// Remove forced styles
const canvas = renderer.domElement;
canvas.style.removeProperty('background');
canvas.style.removeProperty('background-color');
canvas.style.removeProperty('background-image');
document.body.style.removeProperty('background');
document.body.style.removeProperty('background-color');
document.documentElement.style.removeProperty('background');
document.documentElement.style.removeProperty('background-color');
console.log('✅ Original background restored');
};
// Apply white background (used by both test and print modes)
const applyWhiteBackground = () => {
// Force scene background to white
scene.background = new THREE.Color(0xffffff);
// Set renderer clear color to white
renderer.setClearColor(0xffffff, 1.0);
// Force canvas background to white with multiple methods
const canvas = renderer.domElement;
canvas.style.setProperty('background', 'white', 'important');
canvas.style.setProperty('background-color', 'white', 'important');
canvas.style.setProperty('background-image', 'none', 'important');
// Also set body and html backgrounds
document.body.style.setProperty('background', 'white', 'important');
document.body.style.setProperty('background-color', 'white', 'important');
document.documentElement.style.setProperty('background', 'white', 'important');
document.documentElement.style.setProperty('background-color', 'white', 'important');
// Force a render to apply changes immediately
renderer.render(scene, camera);
};
// Restore original background (used by both test and print modes)
const restoreBackground = () => {
// Restore original settings
scene.background = originalBackgroundColor;
renderer.setClearColor(originalRendererClearColor, originalRendererClearAlpha);
// Remove forced styles
const canvas = renderer.domElement;
canvas.style.removeProperty('background');
canvas.style.removeProperty('background-color');
canvas.style.removeProperty('background-image');
document.body.style.removeProperty('background');
document.body.style.removeProperty('background-color');
document.documentElement.style.removeProperty('background');
document.documentElement.style.removeProperty('background-color');
};
// Test the print handler immediately (for debugging)
window.testPrintMode = () => {
console.log('🧪 Testing print mode (print events disabled during test)...');
isTestMode = true;
if (testTimeout) {
clearTimeout(testTimeout);
}
applyWhiteBackground();
console.log('✅ Test mode background set to white');
testTimeout = setTimeout(() => {
console.log('🧪 Test complete, restoring...');
restoreBackground();
isTestMode = false;
testTimeout = null;
console.log('✅ Test mode complete, print events re-enabled');
}, 6000);
};
// Cancel test mode early
window.cancelTestMode = () => {
if (testTimeout) {
clearTimeout(testTimeout);
testTimeout = null;
}
if (isTestMode) {
restoreBackground();
isTestMode = false;
console.log('🧪 Test mode cancelled, print events re-enabled');
}
};
// Listen for print events
if (window.matchMedia) {
const mediaQueryList = window.matchMedia('print');
mediaQueryList.addEventListener('change', (mql) => {
console.log('📱 Media query change detected:', mql.matches ? 'PRINT' : 'SCREEN');
if (mql.matches) {
beforePrint();
} else {
afterPrint();
}
});
}
// Fallback for older browsers
window.addEventListener('beforeprint', () => {
console.log('🖨️ beforeprint event fired');
beforePrint();
});
window.addEventListener('afterprint', () => {
console.log('🖨️ afterprint event fired');
afterPrint();
});
console.log('🔧 Print event handlers set up.');
console.log(' Use window.testPrintMode() to test for 6 seconds');
console.log(' Use window.cancelTestMode() to end test early');
}
function setupPrintButton() {
const printBtn = document.getElementById('print-btn');
if (!printBtn) {
console.warn('Print button not found');
return;
}
const originalBackgroundColor = scene.background;
const originalRendererClearColor = renderer.getClearColor(new THREE.Color());
const originalRendererClearAlpha = renderer.getClearAlpha();
printBtn.addEventListener('click', () => {
console.log('🖨️ Manual print button clicked - forcing white background');
// Force white background immediately
scene.background = new THREE.Color(0xffffff);
renderer.setClearColor(0xffffff, 1.0);
// Force canvas and page backgrounds to white
const canvas = renderer.domElement;
canvas.style.setProperty('background', 'white', 'important');
canvas.style.setProperty('background-color', 'white', 'important');
canvas.style.setProperty('background-image', 'none', 'important');
document.body.style.setProperty('background', 'white', 'important');
document.body.style.setProperty('background-color', 'white', 'important');
document.documentElement.style.setProperty('background', 'white', 'important');
document.documentElement.style.setProperty('background-color', 'white', 'important');
// Force a render to apply changes
renderer.render(scene, camera);
console.log('✅ Background forced to white, opening print dialog...');
// Small delay to ensure changes are applied, then open print dialog
setTimeout(() => {
window.print();
// Restore background after a delay to account for print dialog
setTimeout(() => {
console.log('🔄 Restoring original background...');
scene.background = originalBackgroundColor;
renderer.setClearColor(originalRendererClearColor, originalRendererClearAlpha);
canvas.style.removeProperty('background');
canvas.style.removeProperty('background-color');
canvas.style.removeProperty('background-image');
document.body.style.removeProperty('background');
document.body.style.removeProperty('background-color');
document.documentElement.style.removeProperty('background');
document.documentElement.style.removeProperty('background-color');
console.log('✅ Original background restored');
}, 1000); // Wait 1 second after print dialog opens
}, 100); // Small delay to apply changes
});
console.log('🖨️ Manual print button set up');
}
function setupFPVButton() {
const fpvBtn = document.getElementById('fpv-btn');
if (!fpvBtn) {
console.warn('FPV button not found');
return;
}
fpvBtn.addEventListener('click', () => {
if (!fpvMode) {
enterFPVMode();
} else {
exitFPVMode();
}
});
console.log('🚶 FPV button set up');
}
function setupLighting() {
// Ambient light for overall illumination - increased intensity
const ambientLight = new THREE.AmbientLight(0x404040, 1.2);
scene.add(ambientLight);
// Directional light (like sunlight) - increased intensity
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
directionalLight.position.set(10, 10, 5);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
scene.add(directionalLight);
// Point light for additional illumination - increased intensity
const pointLight = new THREE.PointLight(0xffffff, 0.8, 100);
pointLight.position.set(-10, 10, 10);
scene.add(pointLight);
// Camera light - follows the camera to illuminate from viewing angle
window.cameraLight = new THREE.PointLight(0xffffff, 1.0, 200);
window.cameraLight.position.copy(camera.position);
scene.add(window.cameraLight);
// Additional camera spotlight for focused illumination
window.cameraSpotlight = new THREE.SpotLight(0xffffff, 0.8, 100, Math.PI / 4, 0.3);
window.cameraSpotlight.position.copy(camera.position);
window.cameraSpotlight.target.position.set(0, 0, 0); // Point at origin initially
scene.add(window.cameraSpotlight);
scene.add(window.cameraSpotlight.target);
// Interior point lights (will be repositioned when model loads) - increased intensity
window.interiorLight = new THREE.PointLight(0xffffff, 0.8, 50);
window.interiorLight.position.set(0, 0, 0); // Will be moved to model center
scene.add(window.interiorLight);
// Additional interior light for better coverage - increased intensity
window.interiorLight2 = new THREE.PointLight(0xffffff, 0.5, 30);
window.interiorLight2.position.set(5, 5, -5); // Will be adjusted relative to model
scene.add(window.interiorLight2);
// Hemisphere light for natural lighting - increased intensity
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.8);
scene.add(hemiLight);
console.log('✨ Camera lights added - they will follow your view');
// Create environment map for glass reflections
setupEnvironmentMap();
}
function setupEnvironmentMap() {
// Create a simple cube texture for reflections (makes glass more visible)
const cubeTextureLoader = new THREE.CubeTextureLoader();
// Create a procedural cube map for reflections
const envMapRenderTarget = new THREE.WebGLCubeRenderTarget(256, {
generateMipmaps: true,
minFilter: THREE.LinearMipmapLinearFilter
});
// Simple gradient environment for reflections
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
// Create gradient for reflections
const gradient = ctx.createLinearGradient(0, 0, 0, 256);
gradient.addColorStop(0, '#87CEEB'); // Sky blue
gradient.addColorStop(0.5, '#F0F8FF'); // Alice blue
gradient.addColorStop(1, '#4682B4'); // Steel blue
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 256, 256);
// Convert to texture
const texture = new THREE.CanvasTexture(canvas);
texture.mapping = THREE.EquirectangularReflectionMapping;
// Store for glass materials
window.environmentMap = texture;
}
function loadCubeModel() {
// Create loaders
const mtlLoader = new MTLLoader();
const objLoader = new OBJLoader();
// Load material file first
mtlLoader.load('cube.mtl', function(materials) {
materials.preload();
// Configure materials for double-sided rendering with proper transparency
Object.keys(materials.materials).forEach(function(key) {
const material = materials.materials[key];
material.side = THREE.DoubleSide;
// Handle glass materials specially
if (key.toLowerCase().includes('glass')) {
material.transparent = true;
// Preserve MTL file opacity values for glass materials
// Only apply default opacity if not already set in MTL
if (material.opacity === undefined || material.opacity === 1.0) {
material.opacity = 0.7; // Fallback for visibility
}
// Enhanced glass properties for better visibility
material.shininess = 100;
material.reflectivity = 0.8;
// Glass-like color with slight tint for visibility
material.color = new THREE.Color(0.9, 0.95, 1.0);
// Add specular highlights to make glass more apparent
material.specular = new THREE.Color(0.8, 0.9, 1.0);
// Slight emissive to make glass edges more visible
if (material.emissive) {
material.emissive.setHex(0x001122); // Very subtle blue glow
}
// Apply environment map for reflections
if (window.environmentMap) {
material.envMap = window.environmentMap;
material.envMapIntensity = 0.9;
}
console.log(`Glass material '${key}' configured with opacity: ${material.opacity}`);
} else {
// Non-glass materials
material.transparent = false;
material.opacity = 1.0;
// Add emissive for interior visibility on solid materials
if (material.emissive) {
material.emissive.setHex(0x222222); // Increased emissive brightness
} else {
material.emissive = new THREE.Color(0x222222);
}
}
material.needsUpdate = true;
});
// Set materials to OBJ loader
objLoader.setMaterials(materials);
// Load the OBJ file
objLoader.load('cube.obj', function(object) {
// Scale and position the model
object.scale.set(0.5, 0.5, 0.5);
object.position.set(0, 0, 0);
// Enable shadows and fix materials for interior faces
object.traverse(function(child) {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
// Special handling for glass materials to improve visibility
if (child.material && child.material.name && child.material.name.toLowerCase().includes('glass')) {
// Glass-specific settings
child.material.side = THREE.DoubleSide;
child.material.transparent = true;
// Preserve original opacity from MTL if available
if (child.material.opacity === undefined || child.material.opacity === 1.0) {
child.material.opacity = 0.7;
}
child.material.alphaTest = 0.1;
// Enhanced reflective properties
child.material.envMapIntensity = 0.9;
// Apply environment map for reflections
if (window.environmentMap) {
child.material.envMap = window.environmentMap;
}
// Disable shadow casting for glass (more realistic)
child.castShadow = false;
child.receiveShadow = true;
console.log('Applied glass-specific settings to mesh');
// Add subtle wireframe to glass edges for better visibility
if (child.geometry) {
const wireframe = new THREE.EdgesGeometry(child.geometry);
const wireframeMaterial = new THREE.LineBasicMaterial({
color: 0x88aaff,
transparent: true,
opacity: 0.3,
linewidth: 2
});
const wireframeLines = new THREE.LineSegments(wireframe, wireframeMaterial);
child.add(wireframeLines);
}
} else {
// Non-glass materials
child.material.side = THREE.DoubleSide;
child.material.transparent = false;
child.material.opacity = 1.0;
// Ensure proper lighting on both sides
if (child.material.type === 'MeshLambertMaterial' || child.material.type === 'MeshPhongMaterial') {
child.material.emissive = new THREE.Color(0x222222); // Increased self-illumination for brightness
}
}
child.material.needsUpdate = true;
}
});
// Store reference and add to scene
cube = object;
scene.add(cube);
// Calculate model center and adjust controls
centerModelAndControls(cube);
// Hide loading indicator
document.getElementById('loading').style.display = 'none';
console.log('Cube loaded successfully!');
},
function(progress) {
console.log('Loading progress:', (progress.loaded / progress.total * 100) + '%');
},
function(error) {
console.error('Error loading OBJ file:', error);
document.getElementById('loading').innerHTML =
'<div style="color: #ff6b6b;">Error loading model. Please check console for details.</div>';
});
},
function(progress) {
console.log('Loading MTL progress:', (progress.loaded / progress.total * 100) + '%');
},
function(error) {
console.error('Error loading MTL file:', error);
// Try to load OBJ without materials
loadObjWithoutMaterials();
});
}
function loadObjWithoutMaterials() {
const objLoader = new OBJLoader();
objLoader.load('cube.obj', function(object) {
// Apply default material with better interior visibility
const defaultMaterial = new THREE.MeshPhongMaterial({
color: 0xffffff,
side: THREE.DoubleSide,
transparent: false,
opacity: 1.0,
emissive: new THREE.Color(0x222222), // Increased self-illumination for brightness
shininess: 30
});
object.traverse(function(child) {
if (child instanceof THREE.Mesh) {
child.material = defaultMaterial.clone(); // Clone to avoid sharing
child.castShadow = true;
child.receiveShadow = true;
}
});
// Scale and position
object.scale.set(0.5, 0.5, 0.5);
object.position.set(0, 0, 0);
cube = object;
scene.add(cube);
// Calculate model center and adjust controls
centerModelAndControls(cube);
document.getElementById('loading').style.display = 'none';
console.log('Cube loaded without materials!');
},
function(progress) {
console.log('Loading progress:', (progress.loaded / progress.total * 100) + '%');
},
function(error) {
console.error('Error loading OBJ file:', error);
document.getElementById('loading').innerHTML =
'<div style="color: #ff6b6b;">Failed to load model. Check if cube.obj exists.</div>';
});
}
function centerModelAndControls(object) {
// Calculate the bounding box of the model
const boundingBox = new THREE.Box3().setFromObject(object);
// Get the center point of the bounding box
const center = boundingBox.getCenter(new THREE.Vector3());
// Get the size of the bounding box
const size = boundingBox.getSize(new THREE.Vector3());
// Store model bounds for FPV mode
modelBounds = { boundingBox, center, size };
groundLevel = boundingBox.min.y;
ceilingLevel = boundingBox.max.y;
eyeHeight = groundLevel + (ceilingLevel - groundLevel) * 0.5;
console.log('FPV bounds calculated:');
console.log('Ground level:', groundLevel);
console.log('Ceiling level:', ceilingLevel);
console.log('Eye height (50%):', eyeHeight);
// Set the orbit controls target to the model's center
controls.target.copy(center);
// Calculate optimal camera distance based on model size
const maxDimension = Math.max(size.x, size.y, size.z);
const fov = camera.fov * (Math.PI / 180);
const distance = Math.abs(maxDimension / Math.sin(fov / 2)) * 1.2; // 1.2 for some padding
// Position camera at optimal distance from model center
const direction = camera.position.clone().sub(center).normalize();
camera.position.copy(center).add(direction.multiplyScalar(distance));
// Update camera to look at the model center
camera.lookAt(center);
// Reposition interior lights relative to model center
if (window.interiorLight) {
window.interiorLight.position.copy(center);
// Adjust light range based on model size
window.interiorLight.distance = maxDimension * 2;
}
if (window.interiorLight2) {
window.interiorLight2.position.copy(center).add(new THREE.Vector3(
size.x * 0.3,
size.y * 0.3,
-size.z * 0.3
));
window.interiorLight2.distance = maxDimension * 1.5;
}
// Update camera lights to target the model center
if (window.cameraLight) {
window.cameraLight.distance = maxDimension * 3; // Adjust range based on model size
}
if (window.cameraSpotlight) {
window.cameraSpotlight.target.position.copy(center);
window.cameraSpotlight.distance = maxDimension * 2;
}
// Update controls
controls.update();
// Add rotation logging
setupRotationLogging();
console.log('Model centered at:', center);
console.log('Model size:', size);
console.log('Camera positioned at:', camera.position);
}
function setupRotationLogging() {
let debounceTimer = null;
// Log rotation and zoom changes with 1-second debounce
controls.addEventListener('change', function() {
// Clear existing timer
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Set new timer for 1 second delay
debounceTimer = setTimeout(() => {
const distance = camera.position.distanceTo(controls.target);
const position = camera.position.clone();
const target = controls.target.clone();
// Calculate spherical coordinates for rotation
const spherical = new THREE.Spherical().setFromVector3(
position.clone().sub(target)
);
console.log('=== CAMERA STATE ===');
console.log('Position:', {
x: Math.round(position.x * 100) / 100,
y: Math.round(position.y * 100) / 100,
z: Math.round(position.z * 100) / 100
});
console.log('Target:', {
x: Math.round(target.x * 100) / 100,
y: Math.round(target.y * 100) / 100,
z: Math.round(target.z * 100) / 100
});
console.log('Distance (Zoom):', Math.round(distance * 100) / 100);
console.log('Spherical Rotation:');
console.log(' - Radius:', Math.round(spherical.radius * 100) / 100);
console.log(' - Theta (horizontal):', Math.round(spherical.theta * 100) / 100, 'radians');
console.log(' - Phi (vertical):', Math.round(spherical.phi * 100) / 100, 'radians');
console.log('==================');
debounceTimer = null;
}, 1000);
});
console.log('Rotation logging enabled - camera state will be logged 1 second after movement stops');
}
function setupFPVControls() {
// Keyboard event handlers
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
// Mouse event handlers for FPV mode
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('click', onMouseClick);
console.log('FPV controls setup complete. Press F to enter FPV mode');
}
function onKeyDown(event) {
switch (event.code) {
case 'KeyF':
event.preventDefault();
event.stopPropagation();
if (!fpvMode) {
enterFPVMode();
} else {
exitFPVMode();
}
return false;
case 'Escape':
if (fpvMode) {
event.preventDefault();
event.stopPropagation();
exitFPVMode();
return false;
}
break;
case 'KeyW':
if (fpvMode) fpvControls.moveForward = true;
break;
case 'KeyA':
if (fpvMode) fpvControls.moveLeft = true;
break;
case 'KeyS':
if (fpvMode) fpvControls.moveBackward = true;
break;
case 'KeyD':
if (fpvMode) fpvControls.moveRight = true;
break;
case 'Space':
if (fpvMode) {
event.preventDefault();
fpvControls.jump = true;
}
break;
case 'ShiftLeft':
case 'ShiftRight':
if (fpvMode) {
event.preventDefault();
fpvControls.crouch = true;
fpvControls.isCrouching = true;
}
break;
}
}
function onKeyUp(event) {
switch (event.code) {
case 'KeyW':
if (fpvMode) fpvControls.moveForward = false;
break;
case 'KeyA':
if (fpvMode) fpvControls.moveLeft = false;
break;
case 'KeyS':
if (fpvMode) fpvControls.moveBackward = false;
break;
case 'KeyD':
if (fpvMode) fpvControls.moveRight = false;
break;
case 'Space':
if (fpvMode) fpvControls.jump = false;
break;
case 'ShiftLeft':
case 'ShiftRight':
if (fpvMode) {
fpvControls.crouch = false;
fpvControls.isCrouching = false;
}
break;
}
}
function onMouseMove(event) {
if (!fpvMode) return;
const movementX = event.movementX || event.mozMovementX || event.webkitMovementX || 0;
const movementY = event.movementY || event.mozMovementY || event.webkitMovementY || 0;
fpvControls.rotation.y -= movementX * 0.002; // Horizontal rotation (yaw)
fpvControls.rotation.x -= movementY * 0.002; // Vertical rotation (pitch)
// Limit vertical look angle
fpvControls.rotation.x = Math.max(-Math.PI/2, Math.min(Math.PI/2, fpvControls.rotation.x));
}
function onMouseClick(event) {
if (fpvMode) {
// Request pointer lock for mouse control
renderer.domElement.requestPointerLock();
}
}
function enterFPVMode() {
if (!modelBounds) {
console.warn('Model not loaded yet, cannot enter FPV mode');
return;
}
console.log('🚶 Entering FPV mode');
fpvMode = true;
// Disable orbit controls
controls.enabled = false;
// Keep current camera position instead of forcing to model center
// Just ensure the height is reasonable for walking
fpvControls.currentHeight = Math.max(camera.position.y, eyeHeight);
fpvControls.targetHeight = fpvControls.currentHeight;
camera.position.y = fpvControls.currentHeight;
// Calculate rotation based on current camera orientation
const euler = new THREE.Euler().setFromQuaternion(camera.quaternion, 'YXZ');
fpvControls.rotation.x = euler.x;
fpvControls.rotation.y = euler.y;
// Reset movement state
fpvControls.velocity.set(0, 0, 0);
fpvControls.verticalVelocity = 0;
fpvControls.isJumping = false;
fpvControls.isCrouching = false;
// Show crosshair
const crosshair = document.getElementById('fpv-crosshair');
if (crosshair) {
crosshair.style.display = 'block';
}
// Update button text
const fpvBtn = document.getElementById('fpv-btn');
if (fpvBtn) {
fpvBtn.textContent = 'Exit FPV Mode';
fpvBtn.style.background = '#FF5722';
}
// Request pointer lock
renderer.domElement.requestPointerLock();
console.log('FPV mode active - WASD to move, mouse to look, ESC to exit');
}
function exitFPVMode() {
console.log('🔄 Exiting FPV mode');
// Exit pointer lock first, before changing fpvMode
if (document.pointerLockElement) {
document.exitPointerLock();
}
// Small delay to ensure pointer lock has been released
setTimeout(() => {
fpvMode = false;
// Re-enable orbit controls
controls.enabled = true;
// Hide crosshair
const crosshair = document.getElementById('fpv-crosshair');
if (crosshair) {
crosshair.style.display = 'none';
}
// Update button text
const fpvBtn = document.getElementById('fpv-btn');
if (fpvBtn) {
fpvBtn.textContent = 'Enter FPV Mode';
fpvBtn.style.background = '#2196F3';
}
// Reset movement controls
fpvControls.moveForward = false;
fpvControls.moveBackward = false;
fpvControls.moveLeft = false;
fpvControls.moveRight = false;
fpvControls.jump = false;
fpvControls.crouch = false;
fpvControls.verticalVelocity = 0;
fpvControls.isJumping = false;
fpvControls.isCrouching = false;
fpvControls.currentHeight = eyeHeight;
fpvControls.targetHeight = eyeHeight;
console.log('Orbit controls restored');
}, 50);
}
function updateFPVMovement() {
if (!fpvMode) return;
const delta = 0.016; // Approximate 60fps
const moveSpeed = 100.0; // Units per second (1000x faster)
const jumpSpeed = 220.0; // Jump initial velocity (4x higher)
const gravity = 420.0; // Gravity strength (4x faster)
const crouchHeight = eyeHeight * 0.5; // Crouch height (60% of normal eye height)
const crouchSpeed = 100.0; // Speed of crouching transition
// Reset direction
fpvControls.direction.set(0, 0, 0);
// Calculate movement direction based on current rotation
const forward = new THREE.Vector3(0, 0, -1);
const right = new THREE.Vector3(1, 0, 0);
// Apply horizontal rotation to movement vectors
forward.applyAxisAngle(new THREE.Vector3(0, 1, 0), fpvControls.rotation.y);
right.applyAxisAngle(new THREE.Vector3(0, 1, 0), fpvControls.rotation.y);
// Apply movement inputs
if (fpvControls.moveForward) fpvControls.direction.add(forward);
if (fpvControls.moveBackward) fpvControls.direction.sub(forward);
if (fpvControls.moveLeft) fpvControls.direction.sub(right);
if (fpvControls.moveRight) fpvControls.direction.add(right);
// Normalize and apply speed
if (fpvControls.direction.length() > 0) {
fpvControls.direction.normalize();
fpvControls.direction.multiplyScalar(moveSpeed * delta);
// Apply movement to camera position
camera.position.add(fpvControls.direction);
}
// Handle jumping
if (fpvControls.jump && fpvControls.canJump && !fpvControls.isJumping) {
fpvControls.verticalVelocity = jumpSpeed;
fpvControls.isJumping = true;
fpvControls.canJump = false;
}
// Update target height based on crouching state
fpvControls.targetHeight = fpvControls.isCrouching ? crouchHeight : eyeHeight;
// Apply gravity and vertical movement
if (fpvControls.isJumping) {
fpvControls.verticalVelocity -= gravity * delta;
camera.position.y += fpvControls.verticalVelocity * delta;
// Check if landed back on ground
if (camera.position.y <= fpvControls.targetHeight) {
camera.position.y = fpvControls.targetHeight;
fpvControls.currentHeight = fpvControls.targetHeight;
fpvControls.verticalVelocity = 0;
fpvControls.isJumping = false;
fpvControls.canJump = true;
}
} else {
// Smooth crouching transition when not jumping
if (Math.abs(fpvControls.currentHeight - fpvControls.targetHeight) > 0.01) {
const direction = fpvControls.targetHeight > fpvControls.currentHeight ? 1 : -1;
fpvControls.currentHeight += direction * crouchSpeed * delta;
// Clamp to target
if (direction > 0 && fpvControls.currentHeight >= fpvControls.targetHeight) {
fpvControls.currentHeight = fpvControls.targetHeight;
} else if (direction < 0 && fpvControls.currentHeight <= fpvControls.targetHeight) {
fpvControls.currentHeight = fpvControls.targetHeight;
}
}
camera.position.y = fpvControls.currentHeight;
}
// Apply rotation to camera
camera.rotation.set(fpvControls.rotation.x, fpvControls.rotation.y, 0, 'YXZ');
}
function animate() {
requestAnimationFrame(animate);
// Update FPV movement if in FPV mode
updateFPVMovement();
// Update controls (only if not in FPV mode)
if (!fpvMode) {
controls.update();
}
// Update camera lights to follow camera position
if (window.cameraLight) {
window.cameraLight.position.copy(camera.position);
}
if (window.cameraSpotlight) {
window.cameraSpotlight.position.copy(camera.position);
// Make spotlight point towards the orbit controls target (model center)
if (controls && controls.target) {
window.cameraSpotlight.target.position.copy(controls.target);
}
}
// Clear and render with proper transparency sorting
renderer.clear();
renderer.render(scene, camera);
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Initialize when page loads
window.addEventListener('load', async () => {
await init();
});