/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // ============================================================ // wall/wall.js — Wand-editor (ruwbouw compleet: regelwerk, openingen, OSB // achterzijde, vochtdoek voorzijde, isolatie/specificaties). Ongewijzigde // logica t.o.v. de vorige single-file versie, alleen verpakt als los te // laden/lossen module via mount()/unmount(), en met de gedeelde geometrie- // functies (calculateSegmentPositions, polygonArea, clip*, shapeAreaWithHoles) // verplaatst naar core/geometry.js (moet VOOR dit bestand geladen worden). // // De rekenlogica (ankerresolutie, regelwerk, 3D-opbouw, specificaties) staat // in wall/wall-model.js (WallModel) — gedeeld met de unit-editor. Dit bestand // bevat alleen de UI: invoer, 2D-tekeningen, interactie, JSON/PDF-export. // // Plain script (geen ES-module) — werkt zo ook bij rechtstreeks openen van // index.html via file://. // ============================================================ const WALL_TEMPLATE = `
2D BUITENAANZICHT
2D BOVENAANZICHT
2D ZIJAANZICHT

Wand Dimensies (mm)

mm
mm
mm
mm

Verticale Balken & Dorpels

mm
mm

Muur Liggers (Top & Onder)

mm
mm
mm

Regelwerk Afstand (h.o.h.)

mm
mm
Totaal verticale balken:-
Totaal horizontale balken:-
Dakhelling:-

Binnenzijde

Buitenzijde

Gevelbekleding (WEO/Novalit) — richting

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.
3D PERSPECTIEF

Balken & Dak

Verticale balken:-
Horizontale balken:-
Dakhelling:-

Houtwerk

Volume hout:-
Gewicht hout:-
Inkoop balken:-
Snijverlies:-

Spaanplaat (binnenzijde)

Oppervlak:-
Volume:-
Gewicht:-

Buitenzijde (Vochtdoek)

Oppervlak:-
Volume:-
Gewicht:-
Latwerk 20x30 (kruislat):-

Isolatie & totaal

