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);
+ }
+}