/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // ============================================================ // floor/floor.js — Vloer-editor. Hergebruikt calculateSegmentPositions uit // core/geometry.js (moet voor dit bestand geladen worden) voor exact // dezelfde h.o.h.-balkverdeling als de wand, nu toegepast op vloerbalken. // Vloeren krijgen UITSLUITEND cirkelvormige openingen (leidingdoorvoer, // bijv. riool/water) — en die mogen alleen TUSSEN twee balken vallen, nooit // een balk kruisen: een doorvoer wordt daarom automatisch verkleind tot een // maat die wél binnen zijn balkvak past, en pas verwijderd (uit de // weergave, niet uit de invoer) als zelfs een minimale diameter nergens past. // // Zelfde soorten logica als wall.js: een isolatielaag (leegte tussen de // balken, analytisch bepaald — geen gewicht toegekend, precies zoals bij de // wand), een 3D-perspectief (balken, OSB-onderlaag met doorvoer-gaten, // isolatie, chassis-wielen) en een specificaties-paneel met gewichten. // Onderlaag is standaard OSB-plaat van 22mm. // // De rekenlogica (balken, doorvoeren, chassis, specs, 3D-opbouw) staat in // floor/floor-model.js (FloorModel) — gedeeld met de unit-editor. // // Plain script (geen ES-module), zelfde mount()/unmount()-patroon als // wall.js. // ============================================================ const FLOOR_TEMPLATE = `
BOVENAANZICHT
DOORSNEDE

Afmetingen

mm
mm

Vloerbalken

mm
mm

Onderlaag OSB

mm

Regelwerk

mm
mm
Vloerbalken-
Vloeroppervlak-
Volume balkhout-
Gewicht balkhout-
OSB oppervlak-
OSB volume-
OSB gewicht-
Isolatie-
Totaal gewicht-
mm
mm
mm
3D

Balken

Vloerbalken-
Vloeroppervlak-

Houtwerk

Volume balkhout-
Gewicht balkhout-

OSB onderlaag

Oppervlak-
Volume-
Gewicht-

Isolatie en gewicht

