Files
hermes be3221e93d feat: add catastro SSOT integration, 3DGS hybrid pipeline, casa demo v3
- 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
2026-05-29 01:03:32 +02:00

1128 lines
37 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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)}`;
}
}
// 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>