diff --git a/src/catalog.js b/src/catalog.js
new file mode 100644
index 0000000..0347933
--- /dev/null
+++ b/src/catalog.js
@@ -0,0 +1,233 @@
+/**
+ * CatalogPanel — left sidebar for browsing furniture catalog.
+ *
+ * Shows categories, search, and item cards. Clicking an item
+ * adds it to the center of the selected room via DesignState.
+ */
+export class CatalogPanel {
+ constructor(container, { renderer, state, interaction }) {
+ this.container = container;
+ this.renderer = renderer;
+ this.state = state;
+ this.interaction = interaction;
+
+ this.selectedCategory = 'all';
+ this.searchQuery = '';
+ this.selectedRoomId = null;
+
+ this._build();
+ this._bindEvents();
+ }
+
+ // ---- Build DOM ----
+
+ _build() {
+ this.container.innerHTML = '';
+ this.container.className = 'catalog-panel';
+
+ // Search
+ const searchWrap = document.createElement('div');
+ searchWrap.className = 'catalog-search';
+ this._searchInput = document.createElement('input');
+ this._searchInput.type = 'text';
+ this._searchInput.placeholder = 'Search furniture...';
+ this._searchInput.className = 'catalog-search-input';
+ searchWrap.appendChild(this._searchInput);
+ this.container.appendChild(searchWrap);
+
+ // Categories
+ this._categoryBar = document.createElement('div');
+ this._categoryBar.className = 'catalog-categories';
+ this.container.appendChild(this._categoryBar);
+
+ // Items list
+ this._itemList = document.createElement('div');
+ this._itemList.className = 'catalog-items';
+ this.container.appendChild(this._itemList);
+
+ this._renderCategories();
+ this._renderItems();
+ }
+
+ _renderCategories() {
+ const catalog = this.renderer.catalogData;
+ if (!catalog) return;
+
+ this._categoryBar.innerHTML = '';
+ const categories = ['all', ...catalog.categories];
+
+ const LABELS = {
+ all: 'All',
+ seating: 'Seating',
+ tables: 'Tables',
+ storage: 'Storage',
+ beds: 'Beds',
+ bathroom: 'Bath',
+ kitchen: 'Kitchen',
+ office: 'Office',
+ lighting: 'Lighting',
+ decor: 'Decor'
+ };
+
+ for (const cat of categories) {
+ const btn = document.createElement('button');
+ btn.className = 'catalog-cat-btn' + (cat === this.selectedCategory ? ' active' : '');
+ btn.textContent = LABELS[cat] || cat;
+ btn.dataset.category = cat;
+ btn.addEventListener('click', () => {
+ this.selectedCategory = cat;
+ this._renderCategories();
+ this._renderItems();
+ });
+ this._categoryBar.appendChild(btn);
+ }
+ }
+
+ _renderItems() {
+ const catalog = this.renderer.catalogData;
+ if (!catalog) {
+ this._itemList.innerHTML = '
No catalog loaded
';
+ return;
+ }
+
+ let items = catalog.items;
+
+ // Filter by category
+ if (this.selectedCategory !== 'all') {
+ items = items.filter(it => it.category === this.selectedCategory);
+ }
+
+ // Filter by search
+ if (this.searchQuery) {
+ const q = this.searchQuery.toLowerCase();
+ items = items.filter(it =>
+ it.name.toLowerCase().includes(q) ||
+ it.id.toLowerCase().includes(q) ||
+ it.category.toLowerCase().includes(q)
+ );
+ }
+
+ this._itemList.innerHTML = '';
+
+ if (items.length === 0) {
+ this._itemList.innerHTML = 'No items found
';
+ return;
+ }
+
+ for (const item of items) {
+ const card = this._createItemCard(item);
+ this._itemList.appendChild(card);
+ }
+ }
+
+ _createItemCard(item) {
+ const card = document.createElement('div');
+ card.className = 'catalog-item';
+ card.dataset.catalogId = item.id;
+
+ // Color swatch from first part
+ const color = item.mesh?.parts?.[0]?.color || '#888';
+
+ const dims = item.dimensions;
+ const dimStr = `${dims.width}×${dims.depth}×${dims.height}m`;
+
+ card.innerHTML =
+ `` +
+ `` +
+ `
${item.name}
` +
+ `
${dimStr}
` +
+ `
` +
+ ``;
+
+ card.querySelector('.catalog-item-add').addEventListener('click', (e) => {
+ e.stopPropagation();
+ this._placeItem(item);
+ });
+
+ card.addEventListener('click', () => {
+ this._placeItem(item);
+ });
+
+ return card;
+ }
+
+ // ---- Place item ----
+
+ _placeItem(catalogItem) {
+ // Determine target room
+ const roomId = this._getTargetRoom(catalogItem);
+ if (!roomId) return;
+
+ // Get room center for initial placement
+ const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
+ const room = floor?.rooms.find(r => r.id === roomId);
+ if (!room) return;
+
+ // Place at room center (local coords)
+ const cx = room.dimensions.width / 2;
+ const cz = room.dimensions.length / 2;
+
+ const placement = {
+ catalogId: catalogItem.id,
+ position: { x: cx, z: cz },
+ rotation: 0,
+ wallMounted: false
+ };
+
+ // InteractionManager handles the re-render via furniture-add event
+ this.state.addFurniture(roomId, placement);
+ }
+
+ _getTargetRoom(catalogItem) {
+ // Use currently selected room from interaction manager or sidebar
+ if (this.selectedRoomId) {
+ return this.selectedRoomId;
+ }
+
+ // If interaction has a room selected (via furniture selection)
+ if (this.interaction.selectedRoomId) {
+ return this.interaction.selectedRoomId;
+ }
+
+ // Try to find a matching room on the current floor
+ const rooms = this.renderer.getRooms();
+ if (rooms.length === 0) return null;
+
+ // If catalog item has room hints, try to match
+ if (catalogItem.rooms && catalogItem.rooms.length > 0) {
+ for (const room of rooms) {
+ // Room ids contain the room type (eg "eg-wohnzimmer")
+ for (const hint of catalogItem.rooms) {
+ if (room.id.includes(hint)) return room.id;
+ }
+ }
+ }
+
+ // Fallback: first room on the floor
+ return rooms[0].id;
+ }
+
+ // ---- Events ----
+
+ _bindEvents() {
+ this._searchInput.addEventListener('input', () => {
+ this.searchQuery = this._searchInput.value.trim();
+ this._renderItems();
+ });
+
+ // Track room selection from main sidebar
+ this.renderer.container.addEventListener('roomclick', (e) => {
+ this.selectedRoomId = e.detail.roomId;
+ });
+ }
+
+ /** Called externally when a room is selected in the main sidebar. */
+ setSelectedRoom(roomId) {
+ this.selectedRoomId = roomId;
+ }
+
+ /** Refresh the item list (e.g., after floor change). */
+ refresh() {
+ this._renderItems();
+ }
+}
diff --git a/src/index.html b/src/index.html
index 5921281..442721b 100644
--- a/src/index.html
+++ b/src/index.html
@@ -263,6 +263,7 @@
import { InteractionManager } from './interaction.js';
import { ThemeManager } from './themes.js';
import { ExportManager } from './export.js';
+ import { CatalogPanel } from './catalog.js';
const viewer = document.getElementById('viewer');
const houseRenderer = new HouseRenderer(viewer);
@@ -272,6 +273,7 @@
let interaction = null;
let themeManager = null;
let exportManager = null;
+ let catalogPanel = null;
houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => {
document.getElementById('house-name').textContent = house.name;
@@ -294,6 +296,11 @@
themeManager = new ThemeManager(houseRenderer);
exportManager = new ExportManager(houseRenderer, designState);
+ catalogPanel = new CatalogPanel(document.getElementById('catalog-panel'), {
+ renderer: houseRenderer,
+ state: designState,
+ interaction
+ });
buildFloorButtons();
buildRoomList();
buildThemeButtons();
@@ -316,6 +323,7 @@
btn.classList.add('active');
buildRoomList();
selectedRoom = null;
+ if (catalogPanel) catalogPanel.setSelectedRoom(null);
});
container.appendChild(btn);
}
@@ -340,6 +348,7 @@
document.querySelectorAll('.room-item').forEach(el => {
el.classList.toggle('active', el.dataset.roomId === roomId);
});
+ if (catalogPanel) catalogPanel.setSelectedRoom(roomId);
const room = houseRenderer.getRooms().find(r => r.id === roomId);
if (room) {
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area} m²`;
diff --git a/src/interaction.js b/src/interaction.js
index a91ff4d..1e1d518 100644
--- a/src/interaction.js
+++ b/src/interaction.js
@@ -349,9 +349,9 @@ export class InteractionManager {
this._syncMeshFromState(_detail.roomId, _detail.index);
}
- if (type === 'furniture-remove') {
- // Mesh was already removed by state; re-render the floor
- this.clearSelection();
+ if (type === 'furniture-add' || type === 'furniture-remove') {
+ // Re-render the floor to reflect added/removed furniture
+ if (type === 'furniture-remove') this.clearSelection();
this.renderer.designData = this.state.design;
this.renderer._clearFloor();
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];