Files
interiorscan/demo-v3-casa.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

582 lines
34 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 — Casa 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: 12px; padding: 6px 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: 15px; color: var(--accent); letter-spacing: -0.5px; }
.topbar-logo svg { width: 22px; height: 22px; }
.topbar-address { font-size: 12px; color: var(--text-muted); flex: 1; }
.topbar-status { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--success); background: rgba(16,185,129,0.1); padding: 3px 8px; border-radius: 10px; }
.topbar-status::before { content:''; width:5px; height:5px; border-radius:50%; background:var(--success); animation:pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
.btn { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border); background: var(--surface-2); color: var(--text); font-size: 11px; font-family: 'Inter',sans-serif; cursor: pointer; transition: all 0.15s; display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.btn:hover { background: var(--border); }
.btn-primary { background: var(--primary); border-color: var(--primary); color: white; }
.btn-primary:hover { background: #7d6ff0; }
.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.4; }
.crosshair svg { width: 24px; height: 24px; }
.minimap { position: absolute; top: 12px; right: 12px; width: 220px; height: 220px; background: rgba(10,10,15,0.85); backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: 10px; z-index: 50; overflow: hidden; }
.minimap canvas { width: 100%; height: 100%; }
.minimap-label { position: absolute; bottom: 4px; left: 8px; font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; }
.room-label { position: absolute; top: 12px; left: 12px; z-index: 50; background: rgba(10,10,15,0.85); backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: 8px; padding: 8px 14px; }
.room-label-name { font-size: 15px; font-weight: 600; color: var(--text); }
.room-label-size { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.info-panel { position: absolute; bottom: 70px; left: 12px; z-index: 50; background: rgba(10,10,15,0.85); backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; font-family: 'JetBrains Mono', monospace; display: flex; flex-direction: column; gap: 3px; transition: all 0.3s; }
.info-panel.hidden { display: none; }
.info-row { display: flex; justify-content: space-between; gap: 20px; font-size: 11px; }
.info-label { color: var(--text-muted); }
.info-value { color: var(--accent); }
.pipeline-bar { position: absolute; bottom: 12px; right: 12px; z-index: 50; background: rgba(10,10,15,0.85); backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; font-size: 11px; max-width: 200px; }
.pipeline-title { color: var(--accent); font-weight: 600; margin-bottom: 5px; font-size: 11px; }
.pipeline-step { display: flex; align-items: center; gap: 5px; color: var(--text-muted); margin: 2px 0; font-size: 10px; }
.pipeline-step .dot { width: 5px; height: 5px; border-radius: 50%; }
.pipeline-step .dot.done { background: var(--success); }
.pipeline-step .dot.active { background: var(--accent); animation: pulse 1s infinite; }
/* Upload panel */
.upload-panel { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); z-index: 300; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 32px; max-width: 480px; width: 90%; text-align: center; }
.upload-panel h2 { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
.upload-panel .subtitle { font-size: 14px; color: var(--text-muted); margin-bottom: 20px; line-height: 1.5; }
.upload-zone { border: 2px dashed var(--border); border-radius: 12px; padding: 24px; margin: 16px 0; cursor: pointer; transition: all 0.2s; }
.upload-zone:hover { border-color: var(--accent); background: rgba(0,210,255,0.05); }
.upload-zone svg { width: 40px; height: 40px; color: var(--text-muted); margin-bottom: 8px; }
.upload-zone .label { font-size: 13px; color: var(--text-muted); }
.upload-zone .sublabel { font-size: 11px; color: var(--text-muted); opacity: 0.6; margin-top: 4px; }
.or-divider { font-size: 12px; color: var(--text-muted); margin: 12px 0; }
.sample-btn { padding: 10px 24px; border-radius: 8px; border: 1px solid var(--primary); background: rgba(108,92,231,0.1); color: var(--primary); font-size: 13px; cursor: pointer; transition: all 0.2s; font-family: 'Inter',sans-serif; }
.sample-btn:hover { background: var(--primary); color: white; }
.step-badges { display: flex; justify-content: center; gap: 8px; margin-top: 20px; }
.step-badge { font-size: 10px; padding: 3px 10px; border-radius: 10px; background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
.step-badge.active { background: rgba(0,210,255,0.1); color: var(--accent); border-color: var(--accent); }
.controls-hint {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
background: rgba(10,10,15,0.85); backdrop-filter: blur(12px); border: 1px solid var(--border);
border-radius: 10px; padding: 8px 16px; display: flex; gap: 16px; z-index: 50;
font-size: 11px; color: var(--text-muted); transition: opacity 0.5s;
}
.control-group { display: flex; align-items: center; gap: 4px; }
kbd { background: var(--surface-2); border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px; font-family: 'JetBrains Mono',monospace; font-size: 10px; color: var(--text); }
@media (max-width: 768px) {
.minimap { width: 140px; height: 140px; }
.controls-hint { gap: 10px; font-size: 10px; }
.upload-panel { padding: 20px; }
}
</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">📍 C/ Altabix 24, 3ºB · Elche</div>
<div class="topbar-status">87 fotos procesadas</div>
<div style="display:flex;gap:6px;">
<button class="btn" id="btnMeasures" onclick="toggleMeasures()">📐 Medidas</button>
<button class="btn" id="btnWireframe" onclick="toggleWireframe()">🔲 Malla</button>
<button class="btn" id="btnTextures" onclick="toggleTextures()">🎨 Texturas</button>
<button class="btn btn-primary" onclick="shareLink()">🔗 Compartir</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">Salón</div>
<div class="room-label-size" id="roomSize">5.2m × 4.1m · 21.3 m²</div>
</div>
<div class="minimap"><canvas id="minimapCanvas"></canvas><div class="minimap-label">Plano cargado</div></div>
<div class="info-panel hidden" id="infoPanel">
<div class="info-row"><span class="info-label">Estancia:</span><span class="info-value" id="infoRoom">Salón</span></div>
<div class="info-row"><span class="info-label">Dimensiones:</span><span class="info-value" id="infoDims">5.2 × 4.1 m</span></div>
<div class="info-row"><span class="info-label">Superficie:</span><span class="info-value" id="infoArea">21.32 m²</span></div>
<div class="info-row"><span class="info-label">Altura:</span><span class="info-value" id="infoHeight">2.70 m</span></div>
</div>
<div class="pipeline-bar">
<div class="pipeline-title">📷 Pipeline de reconstrucción</div>
<div class="pipeline-step"><span class="dot done"></span> Plano catastral → muros</div>
<div class="pipeline-step"><span class="dot done"></span> 87 fotos → COLMAP SfM</div>
<div class="pipeline-step"><span class="dot done"></span> OpenMVS → malla densa</div>
<div class="pipeline-step"><span class="dot active"></span> Texturizado foto → malla ✓</div>
</div>
<div class="controls-hint" id="controlsHint">
<div class="control-group"><kbd>WASD</kbd> Mover</div>
<div class="control-group"><kbd>Ratón</kbd> Mirar</div>
<div class="control-group"><kbd>Shift</kbd> Correr</div>
<div class="control-group"><kbd>M</kbd> Medidas</div>
<div class="control-group"><kbd>T</kbd> Texturas</div>
<div class="control-group"><kbd>F</kbd> Malla</div>
</div>
<div class="upload-panel" id="uploadPanel">
<h2>Carga un plano</h2>
<div class="subtitle">Sube el plano arquitectónico o catastral para definir la geometría. Luego las fotos texturizan el interior.</div>
<div class="upload-zone" id="uploadZone">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div class="label">Arrastra un plano (DXF, SVG, PNG, PDF)</div>
<div class="sublabel">O pega una captura del catastro</div>
</div>
<div class="or-divider">— o usa una demo —</div>
<button class="sample-btn" onclick="startDemo()">🏠 Piso en Elche (demo)</button>
<div class="step-badges">
<span class="step-badge active">① Plano → Muros</span>
<span class="step-badge">② Fotos → Texturas</span>
<span class="step-badge">③ Explorar</span>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ── Procedural Textures ──────────────────────────────
function canvasTex(w, h, fn) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
const ctx = c.getContext('2d'); fn(ctx, w, h);
const t = new THREE.CanvasTexture(c); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.encoding = THREE.sRGBEncoding; return t;
}
// Textures
const floorTex = canvasTex(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#d8d0c4'; ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 20000; i++) { ctx.fillStyle = `rgba(${180+Math.random()*40},${170+Math.random()*40},${155+Math.random()*40},${Math.random()*0.06})`; ctx.fillRect(Math.random()*w, Math.random()*h, 2+Math.random()*3, 1+Math.random()*2); }
ctx.strokeStyle = 'rgba(160,150,135,0.25)'; ctx.lineWidth = 1.2;
for (let y = 0; y <= h; y += 64) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
for (let x = 0; x <= w; x += 128) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
}); floorTex.repeat.set(4, 4);
const wallTex = canvasTex(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#f2f0ea'; ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 15000; i++) { ctx.fillStyle = `rgba(${235+Math.random()*20},${232+Math.random()*20},${225+Math.random()*20},${Math.random()*0.05})`; ctx.fillRect(Math.random()*w, Math.random()*h, 3+Math.random()*5, 1+Math.random()*2); }
}); wallTex.repeat.set(3, 1);
const wallBlueTex = canvasTex(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#e8edf2'; ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 12000; i++) { ctx.fillStyle = `rgba(${210+Math.random()*30},${220+Math.random()*25},${235+Math.random()*20},${Math.random()*0.05})`; ctx.fillRect(Math.random()*w, Math.random()*h, 2+Math.random()*3, 1+Math.random()*2); }
}); wallBlueTex.repeat.set(3, 1);
const kitchenTex = canvasTex(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#f5f3ee'; ctx.fillRect(0, 0, w, h);
for (let y = 0; y < h; y += 32) { const bright = y % 64 === 0 ? 0.03 : 0; ctx.fillStyle = `rgba(200,195,185,${bright + Math.random()*0.02})`; ctx.fillRect(0, y, w, 32); }
}); kitchenTex.repeat.set(3, 1);
const bathTex = canvasTex(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#e8f0f0'; ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(180,200,200,0.35)'; ctx.lineWidth = 1;
for (let y = 0; y <= h; y += 48) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
for (let x = 0; x <= w; x += 48) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
}); bathTex.repeat.set(3, 1);
const ceilTex = canvasTex(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#fafaf8'; ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(200,198,190,0.3)'; ctx.lineWidth = 1;
for (let x = 0; x <= w; x += 64) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
}); ceilTex.repeat.set(4, 4);
// Room materials
const M = {
floor: new THREE.MeshStandardMaterial({ map: floorTex, roughness: 0.25, metalness: 0.05 }),
floorKitchen: new THREE.MeshStandardMaterial({ color: 0xc8bfb0, roughness: 0.2, metalness: 0.05 }),
floorBath: new THREE.MeshStandardMaterial({ color: 0xd0ddd8, roughness: 0.15, metalness: 0.1 }),
wall: new THREE.MeshStandardMaterial({ map: wallTex, roughness: 0.85 }),
wallBlue: new THREE.MeshStandardMaterial({ map: wallBlueTex, roughness: 0.85 }),
wallKitchen: new THREE.MeshStandardMaterial({ map: kitchenTex, roughness: 0.7 }),
wallBath: new THREE.MeshStandardMaterial({ map: bathTex, roughness: 0.6 }),
ceil: new THREE.MeshStandardMaterial({ map: ceilTex, roughness: 0.95 }),
door: new THREE.MeshStandardMaterial({ color: 0x8B7355, roughness: 0.6, metalness: 0.1 }),
glass: new THREE.MeshPhysicalMaterial({ color: 0xaaeeff, transparent: true, opacity: 0.12, roughness: 0.02, metalness: 0.1, transmission: 0.9 }),
counter: new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.3, metalness: 0.5 }),
wood: new THREE.MeshStandardMaterial({ color: 0x9B7B4F, roughness: 0.55, metalness: 0.1 }),
white: new THREE.MeshStandardMaterial({ color: 0xf8f8f8, roughness: 0.9 }),
mirror: new THREE.MeshStandardMaterial({ color: 0xccddff, roughness: 0.02, metalness: 0.95 }),
};
// ── Three.js ──────────────────────────────────────
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(0x7ec8e3);
scene.fog = new THREE.FogExp2(0xf0efe8, 0.012);
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 100);
camera.position.set(0, 1.65, 0);
// ── Player ─────────────────────────────────────────
const player = { position: new THREE.Vector3(0, 1.65, 0), velocity: new THREE.Vector3(), euler: new THREE.Euler(0,0,0,'YXZ'), speed: 3.5, sprintSpeed: 6, height: 1.65, radius: 0.25, isLocked: false, currentRoom: 0 };
const keys = {};
let showMeasurements = false, showWireframe = false, showTextures = true, demoStarted = false;
// ── Floor plan (Spanish apartment — 95m²) ────────
// Positions are relative, 0.15m walls, heights 2.7m typical
const WALL = 0.15;
const H = 2.7;
const ROOMS = [
{ name: 'Salón', x: 3.8, z: 2.4, w: 5.2, d: 4.1, h: 2.7, floorMat: M.floor, wallMat: M.wall },
{ name: 'Cocina', x: 7.85, z: 2.4, w: 2.7, d: 4.1, h: 2.7, floorMat: M.floorKitchen, wallMat: M.wallKitchen },
{ name: 'Dorm. princ', x: 1.8, z: 7.0, w: 3.8, d: 3.4, h: 2.7, floorMat: M.floor, wallMat: M.wallBlue },
{ name: 'Dorm. 2', x: 6.2, z: 7.0, w: 3.0, d: 3.4, h: 2.7, floorMat: M.floor, wallMat: M.wall },
{ name: 'Baño', x: 8.6, z: 7.0, w: 2.2, d: 2.0, h: 2.7, floorMat: M.floorBath, wallMat: M.wallBath },
{ name: 'Recibidor', x: 4.2, z: 5.3, w: 4.5, d: 1.8, h: 2.7, floorMat: M.floor, wallMat: M.wall },
{ name: 'Terraza', x: 3.8, z:-0.5, w: 5.2, d: 1.6, h: 2.7, floorMat: M.floor, wallMat: M.wall },
];
const colliders = [];
const allMeshes = [];
const WALL_T = 0.15; // wall thickness
function wall(w, h, d, x, y, z, mat) {
const g = new THREE.BoxGeometry(w, h, d);
const m = new THREE.Mesh(g, mat);
m.position.set(x, y, z); m.castShadow = true; m.receiveShadow = true;
scene.add(m); allMeshes.push(m);
// Auto-collider for walls and tall furniture
if (h >= 1.2) colliders.push({ x: x-w/2, z: z-d/2, w, d, minY: y-h/2, maxY: y+h/2 });
return m;
}
function door(jx, jz, alongX, mat) {
// Door frame: 0.8m wide opening, 2.1m tall, with threshold and lintel
if (alongX) {
wall(0.8, 0.15, WALL_T, jx, 0.075, jz, mat); // threshold
wall(0.8, 0.6, WALL_T, jx, H-0.3, jz, mat); // lintel
} else {
wall(WALL_T, 0.15, 0.8, jx, 0.075, jz, mat);
wall(WALL_T, 0.6, 0.8, jx, H-0.3, jz, mat);
}
}
function roomFloorCeil(r) {
wall(r.w, 0.06, r.d, r.x, 0, r.z, r.floorMat); // floor
wall(r.w, 0.1, r.d, r.x, r.h, r.z, M.ceil); // ceiling
}
// ── Build apartment ───────────────────────────────
function buildApartment() {
// Outer walls — complete perimeter
// South wall (terrace side) - glass
wall(5.2, H, 0.08, 3.8, H/2, 0.25, M.glass);
wall(5.2, 0.15, 0.15, 3.8, 0.075, 0.25, M.wall); // base
wall(5.2, 0.1, 0.15, 3.8, H-0.05, 0.25, M.wall); // top frame
// North exterior wall
wall(10.4, H, WALL_T, 5.2, H/2, 9.0, M.wall);
// West exterior wall
wall(WALL_T, H, 9.25, 0, H/2, 4.625, M.wall);
// East exterior wall
wall(WALL_T, H, 9.25, 10.4, H/2, 4.625, M.wall);
// ── Interior walls ──
// Horizontal walls (along X)
// y=0.25 boundary (terrace/salón)
wall(3.8, H, WALL_T, 2.1, H/2, 1.0, M.wall); // west section
wall(4.8, H, WALL_T, 7.0, H/2, 1.0, M.wall); // east section (kitchen side)
// door between salón and terrace
wall(WALL_T, 0.15, 1.2, 3.8, 0.075, 1.0, M.wall); // threshold
wall(WALL_T, 0.6, 1.2, 3.8, H-0.3, 1.0, M.wall); // lintel
// y≈4.4 boundary (salón/recibidor/kitchen) — runs from x=0 to x=10.4
wall(1.5, H, WALL_T, 0.75, H/2, 4.35, M.wall); // west of salón door
// door salón→recibidor (x≈1.9)
wall(WALL_T, 0.15, 0.8, 1.9, 0.075, 4.35, M.wall);
wall(WALL_T, 0.6, 0.8, 1.9, H-0.3, 4.35, M.wall);
wall(2.2, H, WALL_T, 4.0, H/2, 4.35, M.wall); // between salón door and kitchen door
// door cocina→recibidor (x≈6.4)
wall(WALL_T, 0.15, 0.8, 6.4, 0.075, 4.35, M.wall);
wall(WALL_T, 0.6, 0.8, 6.4, H-0.3, 4.35, M.wall);
wall(3.7, H, WALL_T, 8.55, H/2, 4.35, M.wall); // kitchen to east wall
// y≈5.3 boundary (recibidor/habitaciones) — runs from x=0 to x=10.4
wall(1.5, H, WALL_T, 0.75, H/2, 6.2, M.wall); // west of dorm1 door
// door dorm1←recibidor
wall(WALL_T, 0.15, 0.8, 1.9, 0.075, 6.2, M.wall);
wall(WALL_T, 0.6, 0.8, 1.9, H-0.3, 6.2, M.wall);
wall(1.8, H, WALL_T, 3.6, H/2, 6.2, M.wall); // between dorm1 and dorm2 door
// door dorm2←recibidor
wall(WALL_T, 0.15, 0.8, 5.1, 0.075, 6.2, M.wall);
wall(WALL_T, 0.6, 0.8, 5.1, H-0.3, 6.2, M.wall);
wall(1.6, H, WALL_T, 6.4, H/2, 6.2, M.wall); // between dorm2 and bath door
// door bath
wall(WALL_T, 0.15, 0.65, 7.45, 0.075, 6.2, M.wall);
wall(WALL_T, 0.6, 0.65, 7.45, H-0.3, 6.2, M.wall);
wall(2.7, H, WALL_T, 9.05, H/2, 6.2, M.wall); // bath to east wall
// Vertical walls (along Z)
// Salón / Cocina divider (x≈6.4)
wall(WALL_T, H, 2.5, 6.4, H/2, 2.9, M.wall); // south section (below door)
// salón↔cocina opening (arch) — no wall for 1.6m in middle
wall(WALL_T, H, 1.2, 6.4, H/2, 4.1, M.wall); // above door zone to south wall
// Dorm1 / Recibidor divider (x≈3.7) — full wall, door is from recibidor side
wall(WALL_T, H, 1.7, 3.7, H/2, 7.85, M.wall); // north section
wall(WALL_T, H, 0.6, 3.7, H/2, 5.65, M.wall); // south section
// Dorm2 / Bath divider (x≈7.7) — full wall
wall(WALL_T, H, 3.4, 7.7, H/2, 7.0, M.wall);
// ── Room floors & ceilings ──
ROOMS.forEach(r => roomFloorCeil(r));
// ── Furniture ──
// Salón — sofa
wall(2.2, 0.45, 0.85, 4.5, 0.225, 3.5, M.wood); // sofa seat
wall(2.2, 0.55, 0.15, 4.5, 0.475, 3.05, M.wood); // sofa back
// Coffee table
wall(1.0, 0.35, 0.6, 4.5, 0.175, 2.5, M.counter);
// TV stand
wall(1.8, 0.4, 0.4, 3.8, 0.2, 0.7, M.wood);
wall(1.4, 0.6, 0.05, 3.8, 0.7, 0.7, M.wood); // TV screen
// Cocina — counter
wall(2.4, 0.9, 0.6, 7.85, 0.45, 0.7, M.counter);
wall(2.4, 0.05, 0.6, 7.85, 0.925, 0.7, M.white); // counter top
// Fridge
wall(0.6, 1.8, 0.6, 9.2, 0.9, 3.9, M.white);
// Dorm. principal — bed
wall(1.6, 0.35, 2.0, 1.8, 0.175, 7.5, M.wood); // bed base
wall(1.6, 0.95, 0.1, 1.8, 0.475, 6.55, M.wood); // headboard
// Nightstand
wall(0.45, 0.45, 0.4, 0.95, 0.225, 7.5, M.wood);
// Wardrobe
wall(1.8, 2.2, 0.55, 1.0, 1.1, 8.4, M.wood);
// Dorm. 2 — bed
wall(1.4, 0.35, 1.9, 6.2, 0.175, 7.5, M.wood);
wall(1.4, 0.85, 0.1, 6.2, 0.425, 6.6, M.wood);
// Desk
wall(1.2, 0.75, 0.6, 6.8, 0.375, 8.5, M.wood);
// Baño — fixtures
wall(0.55, 0.4, 0.6, 8.25, 0.2, 8.3, M.white); // toilet
wall(0.55, 0.85, 1.4, 8.6, 0.425, 6.85, M.white); // shower tray
wall(0.4, 0.8, 0.05, 9.0, 0.8, 6.85, M.mirror); // mirror
wall(0.7, 0.85, 0.45, 8.3, 0.5, 7.5, M.counter); // sink counter
// Recibidor — coat rack
wall(0.05, 1.8, 1.2, 0.15, 0.9, 5.1, M.wood);
}
// ── Lights ────────────────────────────────────────
function addLights() {
scene.add(new THREE.AmbientLight(0xfff5e8, 0.3));
// Salón
[[3.8, 2.6, 2.4], [5.5, 2.6, 3.5]].forEach(([x,y,z]) => {
const l = new THREE.PointLight(0xfff5e6, 0.6, 8); l.position.set(x,y,z); l.castShadow = true; scene.add(l);
});
// Cocina
const kl = new THREE.PointLight(0xffffff, 0.5, 6); kl.position.set(7.85, 2.5, 2.4); scene.add(kl);
// Dormitorios
[[1.8, 2.5, 7.0], [6.2, 2.5, 7.0]].forEach(([x,y,z]) => {
const l = new THREE.PointLight(0xfff0e0, 0.4, 6); l.position.set(x,y,z); scene.add(l);
});
// Baño
const bl = new THREE.PointLight(0xffffff, 0.5, 5); bl.position.set(8.6, 2.5, 7.0); scene.add(bl);
// Recibidor
const rl = new THREE.PointLight(0xfff5e6, 0.3, 5); rl.position.set(4.2, 2.5, 5.3); scene.add(rl);
// Sun from terrace
const sun = new THREE.DirectionalLight(0xfff5d0, 0.4); sun.position.set(4, 5, -2); sun.castShadow = true; scene.add(sun);
}
// ── Measurements ──
const measurementGroup = new THREE.Group(); measurementGroup.visible = false; scene.add(measurementGroup);
function addMeasureLine(x1,z1,x2,z2,y,val) {
const pts = [new THREE.Vector3(x1,y,z1), new THREE.Vector3(x2,y,z2)];
measurementGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({color:0x00d2ff})));
[x1,x2].forEach((px,i) => { const sp = new THREE.Mesh(new THREE.SphereGeometry(0.04), new THREE.MeshBasicMaterial({color:0x00d2ff})); sp.position.set(px,y,i===0?z1:z2); measurementGroup.add(sp); });
const c2 = document.createElement('canvas'); c2.width=200; c2.height=50; const cx=c2.getContext('2d'); cx.fillStyle='#00d2ff'; cx.font='bold 28px Inter'; cx.textAlign='center'; cx.fillText(val,100,35);
const sp2 = new THREE.Sprite(new THREE.SpriteMaterial({map: new THREE.CanvasTexture(c2), depthTest:false})); sp2.position.set((x1+x2)/2, y+0.25, (z1+z2)/2); sp2.scale.set(1.5,0.4,1); measurementGroup.add(sp2);
}
// Salón measurements
addMeasureLine(1.2, 0.4, 6.4, 0.4, 2.0, '5.2m');
addMeasureLine(1.2, 0.4, 1.2, 4.35, 2.0, '4.1m');
// ── Interaction ────────────────────────────────────
function lockPointer() { canvas.requestPointerLock(); }
document.addEventListener('pointerlockchange', () => {
player.isLocked = document.pointerLockElement === canvas;
if (player.isLocked && document.getElementById('uploadPanel').style.display !== 'none') {
document.getElementById('uploadPanel').style.display = 'none';
}
});
canvas.addEventListener('click', () => { if(!player.isLocked && demoStarted) lockPointer(); });
document.addEventListener('mousemove', e => {
if(!player.isLocked) return;
player.euler.y -= e.movementX * 0.002;
player.euler.x -= e.movementY * 0.002;
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==='KeyF') toggleWireframe();
if(e.code==='KeyT') toggleTextures();
});
document.addEventListener('keyup', e => keys[e.code] = false);
// Touch
let tX=0, tY=0;
canvas.addEventListener('touchstart', e => { if(!player.isLocked && demoStarted) lockPointer(); tX=e.touches[0].clientX; tY=e.touches[0].clientY; });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if(!player.isLocked) return; const t=e.touches[0]; player.euler.y-=(t.clientX-tX)*0.004; player.euler.x-=(t.clientY-tY)*0.004; player.euler.x=Math.max(-Math.PI/2.2,Math.min(Math.PI/2.2,player.euler.x)); tX=t.clientX; tY=t.clientY; }, {passive:false});
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;
}
function detectRoom() {
for (let i=0; i<ROOMS.length; i++) {
const r = ROOMS[i];
if (player.position.x>r.x-r.w/2-0.3 && player.position.x<r.x+r.w/2+0.3 && player.position.z>r.z-r.d/2-0.3 && player.position.z<r.z+r.d/2+0.3) return i;
}
return -1;
}
function toggleMeasures() { showMeasurements=!showMeasurements; measurementGroup.visible=showMeasurements; document.getElementById('infoPanel').classList.toggle('hidden',!showMeasurements); }
function toggleWireframe() { showWireframe=!showWireframe; allMeshes.forEach(m => m.material = showWireframe ? wireframeMat : m.userData.origMat); }
function toggleTextures() {
showTextures = !showTextures;
allMeshes.forEach(m => { if(m.userData.flatMat) m.material = showTextures ? m.userData.texturedMat : m.userData.flatMat; });
}
function shareLink() { navigator.clipboard?.writeText('https://interiorscan.app/w/altabix-24-3b-elche'); alert('Enlace copiado'); }
const wireframeMat = new THREE.MeshBasicMaterial({color:0x00d2ff, wireframe:true, transparent:true, opacity:0.4});
// ── Minimap ──────────────────────────────────────
const mmCanvas = document.getElementById('minimapCanvas');
const mmCtx = mmCanvas.getContext('2d');
mmCanvas.width = 440; mmCanvas.height = 440;
function drawMinimap() {
const ctx=mmCtx, s=30, ox=220, oz=320;
ctx.clearRect(0,0,440,440);
ctx.fillStyle='#0a0a0f'; ctx.fillRect(0,0,440,440);
ROOMS.forEach((r,i) => {
const rx=ox+(r.x-r.w/2)*s, ry=oz+(r.z-r.d/2)*s, rw=r.w*s, rd=r.d*s;
const colors = ['#6c5ce7','#00d2ff','#a855f7','#6c5ce7','#10b981','#f59e0b','#34d399'];
ctx.fillStyle = i===player.currentRoom ? colors[i]+'33' : 'rgba(255,255,255,0.04)';
ctx.strokeStyle = i===player.currentRoom ? colors[i] : 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1.5;
ctx.fillRect(rx,ry,rw,rd); ctx.strokeRect(rx,ry,rw,rd);
ctx.fillStyle = i===player.currentRoom ? colors[i] : 'rgba(255,255,255,0.4)';
ctx.font = '8px Inter'; ctx.textAlign = 'center';
ctx.fillText(r.name, rx+rw/2, ry+rd/2+3);
});
// Player
if (demoStarted) {
const px=ox+player.position.x*s, py=oz+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,25,-a-0.5,-a+0.5); ctx.closePath(); ctx.fill();
ctx.fillStyle='#00d2ff'; ctx.beginPath(); ctx.arc(px,py,3,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='#00d2ff'; ctx.lineWidth=1.5; ctx.beginPath(); ctx.moveTo(px,py); ctx.lineTo(px+Math.sin(-a)*12,py+Math.cos(a)*12); ctx.stroke();
}
}
// ── Demo start ──────────────────────────────────
function startDemo() {
document.getElementById('uploadPanel').style.display = 'none';
demoStarted = true;
player.position.set(4.2, player.height, 5.0);
player.euler.y = -Math.PI;
lockPointer();
}
// ── Build & animate ────────────────────────────────
buildApartment();
addLights();
allMeshes.forEach(m => m.userData.origMat = m.material);
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.1);
if (player.isLocked && demoStarted) {
const spd = keys['ShiftLeft'] ? player.sprintSpeed : player.speed;
const fwd = new THREE.Vector3(-Math.sin(player.euler.y), 0, -Math.cos(player.euler.y));
const rgt = new THREE.Vector3(Math.cos(player.euler.y), 0, -Math.sin(player.euler.y));
if(keys['KeyW']) player.velocity.add(fwd.clone().multiplyScalar(spd*delta));
if(keys['KeyS']) player.velocity.add(fwd.clone().multiplyScalar(-spd*delta));
if(keys['KeyA']) player.velocity.add(rgt.clone().multiplyScalar(-spd*delta));
if(keys['KeyD']) player.velocity.add(rgt.clone().multiplyScalar(spd*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);
player.position.y = player.height + (player.velocity.length()>0.3 ? Math.sin(clock.elapsedTime*(keys['ShiftLeft']?12:8))*0.035 : 0);
camera.quaternion.setFromEuler(player.euler);
const nr = detectRoom();
if(nr !== player.currentRoom) {
player.currentRoom = nr;
if(nr >= 0) {
const r = ROOMS[nr];
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`;
}
} else {
document.getElementById('roomName').textContent = 'Pasillo';
document.getElementById('roomSize').textContent = '';
}
}
}
camera.position.copy(player.position);
drawMinimap();
renderer.render(scene, camera);
}
function onResize() {
const c = document.querySelector('.viewer-container');
renderer.setSize(c.clientWidth, c.clientHeight);
camera.aspect = c.clientWidth / c.clientHeight;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', onResize); onResize();
animate();
</script>
</body>
</html>