/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// house/house.js — Woning-editor (UI). Een woning = units die aan elkaar
// gekoppeld zijn + binnenwanden (later ook dak, chassis voor transport, ...).
//
// Opbouw van het scherm:
// - Links een navigatieboom: Overzicht, per unit de vloer en 4 wanden,
// binnenwanden, en acties (+ Unit, + Binnenwand).
// - Rechts het actieve onderdeel: het woning-overzicht (plattegrond + 3D
// met bouwfase, inspector met Eigenschappen | Rapport), of de ECHTE
// WallEditor/FloorEditor voor één onderdeel.
//
// Geen eigen wand-/vloer-/unitlogica: alles via HouseModel/UnitModel/
// WallModel/FloorModel en HousePdf.
// Plain script, geladen na house-model.js en house-pdf.js.
// ============================================================
const HOUSE_TEMPLATE = `
Geldt voor elk blad van de woning-PDF. Titel en elementnummer worden per blad automatisch ingevuld.
`;
// Overzicht: plattegrond en 3D over de volle breedte; alle bediening zit in
// de ribbon (core/ribbon.js). Tabblad Rapport toont het rapport in plaats
// van de plattegrond.
const HOUSE_OVERVIEW_TEMPLATE = `
`;
const SPACING_LOCK_MSG = 'De h.o.h. is globaal ingesteld voor alle wanden van de woning (Overzicht, 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; // 'overzicht' | 'floor:A' | 'wall:A:voor' | 'iw:3'
let activeEditor = null; // WallEditor/FloorEditor-instantie of het overzicht
// Selectie in het overzicht (blijft bewaard bij wisselen van onderdeel):
// null | { type: 'unit', id } | { type: 'wall', unit, side, element } | { type: 'iw', id }
let selection = null;
let pendingTool = null; // 'iw' = direct in teken-modus bij openen overzicht
// Weergave van het overzicht (blijft bewaard bij wisselen van onderdeel):
// bouwfase (BuildStages) en de hoogte van de doorsnede (mm boven het loopvlak).
const view3D = { stage: BuildStages.FINAL, section: false, sectionMM: 1000, dak: true, lampen: true, elektra: true };
// Camerastand per onderdeel onthouden (CameraMemory), per tabblad.
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);
// ------------------------------------------------------------
// Onderdelen mounten
// ------------------------------------------------------------
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)) {
// Hoogtes gewijzigd: tegenoverliggende wand spiegelen, haakse wanden
// vlak op de hoekhoogtes (UnitModel.syncWallHeights).
const unit = unitById(a).unit, before = unit.walls[b].wall;
const next = activeEditor.getStateJson();
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' }); // naadzijde blijft zonder plaat
}
}
function showPart(part) {
flushActivePart();
if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount();
activeEditor = 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: Overzicht, niets geselecteerd.',
lockExterior: 'De gevelbekleding volgt de woning (Overzicht › 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 }))))
});
activeEditor.loadStateJson(HouseModel.clone(unitById(a).unit.walls[b]));
} else if (kind === 'roof' && unitById(a)) {
HouseModel.applyGlobalSettings(state); // dakmaat volgt evt. de vloer
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 (overzicht) 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 }); // breedte naadwand-deel
else HouseModel.interiorLayout(iwById(Number(a))); // breedte bijwerken
activeEditor = WallEditor.mount(host, { cameraKey: camKey(part), lockWidth: 'De lengte van een binnenwand volgt uit de tekening (overzicht).', lockSpacing: SPACING_LOCK_MSG });
activeEditor.loadStateJson(HouseModel.clone(iwById(Number(a)).wall));
} else {
activePart = 'overzicht';
activeEditor = mountOverview(); // rendert de boom en de ribbon zelf
return;
}
setPartRibbon(kind, a, b);
renderNav();
}
// Ribbon terwijl een onderdeel-editor open is: terug naar de woning en de
// woning-acties; de editor zelf heeft zijn eigen panelen.
function setPartRibbon(kind, a, b) {
const { big } = Ribbon;
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');
Ribbon.setSource('editor', [{
key: 'start', label: 'Start', order: 10,
groups: () => [
{ label: 'Navigatie', html: big('Terug naar woning', 'arrow-left-circle', 'data-hp="back" title="Overzicht van de woning"') },
{ label: 'Onderdeel', html: `${what} Wijzigingen gelden direct voor de woning. Hoogtes van een wand bepalen de dakvorm van de unit (de overstaande wand spiegelt mee).
` },
{ label: 'Woning', html: big('Unit toevoegen', 'plus-square', 'data-hp="add-unit"') + big('Binnenwand tekenen', 'pencil', 'data-hp="draw-iw"') }
],
wire: (body) => body.querySelectorAll('[data-hp]').forEach(btn => btn.addEventListener('click', () => {
if (btn.dataset.hp === 'back') showPart('overzicht');
else if (btn.dataset.hp === 'add-unit') addUnit();
else drawInteriorWall();
}))
}]);
Ribbon.activate('start');
}
// ------------------------------------------------------------
// Navigatieboom
// ------------------------------------------------------------
// parts (optioneel): al doorgerekende onderdelen (overzicht), anders zelf rekenen.
function renderNav(parts) {
parts = parts || HouseModel.computeParts(state, { skip3D: true });
const item = (part, label, meta, cls, iconName, active) =>
`
${iconName ? UI.icon(iconName) : ''}${label} ${meta ? `${meta} ` : ''}
`;
// "Woning" = overzicht zonder selectie: de instellingen van de hele woning.
let html = item('overzicht', 'Woning', `${parts.units.length} unit${parts.units.length === 1 ? '' : 's'}`, 'hn-top', 'house-door',
activePart === 'overzicht' && !selection);
parts.units.forEach(u => {
const sel = selection && selection.type === 'unit' && selection.id === u.id && activePart === 'overzicht';
html += ``;
html += `
${UI.icon('box')}Unit ${u.id} ${u.rect.W} × ${u.rect.D}
`;
html += item(`floor:${u.id}`, 'Vloer', u.floorElement, 'hn-sub', 'grid-3x3');
html += 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(', ');
html += item(`wall:${u.id}:${w.side}`, w.label, meta, 'hn-sub' + (w.removed ? ' hn-removed' : ''), 'bricks');
});
html += `
`;
});
if (parts.interior.length) {
html += `${UI.icon('distribute-horizontal')}Binnenwanden
`;
const seen = new Set();
parts.interior.forEach(p => {
if (seen.has(p.id)) return; // naadwand: twee delen, één item
seen.add(p.id);
if (p.seam) {
const els = parts.interior.filter(x => x.id === p.id).map(x => x.element).join(', ');
html += item(`iw:${p.id}`, `Naadwand ${p.seam.a}|${p.seam.b}`, els, 'hn-sub', 'layout-split');
} else html += item(`iw:${p.id}`, p.element, `${p.place.width} mm`, 'hn-sub', 'distribute-horizontal');
});
html += `
`;
}
document.getElementById('house-nav-tree').innerHTML = html;
}
document.getElementById('house-nav-tree').addEventListener('click', (e) => {
const unitRow = e.target.closest('[data-select-unit]');
if (unitRow) {
selection = { 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);
});
// ------------------------------------------------------------
// Acties: unit toevoegen/verwijderen, binnenwand
// ------------------------------------------------------------
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); // tussenwand met deur op de nieuwe naad
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');
}
// Binnenwand tekenen (ribbon): vanuit een onderdeel eerst terug naar het overzicht.
function drawInteriorWall() {
pendingTool = 'iw';
if (activePart !== 'overzicht') showPart('overzicht');
else activeEditor.setTool('iw');
}
// ------------------------------------------------------------
// OVERZICHT
// ------------------------------------------------------------
function mountOverview() {
host.innerHTML = HOUSE_OVERVIEW_TEMPLATE;
const canvas = document.getElementById('ho-canvas');
const ctx = canvas.getContext('2d');
// --- 3D ---
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);
// Maaiveld: alleen in de volle render, zodat het licht op de grond valt.
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(); // kaal (niet geschilderd)
const floorMaterials = FloorModel.createMaterials();
const roofMaterials = RoofModel.createMaterials();
const lightingMaterials = LightingModel.createMaterials();
const sandwichMaterials = SandwichModel.createMaterials();
const elektraMaterials = ElektraModel.createMaterials();
let houseGroup = null;
// Opgeslagen camerastand gaat voor; anders eenmalig passend maken.
let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls);
// Doorsnede: alle wanden afgesneden op view3D.sectionMM boven het
// loopvlak (clipping-vlak, snede wit afgedekt); dak en lampen uit.
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 });
// Egaal witte afdekking van de snede: per wandstuk een vlak op de
// snijhoogte over de volle dikte (incl. doek/latwerk of steen aan de
// buitenzijde en de hoekdoorloop), met de openingen die door de snede
// lopen uitgespaard. cutAbs = snijhoogte (mm, absoluut).
function buildPlanCaps(cutAbs) {
const toM = 0.001, g = new THREE.Group();
const S = WallModel.STONE;
// Bouwfase: buitenlagen (latwerk is open, dus pas vanaf de gevel) en beplating alleen als ze er staan.
const gevel = BuildStages.shows(view3D.stage, 'gevel'), beplating = BuildStages.shows(view3D.stage, 'beplating');
HouseModel.allWallPieces(parts).forEach(p => {
const y = cutAbs - p.place.floorTop; // snijhoogte in het stuk
if (y <= 0 || y >= WallModel.topLine(p.config).maxY) return;
const W = p.place.width, T = p.place.thickness;
// Buitenlagen op de snijhoogte (alleen buitenwanden: skinExt/normal).
let out = 0;
if (gevel && p.place.normal && p.buitenMateriaal !== 'geen') {
const tr = p.trasraam;
out = tr && y < tr.top ? (WallModel.isStoneFinish(tr.finish) ? S.THICK_MM : 0) : WallModel.claddingOffset(p.buitenMateriaal, p.stone);
}
const e = p.skinExt || {};
const xL = -W / 2 - (e.left > 0 ? e.left + out : 0), xR = W / 2 + (e.right > 0 ? e.right + out : 0);
const gaps = p.config.openings.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean)
.map(([a, b]) => [-W / 2 + a, -W / 2 + b]).sort((a, b) => a[0] - b[0]);
const spans = [];
let cur = xL;
gaps.forEach(([a, b]) => { if (a > cur) spans.push([cur, Math.min(a, xR)]); cur = Math.max(cur, b); });
if (xR > cur) spans.push([cur, xR]);
const sh = beplating ? 0 : p.config.sheathingThick;
const z0 = -T / 2 + sh, z1 = T / 2 + out - (p.buitenMateriaal !== 'geen' ? sh : 0);
spans.filter(([a, b]) => b - a > 1).forEach(([a, b]) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry((b - a) * toM, (z1 - z0) * toM), capMaterial);
m.rotation.x = -Math.PI / 2;
m.position.set((a + b) / 2 * toM, (y - 0.5) * toM, (z0 + z1) / 2 * toM);
const holder = new THREE.Group();
holder.rotation.y = p.place.rotY;
holder.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
holder.add(m);
g.add(holder);
});
});
return g;
}
let fullRender = false;
let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene);
// Bouwfase (core/build-stages.js) + hoogte van de doorsnede.
const stageBar = BuildStages.createBar(pane3D, view3D.stage, (st) => {
view3D.stage = st;
scheduleRefresh({ keepProps: true });
});
stageBar.extra.innerHTML = ` Doorsnede
`;
const sectionInput = document.getElementById('ho-section');
function syncSectionRow() {
stageBar.extra.classList.toggle('show', view3D.section);
document.getElementById('ho-section-val').textContent = `${view3D.sectionMM} mm`;
}
// Snijhoogte (schuif hier en in Beeld › Doorsnede). Alleen het snijvlak
// en de afdekking verplaatsen, niet alles herbouwen.
function setSectionMM(v) {
if (!isFinite(v)) return;
view3D.sectionMM = Math.max(50, Math.min(8000, Math.round(v)));
sectionInput.value = view3D.sectionMM;
UI.setRange('hv-section-h', view3D.sectionMM);
syncSectionRow();
if (!parts || !view3D.section) return;
placeSection();
}
sectionInput.addEventListener('input', () => setSectionMM(Number(sectionInput.value)));
sectionInput.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) sectionInput.blur(); });
syncSectionRow();
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);
}
// Omgeving (core/skybox.js: geen/dag/nacht) en Render. Render = lampen en
// spots als echte lichtbronnen, in het licht van de gekozen omgeving;
// Nacht = omgevingslicht op 15% en de lampen altijd aan (100%). De
// tegelvloer ligt op het maaiveld, dus het ingegraven deel van vloer en
// sandwichpanelen verdwijnt eronder.
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'; // zonder omgeving: vlak waarop het lamplicht valt
grid.visible = skyMode === 'none' && !fullRender;
disposeLights();
// De nep-lichtbundels (kegels) alleen zonder echte lampen.
if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !lampsOn; });
if (lampsOn && parts && view3D.lampen && !view3D.section && BuildStages.shows(view3D.stage, 'afwerking')) {
const edge = view3D.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,
// Nep-lichtvlekken op de gevel (wall-wash), tot het maaiveld.
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);
}
}
// (Omgeving, Render en Tonen: ribbon Beeld, zie ribbonTabs.)
// --- Plattegrond: view-transform + hit-tests ---
let parts = null;
let view = { scale: 1, ox: 0, oy: 0 };
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;
}
// Verplaatsbaar elektra-onderdeel (ElektraModel items) onder de cursor.
function elektraHit(x, z) {
const el = parts && parts.elektra;
if (!el || !el.enabled || !view3D.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);
function hitTest(x, z) {
const eh = elektraHit(x, z);
if (eh) return { type: 'elektra', id: eh.key };
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 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;
}
const sameSel = (a, b) => a && b && a.type === b.type && (a.id === b.id) && (a.element === b.element);
// Selectie wijzigen (plattegrond of boom): inspector naar Eigenschappen.
function select(sel) {
selection = sel;
syncRibbon(true);
drawPlan();
renderNav(parts);
}
// --- Tekenen van binnenwanden ---
let tool = 'select';
let drawStart = null; // { x, z } eerste klik
let hover = null; // { x, z } gesnapt cursorpunt
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 = t === 'iw' ? 'crosshair' : 'default';
updateHint();
drawPlan();
}
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.')
: 'Klik = selecteren · dubbelklik = bewerken';
}
// Snappen: 50 mm raster, haaks op het beginpunt, en op wandvlakken
// (binnen/buitenzijde van wanden) binnen 12 px.
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();
}
// Elektra slepen: langs de wanden (ElektraModel.snapToWall); bij loslaten
// vastleggen als handmatige plek (settings.elektra.place).
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 = ElektraModel.snapToWall(parts.elektra, w.x, w.z);
drawPlan();
});
function onElektraDrop() {
if (!elDrag) return;
const d = elDrag;
elDrag = null;
canvas.style.cursor = tool === 'iw' ? 'crosshair' : 'default';
if (!d.moved || !d.snap) return;
suppressClick = true;
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);
canvas.addEventListener('mousemove', (e) => {
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 (suppressClick) { suppressClick = false; return; }
const hit = hitTest(w.x, w.z);
if (!sameSel(hit, selection)) 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);
const hit = hitTest(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}`);
});
function onKey(e) {
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
if (e.key === 'Escape') {
if (drawStart) { drawStart = null; updateHint(); drawPlan(); } else if (tool === 'iw') setTool('select');
} else if (e.key === 'Delete' && selection && selection.type === 'iw') {
state.interiorWalls = state.interiorWalls.filter(w => w.id !== selection.id);
selection = null;
refresh();
}
}
window.addEventListener('keydown', onKey);
// --- Plattegrond tekenen ---
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));
view = { scale, ox: w / 2 - ((bx0 + bx1) / 2) * scale, oy: h / 2 - ((bz0 + bz1) / 2) * scale };
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 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.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();
});
};
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'));
// Naden
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();
// Maatlabels buiten elk buiten-wandstuk (stijl: core/canvas-labels.js)
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));
})));
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();
});
// Dak: dakranden (gestippeld) + boeideel/daktrim-buitenrand + spots
ctx.save();
ctx.setLineDash([3, 4]);
ctx.strokeStyle = 'rgba(200, 200, 200, 0.55)'; ctx.lineWidth = 1;
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) {
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';
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.restore();
// Binnenspots (plafond): open cirkeltje
ctx.strokeStyle = '#ffe9a8'; ctx.lineWidth = 1.5;
(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();
});
// Gevellampen: blokje op het gevelvlak
ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 1;
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);
});
// Elektra (ElektraModel): leidingen, meterkast, schakelaars met letter,
// wcd met groepnummer en bij elke lamp de letter van zijn schakelaar.
const el = parts.elektra;
if (el && el.enabled && view3D.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); }));
// Geselecteerd onderdeel (ring; gestippeld = handmatig geplaatst) en sleep-voorbeeld.
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();
});
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';
}
// Noordpijl (boven = noord; wanden/zijden heten naar windrichting)
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();
// Teken-preview binnenwand
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();
}
}
// ------------------------------------------------------------
// RIBBON (core/ribbon.js): alle bediening van het overzicht. Vaste
// tabbladen voor de woning, plus een contextueel tabblad voor de
// selectie (unit, wandstuk, binnenwand/naadwand).
// ------------------------------------------------------------
const { num, sel, toggle } = UI;
const { big, small, kv } = Ribbon;
const after = () => scheduleRefresh({ keepProps: true }); // getal/schuif: ribbon blijft staan
const rerender = () => refresh(); // keuze: velden kunnen wijzigen
// Afgeleide waarden in de ribbon die na een verversing meelopen
// (bijv. de dakhelling), zonder het tabblad opnieuw op te bouwen.
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('.', ',');
// Keuzes voor de vormgeving van het trasraam: plaat of een steen.
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions);
// Kliks op knoppen met data-ho-act / data-go / data-ho-tool (per tekening opnieuw).
function wireButtons(body) {
body.querySelectorAll('[data-ho-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.hoTool)));
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-unit') addUnit();
else if (a === 'project') openProjectProperties();
else if (a === 'save') exportDesign();
else if (a === 'costs') CostSettings.open();
else 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') {
state.interiorWalls = state.interiorWalls.filter(w => w.id !== Number(b.dataset.iw));
selection = null;
refresh();
}
}));
}
// --- Vaste tabbladen ---
const startTab = {
key: 'start', label: 'Start', order: 10,
groups: () => [
{ label: 'Gereedschap', html:
big('Selecteren', 'cursor', `data-ho-tool="select" title="Klik = selecteren, dubbelklik = bewerken (Esc)"`, tool === 'select' ? 'active' : '') +
big('Binnenwand tekenen', 'pencil', `data-ho-tool="iw" title="Twee klikken in de plattegrond; snapt op 50 mm, haaks en op wandvlakken"`, tool === 'iw' ? 'active' : '') },
{ label: 'Woning', html: big('Unit toevoegen', 'plus-square', 'data-ho-act="add-unit" title="Aan de geselecteerde (of laatste) unit"') + big('Project', 'card-text', 'data-ho-act="project"') },
{ label: 'Overzicht', html: `
` },
{ label: 'Werkwijze', html: note('Selecteer een unit, wand of binnenwand in de plattegrond of de structuur: het tabblad van die selectie verschijnt rechts. Dubbelklik om te bewerken. Oranje gestippeld = naad tussen units.') }
],
wire: (body) => {
live = [];
wireButtons(body);
const dimsLive = () => {
const el = body.querySelector('[data-live="start-dims"]');
if (!el || !parts) return;
const d = HouseModel.computeDimensions(parts, state.settings.groundLevel), f = houseFmt;
el.innerHTML = kv('Buitenmaten', `${f(d.lengthX / 1000, 2)} × ${f(d.lengthZ / 1000, 2)}`, 'm') + kv('Bruto vloer', f(d.brutoM2, 1), 'm²') +
kv('Bouwhoogte', f(d.height / 1000, 2), 'm') + kv('Leefbaar', f(d.leefbaarM2, 1), 'm²') +
kv('Gevel netto', f(d.gevelNettoM2, 1), 'm²') + kv('Inhoud', f(d.inhoudM3, 0), 'm³');
};
live.push(dimsLive);
dimsLive();
}
};
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]));
return [
{ label: 'Bekleding', html: sel('hs-facade', 'Type', HouseModel.FACADE_TYPES, st.facade.type, { hint: 'Voor alle buitenwanden; per wand 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))
: 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' }),
`
`)) },
{ 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) : '')
: '') },
{ 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) => {
live = [];
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.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.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender);
UI.onRange('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after);
}
};
const dakTab = {
key: 'dak', label: 'Dak', order: 30,
groups: () => {
const r = state.settings.roof;
return [
{ 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) },
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }) },
{ label: 'Daken per unit', html: `
` },
{ label: 'Dakvorm', html: note('Vorm, hoogtes, helling en bedekking zijn per unit: selecteer een unit.') }
];
},
wire: (body) => {
live = [];
const r = state.settings.roof, pos = { positive: true };
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.onNum('hs-trim-h', v => { r.trimHeight = 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], deg = Math.abs(pl ? pl.theta : 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; egaal, zonder plaatnaden. Uit: kale spaanplaat.' }) +
(st.interiorPaint ? UI.color('hs-int-color', 'Verfkleur', st.interiorColor, { hint: 'Voorlopig één kleur voor de hele woning' }) : '') },
{ 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: 'Binnenwanden', html: big('Binnenwand tekenen', 'pencil', 'data-ho-tool="iw"') + note(`${state.interiorWalls.length} binnen-/naadwand(en). Selecteer er een voor positie, dikte en openingen.`) }
];
},
wire: (body) => {
live = [];
wireButtons(body);
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.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: () => {
live = [];
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) => {
live = [];
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, hint: 'Dubbele wcd' }) });
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;
groups.push({ label: 'Plaatsing', html: note('Sleep meterkast, schakelaars, wcd en apparaten in de plattegrond; selecteer er een voor positie en hoogte.') +
(nManual ? big('Alles automatisch', 'arrow-counterclockwise', `data-he-reset title="${nManual} handmatige plek(ken) wissen"`) : '') });
groups.push({ label: 'Leidingen', html: num('he-slack', 'Marge %', e.slack, { step: 1 }) + 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) => {
live = [];
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.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 rs = body.querySelector('[data-he-reset]');
if (rs) rs.addEventListener('click', () => { e.place = {}; 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();
}
};
const rapportTab = {
key: 'rapport', label: 'Rapport', order: 60,
groups: () => [
{ label: 'Rapport', html: note('Transport per unit, gewicht, afmetingen, kosten, hout en plaatwerk. Staat in plaats van de plattegrond zolang dit tabblad open is en loopt mee met elke wijziging.') },
{ label: 'Uitvoer', html: big('Opslaan + PDF', 'file-earmark-pdf', 'data-ho-act="save" title="JSON en de volledige PDF (Ctrl+S)"') },
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-ho-act="costs"') }
],
wire: (body) => { live = []; wireButtons(body); }
};
const beeldTab = {
key: 'beeld', label: 'Beeld', order: 70,
groups: () => [
{ label: 'Tonen', html: check('hv-dak', 'Dak', view3D.dak) + check('hv-lampen', 'Lampen', view3D.lampen) + check('hv-elektra', 'Elektra', view3D.elektra) },
{ label: 'Doorsnede', html: check('hv-section', 'Doorsnede', view3D.section) +
UI.range('hv-section-h', 'Hoogte', view3D.sectionMM, { min: 100, max: 4000, step: 50, hint: 'Snijhoogte boven de bovenkant vloer' }) },
{ label: 'Omgeving', html: `${Skybox.MODES.map(m => small(Skybox.LABELS[m][1], Skybox.LABELS[m][0], `data-sky="${m}"`, m === skyMode ? 'active' : '')).join('')}
` +
small('Render', 'camera', 'data-render title="Elke lamp en spot als echte lichtbron"', fullRender ? 'active' : '') }
],
wire: (body) => {
live = [];
const onCheck = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('change', () => { fn(el.checked); scheduleRefresh({ keepProps: true }); }); };
onCheck('hv-dak', v => { view3D.dak = v; });
onCheck('hv-lampen', v => { view3D.lampen = v; });
onCheck('hv-elektra', v => { view3D.elektra = v; });
onCheck('hv-section', v => { view3D.section = v; syncSectionRow(); });
UI.onRange('hv-section-h', setSectionMM);
body.querySelectorAll('[data-sky]').forEach(b => b.addEventListener('click', () => {
skyMode = b.dataset.sky;
Skybox.setMode(skyMode);
body.querySelectorAll('[data-sky]').forEach(x => x.classList.toggle('active', x === b));
applyRenderMode();
}));
const rb = body.querySelector('[data-render]');
if (rb) rb.addEventListener('click', () => { fullRender = !fullRender; rb.classList.toggle('active', fullRender); applyRenderMode(); });
}
};
// --- Contextueel tabblad: de selectie ---
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));
return null;
}
// Geselecteerd elektra-onderdeel: plek langs de wand en hoogte; slepen in
// de plattegrond of hier invullen maakt de plek handmatig.
function elektraItemTab(it) {
const e = state.settings.elektra;
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"') : '') }
],
wire: (body) => {
live = [];
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(); });
}
};
}
function unitTab(u) {
const isRoot = state.units[0].id === u.id;
const f = u.unit.floor.floor;
// Dakvorm (UnitModel.roofForm): de invoer; de wanden volgen.
const form = UnitModel.roofForm(u.unit);
const sloped = () => form.type !== 'plat';
const LIM = UnitModel.LIMITS;
// Actuele parts opzoeken: na Breedte/Diepte (keepProps) wordt de tab niet opnieuw opgebouwd.
const partsOf = () => parts && parts.units.find(x => x.id === u.id);
// Horizontale lengte van de helling: over de volle vloermaat (goot boven de buitenkant gevel).
const runOf = () => {
const pu = partsOf();
const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form));
const L = pu ? (s.axis === 'x' ? pu.layout.floorWidth : pu.layout.floorDepth) : 1000;
return UnitModel.hasRidge(form.type) ? L / 2 : L;
};
const degOf = () => sloped() ? Math.atan(Math.max(0, form.high - form.low) / runOf()) * 180 / Math.PI : 0;
// Mansarde: hellingen van het onder- en bovenvlak.
const mansardeDeg = () => {
const kIn = Math.max(1, form.kneeIn || 1), rest = Math.max(1, runOf() - kIn);
return [Math.atan(Math.max(0, form.kneeH - form.low) / kIn), Math.atan(Math.max(0, form.high - form.kneeH) / rest)].map(r => r * 180 / Math.PI);
};
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' }) },
{ label: 'Dakvorm', html: sel('hp-roof-type', 'Vorm', UnitModel.ROOF_TYPES, form.type) +
(form.type === 'lessenaar' ? sel('hp-roof-dir', 'Hoge zijde', SIDE_SHORT, form.dir) : '') +
(UnitModel.hasRidge(form.type) ? sel('hp-roof-ridge', 'Nok', UnitModel.RIDGES, form.ridge) : '') +
UI.range('hp-roof-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()) groups.push({ label: 'Helling', html:
UI.range('hp-roof-high', UnitModel.hasRidge(form.type) ? 'Nok' : 'Hoog', form.high, { min: 2100, max: LIM.ridge, step: 10, hint: `Max ${LIM.ridge} mm` }) +
(form.type === 'mansarde' ? '' : UI.range('hp-roof-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' })) +
`${kv('Kopgevel', `wand ${SIDE_SHORT[UnitModel.gableOf(form)].toLowerCase()} + overstaande`)}
` });
if (form.type === 'mansarde') groups.push({ label: 'Mansarde', html:
UI.range('hp-roof-knee-h', 'Knik', form.kneeH, { min: 2100, max: LIM.ridge, step: 10, hint: 'Hoogte van de knik tussen het steile en het flauwe dakvlak' }) +
UI.range('hp-roof-knee-in', 'Knik in', form.kneeIn, { min: 100, max: 3000, step: 10, hint: 'Horizontale afstand van de gevel tot de knik' }) +
`${kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / '))}
` });
if (form.type === 'wolf') groups.push({ label: 'Wolfsdak', html:
UI.range('hp-roof-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' }) });
groups.push({ label: 'Dak', html:
pair(sel('hp-roof-cover', 'Bedekking', covOpts, covNow, { hint: shapeNow === 'plat' ? 'Riet kan 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' }) +
toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) });
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}"`, 'rb-danger')) });
return groups;
},
wire: (body) => {
live = [];
wireButtons(body);
// Unitmaat binnen de grenzen (de andere maat geklemd als beide te groot zijn).
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);
// Andere koppeling: naadwanden van deze unit gaan mee.
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);
// Dakvorm: elke wijziging past de wanden aan (applyRoofForm).
const apply = () => {
form.low = Math.min(form.low, LIM.eave);
form.high = Math.min(form.high, LIM.ridge);
UnitModel.applyRoofForm(u.unit, form);
};
UI.onSel('hp-roof-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 === 'mansarde') {
// Standaard: steil onderdeel (~65°) over ruim 1/3 van de halve gevel, flauw (~27°) bovendeel.
const run = runOf();
form.kneeIn = Math.round(run * 0.35);
form.kneeH = Math.round(form.low + form.kneeIn * 2.1);
form.high = Math.min(LIM.ridge, Math.max(form.high, Math.round(form.kneeH + (run - form.kneeIn) * 0.5)));
form.kneeRatio = Math.min(0.9, (form.kneeH - form.low) / (form.high - form.low));
}
if (v === 'wolf') {
if (form.high - form.low < 1000) form.high = form.low + 1500;
form.cutRatio = 0.6;
}
apply();
}, rerender);
UI.onSel('hp-roof-dir', v => { form.dir = v; apply(); }, rerender);
UI.onSel('hp-roof-ridge', v => { form.ridge = v; apply(); }, rerender);
// Na toepassen: de werkelijke (geklemde) maten in de velden; de
// verhoudingen (kneeRatio, cutRatio) blijven zoals ingesteld.
const syncDeg = () => {
const now = UnitModel.roofForm(u.unit);
if (now.type === form.type) ['kneeH', 'kneeIn', 'cut'].forEach(k => { if (now[k] !== undefined) form[k] = now[k]; });
UI.setRange('hp-roof-deg', Math.round(degOf() * 10) / 10);
UI.setRange('hp-roof-knee-h', form.kneeH);
UI.setRange('hp-roof-knee-in', form.kneeIn);
UI.setRange('hp-roof-cut', form.cut);
const md = body.querySelector('[data-live="roof-mansarde"]');
if (md && form.type === 'mansarde') md.innerHTML = kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / '));
};
UI.onRange('hp-roof-low', v => {
if (!(v >= 500)) return;
form.low = v;
if (sloped() && form.high < v) { form.high = v; UI.setRange('hp-roof-high', v); }
apply(); syncDeg();
}, after);
UI.onRange('hp-roof-high', v => {
if (!(v >= 500)) return;
form.high = Math.max(v, form.low);
apply(); syncDeg();
}, after);
UI.onRange('hp-roof-deg', v => {
if (!(v >= 0.5) || v > 75) return;
form.high = Math.round(form.low + Math.tan(v * Math.PI / 180) * runOf());
UI.setRange('hp-roof-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('hp-roof-knee-h', v => { if (v >= 500) { form.kneeRatio = ratioOf(v); apply(); syncDeg(); } }, after);
UI.onRange('hp-roof-knee-in', v => { if (v >= 50) { form.kneeIn = v; apply(); syncDeg(); } }, after);
UI.onRange('hp-roof-cut', v => { if (v >= 500) { form.cutRatio = ratioOf(v); apply(); syncDeg(); } }, after);
UI.onSel('hp-roof-cover', v => { u.roof.covering = v; }, rerender);
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender);
UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, after);
UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, after);
UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, after);
UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, after);
}
};
}
function wallTab() {
const pu = parts.units.find(x => x.id === selection.unit);
const wl = pu && pu.walls.find(x => x.side === selection.side);
const piece = wl && wl.pieces.find(p => p.element === selection.element);
if (!piece) return null;
const su = unitById(pu.id);
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)) : '') },
{ 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 (tabblad 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) => {
live = [];
wireButtons(body);
const fc = HouseModel.facadeFor(state, su, wl.side);
UI.onSel('hw-light', v => { su.lighting.walls[wl.side] = v; }, rerender);
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 } : { type: v };
}, rerender);
UI.onSel('hw-facade-stone', v => { su.facade.walls[wl.side].stone = v; }, rerender);
}
};
}
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"`, 'rb-danger') }
],
wire: (body) => {
live = [];
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);
}
};
}
// Naadwand: midden op de naad (twee delen, één per unit) of geheel in
// unit A/B (één wand). Eén set openingen en maten; de hoogte volgt per
// deel de daklijn van zijn unit.
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}"`, 'rb-danger') }
];
},
wire: (body) => {
live = [];
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);
}
};
}
// Ribbon bijwerken (na een verversing of een andere selectie). focus =
// het selectie-tabblad openen.
function syncRibbon(focus) {
const tabs = [startTab, gevelTab, dakTab, binnenTab, constructieTab, lichtTab, elektraTab, rapportTab, beeldTab];
const ctx = contextTab();
if (ctx) tabs.push(ctx);
Ribbon.setSource('editor', tabs, { focus: focus && !!ctx });
}
// Waarschuwingen (overlappende units, ongeldige koppeling) op de plattegrond.
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('');
}
// --- Rapport: in plaats van de plattegrond zolang tabblad Rapport open is ---
let reportOpen = false, reportDirty = true;
const overviewEl = document.getElementById('house-overview');
const offRibbon = Ribbon.onChange((key) => {
const open = key === 'rapport';
if (open === reportOpen) return;
reportOpen = open;
overviewEl.classList.toggle('show-report', open);
if (open && reportDirty && parts) renderReport();
requestAnimationFrame(onResize);
});
// Grafieken (HTML, geen bibliotheek). Kleuren: vaste volgorde, zie css
// (--series-1..3); waarden staan er altijd als tekst bij, de tabel
// eronder is de toegankelijke weergave.
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)),
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.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']);
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));
}
// 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('')}
`;
}
// Kostenraming (CostModel; kosten/tijden via Instellingen > Kosten & tijden).
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(); } });
// --- Alles opnieuw doorrekenen ---
// Verversen gebundeld per beeldje (schuiven en typen geven veel events).
let pending = null, firstSync = true;
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); });
}
// Oude 3D vrijgeven (geometrie wordt per verversing nieuw opgebouwd;
// materialen worden gedeeld en blijven).
const disposeGroup = (g) => g && g.traverse(o => { if (o.geometry) o.geometry.dispose(); });
function refresh(opts) {
// Voorvertoningen van de gekozen materialen alvast laden voor de PDF
// (legenda op het aanzichtenblad; HousePdf.materialThumbs).
HousePdf.materialThumbUrls(state).forEach(PdfSheet.preloadImage);
// Binnenzijde geschilderd (laatste bouwfase): de gedeelde spaanplaat krijgt de verfkleur.
const painted = state.settings.interiorPaint && BuildStages.shows(view3D.stage, 'afwerking');
wallMaterials.spaanplaatMaterial.color.set(painted ? state.settings.interiorColor : SPAANPLAAT_COLOR);
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, elektraMaterials, stage: view3D.stage, lampenVisible: view3D.lampen, elektraVisible: view3D.elektra });
reportDirty = true;
if (houseGroup) { scene.remove(houseGroup); disposeGroup(houseGroup); }
houseGroup = new THREE.Group();
planCaps = null;
const toM = 0.001;
parts.units.forEach(u => {
u.floorGroup.position.set(u.rect.cx * toM, 0, u.rect.cz * toM);
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.rotation.y = p.place.rotY;
p.group.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
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.dak && !section) {
parts.units.forEach(u => {
u.roof.group.position.set(u.roof.rect.cx * toM, u.roof.base * toM, u.roof.rect.cz * toM);
houseGroup.add(u.roof.group);
});
if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup);
}
if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup);
if (parts.lightingGroup && !section) houseGroup.add(parts.lightingGroup);
if (parts.elektraGroup) houseGroup.add(parts.elektraGroup);
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);
// Maaiveld: settings.groundLevel onder het loopvlak (bovenkant vloer).
// 2 mm extra lager: bij maaiveld 0 niet 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 && BuildStages.shows(view3D.stage, 'afwerking')) houseGroup.add(buildLogoSign(groundY));
skyEnv.fitTo(houseGroup, groundY);
ground.position.set(cx, groundY - 0.002, cz);
applyRenderMode();
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); // kijkt tegen de voorzijde aan
controls.update();
cameraFitted = true;
}
// Selectie die na de herberekening niet meer bestaat (wandstuk hernoemd of vervallen): loslaten.
const stale = !!selection && !contextTab();
if (stale) selection = null;
if (stale || !(opts && opts.keepProps)) syncRibbon(firstSync && !!selection);
else updateLive();
firstSync = false;
renderWarnings();
if (reportOpen) renderReport();
drawPlan();
renderNav(parts);
}
// --- Bord met logo: paal + plaat naast het pad, enkele meters voor de
// voordeur, met de plaat naar buiten gericht (leesbaar vanaf de straat).
const SIGN = { ahead: 4000, aside: 1800, poleH: 2200, poleW: 80, plateW: 1500, plateD: 30 };
let signPos = null; // mm, voor botsen tijdens lopen
let signMaterial = null;
// Paal en rand: één keer aanmaken (buildLogoSign draait bij elke refresh).
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();
// Opzij langs de gevel (rechts gezien vanaf de straat), zodat het pad vrij blijft.
const t = { x: n.z, z: -n.x };
signPos = { x: d.x + n.x * SIGN.ahead + t.x * SIGN.aside, z: d.z + n.z * SIGN.ahead + t.z * SIGN.aside };
const toM = 0.001, plateH = SIGN.plateW / 3; // canvas 1024 x 340
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); // lokale +z = gevelnormaal (naar buiten)
return g;
}
// --- Lopen (first person), eigen sneltoets Shift+T ---
// Start 3 m voor de voordeur, buiten; botsen tegen wanden (deuren zijn
// doorgangen), ondergrond = vloer binnen een unit, anders het maaiveld.
const WALK_RADIUS = 250; // mm, "lichaam"
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;
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) : 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(opening) mag je lopen.
return !(p.config.openings || []).some(op => op.y < 100 && op.y + Math.min(op.h, op.hR) > 1800 &&
lx > -W / 2 + op.x + WALK_RADIUS * 0.6 && lx < -W / 2 + op.x + op.w - 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;
// Voordeur (anders een buitendeur, anders midden van de noordgevel):
// midden van de opening aan de buitenzijde (mm) + gevelnormaal naar buiten.
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 } };
}
// Startpositie: 3 m voor de voordeur, kijkend naar de deur.
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 · Shift+T = terug';
pane3D.appendChild(walkHint);
function setWalk(on) {
if (on === walk.active || !parts) return;
const view = document.getElementById('house-overview');
if (on) {
// Lopen gebeurt in de volle 3D (alleen render); vorige stand onthouden.
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 && !shooter.active);
pane3D.classList.toggle('walking', on);
if (!on && shooter.active) shooter.stop();
}
// --- Easter egg: schieten (Ctrl+Shift+Alt+L), bovenop het lopen ---
const shooter = createShooter({ scene, camera, domElement: renderer.domElement, pane: pane3D, walk,
floorAt: groundAt, blocked: wallBlocked, targets: () => [houseGroup, ground] });
function toggleShooter() {
if (shooter.active) { setWalk(false); return; }
if (!parts) return;
setWalk(true);
walk.speedFactor = 1.3; // 30% sneller in de shooter-modus
shooter.start();
walkHint.classList.remove('show');
}
let rafId = null, lastT = performance.now();
function animate() {
rafId = requestAnimationFrame(animate);
const now = performance.now();
walk.update((now - lastT) / 1000);
shooter.update((now - lastT) / 1000);
lastT = now;
renderer.render(scene, camera);
}
function onResize() {
camera.aspect = pane3D.clientWidth / pane3D.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
drawPlan();
}
window.addEventListener('resize', onResize);
function toggleView(fn) {
const el = document.getElementById('house-overview');
setWalk(false); // weergave wisselen beëindigt het lopen
fn(el);
requestAnimationFrame(onResize);
}
refresh();
animate();
setTool(pendingTool || 'select');
pendingTool = null;
return {
unmount() {
window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKey);
window.removeEventListener('mouseup', onElektraDrop);
if (rafId !== null) cancelAnimationFrame(rafId);
setWalk(false); // orbitcamera terug vóór het opslaan
walk.dispose();
shooter.dispose();
CameraMemory.save(overviewCamKey, camera, controls);
disposeGroup(houseGroup);
disposeLights();
signPoleMat.dispose(); signEdgeMat.dispose();
offRibbon();
offCosts();
Ribbon.setSource('editor', []);
renderer.dispose();
},
refresh,
select,
setTool,
toggleFullscreen3D: () => toggleView(UI.cycle3D),
toggleWalk: () => setWalk(!walk.active),
toggleShooter,
toggleFullscreen2D: () => toggleView(UI.toggle2D)
};
}
// ------------------------------------------------------------
// Project eigenschappen (woning-niveau)
// ------------------------------------------------------------
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);
});
// ------------------------------------------------------------
// JSON + PDF
// ------------------------------------------------------------
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 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 () => { if (activeEditor && typeof activeEditor[fn] === 'function') activeEditor[fn](); };
}
showPart('overzicht');
return {
unmount() {
if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount();
activeEditor = null;
},
exportDesign,
triggerImport,
mirrorHorizontal: forward('mirrorHorizontal'),
toggleFullscreen3D: forward('toggleFullscreen3D'),
toggleWalk: forward('toggleWalk'),
toggleShooter: forward('toggleShooter'),
toggleFullscreen2D: forward('toggleFullscreen2D'),
openProjectProperties,
getStateJson,
loadStateJson
};
}
window.HouseEditor = {
mount: mountHouseEditor,
getDefaultState: (template) => HouseModel.createDefaultState(template || 'single')
};