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
971 lines
50 KiB
HTML
971 lines
50 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 — 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)} m²`;
|
||
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)} m²`;
|
||
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> |