Add Three.js 3D room renderer with interactive viewer
renderer.js: HouseRenderer class that loads house JSON and renders rooms as 3D geometry - walls with door/window cutouts, floors with material colors, translucent ceilings, orbit camera controls, room click selection and highlighting. index.html: Viewer page with sidebar showing floor switcher and room list with areas. Click rooms in 3D or sidebar to focus camera. Uses Three.js via CDN importmap (no build step needed).
This commit is contained in:
151
src/index.html
Normal file
151
src/index.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>House Design Viewer</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow: hidden; }
|
||||||
|
|
||||||
|
#viewer { width: 100vw; height: 100vh; }
|
||||||
|
|
||||||
|
#sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 280px;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-left: 1px solid #ddd;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar h2 { font-size: 16px; margin-bottom: 12px; color: #333; }
|
||||||
|
#sidebar h3 { font-size: 13px; margin: 12px 0 6px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
|
||||||
|
.floor-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 14px;
|
||||||
|
margin: 2px 4px 2px 0;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.floor-btn.active { background: #4a90d9; color: #fff; border-color: #4a90d9; }
|
||||||
|
|
||||||
|
.room-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin: 2px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.room-item:hover { background: #e8f0fe; }
|
||||||
|
.room-item.active { background: #4a90d9; color: #fff; }
|
||||||
|
.room-item .area { font-size: 11px; opacity: 0.7; }
|
||||||
|
|
||||||
|
#info {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 16px;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="viewer"></div>
|
||||||
|
|
||||||
|
<div id="sidebar">
|
||||||
|
<h2 id="house-name">Loading...</h2>
|
||||||
|
<h3>Floors</h3>
|
||||||
|
<div id="floor-buttons"></div>
|
||||||
|
<h3>Rooms</h3>
|
||||||
|
<div id="room-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="info">Click a room to select it. Scroll to zoom, drag to orbit.</div>
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
|
||||||
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module">
|
||||||
|
import { HouseRenderer } from './renderer.js';
|
||||||
|
|
||||||
|
const viewer = document.getElementById('viewer');
|
||||||
|
const renderer = new HouseRenderer(viewer);
|
||||||
|
|
||||||
|
let selectedRoom = null;
|
||||||
|
|
||||||
|
renderer.loadHouse('../data/sample-house.json').then(house => {
|
||||||
|
document.getElementById('house-name').textContent = house.name;
|
||||||
|
buildFloorButtons();
|
||||||
|
buildRoomList();
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildFloorButtons() {
|
||||||
|
const container = document.getElementById('floor-buttons');
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (const floor of renderer.getFloors()) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'floor-btn' + (floor.index === renderer.currentFloor ? ' active' : '');
|
||||||
|
btn.textContent = floor.name;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
renderer.showFloor(floor.index);
|
||||||
|
document.querySelectorAll('.floor-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
buildRoomList();
|
||||||
|
selectedRoom = null;
|
||||||
|
});
|
||||||
|
container.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoomList() {
|
||||||
|
const container = document.getElementById('room-list');
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (const room of renderer.getRooms()) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'room-item';
|
||||||
|
item.dataset.roomId = room.id;
|
||||||
|
item.innerHTML = `<span>${room.name}</span><span class="area">${room.area} m²</span>`;
|
||||||
|
item.addEventListener('click', () => selectRoom(room.id));
|
||||||
|
container.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRoom(roomId) {
|
||||||
|
selectedRoom = roomId;
|
||||||
|
renderer.focusRoom(roomId);
|
||||||
|
document.querySelectorAll('.room-item').forEach(el => {
|
||||||
|
el.classList.toggle('active', el.dataset.roomId === roomId);
|
||||||
|
});
|
||||||
|
const room = renderer.getRooms().find(r => r.id === roomId);
|
||||||
|
if (room) {
|
||||||
|
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area} m²`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer.addEventListener('roomclick', (e) => {
|
||||||
|
selectRoom(e.detail.roomId);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
392
src/renderer.js
Normal file
392
src/renderer.js
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
wall: {
|
||||||
|
exterior: 0xe8e0d4,
|
||||||
|
interior: 0xf5f0eb
|
||||||
|
},
|
||||||
|
floor: {
|
||||||
|
tile: 0xc8beb0,
|
||||||
|
hardwood: 0xb5894e
|
||||||
|
},
|
||||||
|
ceiling: 0xfaf8f5,
|
||||||
|
door: 0x8b6914,
|
||||||
|
window: 0x87ceeb,
|
||||||
|
windowFrame: 0xd0d0d0,
|
||||||
|
grid: 0xcccccc,
|
||||||
|
selected: 0x4a90d9
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HouseRenderer {
|
||||||
|
constructor(container) {
|
||||||
|
this.container = container;
|
||||||
|
this.houseData = null;
|
||||||
|
this.currentFloor = 0;
|
||||||
|
this.roomMeshes = new Map();
|
||||||
|
this.roomLabels = new Map();
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
this.scene.background = new THREE.Color(0xf0f0f0);
|
||||||
|
|
||||||
|
this.camera = new THREE.PerspectiveCamera(
|
||||||
|
50,
|
||||||
|
container.clientWidth / container.clientHeight,
|
||||||
|
0.1,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
this.camera.position.set(6, 12, 14);
|
||||||
|
this.camera.lookAt(6, 0, 5);
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
this.renderer.setSize(container.clientWidth, container.clientHeight);
|
||||||
|
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
this.renderer.shadowMap.enabled = true;
|
||||||
|
container.appendChild(this.renderer.domElement);
|
||||||
|
|
||||||
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||||
|
this.controls.target.set(6, 0, 5);
|
||||||
|
this.controls.enableDamping = true;
|
||||||
|
this.controls.dampingFactor = 0.1;
|
||||||
|
this.controls.update();
|
||||||
|
|
||||||
|
this._addLights();
|
||||||
|
this._addGround();
|
||||||
|
|
||||||
|
this.raycaster = new THREE.Raycaster();
|
||||||
|
this.mouse = new THREE.Vector2();
|
||||||
|
this.renderer.domElement.addEventListener('click', (e) => this._onClick(e));
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => this._onResize());
|
||||||
|
|
||||||
|
this._animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_addLights() {
|
||||||
|
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
||||||
|
this.scene.add(ambient);
|
||||||
|
|
||||||
|
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
dir.position.set(10, 15, 10);
|
||||||
|
dir.castShadow = true;
|
||||||
|
dir.shadow.mapSize.width = 2048;
|
||||||
|
dir.shadow.mapSize.height = 2048;
|
||||||
|
this.scene.add(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
_addGround() {
|
||||||
|
const grid = new THREE.GridHelper(30, 30, COLORS.grid, COLORS.grid);
|
||||||
|
grid.position.y = -0.01;
|
||||||
|
this.scene.add(grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadHouse(url) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
this.houseData = await res.json();
|
||||||
|
this.showFloor(0);
|
||||||
|
return this.houseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
showFloor(index) {
|
||||||
|
this.currentFloor = index;
|
||||||
|
// Clear existing room meshes
|
||||||
|
for (const group of this.roomMeshes.values()) {
|
||||||
|
this.scene.remove(group);
|
||||||
|
}
|
||||||
|
this.roomMeshes.clear();
|
||||||
|
this.roomLabels.clear();
|
||||||
|
|
||||||
|
const floor = this.houseData.floors[index];
|
||||||
|
if (!floor) return;
|
||||||
|
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
this._renderRoom(room, floor.ceilingHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderRoom(room, ceilingHeight) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.userData = { roomId: room.id, roomName: room.name };
|
||||||
|
|
||||||
|
const { width, length } = room.dimensions;
|
||||||
|
const { x, y } = room.position;
|
||||||
|
// Map house coords: x -> Three.js x, y -> Three.js z
|
||||||
|
group.position.set(x, 0, y);
|
||||||
|
|
||||||
|
// Floor
|
||||||
|
this._addFloor(group, width, length, room.flooring);
|
||||||
|
|
||||||
|
// Ceiling (translucent)
|
||||||
|
this._addCeiling(group, width, length, ceilingHeight);
|
||||||
|
|
||||||
|
// Walls
|
||||||
|
const wallDefs = [
|
||||||
|
{ dir: 'south', axis: 'z', pos: 0, wallWidth: width, normal: [0, 0, -1], horizontal: true },
|
||||||
|
{ dir: 'north', axis: 'z', pos: length, wallWidth: width, normal: [0, 0, 1], horizontal: true },
|
||||||
|
{ dir: 'west', axis: 'x', pos: 0, wallWidth: length, normal: [-1, 0, 0], horizontal: false },
|
||||||
|
{ dir: 'east', axis: 'x', pos: width, wallWidth: length, normal: [1, 0, 0], horizontal: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const wd of wallDefs) {
|
||||||
|
const wallData = room.walls[wd.dir];
|
||||||
|
if (!wallData) continue;
|
||||||
|
this._addWall(group, wd, wallData, ceilingHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scene.add(group);
|
||||||
|
this.roomMeshes.set(room.id, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
_addFloor(group, width, length, flooring) {
|
||||||
|
const geo = new THREE.PlaneGeometry(width, length);
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color: COLORS.floor[flooring] || COLORS.floor.hardwood,
|
||||||
|
roughness: 0.8
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
|
mesh.rotation.x = -Math.PI / 2;
|
||||||
|
mesh.position.set(width / 2, 0, length / 2);
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
_addCeiling(group, width, length, height) {
|
||||||
|
const geo = new THREE.PlaneGeometry(width, length);
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color: COLORS.ceiling,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.3,
|
||||||
|
side: THREE.DoubleSide
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
|
mesh.rotation.x = Math.PI / 2;
|
||||||
|
mesh.position.set(width / 2, height, length / 2);
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
_addWall(group, wallDef, wallData, ceilingHeight) {
|
||||||
|
const thickness = 0.08;
|
||||||
|
const wallColor = wallData.type === 'exterior' ? COLORS.wall.exterior : COLORS.wall.interior;
|
||||||
|
const openings = [
|
||||||
|
...(wallData.doors || []).map(d => ({ ...d, _kind: 'door', bottom: 0 })),
|
||||||
|
...(wallData.windows || []).map(w => ({ ...w, _kind: 'window', bottom: w.sillHeight || 0.8 }))
|
||||||
|
].sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
const wallWidth = wallDef.wallWidth;
|
||||||
|
const segments = this._computeWallSegments(openings, wallWidth, ceilingHeight);
|
||||||
|
|
||||||
|
for (const seg of segments) {
|
||||||
|
const geo = new THREE.BoxGeometry(seg.w, seg.h, thickness);
|
||||||
|
const mat = new THREE.MeshStandardMaterial({ color: wallColor, roughness: 0.9 });
|
||||||
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
mesh.userData = { isWall: true, roomId: group.userData.roomId };
|
||||||
|
|
||||||
|
if (wallDef.horizontal) {
|
||||||
|
mesh.position.set(seg.cx, seg.cy, wallDef.pos);
|
||||||
|
} else {
|
||||||
|
mesh.rotation.y = Math.PI / 2;
|
||||||
|
mesh.position.set(wallDef.pos, seg.cy, seg.cx);
|
||||||
|
}
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render door and window fills
|
||||||
|
for (const op of openings) {
|
||||||
|
if (op._kind === 'door') {
|
||||||
|
this._addDoorMesh(group, wallDef, op, thickness);
|
||||||
|
} else {
|
||||||
|
this._addWindowMesh(group, wallDef, op, thickness);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeWallSegments(openings, wallWidth, wallHeight) {
|
||||||
|
if (openings.length === 0) {
|
||||||
|
return [{ w: wallWidth, h: wallHeight, cx: wallWidth / 2, cy: wallHeight / 2 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = [];
|
||||||
|
|
||||||
|
// Segments between openings (full height sections on either side)
|
||||||
|
let prevEnd = 0;
|
||||||
|
for (const op of openings) {
|
||||||
|
const opLeft = op.position;
|
||||||
|
const opRight = op.position + op.width;
|
||||||
|
const opBottom = op.bottom;
|
||||||
|
const opTop = opBottom + op.height;
|
||||||
|
|
||||||
|
// Full-height segment to the left of this opening
|
||||||
|
if (opLeft > prevEnd + 0.01) {
|
||||||
|
const w = opLeft - prevEnd;
|
||||||
|
segments.push({ w, h: wallHeight, cx: prevEnd + w / 2, cy: wallHeight / 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment above the opening
|
||||||
|
if (opTop < wallHeight - 0.01) {
|
||||||
|
const h = wallHeight - opTop;
|
||||||
|
segments.push({ w: op.width, h, cx: opLeft + op.width / 2, cy: opTop + h / 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment below the opening (for windows with sill)
|
||||||
|
if (opBottom > 0.01) {
|
||||||
|
segments.push({ w: op.width, h: opBottom, cx: opLeft + op.width / 2, cy: opBottom / 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
prevEnd = opRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining full-height segment on the right
|
||||||
|
if (prevEnd < wallWidth - 0.01) {
|
||||||
|
const w = wallWidth - prevEnd;
|
||||||
|
segments.push({ w, h: wallHeight, cx: prevEnd + w / 2, cy: wallHeight / 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
_addDoorMesh(group, wallDef, door, thickness) {
|
||||||
|
const geo = new THREE.BoxGeometry(door.width, door.height, thickness * 0.5);
|
||||||
|
const mat = new THREE.MeshStandardMaterial({ color: COLORS.door, roughness: 0.6 });
|
||||||
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
|
|
||||||
|
const cx = door.position + door.width / 2;
|
||||||
|
const cy = door.height / 2;
|
||||||
|
|
||||||
|
if (wallDef.horizontal) {
|
||||||
|
mesh.position.set(cx, cy, wallDef.pos);
|
||||||
|
} else {
|
||||||
|
mesh.rotation.y = Math.PI / 2;
|
||||||
|
mesh.position.set(wallDef.pos, cy, cx);
|
||||||
|
}
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
_addWindowMesh(group, wallDef, win, thickness) {
|
||||||
|
// Glass pane
|
||||||
|
const geo = new THREE.BoxGeometry(win.width, win.height, thickness * 0.3);
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color: COLORS.window,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.4,
|
||||||
|
roughness: 0.1
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
|
|
||||||
|
const cx = win.position + win.width / 2;
|
||||||
|
const cy = (win.sillHeight || 0.8) + win.height / 2;
|
||||||
|
|
||||||
|
if (wallDef.horizontal) {
|
||||||
|
mesh.position.set(cx, cy, wallDef.pos);
|
||||||
|
} else {
|
||||||
|
mesh.rotation.y = Math.PI / 2;
|
||||||
|
mesh.position.set(wallDef.pos, cy, cx);
|
||||||
|
}
|
||||||
|
group.add(mesh);
|
||||||
|
|
||||||
|
// Window frame
|
||||||
|
const frameGeo = new THREE.EdgesGeometry(geo);
|
||||||
|
const frameMat = new THREE.LineBasicMaterial({ color: COLORS.windowFrame });
|
||||||
|
const frame = new THREE.LineSegments(frameGeo, frameMat);
|
||||||
|
frame.position.copy(mesh.position);
|
||||||
|
frame.rotation.copy(mesh.rotation);
|
||||||
|
group.add(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightRoom(roomId) {
|
||||||
|
// Reset all rooms
|
||||||
|
for (const [id, group] of this.roomMeshes) {
|
||||||
|
group.traverse(child => {
|
||||||
|
if (child.isMesh && child.material && child.userData.isWall) {
|
||||||
|
child.material.emissive?.setHex(0x000000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight target
|
||||||
|
const target = this.roomMeshes.get(roomId);
|
||||||
|
if (target) {
|
||||||
|
target.traverse(child => {
|
||||||
|
if (child.isMesh && child.userData.isWall) {
|
||||||
|
child.material.emissive.setHex(0x111133);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusRoom(roomId) {
|
||||||
|
const group = this.roomMeshes.get(roomId);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const floor = this.houseData.floors[this.currentFloor];
|
||||||
|
const room = floor.rooms.find(r => r.id === roomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
const cx = room.position.x + room.dimensions.width / 2;
|
||||||
|
const cz = room.position.y + room.dimensions.length / 2;
|
||||||
|
|
||||||
|
const maxDim = Math.max(room.dimensions.width, room.dimensions.length);
|
||||||
|
const dist = maxDim * 1.5;
|
||||||
|
|
||||||
|
this.camera.position.set(cx + dist * 0.5, dist, cz + dist * 0.5);
|
||||||
|
this.controls.target.set(cx, 1, cz);
|
||||||
|
this.controls.update();
|
||||||
|
this.highlightRoom(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFloors() {
|
||||||
|
if (!this.houseData) return [];
|
||||||
|
return this.houseData.floors.map((f, i) => ({ index: i, id: f.id, name: f.name, nameEN: f.nameEN }));
|
||||||
|
}
|
||||||
|
|
||||||
|
getRooms() {
|
||||||
|
if (!this.houseData) return [];
|
||||||
|
const floor = this.houseData.floors[this.currentFloor];
|
||||||
|
if (!floor) return [];
|
||||||
|
return floor.rooms.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
nameEN: r.nameEN,
|
||||||
|
type: r.type,
|
||||||
|
area: +(r.dimensions.width * r.dimensions.length).toFixed(1)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClick(event) {
|
||||||
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||||
|
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
|
||||||
|
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||||
|
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
|
||||||
|
|
||||||
|
for (const hit of intersects) {
|
||||||
|
let obj = hit.object;
|
||||||
|
while (obj && !obj.userData.roomId) {
|
||||||
|
obj = obj.parent;
|
||||||
|
}
|
||||||
|
if (obj && obj.userData.roomId) {
|
||||||
|
this.highlightRoom(obj.userData.roomId);
|
||||||
|
this.container.dispatchEvent(new CustomEvent('roomclick', {
|
||||||
|
detail: { roomId: obj.userData.roomId, roomName: obj.userData.roomName }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onResize() {
|
||||||
|
const w = this.container.clientWidth;
|
||||||
|
const h = this.container.clientHeight;
|
||||||
|
this.camera.aspect = w / h;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
_animate() {
|
||||||
|
requestAnimationFrame(() => this._animate());
|
||||||
|
this.controls.update();
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user