/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const HOUSE_TEMPLATE = `
Geldt voor elk blad van de woning-PDF. Titel en elementnummer worden per blad automatisch ingevuld.
`;
const HOUSE_OVERVIEW_TEMPLATE = `
`;
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', `${UI.tb('Woning', 'arrow-left', 'data-hp="back" title="Terug naar de woning (Esc)"')}${what}
${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) =>
`
${iconName ? UI.icon(iconName) : ''}${label} ${meta ? `${meta} ` : ''}
`;
const chevron = (key) => `${UI.icon('chevron-right')} `;
const group = (key, headHtml, body, hasActive) =>
``;
const head = (key, iconName, label, meta) =>
`${chevron(key)}${UI.icon(iconName)}${label} ${meta ? `${meta} ` : ''}
`;
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,
`${chevron('unit:' + u.id)}${UI.icon('box')}Unit ${u.id} ${u.rect.W} × ${u.rect.D}
`,
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 += `${UI.icon('square-half')}${UI.esc(r.name)} ${houseFmt(r.areaM2, 2)} m²
`;
});
if (R.free.length) body += `${R.free.length} zonder functie ${houseFmt(R.free.reduce((a, r) => a + r.areaM2, 0), 1)} m²
`;
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 += `${UI.icon('dot')}${UI.esc(f.label)} ${f.unitId}
`;
});
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: `${TOOLS.map(([k, label, ic, key, hint]) =>
UI.tb(label, ic, `data-ho-tool="${k}" title="${label} (${key}): ${hint}"`)).join('')}
${UI.tbSep()}${UI.tb('Unit', 'plus-square', 'data-tb="add-unit" title="Unit toevoegen aan de geselecteerde (of laatste) unit"')}
`,
after: `
${UI.tb('Doorsnede', 'scissors', 'data-tb="section" title="Horizontale doorsnede (X)"')}
${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) => `${text}
`;
const pair = (...items) => `${items.join('')}
`;
const thumb = (url, alt) => url ? ` ` : '';
const check = (id, label, checked) => ` ${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) => `
`;
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' }),
`
`);
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: 'Bij 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: `
` });
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: `
` }
];
},
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: `
` });
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: `
` });
groups.push({ label: 'Groepen', html: `
` });
}
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} ${Object.entries(opts).map(([k, v]) =>
`${v} `).join('')} `;
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) => `
`;
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) : `
` },
{ 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' }) : '') +
`
` },
{ 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 ? `${kv('Kopgevel', `wand ${SIDE_SHORT[UnitModel.gableOf(form)].toLowerCase()} + overstaande`)}
` : '') });
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: '
',
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
? `${series.map((sr, i) => ` ${sr.label} `).join('')}
` : '';
const body = rows.map(r => `
${esc(r.label)}${r.sub ? `${r.sub} ` : ''}
${series.map((sr, i) => {
const v = r.values[sr.key] || 0;
return v > 0 ? ` ` : '';
}).join('')}
${fmtVal(r.total)}
`).join('');
return `${UI.icon(iconName)}${title} ${legend}${body}${note ? `
${note}
` : ''}
`;
}
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 ? ` plafond ${r.ceiling} ` : ''}`, 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 = `
${UI.icon('clipboard-data')}Rapport ${UI.esc(state.project.naam || 'Woning')} · ${state.units.length} unit(s) ${cards.join('')}
`;
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 ? `${r.label} ` : r.label, r.sub ? `${eur(r.amount)} ` : 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')
};