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:
2026-05-29 01:03:32 +02:00
parent 3db7e47994
commit be3221e93d
8 changed files with 2785 additions and 3 deletions
+14 -1
View File
@@ -8,9 +8,22 @@
## User Scenarios & Testing
### User Story 0 — Property Setup via Catastro (Priority: P1) 🎯 MVP Entry
As a real estate agent, I enter a property address and InteriorScan fetches the building's exterior footprint from the Spanish Cadastro (INSPIRE WFS), so the 3D model starts with verified exterior walls before I upload any photos.
**Why this priority**: The Catastro provides the SSOT for exterior geometry — wall outlines, building height, and number of floors. This eliminates manual exterior wall drawing and ensures dimensional accuracy. Interior walls come from photos or user-uploaded floor plans.
**Independent Test**: Enter "C/ Altabix 24, Elche" → refcat is resolved → building polygon is displayed on a minimap → exterior walls are generated in the 3D scene.
**Acceptance Scenarios**:
1. **Given** I am creating a new project, **When** I type a Spanish address and submit, **Then** the system resolves it to a cadastral reference (refcat) and fetches the building footprint polygon from INSPIRE WFS.
2. **Given** a building footprint is fetched, **When** the 3D scene initializes, **Then** exterior walls appear at the correct dimensions matching the Catastro polygon, with the correct number of floors.
3. **Given** the Catastro lookup fails, **When** no polygon is found, **Then** the system falls back to manual wall drawing or DXF/SVG upload.
### User Story 1 — Upload & Process (Priority: P1) 🎯 MVP Core
As a real estate agent, I upload 30-100 photos of a property and InteriorScan reconstructs the interior as a 3D walkthrough, so I can share a link with potential buyers who navigate it like a first-person game.
As a real estate agent, I upload 30-100 photos of a property and InteriorScan reconstructs the interior as a 3D walkthrough, so I can share a link where potential buyers navigate it like a first-person game.
**Why this priority**: Without reconstruction, nothing else matters. This is the core value proposition.
@@ -148,6 +148,31 @@ export const processingJobs = pgTable('processing_jobs', {
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// ── Catastro Data ─────────────────────────────────────
export const catastroData = pgTable('catastro_data', {
id: cuid2('id').primaryKey(),
projectId: cuid2('project_id').references(() => projects.id).notNull(),
refcat: text('refcat'), // 14-20 digit cadastral reference
addressNormalized: text('address_normalized'), // Normalized address from Catastro
buildingFootprint: jsonb('building_footprint'), // GeoJSON polygon from INSPIRE WFS
buildingHeight: text('building_height'), // e.g., "12.5" meters
numberOfFloors: integer('number_of_floors'), // From Catastro
floorArea: text('floor_area'), // m² from Catastro
gmlSource: text('gml_source'), // URL of the GML source file
fetchedAt: timestamp('fetched_at').defaultNow().notNull(),
});
export const catastroImports = pgTable('catastro_imports', {
id: cuid2('id').primaryKey(),
projectId: cuid2('project_id').references(() => projects.id).notNull(),
format: text('format').notNull(), // 'dxf', 'svg', 'png', 'gml'
originalKey: text('original_key'), // S3 key for uploaded file
parsedWalls: jsonb('parsed_walls'), // Array of {x1, z1, x2, z2, height, thickness}
source: text('source').notNull(), // 'catastro', 'upload', 'manual'
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// ── Subscriptions ───────────────────────────────────
export const subscriptions = pgTable('subscriptions', {
+15 -2
View File
@@ -46,9 +46,22 @@ See `data-model.md` (separate document — same directory).
---
## Phase 2: Photo Upload & Processing Pipeline (P1) 🎯 MVP Core
## Phase 2: Property Setup & Photo Upload (P1) 🎯 MVP Core
### 2A: Upload API
### 2A: Catastro Integration
- [ ] T007 Create `packages/catastro/` — Catastro client package
- [ ] T008 Write failing test: address "C/ Altabix 24, Elche" → refcat resolved
- [ ] T009 Implement OVK address search (SOAP/REST → refcat)
- [ ] T010 Write failing test: refcat → building polygon GML from INSPIRE WFS
- [ ] T011 Implement INSPIRE WFS client (GML → GeoJSON polygon)
- [ ] T012 Implement GML polygon → wall segments converter (`{x1,z1,x2,z2,height,thickness}`)
- [ ] T13 Write failing test: POST /api/v1/projects with address → catastro_data populated
- [ ] T014 Implement project creation with Catastro lookup (address → refcat → footprint → walls)
**Checkpoint**: Enter "C/ Altabix 24, Elche" → building polygon rendered in minimap → exterior walls in 3D scene.
### 2B: Photo Upload API
- [ ] T007 Write failing test: POST /api/v1/projects with photos returns 201 + project ID
- [ ] T008 Implement project creation endpoint with Zod validation
+3
View File
@@ -41,6 +41,9 @@ See `.specify/memory/constitution.md` — all development must comply.
- **Self-hosted**: All infra self-hosted except Stripe.
- **API-first**: Every feature has REST API before UI.
- **Open exports**: OBJ, E57, LAS/LAZ, DXF minimum. IFC in P4+.
- **Catastro SSOT**: Spanish Cadastro (INSPIRE WFS) is the single source of truth for building exterior geometry. Address → refcat → footprint polygon → exterior walls. Interior comes from photos.
- **3DGS for visuals**: Hybrid pipeline — mesh for collision/measurement, Gaussian Splatting for visual quality (gsplat MIT license, never graphdeco-inria research-only).
- **Plan inputs**: (1) Catastro address lookup, (2) DXF/SVG/PNG upload, (3) manual wall editor, in that order of preference.
## Development Commands
+971
View File
@@ -0,0 +1,971 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>InteriorScan — Textured Walkthrough Demo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--bg: #0a0a0f; --surface: #141420; --surface-2: #1a1a2e; --border: #2a2a3e;
--text: #e8e8f0; --text-muted: #8888a0; --primary: #6c5ce7;
--accent: #00d2ff; --success: #10b981; --warning: #f59e0b;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; overflow: hidden; height: 100vh; }
#app { display: flex; flex-direction: column; height: 100vh; }
.topbar {
display: flex; align-items: center; gap: 16px; padding: 8px 16px;
background: var(--surface); border-bottom: 1px solid var(--border); z-index: 100; flex-shrink: 0;
}
.topbar-logo { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; color: var(--accent); letter-spacing: -0.5px; }
.topbar-logo svg { width: 24px; height: 24px; }
.topbar-address { font-size: 13px; color: var(--text-muted); flex: 1; }
.topbar-status { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--success); background: rgba(16,185,129,0.1); padding: 4px 10px; border-radius: 12px; }
.topbar-status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--success); animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
.btn { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface-2); color: var(--text); font-size: 12px; font-family: 'Inter',sans-serif; cursor: pointer; transition: all 0.15s; display: flex; align-items: center; gap: 6px; }
.btn:hover { background: var(--border); }
.btn-primary { background: var(--primary); border-color: var(--primary); color: white; }
.btn-primary:hover { background: #7d6ff0; }
.btn-active { background: var(--accent); border-color: var(--accent); color: #000; }
.viewer-container { flex: 1; position: relative; overflow: hidden; }
#canvas3d { width: 100%; height: 100%; display: block; cursor: crosshair; }
.crosshair { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); pointer-events: none; z-index: 10; opacity: 0.5; }
.crosshair svg { width: 28px; height: 28px; }
.minimap { position: absolute; top: 16px; right: 16px; width: 200px; height: 200px; background: rgba(10,10,15,0.85); backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: 12px; z-index: 50; overflow: hidden; }
.minimap canvas { width: 100%; height: 100%; }
.minimap-label { position: absolute; bottom: 4px; left: 8px; font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; }
.room-label { position: absolute; top: 16px; left: 16px; z-index: 50; background: rgba(10,10,15,0.85); backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: 10px; padding: 10px 16px; }
.room-label-name { font-size: 16px; font-weight: 600; color: var(--text); }
.room-label-size { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.info-panel {
position: absolute; bottom: 80px; left: 16px; z-index: 50;
background: rgba(10,10,15,0.85); backdrop-filter: blur(12px);
border: 1px solid var(--border); border-radius: 10px; padding: 12px 16px;
font-family: 'JetBrains Mono', monospace; display: flex; flex-direction: column; gap: 4px;
transition: all 0.3s;
}
.info-panel.hidden { display: none; }
.info-row { display: flex; justify-content: space-between; gap: 24px; font-size: 12px; }
.info-label { color: var(--text-muted); }
.info-value { color: var(--accent); }
.pipeline-bar {
position: absolute; bottom: 16px; right: 16px; z-index: 50;
background: rgba(10,10,15,0.85); backdrop-filter: blur(12px);
border: 1px solid var(--border); border-radius: 10px; padding: 10px 14px;
font-size: 11px; max-width: 220px;
}
.pipeline-title { color: var(--accent); font-weight: 600; margin-bottom: 6px; font-size: 12px; }
.pipeline-step { display: flex; align-items: center; gap: 6px; color: var(--text-muted); margin: 3px 0; }
.pipeline-step .dot { width: 6px; height: 6px; border-radius: 50%; }
.pipeline-step .dot.done { background: var(--success); }
.pipeline-step .dot.active { background: var(--accent); animation: pulse 1s infinite; }
.pipeline-step .dot.pending { background: var(--border); }
.pipeline-step.active-step { color: var(--text); }
.controls-hint {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
background: rgba(10,10,15,0.85); backdrop-filter: blur(12px); border: 1px solid var(--border);
border-radius: 12px; padding: 10px 20px; display: flex; gap: 20px; z-index: 50;
font-size: 12px; color: var(--text-muted); transition: opacity 0.5s;
}
.control-group { display: flex; align-items: center; gap: 5px; }
kbd { background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px; font-family: 'JetBrains Mono',monospace; font-size: 11px; color: var(--text); }
.start-screen {
position: absolute; inset: 0; background: rgba(10,10,15,0.95); backdrop-filter: blur(20px);
z-index: 200; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 24px; cursor: pointer;
}
.start-screen h1 { font-size: 52px; font-weight: 700; background: linear-gradient(135deg, var(--accent), #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; letter-spacing: -2px; }
.start-screen .subtitle { font-size: 16px; color: var(--text-muted); text-align: center; max-width: 420px; line-height: 1.6; }
.start-screen .cta { font-size: 14px; color: var(--accent); border: 1px solid var(--accent); padding: 12px 32px; border-radius: 8px; background: rgba(0,210,255,0.08); animation: cta-pulse 2s infinite; }
@keyframes cta-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(0,210,255,0.3)} 50%{box-shadow:0 0 20px 4px rgba(0,210,255,0.15)} }
.start-screen .hint { font-size: 11px; color: var(--text-muted); opacity: 0.6; }
.texture-badge {
position: absolute; top: 16px; left: 50%; transform: translateX(-50%); z-index: 100;
font-size: 11px; padding: 4px 12px; border-radius: 16px;
background: rgba(0,210,255,0.15); border: 1px solid rgba(0,210,255,0.3); color: var(--accent);
display: none; transition: opacity 0.3s;
}
.texture-badge.visible { display: block; }
@media (max-width: 768px) {
.minimap { width: 140px; height: 140px; }
.controls-hint { gap: 12px; padding: 8px 14px; font-size: 11px; }
.start-screen h1 { font-size: 32px; }
.info-panel { display: none; }
.pipeline-bar { display: none; }
}
</style>
</head>
<body>
<div id="app">
<div class="topbar">
<div class="topbar-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
InteriorScan
</div>
<div class="topbar-address">📍 Mercedes-Benz Vegar · Polígono 122, Elche</div>
<div class="topbar-status">Walkthrough Ready</div>
<div class="topbar-actions" style="display:flex;gap:8px;">
<button class="btn" id="btnMeasures" onclick="toggleMeasures()">📐 Measures</button>
<button class="btn" id="btn360" onclick="toggle360()">🖼 360°</button>
<button class="btn" id="btnWireframe" onclick="toggleWireframe()">🔲 Wireframe</button>
<button class="btn" id="btnTextures" onclick="toggleTextures()">🎨 Textures</button>
<button class="btn btn-primary" onclick="shareLink()">🔗 Share</button>
</div>
</div>
<div class="viewer-container">
<canvas id="canvas3d"></canvas>
<div class="crosshair"><svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/><circle cx="12" cy="12" r="3"/></svg></div>
<div class="room-label" id="roomLabel">
<div class="room-label-name" id="roomName">Showroom Principal</div>
<div class="room-label-size" id="roomSize">8.2m × 12.4m · 101.7 m²</div>
</div>
<div class="minimap"><canvas id="minimapCanvas"></canvas><div class="minimap-label">Floor Plan</div></div>
<div class="info-panel" id="infoPanel">
<div class="info-row"><span class="info-label">Room:</span><span class="info-value" id="infoRoom">Showroom Principal</span></div>
<div class="info-row"><span class="info-label">Dimensions:</span><span class="info-value" id="infoDims">8.24 × 12.38 m</span></div>
<div class="info-row"><span class="info-label">Area:</span><span class="info-value" id="infoArea">101.93 m²</span></div>
<div class="info-row"><span class="info-label">Height:</span><span class="info-value" id="infoHeight">3.10 m</span></div>
<div class="info-row"><span class="info-label">Photos used:</span><span class="info-value" id="infoPhotos">47 / 68</span></div>
</div>
<div class="pipeline-bar">
<div class="pipeline-title">📷 Reconstruction Pipeline</div>
<div class="pipeline-step"><span class="dot done"></span> COLMAP SfM → camera poses</div>
<div class="pipeline-step"><span class="dot done"></span> OpenMVS → dense cloud</div>
<div class="pipeline-step"><span class="dot done"></span> Mesh generation</div>
<div class="pipeline-step active-step"><span class="dot active"></span> Photo texturing ✓</div>
<div class="pipeline-step"><span class="dot done"></span> LOD + Draco compression</div>
</div>
<div class="texture-badge" id="textureBadge">🎨 Photo textures active</div>
<div class="controls-hint" id="controlsHint">
<div class="control-group"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> Move</div>
<div class="control-group"><kbd>Mouse</kbd> Look</div>
<div class="control-group"><kbd>Shift</kbd> Sprint</div>
<div class="control-group"><kbd>M</kbd> Measures</div>
<div class="control-group"><kbd>T</kbd> Textures</div>
<div class="control-group"><kbd>F</kbd> Wireframe</div>
</div>
<div class="start-screen" id="startScreen">
<h1>InteriorScan</h1>
<div class="subtitle">
Photo-textured 3D walkthrough.<br/>
Real photos projected onto reconstructed geometry.
</div>
<div class="cta">Click to Enter</div>
<div class="hint">WASD move · Mouse look · Shift sprint · T textures · F wireframe</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ── Procedural Texture Generator ──────────────────────
// Simulates "photo projected onto mesh" — this is what COLMAP+texturing produces
function createCanvasTexture(width, height, drawFn) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
drawFn(ctx, width, height);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.encoding = THREE.sRGBEncoding;
return tex;
}
// Concrete floor texture (like showroom polished concrete)
const floorTex = createCanvasTexture(512, 512, (ctx, w, h) => {
// Base
ctx.fillStyle = '#d4d0c8';
ctx.fillRect(0, 0, w, h);
// Noise
for (let i = 0; i < 15000; i++) {
const x = Math.random() * w;
const y = Math.random() * h;
const a = Math.random() * 0.08;
ctx.fillStyle = `rgba(${100+Math.random()*60}, ${95+Math.random()*55}, ${85+Math.random()*50}, ${a})`;
ctx.fillRect(x, y, 1 + Math.random()*2, 1 + Math.random()*2);
}
// Subtle grid lines (tile joints)
ctx.strokeStyle = 'rgba(150,145,135,0.3)';
ctx.lineWidth = 1;
for (let i = 0; i <= w; i += 128) {
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, h); ctx.stroke();
}
for (let j = 0; j <= h; j += 128) {
ctx.beginPath(); ctx.moveTo(0, j); ctx.lineTo(w, j); ctx.stroke();
}
// Reflective patches (polished concrete sheen)
const gradient = ctx.createRadialGradient(256, 256, 0, 256, 256, 200);
gradient.addColorStop(0, 'rgba(255,255,250,0.12)');
gradient.addColorStop(1, 'rgba(255,255,250,0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
});
floorTex.repeat.set(4, 4);
// White wall texture (photographed interior wall)
const wallTex = createCanvasTexture(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#f5f4f0';
ctx.fillRect(0, 0, w, h);
// Subtle variations (paint roller texture)
for (let i = 0; i < 20000; i++) {
const x = Math.random() * w;
const y = Math.random() * h;
ctx.fillStyle = `rgba(${230+Math.random()*25}, ${228+Math.random()*25}, ${222+Math.random()*25}, ${Math.random()*0.06})`;
ctx.fillRect(x, y, 2 + Math.random()*4, 1 + Math.random()*2);
}
// Horizontal roller lines
for (let j = 0; j < 8; j++) {
const y = 40 + j * 60 + Math.random() * 30;
ctx.strokeStyle = `rgba(240,238,230,${0.15 + Math.random()*0.1})`;
ctx.lineWidth = 8 + Math.random()*12;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
});
wallTex.repeat.set(3, 1);
// Dark accent wall (like showroom feature wall)
const darkWallTex = createCanvasTexture(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 15000; i++) {
const x = Math.random() * w;
const y = Math.random() * h;
ctx.fillStyle = `rgba(${20+Math.random()*30}, ${20+Math.random()*30}, ${35+Math.random()*40}, ${Math.random()*0.08})`;
ctx.fillRect(x, y, 2, 2);
}
// LED strip glow from top
const glow = ctx.createLinearGradient(0, 0, 0, 120);
glow.addColorStop(0, 'rgba(108,92,231,0.15)');
glow.addColorStop(1, 'rgba(108,92,231,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, 120);
});
darkWallTex.repeat.set(3, 1);
// Ceiling texture (white panels)
const ceilTex = createCanvasTexture(512, 512, (ctx, w, h) => {
ctx.fillStyle = '#fafaf8';
ctx.fillRect(0, 0, w, h);
// Ceiling tile grid
ctx.strokeStyle = 'rgba(200,198,190,0.4)';
ctx.lineWidth = 1.5;
for (let i = 0; i <= w; i += 64) {
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, h); ctx.stroke();
}
for (let j = 0; j <= h; j += 64) {
ctx.beginPath(); ctx.moveTo(0, j); ctx.lineTo(w, j); ctx.stroke();
}
// Subtle variation
for (let i = 0; i < 8000; i++) {
ctx.fillStyle = `rgba(${245+Math.random()*10},${244+Math.random()*10},${240+Math.random()*10},${Math.random()*0.05})`;
ctx.fillRect(Math.random()*w, Math.random()*h, 3, 3);
}
});
ceilTex.repeat.set(4, 4);
// Glass texture
const glassTex = createCanvasTexture(256, 256, (ctx, w, h) => {
ctx.fillStyle = 'rgba(180,220,240,0.15)';
ctx.fillRect(0, 0, w, h);
// Reflection streaks
for (let i = 0; i < 5; i++) {
const x = Math.random() * w;
const gradient = ctx.createLinearGradient(x, 0, x+w*0.3, h);
gradient.addColorStop(0, 'rgba(255,255,255,0.05)');
gradient.addColorStop(0.5, 'rgba(255,255,255,0.12)');
gradient.addColorStop(1, 'rgba(255,255,255,0.02)');
ctx.fillStyle = gradient;
ctx.fillRect(x, 0, 20 + Math.random()*30, h);
}
});
// Car paint textures
function createCarPaintTex(baseColor) {
return createCanvasTexture(256, 256, (ctx, w, h) => {
ctx.fillStyle = baseColor;
ctx.fillRect(0, 0, w, h);
// Metallic flakes
for (let i = 0; i < 5000; i++) {
ctx.fillStyle = `rgba(${200+Math.random()*55},${200+Math.random()*55},${200+Math.random()*55},${Math.random()*0.04})`;
ctx.fillRect(Math.random()*w, Math.random()*h, 1, 1);
}
// Clear coat reflection gradient
const gradient = ctx.createLinearGradient(0, 0, 0, h);
gradient.addColorStop(0, 'rgba(255,255,255,0.08)');
gradient.addColorStop(0.5, 'rgba(255,255,255,0)');
gradient.addColorStop(1, 'rgba(255,255,255,0.05)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
});
}
const carPaintObsidian = createCarPaintTex('#1a1a2e');
const carPaintSilver = createCarPaintTex('#8a8a96');
const carPaintBlack = createCarPaintTex('#2d2d38');
// Reception desk wood texture
const woodTex = createCanvasTexture(256, 256, (ctx, w, h) => {
ctx.fillStyle = '#8B7355';
ctx.fillRect(0, 0, w, h);
// Wood grain
for (let j = 0; j < h; j += 2) {
const offset = Math.sin(j * 0.03) * 5 + Math.sin(j * 0.07) * 3;
ctx.strokeStyle = `rgba(${100+Math.random()*40}, ${80+Math.random()*30}, ${50+Math.random()*20}, ${0.1+Math.random()*0.1})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, j);
for (let x = 0; x < w; x += 10) {
ctx.lineTo(x, j + Math.sin((x + offset) * 0.02) * 2);
}
ctx.stroke();
}
});
// ── Three.js Setup ──────────────────────────────────
const canvas = document.getElementById('canvas3d');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
renderer.outputEncoding = THREE.sRGBEncoding;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
scene.fog = new THREE.FogExp2(0xf0efe8, 0.015);
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 100);
camera.position.set(0, 1.7, -4);
// ── Player ──────────────────────────────────────
const player = {
position: new THREE.Vector3(0, 1.7, -4),
velocity: new THREE.Vector3(),
euler: new THREE.Euler(0, 0, 0, 'YXZ'),
speed: 4.0, sprintSpeed: 7.5, height: 1.7, radius: 0.3,
isLocked: false, currentRoom: 0,
};
const keys = {};
let showMeasurements = false, show360 = false, showWireframe = false, showTextures = true;
// ── Rooms ──────────────────────────────────────
const ROOMS = [
{ name: 'Showroom Principal', x: 0, z: 0, w: 8.2, d: 12.4, h: 3.1 },
{ name: 'Recepción', x: -4.8, z: 0, w: 4.0, d: 6.0, h: 2.8 },
{ name: 'Office Ventas', x: -4.8, z: 5.5, w: 4.0, d: 3.5, h: 2.8 },
{ name: 'Zona Accesorios', x: 5.8, z: 0, w: 3.5, d: 6.0, h: 3.0 },
{ name: 'Sala Espera', x: 5.8, z: 5.5, w: 3.5, d: 3.5, h: 2.7 },
];
// ── Materials ──────────────────────────────────
const materials = {
floorTextured: new THREE.MeshStandardMaterial({ map: floorTex, roughness: 0.25, metalness: 0.08 }),
floorFlat: new THREE.MeshStandardMaterial({ color: 0xd4d0c8, roughness: 0.3, metalness: 0.05 }),
wallTextured: new THREE.MeshStandardMaterial({ map: wallTex, roughness: 0.85, metalness: 0.0 }),
wallFlat: new THREE.MeshStandardMaterial({ color: 0xf5f4f0, roughness: 0.85, metalness: 0.0 }),
darkWallTextured: new THREE.MeshStandardMaterial({ map: darkWallTex, roughness: 0.9, metalness: 0.05 }),
darkWallFlat: new THREE.MeshStandardMaterial({ color: 0x2a2a3e, roughness: 0.9, metalness: 0.0 }),
ceilTextured: new THREE.MeshStandardMaterial({ map: ceilTex, roughness: 0.95, metalness: 0.0 }),
ceilFlat: new THREE.MeshStandardMaterial({ color: 0xfafaf8, roughness: 0.95, metalness: 0.0 }),
glass: new THREE.MeshPhysicalMaterial({ color: 0xaaeeff, transparent: true, opacity: 0.12, roughness: 0.02, metalness: 0.1, transmission: 0.92 }),
accent: new THREE.MeshStandardMaterial({ color: 0x6c5ce7, roughness: 0.4, metalness: 0.3, emissive: 0x6c5ce7, emissiveIntensity: 0.1 }),
woodTextured: new THREE.MeshStandardMaterial({ map: woodTex, roughness: 0.6, metalness: 0.05 }),
woodFlat: new THREE.MeshStandardMaterial({ color: 0x8B7355, roughness: 0.6, metalness: 0.1 }),
};
// Wireframe override
const wireframeMat = new THREE.MeshBasicMaterial({ color: 0x00d2ff, wireframe: true, transparent: true, opacity: 0.5 });
const colliders = [];
const allMeshes = []; // For wireframe toggle
const WALL_THICKNESS = 0.15;
const MIN_WALL_HEIGHT = 1.5; // Only walls taller than this block movement
function addBox(w, h, d, x, y, z, mat, castShadow = true) {
const geo = new THREE.BoxGeometry(w, h, d);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z);
mesh.castShadow = castShadow;
mesh.receiveShadow = true;
scene.add(mesh);
allMeshes.push(mesh);
// Auto-generate collision from any mesh tall enough to be a wall/furniture
if (h >= MIN_WALL_HEIGHT || (h >= 0.4 && y + h/2 <= 1.0)) {
colliders.push({
x: x - w/2,
z: z - d/2,
w: w,
d: d,
minY: y - h/2,
maxY: y + h/2
});
}
return mesh;
}
// ── Build Showroom (SR) ─────────────────────────────
const SR = ROOMS[0]; // Showroom principal
const hw = SR.w/2, hd = SR.d/2;
// Floor & Ceiling
addBox(SR.w, 0.06, SR.d, SR.x, 0, SR.z, materials.floorTextured);
addBox(SR.w, 0.12, SR.d, SR.x, SR.h, SR.z, materials.ceilTextured);
// Ceiling light strips
addBox(0.6, 0.02, SR.d * 0.8, 0, SR.h - 0.08, 0, new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.5 }));
addBox(0.6, 0.02, SR.d * 0.6, -2.5, SR.h - 0.08, 2, new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.4 }));
// ── South wall: glass front with solid frame ──
// Glass panels (still collidable — glass blocks movement)
addBox(SR.w, 2.6, 0.08, SR.x, 1.4, SR.z - hd, materials.glass);
// Solid base below glass
addBox(SR.w, 0.5, 0.12, SR.x, 0.25, SR.z - hd, new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.8, roughness: 0.3 }));
// Solid frame above glass
addBox(SR.w, 0.08, 0.12, SR.x, 2.75, SR.z - hd, new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.8, roughness: 0.3 }));
// Pillars at glass wall corners (structural)
addBox(0.15, SR.h, 0.15, -hw + 0.3, SR.h/2, SR.z - hd + 0.1, new THREE.MeshStandardMaterial({ color: 0x444444, metalness: 0.6, roughness: 0.4 }));
addBox(0.15, SR.h, 0.15, hw - 0.3, SR.h/2, SR.z - hd + 0.1, new THREE.MeshStandardMaterial({ color: 0x444444, metalness: 0.6, roughness: 0.4 }));
// ── North wall: SOLID (no exterior door) ──
addBox(SR.w, SR.h, 0.15, SR.x, SR.h/2, SR.z + hd, materials.darkWallTextured);
// ── West wall: with DOOR (2.4m wide, 2.4m tall) to Reception ──
// Wall below door (0 to 0.3m — threshold)
addBox(0.15, 0.3, 2.4, SR.x - hw, 0.15, 1.0, materials.wallTextured);
// Wall above door (2.7m to ceiling)
addBox(0.15, 0.4, 2.4, SR.x - hw, SR.h - 0.2, 1.0, materials.wallTextured);
// Wall north of door
addBox(0.15, SR.h, 5.0, SR.x - hw, SR.h/2, SR.z + 3.7, materials.wallTextured);
// Wall south of door
addBox(0.15, SR.h, 5.0, SR.x - hw, SR.h/2, SR.z - 3.7, materials.wallTextured);
// ── East wall: with DOOR (2.4m wide, 2.4m tall) to Accessories ──
addBox(0.15, 0.3, 2.4, SR.x + hw, 0.15, 1.0, materials.wallTextured);
addBox(0.15, 0.4, 2.4, SR.x + hw, SR.h - 0.2, 1.0, materials.wallTextured);
addBox(0.15, SR.h, 5.0, SR.x + hw, SR.h/2, SR.z - 5.2, materials.wallTextured);
addBox(0.15, SR.h, 5.0, SR.x + hw, SR.h/2, SR.z + 5.2, materials.wallTextured);
// ── Mercedes logo on dark wall ──────────────────────
// Simplified 3-pointed star
const starShape = new THREE.Shape();
const starR = 0.35;
for (let i = 0; i < 3; i++) {
const angle = (i * 2 * Math.PI / 3) - Math.PI/2;
const x2 = Math.cos(angle) * starR;
const y2 = Math.sin(angle) * starR;
if (i === 0) starShape.moveTo(x2, y2);
else starShape.lineTo(x2, y2);
}
starShape.closePath();
// Circle around star
const circleGeo = new THREE.CircleGeometry(0.45, 32);
const circleMesh = new THREE.Mesh(circleGeo, new THREE.MeshStandardMaterial({ color: 0xcccccc, metalness: 0.9, roughness: 0.1 }));
circleMesh.position.set(-2.0, 2.0, SR.z + hd - 0.1);
scene.add(circleMesh);
allMeshes.push(circleMesh);
const starGeo = new THREE.ShapeGeometry(starShape);
const starMesh = new THREE.Mesh(starGeo, new THREE.MeshStandardMaterial({ color: 0x222222, metalness: 0.8, roughness: 0.2, side: THREE.DoubleSide }));
starMesh.position.set(-2.0, 2.0, SR.z + hd - 0.09);
scene.add(starMesh);
allMeshes.push(starMesh);
// ── Showroom cars ───────────────────────────────────
function buildCar(x, z, rotation, paintTex, paintColor) {
const group = new THREE.Group();
const paintMat = showTextures
? new THREE.MeshStandardMaterial({ map: paintTex, roughness: 0.15, metalness: 0.85 })
: new THREE.MeshStandardMaterial({ color: paintColor, roughness: 0.15, metalness: 0.85 });
// Body
const bodyGeo = new THREE.BoxGeometry(1.85, 0.55, 4.4);
const body = new THREE.Mesh(bodyGeo, paintMat);
body.position.y = 0.55;
body.castShadow = true; body.receiveShadow = true;
group.add(body); allMeshes.push(body);
// Cabin (glass)
const cabinGeo = new THREE.BoxGeometry(1.5, 0.45, 2.0);
const cabinMat = new THREE.MeshPhysicalMaterial({ color: 0x334455, transparent: true, opacity: 0.6, roughness: 0.02, metalness: 0.1, transmission: 0.3 });
const cabin = new THREE.Mesh(cabinGeo, cabinMat);
cabin.position.set(0, 1.05, -0.2);
group.add(cabin); allMeshes.push(cabin);
// Hood slope
const hoodGeo = new THREE.BoxGeometry(1.7, 0.15, 1.4);
const hood = new THREE.Mesh(hoodGeo, paintMat);
hood.position.set(0, 0.9, -1.2);
hood.rotation.x = -0.15;
group.add(hood); allMeshes.push(hood);
// Trunk slope
const trunkGeo = new THREE.BoxGeometry(1.7, 0.15, 1.0);
const trunk = new THREE.Mesh(trunkGeo, paintMat);
trunk.position.set(0, 0.9, 1.2);
trunk.rotation.x = 0.2;
group.add(trunk); allMeshes.push(trunk);
// Wheels
const wheelGeo = new THREE.CylinderGeometry(0.28, 0.28, 0.18, 16);
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 });
const rimGeo = new THREE.CylinderGeometry(0.18, 0.18, 0.19, 8);
const rimMat = new THREE.MeshStandardMaterial({ color: 0xaaaaaa, metalness: 0.9, roughness: 0.1 });
[[-0.85, -1.5], [-0.85, 1.5], [0.85, -1.5], [0.85, 1.5]].forEach(([wx, wz]) => {
const wheel = new THREE.Mesh(wheelGeo, wheelMat);
wheel.rotation.z = Math.PI/2;
wheel.position.set(wx, 0.28, wz);
group.add(wheel);
const rim = new THREE.Mesh(rimGeo, rimMat);
rim.rotation.z = Math.PI/2;
rim.position.set(wx, 0.28, wz);
group.add(rim);
});
// Headlights
const lightGeo = new THREE.BoxGeometry(0.25, 0.08, 0.05);
const lightMat = new THREE.MeshStandardMaterial({ color: 0xffffee, emissive: 0xffffcc, emissiveIntensity: 0.4 });
[-0.65, 0.65].forEach(ox => {
const hl = new THREE.Mesh(lightGeo, lightMat);
hl.position.set(ox, 0.6, -2.2);
group.add(hl);
});
// Tail lights
const tailMat = new THREE.MeshStandardMaterial({ color: 0xff2222, emissive: 0xff0000, emissiveIntensity: 0.3 });
[-0.65, 0.65].forEach(ox => {
const tl = new THREE.Mesh(lightGeo, tailMat);
tl.position.set(ox, 0.6, 2.2);
group.add(tl);
});
// Side mirror (left)
const mirrorGeo = new THREE.BoxGeometry(0.1, 0.08, 0.15);
const mirror = new THREE.Mesh(mirrorGeo, paintMat);
mirror.position.set(-0.95, 1.0, -0.5);
group.add(mirror);
group.position.set(x, 0, z);
group.rotation.y = rotation;
scene.add(group);
// Collider for the car
colliders.push({ x: x - 1.0, z: z - 2.3, w: 2.0, d: 4.6, minY: 0, maxY: 1.5 });
}
buildCar(0, -2.5, Math.PI * 0.02, carPaintObsidian, 0x1a1a2e); // Main display
buildCar(-2.5, 3.5, Math.PI * 0.98, carPaintSilver, 0x8a8a96); // Second car
buildCar(2.0, -5.5, Math.PI * 0.5, carPaintBlack, 0x2d2d38); // Third car
// ── Reception (RC) — fully enclosed ──────────────────
const RC = ROOMS[1];
// South wall: SOLID
addBox(RC.w, RC.h, 0.15, RC.x, RC.h/2, RC.z - RC.d/2, materials.wallTextured);
// North wall: with door to sales office
addBox(RC.w * 0.35, RC.h, 0.15, RC.x - RC.w * 0.325, RC.h/2, RC.z + RC.d/2, materials.wallTextured);
addBox(RC.w * 0.35, RC.h, 0.15, RC.x + RC.w * 0.325, RC.h/2, RC.z + RC.d/2, materials.wallTextured);
addBox(RC.w * 0.3, 0.5, 0.15, RC.x, RC.h - 0.25, RC.z + RC.d/2, materials.wallTextured); // above door
// West wall: SOLID
addBox(0.15, RC.h, RC.d, RC.x - RC.w/2, RC.h/2, RC.z, materials.wallTextured);
// East wall: with DOOR to showroom (2.4m wide, matching showroom west wall)
addBox(0.15, 0.3, 2.4, RC.x + RC.w/2, 0.15, RC.z - 0.2, materials.wallTextured); // threshold
addBox(0.15, 0.4, 2.4, RC.x + RC.w/2, RC.h - 0.2, RC.z - 0.2, materials.wallTextured); // lintel
addBox(0.15, RC.h, 1.8, RC.x + RC.w/2, RC.h/2, RC.z - 2.1, materials.wallTextured); // south segment
addBox(0.15, RC.h, 1.8, RC.x + RC.w/2, RC.h/2, RC.z + 1.7, materials.wallTextured); // north segment
// Floor & Ceiling
addBox(RC.w, 0.06, RC.d, RC.x, 0, RC.z, materials.floorTextured);
addBox(RC.w, 0.12, RC.d, RC.x, RC.h, RC.z, materials.ceilTextured);
// Reception desk
addBox(2.2, 0.9, 0.8, RC.x + 0.3, 0.45, RC.z - 1.0, materials.woodTextured);
// Counter top
addBox(2.4, 0.06, 0.9, RC.x + 0.3, 0.93, RC.z - 1.0, new THREE.MeshStandardMaterial({ color: 0xf0ece0, roughness: 0.3, metalness: 0.05 }));
// Monitor on desk
addBox(0.5, 0.35, 0.03, RC.x + 0.3, 1.2, RC.z - 1.0, new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.1, metalness: 0.5 }));
addBox(0.15, 0.03, 0.15, RC.x + 0.3, 0.95, RC.z - 0.85, new THREE.MeshStandardMaterial({ color: 0x222222 }));
// ── Sales Office (OF) — fully enclosed ──────────────
const OF = ROOMS[2];
// South wall: with DOOR to reception
addBox(OF.w * 0.3, OF.h, 0.15, OF.x - OF.w * 0.25, OF.h/2, OF.z - OF.d/2, materials.wallTextured);
addBox(OF.w * 0.3, OF.h, 0.15, OF.x + OF.w * 0.25, OF.h/2, OF.z - OF.d/2, materials.wallTextured);
addBox(OF.w * 0.4, 0.5, 0.15, OF.x, OF.h - 0.25, OF.z - OF.d/2, materials.wallTextured);
// North: SOLID
addBox(OF.w, OF.h, 0.15, OF.x, OF.h/2, OF.z + OF.d/2, materials.wallTextured);
// West: SOLID
addBox(0.15, OF.h, OF.d, OF.x - OF.w/2, OF.h/2, OF.z, materials.wallTextured);
// East: SOLID (connects to reception interior)
addBox(0.15, OF.h, OF.d, OF.x + OF.w/2, OF.h/2, OF.z, materials.wallTextured);
addBox(OF.w, 0.06, OF.d, OF.x, 0, OF.z, materials.floorTextured);
addBox(OF.w, 0.12, OF.d, OF.x, OF.h, OF.z, materials.ceilTextured);
// Desk
addBox(1.8, 0.75, 0.8, OF.x, 0.375, OF.z - 0.5, materials.woodTextured);
addBox(0.5, 0.3, 0.03, OF.x, 0.65, OF.z - 0.5, new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.1, metalness: 0.5 }));
// ── Accessories Area (AC) — fully enclosed ────────────
const AC = ROOMS[3];
// East wall: SOLID
addBox(0.15, AC.h, AC.d, AC.x + AC.w/2, AC.h/2, AC.z, materials.wallTextured);
// West wall: with DOOR to showroom (2.4m wide)
addBox(0.15, 0.3, 2.4, AC.x - AC.w/2, 0.15, AC.z - 0.2, materials.wallTextured); // threshold
addBox(0.15, 0.4, 2.4, AC.x - AC.w/2, AC.h - 0.2, AC.z - 0.2, materials.wallTextured); // lintel
addBox(0.15, AC.h, 1.8, AC.x - AC.w/2, AC.h/2, AC.z - 2.1, materials.wallTextured); // south segment
addBox(0.15, AC.h, 1.8, AC.x - AC.w/2, AC.h/2, AC.z + 1.7, materials.wallTextured); // north segment
// South wall: SOLID
addBox(AC.w, AC.h, 0.15, AC.x, AC.h/2, AC.z - AC.d/2, materials.wallTextured);
// North wall: with DOOR to waiting area
addBox(AC.w * 0.3, AC.h, 0.15, AC.x - AC.w * 0.25, AC.h/2, AC.z + AC.d/2, materials.wallTextured);
addBox(AC.w * 0.3, AC.h, 0.15, AC.x + AC.w * 0.25, AC.h/2, AC.z + AC.d/2, materials.wallTextured);
addBox(AC.w * 0.4, 0.5, 0.15, AC.x, AC.h - 0.25, AC.z + AC.d/2, materials.wallTextured);
addBox(AC.w, 0.06, AC.d, AC.x, 0, AC.z, materials.floorTextured);
addBox(AC.w, 0.12, AC.d, AC.x, AC.h, AC.z, materials.ceilTextured);
// Accessory shelves (3 levels)
for (let i = 0; i < 3; i++) {
const shelfY = 0.7 + i * 0.7;
addBox(2.5, 0.04, 0.35, AC.x, shelfY, AC.z - AC.d/2 + 0.4, materials.woodTextured);
}
// Floor display stand (center)
addBox(1.0, 0.06, 1.0, AC.x + 0.5, 0.5, AC.z + 0.5, new THREE.MeshStandardMaterial({ color: 0xf0ece0, roughness: 0.2, metalness: 0.1 }));
addBox(1.0, 0.5, 1.0, AC.x + 0.5, 0.25, AC.z + 0.5, new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.7, roughness: 0.3 }));
// ── Waiting Area (WA) — fully enclosed ──────────────
const WA = ROOMS[4];
// South wall: SOLID
addBox(WA.w, WA.h, 0.15, WA.x, WA.h/2, WA.z - WA.d/2, materials.wallTextured);
// North wall: with DOOR to accessories
addBox(WA.w * 0.3, WA.h, 0.15, WA.x - WA.w * 0.25, WA.h/2, WA.z + WA.d/2, materials.wallTextured);
addBox(WA.w * 0.3, WA.h, 0.15, WA.x + WA.w * 0.25, WA.h/2, WA.z + WA.d/2, materials.wallTextured);
addBox(WA.w * 0.4, 0.5, 0.15, WA.x, WA.h - 0.25, WA.z + WA.d/2, materials.wallTextured);
// West wall: SOLID
addBox(0.15, WA.h, WA.d, WA.x - WA.w/2, WA.h/2, WA.z, materials.wallTextured);
// East wall: with DOOR to showroom (2.4m wide)
addBox(0.15, 0.3, 2.4, WA.x + WA.w/2, 0.15, WA.z - 0.2, materials.wallTextured); // threshold
addBox(0.15, 0.4, 2.4, WA.x + WA.w/2, WA.h - 0.2, WA.z - 0.2, materials.wallTextured); // lintel
addBox(0.15, WA.h, 1.8, WA.x + WA.w/2, WA.h/2, WA.z - 2.1, materials.wallTextured); // south segment
addBox(0.15, WA.h, 1.8, WA.x + WA.w/2, WA.h/2, WA.z + 1.7, materials.wallTextured); // north segment
addBox(WA.w, 0.06, WA.d, WA.x, 0, WA.z, materials.floorTextured);
addBox(WA.w, 0.12, WA.d, WA.x, WA.h, WA.z, materials.ceilTextured);
// Sofa (L-shaped)
addBox(1.8, 0.4, 0.7, WA.x + 0.3, 0.25, WA.z - 0.5, new THREE.MeshStandardMaterial({ color: 0x444450, roughness: 0.8 }));
addBox(1.8, 0.5, 0.1, WA.x + 0.3, 0.45, WA.z - 0.85, new THREE.MeshStandardMaterial({ color: 0x444450, roughness: 0.8 })); // back
addBox(0.7, 0.4, 0.7, WA.x - 0.8, 0.25, WA.z + 0.3, new THREE.MeshStandardMaterial({ color: 0x444450, roughness: 0.8 }));
addBox(0.1, 0.5, 0.7, WA.x - 1.15, 0.45, WA.z + 0.3, new THREE.MeshStandardMaterial({ color: 0x444450, roughness: 0.8 })); // back
// Coffee table
addBox(0.8, 0.03, 0.5, WA.x + 0.3, 0.4, WA.z + 0.5, new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.8, roughness: 0.2 }));
// ── CONNECTING CORRIDORS (floor + ceiling in gaps between rooms) ──
// Corridor: Showroom ↔ Reception (west side, x≈-4.5, z from -0.2 to +0.2)
// Already handled by overlapping door frames
// Corridor: Reception → Sales Office (north side of reception)
// RC north at z=3.0, OF south at z=3.75 — gap of 0.75m
// Add short corridor walls, floor, ceiling
const corridorH = 2.8;
addBox(0.15, corridorH, 1.0, RC.x - RC.w/2, corridorH/2, RC.z + RC.d/2 + 0.5, materials.wallTextured); // left wall
addBox(0.15, corridorH, 1.0, RC.x + RC.w/2, corridorH/2, RC.z + RC.d/2 + 0.5, materials.wallTextured); // right wall
addBox(RC.w, 0.06, 1.0, RC.x, 0, RC.z + RC.d/2 + 0.5, materials.floorTextured); // floor
addBox(RC.w, 0.12, 1.0, RC.x, corridorH, RC.z + RC.d/2 + 0.5, materials.ceilTextured); // ceiling
// Corridor: Accessories ↔ Waiting Area (north side of accessories)
// AC north at z=3.0, WA south at z=3.75 — same gap
addBox(0.15, corridorH, 1.0, AC.x - AC.w/2, corridorH/2, AC.z + AC.d/2 + 0.5, materials.wallTextured);
addBox(0.15, corridorH, 1.0, AC.x + AC.w/2, corridorH/2, AC.z + AC.d/2 + 0.5, materials.wallTextured);
addBox(AC.w, 0.06, 1.0, AC.x, 0, AC.z + AC.d/2 + 0.5, materials.floorTextured);
addBox(AC.w, 0.12, 1.0, AC.x, corridorH, AC.z + AC.d/2 + 0.5, materials.ceilTextured);
// ── Accent LED strips ──────────────────────────
// Showroom LED strip along dark wall
const ledGeo = new THREE.BoxGeometry(SR.w * 0.3, 0.02, 0.02);
const ledMat = new THREE.MeshStandardMaterial({ color: 0x6c5ce7, emissive: 0x6c5ce7, emissiveIntensity: 2 });
addBox(SR.w * 0.3, 0.02, 0.02, -2.0, 0.4, SR.z + hd - 0.08, ledMat);
// Floor accent light strip
const floorLedMat = new THREE.MeshStandardMaterial({ color: 0x00d2ff, emissive: 0x00d2ff, emissiveIntensity: 0.5 });
addBox(SR.w, 0.03, 0.03, 0, 0.03, SR.z - hd + 0.15, floorLedMat);
// ── Lighting ────────────────────────────────────
const ambientLight = new THREE.AmbientLight(0xfff5e8, 0.35);
scene.add(ambientLight);
// Showroom main lights
[[0, 2.97, -3], [0, 2.97, 3]].forEach(([x, y, z]) => {
const light = new THREE.PointLight(0xfff5e6, 0.9, 15);
light.position.set(x, y, z);
light.castShadow = true;
light.shadow.mapSize.set(1024, 1024);
scene.add(light);
});
// Spotlights on cars
[[0, 2.9, -2.5], [-2.5, 2.9, 3.5], [2, 2.9, -5.5]].forEach(([x, y, z], i) => {
const spot = new THREE.SpotLight(0xffffff, 0.7, 12, Math.PI/5, 0.5, 1);
spot.position.set(x, y, z);
spot.target.position.set(x, 0, z);
spot.castShadow = true;
scene.add(spot);
scene.add(spot.target);
});
// Reception warm light
const recepLight = new THREE.PointLight(0xffe8cc, 0.5, 8);
recepLight.position.set(-4.8, 2.6, 0);
scene.add(recepLight);
// Accessory area
const accLight = new THREE.PointLight(0xffffff, 0.4, 8);
accLight.position.set(5.8, 2.8, 0);
scene.add(accLight);
// ── Measurement Lines ────────────────────────────
const measurementGroup = new THREE.Group();
measurementGroup.visible = false;
scene.add(measurementGroup);
function addMeasureLine(x1, z1, x2, z2, y, value) {
const points = [new THREE.Vector3(x1, y, z1), new THREE.Vector3(x2, y, z2)];
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
const lineMat = new THREE.LineBasicMaterial({ color: 0x00d2ff });
measurementGroup.add(new THREE.Line(lineGeo, lineMat));
[x1, x2].forEach((x, i) => {
const z = i === 0 ? z1 : z2;
const cap = new THREE.Mesh(new THREE.SphereGeometry(0.04), new THREE.MeshBasicMaterial({ color: 0x00d2ff }));
cap.position.set(x, y, z);
measurementGroup.add(cap);
});
// Label
const c2 = document.createElement('canvas');
c2.width = 256; c2.height = 64;
const ctx = c2.getContext('2d');
ctx.fillStyle = '#00d2ff'; ctx.font = 'bold 30px Inter, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(value, 128, 40);
const tex = new THREE.CanvasTexture(c2);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false }));
sprite.position.set((x1+x2)/2, y+0.3, (z1+z2)/2);
sprite.scale.set(2, 0.5, 1);
measurementGroup.add(sprite);
}
addMeasureLine(-4.1, -6.2, 4.1, -6.2, 1.8, '8.24 m');
addMeasureLine(4.1, -6.2, 4.1, 6.2, 1.8, '12.38 m');
// ── 360 Hotspots ──────────────────────────────────
const hotspotsGroup = new THREE.Group();
hotspotsGroup.visible = false;
scene.add(hotspotsGroup);
[{x:0,z:0,label:'Center'},{x:0,z:-2.5,label:'GLE Display'},{x:-4.8,z:0,label:'Reception'},{x:5.8,z:0,label:'Accessories'},{x:5.8,z:5.5,label:'Waiting Area'}].forEach((h,i) => {
const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.12), new THREE.MeshBasicMaterial({ color: 0x6c5ce7 }));
sphere.position.set(h.x, 1.5, h.z);
hotspotsGroup.add(sphere);
const ring = new THREE.Mesh(new THREE.RingGeometry(0.2, 0.25, 24), new THREE.MeshBasicMaterial({ color: 0x6c5ce7, transparent: true, opacity: 0.6, side: THREE.DoubleSide }));
ring.position.set(h.x, 1.5, h.z);
ring.rotation.x = -Math.PI/2;
hotspotsGroup.add(ring);
});
// ── Collisions auto-generated from addBox calls ──
// All walls/furniture with height >= 1.5m or low items >= 0.4m are solid
// No manual colliders needed — derived from scene geometry
console.log('Colliders:', colliders.length, 'collision bodies generated from scene meshes');
// ── Toggle Functions ─────────────────────────────
function toggleMeasures() { showMeasurements = !showMeasurements; measurementGroup.visible = showMeasurements; document.getElementById('infoPanel').classList.toggle('hidden', !showMeasurements); }
function toggle360() { show360 = !show360; hotspotsGroup.visible = show360; const b=document.getElementById('textureBadge'); b.textContent=show360?'📸 360° Photo Mode':''; b.classList.toggle('visible',show360); }
function toggleWireframe() {
showWireframe = !showWireframe;
allMeshes.forEach(m => { m.material = showWireframe ? wireframeMat : m.userData.origMat || m.material; });
if (!showWireframe) allMeshes.forEach(m => { if(m.userData.origMat) m.material = m.userData.origMat; });
}
function toggleTextures() {
showTextures = !showTextures;
const b = document.getElementById('textureBadge');
b.textContent = showTextures ? '🎨 Photo textures active' : '⬜ Flat materials';
b.classList.toggle('visible', true);
setTimeout(() => b.classList.toggle('visible', false), 2000);
// Reload with/without textures — simplified toggle
allMeshes.forEach(m => {
if (m.userData.texturedMat && m.userData.flatMat) {
m.material = showTextures ? m.userData.texturedMat : m.userData.flatMat;
}
});
}
function shareLink() {
navigator.clipboard?.writeText('https://interiorscan.app/w/mercedes-benz-vegar-elche');
const btn = event.target; const orig = btn.textContent;
btn.textContent = '✓ Copied!'; setTimeout(() => btn.textContent = orig, 2000);
}
// Save original materials for wireframe toggle
allMeshes.forEach(m => { m.userData.origMat = m.material; });
// ── Pointer Lock ─────────────────────────────────
function lockPointer() { canvas.requestPointerLock(); }
document.addEventListener('pointerlockchange', () => {
player.isLocked = document.pointerLockElement === canvas;
if (player.isLocked) { document.getElementById('startScreen').style.display='none'; document.getElementById('controlsHint').style.opacity='0.6'; }
});
canvas.addEventListener('click', () => { if(!player.isLocked) lockPointer(); });
document.getElementById('startScreen').addEventListener('click', lockPointer);
document.addEventListener('mousemove', e => {
if (!player.isLocked) return;
const s = 0.002;
player.euler.y -= e.movementX * s;
player.euler.x -= e.movementY * s;
player.euler.x = Math.max(-Math.PI/2.2, Math.min(Math.PI/2.2, player.euler.x));
});
document.addEventListener('keydown', e => {
keys[e.code] = true;
if(e.code==='KeyM') toggleMeasures();
if(e.code==='KeyE') toggle360();
if(e.code==='KeyF') toggleWireframe();
if(e.code==='KeyT') toggleTextures();
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
// Touch
let touchX=0, touchY=0;
canvas.addEventListener('touchstart', e => { if(!player.isLocked) lockPointer(); touchX=e.touches[0].clientX; touchY=e.touches[0].clientY; });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if(!player.isLocked) return; const t=e.touches[0]; player.euler.y-=(t.clientX-touchX)*0.004; player.euler.x-=(t.clientY-touchY)*0.004; player.euler.x=Math.max(-Math.PI/2.2,Math.min(Math.PI/2.2,player.euler.x)); touchX=t.clientX; touchY=t.clientY; }, {passive:false});
// ── Collision Detection ──────────────────────────
function checkCollision(nx, nz) {
for (const c of colliders) {
if (nx+player.radius>c.x && nx-player.radius<c.x+c.w && nz+player.radius>c.z && nz-player.radius<c.z+c.d) {
if (player.position.y>=c.minY-0.01 && player.position.y<=c.maxY+0.01) return true;
}
}
return false;
}
// ── Room Detection ───────────────────────────────
function detectRoom() {
for (let i=0; i<ROOMS.length; i++) {
const r=ROOMS[i];
if (player.position.x>r.x-r.w/2-0.5 && player.position.x<r.x+r.w/2+0.5 && player.position.z>r.z-r.d/2-0.5 && player.position.z<r.z+r.d/2+0.5) return i;
}
return 0;
}
// ── Minimap ──────────────────────────────────────
const mmCanvas = document.getElementById('minimapCanvas');
const mmCtx = mmCanvas.getContext('2d');
mmCanvas.width = 400; mmCanvas.height = 400;
function drawMinimap() {
const ctx=mmCtx, s=16, cx=200, cy=200;
ctx.clearRect(0,0,400,400);
ctx.fillStyle='#0a0a0f'; ctx.fillRect(0,0,400,400);
ROOMS.forEach((r,i) => {
const rx=cx+r.x*s, ry=cy+r.z*s, rw=r.w*s, rd=r.d*s;
ctx.fillStyle = i===player.currentRoom?'rgba(108,92,231,0.25)':'rgba(255,255,255,0.06)';
ctx.strokeStyle = i===player.currentRoom?'#6c5ce7':'rgba(255,255,255,0.15)';
ctx.lineWidth=1.5;
ctx.fillRect(rx-rw/2,ry-rd/2,rw,rd);
ctx.strokeRect(rx-rw/2,ry-rd/2,rw,rd);
ctx.fillStyle='rgba(255,255,255,0.4)'; ctx.font='8px Inter'; ctx.textAlign='center';
ctx.fillText(r.name,rx,ry+3);
});
const px=cx+player.position.x*s, py=cy+player.position.z*s, a=player.euler.y;
ctx.fillStyle='rgba(0,210,255,0.1)';
ctx.beginPath(); ctx.moveTo(px,py); ctx.arc(px,py,35,-a-0.5,-a+0.5); ctx.closePath(); ctx.fill();
ctx.fillStyle='#00d2ff'; ctx.beginPath(); ctx.arc(px,py,4,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='#00d2ff'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(px,py); ctx.lineTo(px+Math.sin(-a)*18,py+Math.cos(a)*18); ctx.stroke();
}
// ── Game Loop ───────────────────────────────────
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.1);
if (player.isLocked) {
const speed = keys['ShiftLeft'] ? player.sprintSpeed : player.speed;
const fwd = new THREE.Vector3(-Math.sin(player.euler.y), 0, -Math.cos(player.euler.y));
const right = new THREE.Vector3(Math.cos(player.euler.y), 0, -Math.sin(player.euler.y));
if(keys['KeyW']) player.velocity.add(fwd.clone().multiplyScalar(speed*delta));
if(keys['KeyS']) player.velocity.add(fwd.clone().multiplyScalar(-speed*delta));
if(keys['KeyA']) player.velocity.add(right.clone().multiplyScalar(-speed*delta));
if(keys['KeyD']) player.velocity.add(right.clone().multiplyScalar(speed*delta));
const nx = player.position.x + player.velocity.x;
const nz = player.position.z + player.velocity.z;
if(!checkCollision(nx, player.position.z)) player.position.x = nx;
if(!checkCollision(player.position.x, nz)) player.position.z = nz;
player.velocity.multiplyScalar(0.85);
const moving = player.velocity.length() > 0.3;
player.position.y = player.height + (moving ? Math.sin(clock.elapsedTime*(keys['ShiftLeft']?12:8))*0.04 : 0);
camera.quaternion.setFromEuler(player.euler);
const newRoom = detectRoom();
if(newRoom !== player.currentRoom) {
player.currentRoom = newRoom;
const r = ROOMS[newRoom];
document.getElementById('roomName').textContent = r.name;
document.getElementById('roomSize').textContent = `${r.w}m × ${r.d}m · ${(r.w*r.d).toFixed(1)}`;
if(showMeasurements) {
document.getElementById('infoRoom').textContent = r.name;
document.getElementById('infoDims').textContent = `${r.w} × ${r.d} m`;
document.getElementById('infoArea').textContent = `${(r.w*r.d).toFixed(2)}`;
document.getElementById('infoHeight').textContent = `${r.h} m`;
}
}
}
camera.position.copy(player.position);
if(hotspotsGroup.visible) {
const t=clock.elapsedTime;
hotspotsGroup.children.forEach((c,i) => {
if(c.geometry?.type==='RingGeometry') { const s=1+Math.sin(t*3+i)*0.15; c.scale.set(s,s,1); c.material.opacity=0.4+Math.sin(t*3+i)*0.3; }
});
}
drawMinimap();
renderer.render(scene, camera);
}
function onResize() {
const c = document.querySelector('.viewer-container');
const w=c.clientWidth, h=c.clientHeight;
renderer.setSize(w,h);
camera.aspect = w/h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', onResize);
onResize();
animate();
</script>
</body>
</html>
+582
View File
@@ -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)}`;
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>
+1128
View File
File diff suppressed because it is too large Load Diff
Executable
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Setup Samba share for ~/projects (InteriorScan and other repos)
# Run with: sudo bash ~/projects/interiorscan/setup-samba.sh
set -e
echo "=== Setting up Samba share for ~/projects ==="
# 1. Add share to smb.conf
if ! grep -q "\[projects\]" /etc/samba/smb.conf; then
cat >> /etc/samba/smb.conf << 'EOF'
[projects]
comment = Development Projects
path = /home/monyi/projects
browseable = yes
read only = no
guest ok = yes
create mask = 0664
directory mask = 0775
force user = monyi
EOF
echo "✓ Share [projects] added to smb.conf"
else
echo "⚠ Share [projects] already exists in smb.conf"
fi
# 2. Set permissions on projects dir
chmod -R 775 /home/monyi/projects 2>/dev/null || true
echo "✓ Permissions set on /home/monyi/projects"
# 3. Test config
echo ""
echo "Testing Samba config..."
testparm -s 2>/dev/null | grep -A5 "\[projects\]" || echo "Run 'sudo testparm' to verify"
# 4. Restart Samba
echo ""
echo "Restarting Samba services..."
systemctl restart smbd nmbd 2>/dev/null || service smbd restart 2>/dev/null || echo "⚠ Could not restart smbd — run: sudo systemctl restart smbd nmbd"
echo ""
echo "=== Done! ==="
echo "From Windows, open: \\\\192.168.1.13\\projects"
echo "Or in File Explorer: \\\\192.168.1.13\\projects"
echo ""
echo "The demo.html is at: \\\\192.168.1.13\\projects\\interiorscan\\demo.html"