2947 lines
172 KiB
JavaScript
2947 lines
172 KiB
JavaScript
/*!
|
||
* Tectum
|
||
* Copyright © 2026 Machiel Heinen.
|
||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||
* Zie LICENTIE.md.
|
||
*/
|
||
|
||
const HOUSE_TEMPLATE = `
|
||
<div id="house-app">
|
||
<div class="house-body">
|
||
<aside id="house-nav">
|
||
<div class="hn-title"><i class="bi bi-diagram-3"></i> Structuur</div>
|
||
<div id="house-nav-tree"></div>
|
||
<input type="file" id="h-file-import-json" accept="application/json" style="display:none;">
|
||
</aside>
|
||
<div id="house-part-host"></div>
|
||
</div>
|
||
<footer class="statusbar">
|
||
<span id="ho-hint" class="sb-hint"></span>
|
||
<span id="h-io-status" class="io-status"></span>
|
||
</footer>
|
||
</div>
|
||
|
||
<div id="house-project-overlay">
|
||
<div id="house-project-modal">
|
||
<div class="modal-header">
|
||
<h2>Project eigenschappen (woning)</h2>
|
||
<button type="button" id="house-project-close-x" class="modal-close-x">×</button>
|
||
</div>
|
||
<div class="controls-grid">
|
||
<div>
|
||
<div class="control-group"><label for="h-proj-naam">Projectnaam</label><div><input type="text" id="h-proj-naam"></div></div>
|
||
<div class="control-group"><label for="h-proj-plaats">Plaats</label><div><input type="text" id="h-proj-plaats"></div></div>
|
||
<div class="control-group"><label for="h-proj-straat">Straat</label><div><input type="text" id="h-proj-straat"></div></div>
|
||
</div>
|
||
<div>
|
||
<div class="control-group"><label for="h-proj-nummer">Projectnummer</label><div><input type="text" id="h-proj-nummer"></div></div>
|
||
<div class="control-group"><label for="h-proj-tekenaar">Tekenaar</label><div><input type="text" id="h-proj-tekenaar"></div></div>
|
||
<div class="control-group"><label for="h-proj-opmerking">Opmerking</label><div><input type="text" id="h-proj-opmerking"></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="material-info">Geldt voor elk blad van de woning-PDF. Titel en elementnummer worden per blad automatisch ingevuld.</div>
|
||
<div class="modal-footer">
|
||
<button type="button" id="house-project-cancel" class="ui-btn" data-esc>Annuleren</button>
|
||
<button type="button" id="house-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const HOUSE_OVERVIEW_TEMPLATE = `
|
||
<div id="house-overview">
|
||
<div class="ed-main">
|
||
<div class="ed-toolbar" id="ho-toolbar"></div>
|
||
<div class="ho-views">
|
||
<div id="ho-report" class="ho-report"></div>
|
||
<div id="ho-pane-plan" class="ho-pane">
|
||
<div class="pane-label">PLATTEGROND</div>
|
||
<div id="ho-warn" class="ho-warn"></div>
|
||
<canvas id="ho-canvas"></canvas>
|
||
<div class="plan-zoom">
|
||
<button type="button" data-zoom="in" title="Inzoomen (scrollwiel)"><i class="bi bi-plus-lg"></i></button>
|
||
<button type="button" data-zoom="out" title="Uitzoomen (scrollwiel)"><i class="bi bi-dash-lg"></i></button>
|
||
<button type="button" data-zoom="fit" title="Passend (F); slepen op een lege plek of met de middelste muisknop verschuift"><i class="bi bi-aspect-ratio"></i></button>
|
||
<div class="plan-zoom-val" id="ho-zoom-val">100%</div>
|
||
</div>
|
||
</div>
|
||
<div id="ho-pane-3d" class="ho-pane">
|
||
<div class="pane-label">3D</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<aside id="ho-inspector" class="ed-panel inspector" aria-label="Eigenschappen"></aside>
|
||
</div>
|
||
`;
|
||
|
||
const SPACING_LOCK_MSG = 'De h.o.h. is globaal ingesteld voor alle wanden van de woning (eigenschappen Woning › Constructie, niets geselecteerd).';
|
||
const HOUSE_PROJECT_KEYS = ['naam', 'plaats', 'straat', 'nummer', 'tekenaar', 'opmerking'];
|
||
const HOUSE_SIDES = ['voor', 'achter', 'links', 'rechts'];
|
||
|
||
function houseFmt(n, decimals = 1) {
|
||
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||
}
|
||
|
||
function mountHouseEditor(container, callbacks) {
|
||
const onSaved = callbacks && callbacks.onSaved;
|
||
container.innerHTML = HOUSE_TEMPLATE;
|
||
const host = document.getElementById('house-part-host');
|
||
|
||
let state = HouseModel.createDefaultState((callbacks && callbacks.template) || 'single');
|
||
let activePart = null;
|
||
let activeEditor = null;
|
||
let activeWallLayout = null;
|
||
let selection = null;
|
||
let pendingTool = null;
|
||
const view3D = { layers: ViewLayers.all(), section: false, sectionMM: 1000, mode: 'split', report: false };
|
||
let fillType = 'woonkamer';
|
||
let fixtureType = 'toilet';
|
||
const camKey = (part) => `${(callbacks && callbacks.cameraKey) || 'woning'}:${part}`;
|
||
|
||
function setIoStatus(msg, cls) {
|
||
const el = document.getElementById('h-io-status');
|
||
el.textContent = msg;
|
||
el.className = 'io-status ' + (cls || '');
|
||
clearTimeout(setIoStatus._t);
|
||
setIoStatus._t = setTimeout(() => { el.textContent = ''; }, 4000);
|
||
}
|
||
|
||
const unitById = (id) => state.units.find(u => u.id === id);
|
||
const iwById = (id) => state.interiorWalls.find(w => w.id === id);
|
||
|
||
function flushActivePart() {
|
||
if (!activeEditor || typeof activeEditor.getStateJson !== 'function' || !activePart) return;
|
||
const [kind, a, b] = activePart.split(':');
|
||
if (kind === 'floor' && unitById(a)) unitById(a).unit.floor = activeEditor.getStateJson();
|
||
else if (kind === 'wall' && unitById(a)) {
|
||
const unit = unitById(a).unit, before = unit.walls[b].wall;
|
||
const next = activeEditor.getStateJson();
|
||
if (activeWallLayout) UnitModel.topParamsFromLayout(activeWallLayout, next.wall);
|
||
unit.walls[b] = next;
|
||
const h = (w) => [w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : null, JSON.stringify(w.topPts || null)].join('|');
|
||
if (h(before) !== h(next.wall)) UnitModel.syncWallHeights(unit, b);
|
||
}
|
||
else if (kind === 'roof' && unitById(a)) unitById(a).roof.deck = activeEditor.getStateJson();
|
||
else if (kind === 'iw' && iwById(Number(a))) {
|
||
const iw = iwById(Number(a));
|
||
iw.wall = activeEditor.getStateJson();
|
||
if (iw.seam) iw.wall.materials = Object.assign({}, iw.wall.materials, { exterior: 'geen' });
|
||
}
|
||
}
|
||
|
||
function showPart(part) {
|
||
flushActivePart();
|
||
if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount();
|
||
activeEditor = null;
|
||
activeWallLayout = null;
|
||
host.innerHTML = '';
|
||
activePart = part;
|
||
|
||
const [kind, a, b] = part.split(':');
|
||
if (kind === 'floor' && unitById(a)) {
|
||
activeEditor = FloorEditor.mount(host, { cameraKey: camKey(part) });
|
||
activeEditor.loadStateJson(HouseModel.clone(unitById(a).unit.floor));
|
||
} else if (kind === 'wall' && unitById(a)) {
|
||
const parts = HouseModel.computeParts(state, { skip3D: true });
|
||
const wl = parts.units.find(u => u.id === a).walls.find(w => w.side === b);
|
||
activeEditor = WallEditor.mount(host, {
|
||
cameraKey: camKey(part),
|
||
lockWidth: 'Binnen een woning volgt de wandbreedte uit de vloer van de unit.',
|
||
lockSpacing: SPACING_LOCK_MSG,
|
||
lockCladding: 'De richting van de gevelbekleding is (voorlopig) globaal: eigenschappen Woning › Gevel, niets geselecteerd.',
|
||
lockExterior: 'De gevelbekleding volgt de woning (eigenschappen Woning › Gevel); per wand aan te passen via de wand in de plattegrond.',
|
||
seamCuts: wl.cutsU,
|
||
lamps: [].concat(...wl.pieces.map(p => p.lamps.map(l => ({ u: p.u0 + l.u, y: l.y }))))
|
||
});
|
||
const ws = HouseModel.clone(unitById(a).unit.walls[b]);
|
||
Object.assign(ws.wall, UnitModel.topParamsOn(wl, ws.wall), { width: wl.width });
|
||
if (!ws.wall.topPts) delete ws.wall.topPts;
|
||
activeWallLayout = wl;
|
||
activeEditor.loadStateJson(ws);
|
||
} else if (kind === 'roof' && unitById(a)) {
|
||
HouseModel.applyGlobalSettings(state);
|
||
const u = unitById(a);
|
||
activeEditor = FloorEditor.mount(host, {
|
||
mode: 'roof',
|
||
cameraKey: camKey(part),
|
||
lockSize: u.roof.followFloor ? 'Dit dak volgt de vloermaat. Zet "Dak volgt vloer" uit bij de unit (woning, unit selecteren) om de maat aan te passen.' : null
|
||
});
|
||
activeEditor.loadStateJson(HouseModel.clone(u.roof.deck));
|
||
} else if (kind === 'iw' && iwById(Number(a))) {
|
||
HouseModel.applyGlobalSettings(state);
|
||
if (iwById(Number(a)).seam) HouseModel.computeParts(state, { skip3D: true });
|
||
else HouseModel.interiorLayout(iwById(Number(a)));
|
||
activeEditor = WallEditor.mount(host, { cameraKey: camKey(part), lockWidth: 'De lengte van een binnenwand volgt uit de tekening in de plattegrond van de woning.', lockSpacing: SPACING_LOCK_MSG });
|
||
activeEditor.loadStateJson(HouseModel.clone(iwById(Number(a)).wall));
|
||
} else {
|
||
activePart = 'overzicht';
|
||
activeEditor = mountOverview();
|
||
return;
|
||
}
|
||
setPartToolbar(kind, a, b);
|
||
renderNav();
|
||
}
|
||
|
||
// Onderdeel-editor binnen de woning: terug-knop en naam vooraan in de werkbalk van die editor.
|
||
function setPartToolbar(kind, a, b) {
|
||
const what = kind === 'floor' ? `Vloer unit ${a}` : kind === 'roof' ? `Dak unit ${a}` : kind === 'wall'
|
||
? `${(UnitModel.WALL_SIDES.find(sd => sd.key === b) || {}).label || 'Wand'} unit ${a}`
|
||
: (iwById(Number(a)) && iwById(Number(a)).seam ? 'Naadwand' : 'Binnenwand');
|
||
const bar = host.querySelector('.ed-toolbar');
|
||
if (bar) {
|
||
bar.insertAdjacentHTML('afterbegin', `<div class="tb-group">${UI.tb('Woning', 'arrow-left', 'data-hp="back" title="Terug naar de woning (Esc)"')}<span class="tb-title">${what}</span></div>${UI.tbSep()}`);
|
||
bar.querySelector('[data-hp="back"]').addEventListener('click', () => showPart('overzicht'));
|
||
}
|
||
setHint('Wijzigingen gelden direct voor de woning. Hoogtes van een wand bepalen de dakvorm van de unit (de overstaande wand spiegelt mee).');
|
||
}
|
||
function setHint(text) { document.getElementById('ho-hint').textContent = text; }
|
||
|
||
// Structuur: groepen in-/uitklapbaar (onthouden); een groep met het actieve onderdeel of de selectie klapt vanzelf open.
|
||
const NAV_PREF = 'tectum.nav.closed';
|
||
let navActiveKey = null;
|
||
let navClosed;
|
||
try { navClosed = new Set(JSON.parse(localStorage.getItem(NAV_PREF) || 'null') || ['iw', 'rooms', 'fixtures']); } catch (e) { navClosed = new Set(['iw', 'rooms', 'fixtures']); }
|
||
function renderNav(parts) {
|
||
parts = parts || HouseModel.computeParts(state, { skip3D: true });
|
||
const inOverview = activePart === 'overzicht';
|
||
const item = (part, label, meta, cls, iconName, active) =>
|
||
`<div class="hn-item ${cls || ''} ${(active !== undefined ? active : activePart === part) ? 'active' : ''}" data-part="${part}">
|
||
<span class="hn-label">${iconName ? UI.icon(iconName) : ''}${label}</span>${meta ? `<span class="hn-meta">${meta}</span>` : ''}</div>`;
|
||
const chevron = (key) => `<button type="button" class="hn-toggle" data-nav-toggle="${key}" title="In-/uitklappen" aria-label="In-/uitklappen">${UI.icon('chevron-right')}</button>`;
|
||
const group = (key, headHtml, body, hasActive) =>
|
||
`<div class="hn-group${hasActive || !navClosed.has(key) ? ' open' : ''}" data-nav-group="${key}">${headHtml}<div class="hn-children">${body}</div></div>`;
|
||
const head = (key, iconName, label, meta) =>
|
||
`<div class="hn-item hn-unit hn-head" data-nav-toggle="${key}">${chevron(key)}<span class="hn-label">${UI.icon(iconName)}${label}</span>${meta ? `<span class="hn-meta">${meta}</span>` : ''}</div>`;
|
||
const [pk, pa] = (activePart || '').split(':');
|
||
let html = item('overzicht', 'Woning', `${parts.units.length} unit${parts.units.length === 1 ? '' : 's'}`, 'hn-top', 'house-door', inOverview && !selection);
|
||
parts.units.forEach(u => {
|
||
const sel = selection && selection.type === 'unit' && selection.id === u.id && inOverview;
|
||
let body = item(`floor:${u.id}`, 'Vloer', u.floorElement, 'hn-sub', 'grid-3x3') + item(`roof:${u.id}`, 'Dak', u.roof.element, 'hn-sub', 'house');
|
||
u.walls.forEach(w => {
|
||
const meta = w.removed ? 'vervalt' : w.pieces.map(p => p.element).join(', ');
|
||
body += item(`wall:${u.id}:${w.side}`, w.label, meta, 'hn-sub' + (w.removed ? ' hn-removed' : ''), 'bricks');
|
||
});
|
||
const wallSel = inOverview && selection && selection.type === 'wall' && selection.unit === u.id;
|
||
html += group('unit:' + u.id,
|
||
`<div class="hn-item hn-unit ${sel ? 'active' : ''}" data-select-unit="${u.id}">${chevron('unit:' + u.id)}<span class="hn-label">${UI.icon('box')}Unit ${u.id}</span><span class="hn-meta">${u.rect.W} × ${u.rect.D}</span></div>`,
|
||
body, (['floor', 'roof', 'wall'].includes(pk) && pa === u.id) || wallSel);
|
||
});
|
||
if (parts.interior.length) {
|
||
let body = '';
|
||
const seen = new Set();
|
||
parts.interior.forEach(p => {
|
||
if (seen.has(p.id)) return;
|
||
seen.add(p.id);
|
||
if (p.seam) {
|
||
const els = parts.interior.filter(x => x.id === p.id).map(x => x.element).join(', ');
|
||
body += item(`iw:${p.id}`, `Naadwand ${p.seam.a}|${p.seam.b}`, els, 'hn-sub', 'layout-split');
|
||
} else body += item(`iw:${p.id}`, p.element, `${p.place.width} mm`, 'hn-sub', 'distribute-horizontal');
|
||
});
|
||
html += group('iw', head('iw', 'distribute-horizontal', 'Binnenwanden', seen.size), body,
|
||
pk === 'iw' || (inOverview && selection && selection.type === 'iw'));
|
||
}
|
||
const R = parts.rooms;
|
||
if (R && (R.rooms.length || R.free.length)) {
|
||
const total = R.rooms.concat(R.free).reduce((a, r) => a + r.areaM2, 0);
|
||
let body = '';
|
||
R.rooms.forEach(r => {
|
||
const act = selection && selection.type === 'room' && selection.id === r.id && inOverview;
|
||
body += `<div class="hn-item hn-sub ${act ? 'active' : ''}" data-select-room="${r.id}"><span class="hn-label">${UI.icon('square-half')}${UI.esc(r.name)}</span><span class="hn-meta">${houseFmt(r.areaM2, 2)} m²</span></div>`;
|
||
});
|
||
if (R.free.length) body += `<div class="hn-item hn-sub hn-static hn-removed"><span class="hn-label">${R.free.length} zonder functie</span><span class="hn-meta">${houseFmt(R.free.reduce((a, r) => a + r.areaM2, 0), 1)} m²</span></div>`;
|
||
html += group('rooms', head('rooms', 'bounding-box', 'Ruimtes', `${houseFmt(total, 1)} m²`), body,
|
||
inOverview && selection && selection.type === 'room');
|
||
}
|
||
if (parts.fixtures && parts.fixtures.length) {
|
||
let body = '';
|
||
parts.fixtures.forEach(f => {
|
||
const act = selection && selection.type === 'fixture' && selection.id === f.id && inOverview;
|
||
body += `<div class="hn-item hn-sub ${act ? 'active' : ''}" data-select-fixture="${f.id}"><span class="hn-label">${UI.icon('dot')}${UI.esc(f.label)}</span><span class="hn-meta">${f.unitId}</span></div>`;
|
||
});
|
||
html += group('fixtures', head('fixtures', 'lamp', 'Inrichting', parts.fixtures.length), body,
|
||
inOverview && selection && selection.type === 'fixture');
|
||
}
|
||
const tree = document.getElementById('house-nav-tree');
|
||
tree.innerHTML = html;
|
||
// Alleen in beeld schuiven als de selectie of het onderdeel verandert, niet bij elke herberekening.
|
||
const key = activePart + '|' + JSON.stringify(selection);
|
||
const act = tree.querySelector('.hn-item.active');
|
||
if (act && key !== navActiveKey) act.scrollIntoView({ block: 'nearest' });
|
||
navActiveKey = key;
|
||
}
|
||
|
||
document.getElementById('house-nav-tree').addEventListener('click', (e) => {
|
||
const tg = e.target.closest('[data-nav-toggle]');
|
||
if (tg && (tg.classList.contains('hn-toggle') || tg.classList.contains('hn-head'))) {
|
||
const key = tg.dataset.navToggle, g = tg.closest('.hn-group');
|
||
const open = !g.classList.contains('open');
|
||
g.classList.toggle('open', open);
|
||
if (open) navClosed.delete(key); else navClosed.add(key);
|
||
try { localStorage.setItem(NAV_PREF, JSON.stringify([...navClosed])); } catch (err) { }
|
||
return;
|
||
}
|
||
const roomRow = e.target.closest('[data-select-room]');
|
||
const unitRow = e.target.closest('[data-select-unit]');
|
||
const fxRow = e.target.closest('[data-select-fixture]');
|
||
if (roomRow || unitRow || fxRow) {
|
||
selection = fxRow ? { type: 'fixture', id: Number(fxRow.dataset.selectFixture) }
|
||
: roomRow ? { type: 'room', id: Number(roomRow.dataset.selectRoom) } : { type: 'unit', id: unitRow.dataset.selectUnit };
|
||
if (activePart !== 'overzicht') showPart('overzicht');
|
||
else activeEditor.select(selection);
|
||
return;
|
||
}
|
||
const row = e.target.closest('[data-part]');
|
||
if (!row) return;
|
||
if (row.dataset.part === 'overzicht') {
|
||
selection = null;
|
||
if (activePart !== 'overzicht') showPart('overzicht');
|
||
else activeEditor.select(null);
|
||
} else if (row.dataset.part !== activePart) showPart(row.dataset.part);
|
||
});
|
||
|
||
function addUnit() {
|
||
flushActivePart();
|
||
const parent = (selection && selection.type === 'unit' && unitById(selection.id)) || state.units[state.units.length - 1];
|
||
const taken = new Set(state.units.filter(u => u.attach && u.attach.to === parent.id).map(u => u.attach.side));
|
||
if (parent.attach) taken.add({ voor: 'achter', achter: 'voor', links: 'rechts', rechts: 'links' }[parent.attach.side]);
|
||
const side = ['rechts', 'achter', 'links', 'voor'].find(s => !taken.has(s)) || 'rechts';
|
||
const id = HouseModel.nextUnitId(state);
|
||
state.units.push(HouseModel.createUnitEntry(id, { to: parent.id, side, align: 'start', offset: 0 }, { width: 3000, depth: 3000, windowOn: [] }));
|
||
HouseModel.ensureSeamWalls(state, id);
|
||
selection = { type: 'unit', id };
|
||
showPart('overzicht');
|
||
}
|
||
|
||
function removeUnit(id) {
|
||
if (state.units[0].id === id) return;
|
||
if (state.units.some(u => u.attach && u.attach.to === id)) {
|
||
setIoStatus(`Unit ${id} kan niet weg: er zijn units aan gekoppeld.`, 'err');
|
||
return;
|
||
}
|
||
state.units = state.units.filter(u => u.id !== id);
|
||
selection = null;
|
||
showPart('overzicht');
|
||
}
|
||
|
||
function drawInteriorWall() {
|
||
pendingTool = 'iw';
|
||
if (activePart !== 'overzicht') showPart('overzicht');
|
||
else activeEditor.setTool('iw');
|
||
}
|
||
|
||
function mountOverview() {
|
||
host.innerHTML = HOUSE_OVERVIEW_TEMPLATE;
|
||
UI.setView(document.getElementById('house-overview'), view3D.mode);
|
||
const canvas = document.getElementById('ho-canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
const pane3D = document.getElementById('ho-pane-3d');
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x1a1a1a);
|
||
const camera = new THREE.PerspectiveCamera(45, pane3D.clientWidth / pane3D.clientHeight, 0.1, 400);
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
|
||
renderer.setPixelRatio(window.devicePixelRatio);
|
||
renderer.shadowMap.enabled = true;
|
||
pane3D.appendChild(renderer.domElement);
|
||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||
const overviewCamKey = camKey('overzicht');
|
||
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
||
scene.add(ambient);
|
||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||
dirLight.position.set(5, 8, 5);
|
||
scene.add(dirLight);
|
||
const grid = new THREE.GridHelper(40, 80, 0x444444, 0x222222);
|
||
scene.add(grid);
|
||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(80, 80), new THREE.MeshStandardMaterial({ color: 0x3a3d40, roughness: 1 }));
|
||
ground.rotation.x = -Math.PI / 2;
|
||
ground.position.y = -0.002;
|
||
ground.visible = false;
|
||
scene.add(ground);
|
||
const wallMaterials = WallModel.createMaterials();
|
||
const SPAANPLAAT_COLOR = wallMaterials.spaanplaatMaterial.color.getHex();
|
||
const floorMaterials = FloorModel.createMaterials();
|
||
const roofMaterials = RoofModel.createMaterials();
|
||
const lightingMaterials = LightingModel.createMaterials();
|
||
const sandwichMaterials = SandwichModel.createMaterials();
|
||
const postMaterials = PostModel.createMaterials();
|
||
const chimneyMaterials = ChimneyModel.createMaterials();
|
||
const elektraMaterials = ElektraModel.createMaterials();
|
||
let houseGroup = null;
|
||
let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls);
|
||
let planCaps = null;
|
||
const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
||
renderer.localClippingEnabled = true;
|
||
const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||
// Snede wit afgedekt; latwerk is open, pas vanaf de gevel afdekken (HouseModel.sectionCaps).
|
||
const buildPlanCaps = (cutAbs) => HouseModel.sectionCaps(parts, cutAbs, capMaterial, view3D.layers);
|
||
let fullRender = false;
|
||
let skyMode = Skybox.getMode();
|
||
const skyEnv = Skybox.createEnvironment(scene);
|
||
|
||
// Werkbalk: gereedschap links (met opties van het actieve gereedschap), weergave rechts.
|
||
const toolbar = document.getElementById('ho-toolbar');
|
||
const TOOLS = [
|
||
['select', 'Selecteren', 'cursor', 'V', 'Klik = selecteren, dubbelklik = bewerken'],
|
||
['iw', 'Binnenwand', 'pencil', 'W', 'Twee klikken in de plattegrond; snapt op 50 mm, haaks en op wandvlakken'],
|
||
['room', 'Ruimte', 'paint-bucket', 'R', 'Functie kiezen en in een ruimte klikken; de ruimte loopt tot de wanden'],
|
||
['fixture', 'Inrichting', 'lamp', 'I', 'Stuk kiezen en in de plattegrond klikken (D = draaien)']
|
||
];
|
||
const bar = UI.editorBar(toolbar, document.getElementById('house-overview'), {
|
||
left: `<div class="tb-group tb-tools" role="group" aria-label="Gereedschap">${TOOLS.map(([k, label, ic, key, hint]) =>
|
||
UI.tb(label, ic, `data-ho-tool="${k}" title="${label} (${key}): ${hint}"`)).join('')}</div>
|
||
${UI.tbSep()}${UI.tb('Unit', 'plus-square', 'data-tb="add-unit" title="Unit toevoegen aan de geselecteerde (of laatste) unit"')}
|
||
<div class="tb-group tb-options" id="ho-tool-options"></div>`,
|
||
after: `<div class="tb-group tb-section" id="ho-section-group">
|
||
${UI.tb('Doorsnede', 'scissors', 'data-tb="section" title="Horizontale doorsnede (X)"')}
|
||
<span class="tb-range"><input type="range" id="ho-section" min="100" max="4000" step="50" value="${view3D.sectionMM}" aria-label="Hoogte doorsnede"><span id="ho-section-val"></span></span>
|
||
</div>
|
||
${UI.tb('', 'camera', 'data-tb="render" title="Render: elke lamp en spot als echte lichtbron"')}
|
||
${UI.tb('', 'person-walking', 'data-tb="walk" title="Lopen (L): W/S vooruit/achteruit, A/D opzij, slepen = rondkijken, E = deur/schakelaar"')}
|
||
${UI.tbSep()}
|
||
${UI.tb('Rapport', 'clipboard-data', 'data-tb="report" title="Rapport in plaats van de plattegrond (4): transport, gewicht, kosten, hout en plaatwerk"')}`,
|
||
onResize,
|
||
layers: { layers: view3D.layers, onChange: setLayers },
|
||
onSky: (m) => { skyMode = m; applyRenderMode(); },
|
||
beforeView: (mode) => { setWalk(false); if (mode === '3d') toggleReport(false); view3D.mode = mode; }
|
||
});
|
||
toolbar.querySelectorAll('[data-ho-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.hoTool)));
|
||
toolbar.addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-tb]');
|
||
if (!b) return;
|
||
const a = b.dataset.tb;
|
||
if (a === 'add-unit') addUnit();
|
||
else if (a === 'section') toggleSection();
|
||
else if (a === 'render') { fullRender = !fullRender; syncToolbar(); applyRenderMode(); }
|
||
else if (a === 'walk') setWalk(!walk.active);
|
||
else if (a === 'report') toggleReport();
|
||
b.blur();
|
||
});
|
||
function syncToolbar() {
|
||
const q = (a) => toolbar.querySelector(`[data-tb="${a}"]`);
|
||
q('section').classList.toggle('active', view3D.section);
|
||
q('render').classList.toggle('active', fullRender);
|
||
q('walk').classList.toggle('active', !!(walk && walk.active));
|
||
q('report').classList.toggle('active', reportOpen);
|
||
document.getElementById('ho-section-group').classList.toggle('on', view3D.section);
|
||
const mode = UI.viewOf(document.getElementById('house-overview'));
|
||
toolbar.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('active', b.dataset.view === mode));
|
||
}
|
||
|
||
// Eén set lagen voor 3D en plattegrond.
|
||
function setLayers(l) {
|
||
view3D.layers = ViewLayers.normalize(l);
|
||
const L = view3D.layers;
|
||
const hidden = selection && ((!L.ruimtes && (selection.type === 'room' || selection.type === 'region')) ||
|
||
(!L.inrichting && selection.type === 'fixture') || (!L.elektra && selection.type === 'elektra'));
|
||
if (hidden) selection = null;
|
||
scheduleRefresh({ keepProps: !hidden }); // vervalt de selectie, dan ook het paneel bijwerken
|
||
}
|
||
const sectionInput = document.getElementById('ho-section');
|
||
function syncSectionRow() {
|
||
document.getElementById('ho-section-val').textContent = `${view3D.sectionMM} mm`;
|
||
syncToolbar();
|
||
}
|
||
function setSectionMM(v) {
|
||
if (!isFinite(v)) return;
|
||
view3D.sectionMM = Math.max(50, Math.min(8000, Math.round(v)));
|
||
sectionInput.value = view3D.sectionMM;
|
||
syncSectionRow();
|
||
if (!parts || !view3D.section) return;
|
||
placeSection();
|
||
}
|
||
function toggleSection() {
|
||
view3D.section = !view3D.section;
|
||
syncSectionRow();
|
||
scheduleRefresh({ keepProps: true });
|
||
}
|
||
sectionInput.addEventListener('input', () => setSectionMM(Number(sectionInput.value)));
|
||
sectionInput.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) sectionInput.blur(); });
|
||
function placeSection() {
|
||
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
|
||
cutPlane.constant = (floorTop + view3D.sectionMM) * 0.001;
|
||
if (planCaps) { houseGroup.remove(planCaps); disposeGroup(planCaps); }
|
||
planCaps = view3D.section ? buildPlanCaps(floorTop + view3D.sectionMM) : null;
|
||
if (planCaps) houseGroup.add(planCaps);
|
||
}
|
||
|
||
let lightsGroup = null;
|
||
// buildLights3D maakt per lamp eigen wash-materialen; de textuur is gedeeld (material.dispose laat die staan).
|
||
function disposeLights() {
|
||
if (!lightsGroup) return;
|
||
scene.remove(lightsGroup);
|
||
lightsGroup.traverse(o => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); if (o.isLight && o.dispose) o.dispose(); });
|
||
lightsGroup = null;
|
||
}
|
||
function applyRenderMode() {
|
||
const lampsOn = fullRender || skyMode === 'night';
|
||
scene.background = new THREE.Color(0x1a1a1a);
|
||
Skybox.apply(skyMode, { env: skyEnv, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: houseGroup });
|
||
ground.visible = fullRender && skyMode === 'none';
|
||
grid.visible = skyMode === 'none' && !fullRender;
|
||
disposeLights();
|
||
if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !lampsOn; });
|
||
if (lampsOn && parts && view3D.layers.lampen && !view3D.section) {
|
||
const edge = view3D.layers.dak ? parts.roofEdge : null;
|
||
lightsGroup = LightingModel.buildLights3D(parts.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, parts.lighting.indoor, {
|
||
outdoorFactor: skyMode === 'night' ? 1 : LightingModel.DAY_OUTDOOR_FACTOR,
|
||
walls: parts.units.flatMap(u => u.walls.flatMap(w => w.pieces)),
|
||
groundY: Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel
|
||
});
|
||
scene.add(lightsGroup);
|
||
}
|
||
applyLightSwitches();
|
||
}
|
||
|
||
let parts = null;
|
||
let view = { scale: 1, ox: 0, oy: 0 };
|
||
// Zoom ten opzichte van passend; cx/cz = midden van het beeld (mm), null = midden van de woning.
|
||
const plan2D = { zoom: 1, cx: null, cz: null };
|
||
const toPx = (x, z) => ({ x: view.ox + x * view.scale, y: view.oy + z * view.scale });
|
||
const toWorld = (mx, my) => ({ x: (mx - view.ox) / view.scale, z: (my - view.oy) / view.scale });
|
||
|
||
function pieceHit(place, x, z, tolPx) {
|
||
const c = Math.cos(place.rotY), s = Math.sin(place.rotY);
|
||
const dx = x - place.cx, dz = z - place.cz;
|
||
const lx = dx * c - dz * s, lz = dx * s + dz * c;
|
||
const tol = tolPx / view.scale;
|
||
return Math.abs(lx) <= place.width / 2 + tol && Math.abs(lz) <= place.thickness / 2 + tol;
|
||
}
|
||
|
||
function elektraHit(x, z) {
|
||
const el = parts && parts.elektra;
|
||
if (!el || !el.enabled || !view3D.layers.elektra) return null;
|
||
const tol = 10 / view.scale;
|
||
let best = null;
|
||
el.items.forEach(it => {
|
||
const d = Math.hypot(it.marker.x - x, it.marker.z - z);
|
||
if (d < tol && (!best || d < best.d)) best = { d, it };
|
||
});
|
||
return best ? best.it : null;
|
||
}
|
||
const elektraItem = (key) => parts && parts.elektra && parts.elektra.items.find(it => it.key === key);
|
||
|
||
// Klik op een schoorsteen: de wand waar hij aan hangt (daar staat het schoorsteenpaneel).
|
||
function chimneyWallHit(ch) {
|
||
const u = parts.units.find(q => q.id === ch.unitId), w = u && u.walls.find(q => q.side === ch.side);
|
||
const pc = w && w.pieces.find(p => !p.baseY);
|
||
return pc ? { type: 'wall', unit: u.id, side: ch.side, element: pc.element } : null;
|
||
}
|
||
function hitTest(x, z) {
|
||
const eh = elektraHit(x, z);
|
||
if (eh) return { type: 'elektra', id: eh.key };
|
||
const ch = view3D.layers.gevel && ChimneyModel.hit(parts.chimneys || [], x, z, 0);
|
||
if (ch && chimneyWallHit(ch)) return chimneyWallHit(ch);
|
||
const iw = parts.interior.find(p => pieceHit(p.place, x, z, 4));
|
||
if (iw) return { type: 'iw', id: iw.id };
|
||
for (const u of parts.units) {
|
||
for (const w of u.walls) {
|
||
const p = w.pieces.find(pc => pieceHit(pc.place, x, z, 3));
|
||
if (p) return { type: 'wall', unit: u.id, side: w.side, element: p.element };
|
||
}
|
||
}
|
||
const fx = fixtureHit(x, z);
|
||
if (fx) return { type: 'fixture', id: fx.id };
|
||
if (view3D.layers.ruimtes && parts.rooms) {
|
||
const rm = parts.rooms.roomAt(x, z);
|
||
if (rm) return { type: 'room', id: rm.id };
|
||
if (parts.rooms.freeAt(x, z)) return { type: 'region', x: Math.round(x), z: Math.round(z) };
|
||
}
|
||
return unitHit(x, z);
|
||
}
|
||
function fixtureHit(x, z) {
|
||
if (!parts || !view3D.layers.inrichting || !parts.fixtures) return null;
|
||
return FixtureModel.hit(parts.fixtures, x, z, 3 / view.scale);
|
||
}
|
||
const fixtureState = (id) => (state.fixtures || []).find(f => f.id === id);
|
||
function placeFixture(fs, cx, cz, rot) {
|
||
const rc = parts.rects.find(r => cx >= r.x0 && cx <= r.x1 && cz >= r.z0 && cz <= r.z1) || parts.rects.find(r => r.id === fs.unitId) || parts.rects[0];
|
||
fs.unitId = rc.id;
|
||
fs.x = Math.round(fs.anchor === 'oost' ? rc.x1 - cx : cx - rc.x0);
|
||
fs.z = Math.round(cz - rc.z0);
|
||
if (rot !== undefined) fs.rot = ((Math.round(rot) % 360) + 360) % 360;
|
||
}
|
||
function removeFixture(id) {
|
||
state.fixtures = (state.fixtures || []).filter(f => f.id !== id);
|
||
selection = null;
|
||
refresh();
|
||
}
|
||
function rotateFixture(id, deg) {
|
||
const fs = fixtureState(id);
|
||
if (!fs) return;
|
||
fs.rot = ((fs.rot + deg) % 360 + 360) % 360;
|
||
refresh({ keepProps: false });
|
||
}
|
||
function duplicateFixture(id) {
|
||
const fs = fixtureState(id);
|
||
if (!fs) return;
|
||
const copy = JSON.parse(JSON.stringify(fs));
|
||
copy.id = state.fixtures.reduce((m, f) => Math.max(m, f.id), 0) + 1;
|
||
copy.x += 300; copy.z += 300;
|
||
state.fixtures.push(copy);
|
||
selection = { type: 'fixture', id: copy.id };
|
||
refresh();
|
||
}
|
||
|
||
function unitHit(x, z) {
|
||
const r = parts.rects.find(rc => x >= rc.x0 && x <= rc.x1 && z >= rc.z0 && z <= rc.z1);
|
||
return r ? { type: 'unit', id: r.id } : null;
|
||
}
|
||
function sameArea(a, b) {
|
||
if (!a || !b || a.type !== b.type) return false;
|
||
if (a.type === 'room') return a.id === b.id;
|
||
if (a.type === 'region') { const fa = parts.rooms.freeAt(a.x, a.z); return !!fa && fa === parts.rooms.freeAt(b.x, b.z); }
|
||
return false;
|
||
}
|
||
|
||
function assignRoom(x, z, type) {
|
||
const R = parts.rooms;
|
||
const existing = R.roomAt(x, z);
|
||
if (existing) {
|
||
RoomModel.applyType(existing.state, type);
|
||
selection = { type: 'room', id: existing.id };
|
||
} else {
|
||
const rect = R.freeAt(x, z) && parts.rects.find(r => x >= r.x0 && x <= r.x1 && z >= r.z0 && z <= r.z1);
|
||
if (!rect) { setIoStatus('Geen afgesloten ruimte op deze plek.', 'err'); return; }
|
||
const rs = RoomModel.create(state, rect, x, z, type);
|
||
state.rooms.push(rs);
|
||
selection = { type: 'room', id: rs.id };
|
||
}
|
||
refresh();
|
||
}
|
||
function removeRoom(id) {
|
||
state.rooms = state.rooms.filter(r => r.id !== id);
|
||
selection = null;
|
||
refresh();
|
||
}
|
||
|
||
const sameSel = (a, b) => a && b && a.type === b.type && (a.id === b.id) && (a.element === b.element);
|
||
function select(sel) {
|
||
selection = sel;
|
||
renderInspector();
|
||
drawPlan();
|
||
renderNav(parts);
|
||
}
|
||
|
||
let tool = 'select';
|
||
let drawStart = null;
|
||
let hover = null;
|
||
let roomHover = null;
|
||
let fxHover = null;
|
||
let fxDrag = null;
|
||
let chDrag = null;
|
||
|
||
function setTool(t) {
|
||
tool = t;
|
||
drawStart = null;
|
||
document.querySelectorAll('[data-ho-tool]').forEach(b => b.classList.toggle('active', b.dataset.hoTool === t));
|
||
canvas.style.cursor = toolCursor();
|
||
roomHover = null;
|
||
fxHover = null;
|
||
renderToolOptions();
|
||
updateHint();
|
||
drawPlan();
|
||
}
|
||
|
||
const toolCursor = () => tool === 'iw' || tool === 'fixture' ? 'crosshair' : tool === 'room' ? 'cell' : 'default';
|
||
function updateHint() {
|
||
document.getElementById('ho-hint').textContent = tool === 'iw'
|
||
? (drawStart ? 'Klik het eindpunt (Esc = annuleren). Snapt op 50 mm, haaks, en op wandvlakken.' : 'Klik het beginpunt van de binnenwand.')
|
||
: tool === 'room' ? `Klik in een ruimte: functie ${RoomModel.TYPES[fillType].label.toLowerCase()} (Esc = stoppen). Deuren en ramen tellen als wand.`
|
||
: tool === 'fixture' ? `Klik om ${FixtureModel.TYPES[fixtureType].label.toLowerCase()} te plaatsen (D = draaien); bij een wand snapt de rug op het wandvlak (Alt = vrij, Esc = stoppen).`
|
||
: 'Klik = selecteren (in een ruimte: nog eens = de unit) · dubbelklik = bewerken';
|
||
}
|
||
|
||
function snapPoint(x, z, from) {
|
||
let sx = Math.round(x / 50) * 50, sz = Math.round(z / 50) * 50;
|
||
if (from) { if (Math.abs(x - from.x) >= Math.abs(z - from.z)) sz = from.z; else sx = from.x; }
|
||
const tol = 12 / view.scale;
|
||
const facesX = [], facesZ = [];
|
||
HouseModel.allWallPieces(parts).forEach(p => {
|
||
const alongX = Math.abs(Math.sin(p.place.rotY)) < 0.5;
|
||
(alongX ? facesZ : facesX).push((alongX ? p.place.cz : p.place.cx) - p.place.thickness / 2, (alongX ? p.place.cz : p.place.cx) + p.place.thickness / 2);
|
||
});
|
||
const nearest = (v, list) => list.reduce((best, f) => Math.abs(f - v) < Math.abs(best - v) ? f : best, Infinity);
|
||
const fx = nearest(x, facesX), fz = nearest(z, facesZ);
|
||
if (!(from && sx === from.x) && Math.abs(fx - x) < tol) sx = fx;
|
||
if (!(from && sz === from.z) && Math.abs(fz - z) < tol) sz = fz;
|
||
return { x: Math.round(sx), z: Math.round(sz) };
|
||
}
|
||
|
||
function createInteriorWall(a, b) {
|
||
if (Math.hypot(b.x - a.x, b.z - a.z) < 300) { setIoStatus('Binnenwand te kort (min. 300 mm).', 'err'); return; }
|
||
const id = state.interiorWalls.reduce((m, w) => Math.max(m, w.id), 0) + 1;
|
||
const height = Math.min(...state.units.map(u => Math.min(u.unit.walls.voor.wall.heightLeft, u.unit.walls.voor.wall.heightRight)));
|
||
state.interiorWalls.push({ id, x1: a.x, z1: a.z, x2: b.x, z2: b.z, wall: HouseModel.interiorWallDefaults(HouseModel.DEFAULT_INTERIOR_THICKNESS, height) });
|
||
selection = { type: 'iw', id };
|
||
refresh();
|
||
}
|
||
|
||
let elDrag = null, suppressClick = false;
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
if (tool !== 'select' || !parts || e.button !== 0) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
const it = elektraHit(w.x, w.z);
|
||
if (it) elDrag = { key: it.key, sx: e.clientX, sy: e.clientY, moved: false, snap: null };
|
||
});
|
||
canvas.addEventListener('mousemove', (e) => {
|
||
if (!elDrag) return;
|
||
if (!elDrag.moved && Math.hypot(e.clientX - elDrag.sx, e.clientY - elDrag.sy) < 4) return;
|
||
elDrag.moved = true;
|
||
canvas.style.cursor = 'grabbing';
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
elDrag.snap = elDrag.key.startsWith('vwcd:')
|
||
? (parts.rects.some(rc => w.x > rc.x0 && w.x < rc.x1 && w.z > rc.z0 && w.z < rc.z1) ? { floor: true, x: Math.round(w.x / 10) * 10, z: Math.round(w.z / 10) * 10 } : null)
|
||
: ElektraModel.snapToWall(parts.elektra, w.x, w.z);
|
||
drawPlan();
|
||
});
|
||
function onElektraDrop(e) {
|
||
if (!elDrag) return;
|
||
const d = elDrag;
|
||
elDrag = null;
|
||
canvas.style.cursor = toolCursor();
|
||
if (!d.moved || !d.snap) return;
|
||
if (e.target === canvas) suppressClick = true;
|
||
if (d.snap.floor) {
|
||
const rc = parts.rects.find(q => d.snap.x > q.x0 && d.snap.x < q.x1 && d.snap.z > q.z0 && d.snap.z < q.z1);
|
||
const vw = state.settings.elektra.vloerWcd.find(o => 'vwcd:' + o.id === d.key);
|
||
if (rc && vw) Object.assign(vw, { unitId: rc.id, x: Math.round(d.snap.x - rc.x0), z: Math.round(d.snap.z - rc.z0) });
|
||
} else {
|
||
const pl = state.settings.elektra.place, prev = pl[d.key] || {};
|
||
pl[d.key] = { element: d.snap.element, side: d.snap.side, u: d.snap.u, y: prev.y };
|
||
}
|
||
selection = { type: 'elektra', id: d.key };
|
||
refresh();
|
||
}
|
||
window.addEventListener('mouseup', onElektraDrop);
|
||
const fixtureItem = (id) => parts && parts.fixtures.find(f => f.id === id);
|
||
const fxPose = (item, x, z, free) => free ? { x: Math.round(x / 10) * 10, z: Math.round(z / 10) * 10, rot: item.rot }
|
||
: FixtureModel.snap(item, x, z, HouseModel.allWallPieces(parts));
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
if (tool !== 'select' || !parts || e.button !== 0 || elDrag) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
if (elektraHit(w.x, w.z)) return;
|
||
const it = fixtureHit(w.x, w.z);
|
||
if (it) fxDrag = { id: it.id, sx: e.clientX, sy: e.clientY, dx: it.cx - w.x, dz: it.cz - w.z, moved: false, pose: null };
|
||
});
|
||
canvas.addEventListener('mousemove', (e) => {
|
||
if (!fxDrag) return;
|
||
if (!fxDrag.moved && Math.hypot(e.clientX - fxDrag.sx, e.clientY - fxDrag.sy) < 4) return;
|
||
fxDrag.moved = true;
|
||
canvas.style.cursor = 'grabbing';
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
const it = fixtureItem(fxDrag.id);
|
||
if (it) fxDrag.pose = fxPose(it, w.x + fxDrag.dx, w.z + fxDrag.dz, e.altKey);
|
||
drawPlan();
|
||
});
|
||
function onFixtureDrop(e) {
|
||
if (!fxDrag) return;
|
||
const d = fxDrag;
|
||
fxDrag = null;
|
||
canvas.style.cursor = toolCursor();
|
||
if (!d.moved || !d.pose) return;
|
||
if (e.target === canvas) suppressClick = true;
|
||
const fs = fixtureState(d.id);
|
||
if (fs) placeFixture(fs, d.pose.x, d.pose.z, d.pose.rot);
|
||
selection = { type: 'fixture', id: d.id };
|
||
refresh();
|
||
}
|
||
window.addEventListener('mouseup', onFixtureDrop);
|
||
// Schoorsteen slepen: alleen langs de buitengevel.
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
if (tool !== 'select' || !parts || e.button !== 0 || elDrag || fxDrag) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
const ch = view3D.layers.gevel && ChimneyModel.hit(parts.chimneys || [], w.x, w.z, 0);
|
||
if (ch) chDrag = { id: ch.id, sx: e.clientX, sy: e.clientY, moved: false, snap: null };
|
||
});
|
||
canvas.addEventListener('mousemove', (e) => {
|
||
if (!chDrag) return;
|
||
if (!chDrag.moved && Math.hypot(e.clientX - chDrag.sx, e.clientY - chDrag.sy) < 4) return;
|
||
chDrag.moved = true;
|
||
canvas.style.cursor = 'grabbing';
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
const ch = (parts.chimneys || []).find(c => c.id === chDrag.id);
|
||
if (ch) chDrag.snap = ChimneyModel.snapToFacade(parts.rects, w.x, w.z, ch.w);
|
||
drawPlan();
|
||
});
|
||
window.addEventListener('mouseup', (e) => {
|
||
if (!chDrag) return;
|
||
const d = chDrag;
|
||
chDrag = null;
|
||
canvas.style.cursor = toolCursor();
|
||
if (!d.moved || !d.snap) return;
|
||
if (e.target === canvas) suppressClick = true;
|
||
const cs = (state.chimneys || []).find(c => c.id === d.id);
|
||
if (cs) Object.assign(cs, { unitId: d.snap.unitId, side: d.snap.side, u: d.snap.u });
|
||
const u = parts.units.find(q => q.id === d.snap.unitId), wl = u && u.walls.find(q => q.side === d.snap.side), pc = wl && wl.pieces.find(p => !p.baseY);
|
||
if (pc) selection = { type: 'wall', unit: u.id, side: d.snap.side, element: pc.element };
|
||
refresh();
|
||
});
|
||
const newFixtureItem = (cx, cz, rot) => {
|
||
const T = FixtureModel.TYPES[fixtureType];
|
||
return { type: fixtureType, w: T.w, d: T.d, h: T.h, opts: Object.assign({}, T.opts || {}), rot: rot || 0, cx, cz };
|
||
};
|
||
canvas.addEventListener('mousemove', (e) => {
|
||
if (tool !== 'fixture' || !parts) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
const p = fxPose(newFixtureItem(w.x, w.z, fxHover ? fxHover.rot : 0), w.x, w.z, e.altKey);
|
||
fxHover = { cx: p.x, cz: p.z, rot: p.rot };
|
||
drawPlan();
|
||
});
|
||
canvas.addEventListener('mousemove', (e) => {
|
||
if (tool === 'room' && parts) {
|
||
const r = canvas.getBoundingClientRect();
|
||
roomHover = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
drawPlan();
|
||
return;
|
||
}
|
||
if (tool !== 'iw' || !parts) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
hover = snapPoint(w.x, w.z, drawStart);
|
||
drawPlan();
|
||
});
|
||
canvas.addEventListener('click', (e) => {
|
||
if (!parts) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
if (tool === 'iw') {
|
||
const p = snapPoint(w.x, w.z, drawStart);
|
||
if (!drawStart) drawStart = p;
|
||
else { const a = drawStart; drawStart = null; createInteriorWall(a, p); }
|
||
updateHint();
|
||
drawPlan();
|
||
return;
|
||
}
|
||
if (tool === 'room') { assignRoom(w.x, w.z, fillType); return; }
|
||
if (tool === 'fixture') {
|
||
const rc = parts.rects.find(q => w.x >= q.x0 && w.x <= q.x1 && w.z >= q.z0 && w.z <= q.z1);
|
||
if (!rc) { setIoStatus('Plaats inrichting binnen een unit.', 'err'); return; }
|
||
const p = fxHover || { cx: w.x, cz: w.z, rot: 0 };
|
||
state.fixtures = state.fixtures || [];
|
||
const fs = FixtureModel.create(state, fixtureType, rc.id, 0, 0, p.rot);
|
||
placeFixture(fs, p.cx, p.cz, p.rot);
|
||
state.fixtures.push(fs);
|
||
selection = { type: 'fixture', id: fs.id };
|
||
setTool('select');
|
||
refresh();
|
||
return;
|
||
}
|
||
if (suppressClick) { suppressClick = false; return; }
|
||
let hit = hitTest(w.x, w.z);
|
||
if (hit && sameArea(hit, selection)) hit = unitHit(w.x, w.z);
|
||
if (!sameSel(hit, selection) || (hit && hit.type === 'region')) select(hit);
|
||
});
|
||
canvas.addEventListener('dblclick', (e) => {
|
||
if (tool !== 'select' || !parts) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
let hit = hitTest(w.x, w.z);
|
||
if (hit && (hit.type === 'room' || hit.type === 'region')) hit = unitHit(w.x, w.z);
|
||
if (!hit) return;
|
||
if (hit.type === 'wall') showPart(`wall:${hit.unit}:${hit.side}`);
|
||
else if (hit.type === 'iw') showPart(`iw:${hit.id}`);
|
||
else if (hit.type === 'unit') showPart(`floor:${hit.id}`);
|
||
});
|
||
const TOOL_KEYS = { v: 'select', w: 'iw', r: 'room', i: 'fixture' };
|
||
function deleteSelection() {
|
||
if (!selection) return;
|
||
if (selection.type === 'fixture') removeFixture(selection.id);
|
||
else if (selection.type === 'room') removeRoom(selection.id);
|
||
else if (selection.type === 'elektra' && selection.id !== 'mk') removeSelectedElektra();
|
||
else if (selection.type === 'iw') removeInteriorWall(selection.id);
|
||
}
|
||
function removeInteriorWall(id) {
|
||
state.interiorWalls = state.interiorWalls.filter(w => w.id !== id);
|
||
selection = null;
|
||
refresh();
|
||
}
|
||
function onKey(e) {
|
||
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||
const k = e.key.toLowerCase(), plain = !e.ctrlKey && !e.altKey && !e.metaKey;
|
||
if (e.key === 'Escape') {
|
||
if (walk.active) setWalk(false);
|
||
else if (drawStart) { drawStart = null; updateHint(); drawPlan(); }
|
||
else if (tool !== 'select') setTool('select');
|
||
else if (selection) select(null);
|
||
} else if (walk.active) {
|
||
if (k === 'e' && plain) interact();
|
||
} else if (plain && !e.shiftKey && TOOL_KEYS[k]) {
|
||
setTool(TOOL_KEYS[k]);
|
||
} else if (k === 'd' && plain && (tool === 'fixture' || (selection && selection.type === 'fixture'))) {
|
||
const deg = e.shiftKey ? -90 : 90;
|
||
if (tool === 'fixture') { if (fxHover) { fxHover.rot = ((fxHover.rot + deg) % 360 + 360) % 360; drawPlan(); } }
|
||
else rotateFixture(selection.id, deg);
|
||
} else if ((e.key === 'Delete' || e.key === 'Backspace') && plain && selection) {
|
||
e.preventDefault();
|
||
deleteSelection();
|
||
}
|
||
}
|
||
window.addEventListener('keydown', onKey);
|
||
|
||
const zoomVal = document.getElementById('ho-zoom-val');
|
||
function zoomPlanAt(px, py, factor) {
|
||
const k = Math.max(0.5, Math.min(25, plan2D.zoom * factor));
|
||
if (!parts || k === plan2D.zoom) return;
|
||
const p = toWorld(px, py), s1 = view.scale * k / plan2D.zoom;
|
||
plan2D.zoom = k;
|
||
plan2D.cx = (canvas.width / 2 - (px - p.x * s1)) / s1;
|
||
plan2D.cz = (canvas.height / 2 - (py - p.z * s1)) / s1;
|
||
zoomVal.textContent = `${Math.round(k * 100)}%`;
|
||
drawPlan();
|
||
}
|
||
function fitPlan() {
|
||
Object.assign(plan2D, { zoom: 1, cx: null, cz: null });
|
||
zoomVal.textContent = '100%';
|
||
drawPlan();
|
||
}
|
||
canvas.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
const r = canvas.getBoundingClientRect();
|
||
zoomPlanAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-Math.max(-300, Math.min(300, e.deltaY * (e.deltaMode === 1 ? 33 : 1))) * 0.002));
|
||
}, { passive: false });
|
||
host.querySelector('.plan-zoom').addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-zoom]');
|
||
if (!b) return;
|
||
b.blur();
|
||
if (b.dataset.zoom === 'fit') fitPlan();
|
||
else zoomPlanAt(canvas.width / 2, canvas.height / 2, b.dataset.zoom === 'in' ? 1.4 : 1 / 1.4);
|
||
});
|
||
// Verschuiven: middelste muisknop overal, linksslepen op een plek zonder sleepbaar onderdeel.
|
||
let pan = null;
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
if (!parts) return;
|
||
const mid = e.button === 1;
|
||
if (!mid && (e.button !== 0 || tool !== 'select' || elDrag || fxDrag || chDrag)) return;
|
||
if (mid) e.preventDefault();
|
||
pan = { sx: e.clientX, sy: e.clientY, ox: view.ox, oy: view.oy, moved: false, mid };
|
||
});
|
||
function onPanMove(e) {
|
||
if (!pan) return;
|
||
if (!pan.moved && Math.hypot(e.clientX - pan.sx, e.clientY - pan.sy) < 4) return;
|
||
pan.moved = true;
|
||
canvas.style.cursor = 'grabbing';
|
||
plan2D.cx = (canvas.width / 2 - (pan.ox + e.clientX - pan.sx)) / view.scale;
|
||
plan2D.cz = (canvas.height / 2 - (pan.oy + e.clientY - pan.sy)) / view.scale;
|
||
drawPlan();
|
||
}
|
||
// Na een sleep de klik op de plattegrond overslaan, maar alleen als die klik er ook komt (loslaten op de plattegrond).
|
||
function onPanEnd(e) {
|
||
if (!pan) return;
|
||
if (pan.moved) { canvas.style.cursor = toolCursor(); if (!pan.mid && e.target === canvas) suppressClick = true; }
|
||
pan = null;
|
||
}
|
||
window.addEventListener('mousemove', onPanMove);
|
||
window.addEventListener('mouseup', onPanEnd);
|
||
|
||
function polyPath(pts) {
|
||
ctx.beginPath();
|
||
pts.forEach((p, i) => { const q = toPx(p.x, p.z); if (i === 0) ctx.moveTo(q.x, q.y); else ctx.lineTo(q.x, q.y); });
|
||
ctx.closePath();
|
||
}
|
||
function pieceCorners(place, lx0, lx1) {
|
||
const t = place.thickness / 2;
|
||
return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([lx, lz]) => UnitModel.localToWorld(place, lx, lz));
|
||
}
|
||
|
||
function drawPlan() {
|
||
if (!parts) return;
|
||
const rect = canvas.parentElement.getBoundingClientRect();
|
||
canvas.width = rect.width;
|
||
canvas.height = rect.height;
|
||
const w = canvas.width, h = canvas.height;
|
||
ctx.clearRect(0, 0, w, h);
|
||
|
||
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1));
|
||
const bz0 = Math.min(...parts.rects.map(r => r.z0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
|
||
const scale = Math.min(w / ((bx1 - bx0) * 1.45), h / ((bz1 - bz0) * 1.45)) * plan2D.zoom;
|
||
const mx = plan2D.cx === null ? (bx0 + bx1) / 2 : plan2D.cx, mz = plan2D.cz === null ? (bz0 + bz1) / 2 : plan2D.cz;
|
||
view = { scale, ox: w / 2 - mx * scale, oy: h / 2 - mz * scale };
|
||
const L = view3D.layers;
|
||
|
||
ctx.strokeStyle = '#222'; ctx.lineWidth = 1;
|
||
const g = 500 * scale, gx = view.ox % g, gy = view.oy % g;
|
||
for (let x = gx; x < w; x += g) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
||
for (let y = gy; y < h; y += g) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||
|
||
const overlapIds = new Set(parts.overlaps.flat());
|
||
parts.rects.forEach(r => {
|
||
const p0 = toPx(r.x0, r.z0);
|
||
const sel = selection && selection.type === 'unit' && selection.id === r.id;
|
||
ctx.fillStyle = sel ? 'rgba(77, 166, 255, 0.18)' : 'rgba(205, 168, 106, 0.12)';
|
||
ctx.fillRect(p0.x, p0.y, r.W * scale, r.D * scale);
|
||
ctx.strokeStyle = overlapIds.has(r.id) ? '#ff5544' : sel ? '#4da6ff' : '#666';
|
||
ctx.lineWidth = sel || overlapIds.has(r.id) ? 2 : 1;
|
||
ctx.strokeRect(p0.x, p0.y, r.W * scale, r.D * scale);
|
||
const c = toPx(r.cx, r.cz);
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.18)';
|
||
ctx.font = `bold ${Math.max(14, Math.min(48, r.D * scale * 0.3))}px sans-serif`;
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText(r.id, c.x, c.y);
|
||
ctx.textBaseline = 'alphabetic';
|
||
});
|
||
|
||
const R = view3D.layers.ruimtes ? parts.rooms : null;
|
||
const rectsPath = (rr) => { ctx.beginPath(); rr.forEach(c => { const a = toPx(c.x0, c.z0); ctx.rect(a.x, a.y, (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale); }); };
|
||
const hexA = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`;
|
||
const selRoom = R && selection && selection.type === 'room' ? R.rooms.find(r => r.id === selection.id) : null;
|
||
const selFree = R && selection && selection.type === 'region' ? R.freeAt(selection.x, selection.z) : null;
|
||
const hoverArea = R && tool === 'room' && roomHover ? (R.roomAt(roomHover.x, roomHover.z) || R.freeAt(roomHover.x, roomHover.z)) : null;
|
||
if (R) {
|
||
R.rooms.forEach(r => { rectsPath(r.rects); ctx.fillStyle = hexA(r.color, r === selRoom ? 0.5 : 0.3); ctx.fill(); });
|
||
R.free.forEach(r => { rectsPath(r.rects); ctx.fillStyle = r === selFree ? 'rgba(77, 166, 255, 0.25)' : 'rgba(255, 255, 255, 0.04)'; ctx.fill(); });
|
||
if (hoverArea) { rectsPath(hoverArea.rects); ctx.fillStyle = hexA(RoomModel.TYPES[fillType].color, 0.55); ctx.fill(); }
|
||
[selRoom, selFree].filter(Boolean).forEach(r => {
|
||
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
r.edges.forEach(e => {
|
||
const a = e.axis === 'x' ? toPx(e.v, e.s0) : toPx(e.s0, e.v), b = e.axis === 'x' ? toPx(e.v, e.s1) : toPx(e.s1, e.v);
|
||
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y);
|
||
});
|
||
ctx.stroke();
|
||
});
|
||
}
|
||
|
||
const drawPiece = (p, selected, color) => {
|
||
polyPath(pieceCorners(p.place, -p.place.width / 2, p.place.width / 2));
|
||
ctx.fillStyle = selected ? '#4da6ff' : color;
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#8a6836'; ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
p.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||
polyPath(pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w));
|
||
ctx.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.85)' : (op.y < p.config.plateThick ? '#1d5f99' : '#9fd0ff');
|
||
ctx.fill();
|
||
if (op.fill && L.kozijnen) drawOpeningSymbol(p, op);
|
||
});
|
||
};
|
||
function drawOpeningSymbol(p, op) {
|
||
const W = p.place.width, T = p.place.thickness;
|
||
KozijnModel.planSymbol(op, op.fill, { zIn: -T / 2, zOut: T / 2 }).forEach(sh => {
|
||
ctx.save();
|
||
ctx.strokeStyle = sh.kind === 'leaf' ? '#e8e8e8' : 'rgba(232, 232, 232, 0.75)';
|
||
ctx.lineWidth = sh.kind === 'leaf' ? 1.4 : 0.9;
|
||
ctx.setLineDash(sh.kind === 'arc' || sh.kind === 'rail' ? [3, 3] : []);
|
||
ctx.beginPath();
|
||
sh.pts.forEach((pt, i) => {
|
||
const w = UnitModel.localToWorld(p.place, -W / 2 + op.x + pt.x, pt.z), q = toPx(w.x, w.z);
|
||
if (i) ctx.lineTo(q.x, q.y); else ctx.moveTo(q.x, q.y);
|
||
});
|
||
if (sh.closed) ctx.closePath();
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
});
|
||
}
|
||
const drawFixture = (item, selected, ghost) => {
|
||
ctx.save();
|
||
polyPath(item.corners || [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([a, b]) => FixtureModel.toWorld(item, a * item.w / 2, b * item.d / 2)));
|
||
ctx.fillStyle = selected ? 'rgba(77, 166, 255, 0.22)' : ghost ? 'rgba(77, 166, 255, 0.15)' : 'rgba(28, 28, 28, 0.45)';
|
||
ctx.fill();
|
||
const col = selected || ghost ? '#4da6ff' : 'rgba(228, 228, 228, 0.9)';
|
||
FixtureModel.planShapes(item).forEach(sh => {
|
||
ctx.strokeStyle = col;
|
||
ctx.lineWidth = sh.kind === 'thin' ? 0.8 : 1.3;
|
||
ctx.setLineDash(sh.kind === 'dash' ? [4, 3] : []);
|
||
if (sh.circle) {
|
||
const c = toPx(sh.circle.x, sh.circle.z);
|
||
ctx.beginPath(); ctx.arc(c.x, c.y, Math.max(1, sh.circle.r * scale), 0, Math.PI * 2); ctx.stroke();
|
||
return;
|
||
}
|
||
if (sh.text) {
|
||
const c = toPx(sh.at.x, sh.at.z);
|
||
ctx.fillStyle = col; ctx.font = '9px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText(sh.text, c.x, c.y);
|
||
ctx.textBaseline = 'alphabetic';
|
||
return;
|
||
}
|
||
ctx.beginPath();
|
||
sh.pts.forEach((pt, i) => { const q = toPx(pt.x, pt.z); if (i) ctx.lineTo(q.x, q.y); else ctx.moveTo(q.x, q.y); });
|
||
if (sh.closed) ctx.closePath();
|
||
if (sh.fill) { ctx.fillStyle = selected || ghost ? 'rgba(77, 166, 255, 0.4)' : 'rgba(205, 205, 205, 0.45)'; ctx.fill(); }
|
||
ctx.stroke();
|
||
});
|
||
ctx.restore();
|
||
};
|
||
if (view3D.layers.inrichting && parts.fixtures) {
|
||
parts.fixtures.forEach(it => {
|
||
const dragging = fxDrag && fxDrag.moved && fxDrag.pose && fxDrag.id === it.id;
|
||
const sel = selection && selection.type === 'fixture' && selection.id === it.id;
|
||
if (dragging) drawFixture(Object.assign({}, it, { cx: fxDrag.pose.x, cz: fxDrag.pose.z, rot: fxDrag.pose.rot, corners: null }), true, true);
|
||
else drawFixture(it, sel, false);
|
||
});
|
||
}
|
||
if (tool === 'fixture' && fxHover) drawFixture(newFixtureItem(fxHover.cx, fxHover.cz, fxHover.rot), false, true);
|
||
parts.units.forEach(u => u.walls.forEach(wl => wl.pieces.forEach(p => {
|
||
drawPiece(p, selection && selection.type === 'wall' && selection.element === p.element, '#c29b62');
|
||
})));
|
||
parts.interior.forEach(p => drawPiece(p, selection && selection.type === 'iw' && selection.id === p.id, '#d8c49a'));
|
||
|
||
ctx.save();
|
||
ctx.strokeStyle = '#ff9f43'; ctx.lineWidth = 2; ctx.setLineDash([6, 4]);
|
||
parts.seams.forEach(s => {
|
||
const a = toPx(s.x0, s.z0), b = toPx(s.x1, s.z1);
|
||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||
});
|
||
ctx.restore();
|
||
|
||
if (L.maten) parts.units.forEach(u => u.walls.forEach(wl => wl.pieces.forEach(p => {
|
||
const n = p.place.normal, off = 14;
|
||
const a = toPx(...Object.values(UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2)));
|
||
const b = toPx(...Object.values(UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2)));
|
||
const x0 = a.x + n.x * off, y0 = a.y + n.y * off, x1 = b.x + n.x * off, y1 = b.y + n.y * off;
|
||
drawCanvasDim(ctx, x0, y0, x1, y1, `${p.element}: ${p.place.width}mm`,
|
||
(x0 + x1) / 2 + (n.x < 0 ? -4 : n.x > 0 ? 12 : 0), (y0 + y1) / 2 + (n.y < 0 ? -4 : n.y > 0 ? 11 : 0));
|
||
})));
|
||
if (L.maten) parts.interior.forEach(p => {
|
||
const c = toPx(p.place.cx, p.place.cz);
|
||
ctx.save();
|
||
ctx.translate(c.x, c.y);
|
||
if (p.place.rotY !== 0) ctx.rotate(-Math.PI / 2);
|
||
ctx.fillStyle = '#4da6ff'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
|
||
ctx.fillText(`${p.element}: ${p.place.width}mm`, 0, -p.place.thickness / 2 * scale - 4);
|
||
ctx.restore();
|
||
});
|
||
|
||
if (R) {
|
||
ctx.save();
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';
|
||
const label = (r, lines) => {
|
||
const c = toPx(r.label.x, r.label.z);
|
||
lines.forEach(([t, font, col], i) => {
|
||
const y = c.y + (i - (lines.length - 1) / 2) * 14;
|
||
ctx.font = font;
|
||
ctx.strokeStyle = 'rgba(20, 20, 20, 0.75)'; ctx.lineWidth = 3; ctx.strokeText(t, c.x, y);
|
||
ctx.fillStyle = col; ctx.fillText(t, c.x, y);
|
||
});
|
||
};
|
||
R.rooms.forEach(r => label(r, [[r.name, 'bold 12px sans-serif', '#fff'], [`${houseFmt(r.areaM2, 2)} m²`, '11px sans-serif', '#e8e8e8']]
|
||
.concat(r.ceilingActive ? [[`plafond ${r.ceiling}`, '10px sans-serif', '#cfd8e0']] : [])));
|
||
R.free.forEach(r => label(r, [['Ruimte', 'italic 11px sans-serif', '#aaa'], [`${houseFmt(r.areaM2, 2)} m²`, '10px sans-serif', '#999']]));
|
||
ctx.restore();
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.setLineDash([3, 4]);
|
||
ctx.strokeStyle = 'rgba(200, 200, 200, 0.55)'; ctx.lineWidth = 1;
|
||
if (L.dak) parts.units.forEach(u => {
|
||
const a = toPx(u.roof.rect.x0, u.roof.rect.z0);
|
||
ctx.strokeRect(a.x, a.y, u.roof.rect.W * scale, u.roof.rect.D * scale);
|
||
});
|
||
if (parts.roofEdge && L.dak) {
|
||
parts.roofEdge.segments.forEach(sg => {
|
||
ctx.strokeStyle = sg.fascia ? 'rgba(232, 136, 61, 0.9)' : 'rgba(190, 195, 200, 0.9)';
|
||
const a = toPx(sg.outerA.x, sg.outerA.z), b = toPx(sg.outerB.x, sg.outerB.z);
|
||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||
});
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = '#ffe9a8';
|
||
if (L.lampen) parts.roofEdge.spots.forEach(sp => {
|
||
const q = toPx(sp.x, sp.z);
|
||
ctx.beginPath(); ctx.arc(q.x, q.y, 3, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
}
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = '#a07c54'; ctx.strokeStyle = '#e8d3b0'; ctx.lineWidth = 1;
|
||
if (L.dak) parts.posts.posts.forEach(pp => {
|
||
const a = toPx(pp.x - pp.size / 2, pp.z - pp.size / 2), sz = pp.size * scale;
|
||
ctx.fillRect(a.x, a.y, sz, sz); ctx.strokeRect(a.x, a.y, sz, sz);
|
||
});
|
||
if (L.dak) parts.posts.walls.forEach(w => {
|
||
const a = toPx(w.x0, w.z0);
|
||
ctx.fillStyle = WallModel.stucHex(w.color); ctx.strokeStyle = '#8a8a8a';
|
||
ctx.fillRect(a.x, a.y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale); ctx.strokeRect(a.x, a.y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale);
|
||
});
|
||
(L.gevel ? parts.chimneys || [] : []).forEach(ch => {
|
||
const path = (pts) => { ctx.beginPath(); pts.forEach((p, i) => { const q = toPx(p.x, p.z); if (i) ctx.lineTo(q.x, q.y); else ctx.moveTo(q.x, q.y); }); ctx.closePath(); };
|
||
const sn = chDrag && chDrag.moved && chDrag.snap && chDrag.id === ch.id ? chDrag.snap : null;
|
||
if (sn) {
|
||
// Voorbeeld tijdens het slepen: dezelfde diepte, op de nieuwe plek.
|
||
const S = ChimneyModel.SIDES[sn.side], W = (t, s) => S.axis === 'z' ? { x: sn.plane + S.n.x * s, z: t } : { x: t, z: sn.plane + S.n.z * s };
|
||
path([W(sn.t - ch.w / 2, ch.sIn), W(sn.t + ch.w / 2, ch.sIn), W(sn.t + ch.w / 2, ch.sFront), W(sn.t - ch.w / 2, ch.sFront)]);
|
||
ctx.fillStyle = 'rgba(77, 166, 255, 0.45)'; ctx.fill(); ctx.strokeStyle = '#4da6ff'; ctx.stroke();
|
||
return;
|
||
}
|
||
path(ch.footprint);
|
||
ctx.fillStyle = ch.outside.type === 'steen' ? (WallModel.STONE_COLORS[ch.outside.stone] || '#8a6a5c') : WallModel.stucHex(ch.outside.stuc);
|
||
ctx.fill(); ctx.strokeStyle = '#2b2b2b'; ctx.stroke();
|
||
path(ch.flueFootprint);
|
||
ctx.fillStyle = '#1b1a19'; ctx.fill();
|
||
});
|
||
ctx.restore();
|
||
|
||
ctx.strokeStyle = '#ffe9a8'; ctx.lineWidth = 1.5;
|
||
(L.lampen ? parts.lighting.indoor || [] : []).forEach(sp => {
|
||
const q = toPx(sp.x, sp.z);
|
||
ctx.beginPath(); ctx.arc(q.x, q.y, 3.5, 0, Math.PI * 2); ctx.stroke();
|
||
});
|
||
|
||
ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 1;
|
||
(L.lampen ? parts.lighting.lamps : []).forEach(l => {
|
||
const q = toPx(l.x + l.normal.x * 60, l.z + l.normal.z * 60);
|
||
ctx.fillRect(q.x - 3.5, q.y - 3.5, 7, 7);
|
||
ctx.strokeRect(q.x - 3.5, q.y - 3.5, 7, 7);
|
||
});
|
||
|
||
const el = parts.elektra;
|
||
if (el && el.enabled && view3D.layers.elektra) {
|
||
const RC = ElektraPdf.RUN_COLORS, rgb = (c, a) => `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a})`;
|
||
const inward = (q, n, d) => toPx(q.x + n.x * d, q.z + n.z * d);
|
||
ctx.save();
|
||
ctx.lineWidth = 1.2; ctx.setLineDash([4, 3]);
|
||
el.runs.forEach(r => {
|
||
ctx.strokeStyle = rgb(RC[r.type] || RC.schakel, 0.8);
|
||
ctx.beginPath();
|
||
r.path.forEach((q, i) => { const p = toPx(q.x, q.z); if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); });
|
||
ctx.stroke();
|
||
});
|
||
ctx.restore();
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
if (el.meterkast) {
|
||
const k = el.meterkast;
|
||
polyPath(k.corners);
|
||
ctx.fillStyle = '#5a5a5a'; ctx.fill();
|
||
ctx.strokeStyle = '#eee'; ctx.lineWidth = 1; ctx.stroke();
|
||
const c = inward({ x: (k.corners[0].x + k.corners[2].x) / 2, z: (k.corners[0].z + k.corners[2].z) / 2 }, k.face.n, 380);
|
||
ctx.fillStyle = '#eee'; ctx.font = 'bold 10px sans-serif';
|
||
ctx.fillText('MK', c.x, c.y);
|
||
}
|
||
ctx.strokeStyle = rgb(RC.schakel, 1); ctx.lineWidth = 1;
|
||
el.boxes.forEach(b => { const q = toPx(b.x, b.z); ctx.strokeRect(q.x - 3, q.y - 3, 6, 6); });
|
||
ctx.font = 'bold 10px sans-serif';
|
||
el.combos.forEach(c => {
|
||
const q = inward(c.pos, c.face.n, 260), text = c.switches.map(x => x.letter).join(' '), w = 8 + text.length * 6;
|
||
ctx.fillStyle = '#fff'; ctx.fillRect(q.x - w / 2, q.y - 7, w, 14);
|
||
ctx.strokeStyle = '#1a1a1a'; ctx.strokeRect(q.x - w / 2, q.y - 7, w, 14);
|
||
ctx.fillStyle = '#1a1a1a'; ctx.fillText(text, q.x, q.y + 0.5);
|
||
});
|
||
ctx.font = '9px sans-serif';
|
||
el.wcds.forEach(w => {
|
||
const q = inward(w.pos, w.face.n, 120), t = inward(w.pos, w.face.n, 420);
|
||
ctx.fillStyle = '#fff'; ctx.strokeStyle = rgb(RC.wcd, 1); ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(q.x, q.y, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||
ctx.fillStyle = rgb(RC.wcd, 1); ctx.fillText(String(w.group), t.x, t.y);
|
||
});
|
||
ctx.font = 'bold 8px sans-serif';
|
||
el.apps.forEach(a => {
|
||
const it = el.items.find(x => x.key === a.key), q = toPx(it.marker.x, it.marker.z), t = inward(a.pos, a.face.n, 480);
|
||
ctx.fillStyle = '#fff'; ctx.strokeStyle = rgb(RC.wcd, 1); ctx.lineWidth = 1.5;
|
||
ctx.fillRect(q.x - 9, q.y - 6, 18, 12); ctx.strokeRect(q.x - 9, q.y - 6, 18, 12);
|
||
ctx.fillStyle = rgb(RC.wcd, 1); ctx.fillText(a.code, q.x, q.y + 0.5);
|
||
ctx.font = '9px sans-serif'; ctx.fillText(String(a.group), t.x, t.y); ctx.font = 'bold 8px sans-serif';
|
||
});
|
||
ctx.fillStyle = '#ffe9a8'; ctx.font = '9px sans-serif';
|
||
el.switches.forEach(sw => sw.lamps.forEach(l => { const q = toPx(l.x, l.z); ctx.fillText(sw.letter, q.x + 7, q.y - 6); }));
|
||
const selIt = selection && selection.type === 'elektra' && el.items.find(x => x.key === selection.id);
|
||
if (selIt) {
|
||
const q = toPx(selIt.marker.x, selIt.marker.z);
|
||
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.arc(q.x, q.y, 13, 0, Math.PI * 2); ctx.stroke();
|
||
}
|
||
el.items.filter(x => x.manual).forEach(x => {
|
||
const q = toPx(x.marker.x, x.marker.z);
|
||
ctx.fillStyle = '#4da6ff'; ctx.beginPath(); ctx.arc(q.x + 10, q.y - 9, 2.5, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
el.floorWcds.forEach(w => {
|
||
const q = toPx(w.x, w.z);
|
||
ctx.fillStyle = '#fff'; ctx.strokeStyle = rgb(RC.wcd, 1); ctx.lineWidth = 1.5;
|
||
ctx.fillRect(q.x - 7, q.y - 7, 14, 14); ctx.strokeRect(q.x - 7, q.y - 7, 14, 14);
|
||
ctx.fillStyle = rgb(RC.wcd, 1); ctx.font = 'bold 8px sans-serif'; ctx.fillText('V' + w.group, q.x, q.y + 0.5);
|
||
});
|
||
if (elDrag && elDrag.snap && elDrag.snap.floor) {
|
||
const q = toPx(elDrag.snap.x, elDrag.snap.z);
|
||
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2; ctx.strokeRect(q.x - 9, q.y - 9, 18, 18);
|
||
} else if (elDrag && elDrag.snap) {
|
||
const sn = elDrag.snap, a = toPx(sn.x, sn.z), b = toPx(sn.x + sn.n.x * 200, sn.z + sn.n.z * 200);
|
||
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||
ctx.beginPath(); ctx.arc(b.x, b.y, 7, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.fillStyle = '#4da6ff'; ctx.textAlign = 'left';
|
||
ctx.fillText(`${sn.element} · ${sn.u} mm`, b.x + 10, b.y - 8);
|
||
ctx.textAlign = 'center';
|
||
}
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.translate(w - 34, 78);
|
||
ctx.fillStyle = '#4da6ff'; ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(0, -18); ctx.lineTo(7, 6); ctx.lineTo(0, 1); ctx.lineTo(-7, 6); ctx.closePath(); ctx.fill();
|
||
ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'center';
|
||
ctx.fillText('N', 0, 20);
|
||
ctx.restore();
|
||
|
||
if (tool === 'iw' && hover) {
|
||
const t = HouseModel.DEFAULT_INTERIOR_THICKNESS / 2;
|
||
if (drawStart) {
|
||
const alongX = Math.abs(hover.x - drawStart.x) >= Math.abs(hover.z - drawStart.z);
|
||
const pts = alongX
|
||
? [{ x: drawStart.x, z: drawStart.z - t }, { x: hover.x, z: drawStart.z - t }, { x: hover.x, z: drawStart.z + t }, { x: drawStart.x, z: drawStart.z + t }]
|
||
: [{ x: drawStart.x - t, z: drawStart.z }, { x: drawStart.x + t, z: drawStart.z }, { x: drawStart.x + t, z: hover.z }, { x: drawStart.x - t, z: hover.z }];
|
||
polyPath(pts);
|
||
ctx.fillStyle = 'rgba(77, 166, 255, 0.35)'; ctx.fill();
|
||
ctx.strokeStyle = '#4da6ff'; ctx.stroke();
|
||
const len = Math.round(alongX ? Math.abs(hover.x - drawStart.x) : Math.abs(hover.z - drawStart.z));
|
||
const hp = toPx(hover.x, hover.z);
|
||
ctx.fillStyle = '#fff'; ctx.font = '11px sans-serif'; ctx.textAlign = 'left';
|
||
ctx.fillText(len + ' mm', hp.x + 10, hp.y - 8);
|
||
}
|
||
const hp = toPx(hover.x, hover.z);
|
||
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(hp.x - 7, hp.y); ctx.lineTo(hp.x + 7, hp.y); ctx.moveTo(hp.x, hp.y - 7); ctx.lineTo(hp.x, hp.y + 7); ctx.stroke();
|
||
}
|
||
}
|
||
|
||
const { num, sel, toggle } = UI;
|
||
const { big, small, kv } = Inspector;
|
||
const after = () => scheduleRefresh({ keepProps: true });
|
||
const rerender = () => refresh();
|
||
let live = [];
|
||
function updateLive() { live.forEach(fn => fn()); }
|
||
const note = (text, isWarn) => `<div class="ins-note${isWarn ? ' ins-warn' : ''}">${text}</div>`;
|
||
const pair = (...items) => `<div class="ins-pair">${items.join('')}</div>`;
|
||
const thumb = (url, alt) => url ? `<img class="ins-thumb" src="${url}" alt="${alt}" title="${alt}">` : '';
|
||
const check = (id, label, checked) => `<label class="ins-check"><input type="checkbox" id="${id}"${checked ? ' checked' : ''}>${label}</label>`;
|
||
const SIDE_SHORT = { voor: 'Noord', achter: 'Zuid', links: 'West', rechts: 'Oost' };
|
||
const fmtDeg = (d) => String(Math.round(d * 10) / 10).replace('.', ',');
|
||
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
|
||
const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions);
|
||
const TRIM_OPTS = Object.fromEntries(Object.entries(WallModel.TRIM_COLORS).map(([k, c]) => [k, c.label]));
|
||
const NO_WOLF = Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf'));
|
||
const stucColors = Object.fromEntries(Object.entries(WallModel.STUC_COLORS).map(([k, c]) => [k, c.label]));
|
||
const felsColors = Object.fromEntries(Object.entries(WallModel.FELS.COLORS).map(([k, c]) => [k, c.label]));
|
||
const swatchOf = (hex) => `<div class="ins-swatch" style="background:${hex}"></div>`;
|
||
function wireButtons(body) {
|
||
body.querySelectorAll('[data-go]').forEach(b => b.addEventListener('click', () => showPart(b.dataset.go)));
|
||
body.querySelectorAll('[data-ho-act]').forEach(b => b.addEventListener('click', () => {
|
||
const a = b.dataset.hoAct;
|
||
if (a === 'add-seams') { HouseModel.ensureSeamWalls(state, b.dataset.unit); refresh(); }
|
||
else if (a === 'remove-unit') removeUnit(b.dataset.unit);
|
||
else if (a === 'remove-iw') removeInteriorWall(Number(b.dataset.iw));
|
||
}));
|
||
}
|
||
|
||
const gevelTab = {
|
||
key: 'gevel', label: 'Gevel', order: 20,
|
||
groups: () => {
|
||
const st = state.settings;
|
||
const rabatColors = Object.fromEntries(Object.entries(WallModel.RABAT_COLORS).map(([k, c]) => [k, c.label]));
|
||
const rabatUi = sel('hs-cladding', 'Richting', WallModel.CLADDING_DIRECTIONS, st.cladding, { hint: 'Rabat op vochtdoek en kruislat' }) +
|
||
pair(sel('hs-rabat-color', 'Kleur', rabatColors, st.facade.rabatColor, { hint: 'Ook voor rabat-overrides per wand' }),
|
||
`<div class="ins-swatch" style="background:${WallModel.rabatHex(st.facade.rabatColor)}"></div>`);
|
||
const uses = (type) => state.units.some(u => Object.keys(u.unit.walls).some(side => HouseModel.facadeFor(state, u, side).type === type ||
|
||
UnitModel.normalizeSplits(u.unit.walls[side].splits).some(sp => sp.facade && sp.facade.type === type)));
|
||
const usesRabat = uses('rabat');
|
||
const felsUi = pair(sel('hs-fels-color', 'Kleur', felsColors, st.facade.felsColor,
|
||
{ hint: `Staande naad, banen van ${WallModel.FELS.SEAM_MM} mm, op latten zoals verticale rabat; ook voor fels-overrides per wand` }),
|
||
swatchOf(WallModel.felsHex(st.facade.felsColor)));
|
||
return [
|
||
{ label: 'Bekleding', html: sel('hs-facade', 'Type', HouseModel.FACADE_TYPES, st.facade.type, { hint: 'Voor alle buitenwanden; per wand of wandstuk aan te passen (selecteer de wand)' }) +
|
||
(st.facade.type === 'steen'
|
||
? pair(sel('hs-facade-stone', 'Steen', stoneOptions, st.facade.stone, { hint: `Steenstrip op isolatieblok, samen ${WallModel.STONE.THICK_MM} mm dik` }), thumb(WallModel.stoneThumbUrl(st.facade.stone, 64), st.facade.stone))
|
||
: st.facade.type === 'stuc'
|
||
? pair(sel('hs-stuc-color', 'Kleur', stucColors, st.facade.stucColor, { hint: `Pleisterlaag ${WallModel.STUC.THICK_MM} mm op cement-gebonden plaat` }), swatchOf(WallModel.stucHex(st.facade.stucColor)))
|
||
: st.facade.type === 'fels' ? felsUi : rabatUi) },
|
||
st.facade.type !== 'rabat' && usesRabat ? { label: 'Rabat (overrides)', html: rabatUi } : null,
|
||
st.facade.type !== 'fels' && uses('fels') ? { label: 'Felsplaten (overrides)', html: felsUi } : null,
|
||
{ label: 'Trasraam', html: toggle('hs-trasraam', 'Trasraam', st.trasraam.enabled, { hint: 'Strook onderaan de gevel, onder rabat én steen' }) +
|
||
(st.trasraam.enabled
|
||
? UI.range('hs-trasraam-h', 'Hoogte', st.trasraam.height, { min: 100, max: 1000, step: 10, hint: 'Vanaf bovenkant vloer' }) +
|
||
pair(sel('hs-trasraam-finish', 'Vormgeving', trasraamFinishes, st.trasraam.finish),
|
||
WallModel.isStoneFinish(st.trasraam.finish) ? thumb(WallModel.stoneThumbUrl(st.trasraam.finish, 64), st.trasraam.finish) : '') +
|
||
(WallModel.isStoneFinish(st.trasraam.finish)
|
||
? pair(sel('hs-trasraam-cap', 'Afdekker', TRIM_OPTS, st.trasraam.capColor, { hint: 'Zetwerk op de steen waar de gevel erboven minder ver uitsteekt (rabat, felsplaat, stuc): 5° aflopend, neus 25 mm over de steen' }),
|
||
swatchOf(WallModel.trimHex(st.trasraam.capColor))) : '')
|
||
: '') },
|
||
{ label: 'Onderzijde', html: toggle('hs-sandwich', 'Sandwichpanelen', st.sandwich,
|
||
{ hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm rondom, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` }) +
|
||
UI.range('hs-ground', 'Maaiveld', st.groundLevel, { min: 0, max: 800, step: 10, hint: 'Onder de bovenkant vloer; tegelvloer in de 3D bij Dag/Nacht' }) },
|
||
{ label: 'Per wand', html: note('Een eigen gevel voor één zijde van een unit: selecteer die wand in de plattegrond.') }
|
||
];
|
||
},
|
||
wire: (body) => {
|
||
const st = state.settings;
|
||
UI.onSel('hs-facade', v => { st.facade.type = v; }, rerender);
|
||
UI.onSel('hs-cladding', v => { st.cladding = v; }, rerender);
|
||
UI.onSel('hs-rabat-color', v => { st.facade.rabatColor = v; }, rerender);
|
||
UI.onSel('hs-facade-stone', v => { st.facade.stone = v; }, rerender);
|
||
UI.onSel('hs-stuc-color', v => { st.facade.stucColor = v; }, rerender);
|
||
UI.onSel('hs-fels-color', v => { st.facade.felsColor = v; }, rerender);
|
||
UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender);
|
||
UI.onRange('hs-trasraam-h', v => { if (v > 0) st.trasraam.height = v; }, after);
|
||
UI.onSel('hs-trasraam-finish', v => { st.trasraam.finish = v; }, rerender);
|
||
UI.onSel('hs-trasraam-cap', v => { st.trasraam.capColor = v; }, rerender);
|
||
UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender);
|
||
UI.onRange('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after);
|
||
}
|
||
};
|
||
|
||
let houseRoofUi = null;
|
||
const dakTab = {
|
||
key: 'dak', label: 'Dak', order: 30,
|
||
groups: () => {
|
||
const r = state.settings.roof, hr = r.house;
|
||
const wolfOk = HouseModel.houseRoofFits(state) && !HouseModel.hasCover(state);
|
||
houseRoofUi = hr.enabled && hr.form ? roofFormUi('hs', hr.form, {
|
||
types: wolfOk || hr.form.type === 'wolf' ? null : NO_WOLF,
|
||
notice: !wolfOk && hr.form.type === 'wolf' ? 'Wolfsdak alleen als de units samen een rechthoek vormen; bij deze L-/T-vorm wordt het een zadeldak.' : null,
|
||
span: () => { const sh = HouseModel.houseRoofShape(state); return sh ? sh.span : 1000; },
|
||
apply: () => HouseModel.applyGlobalSettings(state),
|
||
readBack: () => hr.form
|
||
}) : null;
|
||
const shapeType = hr.enabled && hr.form ? (HouseModel.houseRoofShape(state) || {}).type : null;
|
||
const covOpts = Object.fromEntries(Object.entries(RoofModel.COVERINGS).filter(([, c]) => !c.sloped || shapeType !== 'plat').map(([k, c]) => [k, c.label]));
|
||
const groups = [
|
||
{ label: 'Dak', html:
|
||
toggle('hs-roof-house', 'Over hele woning', hr.enabled, { hint: 'Eén dakvorm over alle units (bijv. een grote wolfskap); uit = dak per unit. De daken per unit blijven bewaard.' }) +
|
||
|
||
(houseRoofUi ? sel('hs-roof-cover', 'Bedekking', covOpts, RoofModel.coveringFor(state.units[0].roof.covering, shapeType), { hint: 'Voor alle units (per unit aan te passen)' }) : '') +
|
||
toggle('hs-steel-ridge', 'Stalen nokbalk', state.units.some(u => u.roof.steelRidge !== false), { hint: 'IPE onder de nok van zadel- en wolfsdaken en van een dubbel lessenaardak (gelijke goot en nok, samen een zadel); alle units; uit = verwijderen' }) }
|
||
];
|
||
if (houseRoofUi) groups.push(...houseRoofUi.groups());
|
||
else groups.push({ label: 'Dakvorm', html: note('Vorm, hoogtes, helling en bedekking zijn per unit: selecteer een unit.') });
|
||
groups.push(
|
||
{ label: 'Boeideel', html:
|
||
UI.range('hs-overhang', 'Overstek', r.overhang, { min: RoofModel.OVERHANG_MIN, max: RoofModel.OVERHANG_MAX, step: 10, hint: 'Vanaf de gevel' }) +
|
||
UI.range('hs-fascia-h', 'Hoogte', r.fasciaHeight, { min: 150, max: 500, step: 10 }) +
|
||
sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) +
|
||
toggle('hs-vent', 'Ventilatierooster', r.vent !== false, { hint: `${RoofModel.VENT.HEIGHT_MM} mm hoog op de gevel, direct onder alle boeidelen` }) +
|
||
(r.vent !== false ? pair(sel('hs-vent-color', 'Kleur rooster', TRIM_OPTS, r.ventColor || 'antraciet'), swatchOf(WallModel.trimHex(r.ventColor))) : '') },
|
||
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Over de gevel; steekt 30 mm boven het dak uit (units zonder boeidelen)' }) },
|
||
{ label: 'Palen overkapping', html:
|
||
toggle('hs-posts', 'Palen', r.posts.enabled, { hint: `Onder een dak dat minstens de min. uitkraging buiten de vloer steekt (veranda); per unit: Overkapping. Nu ${parts ? parts.posts.count : 0} st.` }) +
|
||
(r.posts.enabled ? toggle('hs-posts-side', 'Zijwand', r.posts.sideWall, { hint: `Vrije hoek dicht met een gestuukte wand (${PostModel.SIDE_MM} mm) terug tot de gevel, in plaats van een hoekpaal` }) +
|
||
(r.posts.sideWall ? pair(sel('hs-posts-side-color', 'Kleur zijwand', stucColors, r.posts.sideColor), swatchOf(WallModel.stucHex(r.posts.sideColor))) : '') +
|
||
sel('hs-posts-wood', 'Hout', Object.fromEntries(Object.entries(PostModel.WOODS).map(([k, w]) => [k, w.label])), r.posts.wood) +
|
||
UI.range('hs-posts-size', 'Maat', r.posts.size, { min: 100, max: 300, step: 10, hint: 'Vierkant, mm' }) +
|
||
num('hs-posts-span', 'Max. h.o.h.', r.posts.maxSpan, { step: 100, hint: 'Grootste afstand tussen palen (of paal en gevel)' }) +
|
||
num('hs-posts-min', 'Min. uitkraging', r.posts.minExt, { step: 100, hint: 'Kleinere overstekken krijgen geen palen' }) : '') },
|
||
{ label: 'Daken per unit', html: `<div data-live="roof-list" class="ins-cols"></div>` });
|
||
return groups;
|
||
},
|
||
wire: (body) => {
|
||
const r = state.settings.roof, pos = { positive: true };
|
||
UI.onToggle('hs-roof-house', v => { HouseModel.setHouseRoof(state, v); }, rerender);
|
||
UI.onSel('hs-roof-cover', v => { state.units.forEach(u => { u.roof.covering = v; }); }, rerender);
|
||
UI.onToggle('hs-steel-ridge', v => { state.units.forEach(u => { u.roof.steelRidge = v; }); }, rerender);
|
||
if (houseRoofUi) houseRoofUi.wire(body);
|
||
UI.onRange('hs-overhang', v => { if (v > 0) r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after);
|
||
UI.onRange('hs-fascia-h', v => { if (v > 0) r.fasciaHeight = v; }, after);
|
||
UI.onSel('hs-fascia-color', v => { r.fasciaColor = v; }, rerender);
|
||
UI.onToggle('hs-vent', v => { r.vent = v; }, rerender);
|
||
UI.onSel('hs-vent-color', v => { r.ventColor = v; }, rerender);
|
||
UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos);
|
||
UI.onToggle('hs-posts', v => { r.posts.enabled = v; }, rerender);
|
||
UI.onToggle('hs-posts-side', v => { r.posts.sideWall = v; }, rerender);
|
||
UI.onSel('hs-posts-side-color', v => { r.posts.sideColor = v; }, rerender);
|
||
UI.onSel('hs-posts-wood', v => { r.posts.wood = v; }, rerender);
|
||
UI.onRange('hs-posts-size', v => { if (v > 0) r.posts.size = v; }, after);
|
||
UI.onNum('hs-posts-span', v => { if (v >= 1000) r.posts.maxSpan = v; }, after, pos);
|
||
UI.onNum('hs-posts-min', v => { if (v >= 400) r.posts.minExt = v; }, after, pos);
|
||
const list = () => {
|
||
const el = body.querySelector('[data-live="roof-list"]');
|
||
if (!el || !parts) return;
|
||
el.innerHTML = parts.units.map(u => {
|
||
const pl = u.roof.planes[0], cap = u.roof.unitRoof.cap;
|
||
const deg = (pl ? Math.abs(pl.theta) : cap ? Math.atan(cap.slope) : 0) * 180 / Math.PI;
|
||
const cov = (RoofModel.COVERINGS[u.roof.covering] || {}).label || '';
|
||
return kv(`Unit ${u.id}`, `${(UnitModel.ROOF_TYPES[u.roof.shape.type] || u.roof.shape.type).toLowerCase()}${deg > 0.05 ? ' ' + fmtDeg(deg) + '°' : ''} · ${cov.toLowerCase()}`);
|
||
}).join('');
|
||
};
|
||
live.push(list);
|
||
list();
|
||
}
|
||
};
|
||
|
||
const binnenTab = {
|
||
key: 'binnen', label: 'Binnen', order: 40,
|
||
groups: () => {
|
||
const st = state.settings;
|
||
return [
|
||
{ label: 'Wanden', html: toggle('hs-int-paint', 'Geschilderd', st.interiorPaint, { hint: 'Alle wanden aan de kamerzijde, de dagkanten en het plafond; egaal, zonder plaatnaden. Uit: kale spaanplaat en OSB.' }) +
|
||
(st.interiorPaint ? UI.color('hs-int-color', 'Verfkleur', st.interiorColor, { hint: 'Voorlopig één kleur voor de hele woning; ook de dagkanten rond ramen en deuren' }) +
|
||
UI.color('hs-ceil-color', 'Plafond', st.ceilingColor, { hint: 'Onderkant dak (plafondplaat), afgewerkte fase' }) : '') },
|
||
{ label: 'Vloer', html: pair(sel('hs-floor-finish', 'Afwerking', Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, fi]) => [k, fi.label])), st.floorFinish),
|
||
thumb(FloorModel.finishThumbUrl(st.floorFinish, 64), st.floorFinish)) },
|
||
{ label: 'Ruimtes', html: `<div data-live="room-list" class="ins-cols"></div>` }
|
||
];
|
||
},
|
||
wire: (body) => {
|
||
const list = () => {
|
||
const el = body.querySelector('[data-live="room-list"]');
|
||
if (!el || !parts) return;
|
||
const R = parts.rooms;
|
||
el.innerHTML = R.rooms.map(r => kv(UI.esc(r.name), houseFmt(r.areaM2, 2), 'm²')).join('') +
|
||
(R.free.length ? kv('Zonder functie', `${R.free.length} × · ${houseFmt(R.free.reduce((a, r) => a + r.areaM2, 0), 2)}`, 'm²') : '') ||
|
||
note('Nog geen ruimtes: teken binnenwanden of vul een unit met de verfemmer.');
|
||
};
|
||
live.push(list);
|
||
list();
|
||
const st = state.settings;
|
||
UI.onToggle('hs-int-paint', v => { st.interiorPaint = v; }, rerender);
|
||
UI.onColor('hs-int-color', v => { st.interiorColor = v; }, after);
|
||
UI.onColor('hs-ceil-color', v => { st.ceilingColor = v; }, after);
|
||
UI.onSel('hs-floor-finish', v => { st.floorFinish = v; }, rerender);
|
||
}
|
||
};
|
||
|
||
const constructieTab = {
|
||
key: 'constructie', label: 'Constructie', order: 45,
|
||
groups: () => {
|
||
const sp = state.settings.spacing;
|
||
const bad = sp.exterior.max < 2 * sp.exterior.min || sp.interior.max < 2 * sp.interior.min;
|
||
return [
|
||
{ label: 'Stijlen buitenwanden', html: num('hs-ext-min', 'Afstand min.', sp.exterior.min, { step: 10, hint: 'Hart-op-hart' }) + num('hs-ext-max', 'Afstand max.', sp.exterior.max, { step: 10, hint: 'Hart-op-hart' }) },
|
||
{ label: 'Stijlen binnenwanden', html: num('hs-int-min', 'Afstand min.', sp.interior.min, { step: 10, hint: 'Hart-op-hart' }) + num('hs-int-max', 'Afstand max.', sp.interior.max, { step: 10, hint: 'Hart-op-hart' }) },
|
||
{ label: 'Verdeling', html: note(bad
|
||
? 'Max. is kleiner dan 2 × min.: niet elk vak is dan te verdelen; daar wijkt de verdeling af van min. of max.'
|
||
: 'Elk vak wordt gelijk verdeeld met een h.o.h. tussen min. en max. (max. ≥ 2 × min.: altijd mogelijk).', bad) },
|
||
{ label: 'Balken', html: kv('Wandbalk', `${WallModel.STANDARD_BEAM.width} × ${WallModel.STANDARD_BEAM.depth}`, 'mm') +
|
||
kv('Handelslengtes', WallModel.STOCK_LENGTHS_MM.map(l => l / 1000).join(' · ').replace(/\./g, ','), 'm') +
|
||
kv('Latwerk', `${WallModel.BATTEN.thick} × ${WallModel.BATTEN.width}, ${WallModel.BATTEN.spacing} h.o.h.`, 'mm') }
|
||
];
|
||
},
|
||
wire: () => {
|
||
const sp = state.settings.spacing, pos = { positive: true };
|
||
UI.onNum('hs-ext-min', v => { sp.exterior.min = v; }, after, pos);
|
||
UI.onNum('hs-ext-max', v => { sp.exterior.max = v; }, after, pos);
|
||
UI.onNum('hs-int-min', v => { sp.interior.min = v; }, after, pos);
|
||
UI.onNum('hs-int-max', v => { sp.interior.max = v; }, after, pos);
|
||
}
|
||
};
|
||
|
||
const lichtTab = {
|
||
key: 'licht', label: 'Verlichting', order: 50,
|
||
groups: () => {
|
||
const r = state.settings.roof, lt = state.settings.lighting;
|
||
const groups = [{ label: 'Buiten', html: sel('hs-light-mode', 'Type', LightingModel.MODES, lt.mode) +
|
||
(lt.mode === 'gevel' ? sel('hs-light-dir', 'Richting', LightingModel.DIRECTIONS, lt.direction) : '') }];
|
||
if (lt.mode === 'gevel') {
|
||
groups.push({ label: 'Gevellampen', html: UI.range('hs-lamp-h', 'Hoogte', lt.lampHeight, { min: 1200, max: 3000, step: 50, hint: 'Vanaf bovenkant vloer' }) +
|
||
num('hs-lamp-min', 'Afstand min.', lt.lampMin, { hint: 'Hart-op-hart' }) + num('hs-lamp-max', 'Afstand max.', lt.lampMax, { hint: 'Hart-op-hart' }) });
|
||
groups.push({ label: 'Plaatsing', html: num('hs-lamp-w', 'Min. gevel', lt.minWallWidth, { hint: 'Smallere vrije stukken gevel krijgen geen lamp' }) +
|
||
num('hs-lamp-end', 'Tot hoek', lt.endOffset) + num('hs-lamp-open', 'Tot opening', lt.openingOffset, { hint: 'Voor wanden met de instelling "Naast openingen" (selecteer de wand)' }) });
|
||
} else if (lt.mode === 'spots') {
|
||
groups.push({ label: 'Boeideel-spots', html: num('hs-spot-min', 'Afstand min.', r.spotMin, { hint: 'Hart-op-hart' }) + num('hs-spot-max', 'Afstand max.', r.spotMax, { hint: 'Hart-op-hart' }) +
|
||
note('Alleen onder units met boeidelen.') });
|
||
}
|
||
groups.push({ label: 'Binnenspots', html: toggle('hs-indoor', 'In plafond', lt.indoor, { hint: 'Per unit verdeeld in het plafond' }) +
|
||
(lt.indoor ? num('hs-indoor-min', 'Afstand min.', lt.indoorMin, { hint: 'Hart-op-hart, in beide richtingen' }) +
|
||
num('hs-indoor-max', 'Afstand max.', lt.indoorMax, { hint: 'Hart-op-hart, in beide richtingen' }) +
|
||
num('hs-indoor-off', 'Tot wand', lt.indoorOffset, { hint: 'Tot de binnenzijde van een wand, of tot de unitrand bij een naad' }) : '') });
|
||
groups.push({ label: 'Aantallen', html: `<div data-live="light-count" class="ins-cols"></div>` });
|
||
return groups;
|
||
},
|
||
wire: (body) => {
|
||
const r = state.settings.roof, lt = state.settings.lighting, pos = { positive: true };
|
||
UI.onSel('hs-light-mode', v => { lt.mode = v; }, rerender);
|
||
UI.onSel('hs-light-dir', v => { lt.direction = v; }, rerender);
|
||
UI.onRange('hs-lamp-h', v => { if (v > 0) lt.lampHeight = v; }, after);
|
||
UI.onNum('hs-lamp-min', v => { lt.lampMin = v; }, after, pos);
|
||
UI.onNum('hs-lamp-max', v => { lt.lampMax = v; }, after, pos);
|
||
UI.onNum('hs-lamp-w', v => { lt.minWallWidth = v; }, after, pos);
|
||
UI.onNum('hs-lamp-end', v => { lt.endOffset = v; }, after, pos);
|
||
UI.onNum('hs-lamp-open', v => { lt.openingOffset = v; }, after, pos);
|
||
UI.onNum('hs-spot-min', v => { r.spotMin = v; }, after, pos);
|
||
UI.onNum('hs-spot-max', v => { r.spotMax = v; }, after, pos);
|
||
UI.onToggle('hs-indoor', v => { lt.indoor = v; }, rerender);
|
||
UI.onNum('hs-indoor-min', v => { lt.indoorMin = v; }, after, pos);
|
||
UI.onNum('hs-indoor-max', v => { lt.indoorMax = v; }, after, pos);
|
||
UI.onNum('hs-indoor-off', v => { lt.indoorOffset = v; }, after);
|
||
const count = () => {
|
||
const el = body.querySelector('[data-live="light-count"]');
|
||
if (!el || !parts) return;
|
||
el.innerHTML = kv('Gevellampen', parts.lighting.lamps.length, 'st.') +
|
||
kv('Boeideel-spots', parts.roofEdge ? parts.roofEdge.spots.length : 0, 'st.') + kv('Binnenspots', (parts.lighting.indoor || []).length, 'st.');
|
||
};
|
||
live.push(count);
|
||
count();
|
||
}
|
||
};
|
||
|
||
const elektraTab = {
|
||
key: 'elektra', label: 'Elektra', order: 55,
|
||
groups: () => {
|
||
const e = state.settings.elektra;
|
||
const groups = [{ label: 'Elektra', html: toggle('he-on', 'Installatie', e.enabled, { hint: 'Meterkast, schakelaars, wcd en leidingen (automatisch, eerste versie)' }) }];
|
||
if (e.enabled) {
|
||
groups.push({ label: 'Meterkast', html: num('he-mk-w', 'Breedte', e.meterkast.w, { step: 10 }) + num('he-mk-h', 'Hoogte', e.meterkast.h, { step: 10 }) +
|
||
num('he-mk-d', 'Diepte', e.meterkast.d, { step: 10 }) + num('he-mk-y', 'Onderkant', e.meterkast.y, { step: 10, hint: 'Vanaf bovenkant vloer; zo dicht mogelijk bij de voordeur' }) });
|
||
groups.push({ label: 'Punten', html: num('he-sw-h', 'Schakelaar', e.switchHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) +
|
||
num('he-wcd-h', 'Wcd', e.wcdHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) + num('he-wcd-n', 'Wcd per unit', e.wcdPerUnit, { step: 1, unit: 'st.', hint: 'Dubbele wcd' }) +
|
||
sel('he-plate', 'Kleur', Object.fromEntries(Object.entries(ElektraModel.PLATE_COLORS).map(([k, c]) => [k, c.label])), e.plateColor || 'wit', { hint: 'Schakelaars, wcd\'s en vloerdozen' }) });
|
||
groups.push({ label: 'Apparaten', html: Object.entries(ElektraModel.APPLIANCES).map(([k, A]) =>
|
||
check(`he-app-${k}`, `${A.label} (${A.code})`, !!e.apparaten[k])).join('') });
|
||
const nManual = Object.keys(e.place).length;
|
||
const nExtra = Object.values(e.wcdExtra || {}).reduce((t, n) => t + n, 0);
|
||
groups.push({ label: 'Toevoegen', html: big('Wcd', 'plus-square', 'data-he-add-wcd title="Dubbele wandcontactdoos in de geselecteerde (of eerste) unit; daarna slepen naar de plek"') +
|
||
big('Vloer-wcd', 'plus-square', 'data-he-add-vw title="Vloerdoos met dubbele wcd in de geselecteerde (of eerste) unit"') +
|
||
note(`${nExtra} losse wcd('s), ${e.vloerWcd.length} vloer-wcd('s); leiding vloer-wcd door de vloer.`) });
|
||
const nRemoved = (e.removed || []).length;
|
||
groups.push({ label: 'Plaatsing', html: note('Sleep meterkast, schakelaars, wcd en apparaten in de plattegrond; selecteer er een voor positie, hoogte of verwijderen (Del).') +
|
||
(nManual ? big('Alles automatisch', 'arrow-counterclockwise', `data-he-reset title="${nManual} handmatige plek(ken) wissen"`) : '') +
|
||
(nRemoved ? big('Verwijderde terug', 'arrow-counterclockwise', `data-he-unremove title="${nRemoved} verwijderde wcd/schakelaar(s) terugzetten"`) : '') });
|
||
groups.push({ label: 'Leidingen', html: num('he-slack', 'Marge', e.slack, { step: 1, unit: '%', hint: 'Extra lengte op elke leiding' }) + num('he-res', 'Reserve', e.reserve, { step: 50, hint: 'Per leiding (lussen in de dozen), mm' }) +
|
||
num('he-mk-res', 'Extra MK', e.meterkastReserve, { step: 50, hint: 'Per leiding vanuit de meterkast, mm' }) });
|
||
groups.push({ label: 'Strekkende meters', html: `<div data-live="el-cables" class="ins-cols"></div>` });
|
||
groups.push({ label: 'Groepen', html: `<div data-live="el-groups" class="ins-cols"></div>` });
|
||
}
|
||
return groups;
|
||
},
|
||
wire: (body) => {
|
||
const e = state.settings.elektra, pos = { positive: true };
|
||
UI.onToggle('he-on', v => { e.enabled = v; }, rerender);
|
||
UI.onNum('he-mk-w', v => { e.meterkast.w = v; }, after, pos);
|
||
UI.onNum('he-mk-h', v => { e.meterkast.h = v; }, after, pos);
|
||
UI.onNum('he-mk-d', v => { e.meterkast.d = v; }, after, pos);
|
||
UI.onNum('he-mk-y', v => { e.meterkast.y = v; }, after);
|
||
UI.onNum('he-sw-h', v => { e.switchHeight = v; }, after, pos);
|
||
UI.onNum('he-wcd-h', v => { e.wcdHeight = v; }, after, pos);
|
||
UI.onNum('he-wcd-n', v => { e.wcdPerUnit = Math.max(0, Math.round(v)); }, after);
|
||
UI.onSel('he-plate', v => { e.plateColor = v; }, rerender);
|
||
UI.onNum('he-slack', v => { e.slack = v; }, after);
|
||
UI.onNum('he-res', v => { e.reserve = v; }, after);
|
||
UI.onNum('he-mk-res', v => { e.meterkastReserve = v; }, after);
|
||
Object.keys(ElektraModel.APPLIANCES).forEach(k => {
|
||
const cb = document.getElementById(`he-app-${k}`);
|
||
if (cb) cb.addEventListener('change', () => { e.apparaten[k] = cb.checked; refresh(); });
|
||
});
|
||
const av = body.querySelector('[data-he-add-vw]');
|
||
if (av) av.addEventListener('click', () => {
|
||
const uid = selection && selection.type === 'unit' ? selection.id : state.units[0].id;
|
||
const rc = parts && parts.rects.find(q => q.id === uid);
|
||
const id = e.vloerWcd.reduce((m, o) => Math.max(m, o.id || 0), 0) + 1;
|
||
e.vloerWcd.push({ id, unitId: uid, x: rc ? Math.round(rc.W / 2) : 1000, z: rc ? Math.round(rc.D / 2) : 1000 });
|
||
selection = { type: 'elektra', id: 'vwcd:' + id };
|
||
refresh();
|
||
});
|
||
const aw = body.querySelector('[data-he-add-wcd]');
|
||
if (aw) aw.addEventListener('click', () => {
|
||
const uid = selection && selection.type === 'unit' ? selection.id : state.units[0].id;
|
||
e.wcdExtra = e.wcdExtra || {};
|
||
e.wcdExtra[uid] = (e.wcdExtra[uid] || 0) + 1;
|
||
selection = { type: 'elektra', id: `wcd:${uid}:${e.wcdPerUnit + e.wcdExtra[uid]}` };
|
||
refresh();
|
||
});
|
||
const rs = body.querySelector('[data-he-reset]');
|
||
if (rs) rs.addEventListener('click', () => { e.place = {}; refresh(); });
|
||
const ur = body.querySelector('[data-he-unremove]');
|
||
if (ur) ur.addEventListener('click', () => { e.removed = []; refresh(); });
|
||
const show = () => {
|
||
const el = parts && parts.elektra;
|
||
const c = body.querySelector('[data-live="el-cables"]'), g = body.querySelector('[data-live="el-groups"]');
|
||
if (!el) return;
|
||
if (c) c.innerHTML = el.cables.map(x => kv(x.use, houseFmt(x.meters, 1), 'm')).join('') || note('Geen leidingen.');
|
||
if (g) g.innerHTML = el.groups.map(x => kv(`${x.nr} ${x.phase} AL${x.rcd}`, `${x.label} (${x.breaker})`)).join('') + el.warnings.map(w => note(w, true)).join('');
|
||
};
|
||
live.push(show);
|
||
show();
|
||
}
|
||
};
|
||
|
||
function contextTab() {
|
||
if (!selection) return null;
|
||
if (selection.type === 'unit' && unitById(selection.id)) return unitTab(unitById(selection.id));
|
||
if (selection.type === 'wall') return wallTab();
|
||
if (selection.type === 'iw' && iwById(selection.id)) return iwById(selection.id).seam ? seamTab(iwById(selection.id)) : iwTab(iwById(selection.id));
|
||
if (selection.type === 'elektra' && elektraItem(selection.id)) return elektraItemTab(elektraItem(selection.id));
|
||
if (selection.type === 'room' && state.rooms.find(r => r.id === selection.id)) return roomTab(state.rooms.find(r => r.id === selection.id));
|
||
if (selection.type === 'region') return regionTab();
|
||
if (selection.type === 'fixture' && fixtureState(selection.id)) return fixtureTab(fixtureState(selection.id));
|
||
return null;
|
||
}
|
||
|
||
function fixtureTypeOpts() {
|
||
const out = {};
|
||
Object.keys(FixtureModel.CATS).forEach(cat => Object.entries(FixtureModel.TYPES).filter(([, T]) => T.cat === cat)
|
||
.forEach(([k, T]) => { out[k] = `${FixtureModel.CATS[cat]}: ${T.label}`; }));
|
||
return out;
|
||
}
|
||
const FX_OPT_UI = {
|
||
front: { label: 'Front', kind: 'sel', options: () => Object.fromEntries(Object.entries(FixtureModel.FRONTS).map(([k, f]) => [k, f.label])) },
|
||
spoel: { label: 'Spoelbak', kind: 'toggle' },
|
||
spoelbak: { label: 'Plek spoelbak', kind: 'range', min: 0, max: 1, step: 0.01, unit: '', hint: '0 = links, 1 = rechts (van voren gezien)' },
|
||
hogeKast: { label: 'Hoge kast', kind: 'sel', options: () => ({ geen: 'Geen', links: 'Links', rechts: 'Rechts' }), hint: 'Inbouwkoelkast + oven, 600 mm' },
|
||
vaatwasser: { label: 'Vaatwasser', kind: 'toggle' },
|
||
bovenkasten: { label: 'Bovenkasten', kind: 'toggle' },
|
||
kookplaat: { label: 'Kookplaat', kind: 'toggle', hint: 'Inductie, 4 zones' },
|
||
kookplaatPlek: { label: 'Plek kookplaat', kind: 'range', min: 0, max: 1, step: 0.01, unit: '', hint: '0 = links, 1 = rechts (van voren gezien)' },
|
||
glas: { label: 'Glaswand', kind: 'sel', options: () => ({ links: 'Links', rechts: 'Rechts', geen: 'Geen' }), hint: 'Van voren gezien' },
|
||
glasLengte: { label: 'Lengte glas', kind: 'num', step: 50 },
|
||
spiegel: { label: 'Spiegel', kind: 'toggle' },
|
||
nachtkastjes: { label: 'Nachtkastjes', kind: 'toggle' },
|
||
vorm: { label: 'Vorm', kind: 'sel', options: (t) => t === 'eettafel' ? { ovaal: 'Ovaal', rechthoek: 'Rechthoekig' } : { rond: 'Rond', rechthoek: 'Rechthoekig' } },
|
||
stoelen: { label: 'Stoelen', kind: 'num', step: 1, unit: 'st.' },
|
||
chaise: { label: 'Chaise longue', kind: 'sel', options: () => ({ geen: 'Geen', links: 'Links', rechts: 'Rechts' }), hint: 'Van voren gezien' },
|
||
chaiseLengte: { label: 'Lengte chaise', kind: 'num', step: 50 },
|
||
tv: { label: 'Tv aan de wand', kind: 'toggle' },
|
||
liter: { label: 'Inhoud', kind: 'num', step: 10, unit: 'l' }
|
||
};
|
||
const ROT_LABELS = { 0: 'Rug noord', 90: 'Rug oost', 180: 'Rug zuid', 270: 'Rug west' };
|
||
function fixtureTab(fs) {
|
||
const T = FixtureModel.TYPES[fs.type];
|
||
const item = () => parts.fixtures.find(x => x.id === fs.id);
|
||
return {
|
||
key: 'sel', label: T.label, icon: 'lamp', contextual: true, order: 100,
|
||
groups: () => {
|
||
const it = item();
|
||
const rotOpts = Object.assign({}, ROT_LABELS);
|
||
if (!rotOpts[fs.rot]) rotOpts[fs.rot] = `${fs.rot}°`;
|
||
const dim = (k, label, hint) => T.resize.includes(k) ? num('hf-' + k, label, fs[k], { step: 10, hint }) : kv(label, fs[k], 'mm');
|
||
const optKeys = Object.keys(T.opts || {}).filter(k => FX_OPT_UI[k]);
|
||
const optHtml = optKeys.map(k => {
|
||
const o = FX_OPT_UI[k], id = 'hfo-' + k, v = fs.opts[k];
|
||
if (o.kind === 'sel') return sel(id, o.label, o.options(fs.type), String(v), { hint: o.hint });
|
||
if (o.kind === 'toggle') return toggle(id, o.label, !!v, { hint: o.hint });
|
||
if (o.kind === 'range') return UI.range(id, o.label, v, { min: o.min, max: o.max, step: o.step, unit: o.unit, hint: o.hint });
|
||
return num(id, o.label, v, { step: o.step, unit: o.unit, hint: o.hint });
|
||
}).join('');
|
||
const groups = [
|
||
{ label: 'Stuk', html: sel('hf-type', 'Type', fixtureTypeOpts(), fs.type, { hint: T.hint || 'Ander type: zelfde plek, standaardmaat van dat type' }) + kv('Unit', fs.unitId) +
|
||
sel('hf-anchor', 'Gemaat vanaf', { west: 'Westgevel', oost: 'Oostgevel' }, fs.anchor || 'west', { hint: 'Wordt de unit langer (west–oost), dan blijft het stuk op zijn afstand tot deze gevel' }) },
|
||
{ label: 'Maten', html: dim('w', 'Breedte', 'Langs de rug') + dim('d', 'Diepte', 'Van rug tot voorkant') + dim('h', 'Hoogte') },
|
||
{ label: 'Plaats', html: num('hf-x', 'X (midden)', it ? Math.round(it.cx) : 0, { step: 10, hint: 'Woning-mm, west → oost' }) +
|
||
num('hf-z', 'Y (midden)', it ? Math.round(it.cz) : 0, { step: 10, hint: 'Woning-mm, noord → zuid' }) +
|
||
sel('hf-rot', 'Draaiing', rotOpts, String(fs.rot), { hint: 'Waar de rug naartoe wijst' }) }
|
||
];
|
||
if (optHtml) groups.push({ label: 'Opties', html: optHtml });
|
||
groups.push({ label: 'Bewerken', html: small('Draaien 90°', 'arrow-clockwise', 'data-hf="rot" title="D (Shift+D = andersom)"') +
|
||
small('Draaien 15°', 'arrow-clockwise', 'data-hf="rot15"') + small('Dupliceren', 'files', 'data-hf="dup"') +
|
||
small('Verwijderen', 'trash', 'data-hf="del" title="Delete"', 'ins-danger') +
|
||
note('Sleep het stuk in de plattegrond; bij een wand snapt de rug op het wandvlak, Alt = vrij.') });
|
||
return groups;
|
||
},
|
||
wire: (body) => {
|
||
const place = (x, z) => { placeFixture(fs, x, z); };
|
||
UI.onSel('hf-type', v => {
|
||
const N = FixtureModel.TYPES[v];
|
||
if (!N) return;
|
||
const it = item();
|
||
Object.assign(fs, { type: v, w: N.w, d: N.d, h: N.h, opts: Object.assign({}, N.opts || {}) });
|
||
if (it) place(it.cx, it.cz);
|
||
}, rerender);
|
||
['w', 'd', 'h'].forEach(k => UI.onNum('hf-' + k, v => { if (v >= 50) fs[k] = Math.round(v); }, after));
|
||
UI.onNum('hf-x', v => { const it = item(); if (it) place(v, it.cz); }, after);
|
||
UI.onNum('hf-z', v => { const it = item(); if (it) place(it.cx, v); }, after);
|
||
UI.onSel('hf-rot', v => { fs.rot = Number(v) || 0; }, after);
|
||
UI.onSel('hf-anchor', v => { const it = item(); fs.anchor = v; if (it) place(it.cx, it.cz); }, after);
|
||
Object.keys(T.opts || {}).filter(k => FX_OPT_UI[k]).forEach(k => {
|
||
const o = FX_OPT_UI[k], id = 'hfo-' + k;
|
||
if (o.kind === 'sel') UI.onSel(id, v => { fs.opts[k] = v; }, after);
|
||
else if (o.kind === 'toggle') UI.onToggle(id, v => { fs.opts[k] = v; }, after);
|
||
else if (o.kind === 'range') UI.onRange(id, v => { if (isFinite(v)) fs.opts[k] = v; }, after);
|
||
else UI.onNum(id, v => { if (v >= 0) fs.opts[k] = v; }, after);
|
||
});
|
||
body.querySelectorAll('[data-hf]').forEach(b => b.addEventListener('click', () => {
|
||
const a = b.dataset.hf;
|
||
if (a === 'rot') rotateFixture(fs.id, 90);
|
||
else if (a === 'rot15') rotateFixture(fs.id, 15);
|
||
else if (a === 'dup') duplicateFixture(fs.id);
|
||
else if (a === 'del') removeFixture(fs.id);
|
||
}));
|
||
}
|
||
};
|
||
}
|
||
|
||
const roomTypeOpts = Object.fromEntries(Object.entries(RoomModel.TYPES).map(([k, t]) => [k, t.label]));
|
||
// Opties van het actieve gereedschap, rechts naast het gereedschap in de werkbalk.
|
||
function renderToolOptions() {
|
||
const el = document.getElementById('ho-tool-options');
|
||
if (!el) return;
|
||
const opt = (id, label, opts, val) => `<label class="tb-opt"><span>${label}</span><select id="${id}">${Object.entries(opts).map(([k, v]) =>
|
||
`<option value="${k}"${k === val ? ' selected' : ''}>${v}</option>`).join('')}</select></label>`;
|
||
el.innerHTML = tool === 'room' ? opt('hr-fill-type', 'Functie', roomTypeOpts, fillType)
|
||
: tool === 'fixture' ? opt('hf-new-type', 'Stuk', fixtureTypeOpts(), fixtureType) : '';
|
||
UI.onSel('hr-fill-type', v => { fillType = v; updateHint(); document.activeElement.blur(); });
|
||
UI.onSel('hf-new-type', v => { fixtureType = v; updateHint(); document.activeElement.blur(); });
|
||
}
|
||
const swatch = (hex) => `<div class="ins-swatch" style="background:${hex}"></div>`;
|
||
|
||
function roomTab(rs) {
|
||
const pr = () => parts.rooms.rooms.find(r => r.id === rs.id);
|
||
const lost = parts.rooms.lost.find(l => l.id === rs.id);
|
||
const r0 = pr();
|
||
return {
|
||
key: 'sel', label: r0 ? r0.name : `Ruimte (${RoomModel.TYPES[rs.type].label})`, icon: 'bounding-box', contextual: true, order: 100,
|
||
groups: () => {
|
||
const floorOpts = Object.assign({ woning: `Woning (${((FloorModel.FINISHES[state.settings.floorFinish] || {}).label || '').toLowerCase()})` },
|
||
Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, fi]) => [k, fi.label])));
|
||
const fk = rs.floor || state.settings.floorFinish, ff = FloorModel.FINISHES[fk] || FloorModel.FINISHES.geen;
|
||
const fiboOpts = Object.assign({ geen: 'Geen' }, Object.fromEntries(Object.entries(RoomModel.FIBO).map(([k, f]) => [k, f.label])));
|
||
return [
|
||
{ label: 'Ruimte', html: sel('hr-type', 'Functie', roomTypeOpts, rs.type, { hint: 'Een andere functie zet de standaard afwerking van die functie' }) +
|
||
UI.text('hr-name', 'Naam', rs.name, { placeholder: RoomModel.TYPES[rs.type].label, hint: 'Leeg = de functie (genummerd bij meer van dezelfde)' }) },
|
||
{ label: 'Maten', html: lost ? note(lost.reason + '. Verwijder de ruimte of herstel de wand.', true) : `<div data-live="room-dims" class="ins-cols"></div>` },
|
||
{ label: 'Plafond', html: toggle('hr-ceil-on', 'Vals plafond', !!rs.ceiling, { hint: 'Verlaagd plafond op een vaste hoogte, ongeacht het dak; wanden en spots volgen' }) +
|
||
(rs.ceiling ? UI.range('hr-ceil', 'Hoogte', rs.ceiling, { min: 2000, max: 3400, step: 10, hint: 'Onderkant plafond vanaf bovenkant vloer' }) : '') +
|
||
`<div data-live="room-ceil"></div>` },
|
||
{ label: 'Vloer', html: pair(sel('hr-floor', 'Afwerking', floorOpts, rs.floor || 'woning'),
|
||
FloorModel.finishThumbUrl(fk, 64) ? thumb(FloorModel.finishThumbUrl(fk, 64), ff.label) : swatch(ff.color)) },
|
||
{ label: 'Wanden', html: pair(sel('hr-fibo', 'FIBO', fiboOpts, rs.fibo ? rs.fibo.decor : 'geen', { hint: 'Waterdichte wandpanelen (natte ruimtes)' }),
|
||
rs.fibo ? swatch(RoomModel.FIBO[rs.fibo.decor].color) : '') +
|
||
(rs.fibo ? toggle('hr-fibo-full', 'Tot plafond', !rs.fibo.height) +
|
||
(rs.fibo.height ? UI.range('hr-fibo-h', 'Hoogte', rs.fibo.height, { min: 300, max: 3000, step: 50, hint: 'Vanaf bovenkant vloer' }) : '') : '') },
|
||
{ label: 'Verf', html: toggle('hr-paint-on', 'Eigen kleur', !!rs.paint, { hint: rs.fibo ? 'Boven de FIBO; anders de verfkleur van de woning' : 'Anders de verfkleur van de woning' }) +
|
||
(rs.paint ? UI.color('hr-paint', 'Kleur', rs.paint) : '') },
|
||
{ label: 'Bewerken', html: small('Verwijderen', 'trash', 'data-hr-del title="Ruimte (functie en afwerking) verwijderen (Delete)"', 'ins-danger') }
|
||
];
|
||
},
|
||
wire: (body) => {
|
||
UI.onSel('hr-type', v => { RoomModel.applyType(rs, v); }, rerender);
|
||
UI.onText('hr-name', v => { rs.name = v; }, after);
|
||
UI.onToggle('hr-ceil-on', v => { rs.ceiling = v ? (RoomModel.TYPES[rs.type].ceiling || 2600) : null; }, rerender);
|
||
UI.onRange('hr-ceil', v => { if (v >= RoomModel.CEIL_MIN && v <= RoomModel.CEIL_MAX) rs.ceiling = Math.round(v); }, after);
|
||
UI.onSel('hr-floor', v => { rs.floor = v === 'woning' ? null : v; }, rerender);
|
||
UI.onSel('hr-fibo', v => { rs.fibo = v === 'geen' ? null : { decor: v, height: rs.fibo ? rs.fibo.height : null }; }, rerender);
|
||
UI.onToggle('hr-fibo-full', v => { if (rs.fibo) rs.fibo.height = v ? null : 1200; }, rerender);
|
||
UI.onRange('hr-fibo-h', v => { if (rs.fibo && v > 0) rs.fibo.height = Math.round(v); }, after);
|
||
UI.onToggle('hr-paint-on', v => { rs.paint = v ? state.settings.interiorColor : null; }, rerender);
|
||
UI.onColor('hr-paint', v => { rs.paint = v; }, after);
|
||
const del = body.querySelector('[data-hr-del]');
|
||
if (del) del.addEventListener('click', () => removeRoom(rs.id));
|
||
const dims = () => {
|
||
const r = pr(), el = body.querySelector('[data-live="room-dims"]'), ce = body.querySelector('[data-live="room-ceil"]');
|
||
if (el && r) el.innerHTML = kv('Netto vloer', houseFmt(r.areaM2, 2), 'm²') + kv('Unit', r.units.join(' + ')) +
|
||
(r.fiboM2 ? kv('FIBO', houseFmt(r.fiboM2, 2), 'm²') : '') + (r.paintM2 ? kv('Verf', houseFmt(r.paintM2, 2), 'm²') : '');
|
||
if (ce) ce.innerHTML = r && r.ceiling ? (r.ceilingActive ? kv('Plafond', houseFmt(r.ceilM2, 2), 'm²') : note('Het dak ligt hier lager: geen vals plafond.', true)) : '';
|
||
};
|
||
live.push(dims);
|
||
dims();
|
||
}
|
||
};
|
||
}
|
||
|
||
function regionTab() {
|
||
const fr = parts.rooms.freeAt(selection.x, selection.z);
|
||
if (!fr) return null;
|
||
return {
|
||
key: 'sel', label: 'Ruimte (geen functie)', icon: 'bounding-box', contextual: true, order: 100,
|
||
groups: () => [
|
||
{ label: 'Ruimte', html: sel('hr-new-type', 'Functie', Object.assign({ '': '— kies —' }, roomTypeOpts), '') },
|
||
{ label: 'Maten', html: kv('Netto vloer', houseFmt(fr.areaM2, 2), 'm²') + kv('Unit', fr.units.join(' + ')) },
|
||
{ label: 'Uitleg', html: note('De ruimte loopt tot de wanden; deuren en ramen tellen als wand. Klik nog eens voor de unit.') }
|
||
],
|
||
wire: () => {
|
||
const sx = selection.x, sz = selection.z;
|
||
UI.onSel('hr-new-type', v => { if (v) assignRoom(sx, sz, v); }, null);
|
||
}
|
||
};
|
||
}
|
||
|
||
function removeSelectedElektra() {
|
||
const e = state.settings.elektra, key = String(selection.id);
|
||
if (key === 'mk') return;
|
||
if (key.startsWith('vwcd:')) e.vloerWcd = e.vloerWcd.filter(o => 'vwcd:' + o.id !== key);
|
||
else if (key.startsWith('app:')) e.apparaten[key.slice(4)] = false;
|
||
else if (!(e.removed = e.removed || []).includes(key)) e.removed.push(key);
|
||
delete e.place[key];
|
||
selection = null;
|
||
refresh();
|
||
}
|
||
|
||
function elektraItemTab(it) {
|
||
const e = state.settings.elektra;
|
||
if (it.kind === 'vwcd') {
|
||
const vw = e.vloerWcd.find(o => 'vwcd:' + o.id === it.key);
|
||
return {
|
||
key: 'sel', label: it.label, icon: 'plug', contextual: true, order: 100,
|
||
groups: () => [
|
||
{ label: 'Vloer-wcd', html: kv('Unit', it.unitId) + note('Vloerdoos met dubbele wcd; leiding door de vloer naar de meterkast.') },
|
||
{ label: 'Plaats', html: num('he-vw-x', 'Vanaf west', Math.round(it.x), { step: 10, hint: 'Hart, vanaf de westrand van de unit' }) +
|
||
num('he-vw-z', 'Vanaf noord', Math.round(it.z), { step: 10, hint: 'Hart, vanaf de noordrand van de unit' }) },
|
||
{ label: 'Bewerken', html: note('Sleep de vloer-wcd in de plattegrond (ook naar een andere unit).') + big('Verwijderen', 'trash', 'data-he-vw-del title="Vloer-wcd verwijderen (Del)"') }
|
||
],
|
||
wire: (body) => {
|
||
UI.onNum('he-vw-x', v => { if (vw) vw.x = v; }, after);
|
||
UI.onNum('he-vw-z', v => { if (vw) vw.z = v; }, after);
|
||
const b = body.querySelector('[data-he-vw-del]');
|
||
if (b) b.addEventListener('click', removeSelectedElektra);
|
||
}
|
||
};
|
||
}
|
||
const setPlace = (patch) => {
|
||
const cur = e.place[it.key] || { element: it.element, side: it.side, u: Math.round(it.u) };
|
||
e.place[it.key] = Object.assign({}, cur, patch);
|
||
};
|
||
return {
|
||
key: 'sel', label: it.label, icon: 'plug', contextual: true, order: 100,
|
||
groups: () => [
|
||
{ label: 'Onderdeel', html: kv('Wand', it.element + (it.side > 0 ? ' (andere zijde)' : '')) + kv('Unit', it.unitId) + kv('Plaatsing', it.manual ? 'handmatig' : 'automatisch') },
|
||
{ label: 'Plaats', html: num('he-it-u', 'Langs wand', Math.round(it.u), { step: 10, hint: 'Hart, vanaf de linkerkant van de wand (buitenaanzicht)' }) +
|
||
num('he-it-y', it.kind === 'mk' ? 'Onderkant' : 'Hoogte', Math.round(it.y), { step: 10, hint: it.kind === 'mk' ? 'Vanaf bovenkant vloer' : 'Hart, vanaf bovenkant vloer' }) },
|
||
{ label: 'Verplaatsen', html: note('Sleep het onderdeel in de plattegrond naar een andere wand of plek.') +
|
||
(it.manual ? big('Automatisch', 'arrow-counterclockwise', 'data-he-auto title="Weer automatisch plaatsen"') : '') },
|
||
it.kind === 'mk' ? { label: 'Bewerken', html: note('De meterkast kan niet weg: alle groepen hangen eraan.') }
|
||
: { label: 'Bewerken', html: big('Verwijderen', 'trash', 'data-he-it-del title="Onderdeel verwijderen (Del)"') }
|
||
],
|
||
wire: (body) => {
|
||
UI.onNum('he-it-u', v => setPlace({ u: v }), after);
|
||
UI.onNum('he-it-y', v => setPlace({ y: v }), after);
|
||
const b = body.querySelector('[data-he-auto]');
|
||
if (b) b.addEventListener('click', () => { delete e.place[it.key]; refresh(); });
|
||
const d = body.querySelector('[data-he-it-del]');
|
||
if (d) d.addEventListener('click', removeSelectedElektra);
|
||
}
|
||
};
|
||
}
|
||
|
||
function roofFormUi(id, form, o) {
|
||
const sloped = () => form.type !== 'plat';
|
||
const LIM = UnitModel.LIMITS;
|
||
const runOf = () => UnitModel.hasRidge(form.type) ? o.span() / 2 : o.span();
|
||
const degOf = () => sloped() ? Math.atan(Math.max(0, form.high - form.low) / runOf()) * 180 / Math.PI : 0;
|
||
const f = (k) => `${id}-roof-${k}`;
|
||
const groups = () => {
|
||
const g = [
|
||
{ label: 'Dakvorm', html: sel(f('type'), 'Vorm', (typeof o.types === 'function' ? o.types() : o.types) || UnitModel.ROOF_TYPES, form.type) + (o.notice ? note(o.notice) : '') +
|
||
(form.type === 'lessenaar' ? sel(f('dir'), 'Hoge zijde', SIDE_SHORT, form.dir) : '') +
|
||
(UnitModel.hasRidge(form.type) ? sel(f('ridge'), 'Nok', UnitModel.RIDGES, form.ridge) : '') +
|
||
UI.range(f('low'), sloped() ? 'Goot' : 'Hoogte', form.low, { min: 2100, max: LIM.eave, step: 10, hint: `Vanaf bovenkant vloer tot onderkant dak (max ${LIM.eave} mm)` }) }
|
||
];
|
||
if (sloped()) g.push({ label: 'Helling', html:
|
||
UI.range(f('high'), UnitModel.hasRidge(form.type) ? 'Nok' : 'Hoog', form.high, { min: 2100, max: LIM.ridge, step: 10, hint: `Max ${LIM.ridge} mm` }) +
|
||
UI.range(f('deg'), 'Helling', Math.round(degOf() * 10) / 10, { min: 1, max: 60, step: 0.5, unit: '°', hint: 'Zet de hoge zijde/nok bij de huidige goothoogte' }) +
|
||
(o.gable ? `<div data-live="roof-gable">${kv('Kopgevel', `wand ${SIDE_SHORT[UnitModel.gableOf(form)].toLowerCase()} + overstaande`)}</div>` : '') });
|
||
if (form.type === 'wolf') g.push({ label: 'Wolfsdak', html:
|
||
UI.range(f('cut'), 'Wolfseind', form.cut, { min: 2100, max: LIM.ridge, step: 10, hint: 'Hoogte waarop de kopgevels stoppen; daarboven driehoekige wolfseinden met de dakhelling tot de nok' }) });
|
||
return g;
|
||
};
|
||
const wire = (body) => {
|
||
const apply = () => {
|
||
form.low = Math.min(form.low, LIM.eave);
|
||
form.high = Math.min(form.high, LIM.ridge);
|
||
o.apply();
|
||
};
|
||
UI.onSel(f('type'), v => {
|
||
form.type = v;
|
||
if (v !== 'plat' && !(form.high > form.low)) form.high = form.low + 700;
|
||
if (v === 'lessenaar' && !form.dir) form.dir = 'voor';
|
||
if (UnitModel.hasRidge(v) && !form.ridge) form.ridge = 'z';
|
||
if (v === 'wolf') {
|
||
if (form.high - form.low < 1000) form.high = form.low + 1500;
|
||
form.cutRatio = 0.6;
|
||
}
|
||
apply();
|
||
}, rerender);
|
||
UI.onSel(f('dir'), v => { form.dir = v; apply(); }, rerender);
|
||
UI.onSel(f('ridge'), v => { form.ridge = v; apply(); }, rerender);
|
||
const syncDeg = () => {
|
||
const now = o.readBack();
|
||
if (now && now.type === form.type) ['cut'].forEach(k => { if (now[k] !== undefined) form[k] = now[k]; });
|
||
UI.setRange(f('deg'), Math.round(degOf() * 10) / 10);
|
||
UI.setRange(f('cut'), form.cut);
|
||
};
|
||
UI.onRange(f('low'), v => {
|
||
if (!(v >= 500)) return;
|
||
form.low = v;
|
||
if (sloped() && form.high < v) { form.high = v; UI.setRange(f('high'), v); }
|
||
apply(); syncDeg();
|
||
}, after);
|
||
UI.onRange(f('high'), v => {
|
||
if (!(v >= 500)) return;
|
||
form.high = Math.max(v, form.low);
|
||
apply(); syncDeg();
|
||
}, after);
|
||
UI.onRange(f('deg'), v => {
|
||
if (!(v >= 0.5) || v > 75) return;
|
||
form.high = Math.round(form.low + Math.tan(v * Math.PI / 180) * runOf());
|
||
UI.setRange(f('high'), form.high);
|
||
apply(); syncDeg();
|
||
}, after);
|
||
const ratioOf = (v) => Math.max(0.05, Math.min(0.95, (v - form.low) / Math.max(1, form.high - form.low)));
|
||
UI.onRange(f('cut'), v => { if (v >= 500) { form.cutRatio = ratioOf(v); apply(); syncDeg(); } }, after);
|
||
};
|
||
return { groups, wire };
|
||
}
|
||
|
||
function unitTab(u) {
|
||
const isRoot = state.units[0].id === u.id;
|
||
const f = u.unit.floor.floor;
|
||
const LIM = UnitModel.LIMITS;
|
||
const partsOf = () => parts && parts.units.find(x => x.id === u.id);
|
||
const houseRoof = state.settings.roof.house.enabled;
|
||
const form = UnitModel.roofForm(u.unit);
|
||
const roofUi = roofFormUi('hp', form, {
|
||
gable: true,
|
||
types: () => HouseModel.hasCover(state, u.id) ? NO_WOLF : null,
|
||
span: () => {
|
||
const pu = partsOf();
|
||
const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form));
|
||
return pu ? (s.axis === 'x' ? pu.layout.floorWidth : pu.layout.floorDepth) : 1000;
|
||
},
|
||
apply: () => UnitModel.applyRoofForm(u.unit, form),
|
||
readBack: () => UnitModel.roofForm(u.unit)
|
||
});
|
||
return {
|
||
key: 'sel', label: `Unit ${u.id}`, icon: 'box', contextual: true, order: 100,
|
||
groups: () => {
|
||
const a = u.attach, axis = a && (a.side === 'voor' || a.side === 'achter') ? 'x' : 'z';
|
||
const pu = partsOf(), shapeNow = pu ? pu.roof.shape.type : form.type;
|
||
const covOpts = Object.fromEntries(Object.entries(RoofModel.COVERINGS).filter(([, c]) => !c.sloped || shapeNow !== 'plat').map(([k, c]) => [k, c.label]));
|
||
const covNow = RoofModel.coveringFor(u.roof.covering, shapeNow);
|
||
const c = RoofModel.COVERINGS[covNow];
|
||
const missing = HouseModel.missingSeamWalls(state, u.id);
|
||
const groups = [
|
||
{ label: 'Afmetingen', html:
|
||
UI.range('hp-width', 'Breedte', f.width, { min: 2000, max: LIM.long, step: 50, hint: `West–oost (unit max ${LIM.short / 1000} x ${LIM.long / 1000} m)`.replace('.', ',') }) +
|
||
UI.range('hp-depth', 'Diepte', f.depth, { min: 2000, max: LIM.long, step: 50, hint: `Noord–zuid (unit max ${LIM.short / 1000} x ${LIM.long / 1000} m)`.replace('.', ',') }) +
|
||
sel('hp-corners', 'Hoeken', UnitModel.CORNER_MODES, u.unit.layout.corners, { hint: 'Welke wanden doorlopen over de volle vloermaat' }) },
|
||
];
|
||
if (houseRoof) groups.push({ label: 'Dakvorm', html: note(`Dak over de hele woning (${(UnitModel.ROOF_TYPES[shapeNow] || shapeNow).toLowerCase()}): vorm en hoogtes bij Woning › Dak (niets geselecteerd).`) });
|
||
else groups.push(...roofUi.groups());
|
||
groups.push({ label: 'Dak', html:
|
||
pair(sel('hp-roof-cover', 'Bedekking', covOpts, covNow, { hint: shapeNow === 'plat' ? 'Riet en felsplaten alleen bij een hellend dak' : `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²` }),
|
||
thumb(RoofModel.coveringThumbUrl(covNow, 64), c.label)) +
|
||
toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim' }) +
|
||
(pu && pu.roof.unitRoof.ridgeBeam || u.roof.steelRidge === false && ['zadel', 'wolf', 'lessenaar'].includes(shapeNow)
|
||
? toggle('hp-steel-ridge', 'Stalen nokbalk', u.roof.steelRidge !== false, { hint: pu && pu.roof.unitRoof.ridgeBeam ? `${pu.roof.unitRoof.ridgeBeam.profile.name}, ${Math.round(pu.roof.unitRoof.ridgeBeam.steelKg)} kg; uit = verwijderen` : 'Uit = verwijderd' }) : '') +
|
||
toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) });
|
||
if (u.roof.followFloor) groups.push({ label: 'Overkapping', html:
|
||
['f', 'r', 'b', 'l'].map(k => num('hp-ext-' + k, { f: 'Noord', r: 'Oost', b: 'Zuid', l: 'West' }[k], (u.roof.extend || {})[k] || 0,
|
||
{ hint: k === 'f' ? `Dak verlengen voorbij de gevel (veranda), max. ${HouseModel.EXTEND_MAX} mm; palen volgen vanzelf` : '' })).join('') });
|
||
if (!u.roof.followFloor) groups.push({ label: 'Dakmaat', html:
|
||
num('hp-roof-w', 'Breedte', u.roof.deck.floor.width, { hint: 'West–oost' }) + num('hp-roof-d', 'Diepte', u.roof.deck.floor.depth, { hint: 'Noord–zuid' }) +
|
||
num('hp-roof-ox', 'Versch. oost', u.roof.offsetX || 0) + num('hp-roof-oz', 'Versch. zuid', u.roof.offsetZ || 0) });
|
||
groups.push({ label: 'Koppeling', html: isRoot
|
||
? note('Basis-unit: de noordwesthoek ligt op (0, 0). Andere units koppelen hieraan.')
|
||
: sel('hp-att-to', 'Aan', Object.fromEntries(state.units.filter(o => o.id !== u.id).map(o => [o.id, 'Unit ' + o.id])), a.to) +
|
||
sel('hp-att-side', 'Zijde', HouseModel.SIDE_LABELS, a.side) +
|
||
sel('hp-att-align', 'Uitlijning', HouseModel.ALIGN_LABELS[axis], a.align) +
|
||
num('hp-att-offset', 'Verschuiving', a.offset || 0) });
|
||
groups.push({ label: 'Bewerken', html:
|
||
big('Vloer', 'grid-3x3', `data-go="floor:${u.id}" title="Vloer-editor: balken, doorvoeren"`) +
|
||
big('Dak', 'house', `data-go="roof:${u.id}" title="Dak-editor: dek, doorvoeren"`) +
|
||
(missing.length ? small('Naadwand toevoegen', 'plus-lg', `data-ho-act="add-seams" data-unit="${u.id}" title="Zonder naadwand: ${missing.map(m => `${m.a}|${m.b}`).join(', ')}"`) : '') +
|
||
(isRoot ? '' : small('Unit verwijderen', 'trash', `data-ho-act="remove-unit" data-unit="${u.id}"`, 'ins-danger')) });
|
||
return groups;
|
||
},
|
||
wire: (body) => {
|
||
wireButtons(body);
|
||
const size = (k, v) => {
|
||
if (!(v >= 1000)) return;
|
||
f[k] = v;
|
||
UnitModel.clampSize(f, k === 'width' ? 'depth' : 'width');
|
||
UI.setRange('hp-width', f.width); UI.setRange('hp-depth', f.depth);
|
||
};
|
||
UI.onRange('hp-width', v => size('width', v), after);
|
||
UI.onRange('hp-depth', v => size('depth', v), after);
|
||
UI.onSel('hp-corners', v => { u.unit.layout.corners = v; }, rerender);
|
||
UI.onNum('hp-att-offset', v => { u.attach.offset = v; }, after);
|
||
UI.onSel('hp-att-to', v => { u.attach.to = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
|
||
UI.onSel('hp-att-side', v => { u.attach.side = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
|
||
UI.onSel('hp-att-align', v => { u.attach.align = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
|
||
if (!houseRoof) roofUi.wire(body);
|
||
UI.onSel('hp-roof-cover', v => { u.roof.covering = v; }, rerender);
|
||
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
|
||
UI.onToggle('hp-steel-ridge', v => { u.roof.steelRidge = v; }, rerender);
|
||
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender);
|
||
// Een overkapping maakt van een wolfsdak een zadeldak: dan het paneel opnieuw opbouwen.
|
||
const afterCover = () => (form.type === 'wolf' ? rerender : after)();
|
||
['f', 'r', 'b', 'l'].forEach(k => UI.onNum('hp-ext-' + k, v => {
|
||
u.roof.extend = Object.assign({ f: 0, r: 0, b: 0, l: 0 }, u.roof.extend, { [k]: Math.max(0, Math.min(HouseModel.EXTEND_MAX, v)) });
|
||
}, afterCover));
|
||
UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, afterCover);
|
||
UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, afterCover);
|
||
UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, afterCover);
|
||
UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, afterCover);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Schoorstenen in het vlak van deze wand (ook als ze aan een andere unit hangen, bijv. over een naad).
|
||
function chimneysOnWall(pu, side) {
|
||
const r = pu.rect, plane = { voor: r.z0, achter: r.z1, links: r.x0, rechts: r.x1 }[side];
|
||
const ids = (parts.chimneys || []).filter(c => c.side === side && Math.abs(c.plane - plane) < 1).map(c => c.id);
|
||
return (state.chimneys || []).filter(c => ids.includes(c.id));
|
||
}
|
||
function chimneyGroup(pu, wl, piece, e0) {
|
||
const list = chimneysOnWall(pu, wl.side);
|
||
const fin = (pre, label, f, types) => sel(pre, label, types, f.type) +
|
||
(f.type === 'steen' ? pair(sel(pre + '-stone', 'Steen', stoneOptions, f.stone), thumb(WallModel.stoneThumbUrl(f.stone, 64), f.stone)) : '') +
|
||
(f.type === 'stuc' ? pair(sel(pre + '-stuc', 'Kleur', stucColors, f.stuc), swatchOf(WallModel.stucHex(f.stuc))) : '');
|
||
const html = list.map(c => {
|
||
const r = (parts.chimneys || []).find(x => x.id === c.id);
|
||
const id = 'hw-ch' + c.id;
|
||
return kv('Schoorsteen', `${c.id} (unit ${c.unitId})`) +
|
||
num(id + '-u', 'Positie', c.u, { step: 10, hint: `Hart vanaf de linker vloerhoek van unit ${c.unitId}, van buitenaf gezien` }) +
|
||
num(id + '-w', 'Breedte', c.w, { step: 10, hint: 'Buitenwerks; de wand krijgt een sparing zoals een raam' }) +
|
||
num(id + '-out', 'Voor de gevel', c.out, { step: 10, hint: r ? `Minstens tot voorbij de dakoverstek: nu ${r.protrudes} mm voor de gevel` : '' }) +
|
||
num(id + '-above', 'Boven het dak', c.above, { step: 50, hint: r ? `Bovenkant ${r.yTop - r.yFloor} mm boven de vloer (hoogste dakpunt + dit)` : '' }) +
|
||
fin(id + '-out-type', 'Buiten', c.outside, ChimneyModel.OUTSIDE_TYPES) +
|
||
fin(id + '-in-type', 'Binnen', c.inside, ChimneyModel.INSIDE_TYPES) +
|
||
small('Schoorsteen weg', 'trash', `data-hw-ch="del" data-ch="${c.id}"`, 'ins-danger');
|
||
}).join('');
|
||
return { label: 'Schoorsteen', html: html + small('Schoorsteen toevoegen', 'plus-lg', `data-hw-ch="add" data-u="${Math.round(e0 + (piece.u0 + piece.u1) / 2)}"`) };
|
||
}
|
||
function wireChimneys(body, pu, wl) {
|
||
chimneysOnWall(pu, wl.side).forEach(c => {
|
||
const id = 'hw-ch' + c.id;
|
||
UI.onNum(id + '-u', v => { c.u = Math.round(v); }, after);
|
||
UI.onNum(id + '-w', v => { if (v >= 400) c.w = Math.round(v); }, after);
|
||
UI.onNum(id + '-out', v => { if (v >= 0) c.out = Math.round(v); }, after);
|
||
UI.onNum(id + '-above', v => { if (v >= 0) c.above = Math.round(v); }, after);
|
||
[['out', c.outside], ['in', c.inside]].forEach(([k, f]) => {
|
||
UI.onSel(`${id}-${k}-type`, v => { f.type = v; }, rerender);
|
||
UI.onSel(`${id}-${k}-type-stone`, v => { f.stone = v; }, rerender);
|
||
UI.onSel(`${id}-${k}-type-stuc`, v => { f.stuc = v; }, rerender);
|
||
});
|
||
});
|
||
body.querySelectorAll('[data-hw-ch]').forEach(b => b.addEventListener('click', () => {
|
||
state.chimneys = state.chimneys || [];
|
||
if (b.dataset.hwCh === 'add') state.chimneys.push(ChimneyModel.create(state.chimneys, pu.id, wl.side, Number(b.dataset.u)));
|
||
else state.chimneys = state.chimneys.filter(c => c.id !== Number(b.dataset.ch));
|
||
refresh();
|
||
}));
|
||
}
|
||
|
||
function wallTab() {
|
||
const pu = parts.units.find(x => x.id === selection.unit);
|
||
const wl = pu && pu.walls.find(x => x.side === selection.side);
|
||
// Na splitsen hernoemen de stukken: dan het stuk op positie selection.u.
|
||
const piece = wl && (wl.pieces.find(p => p.element === selection.element) ||
|
||
(selection.u !== undefined && wl.pieces.find(p => !p.baseY && selection.u >= p.u0 - 1 && selection.u <= p.u1 + 1)));
|
||
if (!piece) return null;
|
||
selection.element = piece.element;
|
||
delete selection.u;
|
||
const su = unitById(pu.id);
|
||
const ws = su.unit.walls[wl.side];
|
||
ws.splits = UnitModel.normalizeSplits(ws.splits);
|
||
const e0 = (wl.skinExt && wl.skinExt.left) || 0;
|
||
const own = ws.splits.find(sp => Math.abs(sp.u - e0 - piece.u0) < 1);
|
||
return {
|
||
key: 'sel', label: `Wand ${piece.element}`, icon: 'bricks', contextual: true, order: 100,
|
||
groups: () => {
|
||
const lt = state.settings.lighting;
|
||
const fc = HouseModel.facadeFor(state, su, wl.side);
|
||
const ov = su.facade.walls[wl.side] || null;
|
||
const f0 = state.settings.facade;
|
||
const woningLabel = `Woning (${HouseModel.FACADE_TYPES[f0.type]}${f0.type === 'steen' ? ' ' + f0.stone : ''})`;
|
||
return [
|
||
{ label: piece.label, html:
|
||
kv('Lengte zijde', wl.width, 'mm') + kv('Wandstuk', piece.place.width, 'mm') + kv('Positie', `${Math.round(piece.u0)} – ${Math.round(piece.u1)}`, 'mm') +
|
||
kv('Openingen', piece.config.openings.length) + kv('Naad', wl.cutsU.length ? wl.cutsU.map(([a, b]) => `${Math.round(a)}–${Math.round(b)}`).join(', ') : 'geen', wl.cutsU.length ? 'mm' : '') +
|
||
kv('Hoogte', `${piece.config.wallHeightLeft} / ${piece.config.wallHeightRight}`, 'mm') },
|
||
{ label: 'Gevel (hele zijde)', html:
|
||
sel('hw-facade', 'Type', Object.assign({ woning: woningLabel }, HouseModel.FACADE_TYPES), ov ? ov.type : 'woning') +
|
||
(ov && ov.type === 'steen' ? pair(sel('hw-facade-stone', 'Steen', stoneOptions, fc.stone), thumb(WallModel.stoneThumbUrl(fc.stone, 64), fc.stone)) : '') +
|
||
(ov && ov.type === 'stuc' ? pair(sel('hw-facade-stuc', 'Kleur', stucColors, fc.stuc), swatchOf(WallModel.stucHex(fc.stuc))) : '') +
|
||
(ov && ov.type === 'fels' ? pair(sel('hw-facade-fels', 'Kleur', felsColors, fc.fels), swatchOf(WallModel.felsHex(fc.fels))) : '') },
|
||
{ label: 'Wandstukken', html:
|
||
num('hw-split-at', 'Splitsen op', Math.round(e0 + (piece.u0 + piece.u1) / 2), { step: 10, hint: 'Afstand vanaf de linker vloerhoek, van buitenaf gezien. Elk stuk wordt een eigen wand-element (eigen regelwerk) en kan een eigen gevel krijgen.' }) +
|
||
small('Splitsen', 'scissors', 'data-hw="split"') +
|
||
kv('Splitsingen', ws.splits.length ? ws.splits.map(sp => sp.u).join(' · ') : 'geen', ws.splits.length ? 'mm' : '') +
|
||
(own ? sel('hw-piece-facade', 'Gevel dit stuk', Object.assign({ zijde: 'Zoals de zijde' }, HouseModel.FACADE_TYPES), own.facade ? own.facade.type : 'zijde', { hint: 'Gevel van dit wandstuk (rechts van de splitsing)' }) +
|
||
(own.facade && own.facade.type === 'steen' ? sel('hw-piece-stone', 'Steen', stoneOptions, own.facade.stone || fc.stone) : '') +
|
||
(own.facade && own.facade.type === 'stuc' ? sel('hw-piece-stuc', 'Kleur', stucColors, own.facade.stuc || fc.stuc) : '') +
|
||
(own.facade && own.facade.type === 'fels' ? sel('hw-piece-fels', 'Kleur', felsColors, own.facade.fels || fc.fels) : '') +
|
||
small('Splitsing weg', 'x-lg', 'data-hw="unsplit"') : '') },
|
||
chimneyGroup(pu, wl, piece, e0),
|
||
{ label: 'Gevellampen', html: lt.mode === 'gevel'
|
||
? sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, su.lighting.walls[wl.side] || 'auto',
|
||
{ hint: `Automatisch: ${lt.endOffset} mm uit de hoek, op vrije gevel van min. ${lt.minWallWidth} mm. Naast openingen: ${lt.openingOffset} mm ernaast.` }) +
|
||
kv('Op dit wandstuk', piece.lamps.length, 'st.')
|
||
: note('Gevellampen staan uit (Woning › Verlichting).') },
|
||
wl.dropped.length ? { label: 'Let op', html: note(`${wl.dropped.length} opening(en) van deze zijde vallen in een naad en vervallen.`, true) } : null,
|
||
{ label: 'Bewerken', html: big('Wand bewerken', 'pencil-square', `data-go="wall:${pu.id}:${wl.side}" title="Wand-editor: openingen, hoogtes, regelwerk"`) }
|
||
];
|
||
},
|
||
wire: (body) => {
|
||
wireButtons(body);
|
||
const fc = HouseModel.facadeFor(state, su, wl.side);
|
||
UI.onSel('hw-light', v => { su.lighting.walls[wl.side] = v; }, rerender);
|
||
wireChimneys(body, pu, wl);
|
||
UI.onSel('hw-facade', v => {
|
||
if (v === 'woning') delete su.facade.walls[wl.side];
|
||
else su.facade.walls[wl.side] = v === 'steen' ? { type: v, stone: fc.stone } : v === 'stuc' ? { type: v, stuc: fc.stuc } : v === 'fels' ? { type: v, fels: fc.fels } : { type: v };
|
||
}, rerender);
|
||
UI.onSel('hw-facade-stone', v => { su.facade.walls[wl.side].stone = v; }, rerender);
|
||
UI.onSel('hw-facade-stuc', v => { su.facade.walls[wl.side].stuc = v; }, rerender);
|
||
UI.onSel('hw-facade-fels', v => { su.facade.walls[wl.side].fels = v; }, rerender);
|
||
UI.onSel('hw-piece-facade', v => {
|
||
if (!own) return;
|
||
own.facade = v === 'zijde' ? null : v === 'steen' ? { type: v, stone: fc.stone } : v === 'stuc' ? { type: v, stuc: fc.stuc } : v === 'fels' ? { type: v, fels: fc.fels } : { type: v };
|
||
}, rerender);
|
||
UI.onSel('hw-piece-stone', v => { if (own && own.facade) own.facade.stone = v; }, rerender);
|
||
UI.onSel('hw-piece-stuc', v => { if (own && own.facade) own.facade.stuc = v; }, rerender);
|
||
UI.onSel('hw-piece-fels', v => { if (own && own.facade) own.facade.fels = v; }, rerender);
|
||
body.querySelectorAll('[data-hw]').forEach(b => b.addEventListener('click', () => {
|
||
if (b.dataset.hw === 'split') {
|
||
const v = Number(document.getElementById('hw-split-at').value);
|
||
if (!(v > 0)) return;
|
||
ws.splits = UnitModel.normalizeSplits(ws.splits.concat([{ u: v, facade: null }]));
|
||
selection.u = v - e0 + 1;
|
||
} else if (b.dataset.hw === 'unsplit' && own) {
|
||
ws.splits = ws.splits.filter(sp => sp !== own);
|
||
selection.u = piece.u0 + 1;
|
||
}
|
||
refresh();
|
||
}));
|
||
}
|
||
};
|
||
}
|
||
|
||
function iwTab(iw) {
|
||
const p = parts.interior.find(x => x.id === iw.id);
|
||
if (!p) return null;
|
||
return {
|
||
key: 'sel', label: p.element, icon: 'distribute-horizontal', contextual: true, order: 100,
|
||
groups: () => [
|
||
{ label: 'Positie', html: num('hi-x1', 'Begin X', iw.x1) + num('hi-z1', 'Begin Y', iw.z1) + num('hi-x2', 'Eind X', iw.x2) + num('hi-z2', 'Eind Y', iw.z2) },
|
||
{ label: 'Opbouw', html: num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall), { step: 5 }) + num('hi-height', 'Hoogte', iw.wall.wall.heightAll) + kv('Lengte', p.place.width, 'mm') },
|
||
{ label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}" title="Delete"`, 'ins-danger') }
|
||
],
|
||
wire: (body) => {
|
||
wireButtons(body);
|
||
['x1', 'z1', 'x2', 'z2'].forEach(k => UI.onNum('hi-' + k, v => { iw[k] = v; }, after));
|
||
UI.onNum('hi-thick', v => { if (v >= 40) HouseModel.setThickness(iw.wall, v); }, after);
|
||
UI.onNum('hi-height', v => { if (v >= 500) iw.wall.wall.heightAll = iw.wall.wall.heightLeft = iw.wall.wall.heightRight = v; }, after);
|
||
}
|
||
};
|
||
}
|
||
|
||
function seamTab(iw) {
|
||
const leaves = parts.interior.filter(x => x.id === iw.id);
|
||
const inUnit = iw.seam.in === 'a' || iw.seam.in === 'b' ? iw.seam[iw.seam.in] : null;
|
||
const sheet = iw.wall.wall.sheathingThick;
|
||
return {
|
||
key: 'sel', label: `Naadwand ${iw.seam.a}|${iw.seam.b}`, icon: 'layout-split', contextual: true, order: 100,
|
||
groups: () => {
|
||
const plaatsen = { midden: 'Midden (2 delen)', a: `Geheel in unit ${iw.seam.a}`, b: `Geheel in unit ${iw.seam.b}` };
|
||
const exposed = leaves.filter(l => l.skinFrom !== undefined);
|
||
return [
|
||
{ label: 'Opbouw', html: sel('hi-seam-in', 'Plaats', plaatsen, iw.seam.in || 'midden') +
|
||
(inUnit
|
||
? num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall) + sheet, { step: 5, hint: 'Regelwerk + beplating aan beide zijden' })
|
||
: num('hi-thick', 'Dikte per deel', HouseModel.thicknessOf(iw.wall), { step: 5, hint: 'Regelwerk + beplating aan de kamerzijde; naadzijde zonder plaat' })) },
|
||
{ label: 'Delen', html: leaves.length
|
||
? leaves.map(l => kv(`${l.element}`, l.place.width, 'mm')).join('') + exposed.map(l => kv(`${l.element} buiten vanaf`, l.skinFrom, 'mm')).join('')
|
||
: note(`Unit ${iw.seam.a} en ${iw.seam.b} staan niet (meer) tegen elkaar; deze naadwand wordt niet gebouwd.`, true) },
|
||
{ label: 'Uitleg', html: note(inUnit
|
||
? `Binnenwand geheel in unit ${inUnit}, tegen de naad, beplating aan beide zijden; hoogte volgt de daklijn van unit ${inUnit}.`
|
||
: 'Per unit een eigen deel tegen het andere aan; openingen gelden voor beide delen (de deur loopt door). Hoogte volgt per deel de daklijn.') },
|
||
{ label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}"`, 'ins-danger') }
|
||
];
|
||
},
|
||
wire: (body) => {
|
||
wireButtons(body);
|
||
UI.onSel('hi-seam-in', v => { if (v === 'midden') delete iw.seam.in; else iw.seam.in = v; }, rerender);
|
||
UI.onNum('hi-thick', v => { const t = inUnit ? v - sheet : v; if (t >= 40) HouseModel.setThickness(iw.wall, t); }, after);
|
||
}
|
||
};
|
||
}
|
||
|
||
const WONING_SECTIONS = () => [
|
||
Object.assign({ icon: 'bricks' }, gevelTab), Object.assign({ icon: 'house' }, dakTab), Object.assign({ icon: 'door-open' }, binnenTab),
|
||
Object.assign({ icon: 'lightbulb' }, lichtTab), Object.assign({ icon: 'lightning-charge' }, elektraTab), Object.assign({ icon: 'grid-3x3' }, constructieTab)
|
||
];
|
||
// Eigenschappenpaneel: de selectie, of zonder selectie de instellingen van de hele woning.
|
||
function renderInspector() {
|
||
const el = document.getElementById('ho-inspector');
|
||
if (!el) return;
|
||
live = [];
|
||
const ctx = contextTab();
|
||
if (ctx) {
|
||
Inspector.render(el, { crumbs: [{ label: 'Woning', attrs: 'data-deselect title="Selectie opheffen (Esc)"' }], title: ctx.label, icon: ctx.icon || 'cursor',
|
||
sections: [{ key: 'sel', groups: ctx.groups, wire: ctx.wire }] });
|
||
} else {
|
||
const n = state.units.length;
|
||
Inspector.render(el, { title: 'Woning', icon: 'house-door', sub: `${n} unit${n === 1 ? '' : 's'} · niets geselecteerd`, collapsible: true,
|
||
top: '<div data-live="woning-dims" class="ins-summary"></div>',
|
||
sections: WONING_SECTIONS() });
|
||
const dims = () => {
|
||
const d0 = el.querySelector('[data-live="woning-dims"]');
|
||
if (!d0 || !parts) return;
|
||
const d = HouseModel.computeDimensions(parts, state.settings.groundLevel), f = houseFmt;
|
||
const wide = (h) => h.replace('class="ins-kv"', 'class="ins-kv ins-wide"');
|
||
d0.innerHTML = wide(kv('Buitenmaten', `${f(d.lengthX / 1000, 2)} × ${f(d.lengthZ / 1000, 2)}`, 'm')) +
|
||
kv('Bruto vloer', f(d.brutoM2, 1), 'm²') + kv('Leefbaar', f(d.leefbaarM2, 1), 'm²') +
|
||
kv('Bouwhoogte', f(d.height / 1000, 2), 'm') + kv('Inhoud', f(d.inhoudM3, 0), 'm³') + wide(kv('Gevel netto', f(d.gevelNettoM2, 1), 'm²'));
|
||
};
|
||
live.push(dims);
|
||
dims();
|
||
}
|
||
const back = el.querySelector('[data-deselect]');
|
||
if (back) back.addEventListener('click', () => select(null));
|
||
}
|
||
function renderWarnings() {
|
||
const msgs = (parts.overlaps.length ? [`Units overlappen elkaar: ${parts.overlaps.map(p => p.join(' + ')).join(', ')}.`] : [])
|
||
.concat(parts.rects.filter(r => r.error).map(r => `Unit ${r.id}: ${r.error}`));
|
||
document.getElementById('ho-warn').innerHTML = msgs.map(m => UI.warn(m)).join('');
|
||
}
|
||
|
||
let reportOpen = false, reportDirty = true;
|
||
const overviewEl = document.getElementById('house-overview');
|
||
function toggleReport(force) {
|
||
const open = force === undefined ? !reportOpen : !!force;
|
||
if (open === reportOpen) return;
|
||
reportOpen = view3D.report = open;
|
||
if (open && UI.viewOf(overviewEl) === '3d') UI.setView(overviewEl, view3D.mode = 'split');
|
||
overviewEl.classList.toggle('show-report', open);
|
||
if (open && reportDirty && parts) renderReport();
|
||
syncToolbar();
|
||
requestAnimationFrame(onResize);
|
||
}
|
||
const esc = UI.esc;
|
||
function stackedBars(title, iconName, series, rows, fmtVal, note) {
|
||
const max = Math.max(1, ...rows.map(r => r.total));
|
||
const legend = series.length > 1
|
||
? `<div class="viz-legend">${series.map((sr, i) => `<span><i class="viz-key s${i + 1}"></i>${sr.label}</span>`).join('')}</div>` : '';
|
||
const body = rows.map(r => `<div class="viz-row">
|
||
<div class="viz-label">${esc(r.label)}${r.sub ? `<small>${r.sub}</small>` : ''}</div>
|
||
<div class="viz-track"><div class="viz-bar" style="width:${(r.total / max * 100).toFixed(2)}%">${series.map((sr, i) => {
|
||
const v = r.values[sr.key] || 0;
|
||
return v > 0 ? `<span class="viz-seg s${series.length > 1 ? i + 1 : 1}" style="flex:${v}" title="${esc(`${r.label} · ${sr.label}: ${fmtVal(v)}`)}"></span>` : '';
|
||
}).join('')}</div></div>
|
||
<div class="viz-val">${fmtVal(r.total)}</div>
|
||
</div>`).join('');
|
||
return `<div class="ui-spec-card viz"><h2>${UI.icon(iconName)}${title}</h2>${legend}${body}${note ? `<div class="viz-note">${note}</div>` : ''}</div>`;
|
||
}
|
||
|
||
function renderReport() {
|
||
reportDirty = false;
|
||
const f = houseFmt;
|
||
const totals = HouseModel.computeTotals(parts);
|
||
const dims = HouseModel.computeDimensions(parts, state.settings.groundLevel);
|
||
const transport = HouseModel.computeTransport(parts);
|
||
const cost = CostModel.compute(parts, totals);
|
||
const kg = (v) => `${f(v, 0)} kg`;
|
||
const m = (mm) => f(mm / 1000, 2);
|
||
const cards = [
|
||
stackedBars('Transport per unit', 'truck', [
|
||
{ key: 'vloer', label: 'Vloer' }, { key: 'wanden', label: 'Wanden' }, { key: 'dak', label: 'Dak' }
|
||
], transport.map(t => ({
|
||
label: `Unit ${t.id}`, total: t.totalKg, values: t.kg,
|
||
sub: `${m(t.length)} × ${m(t.width)} × ${m(t.height)} m · ${HouseModel.TRANSPORT_CLASSES[t.cls].toLowerCase()}`
|
||
})), kg, `Buitenmaat l × b × h incl. gevelafwerking, zonder dakrand. Breedte tot ${HouseModel.TRANSPORT.widthRegular} mm regulier, tot ${HouseModel.TRANSPORT.widthPermit} mm met ontheffing (indicatief). Gewicht zonder kozijnen, isolatie en steen.`),
|
||
UI.specCard('Gewicht', 'speedometer2', totals.weights.map(r => [r.label, f(r.kg), 'kg']), ['Totaal woning', f(totals.totalKg), 'kg']),
|
||
UI.specCard('Afmetingen', 'rulers', HouseModel.dimensionRows(dims, f)),
|
||
UI.specCard('Ruimtes (netto vloer)', 'bounding-box', parts.rooms.rooms.map(r => [`${esc(r.name)}${r.ceilingActive ? ` <small>plafond ${r.ceiling}</small>` : ''}`, f(r.areaM2, 2), 'm²'])
|
||
.concat(parts.rooms.free.length ? [[`Zonder functie (${parts.rooms.free.length})`, f(parts.rooms.free.reduce((a, r) => a + r.areaM2, 0), 2), 'm²']] : []),
|
||
['Totaal netto', f(parts.rooms.rooms.concat(parts.rooms.free).reduce((a, r) => a + r.areaM2, 0), 2), 'm²']),
|
||
stackedBars('Kostprijs per unit', 'cash-coin', [{ key: 'v', label: 'Kostprijs' }],
|
||
cost.units.map(u => ({ label: u.id ? `Unit ${u.id}` : 'Woning-breed', total: u.kostprijs, values: { v: u.kostprijs } })), (v) => CostModel.eur(v),
|
||
'Woning-breed: dakrand, fundering, verlichting, kraan.'),
|
||
...costCards(cost),
|
||
UI.specCard('Hout & isolatie', 'tree', [
|
||
['Balken', totals.beamCount, 'st.'],
|
||
['Volume hout', f(totals.woodM3, 3), 'm³'],
|
||
['Inkoop wandbalken', WallModel.purchaseText(totals.purchase), ''],
|
||
['Snijverlies', `${f(totals.purchaseWasteMM / 1000, 2)} m · ${f(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0)}`, '%'],
|
||
[`Latwerk ${WallModel.BATTEN.thick}×${WallModel.BATTEN.width}`, `${totals.battens.count} × ${WallModel.BATTEN.length}`, 'mm'],
|
||
['Glaswol', f(totals.isolatieM3, 3), 'm³']
|
||
]),
|
||
UI.specCard('Plaatwerk', 'layers', totals.plaatwerk.filter(pw => pw.m2 > 0).map(pw => [pw.naam, f(pw.m2, 2), 'm²']))
|
||
];
|
||
if (totals.nokbalken.length) cards.push(UI.specCard('Stalen nokbalk', 'house', totals.nokbalken.flatMap(n => [
|
||
[`Unit ${n.unit}: ${n.name} S235${n.ok ? '' : ' (te licht!)'}`, `${f(n.lengthM, 2)} m · ${f(n.kg)}`, 'kg'],
|
||
['Benutting sterkte / doorbuiging', `${f(n.util * 100)} % · ${f(n.defl, 1)} / ${f(n.deflMax, 1)}`, 'mm'],
|
||
['Vulhout in de holling', `${f(n.fillM3, 3)} m³${n.below ? ` · ${n.below} mm onder plafond` : ''}`, '']
|
||
])));
|
||
if (totals.roofEdge) {
|
||
const e = totals.roofEdge, rows = [];
|
||
if (e.fasciaLengthM > 0) rows.push([`Boeidelen · ${e.fasciaHeight} mm`, f(e.fasciaLengthM, 2), 'm'], ['Onderplaat boeideel', f(e.soffitM2, 2), 'm²']);
|
||
if (e.trimLengthM > 0) rows.push([`Daktrim · ${e.trimHeight} mm`, f(e.trimLengthM, 2), 'm']);
|
||
if (e.ventLengthM > 0) rows.push([`Ventilatierooster · ${e.ventColor.toLowerCase()}`, f(e.ventLengthM, 2), 'm']);
|
||
if (totals.trasraamCap.lengthM > 0) rows.push([`Afdekker trasraam · ${totals.trasraamCap.color.toLowerCase()}`, f(totals.trasraamCap.lengthM, 2), 'm']);
|
||
rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.'], ['Binnenspots', totals.lamps.indoor, 'st.']);
|
||
cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
|
||
}
|
||
if (totals.posts) {
|
||
const pp = totals.posts;
|
||
cards.push(UI.specCard('Overkapping', 'columns', (pp.count ? [
|
||
[`${pp.wood} palen ${pp.size}×${pp.size} mm`, pp.count, 'st.'],
|
||
['Lengte totaal', f(pp.lengthM, 2), 'm'], ['Hout', f(pp.m3, 3), 'm³'], ['Gewicht', f(pp.kg), 'kg'], ['Paalvoeten', pp.count, 'st.']] : [])
|
||
.concat(pp.walls ? [[`Zijwand (${pp.walls} st.)`, f(pp.wallM2, 2), 'm²']] : [])));
|
||
}
|
||
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
|
||
const sw = totals.saw, cs = cost.saw;
|
||
cards.push(UI.specCard('Zaagmachine (tijdelijk)', 'scissors', [
|
||
['Zaagmeters', f(sw.meters, 1), 'm'],
|
||
['Zaagtijd robot', CostModel.uur(cs.robotSec / 3600), 'uur'],
|
||
[`Zaagtijd handmatig (×${cost.settings.zagen.handFactor})`, CostModel.uur(cs.handSec / 3600), 'uur'],
|
||
['Zaagsneden', sw.cuts, 'st.'],
|
||
['Verschillende hoeken', sw.angles.length, '']
|
||
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0°)' : `Schuin ${f(a.deg, 1)}°`, a.count, 'sneden']))));
|
||
document.getElementById('ho-report').innerHTML = `<div class="ho-report-head"><h1>${UI.icon('clipboard-data')}Rapport</h1><span>${UI.esc(state.project.naam || 'Woning')} · ${state.units.length} unit(s)</span></div><div class="ho-report-cards">${cards.join('')}</div>`;
|
||
layoutReport();
|
||
}
|
||
const layoutReport = () => { if (reportOpen) UI.masonry(document.querySelector('#ho-report .ho-report-cards'), 300); };
|
||
|
||
function costCards(c) {
|
||
const s = c.summary, S = c.settings;
|
||
const eur = (n) => CostModel.eur(n);
|
||
return [
|
||
UI.specCard('Kostenraming (indicatief)', 'cash-coin',
|
||
s.rows.map(r => [r.sub ? `<b>${r.label}</b>` : r.label, r.sub ? `<b>${eur(r.amount)}</b>` : eur(r.amount), '']),
|
||
['Totaal incl. btw', eur(s.inclBtw), '']),
|
||
stackedBars('Materiaal per groep', 'box-seam', [{ key: 'v', label: 'Materiaal' }],
|
||
c.categories.filter(r => r.mat > 0).map(r => ({ label: r.name, total: r.mat, values: { v: r.mat } })), eur),
|
||
UI.specCard('Arbeid per werkzaamheid', 'tools', c.activities.filter(r => r.sec > 0).map(r => [r.name, CostModel.uur(r.sec / 3600), 'manuur'])
|
||
.concat([[`Toeslag indirect ${S.arbeid.toeslagUren}%`, CostModel.uur(s.hoursIndirect), 'manuur'], [`QA ${S.arbeid.qa}%`, CostModel.uur(s.hoursQa), 'manuur']]), ['Totaal', CostModel.uur(s.hours), 'manuur'])
|
||
];
|
||
}
|
||
const offCosts = CostModel.onChange(() => { if (parts && overviewEl.isConnected) { reportDirty = true; if (reportOpen) renderReport(); } });
|
||
|
||
let pending = null;
|
||
function scheduleRefresh(opts) {
|
||
const keep = !!(opts && opts.keepProps);
|
||
if (pending) { pending.keepProps = pending.keepProps && keep; return; }
|
||
pending = { keepProps: keep };
|
||
requestAnimationFrame(() => { const o = pending; pending = null; if (canvas.isConnected) refresh(o); });
|
||
}
|
||
// Alleen geometrie vrijgeven: materialen zijn gedeeld.
|
||
const disposeGroup = (g) => g && g.traverse(o => { if (o.geometry) o.geometry.dispose(); });
|
||
|
||
function refresh(opts) {
|
||
HousePdf.materialThumbUrls(state).forEach(PdfSheet.preloadImage);
|
||
const painted = state.settings.interiorPaint && view3D.layers.afwerking;
|
||
wallMaterials.spaanplaatMaterial.color.set(painted ? state.settings.interiorColor : SPAANPLAAT_COLOR);
|
||
roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef');
|
||
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, postMaterials, chimneyMaterials, elektraMaterials, layers: view3D.layers });
|
||
reportDirty = true;
|
||
|
||
if (houseGroup) { scene.remove(houseGroup); disposeGroup(houseGroup); }
|
||
houseGroup = new THREE.Group();
|
||
planCaps = null;
|
||
const toM = 0.001;
|
||
HouseModel.placeGroups(parts);
|
||
parts.units.forEach(u => houseGroup.add(u.floorGroup));
|
||
// Doorsnede: de wandmaterialen worden gedeeld, dus altijd zetten (ook weer uit).
|
||
const section = view3D.section;
|
||
const clip = section ? [cutPlane] : [];
|
||
HouseModel.allWallPieces(parts).forEach(p => {
|
||
p.group.traverse(o => {
|
||
if (!o.material) return;
|
||
(Array.isArray(o.material) ? o.material : [o.material]).forEach(m => {
|
||
if (m.clippingPlanes && m.clippingPlanes.length === clip.length) return;
|
||
m.clippingPlanes = clip;
|
||
m.needsUpdate = true;
|
||
});
|
||
});
|
||
houseGroup.add(p.group);
|
||
});
|
||
placeSection();
|
||
if (view3D.layers.dak && !section) {
|
||
parts.units.forEach(u => houseGroup.add(u.roof.group));
|
||
if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup);
|
||
if (parts.postsGroup) houseGroup.add(parts.postsGroup);
|
||
}
|
||
// Alles wat binnen de wanden of onder het plafond zit, snijdt mee met de doorsnede (anders zweeft het boven de snede).
|
||
const clipGroup = (g) => {
|
||
if (!g) return;
|
||
g.traverse(o => {
|
||
if (!o.material || (o.material.clippingPlanes || []).length === clip.length) return;
|
||
o.material.clippingPlanes = clip;
|
||
o.material.needsUpdate = true;
|
||
});
|
||
houseGroup.add(g);
|
||
};
|
||
if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup);
|
||
if (parts.lightingGroup && !section) houseGroup.add(parts.lightingGroup);
|
||
clipGroup(parts.elektraGroup);
|
||
clipGroup(parts.chimneysGroup); // laag Gevelbekleding, net als in de plattegrond
|
||
if (parts.roomsGroup) parts.roomsGroup.userData.ceilings.visible = !section;
|
||
clipGroup(parts.roomsGroup);
|
||
clipGroup(parts.fixturesGroup);
|
||
scene.add(houseGroup);
|
||
|
||
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1));
|
||
const bz0 = Math.min(...parts.rects.map(r => r.z0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
|
||
const cx = (bx0 + bx1) / 2 * toM, cz = (bz0 + bz1) / 2 * toM;
|
||
grid.position.set(cx, 0, cz);
|
||
// 2 mm extra lager: bij maaiveld 0 anders in hetzelfde vlak als de OSB (flikkeren).
|
||
const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel - 2) * toM;
|
||
signPos = null;
|
||
if (!section && view3D.layers.afwerking) houseGroup.add(buildLogoSign(groundY));
|
||
skyEnv.fitTo(houseGroup, groundY);
|
||
ground.position.set(cx, groundY - 0.002, cz);
|
||
applyRenderMode();
|
||
collectDoors();
|
||
if (!cameraFitted) {
|
||
const size = Math.max(bx1 - bx0, bz1 - bz0) * toM;
|
||
controls.target.set(cx, 1.2, cz);
|
||
camera.position.set(cx - size * 0.9, size * 0.8, cz - size * 1.3);
|
||
controls.update();
|
||
cameraFitted = true;
|
||
}
|
||
|
||
const stale = !!selection && !contextTab();
|
||
if (stale) selection = null;
|
||
if (stale || !(opts && opts.keepProps)) renderInspector();
|
||
else updateLive();
|
||
renderWarnings();
|
||
if (reportOpen) renderReport();
|
||
drawPlan();
|
||
renderNav(parts);
|
||
}
|
||
|
||
const SIGN = { ahead: 4000, aside: 1800, clear: 1200, poleH: 2200, poleW: 80, plateW: 1500, plateD: 30 };
|
||
let signPos = null;
|
||
let signMaterial = null;
|
||
const signPoleMat = new THREE.MeshStandardMaterial({ color: 0x2b2b2b, roughness: 0.6, metalness: 0.3 });
|
||
const signEdgeMat = new THREE.MeshStandardMaterial({ color: 0x212121, roughness: 0.6 });
|
||
function getSignMaterial() {
|
||
if (signMaterial) return signMaterial;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 1024; canvas.height = 340;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
const tex = new THREE.CanvasTexture(canvas);
|
||
tex.anisotropy = 8;
|
||
if (window.TECTUM_LOGO) {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const w = canvas.width * 0.86, h = w / window.TECTUM_LOGO.aspect;
|
||
ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
|
||
tex.needsUpdate = true;
|
||
};
|
||
img.src = window.TECTUM_LOGO.png;
|
||
}
|
||
signMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.5 });
|
||
return signMaterial;
|
||
}
|
||
function buildLogoSign(groundY) {
|
||
const { d, n } = frontDoor();
|
||
const t = { x: n.z, z: -n.x };
|
||
// Voordeur in een binnenhoek: opzij kan het bord onder een andere unit vallen, dan verder opzij of de andere kant op.
|
||
const at = (a) => ({ x: d.x + n.x * SIGN.ahead + t.x * a, z: d.z + n.z * SIGN.ahead + t.z * a });
|
||
const free = (q) => !parts.rects.some(r => !r.error && q.x > r.x0 - SIGN.clear && q.x < r.x1 + SIGN.clear && q.z > r.z0 - SIGN.clear && q.z < r.z1 + SIGN.clear);
|
||
signPos = at(SIGN.aside);
|
||
for (let a = SIGN.aside; a <= 30000; a += 500) {
|
||
const q = [at(a), at(-a)].find(free);
|
||
if (q) { signPos = q; break; }
|
||
}
|
||
const toM = 0.001, plateH = SIGN.plateW / 3;
|
||
const g = new THREE.Group();
|
||
const pole = new THREE.Mesh(new THREE.BoxGeometry(SIGN.poleW * toM, SIGN.poleH * toM, SIGN.poleW * toM), signPoleMat);
|
||
pole.position.y = SIGN.poleH / 2 * toM;
|
||
g.add(pole);
|
||
const edge = signEdgeMat;
|
||
const face = getSignMaterial();
|
||
// BoxGeometry-materialvolgorde: +x, -x, +y, -y, +z (voorkant), -z (achterkant).
|
||
const plate = new THREE.Mesh(new THREE.BoxGeometry(SIGN.plateW * toM, plateH * toM, SIGN.plateD * toM),
|
||
[edge, edge, edge, edge, face, face]);
|
||
plate.position.set(0, (SIGN.poleH - plateH / 2 - 100) * toM, (SIGN.poleW + SIGN.plateD) / 2 * toM);
|
||
g.add(plate);
|
||
g.traverse(o => { if (o.isMesh) o.receiveShadow = true; });
|
||
g.position.set(signPos.x * toM, groundY, signPos.z * toM);
|
||
g.rotation.y = Math.atan2(n.x, n.z);
|
||
return g;
|
||
}
|
||
|
||
const WALK_RADIUS = 250;
|
||
const isWalkDoor = (op) => op.type !== 'schoorsteen' && (op.type === 'deur' || op.type === 'voordeur' ||
|
||
(op.fill && (op.fill.type === 'deur' || op.fill.type === 'schuifdeur')) || op.y < 50);
|
||
function wallBlocked(xm, zm) {
|
||
if (!parts) return false;
|
||
const x = xm * 1000, z = zm * 1000;
|
||
if (signPos && Math.hypot(x - signPos.x, z - signPos.z) < SIGN.poleW / 2 + WALK_RADIUS) return true;
|
||
if ((parts.chimneys || []).length && ChimneyModel.covers(parts.chimneys, x, z, WALK_RADIUS)) return true;
|
||
return HouseModel.allWallPieces(parts).some(p => {
|
||
if (p.place.baseY) return false; // opbouw boven een lager dak
|
||
const c = Math.cos(p.place.rotY), s = Math.sin(p.place.rotY);
|
||
const dx = x - p.place.cx, dz = z - p.place.cz;
|
||
const lx = dx * c - dz * s, lz = dx * s + dz * c;
|
||
const W = p.place.width, T = p.place.thickness;
|
||
const out = p.place.normal ? WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc) : 0;
|
||
if (lz < -T / 2 - WALK_RADIUS || lz > T / 2 + out + WALK_RADIUS || Math.abs(lx) > W / 2 + WALK_RADIUS) return false;
|
||
// Door een deur mag je altijd lopen, ook als de opening smal of laag is.
|
||
return !(p.config.openings || []).some(op => isWalkDoor(op) &&
|
||
lx > -W / 2 + op.x - WALK_RADIUS * 0.2 && lx < -W / 2 + op.x + op.w + WALK_RADIUS * 0.2);
|
||
}) || (view3D.layers.inrichting && (parts.fixtures || [])
|
||
.some(f => FixtureModel.blocks(f) && FixtureModel.contains(f, x, z, WALK_RADIUS * 0.6)));
|
||
}
|
||
function groundAt(xm, zm) {
|
||
const x = xm * 1000, z = zm * 1000;
|
||
const u = parts && parts.units.find(q => x >= q.rect.x0 && x <= q.rect.x1 && z >= q.rect.z0 && z <= q.rect.z1);
|
||
const top = parts ? Math.max(...parts.units.map(q => q.layout.floorTop)) : 0;
|
||
return (u ? u.layout.floorTop : top - (state.settings.groundLevel || 0)) / 1000;
|
||
}
|
||
const walk = createWalkControls(camera, renderer.domElement, { blocked: wallBlocked, floorAt: groundAt });
|
||
let orbitSaved = null;
|
||
function frontDoor() {
|
||
const ext = [];
|
||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
|
||
let best = null;
|
||
['voordeur', 'deur'].some(type => ext.some(p => (p.config.openings || []).some(op => {
|
||
if (op.type !== type) return false;
|
||
best = { p, lx: -p.place.width / 2 + op.x + op.w / 2 };
|
||
return true;
|
||
})));
|
||
if (!best) {
|
||
const p = ext.find(q => q.place.normal && q.place.normal.y < -0.5) || ext[0];
|
||
best = { p, lx: 0 };
|
||
}
|
||
const d = UnitModel.localToWorld(best.p.place, best.lx, best.p.place.thickness / 2);
|
||
return { d, n: { x: best.p.place.normal.x, z: best.p.place.normal.y } };
|
||
}
|
||
function walkStartPose() {
|
||
const { d, n } = frontDoor();
|
||
const x = (d.x + n.x * 3000) / 1000, z = (d.z + n.z * 3000) / 1000;
|
||
return { x, z, yaw: Math.atan2(-n.x, -n.z) };
|
||
}
|
||
const walkHint = document.createElement('div');
|
||
walkHint.className = 'walk-hint';
|
||
walkHint.textContent = 'Lopen: W/S of pijltjes = vooruit/achteruit · A/D = opzij · pijl links/rechts = draaien · Shift = rennen · slepen = rondkijken · E = deur/schakelaar · L of Esc = terug';
|
||
pane3D.appendChild(walkHint);
|
||
const walkPrompt = document.createElement('div');
|
||
walkPrompt.className = 'walk-prompt';
|
||
pane3D.appendChild(walkPrompt);
|
||
|
||
const INTERACT = { reach: 2.2, doorAngle: 0.6, switchAngle: 0.3, swingSpeed: 2.4, slideSpeed: 1.2 };
|
||
const doorState = new Map();
|
||
const lightsOff = new Set();
|
||
let doors = [];
|
||
function applyDoor(o) {
|
||
const d = o.userData.door;
|
||
const dir = d.dir === undefined ? (d.kind === 'swing' ? 1 : -1) : d.dir;
|
||
if (d.kind === 'swing') o.rotation.y = dir * d.open;
|
||
else o.position.x = d.base + dir * d.open;
|
||
}
|
||
function collectDoors() {
|
||
doors = [];
|
||
if (!houseGroup) return;
|
||
houseGroup.updateMatrixWorld(true);
|
||
houseGroup.traverse(o => {
|
||
const d = o.userData.door;
|
||
if (!d) return;
|
||
d.world = o.parent.localToWorld(d.center.clone());
|
||
d.key = `${Math.round(d.world.x * 100)}|${Math.round(d.world.z * 100)}`;
|
||
if (doorState.has(d.key)) d.open = d.target = doorState.get(d.key);
|
||
applyDoor(o);
|
||
doors.push(o);
|
||
});
|
||
}
|
||
function animateDoors(dt) {
|
||
doors.forEach(o => {
|
||
const d = o.userData.door;
|
||
if (d.open === d.target) return;
|
||
const step = (d.kind === 'swing' ? INTERACT.swingSpeed : INTERACT.slideSpeed) * Math.min(dt, 0.1);
|
||
d.open = d.target > d.open ? Math.min(d.target, d.open + step) : Math.max(d.target, d.open - step);
|
||
applyDoor(o);
|
||
});
|
||
}
|
||
function applyLightSwitches() {
|
||
const off = (ref) => !!ref.letter && lightsOff.has(ref.letter);
|
||
if (parts) { LightingModel.applySwitches(parts.lightingGroup, off); LightingModel.applySwitches(parts.roofEdgeGroup, off); }
|
||
LightingModel.applySwitches(lightsGroup, off);
|
||
}
|
||
const tmpDir = new THREE.Vector3();
|
||
function interactTarget() {
|
||
if (!walk.active || !parts) return null;
|
||
camera.getWorldDirection(tmpDir);
|
||
const cam = camera.position, hd = Math.hypot(tmpDir.x, tmpDir.z) || 1;
|
||
let best = null;
|
||
const consider = (score, t) => { if (!best || score < best.score) best = Object.assign(t, { score }); };
|
||
doors.forEach(o => {
|
||
const d = o.userData.door, w = d.world;
|
||
if (!o.parent.visible) return;
|
||
const dx = w.x - cam.x, dz = w.z - cam.z, dist = Math.hypot(dx, dz);
|
||
if (dist > INTERACT.reach || dist < 1e-3) return;
|
||
const ang = Math.acos(Math.max(-1, Math.min(1, (dx * tmpDir.x + dz * tmpDir.z) / (dist * hd))));
|
||
if (ang > INTERACT.doorAngle) return;
|
||
consider(ang + dist * 0.1, { kind: 'door', label: d.target > 0 ? 'Deur sluiten' : 'Deur openen', act: () => {
|
||
const open = !(d.target > 0);
|
||
doors.filter(x => x === o || x.parent === o.parent).forEach(x => {
|
||
const dx2 = x.userData.door;
|
||
dx2.target = open ? dx2.max : 0;
|
||
doorState.set(dx2.key, dx2.target);
|
||
});
|
||
} });
|
||
});
|
||
const el = parts.elektra;
|
||
if (el && el.enabled) el.switches.forEach(sw => {
|
||
const p = sw.pos, x = p.x / 1000, y = p.y / 1000, z = p.z / 1000;
|
||
const dx = x - cam.x, dy = y - cam.y, dz = z - cam.z, dist = Math.hypot(dx, dy, dz);
|
||
if (dist > INTERACT.reach || dist < 1e-3) return;
|
||
const ang = Math.acos(Math.max(-1, Math.min(1, (dx * tmpDir.x + dy * tmpDir.y + dz * tmpDir.z) / dist)));
|
||
if (ang > INTERACT.switchAngle) return;
|
||
const off = lightsOff.has(sw.letter);
|
||
consider(ang + dist * 0.1 - 0.05, { kind: 'switch', label: `Schakelaar ${sw.letter}: ${sw.label.toLowerCase()} ${off ? 'aan' : 'uit'}`, act: () => {
|
||
if (off) lightsOff.delete(sw.letter); else lightsOff.add(sw.letter);
|
||
applyRenderMode();
|
||
} });
|
||
});
|
||
return best;
|
||
}
|
||
function interact() {
|
||
const t = interactTarget();
|
||
if (t) t.act();
|
||
}
|
||
let promptText = '';
|
||
function updatePrompt() {
|
||
const t = walk.active ? interactTarget() : null;
|
||
const text = t ? `E · ${t.label}` : '';
|
||
if (text === promptText) return;
|
||
promptText = text;
|
||
walkPrompt.textContent = text;
|
||
walkPrompt.classList.toggle('show', !!text);
|
||
}
|
||
function setWalk(on) {
|
||
if (on === walk.active || !parts) return;
|
||
const view = document.getElementById('house-overview');
|
||
if (on) {
|
||
orbitSaved = { pos: camera.position.clone(), target: controls.target.clone(), fov: camera.fov, cls: view.className };
|
||
view.classList.remove('fullscreen-2d');
|
||
view.classList.add('fullscreen-3d', 'render-only');
|
||
requestAnimationFrame(onResize);
|
||
controls.enableRotate = controls.enablePan = controls.enableZoom = false;
|
||
camera.fov = 65;
|
||
camera.updateProjectionMatrix();
|
||
walk.start(walkStartPose());
|
||
} else {
|
||
walk.stop();
|
||
controls.enableRotate = controls.enablePan = controls.enableZoom = true;
|
||
if (orbitSaved) {
|
||
view.className = orbitSaved.cls;
|
||
requestAnimationFrame(onResize);
|
||
camera.fov = orbitSaved.fov;
|
||
camera.updateProjectionMatrix();
|
||
camera.position.copy(orbitSaved.pos);
|
||
controls.target.copy(orbitSaved.target);
|
||
controls.update();
|
||
}
|
||
}
|
||
walkHint.classList.toggle('show', on);
|
||
if (!on) updatePrompt();
|
||
pane3D.classList.toggle('walking', on);
|
||
syncToolbar();
|
||
}
|
||
|
||
let rafId = null, lastT = performance.now();
|
||
function animate() {
|
||
rafId = requestAnimationFrame(animate);
|
||
const now = performance.now();
|
||
walk.update((now - lastT) / 1000);
|
||
animateDoors((now - lastT) / 1000);
|
||
if (walk.active) updatePrompt();
|
||
lastT = now;
|
||
renderer.render(scene, camera);
|
||
}
|
||
function onResize() {
|
||
camera.aspect = pane3D.clientWidth / pane3D.clientHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
|
||
drawPlan();
|
||
layoutReport();
|
||
}
|
||
window.addEventListener('resize', onResize);
|
||
|
||
refresh();
|
||
syncSectionRow();
|
||
if (view3D.report) toggleReport(true);
|
||
animate();
|
||
setTool(pendingTool || 'select');
|
||
pendingTool = null;
|
||
|
||
return {
|
||
unmount() {
|
||
window.removeEventListener('resize', onResize);
|
||
window.removeEventListener('keydown', onKey);
|
||
window.removeEventListener('mouseup', onElektraDrop);
|
||
window.removeEventListener('mouseup', onFixtureDrop);
|
||
window.removeEventListener('mousemove', onPanMove);
|
||
window.removeEventListener('mouseup', onPanEnd);
|
||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||
setWalk(false); // orbitcamera terug vóór het opslaan
|
||
walk.dispose();
|
||
CameraMemory.save(overviewCamKey, camera, controls);
|
||
disposeGroup(houseGroup);
|
||
disposeLights();
|
||
signPoleMat.dispose(); signEdgeMat.dispose();
|
||
offCosts();
|
||
renderer.dispose();
|
||
},
|
||
refresh,
|
||
select,
|
||
setTool,
|
||
setView: bar.setView,
|
||
toggleReport: () => toggleReport(),
|
||
toggleSection,
|
||
fitView: () => fitPlan(),
|
||
toggleWalk: () => setWalk(!walk.active),
|
||
viewState: () => ({ view: UI.viewOf(overviewEl), report: reportOpen, section: view3D.section, walk: walk.active })
|
||
};
|
||
}
|
||
|
||
function openProjectProperties() {
|
||
HOUSE_PROJECT_KEYS.forEach(k => { document.getElementById('h-proj-' + k).value = state.project[k] || ''; });
|
||
document.getElementById('house-project-overlay').classList.add('open');
|
||
}
|
||
function closeProjectProperties(save) {
|
||
if (save) HOUSE_PROJECT_KEYS.forEach(k => { state.project[k] = document.getElementById('h-proj-' + k).value; });
|
||
document.getElementById('house-project-overlay').classList.remove('open');
|
||
}
|
||
document.getElementById('house-project-save').addEventListener('click', () => closeProjectProperties(true));
|
||
document.getElementById('house-project-cancel').addEventListener('click', () => closeProjectProperties(false));
|
||
document.getElementById('house-project-close-x').addEventListener('click', () => closeProjectProperties(false));
|
||
document.getElementById('house-project-overlay').addEventListener('click', (e) => {
|
||
if (e.target.id === 'house-project-overlay') closeProjectProperties(false);
|
||
});
|
||
|
||
function getStateJson() {
|
||
flushActivePart();
|
||
return HouseModel.clone(state);
|
||
}
|
||
|
||
function loadStateJson(newState) {
|
||
if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount();
|
||
activeEditor = null;
|
||
activePart = null;
|
||
state = HouseModel.normalize(HouseModel.clone(newState));
|
||
selection = null;
|
||
showPart('overzicht');
|
||
}
|
||
|
||
function exportDesign() {
|
||
const suggested = 'woning-ontwerp-' + new Date().toISOString().slice(0, 10);
|
||
let filename = window.prompt('Bestandsnaam voor het opgeslagen ontwerp:', suggested);
|
||
if (filename === null) { setIoStatus('Opslaan geannuleerd.', ''); return null; }
|
||
filename = filename.trim() || suggested;
|
||
if (!filename.toLowerCase().endsWith('.json')) filename += '.json';
|
||
|
||
const design = { type: 'house-design', version: 1, exportedAt: new Date().toISOString(), ...getStateJson() };
|
||
const blob = new Blob([JSON.stringify(design, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
try {
|
||
HousePdf.generate(state).save(filename.replace(/\.json$/i, '') + '.pdf');
|
||
setIoStatus(`Opgeslagen als "${filename}" + PDF.`, 'ok');
|
||
} catch (err) {
|
||
setIoStatus(`Opgeslagen als "${filename}" (PDF mislukt: ${err.message}).`, 'err');
|
||
}
|
||
if (typeof onSaved === 'function') onSaved(filename);
|
||
return filename;
|
||
}
|
||
|
||
function exportAs(ext, title, run, done) {
|
||
flushActivePart();
|
||
const naam = String((state.project && state.project.naam) || '').replace(/[\\/:*?"<>|]+/g, '-').trim();
|
||
const suggested = (naam || 'woning') + '-' + new Date().toISOString().slice(0, 10);
|
||
let filename = window.prompt(`Bestandsnaam voor de ${title}:`, suggested);
|
||
if (filename === null) { setIoStatus('Export geannuleerd.', ''); return null; }
|
||
filename = filename.trim() || suggested;
|
||
if (!new RegExp(`\\.${ext}$`, 'i').test(filename)) filename += '.' + ext;
|
||
try {
|
||
const res = run(filename);
|
||
setIoStatus(`Geëxporteerd als "${filename}" (${typeof done === 'function' ? done(res) : done}).`, 'ok');
|
||
} catch (err) {
|
||
setIoStatus(`${ext.toUpperCase()}-export mislukt: ${err.message}`, 'err');
|
||
}
|
||
return filename;
|
||
}
|
||
const exportDxf = () => exportAs('dxf', 'CAD-export (DXF)', (f) => HouseDxf.generate(state).save(f), 'DXF, in CAD op te slaan als DWG');
|
||
const exportIfc = () => exportAs('ifc', 'BIM-export (IFC)', (f) => HouseIfc.generate(state).save(f), 'IFC4: balken, platen en elektra');
|
||
|
||
// Render-export (sfeerimpressie): alleen wat in de afgewerkte woning te zien is, los van de gekozen lagen, doorsnede
|
||
// en weergave-opties (HouseModel.computeParts met finishedOnly).
|
||
// Lampen als echte lichtbronnen via een Blender-script in de ZIP.
|
||
// Eigen materiaalsets (steen, riet, rabat en tegels komen uit gedeelde caches); maaiveld onder het midden = oorsprong.
|
||
const exportObj = () => exportAs('zip', 'render-export (OBJ + texturen + lampen, ZIP)', (filename) => {
|
||
const wallMaterials = WallModel.createMaterials(), roofMaterials = RoofModel.createMaterials();
|
||
if (state.settings.interiorPaint) wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorColor);
|
||
roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef');
|
||
const full = HouseModel.computeParts(state, {
|
||
wallMaterials, floorMaterials: FloorModel.createMaterials(), roofMaterials, lightingMaterials: LightingModel.createMaterials(),
|
||
sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(), chimneyMaterials: ChimneyModel.createMaterials(),
|
||
elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), finishedOnly: true
|
||
});
|
||
HouseModel.placeGroups(full);
|
||
const name = filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning';
|
||
const origin = HouseModel.groundOrigin(state, full);
|
||
const out = ObjWriter.build(HouseModel.rootGroups(full), { name, app: APP_VERSION, origin });
|
||
const edge = full.roofEdge;
|
||
const lights = LightingModel.lightSources(full.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, full.lighting.indoor);
|
||
out.files.push({ name: `${name}_lampen.py`, data: new TextEncoder().encode(ObjWriter.blenderLights(lights, { name, app: APP_VERSION, origin })) });
|
||
ZipWriter.save(out.files, filename);
|
||
return Object.assign(out.stats, { lights: lights.length, script: `${name}_lampen.py` });
|
||
}, (s) => `${s.triangles.toLocaleString('nl-NL')} driehoeken, ${s.materials} materialen, ${s.textures} texturen, ${s.lights} lampen; uitpakken en in Blender ${s.script} uitvoeren (importeert de OBJ en plaatst de lampen)`);
|
||
|
||
function triggerImport() {
|
||
document.getElementById('h-file-import-json').click();
|
||
}
|
||
|
||
document.getElementById('h-file-import-json').addEventListener('change', (e) => {
|
||
const file = e.target.files[0];
|
||
e.target.value = '';
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
try {
|
||
let design = JSON.parse(ev.target.result);
|
||
if (design && design.type === 'unit-design') {
|
||
UnitModel.validateState(design);
|
||
design = HouseModel.fromUnitDesign(design);
|
||
} else if (!design || design.type !== 'house-design') {
|
||
throw new Error('geen geldig woning- of unit-ontwerp (type ontbreekt of onjuist)');
|
||
} else {
|
||
HouseModel.validateState(design);
|
||
const { type, version, exportedAt, ...pure } = design;
|
||
design = pure;
|
||
}
|
||
loadStateJson(design);
|
||
setIoStatus('Woning geïmporteerd.', 'ok');
|
||
if (typeof onSaved === 'function') onSaved(file.name);
|
||
} catch (err) {
|
||
setIoStatus('Import mislukt: ' + err.message, 'err');
|
||
}
|
||
};
|
||
reader.onerror = () => setIoStatus('Kon bestand niet lezen.', 'err');
|
||
reader.readAsText(file);
|
||
});
|
||
|
||
function forward(fn) {
|
||
return (...args) => { if (activeEditor && typeof activeEditor[fn] === 'function') return activeEditor[fn](...args); };
|
||
}
|
||
const has = (fn) => !!(activeEditor && typeof activeEditor[fn] === 'function');
|
||
// Acties die naar de actieve onderdeel-editor gaan; de API hieronder wordt uit deze lijst opgebouwd.
|
||
const FORWARDED = ['mirrorHorizontal', 'setView', 'toggleReport', 'toggleSection', 'fitView', 'toggleWalk'];
|
||
|
||
function onPartKey(e) {
|
||
if (e.key !== 'Escape' || activePart === 'overzicht' || ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||
showPart('overzicht');
|
||
}
|
||
window.addEventListener('keydown', onPartKey);
|
||
|
||
showPart('overzicht');
|
||
|
||
return {
|
||
unmount() {
|
||
window.removeEventListener('keydown', onPartKey);
|
||
if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount();
|
||
activeEditor = null;
|
||
},
|
||
exportDesign,
|
||
exportDxf,
|
||
exportIfc,
|
||
exportObj,
|
||
triggerImport,
|
||
...Object.fromEntries(FORWARDED.map(fn => [fn, forward(fn)])),
|
||
viewState: () => forward('viewState')() || {},
|
||
// Doorgestuurde acties bestaan alleen als de actieve onderdeel-editor ze kent (bijv. Rapport alleen in de woning).
|
||
can: (fn) => !FORWARDED.includes(fn) || has(fn),
|
||
openProjectProperties,
|
||
getStateJson,
|
||
loadStateJson
|
||
};
|
||
}
|
||
|
||
window.HouseEditor = {
|
||
mount: mountHouseEditor,
|
||
getDefaultState: (template) => HouseModel.createDefaultState(template || 'single')
|
||
};
|