import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; export 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.catalogData = null; this.designData = null; this.currentFloor = 0; this.roomMeshes = new Map(); this.roomLabels = new Map(); this.furnitureMeshes = new Map(); this._materialCache = new Map(); this._geometryCache = 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, preserveDrawingBuffer: 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; dir.shadow.camera.left = -15; dir.shadow.camera.right = 15; dir.shadow.camera.top = 15; dir.shadow.camera.bottom = -15; dir.shadow.camera.near = 0.5; dir.shadow.camera.far = 40; 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) { try { const res = await fetch(url); if (!res.ok) throw new Error(`Failed to load house: ${res.status} ${res.statusText}`); this.houseData = await res.json(); this.showFloor(0); return this.houseData; } catch (err) { this._emitError('loadHouse', err); throw err; } } async loadCatalog(url) { try { const res = await fetch(url); if (!res.ok) throw new Error(`Failed to load catalog: ${res.status} ${res.statusText}`); this.catalogData = await res.json(); this._catalogIndex = new Map(); for (const item of this.catalogData.items) { this._catalogIndex.set(item.id, item); } return this.catalogData; } catch (err) { this._emitError('loadCatalog', err); throw err; } } async loadDesign(url) { try { const res = await fetch(url); if (!res.ok) throw new Error(`Failed to load design: ${res.status} ${res.statusText}`); this.designData = await res.json(); this._placeFurnitureForFloor(); return this.designData; } catch (err) { this._emitError('loadDesign', err); throw err; } } _emitError(source, error) { this.container.dispatchEvent(new CustomEvent('loaderror', { detail: { source, error: error.message } })); } showFloor(index) { this.currentFloor = index; this._clearFloor(); const floor = this.houseData.floors[index]; if (!floor) return; for (const room of floor.rooms) { this._renderRoom(room, floor.ceilingHeight); } this._placeFurnitureForFloor(); this.container.dispatchEvent(new CustomEvent('floorchange', { detail: { index, floor } })); } setControlsEnabled(enabled) { this.controls.enabled = enabled; } _clearFloor() { for (const group of this.roomMeshes.values()) { this.scene.remove(group); this._disposeGroup(group); } this.roomMeshes.clear(); this.roomLabels.clear(); for (const group of this.furnitureMeshes.values()) { this.scene.remove(group); this._disposeGroup(group); } this.furnitureMeshes.clear(); // Dispose all cached GPU resources — they'll be recreated as needed for (const geo of this._geometryCache.values()) geo.dispose(); this._geometryCache.clear(); for (const mat of this._materialCache.values()) { if (mat.map) mat.map.dispose(); mat.dispose(); } this._materialCache.clear(); } _disposeGroup(group) { group.traverse(child => { if (child.geometry) child.geometry.dispose(); if (child.material) { if (child.material.map) child.material.map.dispose(); child.material.dispose(); } }); } _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); } // Room label const label = this._createRoomLabel(room.name, width, length, ceilingHeight); group.add(label); this.roomLabels.set(room.id, label); this.scene.add(group); this.roomMeshes.set(room.id, group); } _createRoomLabel(name, width, length, ceilingHeight) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const fontSize = 48; ctx.font = `bold ${fontSize}px sans-serif`; const metrics = ctx.measureText(name); const padding = 16; canvas.width = metrics.width + padding * 2; canvas.height = fontSize + padding * 2; ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.font = `bold ${fontSize}px sans-serif`; ctx.fillStyle = '#ffffff'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillText(name, canvas.width / 2, canvas.height / 2); const texture = new THREE.CanvasTexture(canvas); const mat = new THREE.SpriteMaterial({ map: texture, transparent: true }); const sprite = new THREE.Sprite(mat); const scale = 0.008; sprite.scale.set(canvas.width * scale, canvas.height * scale, 1); sprite.position.set(width / 2, ceilingHeight * 0.4, length / 2); return sprite; } _getCachedMaterial(key, createFn) { let mat = this._materialCache.get(key); if (!mat) { mat = createFn(); this._materialCache.set(key, mat); } return mat; } _getCachedGeometry(key, createFn) { let geo = this._geometryCache.get(key); if (!geo) { geo = createFn(); this._geometryCache.set(key, geo); } return geo; } _addFloor(group, width, length, flooring) { const geo = this._getCachedGeometry(`plane:${width}:${length}`, () => new THREE.PlaneGeometry(width, length)); const color = COLORS.floor[flooring] || COLORS.floor.hardwood; const mat = this._getCachedMaterial(`floor:${color}`, () => new THREE.MeshStandardMaterial({ color, 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 = this._getCachedGeometry(`plane:${width}:${length}`, () => new THREE.PlaneGeometry(width, length)); const mat = this._getCachedMaterial('ceiling', () => 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); const matKey = `wall:${wallColor}`; const mat = this._getCachedMaterial(matKey, () => new THREE.MeshStandardMaterial({ color: wallColor, roughness: 0.9 })); for (const seg of segments) { const geo = this._getCachedGeometry(`box:${seg.w}:${seg.h}:${thickness}`, () => new THREE.BoxGeometry(seg.w, seg.h, thickness)); const mesh = new THREE.Mesh(geo, mat); mesh.castShadow = true; mesh.receiveShadow = true; mesh.userData = { isWall: true, roomId: group.userData.roomId, materialKey: matKey }; 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 t = thickness * 0.5; const geo = this._getCachedGeometry(`box:${door.width}:${door.height}:${t}`, () => new THREE.BoxGeometry(door.width, door.height, t)); const mat = this._getCachedMaterial('door', () => 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 t = thickness * 0.3; const geo = this._getCachedGeometry(`box:${win.width}:${win.height}:${t}`, () => new THREE.BoxGeometry(win.width, win.height, t)); const mat = this._getCachedMaterial('window', () => 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 = this._getCachedGeometry(`edges:${win.width}:${win.height}:${t}`, () => new THREE.EdgesGeometry(geo)); const frameMat = this._getCachedMaterial('windowFrame', () => 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 - restore original shared materials for (const [id, group] of this.roomMeshes) { group.traverse(child => { if (child.isMesh && child.userData.isWall && child.userData._origMaterial) { child.material = child.userData._origMaterial; delete child.userData._origMaterial; } }); } // Highlight target - swap to cached highlight variant const target = this.roomMeshes.get(roomId); if (target) { target.traverse(child => { if (child.isMesh && child.userData.isWall) { const baseKey = child.userData.materialKey; child.userData._origMaterial = child.material; child.material = this._getCachedMaterial(`${baseKey}:highlight`, () => { const m = child.material.clone(); m.emissive.setHex(0x111133); return m; }); } }); } } 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; // Walk up to find furniture or room group while (obj && !obj.userData.isFurniture && !obj.userData.roomId) { obj = obj.parent; } if (obj && obj.userData.isFurniture) { this.container.dispatchEvent(new CustomEvent('furnitureclick', { detail: { catalogId: obj.userData.catalogId, roomId: obj.userData.roomId, itemName: obj.userData.itemName, wallMounted: obj.userData.wallMounted, mesh: obj, point: hit.point } })); return; } 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; } } } _placeFurnitureForFloor() { if (!this.designData || !this.catalogData || !this.houseData) return; const floor = this.houseData.floors[this.currentFloor]; if (!floor) return; const floorRoomIds = new Set(floor.rooms.map(r => r.id)); for (const roomDesign of this.designData.rooms) { if (!floorRoomIds.has(roomDesign.roomId)) continue; const room = floor.rooms.find(r => r.id === roomDesign.roomId); if (!room) continue; for (let i = 0; i < roomDesign.furniture.length; i++) { const placement = roomDesign.furniture[i]; const catalogItem = this._catalogIndex.get(placement.catalogId); if (!catalogItem) continue; const mesh = this._buildFurnitureMesh(catalogItem); // Position in room-local coords, then offset by room position const rx = room.position.x + placement.position.x; const rz = room.position.y + placement.position.z; // Wall-mounted items (mirrors, wall cabinets) use position.y for mount // height offset; floor-standing items are placed at y=0 const ry = placement.wallMounted ? (placement.position.y ?? 0) : 0; mesh.position.set(rx, ry, rz); mesh.rotation.y = -(placement.rotation * Math.PI) / 180; const key = `${roomDesign.roomId}-${placement.instanceId || placement.catalogId}-${i}`; mesh.userData = { isFurniture: true, catalogId: placement.catalogId, roomId: roomDesign.roomId, furnitureIndex: i, itemName: catalogItem.name, wallMounted: !!placement.wallMounted }; this.scene.add(mesh); this.furnitureMeshes.set(key, mesh); } } } _buildFurnitureMesh(catalogItem) { const group = new THREE.Group(); const meshDef = catalogItem.mesh; if (!meshDef || meshDef.type !== 'group' || !meshDef.parts) return group; for (const part of meshDef.parts) { let geo; if (part.geometry === 'box') { geo = this._getCachedGeometry(`box:${part.size[0]}:${part.size[1]}:${part.size[2]}`, () => new THREE.BoxGeometry(part.size[0], part.size[1], part.size[2])); } else if (part.geometry === 'cylinder') { geo = this._getCachedGeometry(`cyl:${part.radius}:${part.height}`, () => new THREE.CylinderGeometry(part.radius, part.radius, part.height, 16)); } else { continue; } const mat = this._getCachedMaterial(`furniture:${part.color}`, () => new THREE.MeshStandardMaterial({ color: new THREE.Color(part.color), roughness: 0.7 })); const mesh = new THREE.Mesh(geo, mat); mesh.position.set(part.position[0], part.position[1], part.position[2]); mesh.castShadow = true; mesh.receiveShadow = true; group.add(mesh); } return group; } _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); } }