diff --git a/src/house-editor.js b/src/house-editor.js new file mode 100644 index 0000000..0879d46 --- /dev/null +++ b/src/house-editor.js @@ -0,0 +1,681 @@ +/** + * HouseEditor — UI panel for house customization. + * + * Provides controls to create/edit houses, add/remove rooms, + * adjust dimensions, manage floors, and save as templates. + */ +export class HouseEditor { + constructor(container, { renderer, onHouseChanged }) { + this.container = container; + this.renderer = renderer; + this.onHouseChanged = onHouseChanged || (() => {}); + this._editing = false; + this._selectedRoomId = null; + this.render(); + } + + get houseData() { + return this.renderer.houseData; + } + + render() { + this.container.innerHTML = ''; + + if (!this.houseData) { + this.container.innerHTML = '
No house loaded
'; + return; + } + + // Toggle button + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'he-toggle-btn'; + toggleBtn.textContent = this._editing ? 'Close Editor' : 'Edit House'; + toggleBtn.addEventListener('click', () => { + this._editing = !this._editing; + this.render(); + }); + this.container.appendChild(toggleBtn); + + if (!this._editing) return; + + // House metadata section + this._renderMetadataSection(); + + // Building section + this._renderBuildingSection(); + + // Floor management + this._renderFloorSection(); + + // Room list with editing + this._renderRoomSection(); + + // Room editor (if a room is selected) + if (this._selectedRoomId) { + this._renderRoomEditor(); + } + + // Save as template + this._renderSaveSection(); + } + + setSelectedRoom(roomId) { + this._selectedRoomId = roomId; + if (this._editing) { + this.render(); + } + } + + // ---- Metadata Section ---- + + _renderMetadataSection() { + const section = this._createSection('House Info'); + + const nameRow = this._createFieldRow('Name'); + const nameInput = document.createElement('input'); + nameInput.className = 'he-input'; + nameInput.value = this.houseData.name || ''; + nameInput.addEventListener('change', () => { + this.houseData.name = nameInput.value; + this.onHouseChanged('name'); + }); + nameRow.appendChild(nameInput); + section.appendChild(nameRow); + + const descRow = this._createFieldRow('Description'); + const descInput = document.createElement('input'); + descInput.className = 'he-input'; + descInput.value = this.houseData.description || ''; + descInput.addEventListener('change', () => { + this.houseData.description = descInput.value; + this.onHouseChanged('description'); + }); + descRow.appendChild(descInput); + section.appendChild(descRow); + + this.container.appendChild(section); + } + + // ---- Building Section ---- + + _renderBuildingSection() { + const section = this._createSection('Building'); + const building = this.houseData.building || {}; + const footprint = building.footprint || {}; + + const widthRow = this._createFieldRow('Width (m)'); + const widthInput = this._createNumberInput(footprint.width || 12, 4, 30, 0.5, (val) => { + if (!this.houseData.building) this.houseData.building = {}; + if (!this.houseData.building.footprint) this.houseData.building.footprint = {}; + this.houseData.building.footprint.width = val; + this.onHouseChanged('building'); + }); + widthRow.appendChild(widthInput); + section.appendChild(widthRow); + + const depthRow = this._createFieldRow('Depth (m)'); + const depthInput = this._createNumberInput(footprint.depth || 10, 4, 30, 0.5, (val) => { + if (!this.houseData.building) this.houseData.building = {}; + if (!this.houseData.building.footprint) this.houseData.building.footprint = {}; + this.houseData.building.footprint.depth = val; + this.onHouseChanged('building'); + }); + depthRow.appendChild(depthInput); + section.appendChild(depthRow); + + this.container.appendChild(section); + } + + // ---- Floor Section ---- + + _renderFloorSection() { + const section = this._createSection('Floors'); + + const floors = this.houseData.floors || []; + for (let i = 0; i < floors.length; i++) { + const floor = floors[i]; + const row = document.createElement('div'); + row.className = 'he-floor-row'; + + const label = document.createElement('span'); + label.className = 'he-floor-label'; + label.textContent = `${floor.name} (${floor.rooms.length} rooms)`; + row.appendChild(label); + + // Ceiling height + const heightInput = this._createNumberInput(floor.ceilingHeight || 2.5, 2.2, 4.0, 0.1, (val) => { + floor.ceilingHeight = val; + this._rebuildFloor(); + }); + heightInput.title = 'Ceiling height'; + heightInput.style.width = '55px'; + row.appendChild(heightInput); + + // Remove floor button (only if more than 1 floor) + if (floors.length > 1) { + const removeBtn = document.createElement('button'); + removeBtn.className = 'he-icon-btn he-icon-btn-danger'; + removeBtn.textContent = '\u00d7'; + removeBtn.title = 'Remove floor'; + removeBtn.addEventListener('click', () => this._removeFloor(i)); + row.appendChild(removeBtn); + } + + section.appendChild(row); + } + + const addBtn = document.createElement('button'); + addBtn.className = 'he-add-btn'; + addBtn.textContent = '+ Add Floor'; + addBtn.addEventListener('click', () => this._addFloor()); + section.appendChild(addBtn); + + this.container.appendChild(section); + } + + // ---- Room Section ---- + + _renderRoomSection() { + const section = this._createSection('Rooms'); + + const floor = this.houseData.floors[this.renderer.currentFloor]; + if (!floor) return; + + for (const room of floor.rooms) { + const row = document.createElement('div'); + row.className = 'he-room-row' + (this._selectedRoomId === room.id ? ' active' : ''); + + const info = document.createElement('span'); + info.className = 'he-room-info'; + info.textContent = `${room.name} (${room.dimensions.width}\u00d7${room.dimensions.length}m)`; + info.addEventListener('click', () => { + this._selectedRoomId = room.id; + this.renderer.focusRoom(room.id); + this.render(); + }); + row.appendChild(info); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'he-icon-btn he-icon-btn-danger'; + removeBtn.textContent = '\u00d7'; + removeBtn.title = 'Remove room'; + removeBtn.addEventListener('click', () => this._removeRoom(room.id)); + row.appendChild(removeBtn); + + section.appendChild(row); + } + + const addBtn = document.createElement('button'); + addBtn.className = 'he-add-btn'; + addBtn.textContent = '+ Add Room'; + addBtn.addEventListener('click', () => this._showAddRoomForm(section, addBtn)); + section.appendChild(addBtn); + + this.container.appendChild(section); + } + + // ---- Room Editor ---- + + _renderRoomEditor() { + const floor = this.houseData.floors[this.renderer.currentFloor]; + if (!floor) return; + const room = floor.rooms.find(r => r.id === this._selectedRoomId); + if (!room) return; + + const section = this._createSection(`Edit: ${room.name}`); + + // Name + const nameRow = this._createFieldRow('Name'); + const nameInput = document.createElement('input'); + nameInput.className = 'he-input'; + nameInput.value = room.name; + nameInput.addEventListener('change', () => { + room.name = nameInput.value; + this._rebuildFloor(); + }); + nameRow.appendChild(nameInput); + section.appendChild(nameRow); + + // English name + const nameEnRow = this._createFieldRow('Name (EN)'); + const nameEnInput = document.createElement('input'); + nameEnInput.className = 'he-input'; + nameEnInput.value = room.nameEN || ''; + nameEnInput.addEventListener('change', () => { + room.nameEN = nameEnInput.value; + this._rebuildFloor(); + }); + nameEnRow.appendChild(nameEnInput); + section.appendChild(nameEnRow); + + // Type + const typeRow = this._createFieldRow('Type'); + const typeSelect = document.createElement('select'); + typeSelect.className = 'he-input'; + const roomTypes = [ + 'living', 'kitchen', 'dining', 'bedroom', 'bathroom', + 'hallway', 'office', 'utility', 'storage', 'garage' + ]; + for (const t of roomTypes) { + const opt = document.createElement('option'); + opt.value = t; + opt.textContent = t.charAt(0).toUpperCase() + t.slice(1); + if (room.type === t) opt.selected = true; + typeSelect.appendChild(opt); + } + typeSelect.addEventListener('change', () => { + room.type = typeSelect.value; + }); + typeRow.appendChild(typeSelect); + section.appendChild(typeRow); + + // Dimensions + const dimsLabel = document.createElement('div'); + dimsLabel.className = 'he-field-label'; + dimsLabel.textContent = 'Dimensions'; + section.appendChild(dimsLabel); + + const dimsRow = document.createElement('div'); + dimsRow.className = 'he-dims-row'; + + const wLabel = document.createElement('label'); + wLabel.className = 'he-dim-label'; + wLabel.textContent = 'W'; + dimsRow.appendChild(wLabel); + const wInput = this._createNumberInput(room.dimensions.width, 1, 15, 0.25, (val) => { + room.dimensions.width = val; + this._rebuildFloor(); + }); + dimsRow.appendChild(wInput); + + const lLabel = document.createElement('label'); + lLabel.className = 'he-dim-label'; + lLabel.textContent = 'L'; + dimsRow.appendChild(lLabel); + const lInput = this._createNumberInput(room.dimensions.length, 1, 15, 0.25, (val) => { + room.dimensions.length = val; + this._rebuildFloor(); + }); + dimsRow.appendChild(lInput); + + section.appendChild(dimsRow); + + // Position + const posLabel = document.createElement('div'); + posLabel.className = 'he-field-label'; + posLabel.textContent = 'Position'; + section.appendChild(posLabel); + + const posRow = document.createElement('div'); + posRow.className = 'he-dims-row'; + + const xLabel = document.createElement('label'); + xLabel.className = 'he-dim-label'; + xLabel.textContent = 'X'; + posRow.appendChild(xLabel); + const xInput = this._createNumberInput(room.position.x, 0, 30, 0.25, (val) => { + room.position.x = val; + this._rebuildFloor(); + }); + posRow.appendChild(xInput); + + const yLabel = document.createElement('label'); + yLabel.className = 'he-dim-label'; + yLabel.textContent = 'Y'; + posRow.appendChild(yLabel); + const yInput = this._createNumberInput(room.position.y, 0, 30, 0.25, (val) => { + room.position.y = val; + this._rebuildFloor(); + }); + posRow.appendChild(yInput); + + section.appendChild(posRow); + + // Flooring + const flooringRow = this._createFieldRow('Flooring'); + const flooringSelect = document.createElement('select'); + flooringSelect.className = 'he-input'; + for (const f of ['hardwood', 'tile']) { + const opt = document.createElement('option'); + opt.value = f; + opt.textContent = f.charAt(0).toUpperCase() + f.slice(1); + if (room.flooring === f) opt.selected = true; + flooringSelect.appendChild(opt); + } + flooringSelect.addEventListener('change', () => { + room.flooring = flooringSelect.value; + this._rebuildFloor(); + }); + flooringRow.appendChild(flooringSelect); + section.appendChild(flooringRow); + + this.container.appendChild(section); + } + + // ---- Save Section ---- + + _renderSaveSection() { + const section = this._createSection('Template'); + + const saveBtn = document.createElement('button'); + saveBtn.className = 'he-save-btn'; + saveBtn.textContent = 'Save as House Template'; + saveBtn.addEventListener('click', () => this._saveAsTemplate()); + section.appendChild(saveBtn); + + const newBtn = document.createElement('button'); + newBtn.className = 'he-add-btn'; + newBtn.style.marginTop = '6px'; + newBtn.textContent = 'New Empty House'; + newBtn.addEventListener('click', () => this._createNewHouse()); + section.appendChild(newBtn); + + this.container.appendChild(section); + } + + // ---- Actions ---- + + _addFloor() { + const floors = this.houseData.floors; + const level = floors.length; + const id = `floor-${level}`; + floors.push({ + id, + name: `Floor ${level}`, + nameEN: `Floor ${level}`, + level, + ceilingHeight: 2.5, + rooms: [] + }); + this.onHouseChanged('floors'); + this.render(); + } + + _removeFloor(index) { + this.houseData.floors.splice(index, 1); + // Re-index levels + this.houseData.floors.forEach((f, i) => { f.level = i; }); + // If current floor was removed, switch to last available + if (this.renderer.currentFloor >= this.houseData.floors.length) { + this.renderer.showFloor(this.houseData.floors.length - 1); + } else { + this._rebuildFloor(); + } + this.onHouseChanged('floors'); + this.render(); + } + + _showAddRoomForm(section, addBtn) { + // Replace button with form + addBtn.style.display = 'none'; + + const form = document.createElement('div'); + form.className = 'he-add-room-form'; + + const nameInput = document.createElement('input'); + nameInput.className = 'he-input'; + nameInput.placeholder = 'Room name'; + form.appendChild(nameInput); + + const typeSelect = document.createElement('select'); + typeSelect.className = 'he-input'; + const roomTypes = [ + 'living', 'kitchen', 'dining', 'bedroom', 'bathroom', + 'hallway', 'office', 'utility', 'storage', 'garage' + ]; + for (const t of roomTypes) { + const opt = document.createElement('option'); + opt.value = t; + opt.textContent = t.charAt(0).toUpperCase() + t.slice(1); + typeSelect.appendChild(opt); + } + form.appendChild(typeSelect); + + const dimsRow = document.createElement('div'); + dimsRow.className = 'he-dims-row'; + const wInput = document.createElement('input'); + wInput.className = 'he-input'; + wInput.type = 'number'; + wInput.value = '4'; + wInput.min = '1'; + wInput.max = '15'; + wInput.step = '0.25'; + wInput.placeholder = 'Width'; + wInput.style.flex = '1'; + dimsRow.appendChild(wInput); + + const xSpan = document.createElement('span'); + xSpan.textContent = '\u00d7'; + xSpan.style.padding = '0 4px'; + xSpan.style.color = '#999'; + dimsRow.appendChild(xSpan); + + const lInput = document.createElement('input'); + lInput.className = 'he-input'; + lInput.type = 'number'; + lInput.value = '3'; + lInput.min = '1'; + lInput.max = '15'; + lInput.step = '0.25'; + lInput.placeholder = 'Length'; + lInput.style.flex = '1'; + dimsRow.appendChild(lInput); + form.appendChild(dimsRow); + + const actions = document.createElement('div'); + actions.className = 'he-form-actions'; + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'he-cancel-btn'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.addEventListener('click', () => { + form.remove(); + addBtn.style.display = ''; + }); + actions.appendChild(cancelBtn); + + const submitBtn = document.createElement('button'); + submitBtn.className = 'he-submit-btn'; + submitBtn.textContent = 'Add'; + submitBtn.addEventListener('click', () => { + const name = nameInput.value.trim(); + if (!name) { nameInput.focus(); return; } + this._addRoom({ + name, + type: typeSelect.value, + width: parseFloat(wInput.value) || 4, + length: parseFloat(lInput.value) || 3 + }); + }); + actions.appendChild(submitBtn); + form.appendChild(actions); + + section.appendChild(form); + nameInput.focus(); + } + + _addRoom({ name, type, width, length }) { + const floor = this.houseData.floors[this.renderer.currentFloor]; + if (!floor) return; + + // Auto-position: find rightmost edge of existing rooms + let maxX = 0; + for (const r of floor.rooms) { + const edge = r.position.x + r.dimensions.width; + if (edge > maxX) maxX = edge; + } + + const id = `${floor.id}-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now().toString(36).slice(-4)}`; + const room = { + id, + name, + nameEN: name, + type, + position: { x: maxX + 0.24, y: 0 }, + dimensions: { width, length }, + flooring: (type === 'bathroom' || type === 'kitchen' || type === 'utility') ? 'tile' : 'hardwood', + walls: { + south: { type: 'exterior' }, + north: { type: 'exterior' }, + west: { type: 'interior' }, + east: { type: 'exterior' } + } + }; + + floor.rooms.push(room); + this._rebuildFloor(); + this._selectedRoomId = room.id; + this.onHouseChanged('rooms'); + this.render(); + } + + _removeRoom(roomId) { + const floor = this.houseData.floors[this.renderer.currentFloor]; + if (!floor) return; + + const idx = floor.rooms.findIndex(r => r.id === roomId); + if (idx === -1) return; + + floor.rooms.splice(idx, 1); + + if (this._selectedRoomId === roomId) { + this._selectedRoomId = null; + } + + this._rebuildFloor(); + this.onHouseChanged('rooms'); + this.render(); + } + + _saveAsTemplate() { + const data = structuredClone(this.houseData); + data.savedAt = new Date().toISOString(); + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${(data.name || 'house').replace(/\s+/g, '-').toLowerCase()}.json`; + a.click(); + URL.revokeObjectURL(url); + } + + _createNewHouse() { + const newHouse = { + name: 'New House', + description: '', + units: 'meters', + building: { + footprint: { width: 10, depth: 8 }, + wallThickness: 0.24, + roofType: 'gable' + }, + floors: [ + { + id: 'floor-0', + name: 'Ground Floor', + nameEN: 'Ground Floor', + level: 0, + ceilingHeight: 2.6, + rooms: [ + { + id: 'floor-0-hallway', + name: 'Hallway', + nameEN: 'Hallway', + type: 'hallway', + position: { x: 3.5, y: 0 }, + dimensions: { width: 2.0, length: 8.0 }, + flooring: 'tile', + walls: { + south: { type: 'exterior', doors: [{ id: 'entry', type: 'entry', position: 0.3, width: 1.1, height: 2.2, connectsTo: 'exterior' }] }, + north: { type: 'exterior' }, + west: { type: 'interior' }, + east: { type: 'interior' } + } + }, + { + id: 'floor-0-living', + name: 'Living Room', + nameEN: 'Living Room', + type: 'living', + position: { x: 0, y: 3.0 }, + dimensions: { width: 3.5, length: 5.0 }, + flooring: 'hardwood', + walls: { + south: { type: 'interior' }, + north: { type: 'exterior', windows: [{ id: 'lr-w1', type: 'casement', position: 0.5, width: 1.4, height: 1.4, sillHeight: 0.6 }] }, + west: { type: 'exterior', windows: [{ id: 'lr-w2', type: 'casement', position: 1.5, width: 1.2, height: 1.4, sillHeight: 0.6 }] }, + east: { type: 'interior' } + } + }, + { + id: 'floor-0-kitchen', + name: 'Kitchen', + nameEN: 'Kitchen', + type: 'kitchen', + position: { x: 0, y: 0 }, + dimensions: { width: 3.5, length: 3.0 }, + flooring: 'tile', + walls: { + south: { type: 'exterior', windows: [{ id: 'k-w1', type: 'casement', position: 1.0, width: 1.2, height: 1.2, sillHeight: 0.9 }] }, + north: { type: 'interior' }, + west: { type: 'exterior' }, + east: { type: 'interior' } + } + } + ] + } + ] + }; + + this.renderer.houseData = newHouse; + this.renderer.showFloor(0); + this._selectedRoomId = null; + this.onHouseChanged('new'); + this.render(); + } + + _rebuildFloor() { + this.renderer.showFloor(this.renderer.currentFloor); + this.onHouseChanged('rebuild'); + } + + // ---- UI Helpers ---- + + _createSection(title) { + const section = document.createElement('div'); + section.className = 'he-section'; + const h = document.createElement('div'); + h.className = 'he-section-title'; + h.textContent = title; + section.appendChild(h); + return section; + } + + _createFieldRow(label) { + const row = document.createElement('div'); + row.className = 'he-field-row'; + const lbl = document.createElement('label'); + lbl.className = 'he-field-label'; + lbl.textContent = label; + row.appendChild(lbl); + return row; + } + + _createNumberInput(value, min, max, step, onChange) { + const input = document.createElement('input'); + input.className = 'he-input he-num-input'; + input.type = 'number'; + input.value = value; + input.min = min; + input.max = max; + input.step = step; + input.addEventListener('change', () => { + const val = parseFloat(input.value); + if (!isNaN(val) && val >= min && val <= max) { + onChange(val); + } + }); + return input; + } +} diff --git a/src/index.html b/src/index.html index 5642509..bf15ba8 100644 --- a/src/index.html +++ b/src/index.html @@ -14,7 +14,7 @@ position: fixed; top: 0; right: 0; - width: 280px; + width: 300px; height: 100vh; background: rgba(255, 255, 255, 0.95); border-left: 1px solid #ddd; @@ -404,6 +404,169 @@ } .export-btn:hover { background: #f0f0f0; } .export-btn:active { background: #e0e0e0; } + + /* House Editor */ + #house-editor { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid #ddd; + } + .he-toggle-btn { + display: block; + width: 100%; + padding: 7px; + border: 1px solid #4a90d9; + border-radius: 4px; + background: #fff; + color: #4a90d9; + cursor: pointer; + font-size: 12px; + font-weight: 500; + } + .he-toggle-btn:hover { background: #e8f0fe; } + .he-section { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid #eee; + } + .he-section-title { + font-size: 11px; + font-weight: 600; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; + } + .he-field-row { + margin-bottom: 5px; + } + .he-field-label { + display: block; + font-size: 11px; + color: #888; + margin-bottom: 2px; + } + .he-input { + width: 100%; + padding: 4px 7px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 12px; + outline: none; + } + .he-input:focus { border-color: #4a90d9; } + .he-num-input { width: 65px; } + .he-dims-row { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 6px; + } + .he-dim-label { + font-size: 11px; + color: #888; + min-width: 12px; + } + .he-dims-row .he-input { flex: 1; min-width: 0; } + .he-floor-row { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 0; + } + .he-floor-label { + flex: 1; + font-size: 12px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .he-room-row { + display: flex; + align-items: center; + padding: 4px 6px; + margin: 1px 0; + border-radius: 3px; + cursor: pointer; + } + .he-room-row:hover { background: #f0f4fa; } + .he-room-row.active { background: #e0eaf5; } + .he-room-info { + flex: 1; + font-size: 12px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .he-icon-btn { + width: 20px; + height: 20px; + border: none; + border-radius: 3px; + background: transparent; + cursor: pointer; + font-size: 14px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + .he-icon-btn-danger { color: #c44; } + .he-icon-btn-danger:hover { background: #fdd; } + .he-add-btn { + display: block; + width: 100%; + padding: 5px; + margin-top: 4px; + border: 1px dashed #aaa; + border-radius: 3px; + background: transparent; + color: #666; + cursor: pointer; + font-size: 11px; + } + .he-add-btn:hover { background: #f5f5f5; border-color: #4a90d9; color: #4a90d9; } + .he-add-room-form { + margin-top: 6px; + padding: 8px; + background: #f9f9f9; + border: 1px solid #ddd; + border-radius: 4px; + } + .he-add-room-form .he-input { margin-bottom: 5px; } + .he-form-actions { + display: flex; + gap: 6px; + margin-top: 6px; + } + .he-cancel-btn, .he-submit-btn { + flex: 1; + padding: 5px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 11px; + cursor: pointer; + } + .he-cancel-btn { background: #fff; } + .he-cancel-btn:hover { background: #f0f0f0; } + .he-submit-btn { background: #4a90d9; color: #fff; border-color: #4a90d9; } + .he-submit-btn:hover { background: #3a7bc8; } + .he-save-btn { + display: block; + width: 100%; + padding: 7px; + border: 1px solid #4a90d9; + border-radius: 4px; + background: #4a90d9; + color: #fff; + cursor: pointer; + font-size: 12px; + font-weight: 500; + } + .he-save-btn:hover { background: #3a7bc8; } @@ -425,6 +588,7 @@ +