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

${UI.icon(iconName)}${title}

${legend}${body}${note ? `
${note}
` : ''}
`; } function renderReport() { reportDirty = false; const f = houseFmt; const totals = HouseModel.computeTotals(parts); const dims = HouseModel.computeDimensions(parts, state.settings.groundLevel); const transport = HouseModel.computeTransport(parts); const cost = CostModel.compute(parts, totals); const kg = (v) => `${f(v, 0)} kg`; const m = (mm) => f(mm / 1000, 2); const cards = [ stackedBars('Transport per unit', 'truck', [ { key: 'vloer', label: 'Vloer' }, { key: 'wanden', label: 'Wanden' }, { key: 'dak', label: 'Dak' } ], transport.map(t => ({ label: `Unit ${t.id}`, total: t.totalKg, values: t.kg, sub: `${m(t.length)} × ${m(t.width)} × ${m(t.height)} m · ${HouseModel.TRANSPORT_CLASSES[t.cls].toLowerCase()}` })), kg, `Buitenmaat l × b × h incl. gevelafwerking, zonder dakrand. Breedte tot ${HouseModel.TRANSPORT.widthRegular} mm regulier, tot ${HouseModel.TRANSPORT.widthPermit} mm met ontheffing (indicatief). Gewicht zonder kozijnen, isolatie en steen.`), UI.specCard('Gewicht', 'speedometer2', totals.weights.map(r => [r.label, f(r.kg), 'kg']), ['Totaal woning', f(totals.totalKg), 'kg']), UI.specCard('Afmetingen', 'rulers', HouseModel.dimensionRows(dims, f)), UI.specCard('Ruimtes (netto vloer)', 'bounding-box', parts.rooms.rooms.map(r => [`${esc(r.name)}${r.ceilingActive ? ` plafond ${r.ceiling}` : ''}`, f(r.areaM2, 2), 'm²']) .concat(parts.rooms.free.length ? [[`Zonder functie (${parts.rooms.free.length})`, f(parts.rooms.free.reduce((a, r) => a + r.areaM2, 0), 2), 'm²']] : []), ['Totaal netto', f(parts.rooms.rooms.concat(parts.rooms.free).reduce((a, r) => a + r.areaM2, 0), 2), 'm²']), stackedBars('Kostprijs per unit', 'cash-coin', [{ key: 'v', label: 'Kostprijs' }], cost.units.map(u => ({ label: u.id ? `Unit ${u.id}` : 'Woning-breed', total: u.kostprijs, values: { v: u.kostprijs } })), (v) => CostModel.eur(v), 'Woning-breed: dakrand, fundering, verlichting, kraan.'), ...costCards(cost), UI.specCard('Hout & isolatie', 'tree', [ ['Balken', totals.beamCount, 'st.'], ['Volume hout', f(totals.woodM3, 3), 'm³'], ['Inkoop wandbalken', WallModel.purchaseText(totals.purchase), ''], ['Snijverlies', `${f(totals.purchaseWasteMM / 1000, 2)} m · ${f(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0)}`, '%'], [`Latwerk ${WallModel.BATTEN.thick}×${WallModel.BATTEN.width}`, `${totals.battens.count} × ${WallModel.BATTEN.length}`, 'mm'], ['Glaswol', f(totals.isolatieM3, 3), 'm³'] ]), UI.specCard('Plaatwerk', 'layers', totals.plaatwerk.filter(pw => pw.m2 > 0).map(pw => [pw.naam, f(pw.m2, 2), 'm²'])) ]; if (totals.nokbalken.length) cards.push(UI.specCard('Stalen nokbalk', 'house', totals.nokbalken.flatMap(n => [ [`Unit ${n.unit}: ${n.name} S235${n.ok ? '' : ' (te licht!)'}`, `${f(n.lengthM, 2)} m · ${f(n.kg)}`, 'kg'], ['Benutting sterkte / doorbuiging', `${f(n.util * 100)} % · ${f(n.defl, 1)} / ${f(n.deflMax, 1)}`, 'mm'], ['Vulhout in de holling', `${f(n.fillM3, 3)} m³${n.below ? ` · ${n.below} mm onder plafond` : ''}`, ''] ]))); if (totals.roofEdge) { const e = totals.roofEdge, rows = []; if (e.fasciaLengthM > 0) rows.push([`Boeidelen · ${e.fasciaHeight} mm`, f(e.fasciaLengthM, 2), 'm'], ['Onderplaat boeideel', f(e.soffitM2, 2), 'm²']); if (e.trimLengthM > 0) rows.push([`Daktrim · ${e.trimHeight} mm`, f(e.trimLengthM, 2), 'm']); if (e.ventLengthM > 0) rows.push([`Ventilatierooster · ${e.ventColor.toLowerCase()}`, f(e.ventLengthM, 2), 'm']); if (totals.trasraamCap.lengthM > 0) rows.push([`Afdekker trasraam · ${totals.trasraamCap.color.toLowerCase()}`, f(totals.trasraamCap.lengthM, 2), 'm']); rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.'], ['Binnenspots', totals.lamps.indoor, 'st.']); cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows)); } if (totals.posts) { const pp = totals.posts; cards.push(UI.specCard('Overkapping', 'columns', (pp.count ? [ [`${pp.wood} palen ${pp.size}×${pp.size} mm`, pp.count, 'st.'], ['Lengte totaal', f(pp.lengthM, 2), 'm'], ['Hout', f(pp.m3, 3), 'm³'], ['Gewicht', f(pp.kg), 'kg'], ['Paalvoeten', pp.count, 'st.']] : []) .concat(pp.walls ? [[`Zijwand (${pp.walls} st.)`, f(pp.wallM2, 2), 'm²']] : []))); } // TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht. const sw = totals.saw, cs = cost.saw; cards.push(UI.specCard('Zaagmachine (tijdelijk)', 'scissors', [ ['Zaagmeters', f(sw.meters, 1), 'm'], ['Zaagtijd robot', CostModel.uur(cs.robotSec / 3600), 'uur'], [`Zaagtijd handmatig (×${cost.settings.zagen.handFactor})`, CostModel.uur(cs.handSec / 3600), 'uur'], ['Zaagsneden', sw.cuts, 'st.'], ['Verschillende hoeken', sw.angles.length, ''] ].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0°)' : `Schuin ${f(a.deg, 1)}°`, a.count, 'sneden'])))); document.getElementById('ho-report').innerHTML = `

