diff --git a/src/index.html b/src/index.html index 05d793c..0920041 100644 --- a/src/index.html +++ b/src/index.html @@ -100,6 +100,9 @@ await renderer.loadDesign('../designs/sample-house-design.json'); buildFloorButtons(); buildRoomList(); + }).catch(err => { + document.getElementById('house-name').textContent = 'Error loading data'; + document.getElementById('info').textContent = err.message; }); function buildFloorButtons() { diff --git a/src/renderer.js b/src/renderer.js index 1cc451b..c7d12ce 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -28,6 +28,8 @@ export class HouseRenderer { 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); @@ -74,6 +76,12 @@ export class HouseRenderer { 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); } @@ -84,42 +92,56 @@ export class HouseRenderer { } async loadHouse(url) { - const res = await fetch(url); - this.houseData = await res.json(); - this.showFloor(0); - return this.houseData; + 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) { - const res = await fetch(url); - this.catalogData = await res.json(); - this._catalogIndex = new Map(); - for (const item of this.catalogData.items) { - this._catalogIndex.set(item.id, item); + 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; } - return this.catalogData; } async loadDesign(url) { - const res = await fetch(url); - this.designData = await res.json(); - this._placeFurnitureForFloor(); - return this.designData; + 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; - // Clear existing room meshes - for (const group of this.roomMeshes.values()) { - this.scene.remove(group); - } - this.roomMeshes.clear(); - this.roomLabels.clear(); - // Clear existing furniture - for (const group of this.furnitureMeshes.values()) { - this.scene.remove(group); - } - this.furnitureMeshes.clear(); + this._clearFloor(); const floor = this.houseData.floors[index]; if (!floor) return; @@ -131,6 +153,38 @@ export class HouseRenderer { this._placeFurnitureForFloor(); } + _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 }; @@ -160,16 +214,67 @@ export class HouseRenderer { 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 = new THREE.PlaneGeometry(width, length); - const mat = new THREE.MeshStandardMaterial({ - color: COLORS.floor[flooring] || COLORS.floor.hardwood, - roughness: 0.8 - }); + 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); @@ -178,13 +283,13 @@ export class HouseRenderer { } _addCeiling(group, width, length, height) { - const geo = new THREE.PlaneGeometry(width, length); - const mat = new THREE.MeshStandardMaterial({ + 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); @@ -202,13 +307,15 @@ export class HouseRenderer { 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 = new THREE.BoxGeometry(seg.w, seg.h, thickness); - const mat = new THREE.MeshStandardMaterial({ color: wallColor, roughness: 0.9 }); + 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 }; + mesh.userData = { isWall: true, roomId: group.userData.roomId, materialKey: matKey }; if (wallDef.horizontal) { mesh.position.set(seg.cx, seg.cy, wallDef.pos); @@ -274,8 +381,9 @@ export class HouseRenderer { } _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 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; @@ -292,13 +400,14 @@ export class HouseRenderer { _addWindowMesh(group, wallDef, win, thickness) { // Glass pane - const geo = new THREE.BoxGeometry(win.width, win.height, thickness * 0.3); - const mat = new THREE.MeshStandardMaterial({ + 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; @@ -313,8 +422,8 @@ export class HouseRenderer { group.add(mesh); // Window frame - const frameGeo = new THREE.EdgesGeometry(geo); - const frameMat = new THREE.LineBasicMaterial({ color: COLORS.windowFrame }); + 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); @@ -322,21 +431,28 @@ export class HouseRenderer { } highlightRoom(roomId) { - // Reset all rooms + // Reset all rooms - restore original shared materials for (const [id, group] of this.roomMeshes) { group.traverse(child => { - if (child.isMesh && child.material && child.userData.isWall) { - child.material.emissive?.setHex(0x000000); + if (child.isMesh && child.userData.isWall && child.userData._origMaterial) { + child.material = child.userData._origMaterial; + delete child.userData._origMaterial; } }); } - // Highlight target + // Highlight target - swap to cached highlight variant const target = this.roomMeshes.get(roomId); if (target) { target.traverse(child => { if (child.isMesh && child.userData.isWall) { - child.material.emissive.setHex(0x111133); + 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; + }); } }); } @@ -426,7 +542,10 @@ export class HouseRenderer { // 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; - mesh.position.set(rx, 0, rz); + // 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}`; @@ -434,7 +553,8 @@ export class HouseRenderer { isFurniture: true, catalogId: placement.catalogId, roomId: roomDesign.roomId, - itemName: catalogItem.name + itemName: catalogItem.name, + wallMounted: !!placement.wallMounted }; this.scene.add(mesh); @@ -451,17 +571,17 @@ export class HouseRenderer { for (const part of meshDef.parts) { let geo; if (part.geometry === 'box') { - geo = new THREE.BoxGeometry(part.size[0], part.size[1], part.size[2]); + 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 = new THREE.CylinderGeometry(part.radius, part.radius, part.height, 16); + geo = this._getCachedGeometry(`cyl:${part.radius}:${part.height}`, () => new THREE.CylinderGeometry(part.radius, part.radius, part.height, 16)); } else { continue; } - const mat = new THREE.MeshStandardMaterial({ + 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]);