Without this flag, toDataURL() on the WebGL canvas can return blank in some browsers because the drawing buffer gets cleared.
633 lines
20 KiB
JavaScript
633 lines
20 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 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);
|
|
}
|
|
}
|