${UI.icon('clipboard-data')}Rapport

${UI.esc(state.project.naam || 'Woning')} · ${state.units.length} unit(s)
${cards.join('')}
`; layoutReport(); } const layoutReport = () => { if (reportOpen) UI.masonry(document.querySelector('#ho-report .ho-report-cards'), 300); }; function costCards(c) { const s = c.summary, S = c.settings; const eur = (n) => CostModel.eur(n); return [ UI.specCard('Kostenraming (indicatief)', 'cash-coin', s.rows.map(r => [r.sub ? `${r.label}` : r.label, r.sub ? `${eur(r.amount)}` : eur(r.amount), '']), ['Totaal incl. btw', eur(s.inclBtw), '']), stackedBars('Materiaal per groep', 'box-seam', [{ key: 'v', label: 'Materiaal' }], c.categories.filter(r => r.mat > 0).map(r => ({ label: r.name, total: r.mat, values: { v: r.mat } })), eur), UI.specCard('Arbeid per werkzaamheid', 'tools', c.activities.filter(r => r.sec > 0).map(r => [r.name, CostModel.uur(r.sec / 3600), 'manuur']) .concat([[`Toeslag indirect ${S.arbeid.toeslagUren}%`, CostModel.uur(s.hoursIndirect), 'manuur'], [`QA ${S.arbeid.qa}%`, CostModel.uur(s.hoursQa), 'manuur']]), ['Totaal', CostModel.uur(s.hours), 'manuur']) ]; } const offCosts = CostModel.onChange(() => { if (parts && overviewEl.isConnected) { reportDirty = true; if (reportOpen) renderReport(); } }); let pending = null; function scheduleRefresh(opts) { const keep = !!(opts && opts.keepProps); if (pending) { pending.keepProps = pending.keepProps && keep; return; } pending = { keepProps: keep }; requestAnimationFrame(() => { const o = pending; pending = null; if (canvas.isConnected) refresh(o); }); } // Alleen geometrie vrijgeven: materialen zijn gedeeld. const disposeGroup = (g) => g && g.traverse(o => { if (o.geometry) o.geometry.dispose(); }); function refresh(opts) { HousePdf.materialThumbUrls(state).forEach(PdfSheet.preloadImage); const painted = state.settings.interiorPaint && view3D.layers.afwerking; wallMaterials.spaanplaatMaterial.color.set(painted ? state.settings.interiorColor : SPAANPLAAT_COLOR); roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef'); parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, postMaterials, chimneyMaterials, elektraMaterials, layers: view3D.layers }); reportDirty = true; if (houseGroup) { scene.remove(houseGroup); disposeGroup(houseGroup); } houseGroup = new THREE.Group(); planCaps = null; const toM = 0.001; HouseModel.placeGroups(parts); parts.units.forEach(u => houseGroup.add(u.floorGroup)); // Doorsnede: de wandmaterialen worden gedeeld, dus altijd zetten (ook weer uit). const section = view3D.section; const clip = section ? [cutPlane] : []; HouseModel.allWallPieces(parts).forEach(p => { p.group.traverse(o => { if (!o.material) return; (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => { if (m.clippingPlanes && m.clippingPlanes.length === clip.length) return; m.clippingPlanes = clip; m.needsUpdate = true; }); }); houseGroup.add(p.group); }); placeSection(); if (view3D.layers.dak && !section) { parts.units.forEach(u => houseGroup.add(u.roof.group)); if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup); if (parts.postsGroup) houseGroup.add(parts.postsGroup); } // Alles wat binnen de wanden of onder het plafond zit, snijdt mee met de doorsnede (anders zweeft het boven de snede). const clipGroup = (g) => { if (!g) return; g.traverse(o => { if (!o.material || (o.material.clippingPlanes || []).length === clip.length) return; o.material.clippingPlanes = clip; o.material.needsUpdate = true; }); houseGroup.add(g); }; if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup); if (parts.lightingGroup && !section) houseGroup.add(parts.lightingGroup); clipGroup(parts.elektraGroup); clipGroup(parts.chimneysGroup); // laag Gevelbekleding, net als in de plattegrond if (parts.roomsGroup) parts.roomsGroup.userData.ceilings.visible = !section; clipGroup(parts.roomsGroup); clipGroup(parts.fixturesGroup); scene.add(houseGroup); const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1)); const bz0 = Math.min(...parts.rects.map(r => r.z0)), bz1 = Math.max(...parts.rects.map(r => r.z1)); const cx = (bx0 + bx1) / 2 * toM, cz = (bz0 + bz1) / 2 * toM; grid.position.set(cx, 0, cz); // 2 mm extra lager: bij maaiveld 0 anders in hetzelfde vlak als de OSB (flikkeren). const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel - 2) * toM; signPos = null; if (!section && view3D.layers.afwerking) houseGroup.add(buildLogoSign(groundY)); skyEnv.fitTo(houseGroup, groundY); ground.position.set(cx, groundY - 0.002, cz); applyRenderMode(); collectDoors(); if (!cameraFitted) { const size = Math.max(bx1 - bx0, bz1 - bz0) * toM; controls.target.set(cx, 1.2, cz); camera.position.set(cx - size * 0.9, size * 0.8, cz - size * 1.3); controls.update(); cameraFitted = true; } const stale = !!selection && !contextTab(); if (stale) selection = null; if (stale || !(opts && opts.keepProps)) renderInspector(); else updateLive(); renderWarnings(); if (reportOpen) renderReport(); drawPlan(); renderNav(parts); } const SIGN = { ahead: 4000, aside: 1800, clear: 1200, poleH: 2200, poleW: 80, plateW: 1500, plateD: 30 }; let signPos = null; let signMaterial = null; const signPoleMat = new THREE.MeshStandardMaterial({ color: 0x2b2b2b, roughness: 0.6, metalness: 0.3 }); const signEdgeMat = new THREE.MeshStandardMaterial({ color: 0x212121, roughness: 0.6 }); function getSignMaterial() { if (signMaterial) return signMaterial; const canvas = document.createElement('canvas'); canvas.width = 1024; canvas.height = 340; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); const tex = new THREE.CanvasTexture(canvas); tex.anisotropy = 8; if (window.TECTUM_LOGO) { const img = new Image(); img.onload = () => { const w = canvas.width * 0.86, h = w / window.TECTUM_LOGO.aspect; ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h); tex.needsUpdate = true; }; img.src = window.TECTUM_LOGO.png; } signMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.5 }); return signMaterial; } function buildLogoSign(groundY) { const { d, n } = frontDoor(); const t = { x: n.z, z: -n.x }; // Voordeur in een binnenhoek: opzij kan het bord onder een andere unit vallen, dan verder opzij of de andere kant op. const at = (a) => ({ x: d.x + n.x * SIGN.ahead + t.x * a, z: d.z + n.z * SIGN.ahead + t.z * a }); const free = (q) => !parts.rects.some(r => !r.error && q.x > r.x0 - SIGN.clear && q.x < r.x1 + SIGN.clear && q.z > r.z0 - SIGN.clear && q.z < r.z1 + SIGN.clear); signPos = at(SIGN.aside); for (let a = SIGN.aside; a <= 30000; a += 500) { const q = [at(a), at(-a)].find(free); if (q) { signPos = q; break; } } const toM = 0.001, plateH = SIGN.plateW / 3; const g = new THREE.Group(); const pole = new THREE.Mesh(new THREE.BoxGeometry(SIGN.poleW * toM, SIGN.poleH * toM, SIGN.poleW * toM), signPoleMat); pole.position.y = SIGN.poleH / 2 * toM; g.add(pole); const edge = signEdgeMat; const face = getSignMaterial(); // BoxGeometry-materialvolgorde: +x, -x, +y, -y, +z (voorkant), -z (achterkant). const plate = new THREE.Mesh(new THREE.BoxGeometry(SIGN.plateW * toM, plateH * toM, SIGN.plateD * toM), [edge, edge, edge, edge, face, face]); plate.position.set(0, (SIGN.poleH - plateH / 2 - 100) * toM, (SIGN.poleW + SIGN.plateD) / 2 * toM); g.add(plate); g.traverse(o => { if (o.isMesh) o.receiveShadow = true; }); g.position.set(signPos.x * toM, groundY, signPos.z * toM); g.rotation.y = Math.atan2(n.x, n.z); return g; } const WALK_RADIUS = 250; const isWalkDoor = (op) => op.type !== 'schoorsteen' && (op.type === 'deur' || op.type === 'voordeur' || (op.fill && (op.fill.type === 'deur' || op.fill.type === 'schuifdeur')) || op.y < 50); function wallBlocked(xm, zm) { if (!parts) return false; const x = xm * 1000, z = zm * 1000; if (signPos && Math.hypot(x - signPos.x, z - signPos.z) < SIGN.poleW / 2 + WALK_RADIUS) return true; if ((parts.chimneys || []).length && ChimneyModel.covers(parts.chimneys, x, z, WALK_RADIUS)) return true; return HouseModel.allWallPieces(parts).some(p => { if (p.place.baseY) return false; // opbouw boven een lager dak const c = Math.cos(p.place.rotY), s = Math.sin(p.place.rotY); const dx = x - p.place.cx, dz = z - p.place.cz; const lx = dx * c - dz * s, lz = dx * s + dz * c; const W = p.place.width, T = p.place.thickness; const out = p.place.normal ? WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc) : 0; if (lz < -T / 2 - WALK_RADIUS || lz > T / 2 + out + WALK_RADIUS || Math.abs(lx) > W / 2 + WALK_RADIUS) return false; // Door een deur mag je altijd lopen, ook als de opening smal of laag is. return !(p.config.openings || []).some(op => isWalkDoor(op) && lx > -W / 2 + op.x - WALK_RADIUS * 0.2 && lx < -W / 2 + op.x + op.w + WALK_RADIUS * 0.2); }) || (view3D.layers.inrichting && (parts.fixtures || []) .some(f => FixtureModel.blocks(f) && FixtureModel.contains(f, x, z, WALK_RADIUS * 0.6))); } function groundAt(xm, zm) { const x = xm * 1000, z = zm * 1000; const u = parts && parts.units.find(q => x >= q.rect.x0 && x <= q.rect.x1 && z >= q.rect.z0 && z <= q.rect.z1); const top = parts ? Math.max(...parts.units.map(q => q.layout.floorTop)) : 0; return (u ? u.layout.floorTop : top - (state.settings.groundLevel || 0)) / 1000; } const walk = createWalkControls(camera, renderer.domElement, { blocked: wallBlocked, floorAt: groundAt }); let orbitSaved = null; function frontDoor() { const ext = []; parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p)))); let best = null; ['voordeur', 'deur'].some(type => ext.some(p => (p.config.openings || []).some(op => { if (op.type !== type) return false; best = { p, lx: -p.place.width / 2 + op.x + op.w / 2 }; return true; }))); if (!best) { const p = ext.find(q => q.place.normal && q.place.normal.y < -0.5) || ext[0]; best = { p, lx: 0 }; } const d = UnitModel.localToWorld(best.p.place, best.lx, best.p.place.thickness / 2); return { d, n: { x: best.p.place.normal.x, z: best.p.place.normal.y } }; } function walkStartPose() { const { d, n } = frontDoor(); const x = (d.x + n.x * 3000) / 1000, z = (d.z + n.z * 3000) / 1000; return { x, z, yaw: Math.atan2(-n.x, -n.z) }; } const walkHint = document.createElement('div'); walkHint.className = 'walk-hint'; walkHint.textContent = 'Lopen: W/S of pijltjes = vooruit/achteruit · A/D = opzij · pijl links/rechts = draaien · Shift = rennen · slepen = rondkijken · E = deur/schakelaar · L of Esc = terug'; pane3D.appendChild(walkHint); const walkPrompt = document.createElement('div'); walkPrompt.className = 'walk-prompt'; pane3D.appendChild(walkPrompt); const INTERACT = { reach: 2.2, doorAngle: 0.6, switchAngle: 0.3, swingSpeed: 2.4, slideSpeed: 1.2 }; const doorState = new Map(); const lightsOff = new Set(); let doors = []; function applyDoor(o) { const d = o.userData.door; const dir = d.dir === undefined ? (d.kind === 'swing' ? 1 : -1) : d.dir; if (d.kind === 'swing') o.rotation.y = dir * d.open; else o.position.x = d.base + dir * d.open; } function collectDoors() { doors = []; if (!houseGroup) return; houseGroup.updateMatrixWorld(true); houseGroup.traverse(o => { const d = o.userData.door; if (!d) return; d.world = o.parent.localToWorld(d.center.clone()); d.key = `${Math.round(d.world.x * 100)}|${Math.round(d.world.z * 100)}`; if (doorState.has(d.key)) d.open = d.target = doorState.get(d.key); applyDoor(o); doors.push(o); }); } function animateDoors(dt) { doors.forEach(o => { const d = o.userData.door; if (d.open === d.target) return; const step = (d.kind === 'swing' ? INTERACT.swingSpeed : INTERACT.slideSpeed) * Math.min(dt, 0.1); d.open = d.target > d.open ? Math.min(d.target, d.open + step) : Math.max(d.target, d.open - step); applyDoor(o); }); } function applyLightSwitches() { const off = (ref) => !!ref.letter && lightsOff.has(ref.letter); if (parts) { LightingModel.applySwitches(parts.lightingGroup, off); LightingModel.applySwitches(parts.roofEdgeGroup, off); } LightingModel.applySwitches(lightsGroup, off); } const tmpDir = new THREE.Vector3(); function interactTarget() { if (!walk.active || !parts) return null; camera.getWorldDirection(tmpDir); const cam = camera.position, hd = Math.hypot(tmpDir.x, tmpDir.z) || 1; let best = null; const consider = (score, t) => { if (!best || score < best.score) best = Object.assign(t, { score }); }; doors.forEach(o => { const d = o.userData.door, w = d.world; if (!o.parent.visible) return; const dx = w.x - cam.x, dz = w.z - cam.z, dist = Math.hypot(dx, dz); if (dist > INTERACT.reach || dist < 1e-3) return; const ang = Math.acos(Math.max(-1, Math.min(1, (dx * tmpDir.x + dz * tmpDir.z) / (dist * hd)))); if (ang > INTERACT.doorAngle) return; consider(ang + dist * 0.1, { kind: 'door', label: d.target > 0 ? 'Deur sluiten' : 'Deur openen', act: () => { const open = !(d.target > 0); doors.filter(x => x === o || x.parent === o.parent).forEach(x => { const dx2 = x.userData.door; dx2.target = open ? dx2.max : 0; doorState.set(dx2.key, dx2.target); }); } }); }); const el = parts.elektra; if (el && el.enabled) el.switches.forEach(sw => { const p = sw.pos, x = p.x / 1000, y = p.y / 1000, z = p.z / 1000; const dx = x - cam.x, dy = y - cam.y, dz = z - cam.z, dist = Math.hypot(dx, dy, dz); if (dist > INTERACT.reach || dist < 1e-3) return; const ang = Math.acos(Math.max(-1, Math.min(1, (dx * tmpDir.x + dy * tmpDir.y + dz * tmpDir.z) / dist))); if (ang > INTERACT.switchAngle) return; const off = lightsOff.has(sw.letter); consider(ang + dist * 0.1 - 0.05, { kind: 'switch', label: `Schakelaar ${sw.letter}: ${sw.label.toLowerCase()} ${off ? 'aan' : 'uit'}`, act: () => { if (off) lightsOff.delete(sw.letter); else lightsOff.add(sw.letter); applyRenderMode(); } }); }); return best; } function interact() { const t = interactTarget(); if (t) t.act(); } let promptText = ''; function updatePrompt() { const t = walk.active ? interactTarget() : null; const text = t ? `E · ${t.label}` : ''; if (text === promptText) return; promptText = text; walkPrompt.textContent = text; walkPrompt.classList.toggle('show', !!text); } function setWalk(on) { if (on === walk.active || !parts) return; const view = document.getElementById('house-overview'); if (on) { orbitSaved = { pos: camera.position.clone(), target: controls.target.clone(), fov: camera.fov, cls: view.className }; view.classList.remove('fullscreen-2d'); view.classList.add('fullscreen-3d', 'render-only'); requestAnimationFrame(onResize); controls.enableRotate = controls.enablePan = controls.enableZoom = false; camera.fov = 65; camera.updateProjectionMatrix(); walk.start(walkStartPose()); } else { walk.stop(); controls.enableRotate = controls.enablePan = controls.enableZoom = true; if (orbitSaved) { view.className = orbitSaved.cls; requestAnimationFrame(onResize); camera.fov = orbitSaved.fov; camera.updateProjectionMatrix(); camera.position.copy(orbitSaved.pos); controls.target.copy(orbitSaved.target); controls.update(); } } walkHint.classList.toggle('show', on); if (!on) updatePrompt(); pane3D.classList.toggle('walking', on); syncToolbar(); } let rafId = null, lastT = performance.now(); function animate() { rafId = requestAnimationFrame(animate); const now = performance.now(); walk.update((now - lastT) / 1000); animateDoors((now - lastT) / 1000); if (walk.active) updatePrompt(); lastT = now; renderer.render(scene, camera); } function onResize() { camera.aspect = pane3D.clientWidth / pane3D.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(pane3D.clientWidth, pane3D.clientHeight); drawPlan(); layoutReport(); } window.addEventListener('resize', onResize); refresh(); syncSectionRow(); if (view3D.report) toggleReport(true); animate(); setTool(pendingTool || 'select'); pendingTool = null; return { unmount() { window.removeEventListener('resize', onResize); window.removeEventListener('keydown', onKey); window.removeEventListener('mouseup', onElektraDrop); window.removeEventListener('mouseup', onFixtureDrop); window.removeEventListener('mousemove', onPanMove); window.removeEventListener('mouseup', onPanEnd); if (rafId !== null) cancelAnimationFrame(rafId); setWalk(false); // orbitcamera terug vóór het opslaan walk.dispose(); CameraMemory.save(overviewCamKey, camera, controls); disposeGroup(houseGroup); disposeLights(); signPoleMat.dispose(); signEdgeMat.dispose(); offCosts(); renderer.dispose(); }, refresh, select, setTool, setView: bar.setView, toggleReport: () => toggleReport(), toggleSection, fitView: () => fitPlan(), toggleWalk: () => setWalk(!walk.active), viewState: () => ({ view: UI.viewOf(overviewEl), report: reportOpen, section: view3D.section, walk: walk.active }) }; } function openProjectProperties() { HOUSE_PROJECT_KEYS.forEach(k => { document.getElementById('h-proj-' + k).value = state.project[k] || ''; }); document.getElementById('house-project-overlay').classList.add('open'); } function closeProjectProperties(save) { if (save) HOUSE_PROJECT_KEYS.forEach(k => { state.project[k] = document.getElementById('h-proj-' + k).value; }); document.getElementById('house-project-overlay').classList.remove('open'); } document.getElementById('house-project-save').addEventListener('click', () => closeProjectProperties(true)); document.getElementById('house-project-cancel').addEventListener('click', () => closeProjectProperties(false)); document.getElementById('house-project-close-x').addEventListener('click', () => closeProjectProperties(false)); document.getElementById('house-project-overlay').addEventListener('click', (e) => { if (e.target.id === 'house-project-overlay') closeProjectProperties(false); }); function getStateJson() { flushActivePart(); return HouseModel.clone(state); } function loadStateJson(newState) { if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount(); activeEditor = null; activePart = null; state = HouseModel.normalize(HouseModel.clone(newState)); selection = null; showPart('overzicht'); } function exportDesign() { const suggested = 'woning-ontwerp-' + new Date().toISOString().slice(0, 10); let filename = window.prompt('Bestandsnaam voor het opgeslagen ontwerp:', suggested); if (filename === null) { setIoStatus('Opslaan geannuleerd.', ''); return null; } filename = filename.trim() || suggested; if (!filename.toLowerCase().endsWith('.json')) filename += '.json'; const design = { type: 'house-design', version: 1, exportedAt: new Date().toISOString(), ...getStateJson() }; const blob = new Blob([JSON.stringify(design, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); try { HousePdf.generate(state).save(filename.replace(/\.json$/i, '') + '.pdf'); setIoStatus(`Opgeslagen als "${filename}" + PDF.`, 'ok'); } catch (err) { setIoStatus(`Opgeslagen als "${filename}" (PDF mislukt: ${err.message}).`, 'err'); } if (typeof onSaved === 'function') onSaved(filename); return filename; } function exportAs(ext, title, run, done) { flushActivePart(); const naam = String((state.project && state.project.naam) || '').replace(/[\\/:*?"<>|]+/g, '-').trim(); const suggested = (naam || 'woning') + '-' + new Date().toISOString().slice(0, 10); let filename = window.prompt(`Bestandsnaam voor de ${title}:`, suggested); if (filename === null) { setIoStatus('Export geannuleerd.', ''); return null; } filename = filename.trim() || suggested; if (!new RegExp(`\\.${ext}$`, 'i').test(filename)) filename += '.' + ext; try { const res = run(filename); setIoStatus(`Geëxporteerd als "${filename}" (${typeof done === 'function' ? done(res) : done}).`, 'ok'); } catch (err) { setIoStatus(`${ext.toUpperCase()}-export mislukt: ${err.message}`, 'err'); } return filename; } const exportDxf = () => exportAs('dxf', 'CAD-export (DXF)', (f) => HouseDxf.generate(state).save(f), 'DXF, in CAD op te slaan als DWG'); const exportIfc = () => exportAs('ifc', 'BIM-export (IFC)', (f) => HouseIfc.generate(state).save(f), 'IFC4: balken, platen en elektra'); // Render-export (sfeerimpressie): alleen wat in de afgewerkte woning te zien is, los van de gekozen lagen, doorsnede // en weergave-opties (HouseModel.computeParts met finishedOnly). // Lampen als echte lichtbronnen via een Blender-script in de ZIP. // Eigen materiaalsets (steen, riet, rabat en tegels komen uit gedeelde caches); maaiveld onder het midden = oorsprong. const exportObj = () => exportAs('zip', 'render-export (OBJ + texturen + lampen, ZIP)', (filename) => { const wallMaterials = WallModel.createMaterials(), roofMaterials = RoofModel.createMaterials(); if (state.settings.interiorPaint) wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorColor); roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef'); const full = HouseModel.computeParts(state, { wallMaterials, floorMaterials: FloorModel.createMaterials(), roofMaterials, lightingMaterials: LightingModel.createMaterials(), sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(), chimneyMaterials: ChimneyModel.createMaterials(), elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), finishedOnly: true }); HouseModel.placeGroups(full); const name = filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning'; const origin = HouseModel.groundOrigin(state, full); const out = ObjWriter.build(HouseModel.rootGroups(full), { name, app: APP_VERSION, origin }); const edge = full.roofEdge; const lights = LightingModel.lightSources(full.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, full.lighting.indoor); out.files.push({ name: `${name}_lampen.py`, data: new TextEncoder().encode(ObjWriter.blenderLights(lights, { name, app: APP_VERSION, origin })) }); ZipWriter.save(out.files, filename); return Object.assign(out.stats, { lights: lights.length, script: `${name}_lampen.py` }); }, (s) => `${s.triangles.toLocaleString('nl-NL')} driehoeken, ${s.materials} materialen, ${s.textures} texturen, ${s.lights} lampen; uitpakken en in Blender ${s.script} uitvoeren (importeert de OBJ en plaatst de lampen)`); function triggerImport() { document.getElementById('h-file-import-json').click(); } document.getElementById('h-file-import-json').addEventListener('change', (e) => { const file = e.target.files[0]; e.target.value = ''; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { let design = JSON.parse(ev.target.result); if (design && design.type === 'unit-design') { UnitModel.validateState(design); design = HouseModel.fromUnitDesign(design); } else if (!design || design.type !== 'house-design') { throw new Error('geen geldig woning- of unit-ontwerp (type ontbreekt of onjuist)'); } else { HouseModel.validateState(design); const { type, version, exportedAt, ...pure } = design; design = pure; } loadStateJson(design); setIoStatus('Woning geïmporteerd.', 'ok'); if (typeof onSaved === 'function') onSaved(file.name); } catch (err) { setIoStatus('Import mislukt: ' + err.message, 'err'); } }; reader.onerror = () => setIoStatus('Kon bestand niet lezen.', 'err'); reader.readAsText(file); }); function forward(fn) { return (...args) => { if (activeEditor && typeof activeEditor[fn] === 'function') return activeEditor[fn](...args); }; } const has = (fn) => !!(activeEditor && typeof activeEditor[fn] === 'function'); // Acties die naar de actieve onderdeel-editor gaan; de API hieronder wordt uit deze lijst opgebouwd. const FORWARDED = ['mirrorHorizontal', 'setView', 'toggleReport', 'toggleSection', 'fitView', 'toggleWalk']; function onPartKey(e) { if (e.key !== 'Escape' || activePart === 'overzicht' || ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; showPart('overzicht'); } window.addEventListener('keydown', onPartKey); showPart('overzicht'); return { unmount() { window.removeEventListener('keydown', onPartKey); if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount(); activeEditor = null; }, exportDesign, exportDxf, exportIfc, exportObj, triggerImport, ...Object.fromEntries(FORWARDED.map(fn => [fn, forward(fn)])), viewState: () => forward('viewState')() || {}, // Doorgestuurde acties bestaan alleen als de actieve onderdeel-editor ze kent (bijv. Rapport alleen in de woning). can: (fn) => !FORWARDED.includes(fn) || has(fn), openProjectProperties, getStateJson, loadStateJson }; } window.HouseEditor = { mount: mountHouseEditor, getDefaultState: (template) => HouseModel.createDefaultState(template || 'single') };