From 0d3a4dddf501bee511b20c2300a5b67bff64c9bc Mon Sep 17 00:00:00 2001 From: m Date: Sat, 7 Feb 2026 11:32:08 +0100 Subject: [PATCH] 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). --- src/index.html | 151 +++++++++++++++++++ src/renderer.js | 392 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 543 insertions(+) create mode 100644 src/index.html create mode 100644 src/renderer.js diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..a2b9430 --- /dev/null +++ b/src/index.html @@ -0,0 +1,151 @@ + + + + + + House Design Viewer + + + +
+ + + +
Click a room to select it. Scroll to zoom, drag to orbit.
+ + + + + diff --git a/src/renderer.js b/src/renderer.js new file mode 100644 index 0000000..bbf6099 --- /dev/null +++ b/src/renderer.js @@ -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); + } +}