Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>3D Game Viewer</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| overflow: hidden; | |
| background: #0a0a0a; | |
| } | |
| #viewer-container { | |
| width: 100vw; | |
| height: 100vh; | |
| position: relative; | |
| } | |
| /* Crosshair for FPS mode */ | |
| #crosshair { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| width: 6px; | |
| height: 6px; | |
| background: #000000; | |
| border-radius: 50%; | |
| pointer-events: none; | |
| display: none; /* Show only in FPS mode */ | |
| z-index: 100; | |
| box-shadow: 0 0 2px rgba(255, 255, 255, 0.5); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="viewer-container"> | |
| <div id="crosshair"></div> | |
| </div> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/", | |
| "cannon-es": "https://cdn.jsdelivr.net/npm/cannon-es@0.20.0/dist/cannon-es.js" | |
| } | |
| } | |
| </script> | |
| <!-- Stats library for FPS counter --> | |
| <script src="https://cdn.jsdelivr.net/npm/stats.js@0.17.0/build/stats.min.js"></script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; | |
| import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; | |
| import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'; | |
| import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; | |
| import { Sky } from 'three/addons/objects/Sky.js'; | |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; | |
| import * as CANNON from 'cannon-es'; | |
| // Skybox and environment references | |
| let sky = null; | |
| let sun = new THREE.Vector3(); | |
| // Particle systems | |
| let particleSystems = new Map(); | |
| let animatedModels = []; // Models with animation metadata | |
| // UI overlay container | |
| let uiContainer = null; | |
| let uiElements = new Map(); | |
| // GLTF loader for brick models | |
| const gltfLoader = new GLTFLoader(); | |
| // Get scene ID from URL | |
| const sceneId = window.location.pathname.split('/').pop(); | |
| const baseUrl = window.location.origin; | |
| // Check for control mode in URL params | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const initialMode = urlParams.get('mode') || 'fps'; // Default to FPS mode | |
| let scene, camera, renderer; | |
| let orbitControls; | |
| let controlMode = initialMode; | |
| let sceneData = null; | |
| // Postprocessing for outlines | |
| let composer, outlinePass; | |
| // Scene controls | |
| let gridHelper = null; | |
| let stats = null; | |
| let wireframeEnabled = false; | |
| // Object selection system (FPS mode) | |
| let raycaster = new THREE.Raycaster(); | |
| let mouse = new THREE.Vector2(); | |
| let selectedObject = null; | |
| let selectedObjectId = null; | |
| const MAX_SELECT_DISTANCE = 10; // Max raycast distance for selection | |
| // FPS movement and look variables (configurable via player_config) | |
| let moveSpeed = 8.0; // Default walking speed in units/sec | |
| const velocity = new THREE.Vector3(); | |
| let isMouseLocked = false; | |
| let cameraRotationX = 0; // Pitch (up/down) | |
| let cameraRotationY = 0; // Yaw (left/right) | |
| let mouseSensitivity = 0.002; | |
| let invertY = false; | |
| let movementAcceleration = 0.0; // Phase 2 | |
| let airControl = 1.0; // Phase 2 | |
| let cameraFOV = 75.0; // Phase 2 | |
| let minPitch = -89.0; // Phase 2 | |
| let maxPitch = 89.0; // Phase 2 | |
| // Physics variables (configurable via player_config) | |
| let physicsWorld; | |
| let playerBody; | |
| let groundBody; | |
| let wallBodies = []; | |
| let objectBodies = new Map(); // Maps object IDs to physics bodies | |
| let PLAYER_HEIGHT = 1.7; | |
| let PLAYER_RADIUS = 0.3; | |
| let EYE_HEIGHT = 1.6; // Eye level for camera | |
| let JUMP_FORCE = 5.0; | |
| let GRAVITY = -9.82; | |
| let PLAYER_MASS = 80.0; | |
| let LINEAR_DAMPING = 0.0; // No damping - we control velocity directly | |
| // World size from scene data (default 25x25) | |
| let WORLD_SIZE = 25; | |
| let WORLD_HALF = WORLD_SIZE / 2; | |
| let isGrounded = false; | |
| let canJump = true; | |
| // Movement direction and keyboard state | |
| const direction = new THREE.Vector3(); | |
| const moveForward = { value: false }; | |
| const moveBackward = { value: false }; | |
| const moveLeft = { value: false }; | |
| const moveRight = { value: false }; | |
| const moveUp = { value: false }; | |
| const moveDown = { value: false }; | |
| let prevTime = performance.now(); | |
| function applyPlayerConfig() { | |
| /** | |
| * Apply player configuration from scene data to runtime variables | |
| * Allows MCP tools to customize player controller behavior | |
| */ | |
| if (!sceneData || !sceneData.player_config) { | |
| return; | |
| } | |
| const config = sceneData.player_config; | |
| // Apply movement settings | |
| if (config.move_speed !== undefined) moveSpeed = config.move_speed; | |
| if (config.jump_force !== undefined) JUMP_FORCE = config.jump_force; | |
| if (config.mouse_sensitivity !== undefined) mouseSensitivity = config.mouse_sensitivity; | |
| if (config.invert_y !== undefined) invertY = config.invert_y; | |
| if (config.gravity !== undefined) GRAVITY = config.gravity; | |
| if (config.player_height !== undefined) PLAYER_HEIGHT = config.player_height; | |
| if (config.player_radius !== undefined) PLAYER_RADIUS = config.player_radius; | |
| if (config.eye_height !== undefined) EYE_HEIGHT = config.eye_height; | |
| if (config.player_mass !== undefined) PLAYER_MASS = config.player_mass; | |
| if (config.linear_damping !== undefined) LINEAR_DAMPING = config.linear_damping; | |
| if (config.movement_acceleration !== undefined) movementAcceleration = config.movement_acceleration; | |
| if (config.air_control !== undefined) airControl = config.air_control; | |
| if (config.camera_fov !== undefined) cameraFOV = config.camera_fov; | |
| if (config.min_pitch !== undefined) minPitch = config.min_pitch; | |
| if (config.max_pitch !== undefined) maxPitch = config.max_pitch; | |
| } | |
| function applyInitialEnvironment() { | |
| /** | |
| * Apply initial environment settings from scene data | |
| * Loads skybox, particles, and UI elements on startup | |
| */ | |
| if (!sceneData) return; | |
| // Apply skybox if defined in scene data | |
| if (sceneData.skybox) { | |
| handleAddSkybox(sceneData.skybox); | |
| } | |
| // Apply particles if defined in scene data | |
| if (sceneData.particles && Array.isArray(sceneData.particles)) { | |
| sceneData.particles.forEach(particleConfig => { | |
| handleAddParticles(particleConfig); | |
| }); | |
| } | |
| // Apply UI elements if defined in scene data | |
| if (sceneData.ui_elements && Array.isArray(sceneData.ui_elements)) { | |
| sceneData.ui_elements.forEach(uiConfig => { | |
| if (uiConfig.type === 'text') { | |
| handleRenderText(uiConfig); | |
| } else if (uiConfig.type === 'bar') { | |
| handleRenderBar(uiConfig); | |
| } | |
| }); | |
| } | |
| } | |
| async function init() { | |
| try { | |
| // Check for embedded scene data first (used when served via Gradio) | |
| if (window.SCENE_DATA) { | |
| sceneData = window.SCENE_DATA; | |
| } else { | |
| // Fetch scene data from API (used when served via FastAPI) | |
| const response = await fetch(`${baseUrl}/api/scenes/${sceneId}`); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| console.error('Failed to fetch scene:', errorText); | |
| throw new Error(`Scene not found (${response.status}): ${errorText}`); | |
| } | |
| sceneData = await response.json(); | |
| } | |
| // Apply world size from scene data | |
| if (sceneData.world_width) { | |
| WORLD_SIZE = sceneData.world_width; | |
| WORLD_HALF = WORLD_SIZE / 2; | |
| } | |
| // Apply player configuration from scene data | |
| applyPlayerConfig(); | |
| // Setup Three.js scene | |
| setupScene(); | |
| // Setup physics world | |
| setupPhysics(); | |
| // Render all game objects | |
| renderGameObjects(); | |
| // Apply initial environment (skybox, particles, UI from scene data) | |
| applyInitialEnvironment(); | |
| // Start animation loop | |
| animate(); | |
| } catch (error) { | |
| console.error('Error initializing viewer:', error); | |
| } | |
| } | |
| function setupScene() { | |
| // Create scene | |
| scene = new THREE.Scene(); | |
| const bgColor = sceneData.environment?.background_color || '#87CEEB'; | |
| scene.background = new THREE.Color(bgColor); | |
| // Create camera (FOV from player_config) | |
| camera = new THREE.PerspectiveCamera( | |
| cameraFOV, | |
| window.innerWidth / window.innerHeight, | |
| 0.1, | |
| 1000 | |
| ); | |
| // Position camera at player eye height (will be synced with physics in animate loop) | |
| camera.position.set(0, EYE_HEIGHT, 0); | |
| // Create renderer with shadow support | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| // Enable shadows for realistic lighting | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Soft shadows | |
| // Use physically correct lighting model | |
| renderer.physicallyCorrectLights = true; | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.0; | |
| document.getElementById('viewer-container').appendChild(renderer.domElement); | |
| // Setup postprocessing for object outlines | |
| composer = new EffectComposer(renderer); | |
| const renderPass = new RenderPass(scene, camera); | |
| composer.addPass(renderPass); | |
| outlinePass = new OutlinePass(new THREE.Vector2(window.innerWidth, window.innerHeight), scene, camera); | |
| outlinePass.edgeStrength = 5.0; // Increased for better visibility | |
| outlinePass.edgeGlow = 1.0; // Increased glow | |
| outlinePass.edgeThickness = 3.0; // Thicker edge | |
| outlinePass.pulsePeriod = 0; // No pulsing | |
| outlinePass.visibleEdgeColor.set('#ff8800'); // Orange outline | |
| outlinePass.hiddenEdgeColor.set('#ff4400'); // Darker orange for hidden edges | |
| composer.addPass(outlinePass); | |
| const outputPass = new OutputPass(); | |
| composer.addPass(outputPass); | |
| // Add lights from scene data | |
| // Best practices: Combine ambient (low intensity) + directional (sun) + point lights (accents) | |
| sceneData.lights.forEach(lightData => { | |
| let light; | |
| if (lightData.type === 'ambient') { | |
| light = new THREE.AmbientLight(lightData.color, lightData.intensity); | |
| } else if (lightData.type === 'directional') { | |
| light = new THREE.DirectionalLight(lightData.color, lightData.intensity); | |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); | |
| // Directional lights need their target in the scene to work properly | |
| light.target.position.set(0, 0, 0); | |
| scene.add(light.target); | |
| if (lightData.cast_shadow) { | |
| light.castShadow = true; | |
| // Configure shadow map for better quality | |
| light.shadow.mapSize.width = 2048; | |
| light.shadow.mapSize.height = 2048; | |
| light.shadow.camera.near = 0.5; | |
| light.shadow.camera.far = 100; | |
| light.shadow.camera.left = -30; | |
| light.shadow.camera.right = 30; | |
| light.shadow.camera.top = 30; | |
| light.shadow.camera.bottom = -30; | |
| light.shadow.bias = -0.0001; | |
| } | |
| } else if (lightData.type === 'point') { | |
| // Point lights with distance and decay for realistic falloff | |
| const distance = lightData.distance || 50; | |
| const decay = lightData.decay || 2; // Physically correct decay | |
| light = new THREE.PointLight(lightData.color, lightData.intensity, distance, decay); | |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); | |
| } else if (lightData.type === 'hemisphere') { | |
| // Hemisphere light - great for outdoor scenes (sky + ground colors) | |
| const skyColor = lightData.color || '#87CEEB'; | |
| const groundColor = lightData.ground_color || '#444444'; | |
| light = new THREE.HemisphereLight(skyColor, groundColor, lightData.intensity); | |
| if (lightData.position) { | |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); | |
| } | |
| } | |
| if (light) { | |
| light.name = lightData.name || lightData.type; | |
| scene.add(light); | |
| } | |
| }); | |
| // Add grid (initially hidden, can be toggled) | |
| const gridSize = sceneData.grid_size || 100; | |
| const divisions = sceneData.grid_divisions || 20; | |
| gridHelper = new THREE.GridHelper(gridSize, divisions, 0x444444, 0x222222); | |
| gridHelper.visible = sceneData.show_grid || false; | |
| scene.add(gridHelper); | |
| // Initialize stats (FPS counter) - initially hidden | |
| if (typeof Stats !== 'undefined') { | |
| stats = new Stats(); | |
| stats.dom.style.position = 'absolute'; | |
| stats.dom.style.top = '0px'; | |
| stats.dom.style.left = '0px'; | |
| stats.dom.style.display = 'none'; // Hidden by default | |
| document.getElementById('viewer-container').appendChild(stats.dom); | |
| } | |
| // Setup Orbit controls | |
| orbitControls = new OrbitControls(camera, renderer.domElement); | |
| orbitControls.enableDamping = true; | |
| orbitControls.dampingFactor = 0.05; | |
| // Setup FPS mouse-look controls | |
| setupFPSControls(); | |
| // Click handler for object inspection in orbit mode | |
| renderer.domElement.addEventListener('click', (event) => { | |
| // In orbit mode, allow object inspection | |
| if (controlMode === 'orbit') { | |
| onObjectClick(event); | |
| } | |
| }); | |
| // Keyboard controls for FPS movement | |
| const onKeyDown = (event) => { | |
| switch (event.code) { | |
| case 'KeyW': moveForward.value = true; break; | |
| case 'KeyA': moveLeft.value = true; break; | |
| case 'KeyS': moveBackward.value = true; break; | |
| case 'KeyD': moveRight.value = true; break; | |
| case 'Space': | |
| event.preventDefault(); // Prevent page scroll | |
| if (canJump && isGrounded && playerBody) { | |
| playerBody.velocity.y = JUMP_FORCE; | |
| canJump = false; | |
| isGrounded = false; | |
| } | |
| break; | |
| case 'KeyC': toggleControlMode(); break; | |
| } | |
| }; | |
| const onKeyUp = (event) => { | |
| switch (event.code) { | |
| case 'KeyW': moveForward.value = false; break; | |
| case 'KeyA': moveLeft.value = false; break; | |
| case 'KeyS': moveBackward.value = false; break; | |
| case 'KeyD': moveRight.value = false; break; | |
| } | |
| }; | |
| document.addEventListener('keydown', onKeyDown); | |
| document.addEventListener('keyup', onKeyUp); | |
| // Set initial control mode | |
| setControlMode(controlMode); | |
| // Handle window resize | |
| window.addEventListener('resize', onWindowResize); | |
| } | |
| function setupFPSControls() { | |
| // Mouse-look controls for FPS mode | |
| renderer.domElement.addEventListener('mousedown', (event) => { | |
| if (controlMode === 'fps' && event.button === 0) { | |
| isMouseLocked = true; | |
| // Wrap in try-catch to handle SecurityError when user exits lock quickly | |
| try { | |
| renderer.domElement.requestPointerLock(); | |
| } catch (e) { | |
| isMouseLocked = false; | |
| } | |
| } | |
| }); | |
| renderer.domElement.addEventListener('mouseup', () => { | |
| // Keep mouse locked until Escape is pressed | |
| }); | |
| // Mouse movement for camera rotation | |
| document.addEventListener('mousemove', (event) => { | |
| if (!isMouseLocked || controlMode !== 'fps') return; | |
| const movementX = event.movementX || 0; | |
| const movementY = event.movementY || 0; | |
| // Update rotation (yaw and pitch) | |
| cameraRotationY -= movementX * mouseSensitivity; // Yaw (left/right) | |
| const pitchMultiplier = invertY ? 1 : -1; // Invert Y if configured | |
| cameraRotationX += movementY * mouseSensitivity * pitchMultiplier; // Pitch (up/down) | |
| // Clamp vertical rotation to configured limits | |
| const minPitchRad = THREE.MathUtils.degToRad(minPitch); | |
| const maxPitchRad = THREE.MathUtils.degToRad(maxPitch); | |
| cameraRotationX = Math.max(minPitchRad, Math.min(maxPitchRad, cameraRotationX)); | |
| }); | |
| // Handle pointer lock changes | |
| document.addEventListener('pointerlockchange', () => { | |
| isMouseLocked = document.pointerLockElement === renderer.domElement; | |
| }); | |
| document.addEventListener('pointerlockerror', () => { | |
| // Silently handle pointer lock errors - common when user clicks away quickly | |
| isMouseLocked = false; | |
| }); | |
| } | |
| function setupPhysics() { | |
| // Create physics world | |
| physicsWorld = new CANNON.World(); | |
| physicsWorld.gravity.set(0, GRAVITY, 0); | |
| // Set up collision materials - zero friction since we control velocity directly | |
| const defaultMaterial = new CANNON.Material('default'); | |
| const defaultContactMaterial = new CANNON.ContactMaterial( | |
| defaultMaterial, | |
| defaultMaterial, | |
| { | |
| friction: 0.0, // No friction - we set velocity directly each frame | |
| restitution: 0.0, // No bounce | |
| } | |
| ); | |
| physicsWorld.addContactMaterial(defaultContactMaterial); | |
| physicsWorld.defaultContactMaterial = defaultContactMaterial; | |
| // Create blueprint-style grid texture for ground | |
| function createBlueprintTexture(size = 512) { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = size; | |
| canvas.height = size; | |
| const ctx = canvas.getContext('2d'); | |
| // Background - darker blue | |
| ctx.fillStyle = '#1a5a9e'; | |
| ctx.fillRect(0, 0, size, size); | |
| // Major grid lines - white | |
| const majorSpacing = size / 8; | |
| ctx.strokeStyle = '#ffffff'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| for (let i = 0; i <= 8; i++) { | |
| const pos = i * majorSpacing; | |
| ctx.moveTo(pos, 0); | |
| ctx.lineTo(pos, size); | |
| ctx.moveTo(0, pos); | |
| ctx.lineTo(size, pos); | |
| } | |
| ctx.stroke(); | |
| // Minor grid lines - white (semi-transparent) | |
| const minorSpacing = majorSpacing / 4; | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| for (let i = 0; i <= 32; i++) { | |
| const pos = i * minorSpacing; | |
| ctx.moveTo(pos, 0); | |
| ctx.lineTo(pos, size); | |
| ctx.moveTo(0, pos); | |
| ctx.lineTo(size, pos); | |
| } | |
| ctx.stroke(); | |
| return canvas; | |
| } | |
| // Create ground plane | |
| const groundGeometry = new THREE.PlaneGeometry(WORLD_SIZE, WORLD_SIZE); | |
| const blueprintCanvas = createBlueprintTexture(); | |
| const blueprintTexture = new THREE.CanvasTexture(blueprintCanvas); | |
| blueprintTexture.wrapS = THREE.RepeatWrapping; | |
| blueprintTexture.wrapT = THREE.RepeatWrapping; | |
| blueprintTexture.repeat.set(WORLD_SIZE / 5, WORLD_SIZE / 5); // 5 units per texture tile | |
| const groundMaterial = new THREE.MeshBasicMaterial({ | |
| map: blueprintTexture | |
| }); | |
| const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial); | |
| groundMesh.rotation.x = -Math.PI / 2; // Rotate to be horizontal | |
| groundMesh.position.y = 0; | |
| groundMesh.receiveShadow = true; | |
| groundMesh.userData = { isGround: true }; | |
| scene.add(groundMesh); | |
| // Ground physics body | |
| const groundShape = new CANNON.Plane(); | |
| groundBody = new CANNON.Body({ mass: 0, material: defaultMaterial }); | |
| groundBody.addShape(groundShape); | |
| groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); | |
| physicsWorld.addBody(groundBody); | |
| // Create 4 boundary walls with blueprint texture | |
| const wallHeight = 5; | |
| const wallThickness = 0.5; | |
| // Wall material - same blueprint texture | |
| const wallTexture = new THREE.CanvasTexture(createBlueprintTexture()); | |
| wallTexture.wrapS = THREE.RepeatWrapping; | |
| wallTexture.wrapT = THREE.RepeatWrapping; | |
| const wallMaterial = new THREE.MeshBasicMaterial({ | |
| map: wallTexture | |
| }); | |
| // North/South walls (along X axis) | |
| const nsWallGeometry = new THREE.BoxGeometry(WORLD_SIZE, wallHeight, wallThickness); | |
| // Texture repeat: width based on world size, height = 1 tile (5 units) | |
| const nsWallMaterial = wallMaterial.clone(); | |
| nsWallMaterial.map = wallTexture.clone(); | |
| nsWallMaterial.map.repeat.set(WORLD_SIZE / 5, wallHeight / 5); | |
| // North wall (z = +WORLD_HALF) | |
| const northWallMesh = new THREE.Mesh(nsWallGeometry, nsWallMaterial); | |
| northWallMesh.position.set(0, wallHeight / 2, WORLD_HALF); | |
| northWallMesh.receiveShadow = true; | |
| northWallMesh.castShadow = false; | |
| scene.add(northWallMesh); | |
| const northWallShape = new CANNON.Box(new CANNON.Vec3(WORLD_SIZE / 2, wallHeight / 2, wallThickness / 2)); | |
| const northWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial }); | |
| northWallBody.addShape(northWallShape); | |
| northWallBody.position.copy(northWallMesh.position); | |
| physicsWorld.addBody(northWallBody); | |
| wallBodies.push(northWallBody); | |
| // South wall (z = -WORLD_HALF) | |
| const southWallMesh = new THREE.Mesh(nsWallGeometry, nsWallMaterial); | |
| southWallMesh.position.set(0, wallHeight / 2, -WORLD_HALF); | |
| southWallMesh.receiveShadow = true; | |
| southWallMesh.castShadow = false; | |
| scene.add(southWallMesh); | |
| const southWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial }); | |
| southWallBody.addShape(northWallShape); | |
| southWallBody.position.copy(southWallMesh.position); | |
| physicsWorld.addBody(southWallBody); | |
| wallBodies.push(southWallBody); | |
| // East/West walls (along Z axis) | |
| const ewWallGeometry = new THREE.BoxGeometry(wallThickness, wallHeight, WORLD_SIZE); | |
| const ewWallMaterial = wallMaterial.clone(); | |
| ewWallMaterial.map = wallTexture.clone(); | |
| ewWallMaterial.map.repeat.set(WORLD_SIZE / 5, wallHeight / 5); | |
| // East wall (x = +WORLD_HALF) | |
| const eastWallMesh = new THREE.Mesh(ewWallGeometry, ewWallMaterial); | |
| eastWallMesh.position.set(WORLD_HALF, wallHeight / 2, 0); | |
| eastWallMesh.receiveShadow = true; | |
| eastWallMesh.castShadow = false; | |
| scene.add(eastWallMesh); | |
| const eastWallShape = new CANNON.Box(new CANNON.Vec3(wallThickness / 2, wallHeight / 2, WORLD_SIZE / 2)); | |
| const eastWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial }); | |
| eastWallBody.addShape(eastWallShape); | |
| eastWallBody.position.copy(eastWallMesh.position); | |
| physicsWorld.addBody(eastWallBody); | |
| wallBodies.push(eastWallBody); | |
| // West wall (x = -WORLD_HALF) | |
| const westWallMesh = new THREE.Mesh(ewWallGeometry, ewWallMaterial); | |
| westWallMesh.position.set(-WORLD_HALF, wallHeight / 2, 0); | |
| westWallMesh.receiveShadow = true; | |
| westWallMesh.castShadow = false; | |
| scene.add(westWallMesh); | |
| const westWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial }); | |
| westWallBody.addShape(eastWallShape); | |
| westWallBody.position.copy(westWallMesh.position); | |
| physicsWorld.addBody(westWallBody); | |
| wallBodies.push(westWallBody); | |
| // Ceiling - same material as floor/walls, positioned at wall height | |
| const ceilingGeometry = new THREE.PlaneGeometry(WORLD_SIZE, WORLD_SIZE); | |
| const ceilingMaterial = new THREE.MeshBasicMaterial({ | |
| map: blueprintTexture.clone() | |
| }); | |
| ceilingMaterial.map.repeat.set(WORLD_SIZE / 5, WORLD_SIZE / 5); | |
| const ceilingMesh = new THREE.Mesh(ceilingGeometry, ceilingMaterial); | |
| ceilingMesh.rotation.x = Math.PI / 2; // Rotate to face downward | |
| ceilingMesh.position.y = wallHeight; | |
| ceilingMesh.receiveShadow = false; | |
| ceilingMesh.castShadow = false; | |
| scene.add(ceilingMesh); | |
| // Create player physics body (capsule approximated with cylinder) | |
| const playerShape = new CANNON.Cylinder(PLAYER_RADIUS, PLAYER_RADIUS, PLAYER_HEIGHT, 8); | |
| playerBody = new CANNON.Body({ | |
| mass: PLAYER_MASS, // kg (configurable via player_config) | |
| material: defaultMaterial, | |
| fixedRotation: true, // Prevent player from tipping over | |
| linearDamping: LINEAR_DAMPING, // Air resistance for movement (configurable) | |
| }); | |
| playerBody.addShape(playerShape); | |
| // Set player starting position - spawn behind and facing the HF logo at (0, 1.5, 0) | |
| playerBody.position.set(0, 1 + PLAYER_HEIGHT / 2, 8); | |
| physicsWorld.addBody(playerBody); | |
| // Add collision detection for grounded check | |
| playerBody.addEventListener('collide', (e) => { | |
| // Check if colliding with ground | |
| if (e.body === groundBody) { | |
| isGrounded = true; | |
| canJump = true; | |
| } | |
| }); | |
| } | |
| function toggleControlMode() { | |
| const newMode = controlMode === 'orbit' ? 'fps' : 'orbit'; | |
| setControlMode(newMode); | |
| } | |
| function setControlMode(mode) { | |
| controlMode = mode; | |
| const crosshair = document.getElementById('crosshair'); | |
| if (mode === 'fps') { | |
| // Switch to FPS | |
| orbitControls.enabled = false; | |
| // Exit pointer lock if active | |
| if (document.pointerLockElement) { | |
| document.exitPointerLock(); | |
| } | |
| isMouseLocked = false; | |
| // Show crosshair | |
| if (crosshair) crosshair.style.display = 'block'; | |
| } else { | |
| // Switch to Orbit | |
| if (document.pointerLockElement) { | |
| document.exitPointerLock(); | |
| } | |
| isMouseLocked = false; | |
| orbitControls.enabled = true; | |
| // Hide crosshair | |
| if (crosshair) crosshair.style.display = 'none'; | |
| // Clear selection | |
| if (outlinePass) outlinePass.selectedObjects = []; | |
| selectedObject = null; | |
| selectedObjectId = null; | |
| } | |
| } | |
| function createPhysicsShape(objType, scale) { | |
| // Create Cannon.js physics shape based on object type | |
| switch (objType) { | |
| case 'cube': | |
| return new CANNON.Box(new CANNON.Vec3(scale.x / 2, scale.y / 2, scale.z / 2)); | |
| case 'sphere': | |
| return new CANNON.Sphere(scale.x); | |
| case 'cylinder': | |
| return new CANNON.Cylinder(scale.x, scale.x, scale.y, 8); | |
| case 'plane': | |
| // For planes, create a thin box | |
| return new CANNON.Box(new CANNON.Vec3(scale.x / 2, 0.01, scale.y / 2)); | |
| case 'cone': | |
| // Approximate cone with cylinder (Cannon doesn't have cone shape) | |
| return new CANNON.Cylinder(0, scale.x, scale.y, 8); | |
| case 'torus': | |
| // Approximate torus with sphere | |
| return new CANNON.Sphere(scale.x); | |
| default: | |
| // Default to box | |
| return new CANNON.Box(new CANNON.Vec3(scale.x / 2, scale.y / 2, scale.z / 2)); | |
| } | |
| } | |
| function renderGameObjects() { | |
| sceneData.objects.forEach(obj => { | |
| // Validate object is within bounds | |
| if (Math.abs(obj.position.x) > WORLD_HALF || Math.abs(obj.position.z) > WORLD_HALF) { | |
| console.warn(`Object ${obj.name} at (${obj.position.x}, ${obj.position.z}) is outside 10x10 world bounds - skipping`); | |
| return; | |
| } | |
| let geometry, mesh; | |
| // Create geometry based on type | |
| switch (obj.type) { | |
| case 'cube': | |
| geometry = new THREE.BoxGeometry( | |
| obj.scale.x, | |
| obj.scale.y, | |
| obj.scale.z | |
| ); | |
| break; | |
| case 'sphere': | |
| geometry = new THREE.SphereGeometry(obj.scale.x, 32, 32); | |
| break; | |
| case 'cylinder': | |
| geometry = new THREE.CylinderGeometry( | |
| obj.scale.x, | |
| obj.scale.x, | |
| obj.scale.y, | |
| 32 | |
| ); | |
| break; | |
| case 'plane': | |
| geometry = new THREE.PlaneGeometry(obj.scale.x, obj.scale.y); | |
| break; | |
| case 'cone': | |
| geometry = new THREE.ConeGeometry(obj.scale.x, obj.scale.y, 32); | |
| break; | |
| case 'torus': | |
| geometry = new THREE.TorusGeometry(obj.scale.x, obj.scale.x * 0.4, 16, 100); | |
| break; | |
| case 'model': | |
| // Load GLB/GLTF model asynchronously | |
| if (obj.model_path) { | |
| const loader = new GLTFLoader(); | |
| // Use static_base_url from scene data for correct server | |
| const staticBase = sceneData.static_base_url || ''; | |
| const modelUrl = staticBase + obj.model_path; | |
| loader.load( | |
| modelUrl, | |
| (gltf) => { | |
| const model = gltf.scene; | |
| model.position.set(obj.position.x, obj.position.y, obj.position.z); | |
| model.scale.set(obj.scale.x, obj.scale.y, obj.scale.z); | |
| model.rotation.set( | |
| THREE.MathUtils.degToRad(obj.rotation.x), | |
| THREE.MathUtils.degToRad(obj.rotation.y), | |
| THREE.MathUtils.degToRad(obj.rotation.z) | |
| ); | |
| // Apply unlit material if specified in metadata | |
| // This makes the model render at its true colors without lighting | |
| if (obj.metadata?.unlit) { | |
| model.traverse((child) => { | |
| if (child.isMesh && child.material) { | |
| // Preserve the original texture/color but make it unlit | |
| const oldMaterial = child.material; | |
| const newMaterial = new THREE.MeshBasicMaterial(); | |
| // Copy texture if exists | |
| if (oldMaterial.map) { | |
| newMaterial.map = oldMaterial.map; | |
| } | |
| // Copy color if no texture | |
| if (oldMaterial.color) { | |
| newMaterial.color = oldMaterial.color.clone(); | |
| } | |
| // Preserve transparency | |
| if (oldMaterial.transparent) { | |
| newMaterial.transparent = true; | |
| newMaterial.opacity = oldMaterial.opacity; | |
| } | |
| if (oldMaterial.alphaMap) { | |
| newMaterial.alphaMap = oldMaterial.alphaMap; | |
| } | |
| child.material = newMaterial; | |
| } | |
| }); | |
| } | |
| model.userData = { | |
| id: obj.id, | |
| name: obj.name, | |
| type: 'model', | |
| isSceneObject: true, | |
| animate: obj.metadata?.animate || false, | |
| baseY: obj.metadata?.baseY || obj.position.y, | |
| unlit: obj.metadata?.unlit || false, | |
| }; | |
| scene.add(model); | |
| // Track animated models | |
| if (obj.metadata?.animate) { | |
| animatedModels.push(model); | |
| } | |
| }, | |
| undefined, | |
| (error) => console.error(`Failed to load model ${modelUrl}:`, error) | |
| ); | |
| } | |
| return; // Skip the rest of the geometry/material creation | |
| default: | |
| console.warn('Unknown object type:', obj.type); | |
| return; | |
| } | |
| // Create material | |
| const material = new THREE.MeshStandardMaterial({ | |
| color: obj.material.color, | |
| metalness: obj.material.metalness || 0.5, | |
| roughness: obj.material.roughness || 0.5, | |
| opacity: obj.material.opacity || 1.0, | |
| transparent: obj.material.opacity < 1.0, | |
| wireframe: obj.material.wireframe || false, | |
| }); | |
| // Create mesh | |
| mesh = new THREE.Mesh(geometry, material); | |
| // Set position | |
| mesh.position.set(obj.position.x, obj.position.y, obj.position.z); | |
| // Set rotation (convert degrees to radians) | |
| mesh.rotation.set( | |
| THREE.MathUtils.degToRad(obj.rotation.x), | |
| THREE.MathUtils.degToRad(obj.rotation.y), | |
| THREE.MathUtils.degToRad(obj.rotation.z) | |
| ); | |
| // Store metadata | |
| mesh.userData = { | |
| id: obj.id, | |
| name: obj.name, | |
| type: obj.type, | |
| isSceneObject: true, | |
| }; | |
| scene.add(mesh); | |
| // Create physics body for collision | |
| const physicsShape = createPhysicsShape(obj.type, obj.scale); | |
| const physicsBody = new CANNON.Body({ | |
| mass: 0, // Static objects | |
| position: new CANNON.Vec3(obj.position.x, obj.position.y, obj.position.z), | |
| }); | |
| physicsBody.addShape(physicsShape); | |
| // Apply rotation to physics body | |
| const quaternion = new CANNON.Quaternion(); | |
| quaternion.setFromEuler( | |
| THREE.MathUtils.degToRad(obj.rotation.x), | |
| THREE.MathUtils.degToRad(obj.rotation.y), | |
| THREE.MathUtils.degToRad(obj.rotation.z), | |
| 'XYZ' | |
| ); | |
| physicsBody.quaternion.copy(quaternion); | |
| physicsWorld.addBody(physicsBody); | |
| objectBodies.set(obj.id, physicsBody); | |
| }); | |
| } | |
| function onWindowResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| // Update composer | |
| if (composer) { | |
| composer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| } | |
| function updateLookedAtObject() { | |
| if (controlMode !== 'fps') { | |
| if (selectedObject) { | |
| outlinePass.selectedObjects = []; | |
| selectedObject = null; | |
| selectedObjectId = null; | |
| } | |
| return; | |
| } | |
| // Raycast from camera center (crosshair position) | |
| const raycaster = new THREE.Raycaster(); | |
| raycaster.setFromCamera(new THREE.Vector2(0, 0), camera); // Center of screen | |
| const selectableObjects = scene.children.filter(obj => obj.userData.isSceneObject && obj.userData.id); | |
| const intersects = raycaster.intersectObjects(selectableObjects); | |
| if (intersects.length > 0 && intersects[0].distance < MAX_SELECT_DISTANCE) { | |
| const newSelected = intersects[0].object; | |
| if (newSelected !== selectedObject) { | |
| selectedObject = newSelected; | |
| selectedObjectId = newSelected.userData.id; | |
| outlinePass.selectedObjects = [selectedObject]; | |
| // Send selection to parent (for chat commands) | |
| if (window.parent) { | |
| window.parent.postMessage({ | |
| action: 'objectSelected', | |
| data: { | |
| object_id: selectedObjectId, | |
| object_type: newSelected.userData.type, | |
| distance: intersects[0].distance.toFixed(2) | |
| } | |
| }, '*'); | |
| } | |
| } | |
| } else { | |
| // No object in view | |
| if (selectedObject) { | |
| outlinePass.selectedObjects = []; | |
| selectedObject = null; | |
| selectedObjectId = null; | |
| // Notify deselection | |
| if (window.parent) { | |
| window.parent.postMessage({ | |
| action: 'objectDeselected', | |
| data: {} | |
| }, '*'); | |
| } | |
| } | |
| } | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| // Update stats | |
| if (stats) stats.begin(); | |
| const time = performance.now(); | |
| const delta = (time - prevTime) / 1000; | |
| // Step physics simulation | |
| if (physicsWorld) { | |
| physicsWorld.step(1/60, delta, 3); | |
| } | |
| if (controlMode === 'fps' && playerBody) { | |
| // Apply camera rotation from mouse-look | |
| camera.rotation.order = 'YXZ'; // Ensure correct rotation order | |
| camera.rotation.y = cameraRotationY; | |
| camera.rotation.x = cameraRotationX; | |
| camera.rotation.z = 0; | |
| // Get input direction from keyboard | |
| direction.z = Number(moveForward.value) - Number(moveBackward.value); | |
| direction.x = Number(moveRight.value) - Number(moveLeft.value); | |
| direction.y = 0; // No vertical movement from input | |
| direction.normalize(); | |
| // Calculate movement direction relative to camera orientation | |
| const forward = new THREE.Vector3(0, 0, -1); | |
| const right = new THREE.Vector3(1, 0, 0); | |
| // Apply camera rotation to get movement direction | |
| forward.applyQuaternion(camera.quaternion); | |
| right.applyQuaternion(camera.quaternion); | |
| // Project to horizontal plane | |
| forward.y = 0; | |
| right.y = 0; | |
| forward.normalize(); | |
| right.normalize(); | |
| // Apply movement to physics body (keep current Y velocity for gravity/jump) | |
| // Reduce effectiveness when airborne based on air control setting | |
| const controlFactor = isGrounded ? 1.0 : airControl; | |
| const moveX = (direction.x * right.x + direction.z * forward.x) * moveSpeed * controlFactor; | |
| const moveZ = (direction.x * right.z + direction.z * forward.z) * moveSpeed * controlFactor; | |
| playerBody.velocity.x = moveX; | |
| playerBody.velocity.z = moveZ; | |
| // Don't modify playerBody.velocity.y - let physics handle gravity and jumping | |
| // Check if grounded using a small raycast downward | |
| const groundRaycaster = new THREE.Raycaster( | |
| new THREE.Vector3(playerBody.position.x, playerBody.position.y, playerBody.position.z), | |
| new THREE.Vector3(0, -1, 0), | |
| 0, | |
| PLAYER_HEIGHT / 2 + 0.1 | |
| ); | |
| const groundIntersects = groundRaycaster.intersectObjects( | |
| scene.children.filter(obj => obj.userData.isGround || obj.userData.isWall || obj.userData.isSceneObject) | |
| ); | |
| if (groundIntersects.length > 0) { | |
| isGrounded = true; | |
| canJump = true; | |
| } else { | |
| isGrounded = false; | |
| } | |
| // Sync camera position to physics body (at eye height) | |
| camera.position.x = playerBody.position.x; | |
| camera.position.y = playerBody.position.y - PLAYER_HEIGHT / 2 + EYE_HEIGHT; | |
| camera.position.z = playerBody.position.z; | |
| prevTime = time; | |
| } else if (controlMode === 'orbit') { | |
| // Orbit controls | |
| orbitControls.update(); | |
| } | |
| // Update looked-at object (FPS mode only) | |
| updateLookedAtObject(); | |
| // Update crosshair floor intersection and send to parent | |
| updateCrosshairPosition(); | |
| // Update particle systems | |
| updateParticleSystems(delta); | |
| // Update animated models (rotate + bob up/down) | |
| updateAnimatedModels(time); | |
| // Render using composer (for outlines) instead of direct renderer | |
| if (composer) { | |
| composer.render(); | |
| } else { | |
| renderer.render(scene, camera); | |
| } | |
| // End stats measurement | |
| if (stats) stats.end(); | |
| } | |
| // ==================== Crosshair Position Tracking ==================== | |
| let lastCrosshairPosition = null; | |
| let crosshairUpdateThrottle = 0; | |
| /** | |
| * Update crosshair position by projecting ray to y=0 plane | |
| * This allows the chat to know where to place objects when user doesn't specify position | |
| */ | |
| function updateCrosshairPosition() { | |
| // Throttle updates to every 10 frames (~6 times per second at 60fps) | |
| crosshairUpdateThrottle++; | |
| if (crosshairUpdateThrottle < 10) return; | |
| crosshairUpdateThrottle = 0; | |
| // Only track in FPS mode | |
| if (controlMode !== 'fps') { | |
| if (lastCrosshairPosition !== null) { | |
| lastCrosshairPosition = null; | |
| window.parent.postMessage({ | |
| action: 'crosshairPosition', | |
| data: null | |
| }, '*'); | |
| } | |
| return; | |
| } | |
| // Get ray from camera center (crosshair) | |
| const crosshairRaycaster = new THREE.Raycaster(); | |
| crosshairRaycaster.setFromCamera(new THREE.Vector2(0, 0), camera); | |
| // Calculate intersection with y=0 plane (floor level) | |
| // Ray: P = origin + t * direction | |
| // Plane y=0: solve for t where origin.y + t * direction.y = 0 | |
| const origin = crosshairRaycaster.ray.origin; | |
| const direction = crosshairRaycaster.ray.direction; | |
| // Avoid division by zero (looking perfectly horizontal) | |
| if (Math.abs(direction.y) < 0.0001) { | |
| // Looking horizontal - project forward at a fixed distance | |
| const distance = 10; | |
| const newPosition = { | |
| x: Math.round((origin.x + direction.x * distance) * 100) / 100, | |
| y: 0, | |
| z: Math.round((origin.z + direction.z * distance) * 100) / 100 | |
| }; | |
| sendCrosshairUpdate(newPosition); | |
| return; | |
| } | |
| const t = -origin.y / direction.y; | |
| // If t is negative, ray is pointing away from floor (looking up) | |
| // Still calculate the position as if projected through | |
| const intersectX = origin.x + direction.x * Math.abs(t); | |
| const intersectZ = origin.z + direction.z * Math.abs(t); | |
| // Clamp to world bounds | |
| const clampedX = Math.max(-WORLD_HALF + 1, Math.min(WORLD_HALF - 1, intersectX)); | |
| const clampedZ = Math.max(-WORLD_HALF + 1, Math.min(WORLD_HALF - 1, intersectZ)); | |
| const newPosition = { | |
| x: Math.round(clampedX * 100) / 100, | |
| y: 0, | |
| z: Math.round(clampedZ * 100) / 100 | |
| }; | |
| sendCrosshairUpdate(newPosition); | |
| } | |
| function sendCrosshairUpdate(newPosition) { | |
| // Only send update if position changed significantly | |
| if (!lastCrosshairPosition || | |
| Math.abs(newPosition.x - lastCrosshairPosition.x) > 0.1 || | |
| Math.abs(newPosition.z - lastCrosshairPosition.z) > 0.1) { | |
| lastCrosshairPosition = newPosition; | |
| window.parent.postMessage({ | |
| action: 'crosshairPosition', | |
| data: newPosition | |
| }, '*'); | |
| } | |
| } | |
| // ==================== Scene Control Functions ==================== | |
| /** | |
| * Toggle grid helper visibility | |
| */ | |
| function toggleGrid(enabled) { | |
| if (gridHelper) gridHelper.visible = enabled; | |
| } | |
| function toggleWireframe(enabled) { | |
| wireframeEnabled = enabled; | |
| scene.traverse((object) => { | |
| if (object.isMesh && object.material) { | |
| object.material.wireframe = enabled; | |
| } | |
| }); | |
| } | |
| function toggleStats(enabled) { | |
| if (stats) stats.dom.style.display = enabled ? 'block' : 'none'; | |
| } | |
| /** | |
| * Capture screenshot of the current scene | |
| */ | |
| function captureScreenshot() { | |
| if (!renderer) { | |
| console.error('Renderer not initialized'); | |
| return; | |
| } | |
| // Render one more frame to ensure we have the latest scene | |
| renderer.render(scene, camera); | |
| // Get the canvas data as PNG | |
| const dataURL = renderer.domElement.toDataURL('image/png'); | |
| // Send screenshot data back to parent window | |
| window.parent.postMessage({ | |
| action: 'screenshot', | |
| data: { | |
| dataURL: dataURL, | |
| timestamp: Date.now(), | |
| sceneName: sceneData?.name || 'scene' | |
| } | |
| }, '*'); | |
| } | |
| /** | |
| * Handle object click for inspection | |
| */ | |
| function onObjectClick(event) { | |
| // Calculate mouse position in normalized device coordinates (-1 to +1) | |
| const rect = renderer.domElement.getBoundingClientRect(); | |
| mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; | |
| mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; | |
| // Update raycaster | |
| raycaster.setFromCamera(mouse, camera); | |
| // Find intersections (only check mesh objects, not lights or helpers) | |
| const intersects = raycaster.intersectObjects(scene.children.filter(obj => obj.isMesh)); | |
| if (intersects.length > 0) { | |
| const clickedObject = intersects[0].object; | |
| // Deselect previous object | |
| if (selectedObject && selectedObject !== clickedObject) { | |
| if (selectedObject.userData.originalColor) { | |
| selectedObject.material.emissive.copy(selectedObject.userData.originalColor); | |
| selectedObject.material.emissiveIntensity = 0; | |
| } | |
| } | |
| // Select new object | |
| selectedObject = clickedObject; | |
| // Highlight selected object with persistent glow | |
| if (!selectedObject.userData.originalColor) { | |
| selectedObject.userData.originalColor = selectedObject.material.emissive.clone(); | |
| } | |
| selectedObject.material.emissive = new THREE.Color(0x4444ff); | |
| selectedObject.material.emissiveIntensity = 0.3; | |
| // Get object properties | |
| const objectInfo = { | |
| id: clickedObject.userData.id, | |
| name: clickedObject.userData.name || 'Unnamed Object', | |
| type: clickedObject.userData.type || 'unknown', | |
| position: { | |
| x: clickedObject.position.x.toFixed(2), | |
| y: clickedObject.position.y.toFixed(2), | |
| z: clickedObject.position.z.toFixed(2) | |
| }, | |
| rotation: { | |
| x: THREE.MathUtils.radToDeg(clickedObject.rotation.x).toFixed(2), | |
| y: THREE.MathUtils.radToDeg(clickedObject.rotation.y).toFixed(2), | |
| z: THREE.MathUtils.radToDeg(clickedObject.rotation.z).toFixed(2) | |
| }, | |
| scale: { | |
| x: clickedObject.scale.x.toFixed(2), | |
| y: clickedObject.scale.y.toFixed(2), | |
| z: clickedObject.scale.z.toFixed(2) | |
| }, | |
| color: '#' + clickedObject.material.color.getHexString() | |
| }; | |
| // Send object info to parent window | |
| window.parent.postMessage({ | |
| action: 'objectInspect', | |
| data: objectInfo | |
| }, '*'); | |
| } | |
| } | |
| // ==================== PostMessage API for Dynamic Updates ==================== | |
| /** | |
| * Listen for messages from parent window (Gradio) to update scene dynamically | |
| * This eliminates the need for iframe reloads | |
| */ | |
| window.addEventListener('message', (event) => { | |
| // Security: verify origin in production | |
| // if (event.origin !== window.location.origin) return; | |
| const { action, data } = event.data; | |
| switch (action) { | |
| case 'addObject': | |
| handleAddObject(data); | |
| break; | |
| case 'removeObject': | |
| handleRemoveObject(data); | |
| break; | |
| case 'setLighting': | |
| handleSetLighting(data); | |
| break; | |
| case 'updateScene': | |
| handleUpdateScene(data); | |
| break; | |
| case 'setControlMode': | |
| setControlMode(data.mode); | |
| break; | |
| case 'toggleGrid': | |
| toggleGrid(data.enabled); | |
| break; | |
| case 'toggleWireframe': | |
| toggleWireframe(data.enabled); | |
| break; | |
| case 'toggleStats': | |
| toggleStats(data.enabled); | |
| break; | |
| case 'takeScreenshot': | |
| captureScreenshot(); | |
| break; | |
| case 'addLight': | |
| addLightToScene(data); | |
| break; | |
| case 'removeLight': | |
| removeLightFromScene(data.light_name); | |
| break; | |
| case 'updateLight': | |
| updateSceneLight(data.light_name, data); | |
| break; | |
| case 'updateMaterial': | |
| updateObjectMaterial(data.object_id, data); | |
| break; | |
| case 'setBackground': | |
| setSceneBackground(data); | |
| break; | |
| case 'setFog': | |
| setSceneFog(data); | |
| break; | |
| // Player configuration actions | |
| case 'setPlayerSpeed': | |
| moveSpeed = data.walk_speed || 5.0; | |
| break; | |
| case 'setJumpForce': | |
| JUMP_FORCE = data.jump_force || 5.0; | |
| break; | |
| case 'setGravity': | |
| if (physicsWorld) physicsWorld.gravity.set(0, data.gravity || -9.82, 0); | |
| break; | |
| case 'setCameraFov': | |
| if (camera) { | |
| camera.fov = data.fov || 75; | |
| camera.updateProjectionMatrix(); | |
| } | |
| break; | |
| case 'setMouseSensitivity': | |
| mouseSensitivity = data.sensitivity || 0.002; | |
| if (data.invert_y !== undefined) invertY = data.invert_y; | |
| break; | |
| case 'setPlayerDimensions': | |
| if (data.height) PLAYER_HEIGHT = data.height; | |
| if (data.radius) PLAYER_RADIUS = data.radius; | |
| break; | |
| // Skybox actions | |
| case 'addSkybox': | |
| handleAddSkybox(data); | |
| break; | |
| case 'removeSkybox': | |
| handleRemoveSkybox(); | |
| break; | |
| // Particle actions | |
| case 'addParticles': | |
| handleAddParticles(data); | |
| break; | |
| case 'removeParticles': | |
| handleRemoveParticles(data.particle_id); | |
| break; | |
| // UI actions | |
| case 'renderText': | |
| handleRenderText(data); | |
| break; | |
| case 'renderBar': | |
| handleRenderBar(data); | |
| break; | |
| case 'removeUIElement': | |
| handleRemoveUIElement(data.element_id); | |
| break; | |
| // Toon shading | |
| case 'updateToonMaterial': | |
| handleUpdateToonMaterial(data); | |
| break; | |
| // Brick blocks | |
| case 'addBrick': | |
| handleAddBrick(data); | |
| break; | |
| default: | |
| console.warn('Unknown postMessage action:', action); | |
| } | |
| }); | |
| /** | |
| * Calculate spawn position in front of camera (Minecraft-style placement) | |
| * Distance is calculated based on object size so larger objects spawn further away | |
| * | |
| * @param {Object} scale - Object scale {x, y, z} | |
| * @param {boolean} snapToGround - Whether to snap object to ground plane | |
| * @returns {THREE.Vector3} - The calculated spawn position | |
| */ | |
| function getForwardSpawnPosition(scale = {x: 1, y: 1, z: 1}, snapToGround = true) { | |
| const dir = new THREE.Vector3(); | |
| camera.getWorldDirection(dir); | |
| // Calculate object's bounding size (largest dimension) | |
| const objectSize = Math.max(scale.x || 1, scale.y || 1, scale.z || 1); | |
| // Base distance + object size + small buffer | |
| // This ensures the object spawns in front of player without clipping | |
| const baseDistance = 2.0; // Minimum distance from player | |
| const sizeMultiplier = 1.2; // Extra padding based on size | |
| const distance = baseDistance + (objectSize * sizeMultiplier); | |
| // Start from camera position and move forward | |
| const spawnPos = new THREE.Vector3() | |
| .copy(camera.position) | |
| .add(dir.clone().multiplyScalar(distance)); | |
| // Snap to ground if requested | |
| if (snapToGround) { | |
| // Cast ray downward to find ground | |
| const downRay = new THREE.Raycaster( | |
| new THREE.Vector3(spawnPos.x, spawnPos.y + 20, spawnPos.z), // Start above | |
| new THREE.Vector3(0, -1, 0) // Point down | |
| ); | |
| // Find ground mesh (look for the floor plane) | |
| const groundObjects = scene.children.filter(obj => | |
| obj.userData.isGround || obj.name === 'ground' || obj.name === 'floor' | |
| ); | |
| if (groundObjects.length > 0) { | |
| const hits = downRay.intersectObjects(groundObjects); | |
| if (hits.length > 0) { | |
| spawnPos.y = hits[0].point.y; | |
| } else { | |
| spawnPos.y = 0; | |
| } | |
| } else { | |
| spawnPos.y = 0; | |
| } | |
| } | |
| return spawnPos; | |
| } | |
| /** | |
| * Dynamically add an object to the scene | |
| */ | |
| function handleAddObject(objData) { | |
| if (!scene || !sceneData) { | |
| console.error('Scene not initialized yet'); | |
| return; | |
| } | |
| // If position is at origin (0,0,0) or use_camera_position flag is set, | |
| // spawn in front of the camera (Minecraft-style) | |
| const isDefaultPosition = | |
| objData.position.x === 0 && | |
| objData.position.y === 0 && | |
| objData.position.z === 0; | |
| if (objData.use_camera_position || isDefaultPosition) { | |
| const scale = objData.scale || {x: 1, y: 1, z: 1}; | |
| const spawnPos = getForwardSpawnPosition(scale, true); | |
| // Offset Y by half the object height so it sits on ground | |
| const halfHeight = (scale.y || 1) / 2; | |
| spawnPos.y += halfHeight; | |
| objData.position = { | |
| x: spawnPos.x, | |
| y: spawnPos.y, | |
| z: spawnPos.z | |
| }; | |
| } | |
| // Validate object is within bounds | |
| if (Math.abs(objData.position.x) > WORLD_HALF || Math.abs(objData.position.z) > WORLD_HALF) { | |
| console.error(`Cannot add object at (${objData.position.x}, ${objData.position.z}) - outside world bounds`); | |
| return; | |
| } | |
| // Add to scene data | |
| sceneData.objects.push(objData); | |
| // Create and add to Three.js scene | |
| let geometry; | |
| switch (objData.type) { | |
| case 'cube': | |
| geometry = new THREE.BoxGeometry(objData.scale.x, objData.scale.y, objData.scale.z); | |
| break; | |
| case 'sphere': | |
| geometry = new THREE.SphereGeometry(objData.scale.x, 32, 32); | |
| break; | |
| case 'cylinder': | |
| geometry = new THREE.CylinderGeometry(objData.scale.x, objData.scale.x, objData.scale.y, 32); | |
| break; | |
| case 'plane': | |
| geometry = new THREE.PlaneGeometry(objData.scale.x, objData.scale.y); | |
| break; | |
| case 'cone': | |
| geometry = new THREE.ConeGeometry(objData.scale.x, objData.scale.y, 32); | |
| break; | |
| case 'torus': | |
| geometry = new THREE.TorusGeometry(objData.scale.x, objData.scale.x * 0.4, 16, 100); | |
| break; | |
| default: | |
| console.warn('Unknown object type:', objData.type); | |
| return; | |
| } | |
| const material = new THREE.MeshStandardMaterial({ | |
| color: objData.material.color, | |
| metalness: objData.material.metalness || 0.5, | |
| roughness: objData.material.roughness || 0.5, | |
| opacity: objData.material.opacity || 1.0, | |
| transparent: objData.material.opacity < 1.0, | |
| wireframe: wireframeEnabled || objData.material.wireframe || false, | |
| }); | |
| const mesh = new THREE.Mesh(geometry, material); | |
| mesh.position.set(objData.position.x, objData.position.y, objData.position.z); | |
| mesh.rotation.set( | |
| THREE.MathUtils.degToRad(objData.rotation.x), | |
| THREE.MathUtils.degToRad(objData.rotation.y), | |
| THREE.MathUtils.degToRad(objData.rotation.z) | |
| ); | |
| mesh.userData = { | |
| id: objData.id, | |
| name: objData.name, | |
| type: objData.type, | |
| isSceneObject: true, | |
| }; | |
| scene.add(mesh); | |
| // Create physics body for collision | |
| const physicsShape = createPhysicsShape(objData.type, objData.scale); | |
| const physicsBody = new CANNON.Body({ | |
| mass: 0, // Static objects | |
| position: new CANNON.Vec3(objData.position.x, objData.position.y, objData.position.z), | |
| }); | |
| physicsBody.addShape(physicsShape); | |
| // Apply rotation to physics body | |
| const quaternion = new CANNON.Quaternion(); | |
| quaternion.setFromEuler( | |
| THREE.MathUtils.degToRad(objData.rotation.x), | |
| THREE.MathUtils.degToRad(objData.rotation.y), | |
| THREE.MathUtils.degToRad(objData.rotation.z), | |
| 'XYZ' | |
| ); | |
| physicsBody.quaternion.copy(quaternion); | |
| physicsWorld.addBody(physicsBody); | |
| objectBodies.set(objData.id, physicsBody); | |
| // Add highlight effect | |
| animateObjectHighlight(mesh); | |
| } | |
| /** | |
| * Dynamically remove an object from the scene | |
| */ | |
| function handleRemoveObject(data) { | |
| if (!scene || !sceneData) { | |
| console.error('Scene not initialized yet'); | |
| return; | |
| } | |
| const { object_id } = data; | |
| // Remove from Three.js scene | |
| const objectToRemove = scene.children.find(obj => obj.userData && obj.userData.id === object_id); | |
| if (objectToRemove) { | |
| scene.remove(objectToRemove); | |
| if (objectToRemove.geometry) objectToRemove.geometry.dispose(); | |
| if (objectToRemove.material) objectToRemove.material.dispose(); | |
| } | |
| // Remove physics body | |
| const physicsBody = objectBodies.get(object_id); | |
| if (physicsBody) { | |
| physicsWorld.removeBody(physicsBody); | |
| objectBodies.delete(object_id); | |
| } | |
| // Remove from scene data | |
| sceneData.objects = sceneData.objects.filter(obj => obj.id !== object_id); | |
| } | |
| /** | |
| * Dynamically update lighting | |
| */ | |
| function handleSetLighting(data) { | |
| if (!scene || !sceneData) { | |
| console.error('Scene not initialized yet'); | |
| return; | |
| } | |
| const { lights } = data; | |
| // Remove all existing lights | |
| const lightsToRemove = scene.children.filter(obj => obj.isLight); | |
| lightsToRemove.forEach(light => scene.remove(light)); | |
| // Add new lights | |
| lights.forEach(lightData => { | |
| let light; | |
| if (lightData.type === 'ambient') { | |
| light = new THREE.AmbientLight(lightData.color, lightData.intensity); | |
| } else if (lightData.type === 'directional') { | |
| light = new THREE.DirectionalLight(lightData.color, lightData.intensity); | |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); | |
| light.target.position.set(0, 0, 0); | |
| scene.add(light.target); | |
| if (lightData.cast_shadow) { | |
| light.castShadow = true; | |
| } | |
| } else if (lightData.type === 'point') { | |
| light = new THREE.PointLight(lightData.color, lightData.intensity); | |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); | |
| } | |
| if (light) { | |
| scene.add(light); | |
| } | |
| }); | |
| // Update scene data | |
| sceneData.lights = lights; | |
| } | |
| /** | |
| * Fully reload the scene from new data | |
| */ | |
| function handleUpdateScene(data) { | |
| // For major updates, we can reload the entire scene | |
| // This is a fallback for complex changes | |
| location.reload(); | |
| } | |
| /** | |
| * Animate object highlight with enhanced color pulse and scale effect | |
| */ | |
| function animateObjectHighlight(mesh) { | |
| const originalColor = mesh.material.color.clone(); | |
| const originalScale = mesh.scale.clone(); | |
| // Color pulse sequence: yellow -> cyan -> yellow | |
| const pulseColors = [ | |
| new THREE.Color(0xffff00), // Yellow | |
| new THREE.Color(0x00ffff), // Cyan | |
| new THREE.Color(0xffff00), // Yellow | |
| ]; | |
| let progress = 0; | |
| const duration = 90; // frames (1.5 seconds at 60fps) | |
| const pulseIntensity = 0.6; // How much to mix highlight colors | |
| function animateHighlight() { | |
| if (progress < duration) { | |
| // Calculate normalized progress (0 to 1) | |
| const t = progress / duration; | |
| // Color animation - cycle through pulse colors | |
| const colorPhase = t * (pulseColors.length - 1); | |
| const colorIndex = Math.floor(colorPhase); | |
| const colorBlend = colorPhase - colorIndex; | |
| if (colorIndex < pulseColors.length - 1) { | |
| const color1 = pulseColors[colorIndex]; | |
| const color2 = pulseColors[colorIndex + 1]; | |
| const blendedColor = color1.clone().lerp(color2, colorBlend); | |
| // Mix with original color using sine wave for smooth pulse | |
| const pulseMix = Math.sin(t * Math.PI) * pulseIntensity; | |
| mesh.material.color.lerpColors(originalColor, blendedColor, pulseMix); | |
| } | |
| // Scale animation - subtle "pop" effect | |
| const scaleAmount = 1.0 + Math.sin(t * Math.PI) * 0.15; // 15% scale increase | |
| mesh.scale.copy(originalScale).multiplyScalar(scaleAmount); | |
| // Increase emissive for glow effect | |
| if (mesh.material.emissive) { | |
| const emissiveIntensity = Math.sin(t * Math.PI * 2) * 0.3; | |
| mesh.material.emissiveIntensity = emissiveIntensity; | |
| } | |
| progress++; | |
| requestAnimationFrame(animateHighlight); | |
| } else { | |
| // Reset to original state | |
| mesh.material.color.copy(originalColor); | |
| mesh.scale.copy(originalScale); | |
| if (mesh.material.emissive) { | |
| mesh.material.emissiveIntensity = 0; | |
| } | |
| } | |
| } | |
| // Enable emissive if not already set | |
| if (!mesh.material.emissive) { | |
| mesh.material.emissive = new THREE.Color(originalColor); | |
| mesh.material.emissiveIntensity = 0; | |
| } | |
| animateHighlight(); | |
| } | |
| // ==================== Rendering & Lighting Handler Functions ==================== | |
| function addLightToScene(lightData) { | |
| let light; | |
| if (lightData.light_type === 'ambient') { | |
| light = new THREE.AmbientLight(lightData.color, lightData.intensity); | |
| } else if (lightData.light_type === 'directional') { | |
| light = new THREE.DirectionalLight(lightData.color, lightData.intensity); | |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); | |
| if (lightData.target) { | |
| light.target.position.set(lightData.target.x, lightData.target.y, lightData.target.z); | |
| scene.add(light.target); | |
| } | |
| if (lightData.cast_shadow) { | |
| light.castShadow = true; | |
| // Configure shadow map for better quality | |
| light.shadow.mapSize.width = 2048; | |
| light.shadow.mapSize.height = 2048; | |
| light.shadow.camera.near = 0.5; | |
| light.shadow.camera.far = 100; | |
| light.shadow.camera.left = -30; | |
| light.shadow.camera.right = 30; | |
| light.shadow.camera.top = 30; | |
| light.shadow.camera.bottom = -30; | |
| light.shadow.bias = -0.0001; | |
| } | |
| } else if (lightData.light_type === 'point') { | |
| // Point lights with distance and decay for realistic falloff | |
| const distance = lightData.distance || 50; | |
| const decay = lightData.decay || 2; | |
| light = new THREE.PointLight(lightData.color, lightData.intensity, distance, decay); | |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); | |
| } else if (lightData.light_type === 'spot') { | |
| light = new THREE.SpotLight(lightData.color, lightData.intensity); | |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); | |
| light.angle = THREE.MathUtils.degToRad(lightData.spot_angle || 45); | |
| if (lightData.target) { | |
| light.target.position.set(lightData.target.x, lightData.target.y, lightData.target.z); | |
| scene.add(light.target); | |
| } | |
| if (lightData.cast_shadow) { | |
| light.castShadow = true; | |
| light.shadow.mapSize.width = 1024; | |
| light.shadow.mapSize.height = 1024; | |
| } | |
| } else if (lightData.light_type === 'hemisphere') { | |
| // Hemisphere light - great for outdoor scenes (sky + ground colors) | |
| const skyColor = lightData.color || '#87CEEB'; | |
| const groundColor = lightData.ground_color || '#444444'; | |
| light = new THREE.HemisphereLight(skyColor, groundColor, lightData.intensity); | |
| if (lightData.position) { | |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); | |
| } | |
| } | |
| if (light) { | |
| light.name = lightData.name; | |
| scene.add(light); | |
| } else { | |
| console.error('Failed to create light:', lightData); | |
| } | |
| } | |
| function removeLightFromScene(lightName) { | |
| const light = scene.getObjectByName(lightName); | |
| if (light) scene.remove(light); | |
| } | |
| function updateSceneLight(lightName, updates) { | |
| const light = scene.getObjectByName(lightName); | |
| if (!light) return; | |
| if (updates.color) light.color.set(updates.color); | |
| if (updates.intensity !== undefined) light.intensity = updates.intensity; | |
| if (updates.position) { | |
| light.position.set(updates.position.x, updates.position.y, updates.position.z); | |
| } | |
| if (updates.cast_shadow !== undefined) light.castShadow = updates.cast_shadow; | |
| } | |
| function updateObjectMaterial(objectId, materialData) { | |
| const obj = scene.children.find(child => child.userData.id === objectId); | |
| if (!obj || !obj.material) return; | |
| if (materialData.color) obj.material.color.set(materialData.color); | |
| if (materialData.metalness !== undefined) obj.material.metalness = materialData.metalness; | |
| if (materialData.roughness !== undefined) obj.material.roughness = materialData.roughness; | |
| if (materialData.opacity !== undefined) { | |
| obj.material.opacity = materialData.opacity; | |
| obj.material.transparent = materialData.opacity < 1.0; | |
| } | |
| if (materialData.emissive) obj.material.emissive = new THREE.Color(materialData.emissive); | |
| if (materialData.emissive_intensity !== undefined) obj.material.emissiveIntensity = materialData.emissive_intensity; | |
| obj.material.needsUpdate = true; | |
| } | |
| function setSceneBackground(bgData) { | |
| if (bgData.background_type === 'gradient') { | |
| // Create gradient canvas | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = 2; | |
| canvas.height = 256; | |
| const ctx = canvas.getContext('2d'); | |
| const gradient = ctx.createLinearGradient(0, 0, 0, 256); | |
| gradient.addColorStop(0, bgData.background_gradient_top); | |
| gradient.addColorStop(1, bgData.background_gradient_bottom); | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(0, 0, 2, 256); | |
| const texture = new THREE.CanvasTexture(canvas); | |
| scene.background = texture; | |
| } else { | |
| scene.background = new THREE.Color(bgData.background_color); | |
| } | |
| } | |
| function setSceneFog(fogData) { | |
| if (!fogData.enabled) { | |
| scene.fog = null; | |
| return; | |
| } | |
| const color = new THREE.Color(fogData.color); | |
| if (fogData.type === 'exponential') { | |
| scene.fog = new THREE.FogExp2(color, fogData.density); | |
| } else { | |
| scene.fog = new THREE.Fog(color, fogData.near, fogData.far); | |
| } | |
| } | |
| // ==================== Skybox Handlers ==================== | |
| function handleAddSkybox(skyboxData) { | |
| // Remove existing skybox if any | |
| if (sky) { | |
| scene.remove(sky); | |
| } | |
| // Create Sky mesh | |
| sky = new Sky(); | |
| sky.scale.setScalar(450000); | |
| scene.add(sky); | |
| const skyUniforms = sky.material.uniforms; | |
| skyUniforms['turbidity'].value = skyboxData.turbidity || 10; | |
| skyUniforms['rayleigh'].value = skyboxData.rayleigh || 2; | |
| skyUniforms['mieCoefficient'].value = 0.005; | |
| skyUniforms['mieDirectionalG'].value = 0.8; | |
| // Calculate sun position from elevation and azimuth | |
| const phi = THREE.MathUtils.degToRad(90 - (skyboxData.sun_elevation || 45)); | |
| const theta = THREE.MathUtils.degToRad(skyboxData.sun_azimuth || 180); | |
| sun.setFromSphericalCoords(1, phi, theta); | |
| skyUniforms['sunPosition'].value.copy(sun); | |
| // Update scene background to use sky | |
| scene.background = null; // Sky will render as background | |
| } | |
| function handleRemoveSkybox() { | |
| if (sky) { | |
| scene.remove(sky); | |
| sky = null; | |
| } | |
| // Revert to solid background | |
| const bgColor = sceneData?.environment?.background_color || '#87CEEB'; | |
| scene.background = new THREE.Color(bgColor); | |
| } | |
| // ==================== Particle System Handlers ==================== | |
| function handleAddParticles(particleData) { | |
| const id = particleData.id || particleData.particle_id; | |
| // Remove existing particle system with same ID | |
| if (particleSystems.has(id)) { | |
| const existingSystem = particleSystems.get(id); | |
| scene.remove(existingSystem.points); | |
| particleSystems.delete(id); | |
| } | |
| const config = particleData; | |
| const count = config.count || 100; | |
| // Create particle geometry | |
| const geometry = new THREE.BufferGeometry(); | |
| const positions = new Float32Array(count * 3); | |
| const velocities = new Float32Array(count * 3); | |
| const lifetimes = new Float32Array(count); | |
| const spread = config.spread || 1.0; | |
| // Use forward spawn position if no position specified or default position | |
| // Backend defaults to {0, 1, 0} when no position is provided | |
| let pos = config.position || { x: 0, y: 0, z: 0 }; | |
| const isDefaultPosition = pos.x === 0 && pos.z === 0 && (pos.y === 0 || pos.y === 1); | |
| if (config.localized !== false && isDefaultPosition) { | |
| // For localized effects, spawn in front of player (don't snap to ground for particles) | |
| const spawnPos = getForwardSpawnPosition({x: spread, y: spread, z: spread}, false); | |
| pos = { x: spawnPos.x, y: spawnPos.y, z: spawnPos.z }; | |
| } | |
| for (let i = 0; i < count; i++) { | |
| const i3 = i * 3; | |
| if (config.localized !== false) { | |
| // Localized effect (fire, smoke, sparkle) | |
| positions[i3] = pos.x + (Math.random() - 0.5) * spread; | |
| positions[i3 + 1] = pos.y + Math.random() * spread; | |
| positions[i3 + 2] = pos.z + (Math.random() - 0.5) * spread; | |
| } else { | |
| // Weather effect (rain, snow) - covers world | |
| positions[i3] = (Math.random() - 0.5) * WORLD_SIZE * 2; | |
| positions[i3 + 1] = Math.random() * 20; | |
| positions[i3 + 2] = (Math.random() - 0.5) * WORLD_SIZE * 2; | |
| } | |
| const vel = config.velocity || { x: 0, y: 1, z: 0 }; | |
| velocities[i3] = vel.x + (Math.random() - 0.5) * 0.5; | |
| velocities[i3 + 1] = vel.y + (Math.random() - 0.5) * 0.5; | |
| velocities[i3 + 2] = vel.z + (Math.random() - 0.5) * 0.5; | |
| lifetimes[i] = Math.random() * (config.lifetime || 2.0); | |
| } | |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| // Create particle material | |
| const startColor = new THREE.Color(config.color_start || '#ffffff'); | |
| const material = new THREE.PointsMaterial({ | |
| size: config.size || 0.1, | |
| color: startColor, | |
| transparent: true, | |
| opacity: 0.8, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false | |
| }); | |
| const points = new THREE.Points(geometry, material); | |
| points.name = `particles_${id}`; | |
| scene.add(points); | |
| // Store particle system data for animation | |
| particleSystems.set(id, { | |
| points, | |
| geometry, | |
| velocities, | |
| lifetimes, | |
| config, | |
| maxLifetime: config.lifetime || 2.0, | |
| startColor, | |
| endColor: new THREE.Color(config.color_end || config.color_start || '#ffffff') | |
| }); | |
| } | |
| function handleRemoveParticles(particleId) { | |
| if (particleSystems.has(particleId)) { | |
| const system = particleSystems.get(particleId); | |
| scene.remove(system.points); | |
| system.geometry.dispose(); | |
| system.points.material.dispose(); | |
| particleSystems.delete(particleId); | |
| } | |
| } | |
| function updateAnimatedModels(time) { | |
| // Animate models with sine wave bobbing (no rotation) | |
| const timeInSeconds = time / 1000; | |
| animatedModels.forEach(model => { | |
| // Bob up and down with sine wave | |
| const baseY = model.userData.baseY || 1.5; | |
| model.position.y = baseY + Math.sin(timeInSeconds * 2) * 0.3; | |
| }); | |
| } | |
| function updateParticleSystems(delta) { | |
| particleSystems.forEach((system, id) => { | |
| const positions = system.geometry.attributes.position.array; | |
| const count = positions.length / 3; | |
| const config = system.config; | |
| const pos = config.position || { x: 0, y: 0, z: 0 }; | |
| const spread = config.spread || 1.0; | |
| for (let i = 0; i < count; i++) { | |
| const i3 = i * 3; | |
| // Update position based on velocity | |
| positions[i3] += system.velocities[i3] * delta; | |
| positions[i3 + 1] += system.velocities[i3 + 1] * delta; | |
| positions[i3 + 2] += system.velocities[i3 + 2] * delta; | |
| // Update lifetime | |
| system.lifetimes[i] += delta; | |
| // Reset particle if lifetime exceeded | |
| if (system.lifetimes[i] >= system.maxLifetime) { | |
| system.lifetimes[i] = 0; | |
| if (config.localized !== false) { | |
| positions[i3] = pos.x + (Math.random() - 0.5) * spread; | |
| positions[i3 + 1] = pos.y; | |
| positions[i3 + 2] = pos.z + (Math.random() - 0.5) * spread; | |
| } else { | |
| // Weather - respawn at top | |
| positions[i3] = (Math.random() - 0.5) * WORLD_SIZE * 2; | |
| positions[i3 + 1] = 20; | |
| positions[i3 + 2] = (Math.random() - 0.5) * WORLD_SIZE * 2; | |
| } | |
| } | |
| } | |
| system.geometry.attributes.position.needsUpdate = true; | |
| }); | |
| } | |
| // ==================== UI Overlay Handlers ==================== | |
| function ensureUIContainer() { | |
| if (!uiContainer) { | |
| uiContainer = document.createElement('div'); | |
| uiContainer.id = 'ui-overlay'; | |
| uiContainer.style.cssText = ` | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| z-index: 50; | |
| `; | |
| document.getElementById('viewer-container').appendChild(uiContainer); | |
| } | |
| } | |
| function handleRenderText(textData) { | |
| ensureUIContainer(); | |
| const id = textData.id || textData.text_id; | |
| // Remove existing element with same ID | |
| if (uiElements.has(id)) { | |
| uiContainer.removeChild(uiElements.get(id)); | |
| } | |
| const element = document.createElement('div'); | |
| element.id = `ui-${id}`; | |
| let bgStyle = ''; | |
| if (textData.background_color) { | |
| bgStyle = `background-color: ${textData.background_color}; padding: ${textData.padding || 8}px; border-radius: 4px;`; | |
| } | |
| element.style.cssText = ` | |
| position: absolute; | |
| left: ${textData.x}%; | |
| top: ${textData.y}%; | |
| transform: translate(-50%, 0); | |
| color: ${textData.color || '#ffffff'}; | |
| font-family: ${textData.font_family || 'Arial'}, sans-serif; | |
| font-size: ${textData.font_size || 24}px; | |
| text-align: ${textData.text_align || 'center'}; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.5); | |
| white-space: nowrap; | |
| ${bgStyle} | |
| `; | |
| element.textContent = textData.text; | |
| uiContainer.appendChild(element); | |
| uiElements.set(id, element); | |
| } | |
| function handleRenderBar(barData) { | |
| ensureUIContainer(); | |
| const id = barData.id || barData.bar_id; | |
| // Remove existing element with same ID | |
| if (uiElements.has(id)) { | |
| uiContainer.removeChild(uiElements.get(id)); | |
| } | |
| const percentage = barData.percentage || | |
| ((barData.value / barData.max_value) * 100); | |
| const container = document.createElement('div'); | |
| container.id = `ui-${id}`; | |
| container.style.cssText = ` | |
| position: absolute; | |
| left: ${barData.x}%; | |
| top: ${barData.y}%; | |
| `; | |
| // Add label if provided | |
| if (barData.label) { | |
| const label = document.createElement('div'); | |
| label.style.cssText = ` | |
| color: #ffffff; | |
| font-family: Arial, sans-serif; | |
| font-size: 14px; | |
| margin-bottom: 4px; | |
| text-shadow: 1px 1px 2px rgba(0,0,0,0.5); | |
| `; | |
| label.textContent = barData.label; | |
| container.appendChild(label); | |
| } | |
| // Create bar container | |
| const barContainer = document.createElement('div'); | |
| barContainer.style.cssText = ` | |
| width: ${barData.width || 200}px; | |
| height: ${barData.height || 20}px; | |
| background-color: ${barData.background_color || '#333333'}; | |
| border: 2px solid ${barData.border_color || '#ffffff'}; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| position: relative; | |
| `; | |
| // Create fill bar | |
| const fill = document.createElement('div'); | |
| fill.style.cssText = ` | |
| width: ${percentage}%; | |
| height: 100%; | |
| background-color: ${barData.bar_color || '#00ff00'}; | |
| transition: width 0.3s ease; | |
| `; | |
| barContainer.appendChild(fill); | |
| // Show value if requested | |
| if (barData.show_value) { | |
| const valueText = document.createElement('div'); | |
| valueText.style.cssText = ` | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: #ffffff; | |
| font-family: Arial, sans-serif; | |
| font-size: 12px; | |
| text-shadow: 1px 1px 2px rgba(0,0,0,0.8); | |
| `; | |
| valueText.textContent = `${Math.round(barData.value)}/${Math.round(barData.max_value)}`; | |
| barContainer.appendChild(valueText); | |
| } | |
| container.appendChild(barContainer); | |
| uiContainer.appendChild(container); | |
| uiElements.set(id, container); | |
| } | |
| function handleRemoveUIElement(elementId) { | |
| if (uiElements.has(elementId)) { | |
| uiContainer.removeChild(uiElements.get(elementId)); | |
| uiElements.delete(elementId); | |
| } | |
| } | |
| // ==================== Toon Material Handler ==================== | |
| function handleUpdateToonMaterial(data) { | |
| const obj = scene.children.find(child => | |
| child.userData.id === data.object_id || | |
| child.userData.object_id === data.object_id | |
| ); | |
| if (!obj) { | |
| console.error('Object not found for toon material:', data.object_id); | |
| return; | |
| } | |
| if (data.enabled !== false) { | |
| // Create toon material | |
| const existingColor = obj.material?.color?.getHex() || 0xffffff; | |
| const color = data.color ? new THREE.Color(data.color) : new THREE.Color(existingColor); | |
| // Create gradient texture for toon shading | |
| const steps = data.gradient_steps || 3; | |
| const gradientMap = createToonGradientMap(steps); | |
| const toonMaterial = new THREE.MeshToonMaterial({ | |
| color: color, | |
| gradientMap: gradientMap | |
| }); | |
| // Dispose old material | |
| if (obj.material) obj.material.dispose(); | |
| obj.material = toonMaterial; | |
| // Store toon settings in userData for reference | |
| obj.userData.toonEnabled = true; | |
| obj.userData.toonSettings = data; | |
| } else { | |
| // Revert to standard material | |
| const existingColor = obj.material?.color?.getHex() || 0xffffff; | |
| const standardMaterial = new THREE.MeshStandardMaterial({ | |
| color: existingColor, | |
| roughness: 0.7, | |
| metalness: 0.0 | |
| }); | |
| if (obj.material) obj.material.dispose(); | |
| obj.material = standardMaterial; | |
| obj.userData.toonEnabled = false; | |
| } | |
| } | |
| function createToonGradientMap(steps) { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = steps; | |
| canvas.height = 1; | |
| const ctx = canvas.getContext('2d'); | |
| for (let i = 0; i < steps; i++) { | |
| const value = Math.floor((i / (steps - 1)) * 255); | |
| ctx.fillStyle = `rgb(${value},${value},${value})`; | |
| ctx.fillRect(i, 0, 1, 1); | |
| } | |
| const texture = new THREE.CanvasTexture(canvas); | |
| texture.minFilter = THREE.NearestFilter; | |
| texture.magFilter = THREE.NearestFilter; | |
| return texture; | |
| } | |
| // ==================== Brick Block Handler ==================== | |
| function handleAddBrick(brickData) { | |
| if (!scene || !sceneData) { | |
| console.error('Scene not initialized yet'); | |
| return; | |
| } | |
| // Use static_base_url from scene data for correct server | |
| const staticBase = sceneData.static_base_url || ''; | |
| const modelPath = staticBase + brickData.model_path; | |
| const position = brickData.position || { x: 0, y: 0, z: 0 }; | |
| const rotation = brickData.rotation || { x: 0, y: 0, z: 0 }; | |
| const color = new THREE.Color(brickData.material?.color || '#ff0000'); | |
| // Load the GLTF model | |
| gltfLoader.load( | |
| modelPath, | |
| (gltf) => { | |
| const model = gltf.scene; | |
| // Apply position | |
| model.position.set(position.x, position.y, position.z); | |
| // Apply rotation (convert degrees to radians) | |
| model.rotation.set( | |
| THREE.MathUtils.degToRad(rotation.x), | |
| THREE.MathUtils.degToRad(rotation.y), | |
| THREE.MathUtils.degToRad(rotation.z) | |
| ); | |
| // Apply color to all meshes in the model | |
| model.traverse((child) => { | |
| if (child.isMesh) { | |
| child.material = new THREE.MeshStandardMaterial({ | |
| color: color, | |
| metalness: brickData.material?.metalness || 0.1, | |
| roughness: brickData.material?.roughness || 0.7 | |
| }); | |
| child.castShadow = true; | |
| child.receiveShadow = true; | |
| } | |
| }); | |
| // Store metadata | |
| model.userData.id = brickData.id; | |
| model.userData.type = 'brick'; | |
| model.userData.brick_type = brickData.brick_type; | |
| model.userData.name = brickData.name; | |
| // Add to scene | |
| scene.add(model); | |
| // Add to scene data for tracking | |
| if (!sceneData.objects) sceneData.objects = []; | |
| sceneData.objects.push(brickData); | |
| }, | |
| undefined, | |
| (error) => console.error('Error loading brick:', error) | |
| ); | |
| } | |
| // Start the application | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |