be3221e93d
- SPEC: add User Story 0 (Property Setup via Catastro) as P1 entry - SPEC: add catastro_data and catastro_imports tables to data model - PLAN: add Phase 2A (Catastro Integration) with 8 tasks - AGENTS.md: add 3 key decisions (Catastro SSOT, 3DGS hybrid, plan inputs) - Demo v3: Spanish apartment (C/ Altabix 24, 3ºB, Elche), 7 rooms, upload panel for catastro/DXF, proper collisions on all doors
1128 lines
37 KiB
HTML
1128 lines
37 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>InteriorScan — FPS Walkthrough Demo</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||
|
||
:root {
|
||
--bg: #0a0a0f;
|
||
--surface: #141420;
|
||
--surface-2: #1a1a2e;
|
||
--border: #2a2a3e;
|
||
--text: #e8e8f0;
|
||
--text-muted: #8888a0;
|
||
--primary: #6c5ce7;
|
||
--primary-hover: #7d6ff0;
|
||
--accent: #00d2ff;
|
||
--accent-2: #a855f7;
|
||
--success: #10b981;
|
||
--warning: #f59e0b;
|
||
--danger: #ef4444;
|
||
}
|
||
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: 'Inter', sans-serif;
|
||
overflow: hidden;
|
||
height: 100vh;
|
||
}
|
||
|
||
#app {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
}
|
||
|
||
/* ── Top Bar ──────────────────────────────────────── */
|
||
.topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 8px 16px;
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
z-index: 100;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.topbar-logo {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-weight: 700;
|
||
font-size: 16px;
|
||
color: var(--accent);
|
||
letter-spacing: -0.5px;
|
||
}
|
||
|
||
.topbar-logo svg {
|
||
width: 24px;
|
||
height: 24px;
|
||
}
|
||
|
||
.topbar-address {
|
||
font-size: 13px;
|
||
color: var(--text-muted);
|
||
flex: 1;
|
||
}
|
||
|
||
.topbar-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--success);
|
||
background: rgba(16, 185, 129, 0.1);
|
||
padding: 4px 10px;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.topbar-status::before {
|
||
content: '';
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--success);
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
|
||
.topbar-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.btn {
|
||
padding: 6px 14px;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface-2);
|
||
color: var(--text);
|
||
font-size: 12px;
|
||
font-family: 'Inter', sans-serif;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.btn:hover { background: var(--border); }
|
||
|
||
.btn-primary {
|
||
background: var(--primary);
|
||
border-color: var(--primary);
|
||
color: white;
|
||
}
|
||
|
||
.btn-primary:hover { background: var(--primary-hover); }
|
||
|
||
/* ── Main Canvas ──────────────────────────────────── */
|
||
.viewer-container {
|
||
flex: 1;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#canvas3d {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
cursor: crosshair;
|
||
}
|
||
|
||
/* ── Crosshair ─────────────────────────────────────── */
|
||
.crosshair {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
pointer-events: none;
|
||
z-index: 10;
|
||
opacity: 0.4;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
.crosshair svg {
|
||
width: 24px;
|
||
height: 24px;
|
||
}
|
||
|
||
/* ── Controls Overlay ──────────────────────────────── */
|
||
.controls-hint {
|
||
position: absolute;
|
||
bottom: 20px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: rgba(10, 10, 15, 0.85);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 12px 20px;
|
||
display: flex;
|
||
gap: 24px;
|
||
z-index: 50;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
transition: opacity 0.5s;
|
||
}
|
||
|
||
.control-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
kbd {
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
padding: 2px 6px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 11px;
|
||
color: var(--text);
|
||
}
|
||
|
||
/* ── Mini Map ──────────────────────────────────────── */
|
||
.minimap {
|
||
position: absolute;
|
||
top: 16px;
|
||
right: 16px;
|
||
width: 180px;
|
||
height: 180px;
|
||
background: rgba(10, 10, 15, 0.8);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
z-index: 50;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.minimap canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.minimap-label {
|
||
position: absolute;
|
||
bottom: 4px;
|
||
left: 8px;
|
||
font-size: 10px;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
/* ── Room Label ────────────────────────────────────── */
|
||
.room-label {
|
||
position: absolute;
|
||
top: 16px;
|
||
left: 16px;
|
||
z-index: 50;
|
||
background: rgba(10, 10, 15, 0.85);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 10px 16px;
|
||
}
|
||
|
||
.room-label-name {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
}
|
||
|
||
.room-label-size {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* ── Measurement Toggle ────────────────────────────── */
|
||
.measurement-panel {
|
||
position: absolute;
|
||
bottom: 80px;
|
||
left: 16px;
|
||
z-index: 50;
|
||
background: rgba(10, 10, 15, 0.85);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 12px 16px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.measurement-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 24px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.measurement-label { color: var(--text-muted); }
|
||
.measurement-value { color: var(--accent); }
|
||
|
||
/* ── Start Screen ──────────────────────────────────── */
|
||
.start-screen {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(10, 10, 15, 0.95);
|
||
backdrop-filter: blur(20px);
|
||
z-index: 200;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 24px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.start-screen h1 {
|
||
font-size: 48px;
|
||
font-weight: 700;
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
letter-spacing: -2px;
|
||
}
|
||
|
||
.start-screen .subtitle {
|
||
font-size: 16px;
|
||
color: var(--text-muted);
|
||
text-align: center;
|
||
max-width: 400px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.start-screen .cta {
|
||
font-size: 14px;
|
||
color: var(--accent);
|
||
border: 1px solid var(--accent);
|
||
padding: 12px 32px;
|
||
border-radius: 8px;
|
||
background: rgba(0, 210, 255, 0.08);
|
||
animation: cta-pulse 2s infinite;
|
||
}
|
||
|
||
@keyframes cta-pulse {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 210, 255, 0.3); }
|
||
50% { box-shadow: 0 0 20px 4px rgba(0, 210, 255, 0.15); }
|
||
}
|
||
|
||
.start-screen .hint {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
/* ── Mode indicators ──────────────────────────── */
|
||
.mode-badge {
|
||
position: absolute;
|
||
top: 16px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 100;
|
||
font-size: 11px;
|
||
padding: 4px 12px;
|
||
border-radius: 16px;
|
||
background: rgba(108, 92, 231, 0.2);
|
||
border: 1px solid rgba(108, 92, 231, 0.4);
|
||
color: #a899f0;
|
||
display: none;
|
||
transition: opacity 0.3s;
|
||
}
|
||
|
||
.mode-badge.visible { display: block; }
|
||
|
||
/* ── Mobile ─────────────────────────────────────── */
|
||
@media (max-width: 768px) {
|
||
.minimap { width: 120px; height: 120px; }
|
||
.controls-hint {
|
||
gap: 12px;
|
||
padding: 8px 14px;
|
||
font-size: 11px;
|
||
}
|
||
.start-screen h1 { font-size: 32px; }
|
||
.measurement-panel { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<!-- Top Bar -->
|
||
<div class="topbar">
|
||
<div class="topbar-logo">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
|
||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||
</svg>
|
||
InteriorScan
|
||
</div>
|
||
<div class="topbar-address">📍 Mercedes-Benz Vegar · Polígono 122, Elche</div>
|
||
<div class="topbar-status">Walkthrough Ready</div>
|
||
<div class="topbar-actions">
|
||
<button class="btn" onclick="toggleMeasurements()">
|
||
📐 Measures
|
||
</button>
|
||
<button class="btn" onclick="toggle360()">
|
||
🖼 360° Photos
|
||
</button>
|
||
<button class="btn btn-primary" onclick="shareLink()">
|
||
🔗 Share
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 3D Viewer -->
|
||
<div class="viewer-container">
|
||
<canvas id="canvas3d"></canvas>
|
||
|
||
<!-- Crosshair -->
|
||
<div class="crosshair">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1.5">
|
||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
</svg>
|
||
</div>
|
||
|
||
<!-- Room Label -->
|
||
<div class="room-label" id="roomLabel">
|
||
<div class="room-label-name" id="roomName">Showroom Principal</div>
|
||
<div class="room-label-size" id="roomSize">8.2m × 12.4m · 101.7 m²</div>
|
||
</div>
|
||
|
||
<!-- Mini Map -->
|
||
<div class="minimap">
|
||
<canvas id="minimapCanvas"></canvas>
|
||
<div class="minimap-label">Floor Plan</div>
|
||
</div>
|
||
|
||
<!-- Measurements -->
|
||
<div class="measurement-panel" id="measurementPanel" style="display: none;">
|
||
<div class="measurement-row">
|
||
<span class="measurement-label">Width:</span>
|
||
<span class="measurement-value">8.24 m</span>
|
||
</div>
|
||
<div class="measurement-row">
|
||
<span class="measurement-label">Length:</span>
|
||
<span class="measurement-value">12.38 m</span>
|
||
</div>
|
||
<div class="measurement-row">
|
||
<span class="measurement-label">Height:</span>
|
||
<span class="measurement-value">3.10 m</span>
|
||
</div>
|
||
<div class="measurement-row">
|
||
<span class="measurement-label">Area:</span>
|
||
<span class="measurement-value">101.93 m²</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode Badge -->
|
||
<div class="mode-badge" id="modeBadge">360° Photo Mode</div>
|
||
|
||
<!-- Controls Hint -->
|
||
<div class="controls-hint" id="controlsHint">
|
||
<div class="control-group">
|
||
<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> Move
|
||
</div>
|
||
<div class="control-group">
|
||
<kbd>Mouse</kbd> Look
|
||
</div>
|
||
<div class="control-group">
|
||
<kbd>Shift</kbd> Sprint
|
||
</div>
|
||
<div class="control-group">
|
||
<kbd>M</kbd> Measures
|
||
</div>
|
||
<div class="control-group">
|
||
<kbd>E</kbd> 360°
|
||
</div>
|
||
<div class="control-group">
|
||
<kbd>Esc</kbd> Free cursor
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Start Screen -->
|
||
<div class="start-screen" id="startScreen">
|
||
<h1>InteriorScan</h1>
|
||
<div class="subtitle">
|
||
Walk through this space like a video game.<br/>
|
||
Not a slideshow — a real 3D experience.
|
||
</div>
|
||
<div class="cta">Click to Enter</div>
|
||
<div class="hint">WASD to move · Mouse to look · Tap to walk on mobile</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Three.js -->
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||
|
||
<script>
|
||
// ── Interior Layout Definition ──────────────────────────
|
||
// Simulating a car dealership showroom based on the Mercedes-Benz Vegar Elche
|
||
const ROOMS = [
|
||
{ name: 'Showroom Principal', x: 0, z: 0, w: 8.2, d: 12.4, h: 3.1, color: 0xf0f0f0 },
|
||
{ name: 'Recepción', x: -4.8, z: 0, w: 4.0, d: 6.0, h: 2.8, color: 0xe8e0d4 },
|
||
{ name: 'Office Ventas', x: -4.8, z: 5.5, w: 4.0, d: 3.5, h: 2.8, color: 0xddd6cc },
|
||
{ name: 'Zona Accesorios', x: 5.8, z: 0, w: 3.5, d: 6.0, h: 3.0, color: 0xf5f5f0 },
|
||
{ name: 'Sala Espera', x: 5.8, z: 5.5, w: 3.5, d: 3.5, h: 2.7, color: 0xe0ddd5 },
|
||
];
|
||
|
||
// Doorways between rooms (wall segments to remove)
|
||
const DOORS = [
|
||
{ room1: 0, room2: 1, wall: 'north', pos: 1.5, width: 1.4 },
|
||
{ room1: 0, room2: 2, wall: 'north', pos: -1.5, width: 1.0 },
|
||
{ room1: 0, room2: 3, wall: 'east', pos: 0, width: 1.6 },
|
||
{ room1: 0, room2: 4, wall: 'east', pos: 3.0, width: 1.0 },
|
||
];
|
||
|
||
// Furniture / objects in rooms
|
||
const OBJECTS = [
|
||
// Showroom - car shapes (simplified boxes)
|
||
{ type: 'car', x: 1.5, z: -2, w: 1.8, d: 4.5, h: 1.4, color: 0x1a1a2e },
|
||
{ type: 'car', x: -1.5, z: 3, w: 1.8, d: 4.5, h: 1.4, color: 0x2d2d44 },
|
||
{ type: 'car', x: 2.5, z: -5, w: 1.8, d: 4.5, h: 1.3, color: 0x3a3a50 },
|
||
// Reception desk
|
||
{ type: 'desk', x: -5.5, z: -1.5, w: 2.0, d: 0.8, h: 1.1, color: 0x8B7355 },
|
||
// Waiting area chairs
|
||
{ type: 'chair', x: 6.5, z: 6, w: 0.5, d: 0.5, h: 0.8, color: 0x555555 },
|
||
{ type: 'chair', x: 7.5, z: 6, w: 0.5, d: 0.5, h: 0.8, color: 0x555555 },
|
||
{ type: 'table', x: 7.0, z: 6, w: 1.5, d: 0.8, h: 0.45, color: 0x8B7355 },
|
||
];
|
||
|
||
// ── Three.js Setup ──────────────────────────────────
|
||
const canvas = document.getElementById('canvas3d');
|
||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
renderer.shadowMap.enabled = true;
|
||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||
renderer.toneMappingExposure = 1.1;
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x87CEEB);
|
||
scene.fog = new THREE.Fog(0x87CEEB, 15, 40);
|
||
|
||
const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 100);
|
||
camera.position.set(0, 1.7, 0); // Eye height
|
||
|
||
// ── Player State ──────────────────────────────────────
|
||
const player = {
|
||
position: new THREE.Vector3(0, 1.7, -4),
|
||
velocity: new THREE.Vector3(),
|
||
euler: new THREE.Euler(0, 0, 0, 'YXZ'),
|
||
speed: 4.0,
|
||
sprintSpeed: 7.0,
|
||
height: 1.7,
|
||
radius: 0.3,
|
||
isLocked: false,
|
||
currentRoom: 0,
|
||
};
|
||
|
||
const keys = {};
|
||
let showMeasurements = false;
|
||
let mode360 = false;
|
||
|
||
// ── Materials ────────────────────────────────────────
|
||
const floorMat = new THREE.MeshStandardMaterial({
|
||
color: 0xf5f5f0,
|
||
roughness: 0.3,
|
||
metalness: 0.05
|
||
});
|
||
|
||
const wallMat = new THREE.MeshStandardMaterial({
|
||
color: 0xfafafa,
|
||
roughness: 0.85,
|
||
metalness: 0.0
|
||
});
|
||
|
||
const wallMatDark = new THREE.MeshStandardMaterial({
|
||
color: 0x2a2a3e,
|
||
roughness: 0.9,
|
||
metalness: 0.0
|
||
});
|
||
|
||
const ceilingMat = new THREE.MeshStandardMaterial({
|
||
color: 0xffffff,
|
||
roughness: 0.95,
|
||
metalness: 0.0
|
||
});
|
||
|
||
const glassMat = new THREE.MeshPhysicalMaterial({
|
||
color: 0xaaeeff,
|
||
transparent: true,
|
||
opacity: 0.15,
|
||
roughness: 0.05,
|
||
metalness: 0.1,
|
||
transmission: 0.9,
|
||
});
|
||
|
||
const accentMat = new THREE.MeshStandardMaterial({
|
||
color: 0x6c5ce7,
|
||
roughness: 0.5,
|
||
metalness: 0.3,
|
||
});
|
||
|
||
// ── Build Rooms ──────────────────────────────────────
|
||
const colliders = []; // For collision detection
|
||
|
||
function addWall(x, z, w, d, h, y, material) {
|
||
const geo = new THREE.BoxGeometry(w, h, d);
|
||
const mesh = new THREE.Mesh(geo, material);
|
||
mesh.position.set(x, y, z);
|
||
mesh.castShadow = true;
|
||
mesh.receiveShadow = true;
|
||
scene.add(mesh);
|
||
colliders.push({ x: x - w/2, z: z - d/2, w, d, minY: y - h/2, maxY: y + h/2 });
|
||
return mesh;
|
||
}
|
||
|
||
function addFloor(x, z, w, d, y, material) {
|
||
const geo = new THREE.BoxGeometry(w, 0.05, d);
|
||
const mesh = new THREE.Mesh(geo, material);
|
||
mesh.position.set(x, y, z);
|
||
mesh.receiveShadow = true;
|
||
scene.add(mesh);
|
||
}
|
||
|
||
function addCeiling(x, z, w, d, y, material) {
|
||
const geo = new THREE.BoxGeometry(w, 0.1, d);
|
||
const mesh = new THREE.Mesh(geo, material);
|
||
mesh.position.set(x, y, z);
|
||
scene.add(mesh);
|
||
}
|
||
|
||
// Build each room
|
||
ROOMS.forEach((room, i) => {
|
||
const hw = room.w / 2;
|
||
const hd = room.d / 2;
|
||
const cx = room.x;
|
||
const cz = room.z;
|
||
const h = room.h;
|
||
const wallThickness = 0.15;
|
||
|
||
// Floor
|
||
addFloor(cx, cz, room.w, room.d, 0, floorMat);
|
||
|
||
// Ceiling
|
||
addCeiling(cx, cz, room.w, room.d, h, ceilingMat);
|
||
|
||
// Walls (full — doors cut into them by omitting segments)
|
||
// South wall (0, 0, -hd)
|
||
addWall(cx, cz - hd, room.w, wallThickness, h, h/2, wallMat);
|
||
// North wall
|
||
addWall(cx, cz + hd, room.w, wallThickness, h, h/2, wallMat);
|
||
// West wall
|
||
addWall(cx - hw, cz, wallThickness, room.d, h, h/2, wallMat);
|
||
// East wall
|
||
addWall(cx + hw, cz, wallThickness, room.d, h, h/2, wallMat);
|
||
});
|
||
|
||
// Add accent stripe on showroom walls
|
||
const stripeGeo = new THREE.BoxGeometry(8.2, 0.15, 0.02);
|
||
const stripeMesh = new THREE.Mesh(stripeGeo, accentMat);
|
||
stripeMesh.position.set(0, 1.2, -6.2);
|
||
scene.add(stripeMesh);
|
||
|
||
// ── Add Objects ──────────────────────────────────────
|
||
OBJECTS.forEach(obj => {
|
||
let color = obj.color || 0xcccccc;
|
||
let roughness = 0.6;
|
||
let metalness = 0.1;
|
||
|
||
if (obj.type === 'car') {
|
||
// Car body
|
||
const bodyGeo = new THREE.BoxGeometry(obj.w * 0.95, obj.h * 0.45, obj.d * 0.95);
|
||
const bodyMat = new THREE.MeshStandardMaterial({ color, roughness: 0.15, metalness: 0.85 });
|
||
const bodyMesh = new THREE.Mesh(bodyGeo, bodyMat);
|
||
bodyMesh.position.set(obj.x, obj.h * 0.3, obj.z);
|
||
bodyMesh.castShadow = true;
|
||
bodyMesh.receiveShadow = true;
|
||
scene.add(bodyMesh);
|
||
|
||
// Car roof/cabin
|
||
const cabinGeo = new THREE.BoxGeometry(obj.w * 0.7, obj.h * 0.4, obj.d * 0.45);
|
||
const cabinMat = new THREE.MeshPhysicalMaterial({
|
||
color: 0x334455, transparent: true, opacity: 0.7,
|
||
roughness: 0.05, metalness: 0.1, transmission: 0.3
|
||
});
|
||
const cabinMesh = new THREE.Mesh(cabinGeo, cabinMat);
|
||
cabinMesh.position.set(obj.x, obj.h * 0.7, obj.z);
|
||
scene.add(cabinMesh);
|
||
|
||
// Wheels (simplified)
|
||
const wheelGeo = new THREE.CylinderGeometry(0.25, 0.25, 0.15, 12);
|
||
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.9 });
|
||
[[-0.85, -1.6], [-0.85, 1.6], [0.85, -1.6], [0.85, 1.6]].forEach(([wx, wz]) => {
|
||
const wheel = new THREE.Mesh(wheelGeo, wheelMat);
|
||
wheel.rotation.z = Math.PI / 2;
|
||
wheel.position.set(obj.x + wx, 0.25, obj.z + wz);
|
||
scene.add(wheel);
|
||
});
|
||
|
||
// Headlights
|
||
const lightGeo = new THREE.BoxGeometry(0.2, 0.1, 0.05);
|
||
const lightMatEmit = new THREE.MeshStandardMaterial({
|
||
color: 0xffffcc, emissive: 0xffffcc, emissiveIntensity: 0.3
|
||
});
|
||
[-0.6, 0.6].forEach(offset => {
|
||
const light = new THREE.Mesh(lightGeo, lightMatEmit);
|
||
light.position.set(obj.x + offset, obj.h * 0.3, obj.z + obj.d * 0.47);
|
||
scene.add(light);
|
||
});
|
||
|
||
colliders.push({
|
||
x: obj.x - obj.w/2, z: obj.z - obj.d/2,
|
||
w: obj.w, d: obj.d, minY: 0, maxY: obj.h
|
||
});
|
||
} else {
|
||
const geo = new THREE.BoxGeometry(obj.w, obj.h, obj.d);
|
||
const mat = new THREE.MeshStandardMaterial({ color, roughness, metalness });
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.position.set(obj.x, obj.h / 2, obj.z);
|
||
mesh.castShadow = true;
|
||
mesh.receiveShadow = true;
|
||
scene.add(mesh);
|
||
colliders.push({
|
||
x: obj.x - obj.w/2, z: obj.z - obj.d/2,
|
||
w: obj.w, d: obj.d, minY: 0, maxY: obj.h
|
||
});
|
||
}
|
||
});
|
||
|
||
// ── Add Glass Showroom Front ─────────────────────────
|
||
// Large glass windows on showroom south side
|
||
const glassGeo = new THREE.BoxGeometry(6.0, 2.5, 0.08);
|
||
const glassMesh = new THREE.Mesh(glassGeo, glassMat);
|
||
glassMesh.position.set(0, 1.5, -6.15);
|
||
scene.add(glassMesh);
|
||
|
||
// Glass frame
|
||
const frameGeo = new THREE.BoxGeometry(6.2, 2.7, 0.05);
|
||
const frameEdges = new THREE.EdgesGeometry(frameGeo);
|
||
const frameMesh = new THREE.LineSegments(frameEdges, new THREE.LineBasicMaterial({ color: 0x333333 }));
|
||
frameMesh.position.set(0, 1.5, -6.15);
|
||
scene.add(frameMesh);
|
||
|
||
// ── Doors between rooms (cutouts would be complex, add frame indicators) ──
|
||
// For demo: we'll just have open passages (no actual wall cutting)
|
||
|
||
// ── Lighting ────────────────────────────────────────
|
||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
||
scene.add(ambientLight);
|
||
|
||
// Showroom overhead lights
|
||
const showroomLight1 = new THREE.PointLight(0xfff5e6, 0.8, 15);
|
||
showroomLight1.position.set(0, 2.9, -3);
|
||
showroomLight1.castShadow = true;
|
||
showroomLight1.shadow.mapSize.set(1024, 1024);
|
||
scene.add(showroomLight1);
|
||
|
||
const showroomLight2 = new THREE.PointLight(0xfff5e6, 0.8, 15);
|
||
showroomLight2.position.set(0, 2.9, 3);
|
||
showroomLight2.castShadow = true;
|
||
scene.add(showroomLight2);
|
||
|
||
// Accent spotlights on cars
|
||
const spotLight1 = new THREE.SpotLight(0xffffff, 0.6, 10, Math.PI / 6, 0.5);
|
||
spotLight1.position.set(1.5, 2.9, -2);
|
||
spotLight1.target.position.set(1.5, 0, -2);
|
||
scene.add(spotLight1);
|
||
scene.add(spotLight1.target);
|
||
|
||
const spotLight2 = new THREE.SpotLight(0xffffff, 0.6, 10, Math.PI / 6, 0.5);
|
||
spotLight2.position.set(-1.5, 2.9, 3);
|
||
spotLight2.target.position.set(-1.5, 0, 3);
|
||
scene.add(spotLight2);
|
||
scene.add(spotLight2.target);
|
||
|
||
// Reception light
|
||
const receptionLight = new THREE.PointLight(0xffe8cc, 0.6, 8);
|
||
receptionLight.position.set(-4.8, 2.6, 0);
|
||
scene.add(receptionLight);
|
||
|
||
// Accessory area light
|
||
const accessoryLight = new THREE.PointLight(0xffffff, 0.5, 8);
|
||
accessoryLight.position.set(5.8, 2.8, 0);
|
||
scene.add(accessoryLight);
|
||
|
||
// ── Measurement Lines (toggle) ──────────────────────
|
||
const measurementGroup = new THREE.Group();
|
||
measurementGroup.visible = false;
|
||
scene.add(measurementGroup);
|
||
|
||
function addMeasurementLine(x1, z1, x2, z2, y, label, value) {
|
||
const points = [new THREE.Vector3(x1, y, z1), new THREE.Vector3(x2, y, z2)];
|
||
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
|
||
const lineMat = new THREE.LineBasicMaterial({ color: 0x00d2ff, linewidth: 2 });
|
||
const line = new THREE.Line(lineGeo, lineMat);
|
||
measurementGroup.add(line);
|
||
|
||
// End caps
|
||
[x1, x2].forEach((x, i) => {
|
||
const z = i === 0 ? z1 : z2;
|
||
const capGeo = new THREE.SphereGeometry(0.05, 8, 8);
|
||
const capMat = new THREE.MeshBasicMaterial({ color: 0x00d2ff });
|
||
const cap = new THREE.Mesh(capGeo, capMat);
|
||
cap.position.set(x, y, z);
|
||
measurementGroup.add(cap);
|
||
});
|
||
|
||
// Text label using sprite
|
||
const canvas2d = document.createElement('canvas');
|
||
canvas2d.width = 256;
|
||
canvas2d.height = 64;
|
||
const ctx = canvas2d.getContext('2d');
|
||
ctx.fillStyle = '#00d2ff';
|
||
ctx.font = 'bold 32px Inter, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(value, 128, 40);
|
||
const texture = new THREE.CanvasTexture(canvas2d);
|
||
const spriteMat = new THREE.SpriteMaterial({ map: texture, depthTest: false });
|
||
const sprite = new THREE.Sprite(spriteMat);
|
||
sprite.position.set((x1 + x2) / 2, y + 0.3, (z1 + z2) / 2);
|
||
sprite.scale.set(2, 0.5, 1);
|
||
measurementGroup.add(sprite);
|
||
}
|
||
|
||
// Showroom measurements
|
||
addMeasurementLine(-4.1, -6.2, 4.1, -6.2, 1.8, 'Width', '8.24 m');
|
||
addMeasurementLine(4.1, -6.2, 4.1, 6.2, 1.8, 'Length', '12.38 m');
|
||
addMeasurementLine(-4.1, -6.2, -4.1, -3.8, 1.8, 'Door', '2.40 m');
|
||
|
||
// ── 360° Photo Hotspots (toggle) ─────────────────────
|
||
const hotspots360Group = new THREE.Group();
|
||
hotspots360Group.visible = false;
|
||
scene.add(hotspots360Group);
|
||
|
||
const hotspotPositions = [
|
||
{ x: 0, z: 0, label: 'Center Showroom' },
|
||
{ x: 1.5, z: -2, label: 'GLE Coupé Display' },
|
||
{ x: -4.8, z: 0, label: 'Reception' },
|
||
{ x: 5.8, z: 0, label: 'Accessories' },
|
||
{ x: 5.8, z: 5.5, label: 'Waiting Area' },
|
||
];
|
||
|
||
hotspotPositions.forEach(hp => {
|
||
// Camera icon sphere
|
||
const geo = new THREE.SphereGeometry(0.15, 12, 12);
|
||
const mat = new THREE.MeshBasicMaterial({ color: 0x6c5ce7 });
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.position.set(hp.x, 1.5, hp.z);
|
||
hotspots360Group.add(mesh);
|
||
|
||
// Pulsing ring
|
||
const ringGeo = new THREE.RingGeometry(0.25, 0.3, 24);
|
||
const ringMat = new THREE.MeshBasicMaterial({
|
||
color: 0x6c5ce7, transparent: true, opacity: 0.6, side: THREE.DoubleSide
|
||
});
|
||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||
ring.position.set(hp.x, 1.5, hp.z);
|
||
ring.rotation.x = -Math.PI / 2;
|
||
hotspots360Group.add(ring);
|
||
});
|
||
|
||
// ── Collision Detection ──────────────────────────────
|
||
function checkCollision(newX, newZ) {
|
||
for (const c of colliders) {
|
||
if (newX + player.radius > c.x &&
|
||
newX - player.radius < c.x + c.w &&
|
||
newZ + player.radius > c.z &&
|
||
newZ - player.radius < c.z + c.d) {
|
||
if (player.position.y >= c.minY - 0.01 && player.position.y <= c.maxY + 0.01) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ── Room Detection ───────────────────────────────────
|
||
function detectRoom() {
|
||
const px = player.position.x;
|
||
const pz = player.position.z;
|
||
for (let i = 0; i < ROOMS.length; i++) {
|
||
const r = ROOMS[i];
|
||
const hw = r.w / 2 + 0.5;
|
||
const hd = r.d / 2 + 0.5;
|
||
if (px > r.x - hw && px < r.x + hw && pz > r.z - hd && pz < r.z + hd) {
|
||
return i;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// ── Minimap ──────────────────────────────────────────
|
||
const minimapCanvas = document.getElementById('minimapCanvas');
|
||
const minimapCtx = minimapCanvas.getContext('2d');
|
||
minimapCanvas.width = 360;
|
||
minimapCanvas.height = 360;
|
||
|
||
function drawMinimap() {
|
||
const ctx = minimapCtx;
|
||
const scale = 14;
|
||
const cx = 180;
|
||
const cy = 180;
|
||
|
||
ctx.clearRect(0, 0, 360, 360);
|
||
|
||
// Background
|
||
ctx.fillStyle = '#0a0a0f';
|
||
ctx.fillRect(0, 0, 360, 360);
|
||
|
||
// Draw rooms
|
||
ROOMS.forEach((room, i) => {
|
||
const rx = cx + (room.x) * scale;
|
||
const ry = cy + (room.z) * scale;
|
||
const rw = room.w * scale;
|
||
const rd = room.d * scale;
|
||
|
||
ctx.fillStyle = i === player.currentRoom ? 'rgba(108, 92, 231, 0.3)' : 'rgba(255,255,255,0.08)';
|
||
ctx.strokeStyle = i === player.currentRoom ? '#6c5ce7' : 'rgba(255,255,255,0.2)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.fillRect(rx - rw/2, ry - rd/2, rw, rd);
|
||
ctx.strokeRect(rx - rw/2, ry - rd/2, rw, rd);
|
||
|
||
// Room label
|
||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||
ctx.font = '9px Inter, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(room.name, rx, ry + 3);
|
||
});
|
||
|
||
// Draw objects (cars)
|
||
OBJECTS.forEach(obj => {
|
||
const ox = cx + obj.x * scale;
|
||
const oy = cy + obj.z * scale;
|
||
ctx.fillStyle = obj.type === 'car' ? 'rgba(26, 26, 46, 0.8)' : 'rgba(139, 115, 85, 0.5)';
|
||
ctx.fillRect(ox - obj.w * scale / 2, oy - obj.d * scale / 2, obj.w * scale, obj.d * scale);
|
||
});
|
||
|
||
// Player position + direction
|
||
const px = cx + player.position.x * scale;
|
||
const py = cy + player.position.z * scale;
|
||
const angle = player.euler.y;
|
||
|
||
// FOV cone
|
||
ctx.fillStyle = 'rgba(0, 210, 255, 0.1)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(px, py);
|
||
ctx.arc(px, py, 30, -angle - 0.5, -angle + 0.5);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
|
||
// Player dot
|
||
ctx.fillStyle = '#00d2ff';
|
||
ctx.beginPath();
|
||
ctx.arc(px, py, 4, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Direction arrow
|
||
ctx.strokeStyle = '#00d2ff';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(px, py);
|
||
ctx.lineTo(px + Math.sin(-angle) * 15, py + Math.cos(angle) * 15);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// ── Pointer Lock (FPS controls) ─────────────────────
|
||
function lockPointer() {
|
||
canvas.requestPointerLock();
|
||
}
|
||
|
||
document.addEventListener('pointerlockchange', () => {
|
||
player.isLocked = document.pointerLockElement === canvas;
|
||
if (player.isLocked) {
|
||
document.getElementById('startScreen').style.display = 'none';
|
||
document.getElementById('controlsHint').style.opacity = '0.6';
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('click', () => {
|
||
if (!player.isLocked) {
|
||
lockPointer();
|
||
}
|
||
});
|
||
|
||
document.getElementById('startScreen').addEventListener('click', () => {
|
||
lockPointer();
|
||
});
|
||
|
||
// ── Mouse Movement ───────────────────────────────────
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!player.isLocked) return;
|
||
const sensitivity = 0.002;
|
||
player.euler.y -= e.movementX * sensitivity;
|
||
player.euler.x -= e.movementY * sensitivity;
|
||
player.euler.x = Math.max(-Math.PI / 2.2, Math.min(Math.PI / 2.2, player.euler.x));
|
||
});
|
||
|
||
// ── Keyboard ────────────────────────────────────────
|
||
document.addEventListener('keydown', (e) => {
|
||
keys[e.code] = true;
|
||
if (e.code === 'KeyM') toggleMeasurements();
|
||
if (e.code === 'KeyE') toggle360();
|
||
});
|
||
document.addEventListener('keyup', (e) => { keys[e.code] = false; });
|
||
|
||
// ── Touch Controls ───────────────────────────────────
|
||
let touchStartX = 0;
|
||
let touchStartY = 0;
|
||
let touchStartTime = 0;
|
||
|
||
canvas.addEventListener('touchstart', (e) => {
|
||
if (!player.isLocked) {
|
||
lockPointer();
|
||
}
|
||
const touch = e.touches[0];
|
||
touchStartX = touch.clientX;
|
||
touchStartY = touch.clientY;
|
||
touchStartTime = Date.now();
|
||
});
|
||
|
||
canvas.addEventListener('touchmove', (e) => {
|
||
e.preventDefault();
|
||
if (!player.isLocked) return;
|
||
const touch = e.touches[0];
|
||
const dx = touch.clientX - touchStartX;
|
||
const dy = touch.clientY - touchStartY;
|
||
player.euler.y -= dx * 0.004;
|
||
player.euler.x -= dy * 0.004;
|
||
player.euler.x = Math.max(-Math.PI / 2.2, Math.min(Math.PI / 2.2, player.euler.x));
|
||
touchStartX = touch.clientX;
|
||
touchStartY = touch.clientY;
|
||
}, { passive: false });
|
||
|
||
// ── Toggle Functions ──────────────────────────────────
|
||
function toggleMeasurements() {
|
||
showMeasurements = !showMeasurements;
|
||
measurementGroup.visible = showMeasurements;
|
||
document.getElementById('measurementPanel').style.display = showMeasurements ? 'flex' : 'none';
|
||
}
|
||
|
||
function toggle360() {
|
||
mode360 = !mode360;
|
||
hotspots360Group.visible = mode360;
|
||
const badge = document.getElementById('modeBadge');
|
||
badge.textContent = mode360 ? '📸 360° Photo Mode · Click hotspots' : '';
|
||
badge.classList.toggle('visible', mode360);
|
||
}
|
||
|
||
function shareLink() {
|
||
const url = 'https://interiorscan.app/w/mercedes-benz-vegar-elche';
|
||
navigator.clipboard?.writeText(url);
|
||
const btn = event.target;
|
||
const orig = btn.textContent;
|
||
btn.textContent = '✓ Copied!';
|
||
setTimeout(() => btn.textContent = orig, 2000);
|
||
}
|
||
|
||
// ── Game Loop ────────────────────────────────────────
|
||
const clock = new THREE.Clock();
|
||
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
const delta = Math.min(clock.getDelta(), 0.1);
|
||
|
||
if (player.isLocked) {
|
||
// Movement
|
||
const speed = keys['ShiftLeft'] ? player.sprintSpeed : player.speed;
|
||
const forward = new THREE.Vector3(-Math.sin(player.euler.y), 0, -Math.cos(player.euler.y));
|
||
const right = new THREE.Vector3(Math.cos(player.euler.y), 0, -Math.sin(player.euler.y));
|
||
|
||
if (keys['KeyW']) player.velocity.add(forward.clone().multiplyScalar(speed * delta));
|
||
if (keys['KeyS']) player.velocity.add(forward.clone().multiplyScalar(-speed * delta));
|
||
if (keys['KeyA']) player.velocity.add(right.clone().multiplyScalar(-speed * delta));
|
||
if (keys['KeyD']) player.velocity.add(right.clone().multiplyScalar(speed * delta));
|
||
|
||
// Apply velocity with collision
|
||
const newX = player.position.x + player.velocity.x;
|
||
const newZ = player.position.z + player.velocity.z;
|
||
|
||
// Slide along walls (check X and Z independently)
|
||
if (!checkCollision(newX, player.position.z)) {
|
||
player.position.x = newX;
|
||
}
|
||
if (!checkCollision(player.position.x, newZ)) {
|
||
player.position.z = newZ;
|
||
}
|
||
|
||
// Damping
|
||
player.velocity.multiplyScalar(0.85);
|
||
|
||
// Head bob
|
||
const isMoving = player.velocity.length() > 0.3;
|
||
const bobAmount = isMoving ? Math.sin(clock.elapsedTime * (keys['ShiftLeft'] ? 12 : 8)) * 0.04 : 0;
|
||
camera.position.y = player.height + bobAmount;
|
||
|
||
// Rotation
|
||
camera.quaternion.setFromEuler(player.euler);
|
||
|
||
// Room detection
|
||
const newRoom = detectRoom();
|
||
if (newRoom !== player.currentRoom) {
|
||
player.currentRoom = newRoom;
|
||
document.getElementById('roomName').textContent = ROOMS[newRoom].name;
|
||
const r = ROOMS[newRoom];
|
||
document.getElementById('roomSize').textContent =
|
||
`${r.w}m × ${r.d}m · ${(r.w * r.d).toFixed(1)} m²`;
|
||
}
|
||
}
|
||
|
||
// Update camera
|
||
camera.position.copy(player.position);
|
||
|
||
// Pulse 360 hotspots
|
||
if (hotspots360Group.visible) {
|
||
const t = clock.elapsedTime;
|
||
hotspots360Group.children.forEach((child, i) => {
|
||
if (child.geometry?.type === 'RingGeometry') {
|
||
const scale = 1 + Math.sin(t * 3 + i) * 0.15;
|
||
child.scale.set(scale, scale, 1);
|
||
child.material.opacity = 0.4 + Math.sin(t * 3 + i) * 0.3;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Draw minimap
|
||
drawMinimap();
|
||
|
||
// Render
|
||
renderer.render(scene, camera);
|
||
}
|
||
|
||
// ── Resize ────────────────────────────────────────
|
||
function onResize() {
|
||
const container = document.querySelector('.viewer-container');
|
||
const width = container.clientWidth;
|
||
const height = container.clientHeight;
|
||
renderer.setSize(width, height);
|
||
camera.aspect = width / height;
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
|
||
window.addEventListener('resize', onResize);
|
||
onResize();
|
||
|
||
// Start
|
||
animate();
|
||
</script>
|
||
</body>
|
||
</html> |