- Create data/ikea-catalog.json with 41 curated IKEA items across 23 series (KALLAX, BILLY, MALM, PAX, HEMNES, LACK, etc.) with verified dimensions - Add source tabs (All/Standard/IKEA) to catalog panel for filtering - Add IKEA series filter bar when viewing IKEA items - Add IKEA badge and series label on item cards - Add mergeCatalog() to renderer for loading additional catalog files - Add scripts/import-ikea-hf.js for importing from HuggingFace dataset
664 lines
21 KiB
JavaScript
664 lines
21 KiB
JavaScript
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 mergeCatalog(url) {
|
|
try {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`Failed to load catalog: ${res.status} ${res.statusText}`);
|
|
const extra = await res.json();
|
|
if (!this.catalogData) {
|
|
return this.loadCatalog(url);
|
|
}
|
|
// Merge categories
|
|
for (const cat of extra.categories || []) {
|
|
if (!this.catalogData.categories.includes(cat)) {
|
|
this.catalogData.categories.push(cat);
|
|
}
|
|
}
|
|
// Merge items, avoiding duplicates by id
|
|
for (const item of extra.items || []) {
|
|
if (!this._catalogIndex.has(item.id)) {
|
|
this.catalogData.items.push(item);
|
|
this._catalogIndex.set(item.id, item);
|
|
}
|
|
}
|
|
// Store extra catalog for tabbed access
|
|
if (!this._extraCatalogs) this._extraCatalogs = [];
|
|
this._extraCatalogs.push(extra);
|
|
return extra;
|
|
} catch (err) {
|
|
this._emitError('mergeCatalog', 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);
|
|
}
|
|
}
|