Add house customization UI with create/edit houses and room management
Adds HouseEditor panel to the right sidebar with: - Edit house name and description - Adjust building footprint dimensions - Add/remove floors with ceiling height control - Add/remove rooms with name, type, and dimensions - Edit selected room: name, type, dimensions, position, flooring - Save house as JSON template - Create new empty house from scratch
This commit is contained in:
681
src/house-editor.js
Normal file
681
src/house-editor.js
Normal file
@@ -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 = '<p style="color:#999;font-size:12px;">No house loaded</p>';
|
||||
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;
|
||||
}
|
||||
}
|
||||
178
src/index.html
178
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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -425,6 +588,7 @@
|
||||
<button class="export-btn" id="btn-load">Load JSON</button>
|
||||
<button class="export-btn" id="btn-screenshot">Screenshot</button>
|
||||
</div>
|
||||
<div id="house-editor"></div>
|
||||
</div>
|
||||
|
||||
<div id="info">Click a room to select it. Click furniture to edit. Scroll to zoom, drag to orbit.</div>
|
||||
@@ -444,6 +608,7 @@
|
||||
import { ThemeManager } from './themes.js';
|
||||
import { ExportManager } from './export.js';
|
||||
import { CatalogPanel } from './catalog.js';
|
||||
import { HouseEditor } from './house-editor.js';
|
||||
|
||||
const viewer = document.getElementById('viewer');
|
||||
const houseRenderer = new HouseRenderer(viewer);
|
||||
@@ -454,6 +619,7 @@
|
||||
let themeManager = null;
|
||||
let exportManager = null;
|
||||
let catalogPanel = null;
|
||||
let houseEditor = null;
|
||||
|
||||
houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => {
|
||||
document.getElementById('house-name').textContent = house.name;
|
||||
@@ -485,6 +651,14 @@
|
||||
state: designState,
|
||||
interaction
|
||||
});
|
||||
houseEditor = new HouseEditor(document.getElementById('house-editor'), {
|
||||
renderer: houseRenderer,
|
||||
onHouseChanged: (what) => {
|
||||
document.getElementById('house-name').textContent = houseRenderer.houseData.name;
|
||||
buildFloorButtons();
|
||||
buildRoomList();
|
||||
}
|
||||
});
|
||||
buildFloorButtons();
|
||||
buildRoomList();
|
||||
buildThemeButtons();
|
||||
@@ -508,6 +682,7 @@
|
||||
buildRoomList();
|
||||
selectedRoom = null;
|
||||
if (catalogPanel) catalogPanel.setSelectedRoom(null);
|
||||
if (houseEditor) houseEditor.setSelectedRoom(null);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
@@ -533,6 +708,7 @@
|
||||
el.classList.toggle('active', el.dataset.roomId === roomId);
|
||||
});
|
||||
if (catalogPanel) catalogPanel.setSelectedRoom(roomId);
|
||||
if (houseEditor) houseEditor.setSelectedRoom(roomId);
|
||||
const room = houseRenderer.getRooms().find(r => r.id === roomId);
|
||||
if (room) {
|
||||
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area} m²`;
|
||||
|
||||
Reference in New Issue
Block a user