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
This commit is contained in:
@@ -0,0 +1,582 @@
|
||||
<!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)} 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`;
|
||||
}
|
||||
} 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>
|
||||
Reference in New Issue
Block a user