/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ const WALL_TEMPLATE = `
BUITENAANZICHT
BOVENAANZICHT
ZIJAANZICHT
3D

Afmetingen

mm
mm
mm
mm

Stijlen

mm
mm

Regels (boven en onder)

mm
mm
mm

Regelwerk

mm
mm

Buitenzijde

Richting gevelbekleding

Op het vochtdoek komt een kruislat van 20×30 mm, 400 mm h.o.h. in beide richtingen. De bovenste lat staat haaks op de gevelbekleding.

Balken

Verticale balken-
Horizontale balken-
Dakhelling-

Houtwerk

Volume hout-
Gewicht hout-
Inkoop balken-
Snijverlies-

Binnenzijde: spaanplaat

Oppervlak-
Volume-
Gewicht-

Buitenzijde: Vochtdoek

Oppervlak-
Volume-
Gewicht-
Latwerk 20×30-

Isolatie en gewicht

Isolatie-
Totaal gewicht-
`; function mountWallEditor(container, callbacks) { const onSaved = callbacks && callbacks.onSaved; container.innerHTML = WALL_TEMPLATE; if (callbacks && callbacks.lockWidth) { const widthInput = document.getElementById('inp-wall-w'); widthInput.disabled = true; widthInput.title = callbacks.lockWidth; } const lockCladding = callbacks && callbacks.lockCladding; const lockExterior = callbacks && callbacks.lockExterior; if (lockExterior && lockCladding) { const tabBtn = container.querySelector('.tab-btn[data-tab="tab-materials"]'); if (tabBtn) tabBtn.style.display = 'none'; } if (callbacks && callbacks.lockSpacing) { ['inp-min-sp', 'inp-max-sp'].forEach(id => { const el = document.getElementById(id); el.disabled = true; el.title = callbacks.lockSpacing; }); } const seamCuts = (callbacks && callbacks.seamCuts) || []; const lampMarks = (callbacks && callbacks.lamps) || []; let CONFIG = {}; let calcData = { fullStuds: [], studs: [] }; let wallGroup3D = null; let lastBaseHeight = 2400; let wallTopPts = null; // offset: left = vanaf de linker wandrand, right = vanaf de rechter, middle = t.o.v. het midden (+ = rechts). // hR = hoogte rechts bij een schuine bovenzijde; de onderkant is altijd waterpas. let openings = [ { id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') } ]; let openingIdCounter = 2; let selectedOpeningId = null; let dragInfo = null; let frontView = { scale: 1, centerX: 0, floorY: 0 }; let frontOpeningRects = []; let frontHandleRects = []; function snap50(mm) { return Math.round(mm / 50) * 50; } function applyOpeningPosition(op, leftLocalX, w, wallWidth) { if (op.anchor === 'right') op.offset = wallWidth - leftLocalX - w; else if (op.anchor === 'middle') op.offset = leftLocalX - (wallWidth - w) / 2; else op.offset = leftLocalX; op.w = w; } const isSloped = (op) => op.hR !== undefined; const rawOpening = (o) => Object.assign({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset }, o.hR !== undefined ? { hR: o.hR } : {}, o.fill ? { fill: Object.assign({}, o.fill) } : {}); function syncOpeningCardInputs(op) { const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`); if (!card) return; if (!!card.querySelector('input[data-field="hR"]') !== isSloped(op)) { renderOpeningsList(); return; } ['w', 'h', 'hR', 'y', 'offset'].forEach(field => { const input = card.querySelector(`input[data-field="${field}"]`); if (input && document.activeElement !== input) input.value = Math.round(op[field]); }); } function highlightSelectedCard() { document.querySelectorAll('.opening-card').forEach(card => { const id = parseInt(card.dataset.openingId, 10); card.classList.toggle('opening-selected', id === selectedOpeningId); }); } function switchTab(tabId) { document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(ct => ct.classList.remove('active')); document.querySelector(`.tab-btn[data-tab="${tabId}"]`).classList.add('active'); document.getElementById(tabId).classList.add('active'); } document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); }); function anchorOffsetLabel(anchor) { if (anchor === 'right') return 'Afstand tot rechts'; if (anchor === 'middle') return 'Verschuiving t.o.v. midden'; return 'Afstand tot links'; } function addOpening() { const n = openings.length; const preset = WallModel.OPENING_TYPES.raam; openings.push({ id: openingIdCounter++, type: 'raam', w: preset.w, h: preset.h, y: preset.y, anchor: 'left', offset: 300 + n * 1400, fill: KozijnModel.defaultFill('raam', lastFill()) }); renderOpeningsList(); updateAll(); } const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer', 'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking']; let projectPropsSnapshot = null; function openProjectProperties() { projectPropsSnapshot = {}; PROJECT_PROP_IDS.forEach(id => { projectPropsSnapshot[id] = document.getElementById(id).value; }); document.getElementById('wall-project-overlay').classList.add('open'); } function closeProjectProperties() { document.getElementById('wall-project-overlay').classList.remove('open'); } document.getElementById('wall-project-save').addEventListener('click', closeProjectProperties); document.getElementById('wall-project-cancel').addEventListener('click', () => { if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; }); closeProjectProperties(); }); document.getElementById('wall-project-close-x').addEventListener('click', () => { if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; }); closeProjectProperties(); }); document.getElementById('wall-project-overlay').addEventListener('click', (e) => { if (e.target.id === 'wall-project-overlay') { if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; }); closeProjectProperties(); } }); function removeOpening(id) { openings = openings.filter(o => o.id !== id); if (selectedOpeningId === id) selectedOpeningId = null; renderOpeningsList(); updateAll(); } // Verticaal spiegelen bewust niet: een wand mag onderin niet scheef worden (menu-item blijft uit). function mirrorHorizontal() { const hLeftInput = document.getElementById('inp-wall-h-left'); const hRightInput = document.getElementById('inp-wall-h-right'); const tmp = hLeftInput.value; hLeftInput.value = hRightInput.value; hRightInput.value = tmp; if (wallTopPts) wallTopPts = wallTopPts.map(q => ({ f: 1 - q.f, h: q.h })).reverse(); openings = openings.map(o => { if (isSloped(o)) o = { ...o, h: o.hR, hR: o.h }; if (o.anchor === 'left') return { ...o, anchor: 'right' }; if (o.anchor === 'right') return { ...o, anchor: 'left' }; if (o.anchor === 'middle') return { ...o, offset: -o.offset }; return o; }); renderOpeningsList(); updateAll(); setIoStatus('Horizontaal gespiegeld.', 'ok'); } function fillControls(op) { const f = KozijnModel.normalize(op.fill); const type = f ? f.type : 'geen'; const opts = (entries, cur) => entries.map(([k, label]) => ``).join(''); const colors = Object.entries(KozijnModel.COLORS).map(([k, c]) => [k, `${c.label} (${c.ral})`]); const sw = (field, label, on, title) => `
`; const pick = (field, label, entries, cur, title) => `
`; return `
${type === 'raam' ? sw('fillRooster', 'Rooster', f.rooster, 'Ventilatierooster bovenin het kozijn') : ''} ${type === 'deur' ? sw('fillGlas', 'Glasstrook', f.glas, 'Glasstrook in het deurblad') : ''} ${type === 'schuifdeur' ? sw('fillGlas', 'Glazen blad', f.glas, 'Glas in een smal kader; uit = dicht blad') : ''} ${type === 'deur' ? sw('fillVolglas', 'Glasdeur', f.volglas, 'Glas in het hele blad (smal kader)') + pick('fillLeaf', 'Blad', KozijnModel.HINGES, f.leaf, 'Kant van het deurblad (van buitenaf gezien) als er een zijlicht naast past') + pick('fillHinge', 'Scharnier', KozijnModel.HINGES, f.hinge, 'Van buitenaf gezien. Openslaande deuren: twee deuren naast elkaar en gekoppeld, bladen naar het midden, scharnieren buiten') + pick('fillSwing', 'Draait', KozijnModel.SWINGS, f.swing) : ''} ${type === 'schuifdeur' ? pick('fillLeaves', 'Bladen', { 1: '1 blad', 2: '2 bladen (uit elkaar)' }, String(f.leaves)) + pick('fillMount', 'Montage', KozijnModel.SLIDE_MOUNTS, f.mount) + (f.mount === 'opbouw' ? pick('fillSide', 'Rail aan', KozijnModel.SLIDE_SIDES, f.side, 'Buitenzijde = +z (bij een binnenwand de "buiten"-kant)') : '') + (f.leaves === 1 ? pick('fillDir', 'Schuift', KozijnModel.SLIDE_DIRS, f.dir, 'Van buitenaf gezien') : '') : ''}
${f ? `
` : ''}
`; } const lastFill = () => { const o = openings.slice().reverse().find(x => x.fill); return o ? o.fill : null; }; function renderOpeningsList() { const list = document.getElementById('openings-list'); list.innerHTML = openings.map(op => `
${(WallModel.OPENING_TYPES[op.type] || WallModel.OPENING_TYPES.raam).label} #${op.id}
mm
mm
${isSloped(op) ? `
mm
` : ''}
mm
mm
${isSloped(op) ? `` : ''}
${fillControls(op)}
`).join(''); highlightSelectedCard(); } document.getElementById('openings-list').addEventListener('click', (e) => { const header = e.target.closest('.opening-card-header'); if (header && !e.target.closest('.opening-remove-btn')) { const card = header.closest('.opening-card'); selectedOpeningId = parseInt(card.dataset.openingId, 10); highlightSelectedCard(); draw2D(); } }); document.getElementById('openings-list').addEventListener('input', (e) => { const id = parseInt(e.target.dataset.id, 10); const field = e.target.dataset.field; if (!field) return; const op = openings.find(o => o.id === id); if (!op) return; if (field === 'type') { const preset = WallModel.OPENING_TYPES[e.target.value]; op.type = e.target.value; op.w = preset.w; op.h = preset.h; op.y = preset.y; delete op.hR; if (op.fill) op.fill = KozijnModel.defaultFill(op.type, op.fill); renderOpeningsList(); } else if (field === 'fill') { const v = e.target.value; if (v === 'geen') delete op.fill; else op.fill = Object.assign(KozijnModel.defaultFill(v === 'deur' || v === 'schuifdeur' ? (op.type === 'raam' ? 'deur' : op.type) : 'raam', op.fill || lastFill()), { type: v }); renderOpeningsList(); } else if (/^fill(Rooster|Glas|Volglas|Out|In|Hinge|Leaf|Swing|Leaves|Mount|Side|Dir)$/.test(field)) { if (!op.fill) return; const key = { fillRooster: 'rooster', fillGlas: 'glas', fillVolglas: 'volglas', fillOut: 'out', fillIn: 'in', fillHinge: 'hinge', fillLeaf: 'leaf', fillSwing: 'swing', fillLeaves: 'leaves', fillMount: 'mount', fillSide: 'side', fillDir: 'dir' }[field]; op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : key === 'leaves' ? Number(e.target.value) : e.target.value; if (key === 'leaves' || key === 'mount') renderOpeningsList(); } else if (field === 'koppel') { if (e.target.checked) op.koppel = true; else delete op.koppel; } else if (field === 'sloped') { if (e.target.checked) op.hR = op.h; else delete op.hR; renderOpeningsList(); } else if (field === 'anchor') { op.anchor = e.target.value; renderOpeningsList(); } else { op[field] = parseFloat(e.target.value) || 0; } updateAll(); }); document.getElementById('openings-list').addEventListener('click', (e) => { if (e.target.dataset.action === 'remove') { removeOpening(parseInt(e.target.dataset.id, 10)); } const roofBtn = e.target.closest('[data-action="follow-roof"]'); if (roofBtn) followRoof(parseInt(roofBtn.dataset.id, 10)); }); document.getElementById('btn-add-opening').addEventListener('click', addOpening); function followRoof(id) { const op = openings.find(o => o.id === id); const res = (CONFIG.openings || []).find(o => o.id === id); if (!op || !res) return; const at = WallModel.topLine(CONFIG).at, x0 = -CONFIG.wallWidth / 2 + res.x; op.hR = Math.max(400, Math.round(op.h + at(x0 + res.w) - at(x0))); syncOpeningCardInputs(op); updateAll(); } function setIoStatus(msg, cls) { const el = document.getElementById('io-status'); el.textContent = msg; el.className = 'io-status ' + (cls || ''); clearTimeout(setIoStatus._t); setIoStatus._t = setTimeout(() => { el.textContent = ''; }, 4000); } function collectWallParams() { return { width: parseFloat(document.getElementById('inp-wall-w').value), heightAll: parseFloat(document.getElementById('inp-wall-h-all').value), heightLeft: parseFloat(document.getElementById('inp-wall-h-left').value), heightRight: parseFloat(document.getElementById('inp-wall-h-right').value), heightMid: document.getElementById('inp-wall-peak').checked ? parseFloat(document.getElementById('inp-wall-h-mid').value) : null, topPts: wallTopPts ? wallTopPts.map(q => Object.assign({}, q)) : undefined, studWidth: parseFloat(document.getElementById('inp-stud-w').value), studDepth: parseFloat(document.getElementById('inp-stud-d').value), plateThick: parseFloat(document.getElementById('inp-plate-w').value), plateDepth: parseFloat(document.getElementById('inp-plate-d').value), sheathingThick: parseFloat(document.getElementById('inp-sheathing-t').value), minSpacing: parseFloat(document.getElementById('inp-min-sp').value), maxSpacing: parseFloat(document.getElementById('inp-max-sp').value) }; } function getPureState() { return { wall: collectWallParams(), materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, fels }, openings: openings.map(rawOpening) }; } function applyPureState(state) { document.getElementById('inp-wall-w').value = state.wall.width; document.getElementById('inp-wall-h-all').value = state.wall.heightAll; document.getElementById('inp-wall-h-left').value = state.wall.heightLeft; document.getElementById('inp-wall-h-right').value = state.wall.heightRight; document.getElementById('inp-wall-peak').checked = state.wall.heightMid > 0; document.getElementById('inp-wall-h-mid').value = state.wall.heightMid > 0 ? state.wall.heightMid : Math.max(state.wall.heightLeft, state.wall.heightRight) + 800; document.getElementById('inp-stud-w').value = state.wall.studWidth; document.getElementById('inp-stud-d').value = state.wall.studDepth; document.getElementById('inp-plate-w').value = state.wall.plateThick; document.getElementById('inp-plate-d').value = state.wall.plateDepth; document.getElementById('inp-sheathing-t').value = state.wall.sheathingThick; document.getElementById('inp-min-sp').value = state.wall.minSpacing; document.getElementById('inp-max-sp').value = state.wall.maxSpacing; lastBaseHeight = state.wall.heightAll; wallTopPts = Array.isArray(state.wall.topPts) && state.wall.topPts.length ? state.wall.topPts.map(q => Object.assign({}, q)) : null; buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek'; cladding = (state.materials && state.materials.cladding) || 'horizontaal'; trasraam = (state.materials && state.materials.trasraam) || null; stone = (state.materials && state.materials.stone) || null; stuc = (state.materials && state.materials.stuc) || null; rabatColor = (state.materials && state.materials.rabatColor) || null; fels = (state.materials && state.materials.fels) || null; syncMaterialButtons(); openings = state.openings.map(rawOpening); openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1; selectedOpeningId = null; renderOpeningsList(); updateAll(); } function generateWallPdf() { recalculate(); const doc = PdfSheet.createDoc(); const project = PdfSheet.readProjectFromDom(); WallPdf.drawSheet(doc, CONFIG, calcData, { buitenMateriaal, cladding, trasraam, project, titel: project.titel, element: project.element }); return doc; } function buildDesignObject() { return { type: 'wall-design', version: 1, exportedAt: new Date().toISOString(), ...getPureState() }; } function exportDesign() { const suggested = 'wand-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(); if (!filename) filename = suggested; if (!filename.toLowerCase().endsWith('.json')) filename += '.json'; const design = buildDesignObject(); 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); const pdfBase = filename.replace(/\.json$/i, ''); try { const pdfDoc = generateWallPdf(); pdfDoc.save(pdfBase + '.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 validateDesignJson(design) { if (!design || design.type !== 'wall-design') throw new Error('geen geldig wand-ontwerp (type ontbreekt of onjuist)'); WallModel.validateState(design); } function importDesign(file) { const reader = new FileReader(); reader.onload = (e) => { try { const design = JSON.parse(e.target.result); validateDesignJson(design); applyPureState(design); setIoStatus(`Geïmporteerd (${openings.length} opening(en)).`, '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 triggerImport() { document.getElementById('file-import-json').click(); } document.getElementById('file-import-json').addEventListener('change', (e) => { const file = e.target.files[0]; if (file) importDesign(file); e.target.value = ''; }); function updateOpeningWarnings(resolvedOpenings) { const anyOverflow = resolvedOpenings.some(o => o.overflowing); const globalWarn = document.getElementById('openings-global-warning'); if (anyOverflow) { globalWarn.style.display = 'block'; globalWarn.textContent = 'Let op: de opgegeven openingen passen niet allemaal binnen deze wandbreedte op hun gekozen ankerpunt. Rood gemarkeerde openingen zijn tegen de rand of tegen elkaar aan geduwd.'; } else { globalWarn.style.display = 'none'; } resolvedOpenings.forEach(o => { const card = document.querySelector(`.opening-card[data-opening-id="${o.id}"]`); const warnEl = document.querySelector(`[data-warning-for="${o.id}"]`); if (!card || !warnEl) return; card.classList.toggle('opening-warn', !!o.overflowing); const msg = o.overflowing ? 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.' : o.koppelWarn ? `Koppelen: ${o.koppelWarn}.` : ''; warnEl.style.display = msg ? 'block' : 'none'; warnEl.textContent = msg; }); } function readInputs() { const wallWidth = Math.round(parseFloat(document.getElementById('inp-wall-w').value) || 3000); const hAllInput = document.getElementById('inp-wall-h-all'); const hLeftInput = document.getElementById('inp-wall-h-left'); const hRightInput = document.getElementById('inp-wall-h-right'); const hMidInput = document.getElementById('inp-wall-h-mid'); const peakOn = document.getElementById('inp-wall-peak').checked; let hAll = Math.round(parseFloat(hAllInput.value) || 2400); let hLeft = Math.round(parseFloat(hLeftInput.value) || 2400); let hRight = Math.round(parseFloat(hRightInput.value) || 2900); let hMid = Math.round(parseFloat(hMidInput.value) || 0); if (hAll !== lastBaseHeight) { const delta = hAll - lastBaseHeight; hLeft = Math.max(500, hLeft + delta); hRight = Math.max(500, hRight + delta); hMid = Math.max(500, hMid + delta); hLeftInput.value = hLeft; hRightInput.value = hRight; hMidInput.value = hMid; lastBaseHeight = hAll; } // Met nok: links/rechts zijn goothoogtes. document.getElementById('grp-wall-h-mid').style.display = peakOn ? '' : 'none'; document.getElementById('lbl-wall-h-left').textContent = peakOn ? 'Goothoogte links' : 'Hoogte links'; document.getElementById('lbl-wall-h-right').textContent = peakOn ? 'Goothoogte rechts' : 'Hoogte rechts'; CONFIG = WallModel.buildConfig({ width: wallWidth, heightLeft: hLeft, heightRight: hRight, heightMid: peakOn ? hMid : null, topPts: wallTopPts || undefined, studWidth: parseFloat(document.getElementById('inp-stud-w').value), studDepth: parseFloat(document.getElementById('inp-stud-d').value), plateThick: parseFloat(document.getElementById('inp-plate-w').value), plateDepth: parseFloat(document.getElementById('inp-plate-d').value), sheathingThick: parseFloat(document.getElementById('inp-sheathing-t').value), minSpacing: parseFloat(document.getElementById('inp-min-sp').value), maxSpacing: parseFloat(document.getElementById('inp-max-sp').value) }, openings); updateOpeningWarnings(CONFIG.openings); } function recalculate() { readInputs(); calcData = WallModel.computeFrame(CONFIG); setSpecAll('vert-beams', calcData.studs.length + ' stuks'); setSpecAll('roof-angle', calcData.angleDeg + '°'); } const canvas2DTop = document.getElementById('canvas-2d-top'); const ctx = canvas2DTop.getContext('2d'); function draw2DTop() { const rect = canvas2DTop.parentElement.getBoundingClientRect(); canvas2DTop.width = rect.width; canvas2DTop.height = rect.height; const w = canvas2DTop.width; const h = canvas2DTop.height; ctx.clearRect(0, 0, w, h); const maxDepth = Math.max(CONFIG.studDepth, CONFIG.plateDepth); const scale = Math.min(w, h) / (Math.max(CONFIG.wallWidth, 1500) * 1.4); const centerX = w / 2; const centerY = h / 2; ctx.strokeStyle = '#222'; ctx.lineWidth = 1; const gridSize = 100 * scale; for (let x = 0; x < w; x += gridSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } for (let y = 0; y < h; y += gridSize) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } const plateWidthPx = CONFIG.wallWidth * scale; const sheathingPx = CONFIG.sheathingThick * scale; const maxDepthPx = maxDepth * scale; ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; ctx.strokeStyle = '#666'; ctx.fillRect(centerX - plateWidthPx / 2, centerY - (maxDepthPx / 2) - sheathingPx, plateWidthPx, sheathingPx); ctx.strokeRect(centerX - plateWidthPx / 2, centerY - (maxDepthPx / 2) - sheathingPx, plateWidthPx, sheathingPx); ctx.fillRect(centerX - plateWidthPx / 2, centerY + (maxDepthPx / 2), plateWidthPx, sheathingPx); ctx.strokeRect(centerX - plateWidthPx / 2, centerY + (maxDepthPx / 2), plateWidthPx, sheathingPx); const studWidthPx = CONFIG.studWidth * scale; const studDepthPx = CONFIG.studDepth * scale; const drawStud2D = (xPos, isJack = false) => { const screenX = centerX + (xPos * scale) - (studWidthPx / 2); const screenY = centerY - (studDepthPx / 2); ctx.fillStyle = isJack ? '#a37c45' : '#c29b62'; ctx.fillRect(screenX, screenY, studWidthPx, studDepthPx); ctx.strokeStyle = '#8a6836'; ctx.strokeRect(screenX, screenY, studWidthPx, studDepthPx); }; const fullXs = new Set(calcData.studs.filter(st => st.kind !== 'kreupel').map(st => st.x)); [...new Set(calcData.studs.map(st => st.x))].forEach(x => drawStud2D(x, !fullXs.has(x))); const startX = -CONFIG.wallWidth / 2; CONFIG.openings.forEach(op => { const opLeft = startX + op.x; const opLeftPx = centerX + (opLeft * scale); const opW_Px = op.w * scale; ctx.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)'; ctx.fillRect(opLeftPx, centerY - (maxDepthPx / 2) - sheathingPx, opW_Px, maxDepthPx + sheathingPx * 2); ctx.strokeStyle = '#ff3333'; ctx.setLineDash([3, 3]); ctx.strokeRect(opLeftPx, centerY - (maxDepthPx / 2) - sheathingPx, opW_Px, maxDepthPx + sheathingPx * 2); ctx.setLineDash([]); ctx.fillStyle = '#fff'; ctx.font = '9px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('#' + op.id, opLeftPx + opW_Px / 2, centerY - (maxDepthPx / 2) - sheathingPx - 4); }); } const canvas2DFront = document.getElementById('canvas-2d-front'); const ctxFront = canvas2DFront.getContext('2d'); function draw2DFront() { const rect = canvas2DFront.parentElement.getBoundingClientRect(); canvas2DFront.width = rect.width; canvas2DFront.height = rect.height; const w = canvas2DFront.width; const h = canvas2DFront.height; ctxFront.clearRect(0, 0, w, h); const wallWidthMM = CONFIG.wallWidth; const hLeftMM = CONFIG.wallHeightLeft; const hRightMM = CONFIG.wallHeightRight; const halfWMM = wallWidthMM / 2; const tl = WallModel.topLine(CONFIG); const maxRoofMM = tl.maxY; const scale = Math.min(w / (wallWidthMM * 1.3), h / (maxRoofMM * 1.3)); const centerX = w / 2; const floorY = h - 20; frontView = { scale, centerX, floorY }; const toCanvasX = (xmm) => centerX + xmm * scale; const toCanvasY = (ymm) => floorY - ymm * scale; ctxFront.strokeStyle = '#222'; ctxFront.lineWidth = 1; const gridSize = 100 * scale; for (let x = 0; x < w; x += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(x, 0); ctxFront.lineTo(x, h); ctxFront.stroke(); } for (let y = 0; y < h; y += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(0, y); ctxFront.lineTo(w, y); ctxFront.stroke(); } ctxFront.strokeStyle = '#555'; ctxFront.setLineDash([4, 4]); for (let bandY = 1500; buitenMateriaal === 'vochtdoek' && bandY < maxRoofMM; bandY += 1500) { WallModel.skinSegmentsAtY(CONFIG, bandY).forEach(([x0, x1]) => { ctxFront.beginPath(); ctxFront.moveTo(toCanvasX(x0), toCanvasY(bandY)); ctxFront.lineTo(toCanvasX(x1), toCanvasY(bandY)); ctxFront.stroke(); }); } ctxFront.setLineDash([]); if (buitenMateriaal === 'vochtdoek' && trasraam && trasraam.top > 0) { ctxFront.strokeStyle = '#a0a3a8'; ctxFront.lineWidth = 1.5; WallModel.skinSegmentsAtY(CONFIG, trasraam.top).forEach(([x0, x1]) => { ctxFront.beginPath(); ctxFront.moveTo(toCanvasX(x0), toCanvasY(trasraam.top)); ctxFront.lineTo(toCanvasX(x1), toCanvasY(trasraam.top)); ctxFront.stroke(); }); ctxFront.fillStyle = '#a0a3a8'; ctxFront.font = '10px sans-serif'; ctxFront.fillText('trasraam', toCanvasX(-halfWMM) + 4, toCanvasY(trasraam.top) - 3); } ctxFront.strokeStyle = '#666'; ctxFront.lineWidth = 2; ctxFront.beginPath(); ctxFront.moveTo(toCanvasX(-halfWMM), toCanvasY(0)); ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(0)); tl.pts.slice().reverse().forEach(p => ctxFront.lineTo(toCanvasX(p.x), toCanvasY(p.y))); ctxFront.closePath(); ctxFront.stroke(); ctxFront.fillStyle = '#c29b62'; ctxFront.strokeStyle = '#8a6836'; ctxFront.lineWidth = 1; ctxFront.beginPath(); ctxFront.rect(toCanvasX(-halfWMM), toCanvasY(CONFIG.plateThick), toCanvasX(halfWMM) - toCanvasX(-halfWMM), toCanvasY(0) - toCanvasY(CONFIG.plateThick)); ctxFront.fill(); ctxFront.stroke(); WallModel.topPlateOutlines(tl).forEach(pts => { ctxFront.beginPath(); pts.forEach((p, i) => (i ? ctxFront.lineTo : ctxFront.moveTo).call(ctxFront, toCanvasX(p.x), toCanvasY(p.y))); ctxFront.closePath(); ctxFront.fill(); ctxFront.stroke(); }); const xAxisY = Math.min(h - 6, toCanvasY(0) + 16); drawCanvasDim(ctxFront, toCanvasX(-halfWMM), xAxisY, toCanvasX(halfWMM), xAxisY, 'X: ' + Math.round(wallWidthMM) + 'mm', (toCanvasX(-halfWMM) + toCanvasX(halfWMM)) / 2, Math.min(h - 2, xAxisY + 11)); const yAxisLeftX = Math.max(10, toCanvasX(-halfWMM) - 14); drawCanvasDim(ctxFront, yAxisLeftX, toCanvasY(0), yAxisLeftX, toCanvasY(hLeftMM), 'Y: ' + Math.round(hLeftMM) + 'mm', Math.max(10, yAxisLeftX - 4), (toCanvasY(0) + toCanvasY(hLeftMM)) / 2); const yAxisRightX = Math.min(w - 10, toCanvasX(halfWMM) + 14); drawCanvasDim(ctxFront, yAxisRightX, toCanvasY(0), yAxisRightX, toCanvasY(hRightMM), 'Y: ' + Math.round(hRightMM) + 'mm', Math.min(w - 4, yAxisRightX + 12), (toCanvasY(0) + toCanvasY(hRightMM)) / 2); const inner = tl.pts.slice(1, -1); inner.forEach(q => { const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1; ctxFront.fillStyle = '#9ab'; ctxFront.font = '11px sans-serif'; ctxFront.textAlign = 'center'; ctxFront.fillText((isNok ? 'nok ' : 'knik ') + Math.round(q.y) + 'mm', toCanvasX(q.x), Math.max(12, toCanvasY(q.y) - 6)); ctxFront.textAlign = 'start'; }); const drawStudFront = (st) => { const xLeft = st.x - CONFIG.studWidth / 2; const xRight = st.x + CONFIG.studWidth / 2; ctxFront.fillStyle = st.kind === 'kreupel' ? '#a37c45' : '#c29b62'; ctxFront.strokeStyle = '#8a6836'; ctxFront.beginPath(); ctxFront.moveTo(toCanvasX(xLeft), toCanvasY(st.y0)); ctxFront.lineTo(toCanvasX(xRight), toCanvasY(st.y0)); if (st.y1 === null) tl.underPts(xLeft, xRight).reverse().forEach(p => ctxFront.lineTo(toCanvasX(p.x), toCanvasY(p.y))); else { ctxFront.lineTo(toCanvasX(xRight), toCanvasY(st.y1)); ctxFront.lineTo(toCanvasX(xLeft), toCanvasY(st.y1)); } ctxFront.closePath(); ctxFront.fill(); ctxFront.stroke(); }; calcData.studs.forEach(drawStudFront); const startXMM = -halfWMM; frontOpeningRects = []; frontHandleRects = []; CONFIG.openings.forEach(op => { const opLeftMM = startXMM + op.x; const x0 = toCanvasX(opLeftMM); const x1 = toCanvasX(opLeftMM + op.w); const y0 = toCanvasY(op.y); const y1 = toCanvasY(op.y + op.h); const y1r = toCanvasY(op.y + op.hR); const isSelected = op.id === selectedOpeningId; frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, y1r, localX: op.x, y: op.y, w: op.w, h: op.h, hR: op.hR }); const outline = () => { ctxFront.beginPath(); ctxFront.moveTo(x0, y0); ctxFront.lineTo(x1, y0); ctxFront.lineTo(x1, y1r); ctxFront.lineTo(x0, y1); ctxFront.closePath(); }; outline(); ctxFront.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)'; ctxFront.fill(); ctxFront.strokeStyle = isSelected ? '#4da6ff' : '#ff3333'; ctxFront.lineWidth = isSelected ? 2.5 : 1; ctxFront.setLineDash(isSelected ? [] : [3, 3]); ctxFront.stroke(); ctxFront.setLineDash([]); ctxFront.lineWidth = 1; ctxFront.fillStyle = '#fff'; ctxFront.font = '9px sans-serif'; ctxFront.textAlign = 'center'; ctxFront.fillText('#' + op.id, (x0 + x1) / 2, (y1 + y1r) / 2 - 4); if (isSelected) { const midX = (x0 + x1) / 2, midTop = (y1 + y1r) / 2; const handles = [ { handle: 'nw', cx: x0, cy: y1 }, { handle: 'n', cx: midX, cy: midTop }, { handle: 'ne', cx: x1, cy: y1r }, { handle: 'w', cx: x0, cy: (y0 + y1) / 2 }, { handle: 'c', cx: midX, cy: (y0 + midTop) / 2 }, { handle: 'e', cx: x1, cy: (y0 + y1r) / 2 }, { handle: 'sw', cx: x0, cy: y0 }, { handle: 's', cx: midX, cy: y0 }, { handle: 'se', cx: x1, cy: y0 } ]; handles.forEach(hd => { frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy }); ctxFront.fillStyle = hd.handle === 'c' ? '#ffffff' : '#4da6ff'; ctxFront.strokeStyle = '#0a1a2a'; ctxFront.beginPath(); if (hd.handle === 'c') ctxFront.arc(hd.cx, hd.cy, 5, 0, Math.PI * 2); else ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10); ctxFront.fill(); ctxFront.stroke(); }); } }); drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM); lampMarks.forEach(l => { const x = toCanvasX(-halfWMM + l.u), y = toCanvasY(l.y); ctxFront.fillStyle = '#ffd36b'; ctxFront.strokeStyle = '#1a1a1a'; ctxFront.lineWidth = 1; ctxFront.fillRect(x - 4, y - 7, 8, 14); ctxFront.strokeRect(x - 4, y - 7, 8, 14); }); } function drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM) { seamCuts.forEach(([u0, u1, upTo]) => { const x0 = toCanvasX(-halfWMM + u0), x1 = toCanvasX(-halfWMM + u1); const yTop = toCanvasY(upTo > 0 ? Math.min(upTo, maxRoofMM) : maxRoofMM), yBot = toCanvasY(0); ctxFront.save(); ctxFront.fillStyle = 'rgba(17, 17, 17, 0.8)'; ctxFront.fillRect(x0, yTop, x1 - x0, yBot - yTop); ctxFront.beginPath(); ctxFront.rect(x0, yTop, x1 - x0, yBot - yTop); ctxFront.clip(); ctxFront.strokeStyle = '#ff9f43'; ctxFront.lineWidth = 1; for (let d = -(yBot - yTop); d < x1 - x0; d += 12) { ctxFront.beginPath(); ctxFront.moveTo(x0 + d, yBot); ctxFront.lineTo(x0 + d + (yBot - yTop), yTop); ctxFront.stroke(); } ctxFront.restore(); ctxFront.fillStyle = '#ff9f43'; ctxFront.font = 'bold 10px sans-serif'; ctxFront.textAlign = 'center'; ctxFront.fillText('NAAD — VERVALT', (x0 + x1) / 2, (yTop + yBot) / 2); }); } function frontHandleAt(mx, my) { return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8); } function frontOpeningAt(mx, my) { return frontOpeningRects.find(r => { if (mx < r.x0 || mx > r.x1) return false; const yTop = r.y1 + (mx - r.x0) / ((r.x1 - r.x0) || 1) * (r.y1r - r.y1); return my <= r.y0 && my >= yTop; }); } canvas2DFront.classList.add('interactive'); canvas2DFront.addEventListener('mousedown', (e) => { const rect = canvas2DFront.getBoundingClientRect(); const mx = e.clientX - rect.left, my = e.clientY - rect.top; if (selectedOpeningId !== null) { const hit = frontHandleAt(mx, my); if (hit) { const resolved = frontOpeningRects.find(r => r.id === hit.id); dragInfo = { type: hit.handle === 'c' ? 'move' : 'resize', handle: hit.handle, openingId: hit.id, startMouseX: e.clientX, startMouseY: e.clientY, startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h, startHR: resolved.hR }; e.preventDefault(); return; } } const bodyHit = frontOpeningAt(mx, my); if (bodyHit) { selectedOpeningId = bodyHit.id; dragInfo = { type: 'move', openingId: bodyHit.id, startMouseX: e.clientX, startMouseY: e.clientY, startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h, startHR: bodyHit.hR }; highlightSelectedCard(); draw2D(); e.preventDefault(); return; } if (selectedOpeningId !== null) { selectedOpeningId = null; highlightSelectedCard(); draw2D(); } }); function onFrontMouseMove(e) { if (!dragInfo) return; const dxMM = (e.clientX - dragInfo.startMouseX) / frontView.scale; const dyMM = -(e.clientY - dragInfo.startMouseY) / frontView.scale; const op = openings.find(o => o.id === dragInfo.openingId); if (!op) { dragInfo = null; return; } const wallWidth = CONFIG.wallWidth; if (dragInfo.type === 'move') { const newLocalX = snap50(dragInfo.startLocalX + dxMM); applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth); op.y = Math.max(0, snap50(dragInfo.startY + dyMM)); } else if (dragInfo.type === 'resize') { const hd = dragInfo.handle; const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op); if (hd === 'n') { const dh = snap50(dyMM); op.h = Math.max(400, dragInfo.startH + dh); if (sloped) op.hR = Math.max(400, dragInfo.startHR + dh); } else if (hd === 'nw' || hd === 'ne') { op.h = hd === 'nw' ? Math.max(400, snap50(dragInfo.startH + dyMM)) : dragInfo.startH; op.hR = hd === 'ne' ? Math.max(400, snap50(dragInfo.startHR + dyMM)) : dragInfo.startHR; if (op.h === op.hR) delete op.hR; } if (hd === 's' || hd === 'sw' || hd === 'se') { const topL = dragInfo.startY + dragInfo.startH, topR = dragInfo.startY + dragInfo.startHR; const newY = Math.min(snap50(dragInfo.startY + dyMM), Math.min(topL, topR) - 400); op.y = Math.max(0, newY); op.h = topL - op.y; if (isSloped(op)) op.hR = topR - op.y; } if (hd === 'e' || hd === 'ne' || hd === 'se') { const newW = Math.max(400, snap50(dragInfo.startW + dxMM)); applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth); } else if (hd === 'w' || hd === 'nw' || hd === 'sw') { const right = dragInfo.startLocalX + dragInfo.startW; const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400); applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth); } } syncOpeningCardInputs(op); updateAll(); } function onFrontMouseUp() { dragInfo = null; } window.addEventListener('mousemove', onFrontMouseMove); window.addEventListener('mouseup', onFrontMouseUp); const canvas2DSide = document.getElementById('canvas-2d-side'); const ctxSide = canvas2DSide.getContext('2d'); function draw2DSide() { const rect = canvas2DSide.parentElement.getBoundingClientRect(); canvas2DSide.width = rect.width; canvas2DSide.height = rect.height; const w = canvas2DSide.width, h = canvas2DSide.height; ctxSide.clearRect(0, 0, w, h); const buitenThick = CONFIG.sheathingThick; const spaanThick = CONFIG.sheathingThick; const totalDepth = buitenThick + CONFIG.studDepth + spaanThick; const maxH = WallModel.topLine(CONFIG).maxY; const scale = Math.min((w * 0.45) / totalDepth, (h * 0.8) / maxH); const originX = w / 2 - (totalDepth * scale) / 2; const floorY = h - 24; const toX = (mm) => originX + mm * scale; const toY = (mm) => floorY - mm * scale; const layers = [ { thick: buitenThick, color: { cementplaat: '#808285', spaanplaat: '#1e4620' }[buitenMateriaal] || '#444444' }, { thick: CONFIG.studDepth, color: '#c29b6255' }, { thick: spaanThick, color: '#1e4620' } ]; let cursor = 0; layers.forEach(layer => { ctxSide.fillStyle = layer.color; ctxSide.strokeStyle = '#000'; ctxSide.lineWidth = 0.5; ctxSide.fillRect(toX(cursor), toY(maxH), layer.thick * scale, toY(0) - toY(maxH)); ctxSide.strokeRect(toX(cursor), toY(maxH), layer.thick * scale, toY(0) - toY(maxH)); ctxSide.fillStyle = '#fff'; ctxSide.font = '9px sans-serif'; ctxSide.textAlign = 'center'; ctxSide.fillText(String(layer.thick) + 'mm', toX(cursor) + (layer.thick * scale) / 2, floorY + 14); cursor += layer.thick; }); ctxSide.strokeStyle = '#4da6ff'; ctxSide.beginPath(); ctxSide.moveTo(toX(totalDepth) + 15, toY(0)); ctxSide.lineTo(toX(totalDepth) + 15, toY(maxH)); ctxSide.stroke(); ctxSide.fillStyle = '#4da6ff'; ctxSide.font = '9px sans-serif'; ctxSide.save(); ctxSide.translate(toX(totalDepth) + 24, (toY(0) + toY(maxH)) / 2); ctxSide.rotate(-Math.PI / 2); ctxSide.textAlign = 'center'; ctxSide.fillText('Y: ' + Math.round(maxH) + 'mm', 0, 0); ctxSide.restore(); } function draw2D() { draw2DTop(); draw2DFront(); draw2DSide(); } const pane3D = document.getElementById('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, 100); camera.position.set(3.5, 2.0, 4.5); 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 cameraKey = callbacks && callbacks.cameraKey; if (!CameraMemory.restore(cameraKey, camera, controls)) { controls.target.set(0, 1.2, 0); controls.update(); } 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); dirLight.castShadow = true; scene.add(dirLight); const grid = new THREE.GridHelper(10, 20, 0x444444, 0x222222); scene.add(grid); const wallMaterials = WallModel.createMaterials(); let buitenMateriaal = 'vochtdoek'; let cladding = 'horizontaal'; let trasraam = null; let stone = null; let stuc = null; let rabatColor = null; let fels = null; let layers = ViewLayers.all(); function syncMaterialButtons() { document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal)); document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding)); document.getElementById('cladding-section').style.display = buitenMateriaal === 'vochtdoek' ? '' : 'none'; if (lockCladding) document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => { b.disabled = true; b.title = lockCladding; }); if (lockExterior) document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => { b.disabled = true; b.title = lockExterior; }); } document.querySelectorAll('.material-toggle-btn[data-material]').forEach(btn => { btn.addEventListener('click', () => { buitenMateriaal = btn.dataset.material; syncMaterialButtons(); updateAll(); }); }); document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(btn => { btn.addEventListener('click', () => { cladding = btn.dataset.cladding; syncMaterialButtons(); updateAll(); }); }); let spaanplaatVisible = true; let buitenVisible = true; let skyMode = Skybox.getMode(); const skyEnv = Skybox.createEnvironment(scene); const applySky = () => { skyEnv.fitTo(wallGroup3D, 0); Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D }); }; const toolbar = document.getElementById('wall-toolbar'); const bar = UI.editorBar(toolbar, document.getElementById('app-container'), { left: `
${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}
`, onResize: onWindowResize, layers: { layers, keys: ['regelwerk', 'beplating', 'doek', 'latwerk', 'gevel', 'kozijnen', 'afwerking'], onChange: (l) => { layers = l; update3DScene(); }, extras: [{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }], onExtra: (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; update3DScene(); } }, onSky: (m) => { skyMode = m; applySky(); } }); toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal()); let lastWallStats = null; function update3DScene() { if (wallGroup3D) scene.remove(wallGroup3D); const result = WallModel.build3D(CONFIG, calcData, { materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, fels, spaanplaatVisible, buitenVisible, layers }); wallGroup3D = result.group; lastWallStats = result.stats; scene.add(wallGroup3D); applySky(); updateSpecificaties(); } function fmt(n, decimals = 1) { return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } function setSpecAll(key, text) { document.querySelectorAll(`[data-spec="${key}"]`).forEach(el => { el.textContent = text; }); } function updateSpecificaties() { const specs = WallModel.computeSpecs(CONFIG, lastWallStats, buitenMateriaal); const buitenNaam = WallModel.EXTERIOR_MATERIALS[buitenMateriaal] || 'Vochtdoek'; const buitenVolumeText = specs.buitenVolumeM3 === null ? 'n.v.t. (rolmateriaal)' : fmt(specs.buitenVolumeM3, 3) + ' m3'; setSpecAll('wood-m3', fmt(lastWallStats.woodVolumeM3, 3) + ' m3'); setSpecAll('wood-kg', fmt(specs.woodKg) + ' kg'); setSpecAll('spaanplaat-m2', fmt(lastWallStats.spaanplaatAreaM2) + ' m2'); setSpecAll('spaanplaat-m3', fmt(lastWallStats.spaanplaatVolumeM3, 3) + ' m3'); setSpecAll('spaanplaat-kg', fmt(specs.spaanplaatKg) + ' kg'); setSpecAll('buiten-materiaalnaam', buitenNaam); setSpecAll('buiten-m2', fmt(specs.buitenAreaM2) + ' m2'); setSpecAll('buiten-m3', buitenVolumeText); setSpecAll('buiten-kg', fmt(specs.buitenKg) + ' kg'); setSpecAll('latten', lastWallStats.battenCount ? `${lastWallStats.battenCount} st. \u00e0 ${WallModel.BATTEN.length} mm (${fmt(lastWallStats.battenLengthM, 1)} m, ${fmt(specs.battenKg)} kg)` : 'n.v.t.'); setSpecAll('total-kg', fmt(specs.totalKg) + ' kg'); setSpecAll('void-m3', fmt(specs.voidVolumeM3, 3) + ' m3'); setSpecAll('horiz-beams', lastWallStats.horizBeamCount + ' stuks'); const purchase = WallModel.planPurchase(WallModel.computeBOM(CONFIG, calcData)); const wasteMM = purchase.reduce((a, sec) => a + sec.wasteMM, 0); const stockMM = purchase.reduce((a, sec) => a + sec.stockMM, 0); setSpecAll('stock-list', WallModel.purchaseText(purchase)); setSpecAll('stock-waste', fmt(wasteMM / 1000, 2) + ' m (' + fmt(stockMM ? wasteMM / stockMM * 100 : 0) + '%)'); } function updateAll() { recalculate(); draw2D(); update3DScene(); } const inputs = document.querySelectorAll('#tab-wall input'); inputs.forEach(input => { // Hoogte met de hand gewijzigd: de knikken van een wolfsdak vervallen. if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; }); input.addEventListener('input', updateAll); }); let __rafId = null; function animate() { __rafId = requestAnimationFrame(animate); renderer.render(scene, camera); } function onWindowResize() { camera.aspect = pane3D.clientWidth / pane3D.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(pane3D.clientWidth, pane3D.clientHeight); draw2D(); } window.addEventListener('resize', onWindowResize); renderOpeningsList(); updateAll(); animate(); return { unmount() { window.removeEventListener('resize', onWindowResize); window.removeEventListener('mousemove', onFrontMouseMove); window.removeEventListener('mouseup', onFrontMouseUp); if (__rafId !== null) cancelAnimationFrame(__rafId); CameraMemory.save(cameraKey, camera, controls); renderer.dispose(); }, exportDesign, triggerImport, mirrorHorizontal, setView: bar.setView, viewState: bar.viewState, openProjectProperties, getStateJson: getPureState, loadStateJson: applyPureState }; } window.WallEditor = { mount: mountWallEditor, getDefaultState: () => ({ wall: { width: 6000, heightAll: 2400, heightLeft: 2400, heightRight: 2400, studWidth: 38, studDepth: 235, plateThick: 38, plateDepth: 235, sheathingThick: 10, minSpacing: 240, maxSpacing: 480 }, materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' }, openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }] }) };