Files
interiorscan/demo-v2-textured.html
T
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

971 lines
50 KiB
HTML
Raw 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 — Textured 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;
--accent: #00d2ff; --success: #10b981; --warning: #f59e0b;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; overflow: hidden; height: 100vh; }
#app { display: flex; flex-direction: column; height: 100vh; }
.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} }
.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: #7d6ff0; }
.btn-active { background: var(--accent); border-color: var(--accent); color: #000; }
.viewer-container { flex: 1; position: relative; overflow: hidden; }
#canvas3d { width: 100%; height: 100%; display: block; cursor: crosshair; }
.crosshair { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); pointer-events: none; z-index: 10; opacity: 0.5; }
.crosshair svg { width: 28px; height: 28px; }
.minimap { position: absolute; top: 16px; right: 16px; width: 200px; height: 200px; background: rgba(10,10,15,0.85); 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 { 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; }
.info-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;
transition: all 0.3s;
}
.info-panel.hidden { display: none; }
.info-row { display: flex; justify-content: space-between; gap: 24px; font-size: 12px; }
.info-label { color: var(--text-muted); }
.info-value { color: var(--accent); }
.pipeline-bar {
position: absolute; bottom: 16px; right: 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 14px;
font-size: 11px; max-width: 220px;
}
.pipeline-title { color: var(--accent); font-weight: 600; margin-bottom: 6px; font-size: 12px; }
.pipeline-step { display: flex; align-items: center; gap: 6px; color: var(--text-muted); margin: 3px 0; }
.pipeline-step .dot { width: 6px; height: 6px; border-radius: 50%; }
.pipeline-step .dot.done { background: var(--success); }
.pipeline-step .dot.active { background: var(--accent); animation: pulse 1s infinite; }
.pipeline-step .dot.pending { background: var(--border); }
.pipeline-step.active-step { color: var(--text); }
.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: 10px 20px; display: flex; gap: 20px; z-index: 50;
font-size: 12px; color: var(--text-muted); transition: opacity 0.5s;
}
.control-group { display: flex; align-items: center; gap: 5px; }
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); }
.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: 52px; font-weight: 700; background: linear-gradient(135deg, var(--accent), #a855f7); -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: 420px; line-height: 1.6; }
.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; }
.texture-badge {
position: absolute; top: 16px; left: 50%; transform: translateX(-50%); z-index: 100;
font-size: 11px; padding: 4px 12px; border-radius: 16px;
background: rgba(0,210,255,0.15); border: 1px solid rgba(0,210,255,0.3); color: var(--accent);
display: none; transition: opacity 0.3s;
}
.texture-badge.visible { display: block; }
@media (max-width: 768px) {
.minimap { width: 140px; height: 140px; }
.controls-hint { gap: 12px; padding: 8px 14px; font-size: 11px; }
.start-screen h1 { font-size: 32px; }
.info-panel { display: none; }
.pipeline-bar { display: none; }
}
</style>
</head>
<body>
<div id="app">
<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" style="display:flex;gap:8px;">
<button class="btn" id="btnMeasures" onclick="toggleMeasures()">📐 Measures</button>
<button class="btn" id="btn360" onclick="toggle360()">🖼 360°</button>
<button class="btn" id="btnWireframe" onclick="toggleWireframe()">🔲 Wireframe</button>
<button class="btn" id="btnTextures" onclick="toggleTextures()">🎨 Textures</button>
<button class="btn btn-primary" onclick="shareLink()">🔗 Share</button>
</div>
</div>
<div class="viewer-container">
<canvas id="canvas3d"></canvas>
<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>
<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>
<div class="minimap"><canvas id="minimapCanvas"></canvas><div class="minimap-label">Floor Plan</div></div>
<div class="info-panel" id="infoPanel">
<div class="info-row"><span class="info-label">Room:</span><span class="info-value" id="infoRoom">Showroom Principal</span></div>
<div class="info-row"><span class="info-label">Dimensions:</span><span class="info-value" id="infoDims">8.24 × 12.38 m</span></div>
<div class="info-row"><span class="info-label">Area:</span><span class="info-value" id="infoArea">101.93 m²</span></div>
<div class="info-row"><span class="info-label">Height:</span><span class="info-value" id="infoHeight">3.10 m</span></div>
<div class="info-row"><span class="info-label">Photos used:</span><span class="info-value" id="infoPhotos">47 / 68</span></div>
</div>
<div class="pipeline-bar">
<div class="pipeline-title">📷 Reconstruction Pipeline</div>
<div class="pipeline-step"><span class="dot done"></span> COLMAP SfM → camera poses</div>
<div class="pipeline-step"><span class="dot done"></span> OpenMVS → dense cloud</div>
<div class="pipeline-step"><span class="dot done"></span> Mesh generation</div>
<div class="pipeline-step active-step"><span class="dot active"></span> Photo texturing ✓</div>
<div class="pipeline-step"><span class="dot done"></span> LOD + Draco compression</div>
</div>
<div class="texture-badge" id="textureBadge">🎨 Photo textures active</div>
<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>T</kbd> Textures</div>
<div class="control-group"><kbd>F</kbd> Wireframe</div>
</div>
<div class="start-screen" id="startScreen">
<h1>InteriorScan</h1>
<div class="subtitle">
Photo-textured 3D walkthrough.<br/>
Real photos projected onto reconstructed geometry.
</div>
<div class="cta">Click to Enter</div>
<div class="hint">WASD move · Mouse look · Shift sprint · T textures · F wireframe</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ── Procedural Texture Generator ──────────────────────
// Simulates "photo projected onto mesh" — this is what COLMAP+texturing produces
function createCanvasTexture(width, height, drawFn) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
drawFn(ctx, width, height);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.encoding = THREE.sRGBEncoding;
return tex;
}
// Concrete floor texture (like showroom polished concrete)
const floorTex = createCanvasTexture(512, 512, (ctx, w, h) => {
// Base
ctx.fillStyle = '#d4d0c8';
ctx.fillRect(0, 0, w, h);
// Noise
for (let i = 0; i < 15000; i++) {
const x = Math.random() * w;
const y = Math.random() * h;
const a = Math.random() * 0.08;
ctx.fillStyle = `rgba(${100+Math.random()*60}, ${95+Math.random()*55}, ${85+Math.random()*50}, ${a})`;
ctx.fillRect(x, y, 1 + Math.random()*2, 1 + Math.random()*2);
}
// Subtle grid lines (tile joints)
ctx.strokeStyle = 'rgba(150,145,135,0.3)';
ctx.lineWidth = 1;
for (let i = 0; i <= w; i += 128) {
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, h); ctx.stroke();
}
for (let j = 0; j <= h; j += 128) {
ctx.beginPath(); ctx.moveTo(0, j); ctx.lineTo(w, j); ctx.stroke();
}
// Reflective patches (polished concrete sheen)
const gradient = ctx.createRadialGradient(256, 256, 0, 256, 256, 200);
gradient.addColorStop(0, 'rgba(255,255,250,0.12)');
gradient.addColorStop(1, 'rgba(255,255,250,0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
});
floorTex.repeat.set(4, 4);
// White wall texture (photographed interior wall)
const wallTex = createCanvasTexture(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#f5f4f0';
ctx.fillRect(0, 0, w, h);
// Subtle variations (paint roller texture)
for (let i = 0; i < 20000; i++) {
const x = Math.random() * w;
const y = Math.random() * h;
ctx.fillStyle = `rgba(${230+Math.random()*25}, ${228+Math.random()*25}, ${222+Math.random()*25}, ${Math.random()*0.06})`;
ctx.fillRect(x, y, 2 + Math.random()*4, 1 + Math.random()*2);
}
// Horizontal roller lines
for (let j = 0; j < 8; j++) {
const y = 40 + j * 60 + Math.random() * 30;
ctx.strokeStyle = `rgba(240,238,230,${0.15 + Math.random()*0.1})`;
ctx.lineWidth = 8 + Math.random()*12;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
});
wallTex.repeat.set(3, 1);
// Dark accent wall (like showroom feature wall)
const darkWallTex = createCanvasTexture(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 15000; i++) {
const x = Math.random() * w;
const y = Math.random() * h;
ctx.fillStyle = `rgba(${20+Math.random()*30}, ${20+Math.random()*30}, ${35+Math.random()*40}, ${Math.random()*0.08})`;
ctx.fillRect(x, y, 2, 2);
}
// LED strip glow from top
const glow = ctx.createLinearGradient(0, 0, 0, 120);
glow.addColorStop(0, 'rgba(108,92,231,0.15)');
glow.addColorStop(1, 'rgba(108,92,231,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, 120);
});
darkWallTex.repeat.set(3, 1);
// Ceiling texture (white panels)
const ceilTex = createCanvasTexture(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#fafaf8';
ctx.fillRect(0, 0, w, h);
// Ceiling tile grid
ctx.strokeStyle = 'rgba(200,198,190,0.4)';
ctx.lineWidth = 1.5;
for (let i = 0; i <= w; i += 64) {
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, h); ctx.stroke();
}
for (let j = 0; j <= h; j += 64) {
ctx.beginPath(); ctx.moveTo(0, j); ctx.lineTo(w, j); ctx.stroke();
}
// Subtle variation
for (let i = 0; i < 8000; i++) {
ctx.fillStyle = `rgba(${245+Math.random()*10},${244+Math.random()*10},${240+Math.random()*10},${Math.random()*0.05})`;
ctx.fillRect(Math.random()*w, Math.random()*h, 3, 3);
}
});
ceilTex.repeat.set(4, 4);
// Glass texture
const glassTex = createCanvasTexture(256, 256, (ctx, w, h) => {
ctx.fillStyle = 'rgba(180,220,240,0.15)';
ctx.fillRect(0, 0, w, h);
// Reflection streaks
for (let i = 0; i < 5; i++) {
const x = Math.random() * w;
const gradient = ctx.createLinearGradient(x, 0, x+w*0.3, h);
gradient.addColorStop(0, 'rgba(255,255,255,0.05)');
gradient.addColorStop(0.5, 'rgba(255,255,255,0.12)');
gradient.addColorStop(1, 'rgba(255,255,255,0.02)');
ctx.fillStyle = gradient;
ctx.fillRect(x, 0, 20 + Math.random()*30, h);
}
});
// Car paint textures
function createCarPaintTex(baseColor) {
return createCanvasTexture(256, 256, (ctx, w, h) => {
ctx.fillStyle = baseColor;
ctx.fillRect(0, 0, w, h);
// Metallic flakes
for (let i = 0; i < 5000; i++) {
ctx.fillStyle = `rgba(${200+Math.random()*55},${200+Math.random()*55},${200+Math.random()*55},${Math.random()*0.04})`;
ctx.fillRect(Math.random()*w, Math.random()*h, 1, 1);
}
// Clear coat reflection gradient
const gradient = ctx.createLinearGradient(0, 0, 0, h);
gradient.addColorStop(0, 'rgba(255,255,255,0.08)');
gradient.addColorStop(0.5, 'rgba(255,255,255,0)');
gradient.addColorStop(1, 'rgba(255,255,255,0.05)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
});
}
const carPaintObsidian = createCarPaintTex('#1a1a2e');
const carPaintSilver = createCarPaintTex('#8a8a96');
const carPaintBlack = createCarPaintTex('#2d2d38');
// Reception desk wood texture
const woodTex = createCanvasTexture(256, 256, (ctx, w, h) => {
ctx.fillStyle = '#8B7355';
ctx.fillRect(0, 0, w, h);
// Wood grain
for (let j = 0; j < h; j += 2) {
const offset = Math.sin(j * 0.03) * 5 + Math.sin(j * 0.07) * 3;
ctx.strokeStyle = `rgba(${100+Math.random()*40}, ${80+Math.random()*30}, ${50+Math.random()*20}, ${0.1+Math.random()*0.1})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, j);
for (let x = 0; x < w; x += 10) {
ctx.lineTo(x, j + Math.sin((x + offset) * 0.02) * 2);
}
ctx.stroke();
}
});
// ── 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.2;
renderer.outputEncoding = THREE.sRGBEncoding;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
scene.fog = new THREE.FogExp2(0xf0efe8, 0.015);
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 100);
camera.position.set(0, 1.7, -4);
// ── Player ──────────────────────────────────────
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.5, height: 1.7, radius: 0.3,
isLocked: false, currentRoom: 0,
};
const keys = {};
let showMeasurements = false, show360 = false, showWireframe = false, showTextures = true;
// ── Rooms ──────────────────────────────────────
const ROOMS = [
{ name: 'Showroom Principal', x: 0, z: 0, w: 8.2, d: 12.4, h: 3.1 },
{ name: 'Recepción', x: -4.8, z: 0, w: 4.0, d: 6.0, h: 2.8 },
{ name: 'Office Ventas', x: -4.8, z: 5.5, w: 4.0, d: 3.5, h: 2.8 },
{ name: 'Zona Accesorios', x: 5.8, z: 0, w: 3.5, d: 6.0, h: 3.0 },
{ name: 'Sala Espera', x: 5.8, z: 5.5, w: 3.5, d: 3.5, h: 2.7 },
];
// ── Materials ──────────────────────────────────
const materials = {
floorTextured: new THREE.MeshStandardMaterial({ map: floorTex, roughness: 0.25, metalness: 0.08 }),
floorFlat: new THREE.MeshStandardMaterial({ color: 0xd4d0c8, roughness: 0.3, metalness: 0.05 }),
wallTextured: new THREE.MeshStandardMaterial({ map: wallTex, roughness: 0.85, metalness: 0.0 }),
wallFlat: new THREE.MeshStandardMaterial({ color: 0xf5f4f0, roughness: 0.85, metalness: 0.0 }),
darkWallTextured: new THREE.MeshStandardMaterial({ map: darkWallTex, roughness: 0.9, metalness: 0.05 }),
darkWallFlat: new THREE.MeshStandardMaterial({ color: 0x2a2a3e, roughness: 0.9, metalness: 0.0 }),
ceilTextured: new THREE.MeshStandardMaterial({ map: ceilTex, roughness: 0.95, metalness: 0.0 }),
ceilFlat: new THREE.MeshStandardMaterial({ color: 0xfafaf8, roughness: 0.95, metalness: 0.0 }),
glass: new THREE.MeshPhysicalMaterial({ color: 0xaaeeff, transparent: true, opacity: 0.12, roughness: 0.02, metalness: 0.1, transmission: 0.92 }),
accent: new THREE.MeshStandardMaterial({ color: 0x6c5ce7, roughness: 0.4, metalness: 0.3, emissive: 0x6c5ce7, emissiveIntensity: 0.1 }),
woodTextured: new THREE.MeshStandardMaterial({ map: woodTex, roughness: 0.6, metalness: 0.05 }),
woodFlat: new THREE.MeshStandardMaterial({ color: 0x8B7355, roughness: 0.6, metalness: 0.1 }),
};
// Wireframe override
const wireframeMat = new THREE.MeshBasicMaterial({ color: 0x00d2ff, wireframe: true, transparent: true, opacity: 0.5 });
const colliders = [];
const allMeshes = []; // For wireframe toggle
const WALL_THICKNESS = 0.15;
const MIN_WALL_HEIGHT = 1.5; // Only walls taller than this block movement
function addBox(w, h, d, x, y, z, mat, castShadow = true) {
const geo = new THREE.BoxGeometry(w, h, d);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z);
mesh.castShadow = castShadow;
mesh.receiveShadow = true;
scene.add(mesh);
allMeshes.push(mesh);
// Auto-generate collision from any mesh tall enough to be a wall/furniture
if (h >= MIN_WALL_HEIGHT || (h >= 0.4 && y + h/2 <= 1.0)) {
colliders.push({
x: x - w/2,
z: z - d/2,
w: w,
d: d,
minY: y - h/2,
maxY: y + h/2
});
}
return mesh;
}
// ── Build Showroom (SR) ─────────────────────────────
const SR = ROOMS[0]; // Showroom principal
const hw = SR.w/2, hd = SR.d/2;
// Floor & Ceiling
addBox(SR.w, 0.06, SR.d, SR.x, 0, SR.z, materials.floorTextured);
addBox(SR.w, 0.12, SR.d, SR.x, SR.h, SR.z, materials.ceilTextured);
// Ceiling light strips
addBox(0.6, 0.02, SR.d * 0.8, 0, SR.h - 0.08, 0, new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.5 }));
addBox(0.6, 0.02, SR.d * 0.6, -2.5, SR.h - 0.08, 2, new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.4 }));
// ── South wall: glass front with solid frame ──
// Glass panels (still collidable — glass blocks movement)
addBox(SR.w, 2.6, 0.08, SR.x, 1.4, SR.z - hd, materials.glass);
// Solid base below glass
addBox(SR.w, 0.5, 0.12, SR.x, 0.25, SR.z - hd, new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.8, roughness: 0.3 }));
// Solid frame above glass
addBox(SR.w, 0.08, 0.12, SR.x, 2.75, SR.z - hd, new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.8, roughness: 0.3 }));
// Pillars at glass wall corners (structural)
addBox(0.15, SR.h, 0.15, -hw + 0.3, SR.h/2, SR.z - hd + 0.1, new THREE.MeshStandardMaterial({ color: 0x444444, metalness: 0.6, roughness: 0.4 }));
addBox(0.15, SR.h, 0.15, hw - 0.3, SR.h/2, SR.z - hd + 0.1, new THREE.MeshStandardMaterial({ color: 0x444444, metalness: 0.6, roughness: 0.4 }));
// ── North wall: SOLID (no exterior door) ──
addBox(SR.w, SR.h, 0.15, SR.x, SR.h/2, SR.z + hd, materials.darkWallTextured);
// ── West wall: with DOOR (2.4m wide, 2.4m tall) to Reception ──
// Wall below door (0 to 0.3m — threshold)
addBox(0.15, 0.3, 2.4, SR.x - hw, 0.15, 1.0, materials.wallTextured);
// Wall above door (2.7m to ceiling)
addBox(0.15, 0.4, 2.4, SR.x - hw, SR.h - 0.2, 1.0, materials.wallTextured);
// Wall north of door
addBox(0.15, SR.h, 5.0, SR.x - hw, SR.h/2, SR.z + 3.7, materials.wallTextured);
// Wall south of door
addBox(0.15, SR.h, 5.0, SR.x - hw, SR.h/2, SR.z - 3.7, materials.wallTextured);
// ── East wall: with DOOR (2.4m wide, 2.4m tall) to Accessories ──
addBox(0.15, 0.3, 2.4, SR.x + hw, 0.15, 1.0, materials.wallTextured);
addBox(0.15, 0.4, 2.4, SR.x + hw, SR.h - 0.2, 1.0, materials.wallTextured);
addBox(0.15, SR.h, 5.0, SR.x + hw, SR.h/2, SR.z - 5.2, materials.wallTextured);
addBox(0.15, SR.h, 5.0, SR.x + hw, SR.h/2, SR.z + 5.2, materials.wallTextured);
// ── Mercedes logo on dark wall ──────────────────────
// Simplified 3-pointed star
const starShape = new THREE.Shape();
const starR = 0.35;
for (let i = 0; i < 3; i++) {
const angle = (i * 2 * Math.PI / 3) - Math.PI/2;
const x2 = Math.cos(angle) * starR;
const y2 = Math.sin(angle) * starR;
if (i === 0) starShape.moveTo(x2, y2);
else starShape.lineTo(x2, y2);
}
starShape.closePath();
// Circle around star
const circleGeo = new THREE.CircleGeometry(0.45, 32);
const circleMesh = new THREE.Mesh(circleGeo, new THREE.MeshStandardMaterial({ color: 0xcccccc, metalness: 0.9, roughness: 0.1 }));
circleMesh.position.set(-2.0, 2.0, SR.z + hd - 0.1);
scene.add(circleMesh);
allMeshes.push(circleMesh);
const starGeo = new THREE.ShapeGeometry(starShape);
const starMesh = new THREE.Mesh(starGeo, new THREE.MeshStandardMaterial({ color: 0x222222, metalness: 0.8, roughness: 0.2, side: THREE.DoubleSide }));
starMesh.position.set(-2.0, 2.0, SR.z + hd - 0.09);
scene.add(starMesh);
allMeshes.push(starMesh);
// ── Showroom cars ───────────────────────────────────
function buildCar(x, z, rotation, paintTex, paintColor) {
const group = new THREE.Group();
const paintMat = showTextures
? new THREE.MeshStandardMaterial({ map: paintTex, roughness: 0.15, metalness: 0.85 })
: new THREE.MeshStandardMaterial({ color: paintColor, roughness: 0.15, metalness: 0.85 });
// Body
const bodyGeo = new THREE.BoxGeometry(1.85, 0.55, 4.4);
const body = new THREE.Mesh(bodyGeo, paintMat);
body.position.y = 0.55;
body.castShadow = true; body.receiveShadow = true;
group.add(body); allMeshes.push(body);
// Cabin (glass)
const cabinGeo = new THREE.BoxGeometry(1.5, 0.45, 2.0);
const cabinMat = new THREE.MeshPhysicalMaterial({ color: 0x334455, transparent: true, opacity: 0.6, roughness: 0.02, metalness: 0.1, transmission: 0.3 });
const cabin = new THREE.Mesh(cabinGeo, cabinMat);
cabin.position.set(0, 1.05, -0.2);
group.add(cabin); allMeshes.push(cabin);
// Hood slope
const hoodGeo = new THREE.BoxGeometry(1.7, 0.15, 1.4);
const hood = new THREE.Mesh(hoodGeo, paintMat);
hood.position.set(0, 0.9, -1.2);
hood.rotation.x = -0.15;
group.add(hood); allMeshes.push(hood);
// Trunk slope
const trunkGeo = new THREE.BoxGeometry(1.7, 0.15, 1.0);
const trunk = new THREE.Mesh(trunkGeo, paintMat);
trunk.position.set(0, 0.9, 1.2);
trunk.rotation.x = 0.2;
group.add(trunk); allMeshes.push(trunk);
// Wheels
const wheelGeo = new THREE.CylinderGeometry(0.28, 0.28, 0.18, 16);
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 });
const rimGeo = new THREE.CylinderGeometry(0.18, 0.18, 0.19, 8);
const rimMat = new THREE.MeshStandardMaterial({ color: 0xaaaaaa, metalness: 0.9, roughness: 0.1 });
[[-0.85, -1.5], [-0.85, 1.5], [0.85, -1.5], [0.85, 1.5]].forEach(([wx, wz]) => {
const wheel = new THREE.Mesh(wheelGeo, wheelMat);
wheel.rotation.z = Math.PI/2;
wheel.position.set(wx, 0.28, wz);
group.add(wheel);
const rim = new THREE.Mesh(rimGeo, rimMat);
rim.rotation.z = Math.PI/2;
rim.position.set(wx, 0.28, wz);
group.add(rim);
});
// Headlights
const lightGeo = new THREE.BoxGeometry(0.25, 0.08, 0.05);
const lightMat = new THREE.MeshStandardMaterial({ color: 0xffffee, emissive: 0xffffcc, emissiveIntensity: 0.4 });
[-0.65, 0.65].forEach(ox => {
const hl = new THREE.Mesh(lightGeo, lightMat);
hl.position.set(ox, 0.6, -2.2);
group.add(hl);
});
// Tail lights
const tailMat = new THREE.MeshStandardMaterial({ color: 0xff2222, emissive: 0xff0000, emissiveIntensity: 0.3 });
[-0.65, 0.65].forEach(ox => {
const tl = new THREE.Mesh(lightGeo, tailMat);
tl.position.set(ox, 0.6, 2.2);
group.add(tl);
});
// Side mirror (left)
const mirrorGeo = new THREE.BoxGeometry(0.1, 0.08, 0.15);
const mirror = new THREE.Mesh(mirrorGeo, paintMat);
mirror.position.set(-0.95, 1.0, -0.5);
group.add(mirror);
group.position.set(x, 0, z);
group.rotation.y = rotation;
scene.add(group);
// Collider for the car
colliders.push({ x: x - 1.0, z: z - 2.3, w: 2.0, d: 4.6, minY: 0, maxY: 1.5 });
}
buildCar(0, -2.5, Math.PI * 0.02, carPaintObsidian, 0x1a1a2e); // Main display
buildCar(-2.5, 3.5, Math.PI * 0.98, carPaintSilver, 0x8a8a96); // Second car
buildCar(2.0, -5.5, Math.PI * 0.5, carPaintBlack, 0x2d2d38); // Third car
// ── Reception (RC) — fully enclosed ──────────────────
const RC = ROOMS[1];
// South wall: SOLID
addBox(RC.w, RC.h, 0.15, RC.x, RC.h/2, RC.z - RC.d/2, materials.wallTextured);
// North wall: with door to sales office
addBox(RC.w * 0.35, RC.h, 0.15, RC.x - RC.w * 0.325, RC.h/2, RC.z + RC.d/2, materials.wallTextured);
addBox(RC.w * 0.35, RC.h, 0.15, RC.x + RC.w * 0.325, RC.h/2, RC.z + RC.d/2, materials.wallTextured);
addBox(RC.w * 0.3, 0.5, 0.15, RC.x, RC.h - 0.25, RC.z + RC.d/2, materials.wallTextured); // above door
// West wall: SOLID
addBox(0.15, RC.h, RC.d, RC.x - RC.w/2, RC.h/2, RC.z, materials.wallTextured);
// East wall: with DOOR to showroom (2.4m wide, matching showroom west wall)
addBox(0.15, 0.3, 2.4, RC.x + RC.w/2, 0.15, RC.z - 0.2, materials.wallTextured); // threshold
addBox(0.15, 0.4, 2.4, RC.x + RC.w/2, RC.h - 0.2, RC.z - 0.2, materials.wallTextured); // lintel
addBox(0.15, RC.h, 1.8, RC.x + RC.w/2, RC.h/2, RC.z - 2.1, materials.wallTextured); // south segment
addBox(0.15, RC.h, 1.8, RC.x + RC.w/2, RC.h/2, RC.z + 1.7, materials.wallTextured); // north segment
// Floor & Ceiling
addBox(RC.w, 0.06, RC.d, RC.x, 0, RC.z, materials.floorTextured);
addBox(RC.w, 0.12, RC.d, RC.x, RC.h, RC.z, materials.ceilTextured);
// Reception desk
addBox(2.2, 0.9, 0.8, RC.x + 0.3, 0.45, RC.z - 1.0, materials.woodTextured);
// Counter top
addBox(2.4, 0.06, 0.9, RC.x + 0.3, 0.93, RC.z - 1.0, new THREE.MeshStandardMaterial({ color: 0xf0ece0, roughness: 0.3, metalness: 0.05 }));
// Monitor on desk
addBox(0.5, 0.35, 0.03, RC.x + 0.3, 1.2, RC.z - 1.0, new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.1, metalness: 0.5 }));
addBox(0.15, 0.03, 0.15, RC.x + 0.3, 0.95, RC.z - 0.85, new THREE.MeshStandardMaterial({ color: 0x222222 }));
// ── Sales Office (OF) — fully enclosed ──────────────
const OF = ROOMS[2];
// South wall: with DOOR to reception
addBox(OF.w * 0.3, OF.h, 0.15, OF.x - OF.w * 0.25, OF.h/2, OF.z - OF.d/2, materials.wallTextured);
addBox(OF.w * 0.3, OF.h, 0.15, OF.x + OF.w * 0.25, OF.h/2, OF.z - OF.d/2, materials.wallTextured);
addBox(OF.w * 0.4, 0.5, 0.15, OF.x, OF.h - 0.25, OF.z - OF.d/2, materials.wallTextured);
// North: SOLID
addBox(OF.w, OF.h, 0.15, OF.x, OF.h/2, OF.z + OF.d/2, materials.wallTextured);
// West: SOLID
addBox(0.15, OF.h, OF.d, OF.x - OF.w/2, OF.h/2, OF.z, materials.wallTextured);
// East: SOLID (connects to reception interior)
addBox(0.15, OF.h, OF.d, OF.x + OF.w/2, OF.h/2, OF.z, materials.wallTextured);
addBox(OF.w, 0.06, OF.d, OF.x, 0, OF.z, materials.floorTextured);
addBox(OF.w, 0.12, OF.d, OF.x, OF.h, OF.z, materials.ceilTextured);
// Desk
addBox(1.8, 0.75, 0.8, OF.x, 0.375, OF.z - 0.5, materials.woodTextured);
addBox(0.5, 0.3, 0.03, OF.x, 0.65, OF.z - 0.5, new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.1, metalness: 0.5 }));
// ── Accessories Area (AC) — fully enclosed ────────────
const AC = ROOMS[3];
// East wall: SOLID
addBox(0.15, AC.h, AC.d, AC.x + AC.w/2, AC.h/2, AC.z, materials.wallTextured);
// West wall: with DOOR to showroom (2.4m wide)
addBox(0.15, 0.3, 2.4, AC.x - AC.w/2, 0.15, AC.z - 0.2, materials.wallTextured); // threshold
addBox(0.15, 0.4, 2.4, AC.x - AC.w/2, AC.h - 0.2, AC.z - 0.2, materials.wallTextured); // lintel
addBox(0.15, AC.h, 1.8, AC.x - AC.w/2, AC.h/2, AC.z - 2.1, materials.wallTextured); // south segment
addBox(0.15, AC.h, 1.8, AC.x - AC.w/2, AC.h/2, AC.z + 1.7, materials.wallTextured); // north segment
// South wall: SOLID
addBox(AC.w, AC.h, 0.15, AC.x, AC.h/2, AC.z - AC.d/2, materials.wallTextured);
// North wall: with DOOR to waiting area
addBox(AC.w * 0.3, AC.h, 0.15, AC.x - AC.w * 0.25, AC.h/2, AC.z + AC.d/2, materials.wallTextured);
addBox(AC.w * 0.3, AC.h, 0.15, AC.x + AC.w * 0.25, AC.h/2, AC.z + AC.d/2, materials.wallTextured);
addBox(AC.w * 0.4, 0.5, 0.15, AC.x, AC.h - 0.25, AC.z + AC.d/2, materials.wallTextured);
addBox(AC.w, 0.06, AC.d, AC.x, 0, AC.z, materials.floorTextured);
addBox(AC.w, 0.12, AC.d, AC.x, AC.h, AC.z, materials.ceilTextured);
// Accessory shelves (3 levels)
for (let i = 0; i < 3; i++) {
const shelfY = 0.7 + i * 0.7;
addBox(2.5, 0.04, 0.35, AC.x, shelfY, AC.z - AC.d/2 + 0.4, materials.woodTextured);
}
// Floor display stand (center)
addBox(1.0, 0.06, 1.0, AC.x + 0.5, 0.5, AC.z + 0.5, new THREE.MeshStandardMaterial({ color: 0xf0ece0, roughness: 0.2, metalness: 0.1 }));
addBox(1.0, 0.5, 1.0, AC.x + 0.5, 0.25, AC.z + 0.5, new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.7, roughness: 0.3 }));
// ── Waiting Area (WA) — fully enclosed ──────────────
const WA = ROOMS[4];
// South wall: SOLID
addBox(WA.w, WA.h, 0.15, WA.x, WA.h/2, WA.z - WA.d/2, materials.wallTextured);
// North wall: with DOOR to accessories
addBox(WA.w * 0.3, WA.h, 0.15, WA.x - WA.w * 0.25, WA.h/2, WA.z + WA.d/2, materials.wallTextured);
addBox(WA.w * 0.3, WA.h, 0.15, WA.x + WA.w * 0.25, WA.h/2, WA.z + WA.d/2, materials.wallTextured);
addBox(WA.w * 0.4, 0.5, 0.15, WA.x, WA.h - 0.25, WA.z + WA.d/2, materials.wallTextured);
// West wall: SOLID
addBox(0.15, WA.h, WA.d, WA.x - WA.w/2, WA.h/2, WA.z, materials.wallTextured);
// East wall: with DOOR to showroom (2.4m wide)
addBox(0.15, 0.3, 2.4, WA.x + WA.w/2, 0.15, WA.z - 0.2, materials.wallTextured); // threshold
addBox(0.15, 0.4, 2.4, WA.x + WA.w/2, WA.h - 0.2, WA.z - 0.2, materials.wallTextured); // lintel
addBox(0.15, WA.h, 1.8, WA.x + WA.w/2, WA.h/2, WA.z - 2.1, materials.wallTextured); // south segment
addBox(0.15, WA.h, 1.8, WA.x + WA.w/2, WA.h/2, WA.z + 1.7, materials.wallTextured); // north segment
addBox(WA.w, 0.06, WA.d, WA.x, 0, WA.z, materials.floorTextured);
addBox(WA.w, 0.12, WA.d, WA.x, WA.h, WA.z, materials.ceilTextured);
// Sofa (L-shaped)
addBox(1.8, 0.4, 0.7, WA.x + 0.3, 0.25, WA.z - 0.5, new THREE.MeshStandardMaterial({ color: 0x444450, roughness: 0.8 }));
addBox(1.8, 0.5, 0.1, WA.x + 0.3, 0.45, WA.z - 0.85, new THREE.MeshStandardMaterial({ color: 0x444450, roughness: 0.8 })); // back
addBox(0.7, 0.4, 0.7, WA.x - 0.8, 0.25, WA.z + 0.3, new THREE.MeshStandardMaterial({ color: 0x444450, roughness: 0.8 }));
addBox(0.1, 0.5, 0.7, WA.x - 1.15, 0.45, WA.z + 0.3, new THREE.MeshStandardMaterial({ color: 0x444450, roughness: 0.8 })); // back
// Coffee table
addBox(0.8, 0.03, 0.5, WA.x + 0.3, 0.4, WA.z + 0.5, new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.8, roughness: 0.2 }));
// ── CONNECTING CORRIDORS (floor + ceiling in gaps between rooms) ──
// Corridor: Showroom ↔ Reception (west side, x≈-4.5, z from -0.2 to +0.2)
// Already handled by overlapping door frames
// Corridor: Reception → Sales Office (north side of reception)
// RC north at z=3.0, OF south at z=3.75 — gap of 0.75m
// Add short corridor walls, floor, ceiling
const corridorH = 2.8;
addBox(0.15, corridorH, 1.0, RC.x - RC.w/2, corridorH/2, RC.z + RC.d/2 + 0.5, materials.wallTextured); // left wall
addBox(0.15, corridorH, 1.0, RC.x + RC.w/2, corridorH/2, RC.z + RC.d/2 + 0.5, materials.wallTextured); // right wall
addBox(RC.w, 0.06, 1.0, RC.x, 0, RC.z + RC.d/2 + 0.5, materials.floorTextured); // floor
addBox(RC.w, 0.12, 1.0, RC.x, corridorH, RC.z + RC.d/2 + 0.5, materials.ceilTextured); // ceiling
// Corridor: Accessories ↔ Waiting Area (north side of accessories)
// AC north at z=3.0, WA south at z=3.75 — same gap
addBox(0.15, corridorH, 1.0, AC.x - AC.w/2, corridorH/2, AC.z + AC.d/2 + 0.5, materials.wallTextured);
addBox(0.15, corridorH, 1.0, AC.x + AC.w/2, corridorH/2, AC.z + AC.d/2 + 0.5, materials.wallTextured);
addBox(AC.w, 0.06, 1.0, AC.x, 0, AC.z + AC.d/2 + 0.5, materials.floorTextured);
addBox(AC.w, 0.12, 1.0, AC.x, corridorH, AC.z + AC.d/2 + 0.5, materials.ceilTextured);
// ── Accent LED strips ──────────────────────────
// Showroom LED strip along dark wall
const ledGeo = new THREE.BoxGeometry(SR.w * 0.3, 0.02, 0.02);
const ledMat = new THREE.MeshStandardMaterial({ color: 0x6c5ce7, emissive: 0x6c5ce7, emissiveIntensity: 2 });
addBox(SR.w * 0.3, 0.02, 0.02, -2.0, 0.4, SR.z + hd - 0.08, ledMat);
// Floor accent light strip
const floorLedMat = new THREE.MeshStandardMaterial({ color: 0x00d2ff, emissive: 0x00d2ff, emissiveIntensity: 0.5 });
addBox(SR.w, 0.03, 0.03, 0, 0.03, SR.z - hd + 0.15, floorLedMat);
// ── Lighting ────────────────────────────────────
const ambientLight = new THREE.AmbientLight(0xfff5e8, 0.35);
scene.add(ambientLight);
// Showroom main lights
[[0, 2.97, -3], [0, 2.97, 3]].forEach(([x, y, z]) => {
const light = new THREE.PointLight(0xfff5e6, 0.9, 15);
light.position.set(x, y, z);
light.castShadow = true;
light.shadow.mapSize.set(1024, 1024);
scene.add(light);
});
// Spotlights on cars
[[0, 2.9, -2.5], [-2.5, 2.9, 3.5], [2, 2.9, -5.5]].forEach(([x, y, z], i) => {
const spot = new THREE.SpotLight(0xffffff, 0.7, 12, Math.PI/5, 0.5, 1);
spot.position.set(x, y, z);
spot.target.position.set(x, 0, z);
spot.castShadow = true;
scene.add(spot);
scene.add(spot.target);
});
// Reception warm light
const recepLight = new THREE.PointLight(0xffe8cc, 0.5, 8);
recepLight.position.set(-4.8, 2.6, 0);
scene.add(recepLight);
// Accessory area
const accLight = new THREE.PointLight(0xffffff, 0.4, 8);
accLight.position.set(5.8, 2.8, 0);
scene.add(accLight);
// ── Measurement Lines ────────────────────────────
const measurementGroup = new THREE.Group();
measurementGroup.visible = false;
scene.add(measurementGroup);
function addMeasureLine(x1, z1, x2, z2, y, 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 });
measurementGroup.add(new THREE.Line(lineGeo, lineMat));
[x1, x2].forEach((x, i) => {
const z = i === 0 ? z1 : z2;
const cap = new THREE.Mesh(new THREE.SphereGeometry(0.04), new THREE.MeshBasicMaterial({ color: 0x00d2ff }));
cap.position.set(x, y, z);
measurementGroup.add(cap);
});
// Label
const c2 = document.createElement('canvas');
c2.width = 256; c2.height = 64;
const ctx = c2.getContext('2d');
ctx.fillStyle = '#00d2ff'; ctx.font = 'bold 30px Inter, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(value, 128, 40);
const tex = new THREE.CanvasTexture(c2);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false }));
sprite.position.set((x1+x2)/2, y+0.3, (z1+z2)/2);
sprite.scale.set(2, 0.5, 1);
measurementGroup.add(sprite);
}
addMeasureLine(-4.1, -6.2, 4.1, -6.2, 1.8, '8.24 m');
addMeasureLine(4.1, -6.2, 4.1, 6.2, 1.8, '12.38 m');
// ── 360 Hotspots ──────────────────────────────────
const hotspotsGroup = new THREE.Group();
hotspotsGroup.visible = false;
scene.add(hotspotsGroup);
[{x:0,z:0,label:'Center'},{x:0,z:-2.5,label:'GLE Display'},{x:-4.8,z:0,label:'Reception'},{x:5.8,z:0,label:'Accessories'},{x:5.8,z:5.5,label:'Waiting Area'}].forEach((h,i) => {
const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.12), new THREE.MeshBasicMaterial({ color: 0x6c5ce7 }));
sphere.position.set(h.x, 1.5, h.z);
hotspotsGroup.add(sphere);
const ring = new THREE.Mesh(new THREE.RingGeometry(0.2, 0.25, 24), new THREE.MeshBasicMaterial({ color: 0x6c5ce7, transparent: true, opacity: 0.6, side: THREE.DoubleSide }));
ring.position.set(h.x, 1.5, h.z);
ring.rotation.x = -Math.PI/2;
hotspotsGroup.add(ring);
});
// ── Collisions auto-generated from addBox calls ──
// All walls/furniture with height >= 1.5m or low items >= 0.4m are solid
// No manual colliders needed — derived from scene geometry
console.log('Colliders:', colliders.length, 'collision bodies generated from scene meshes');
// ── Toggle Functions ─────────────────────────────
function toggleMeasures() { showMeasurements = !showMeasurements; measurementGroup.visible = showMeasurements; document.getElementById('infoPanel').classList.toggle('hidden', !showMeasurements); }
function toggle360() { show360 = !show360; hotspotsGroup.visible = show360; const b=document.getElementById('textureBadge'); b.textContent=show360?'📸 360° Photo Mode':''; b.classList.toggle('visible',show360); }
function toggleWireframe() {
showWireframe = !showWireframe;
allMeshes.forEach(m => { m.material = showWireframe ? wireframeMat : m.userData.origMat || m.material; });
if (!showWireframe) allMeshes.forEach(m => { if(m.userData.origMat) m.material = m.userData.origMat; });
}
function toggleTextures() {
showTextures = !showTextures;
const b = document.getElementById('textureBadge');
b.textContent = showTextures ? '🎨 Photo textures active' : '⬜ Flat materials';
b.classList.toggle('visible', true);
setTimeout(() => b.classList.toggle('visible', false), 2000);
// Reload with/without textures — simplified toggle
allMeshes.forEach(m => {
if (m.userData.texturedMat && m.userData.flatMat) {
m.material = showTextures ? m.userData.texturedMat : m.userData.flatMat;
}
});
}
function shareLink() {
navigator.clipboard?.writeText('https://interiorscan.app/w/mercedes-benz-vegar-elche');
const btn = event.target; const orig = btn.textContent;
btn.textContent = '✓ Copied!'; setTimeout(() => btn.textContent = orig, 2000);
}
// Save original materials for wireframe toggle
allMeshes.forEach(m => { m.userData.origMat = m.material; });
// ── Pointer Lock ─────────────────────────────────
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);
document.addEventListener('mousemove', e => {
if (!player.isLocked) return;
const s = 0.002;
player.euler.y -= e.movementX * s;
player.euler.x -= e.movementY * s;
player.euler.x = Math.max(-Math.PI/2.2, Math.min(Math.PI/2.2, player.euler.x));
});
document.addEventListener('keydown', e => {
keys[e.code] = true;
if(e.code==='KeyM') toggleMeasures();
if(e.code==='KeyE') toggle360();
if(e.code==='KeyF') toggleWireframe();
if(e.code==='KeyT') toggleTextures();
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
// Touch
let touchX=0, touchY=0;
canvas.addEventListener('touchstart', e => { if(!player.isLocked) lockPointer(); touchX=e.touches[0].clientX; touchY=e.touches[0].clientY; });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if(!player.isLocked) return; const t=e.touches[0]; player.euler.y-=(t.clientX-touchX)*0.004; player.euler.x-=(t.clientY-touchY)*0.004; player.euler.x=Math.max(-Math.PI/2.2,Math.min(Math.PI/2.2,player.euler.x)); touchX=t.clientX; touchY=t.clientY; }, {passive:false});
// ── Collision Detection ──────────────────────────
function checkCollision(nx, nz) {
for (const c of colliders) {
if (nx+player.radius>c.x && nx-player.radius<c.x+c.w && nz+player.radius>c.z && nz-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() {
for (let i=0; i<ROOMS.length; i++) {
const r=ROOMS[i];
if (player.position.x>r.x-r.w/2-0.5 && player.position.x<r.x+r.w/2+0.5 && player.position.z>r.z-r.d/2-0.5 && player.position.z<r.z+r.d/2+0.5) return i;
}
return 0;
}
// ── Minimap ──────────────────────────────────────
const mmCanvas = document.getElementById('minimapCanvas');
const mmCtx = mmCanvas.getContext('2d');
mmCanvas.width = 400; mmCanvas.height = 400;
function drawMinimap() {
const ctx=mmCtx, s=16, cx=200, cy=200;
ctx.clearRect(0,0,400,400);
ctx.fillStyle='#0a0a0f'; ctx.fillRect(0,0,400,400);
ROOMS.forEach((r,i) => {
const rx=cx+r.x*s, ry=cy+r.z*s, rw=r.w*s, rd=r.d*s;
ctx.fillStyle = i===player.currentRoom?'rgba(108,92,231,0.25)':'rgba(255,255,255,0.06)';
ctx.strokeStyle = i===player.currentRoom?'#6c5ce7':'rgba(255,255,255,0.15)';
ctx.lineWidth=1.5;
ctx.fillRect(rx-rw/2,ry-rd/2,rw,rd);
ctx.strokeRect(rx-rw/2,ry-rd/2,rw,rd);
ctx.fillStyle='rgba(255,255,255,0.4)'; ctx.font='8px Inter'; ctx.textAlign='center';
ctx.fillText(r.name,rx,ry+3);
});
const px=cx+player.position.x*s, py=cy+player.position.z*s, a=player.euler.y;
ctx.fillStyle='rgba(0,210,255,0.1)';
ctx.beginPath(); ctx.moveTo(px,py); ctx.arc(px,py,35,-a-0.5,-a+0.5); ctx.closePath(); ctx.fill();
ctx.fillStyle='#00d2ff'; ctx.beginPath(); ctx.arc(px,py,4,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='#00d2ff'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(px,py); ctx.lineTo(px+Math.sin(-a)*18,py+Math.cos(a)*18); ctx.stroke();
}
// ── Game Loop ───────────────────────────────────
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.1);
if (player.isLocked) {
const speed = keys['ShiftLeft'] ? player.sprintSpeed : player.speed;
const fwd = 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(fwd.clone().multiplyScalar(speed*delta));
if(keys['KeyS']) player.velocity.add(fwd.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));
const nx = player.position.x + player.velocity.x;
const nz = player.position.z + player.velocity.z;
if(!checkCollision(nx, player.position.z)) player.position.x = nx;
if(!checkCollision(player.position.x, nz)) player.position.z = nz;
player.velocity.multiplyScalar(0.85);
const moving = player.velocity.length() > 0.3;
player.position.y = player.height + (moving ? Math.sin(clock.elapsedTime*(keys['ShiftLeft']?12:8))*0.04 : 0);
camera.quaternion.setFromEuler(player.euler);
const newRoom = detectRoom();
if(newRoom !== player.currentRoom) {
player.currentRoom = newRoom;
const r = ROOMS[newRoom];
document.getElementById('roomName').textContent = r.name;
document.getElementById('roomSize').textContent = `${r.w}m × ${r.d}m · ${(r.w*r.d).toFixed(1)}`;
if(showMeasurements) {
document.getElementById('infoRoom').textContent = r.name;
document.getElementById('infoDims').textContent = `${r.w} × ${r.d} m`;
document.getElementById('infoArea').textContent = `${(r.w*r.d).toFixed(2)}`;
document.getElementById('infoHeight').textContent = `${r.h} m`;
}
}
}
camera.position.copy(player.position);
if(hotspotsGroup.visible) {
const t=clock.elapsedTime;
hotspotsGroup.children.forEach((c,i) => {
if(c.geometry?.type==='RingGeometry') { const s=1+Math.sin(t*3+i)*0.15; c.scale.set(s,s,1); c.material.opacity=0.4+Math.sin(t*3+i)*0.3; }
});
}
drawMinimap();
renderer.render(scene, camera);
}
function onResize() {
const c = document.querySelector('.viewer-container');
const w=c.clientWidth, h=c.clientHeight;
renderer.setSize(w,h);
camera.aspect = w/h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', onResize);
onResize();
animate();
</script>
</body>
</html>