Isolatie-
Totaal gewicht-
`; // Dakmodus: een plat dak is een vloer die 180° gespiegeld is (OSB-plaat aan // de onderzijde als plafond, balken erboven) met dakleer als buitenlaag // (RoofModel). Zelfde editor, andere teksten + gespiegelde doorsnede/3D. function floorTemplateForRoof() { const r = [ [' Vloerbalken', ' Dakbalken'], ['Vloerbalken', 'Dakbalken'], [' Onderlaag OSB', ' Plafondplaat OSB'], [' OSB onderlaag', ' OSB plafond'], ['', ''], [' Isolatie en gewicht', ' Dakleer, isolatie en gewicht\n
Dakleer oppervlak-
\n
Dakleer gewicht-
'] ]; let t = FLOOR_TEMPLATE; r.forEach(([a, b]) => { t = t.split(a).join(b); }); return t.split('Vloeroppervlak').join('Dakoppervlak'); } function mountFloorEditor(container, callbacks) { const onSaved = callbacks && callbacks.onSaved; const roofMode = !!(callbacks && callbacks.mode === 'roof'); container.innerHTML = roofMode ? floorTemplateForRoof() : FLOOR_TEMPLATE; // Binnen een woning kan de maat vastliggen (dak volgt de vloer); // callbacks.lockSize is dan de uitleg-tekst voor de tooltip. if (callbacks && callbacks.lockSize) { ['f-inp-width', 'f-inp-depth'].forEach(id => { const el = document.getElementById(id); el.disabled = true; el.title = callbacks.lockSize; }); } // ---------------------------------------------------------- // State // ---------------------------------------------------------- // Dichtheden, balkvak-marge en minimale doorvoer-diameter: zie FloorModel. // Geen residuele demo-doorvoer meer — leeg beginnen, gebruiker voegt zelf toe. let openings = []; let openingIdCounter = 1; // Chassis-laag: een losse, schematische onderconstructie (transportwielen) // die NIET met de balkpositie hoeft te overlappen — in tegenstelling tot de // doorvoeren hierboven wordt hier niets geklemd/verkleind, alleen buiten de // vloerrand gewaarschuwd (zie computeChassisWheels). let chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 }; // Tab-navigatie (1. Vloer Opbouw / 2. Doorvoeren / 3. Chassis) — dezelfde // generieke .tab-btn/.tab-content-wiring als wall.js. Ontbrak hier eerder // (residu van vóór de tab-bar werd toegevoegd), waardoor de knoppen wel // getekend maar niet klikbaar waren. 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 anchorLabel(a) { if (a === 'right') return 'Afstand tot oostzijde'; if (a === 'middle') return 'Verschuiving t.o.v. midden'; return 'Afstand tot westzijde'; } function renderOpeningsList() { const el = document.getElementById('f-openings-list'); el.innerHTML = openings.map(op => `
Doorvoer #${op.id}
mm
mm
mm
`).join(''); } document.getElementById('f-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 === 'anchorX') { op.anchorX = e.target.value; renderOpeningsList(); } else op[field] = parseFloat(e.target.value) || 0; updateAll(); }); document.getElementById('f-openings-list').addEventListener('click', (e) => { if (e.target.dataset.action === 'remove') { openings = openings.filter(o => o.id !== parseInt(e.target.dataset.id, 10)); renderOpeningsList(); updateAll(); } }); document.getElementById('f-btn-add-opening').addEventListener('click', () => { openings.push({ id: openingIdCounter++, diameter: 110, anchorX: 'left', offsetX: 500 + openings.length * 600, offsetY: 500 }); renderOpeningsList(); updateAll(); }); document.querySelectorAll('#floor-tab-main input').forEach(inp => inp.addEventListener('input', updateAll)); function syncChassisToggleLabel() { const btn = document.getElementById('f-btn-chassis-toggle'); btn.classList.toggle('active', chassis.enabled); btn.textContent = 'Chassis-laag: ' + (chassis.enabled ? 'Aan' : 'Uit'); } document.getElementById('f-btn-chassis-toggle').addEventListener('click', () => { chassis.enabled = !chassis.enabled; syncChassisToggleLabel(); updateAll(); }); document.querySelectorAll('#floor-tab-chassis input').forEach(inp => inp.addEventListener('input', () => { chassis.wheelDiameter = parseFloat(document.getElementById('f-inp-wheel-d').value) || 400; chassis.trackWidth = parseFloat(document.getElementById('f-inp-track').value) || 1200; chassis.wheelbase = parseFloat(document.getElementById('f-inp-wheelbase').value) || 1200; updateAll(); })); syncChassisToggleLabel(); // ------------------------------------------------------------ // Project eigenschappen — zelfde modal/velden als wall.js (bewust // dezelfde id's: er is nooit meer dan één editor gelijktijdig gemount, // dus hergebruik hiervan is veilig en voorkomt dubbele CSS/HTML). // ------------------------------------------------------------ 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(); } }); // ------------------------------------------------------------ // JSON export/import — zelfde patroon als wall.js: sla het volledige // vloer-ontwerp op (parameters + chassis + ruwe doorvoeren) als JSON, en // laad het weer terug. Bij "Opslaan als" wordt ook automatisch de // bijbehorende PDF-tekening gegenereerd (zie generateFloorPdf). // ------------------------------------------------------------ function setIoStatus(msg, cls) { const el = document.getElementById('f-io-status'); el.textContent = msg; el.className = 'io-status ' + (cls || ''); clearTimeout(setIoStatus._t); setIoStatus._t = setTimeout(() => { el.textContent = ''; }, 4000); } function buildDesignObject() { return { type: 'floor-design', version: 1, exportedAt: new Date().toISOString(), ...getPureState() }; } function exportDesign() { const suggested = 'vloer-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 = generateFloorPdf(); 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 !== 'floor-design') throw new Error('geen geldig vloer-ontwerp (type ontbreekt of onjuist)'); FloorModel.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} doorvoer(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('f-file-import-json').click(); } document.getElementById('f-file-import-json').addEventListener('change', (e) => { const file = e.target.files[0]; if (file) importDesign(file); e.target.value = ''; }); // Balkvakken + doorvoer-resolutie (nooit een balk kruisen): zie // FloorModel.resolveOpenings (floor/floor-model.js). // ---------------------------------------------------------- // Balkverdeling: FloorModel.computeFrame (zelfde algoritme als de wand). // ---------------------------------------------------------- let calcData = { joists: [] }; 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 recalculate() { calcData = FloorModel.computeFrame({ width: parseFloat(document.getElementById('f-inp-width').value), depth: parseFloat(document.getElementById('f-inp-depth').value), joistWidth: parseFloat(document.getElementById('f-inp-joist-w').value), joistHeight: parseFloat(document.getElementById('f-inp-joist-h').value), minSpacing: parseFloat(document.getElementById('f-inp-min-sp').value), maxSpacing: parseFloat(document.getElementById('f-inp-max-sp').value), underlayThick: parseFloat(document.getElementById('f-inp-underlay-t').value) }); } // Specificaties: FloorModel.computeSpecs (balkhout, OSB, isolatie-leegte, // gewichtstotaal). function updateSpecificaties(resolvedOpenings) { const specs = roofMode ? RoofModel.computeSpecs(calcData, resolvedOpenings) : FloorModel.computeSpecs(calcData, resolvedOpenings); if (roofMode) { setSpecAll('dakleer-m2', fmt(specs.dakleerM2, 2) + ' m2'); setSpecAll('dakleer-kg', fmt(specs.dakleerKg) + ' kg'); } setSpecAll('joist-count', calcData.joists.length + ' stuks'); setSpecAll('floor-area', fmt((calcData.width * calcData.depth) / 1e6, 2) + ' m2'); setSpecAll('joist-m3', fmt(specs.joistWoodM3, 3) + ' m3'); setSpecAll('joist-kg', fmt(specs.joistWoodKg) + ' kg'); setSpecAll('osb-m2', fmt(specs.osbAreaM2, 2) + ' m2'); setSpecAll('osb-m3', fmt(specs.osbVolM3, 3) + ' m3'); setSpecAll('osb-kg', fmt(specs.osbKg) + ' kg'); setSpecAll('void-m3', fmt(specs.voidM3, 3) + ' m3'); setSpecAll('total-kg', fmt(specs.totalKg) + ' kg'); return specs; } // Chassis-laag (4 wielen, "dobbelsteen 4"): FloorModel.computeChassisWheels. function updateChassisWarning(outOfBounds) { const warnBox = document.getElementById('f-chassis-warning'); warnBox.style.display = outOfBounds ? 'block' : 'none'; warnBox.textContent = 'Let op: één of meer wielen steken buiten de vloerrand — verklein de spoorbreedte/wielbasis of vergroot de vloer.'; } // ---------------------------------------------------------- // Canvas: bovenaanzicht // ---------------------------------------------------------- const canvasTop = document.getElementById('floor-canvas-top'); const ctxTop = canvasTop.getContext('2d'); function drawTop(resolvedOpenings, chassisData) { const { width, depth } = calcData; const rect = canvasTop.parentElement.getBoundingClientRect(); canvasTop.width = rect.width; canvasTop.height = rect.height; const w = canvasTop.width, h = canvasTop.height; ctxTop.clearRect(0, 0, w, h); const scale = Math.min(w / (width * 1.35), h / (depth * 1.35)); const cx = w / 2, cy = h / 2; const toX = (xmm) => cx + xmm * scale; const toY = (ymm) => cy - (depth / 2 - ymm) * scale; // y=0 (voor) boven, y=depth (achter) onder ctxTop.strokeStyle = '#222'; ctxTop.lineWidth = 1; const gridSize = 200 * scale; for (let x = 0; x < w; x += gridSize) { ctxTop.beginPath(); ctxTop.moveTo(x, 0); ctxTop.lineTo(x, h); ctxTop.stroke(); } for (let y = 0; y < h; y += gridSize) { ctxTop.beginPath(); ctxTop.moveTo(0, y); ctxTop.lineTo(w, y); ctxTop.stroke(); } ctxTop.strokeStyle = '#666'; ctxTop.lineWidth = 2; ctxTop.strokeRect(toX(-width / 2), toY(0), width * scale, depth * scale); calcData.joists.forEach(x => { ctxTop.fillStyle = '#c29b62'; ctxTop.strokeStyle = '#8a6836'; const jw = calcData.joistW * scale; ctxTop.fillRect(toX(x) - jw / 2, toY(0), jw, depth * scale); ctxTop.strokeRect(toX(x) - jw / 2, toY(0), jw, depth * scale); }); resolvedOpenings.filter(o => !o.removed).forEach(op => { ctxTop.beginPath(); ctxTop.arc(toX(op.x), toY(op.y), op.r * scale, 0, Math.PI * 2); ctxTop.fillStyle = op.overflowing ? 'rgba(255,170,60,.4)' : 'rgba(102,170,255,.35)'; ctxTop.strokeStyle = op.overflowing ? '#ffaa33' : '#4da6ff'; ctxTop.lineWidth = 2; ctxTop.fill(); ctxTop.stroke(); ctxTop.fillStyle = '#fff'; ctxTop.font = '10px sans-serif'; ctxTop.textAlign = 'center'; ctxTop.fillText('#' + op.id, toX(op.x), toY(op.y) - op.r * scale - 6); }); (chassisData ? chassisData.wheels : []).forEach(wheel => { ctxTop.beginPath(); ctxTop.arc(toX(wheel.x), toY(wheel.y), wheel.r * scale, 0, Math.PI * 2); ctxTop.fillStyle = chassisData.outOfBounds ? 'rgba(255,60,60,.35)' : 'rgba(30,30,30,.55)'; ctxTop.strokeStyle = chassisData.outOfBounds ? '#ff3c3c' : '#000'; ctxTop.lineWidth = 1.5; ctxTop.fill(); ctxTop.stroke(); }); // Maatlabels (zelfde stijl als het wand-vooraanzicht, core/canvas-labels.js) const xAxisY = Math.max(14, toY(0) - 14); drawCanvasDim(ctxTop, toX(-width / 2), xAxisY, toX(width / 2), xAxisY, 'X: ' + Math.round(width) + 'mm', cx, Math.max(10, xAxisY - 4)); const yAxisX = Math.max(14, toX(-width / 2) - 14); drawCanvasDim(ctxTop, yAxisX, toY(0), yAxisX, toY(depth), 'Y: ' + Math.round(depth) + 'mm', Math.max(10, yAxisX - 4), (toY(0) + toY(depth)) / 2); } // ---------------------------------------------------------- // Canvas: doorsnede (balkprofiel — breedte x hoogte, aanvullende kijkhoek // naast het bovenaanzicht, analoog aan de vooraanzicht-doorsnede bij wall.js) // ---------------------------------------------------------- const canvasSection = document.getElementById('floor-canvas-section'); const ctxSection = canvasSection.getContext('2d'); function drawSection() { const { width, joistW, joistH, underlayThick } = calcData; const rect = canvasSection.parentElement.getBoundingClientRect(); canvasSection.width = rect.width; canvasSection.height = rect.height; const w = canvasSection.width, h = canvasSection.height; ctxSection.clearRect(0, 0, w, h); // Dakmodus: 180° gespiegeld — OSB onder, balken erboven, dakleer bovenop. const leerT = roofMode ? RoofModel.DAKLEER_MM : 0; const totalH = joistH + underlayThick + leerT; const joistY0 = roofMode ? underlayThick : 0; const osbY0 = roofMode ? 0 : joistH; const scale = Math.min(w / (width * 1.3), h / (totalH * 3)); const cx = w / 2, floorY = h - 20; const toX = (xmm) => cx + xmm * scale; const toY = (ymm) => floorY - ymm * scale; ctxSection.strokeStyle = '#666'; ctxSection.lineWidth = 1; ctxSection.beginPath(); ctxSection.moveTo(toX(-width / 2), toY(0)); ctxSection.lineTo(toX(width / 2), toY(0)); ctxSection.stroke(); // Isolatie (leegte tussen de balken) — geschematiseerd als een // doorlopende, licht transparante band ter hoogte van de balken, zodat // ook in de doorsnede duidelijk is wát er tussen de balken zit. ctxSection.fillStyle = 'rgba(255, 224, 140, 0.25)'; ctxSection.fillRect(toX(-width / 2), toY(joistY0 + joistH), width * scale, toY(joistY0) - toY(joistY0 + joistH)); calcData.joists.forEach(x => { ctxSection.fillStyle = '#c29b62'; ctxSection.strokeStyle = '#8a6836'; ctxSection.fillRect(toX(x - joistW / 2), toY(joistY0 + joistH), joistW * scale, joistH * scale); ctxSection.strokeRect(toX(x - joistW / 2), toY(joistY0 + joistH), joistW * scale, joistH * scale); }); // OSB-plaat: bij een vloer boven op de balken, bij een dak eronder (plafond). ctxSection.fillStyle = '#cda86a'; ctxSection.strokeStyle = '#8a6836'; ctxSection.fillRect(toX(-width / 2), toY(osbY0 + underlayThick), width * scale, toY(osbY0) - toY(osbY0 + underlayThick)); ctxSection.strokeRect(toX(-width / 2), toY(osbY0 + underlayThick), width * scale, toY(osbY0) - toY(osbY0 + underlayThick)); ctxSection.fillStyle = '#fff'; ctxSection.font = '9px sans-serif'; ctxSection.textAlign = 'center'; if (roofMode) { const topY = toY(totalH); ctxSection.fillStyle = '#2a2a2a'; ctxSection.strokeStyle = '#888'; ctxSection.fillRect(toX(-width / 2), topY - 2, width * scale, 3); ctxSection.strokeRect(toX(-width / 2), topY - 2, width * scale, 3); ctxSection.fillStyle = '#fff'; ctxSection.fillText('Dakleer', cx, topY - 8); ctxSection.textAlign = 'right'; ctxSection.fillText(underlayThick + 'mm OSB (plafond)', toX(-width / 2) - 6, toY(underlayThick / 2) + 3); ctxSection.textAlign = 'center'; } else { ctxSection.fillText(underlayThick + 'mm OSB', cx, toY(joistH + underlayThick) - 6); } // Maatlabels (zelfde stijl als het wand-vooraanzicht) const xAxisY = Math.min(h - 12, toY(0) + 8); drawCanvasDim(ctxSection, toX(-width / 2), xAxisY, toX(width / 2), xAxisY, 'X: ' + Math.round(width) + 'mm', cx, Math.min(h - 2, xAxisY + 11)); const zAxisX = Math.min(w - 14, toX(width / 2) + 14); drawCanvasDim(ctxSection, zAxisX, toY(0), zAxisX, toY(totalH), 'Z: ' + Math.round(totalH) + 'mm', Math.min(w - 4, zAxisX + 12), (toY(0) + toY(totalH)) / 2); } // ---------------------------------------------------------- // Waarschuwingen naar de openingen-kaarten // ---------------------------------------------------------- function updateWarnings(resolved) { const anyWarn = resolved.some(o => o.overflowing); const warnBox = document.getElementById('f-openings-global-warn'); warnBox.style.display = anyWarn ? 'block' : 'none'; warnBox.textContent = 'Let op: één of meer doorvoeren zijn verkleind/verwijderd zodat ze geen balk kruisen, of overlappen elkaar.'; resolved.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); warnEl.style.display = o.overflowing ? 'block' : 'none'; if (o.removed) warnEl.textContent = 'Past nergens tussen twee balken (ook niet verkleind) — niet getekend.'; else if (o.shrunk) warnEl.textContent = `Verkleind naar ${Math.round(o.diameter)}mm om niet over een balk heen te vallen (gevraagd: ${o.requestedDiameter}mm).`; else if (o.overlapping) warnEl.textContent = 'Overlapt een andere doorvoer in hetzelfde balkvak.'; else warnEl.textContent = ''; }); } // ---------------------------------------------------------- // THREE.JS 3D RENDERER — zelfde opzet als wall.js (scene/camera/renderer/ // OrbitControls/licht/grid), maar plat gelegd: X = breedte, Z = diepte, // Y = dikte (balkhoogte + OSB), in plaats van wall.js' X/Y/Z (breedte/ // hoogte/diepte). Balken en isolatie zijn simpele dozen; de OSB-onderlaag // is — net als de platen bij wall.js — een geëxtrudeerd Shape met ronde // uitsparingen voor de doorvoeren. // ---------------------------------------------------------- const pane3D = document.getElementById('floor-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(2.6, 2.3, 3.4); 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, 0.15, 0); controls.update(); } const floorAmbient = new THREE.AmbientLight(0xffffff, 0.6); scene.add(floorAmbient); 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 floorMaterials = roofMode ? RoofModel.createMaterials() : FloorModel.createMaterials(); let osbVisible = true; let floorGroup3D = null; let lastResolvedOpenings = []; let lastChassisData = { wheels: [], outOfBounds: false }; // Weergave-filter (core/view-filter.js). Beplating = OSB-plaat (bij een // dak: plafondplaat + dakleer). createViewFilter(document.getElementById('floor-toolbar-3d'), [ { key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' } ], (v) => { osbVisible = v.beplating; update3DScene(); }); // Omgeving (core/skybox.js: geen/dag/nacht); de vloer staat op de tegelvloer. let skyMode = Skybox.getMode(); const skyEnv = Skybox.createEnvironment(scene); const applySky = () => { skyEnv.fitTo(floorGroup3D, 0); Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient: floorAmbient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: floorGroup3D }); }; Skybox.createToggle(document.getElementById('floor-toolbar-3d'), (m) => { skyMode = m; applySky(); }); function update3DScene() { if (floorGroup3D) scene.remove(floorGroup3D); floorGroup3D = roofMode ? RoofModel.build3D(calcData, lastResolvedOpenings, { materials: floorMaterials, osbVisible, dakleerVisible: osbVisible }) : FloorModel.build3D(calcData, lastResolvedOpenings, lastChassisData, { materials: floorMaterials, osbVisible }); scene.add(floorGroup3D); applySky(); // schaduw van de nieuwe vloer } // ------------------------------------------------------------ // PDF-export: het tekenblad zelf komt uit FloorPdf (floor/floor-pdf.js), // gedeeld met de unit-PDF. // ------------------------------------------------------------ function generateFloorPdf() { recalculate(); const resolvedOpenings = FloorModel.resolveOpenings(openings, calcData.width, calcData.depth, calcData.joists, calcData.joistW); const doc = PdfSheet.createDoc(); const project = PdfSheet.readProjectFromDom(); FloorPdf.drawSheet(doc, calcData, resolvedOpenings, { project, titel: project.titel, element: project.element, roof: roofMode }); return doc; } function updateAll() { recalculate(); lastResolvedOpenings = FloorModel.resolveOpenings(openings, calcData.width, calcData.depth, calcData.joists, calcData.joistW); lastChassisData = FloorModel.computeChassisWheels(chassis, calcData.width, calcData.depth); drawTop(lastResolvedOpenings, lastChassisData); drawSection(); updateWarnings(lastResolvedOpenings); updateChassisWarning(lastChassisData.outOfBounds); updateSpecificaties(lastResolvedOpenings); update3DScene(); } 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); drawTop(lastResolvedOpenings, lastChassisData); drawSection(); } window.addEventListener('resize', onWindowResize); function toggleFullscreen3D() { UI.cycle3D(document.getElementById('floor-app')); // T: standaard -> 3D groot -> alleen render requestAnimationFrame(onWindowResize); } function toggleFullscreen2D() { UI.toggle2D(document.getElementById('floor-app')); requestAnimationFrame(onWindowResize); } function getPureState() { return { floor: { width: parseFloat(document.getElementById('f-inp-width').value), depth: parseFloat(document.getElementById('f-inp-depth').value), joistWidth: parseFloat(document.getElementById('f-inp-joist-w').value), joistHeight: parseFloat(document.getElementById('f-inp-joist-h').value), minSpacing: parseFloat(document.getElementById('f-inp-min-sp').value), maxSpacing: parseFloat(document.getElementById('f-inp-max-sp').value), underlayThick: parseFloat(document.getElementById('f-inp-underlay-t').value) }, chassis: { ...chassis }, openings: openings.map(o => ({ id: o.id, diameter: o.diameter, anchorX: o.anchorX, offsetX: o.offsetX, offsetY: o.offsetY })) }; } function applyPureState(state) { document.getElementById('f-inp-width').value = state.floor.width; document.getElementById('f-inp-depth').value = state.floor.depth; document.getElementById('f-inp-joist-w').value = state.floor.joistWidth; document.getElementById('f-inp-joist-h').value = state.floor.joistHeight; document.getElementById('f-inp-min-sp').value = state.floor.minSpacing; document.getElementById('f-inp-max-sp').value = state.floor.maxSpacing; document.getElementById('f-inp-underlay-t').value = state.floor.underlayThick ?? 22; chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200, ...(state.chassis || {}) }; document.getElementById('f-inp-wheel-d').value = chassis.wheelDiameter; document.getElementById('f-inp-track').value = chassis.trackWidth; document.getElementById('f-inp-wheelbase').value = chassis.wheelbase; syncChassisToggleLabel(); openings = state.openings.map(o => ({ id: o.id, diameter: o.diameter, anchorX: o.anchorX, offsetX: o.offsetX, offsetY: o.offsetY })); openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1; renderOpeningsList(); updateAll(); } renderOpeningsList(); updateAll(); animate(); return { unmount() { window.removeEventListener('resize', onWindowResize); if (__rafId !== null) cancelAnimationFrame(__rafId); CameraMemory.save(cameraKey, camera, controls); renderer.dispose(); }, exportDesign, triggerImport, toggleFullscreen3D, toggleFullscreen2D, openProjectProperties, getStateJson: getPureState, loadStateJson: applyPureState }; } window.FloorEditor = { mount: mountFloorEditor, getDefaultState: () => ({ floor: { width: 2000, depth: 2000, joistWidth: 70, joistHeight: 220, minSpacing: 300, maxSpacing: 600, underlayThick: 22 }, chassis: { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 }, openings: [] }) };