Inhoud leegtes:-
Totaal gewicht wand:-
`; function mountWallEditor(container, callbacks) { const onSaved = callbacks && callbacks.onSaved; container.innerHTML = WALL_TEMPLATE; // Binnen een unit volgt de wandbreedte uit de vloer (zie unit/unit.js); // callbacks.lockWidth is dan de uitleg-tekst voor de tooltip. if (callbacks && callbacks.lockWidth) { const widthInput = document.getElementById('inp-wall-w'); widthInput.disabled = true; widthInput.title = callbacks.lockWidth; } // Binnen een woning is de richting van de gevelbekleding (voorlopig) globaal. const lockCladding = callbacks && callbacks.lockCladding; // Binnen een woning is de h.o.h. globaal (woning-instellingen), niet per wand. if (callbacks && callbacks.lockSpacing) { ['inp-min-sp', 'inp-max-sp'].forEach(id => { const el = document.getElementById(id); el.disabled = true; el.title = callbacks.lockSpacing; }); } // Binnen een woning: delen van de wand die op een naad met een andere unit // vallen (en dus vervallen), als [vanaf-links, tot] in mm. const seamCuts = (callbacks && callbacks.seamCuts) || []; // Binnen een woning: gevellampen op deze wand ({ u vanaf links, y }), zie LightingModel. const lampMarks = (callbacks && callbacks.lamps) || []; // ============================================================ // STATE // ============================================================ // Dichtheden/gewichten: zie WallModel (wall/wall-model.js). let CONFIG = {}; let calcData = { fullStuds: [], jackStuds: [] }; let wallGroup3D = null; let lastBaseHeight = 2400; // Elke opening: { id, w, h, y, anchor: 'left'|'middle'|'right', offset } // 'offset' betekent afhankelijk van anchor: // left -> afstand vanaf linker wandrand tot linkerkant opening // right -> afstand vanaf rechter wandrand tot rechterkant opening // middle -> verschuiving t.o.v. het midden (0 = gecentreerd, + = naar rechts) let openings = [ { id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 } ]; let openingIdCounter = 2; // ------------------------------------------------------------ // Interactieve selectie/verslepen van openingen in het vooraanzicht // (nu het primaire aanzicht). Snap op 50mm, net als de stapgrootte van // de invoervelden. N/Z-handles wijzigen alleen de hoogte, O/W-handles // alleen de breedte — precies zoals bij de meeste 2D-tekentools. // ------------------------------------------------------------ let selectedOpeningId = null; let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH } let frontView = { scale: 1, centerX: 0, floorY: 0 }; // bijgewerkt door draw2DFront() let frontOpeningRects = []; // hit-test data, bijgewerkt door draw2DFront() let frontHandleRects = []; // idem, alleen voor de geselecteerde opening function snap50(mm) { return Math.round(mm / 50) * 50; } // Zet offset (en evt. breedte) zodat de opening's linkerrand op // `leftLocalX` komt te liggen met breedte `w`, ongeacht welk ankertype // actief is — dit is de kern die verslepen/resizen mogelijk maakt zonder // dat het ankertype zelf hoeft te veranderen. 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; // 'left' op.w = w; } function syncOpeningCardInputs(op) { const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`); if (!card) return; ['w', 'h', '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)); }); // ============================================================ // OPENINGEN: UI (lijst met kaarten, ankerkeuze, toevoegen/verwijderen) // ============================================================ function anchorOffsetLabel(anchor) { if (anchor === 'right') return 'Afstand vanaf rechts:'; if (anchor === 'middle') return 'Verschuiving t.o.v. midden:'; return 'Afstand vanaf 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 }); renderOpeningsList(); updateAll(); } function toggleFullscreen3D() { const el = document.getElementById('app-container'); el.classList.remove('fullscreen-2d'); el.classList.toggle('fullscreen-3d'); requestAnimationFrame(onWindowResize); } function toggleFullscreen2D() { const el = document.getElementById('app-container'); el.classList.remove('fullscreen-3d'); el.classList.toggle('fullscreen-2d'); requestAnimationFrame(onWindowResize); } 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(); } // Horizontaal spiegelen (Bewerken-menu): wisselt links/rechts om. De // hoogte-links/-rechts worden verwisseld (de schuine kant "klapt om"), // en elke opening wordt zo herankerd dat 'ie fysiek op dezelfde plek // blijft t.o.v. zijn (nu omgewisselde) referentierand: // - anchor 'left' <-> 'right', offset ONGEWIJZIGD (het is en blijft // een afstand-tot-een-rand, alleen de rand zelf wisselt) // - anchor 'middle': offset wordt genegeerd (spiegelbeeld t.o.v. midden) // Verticaal spiegelen is bewust niet geïmplementeerd: een wand mag aan de // onderzijde niet scheef worden, dus dat menu-item blijft permanent // disabled (zie index.html/app.js). 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; openings = openings.map(o => { 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 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
mm
mm
`).join(''); highlightSelectedCard(); } // Klik op de titelbalk van een kaart selecteert 'm ook (symmetrisch met // klikken op de opening in het vooraanzicht). 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(); } }); // Event delegation: 1 listener voor alle inputs/knoppen in de openingen-lijst, // in plaats van opnieuw listeners koppelen na elke re-render. 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') { // Type kiezen = standaardmaat van dat type overnemen (daarna vrij aan te passen). 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; renderOpeningsList(); } else if (field === 'anchor') { op.anchor = e.target.value; renderOpeningsList(); // label van 'offset' hangt af van anchor } 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)); } }); document.getElementById('btn-add-opening').addEventListener('click', addOpening); // ============================================================ // JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters + // ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug. // ============================================================ 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), 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 }, openings: openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset })) }; } 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-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; buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek'; cladding = (state.materials && state.materials.cladding) || 'horizontaal'; trasraam = (state.materials && state.materials.trasraam) || null; // woning-instelling syncMaterialButtons(); openings = state.openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset })); openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1; selectedOpeningId = null; renderOpeningsList(); updateAll(); } // ------------------------------------------------------------ // PDF-export: het tekenblad zelf komt uit WallPdf (wall/wall-pdf.js), // gedeeld met de unit-PDF. // ------------------------------------------------------------ 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 = ''; }); // ============================================================ // OPENINGEN: ankerresolutie + overlap-handling — zie // WallModel.resolveOpenings (wall/wall-model.js). // ============================================================ 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); if (o.overflowing) { warnEl.style.display = 'block'; warnEl.textContent = 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.'; } else { warnEl.style.display = 'none'; } }); } 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'); let hAll = Math.round(parseFloat(hAllInput.value) || 2400); let hLeft = Math.round(parseFloat(hLeftInput.value) || 2400); let hRight = Math.round(parseFloat(hRightInput.value) || 2900); if (hAll !== lastBaseHeight) { const delta = hAll - lastBaseHeight; hLeft = Math.max(500, hLeft + delta); hRight = Math.max(500, hRight + delta); hLeftInput.value = hLeft; hRightInput.value = hRight; lastBaseHeight = hAll; } // Afronding/fallbacks + ankerresolutie van de openingen: WallModel. CONFIG = WallModel.buildConfig({ width: wallWidth, heightLeft: hLeft, heightRight: hRight, 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.fullStuds.length + calcData.jackStuds.length) + ' stuks'); setSpecAll('roof-angle', calcData.angleDeg + '°'); } // --- 2D CANVAS RENDERER: BOVENAANZICHT --- 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); }; calcData.fullStuds.forEach(x => drawStud2D(x, false)); calcData.jackStuds.forEach(j => drawStud2D(j.x, true)); 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); }); } // --- 2D CANVAS RENDERER: VOORAANZICHT (ZUID) --- 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 slopeMM = (hRightMM - hLeftMM) / wallWidthMM; const topYAtMM = (x) => hLeftMM + (x + halfWMM) * slopeMM; const vertOffsetMM = CONFIG.plateThick * Math.sqrt(1 + slopeMM * slopeMM); const maxRoofMM = Math.max(hLeftMM, hRightMM); 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(); } // Referentielijnen vochtdoek-banen (1500mm h.o.h. vanaf de vloer). // Zelfde uitsparingen als de fill (WallModel.skinSegmentsAtY): de // lijnen stoppen bij openingen en bij de (schuine) bovenrand. 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([]); // Bovenkant trasraam (cement-gebonden plaat, woning-instelling). 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); } // Wand-omtrek (incl. schuine bovenrand) ctxFront.strokeStyle = '#666'; ctxFront.lineWidth = 2; ctxFront.beginPath(); ctxFront.moveTo(toCanvasX(-halfWMM), toCanvasY(0)); ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(0)); ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(hRightMM)); ctxFront.lineTo(toCanvasX(-halfWMM), toCanvasY(hLeftMM)); ctxFront.closePath(); ctxFront.stroke(); // Onder- en bovenregel als volwaardig gevuld vlak (was alleen de // omtreklijn hierboven — dat liet lege ruimtes zien i.p.v. de // daadwerkelijke leggers). 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(); ctxFront.beginPath(); ctxFront.moveTo(toCanvasX(-halfWMM), toCanvasY(hLeftMM)); ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(hRightMM)); ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(hRightMM - vertOffsetMM)); ctxFront.lineTo(toCanvasX(-halfWMM), toCanvasY(hLeftMM - vertOffsetMM)); ctxFront.closePath(); ctxFront.fill(); ctxFront.stroke(); // X/Y-assen (lijn + label), altijd binnen het canvas gehouden zodat er // niks buiten het zichtbare venster valt. Stijl: core/canvas-labels.js. 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 drawStudFront = (xMM, isJack) => { const xLeft = xMM - CONFIG.studWidth / 2; const xRight = xMM + CONFIG.studWidth / 2; const yTopLeft = topYAtMM(xLeft) - vertOffsetMM; const yTopRight = topYAtMM(xRight) - vertOffsetMM; const yBot = CONFIG.plateThick; ctxFront.fillStyle = isJack ? '#a37c45' : '#c29b62'; ctxFront.strokeStyle = '#8a6836'; ctxFront.beginPath(); ctxFront.moveTo(toCanvasX(xLeft), toCanvasY(yBot)); ctxFront.lineTo(toCanvasX(xRight), toCanvasY(yBot)); ctxFront.lineTo(toCanvasX(xRight), toCanvasY(yTopRight)); ctxFront.lineTo(toCanvasX(xLeft), toCanvasY(yTopLeft)); ctxFront.closePath(); ctxFront.fill(); ctxFront.stroke(); }; calcData.fullStuds.forEach(x => drawStudFront(x, false)); calcData.jackStuds.forEach(j => drawStudFront(j.x, true)); 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 isSelected = op.id === selectedOpeningId; frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, localX: op.x, y: op.y, w: op.w, h: op.h }); ctxFront.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)'; ctxFront.fillRect(x0, y1, x1 - x0, y0 - y1); ctxFront.strokeStyle = isSelected ? '#4da6ff' : '#ff3333'; ctxFront.lineWidth = isSelected ? 2.5 : 1; ctxFront.setLineDash(isSelected ? [] : [3, 3]); ctxFront.strokeRect(x0, y1, x1 - x0, y0 - y1); ctxFront.setLineDash([]); ctxFront.lineWidth = 1; ctxFront.fillStyle = '#fff'; ctxFront.font = '9px sans-serif'; ctxFront.textAlign = 'center'; ctxFront.fillText('#' + op.id, (x0 + x1) / 2, y1 - 4); if (isSelected) { const midX = (x0 + x1) / 2, midY = (y0 + y1) / 2; const handles = [ { handle: 'n', cx: midX, cy: y1 }, { handle: 's', cx: midX, cy: y0 }, { handle: 'e', cx: x1, cy: midY }, { handle: 'w', cx: x0, cy: midY } ]; handles.forEach(hd => { frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy }); ctxFront.fillStyle = '#4da6ff'; ctxFront.strokeStyle = '#0a1a2a'; ctxFront.beginPath(); ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10); ctxFront.fill(); ctxFront.stroke(); }); } }); drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM); // Gevellampen (buitenverlichting woning) 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); }); } // Naad-zones (wand vervalt daar, zie woning) als gearceerde overlay. function drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM) { seamCuts.forEach(([u0, u1]) => { const x0 = toCanvasX(-halfWMM + u0), x1 = toCanvasX(-halfWMM + u1); const yTop = toCanvasY(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 => mx >= Math.min(r.x0, r.x1) && mx <= Math.max(r.x0, r.x1) && my >= Math.min(r.y0, r.y1) && my <= Math.max(r.y0, r.y1)); } 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: '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 }; 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 }; 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') { if (dragInfo.handle === 'n') { op.h = Math.max(400, snap50(dragInfo.startH + dyMM)); } else if (dragInfo.handle === 's') { const top = dragInfo.startY + dragInfo.startH; const newY = Math.min(snap50(dragInfo.startY + dyMM), top - 400); op.y = Math.max(0, newY); op.h = top - op.y; } else if (dragInfo.handle === 'e') { const newW = Math.max(400, snap50(dragInfo.startW + dxMM)); applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth); } else if (dragInfo.handle === 'w') { 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 = Math.max(CONFIG.wallHeightLeft, CONFIG.wallHeightRight); 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(); } // --- THREE.JS 3D RENDERER --- 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(); } scene.add(new THREE.AmbientLight(0xffffff, 0.6)); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(5, 8, 5); dirLight.castShadow = true; scene.add(dirLight); scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x222222)); const wallMaterials = WallModel.createMaterials(); // Buitenzijde-materiaalkeuze (tab 3): 'vochtdoek' of 'cementplaat', // wederzijds exclusief. Binnenzijde is altijd spaanplaat (geen alternatief). // (Alleen knoppen met data-material/-cladding: de "Opslaan"-knop van // Project eigenschappen heeft ook de klasse material-toggle-btn.) let buitenMateriaal = 'vochtdoek'; let cladding = 'horizontaal'; // richting gevelbekleding (alleen bij vochtdoek) let trasraam = null; // { bottom, top } mm, komt uit de woning-instellingen (WallModel.trasraamFor) 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; }); } 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 isolatieVisible = true; // Weergave-filter (core/view-filter.js): beplating binnen/buiten + isolatie. createViewFilter(document.getElementById('wall-toolbar-3d'), [ { key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }, { key: 'isolatie', label: 'Isolatie' } ], (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; isolatieVisible = v.isolatie; update3DScene(); }); // 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld // met de unit-editor). let lastWallStats = null; function update3DScene() { if (wallGroup3D) scene.remove(wallGroup3D); const result = WallModel.build3D(CONFIG, calcData, { materials: wallMaterials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible, isolatieVisible }); wallGroup3D = result.group; lastWallStats = result.stats; scene.add(wallGroup3D); updateSpecificaties(); } // ============================================================ // SPECIFICATIES (tab 3) // ============================================================ 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'); // Inkoop in handelslengtes (zaagplan uit WallModel). 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 => { 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, toggleFullscreen3D, toggleFullscreen2D, 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 }] }) };