Files
Tectum/js/floor/floor.js
T
MachielandClaude Opus 5.5 ab0dca9bb5 v0.7.1: UI-overhaul, ribbon vervangen door de indeling van CAD/BIM-pakketten met elke functie op één plek: bovenbalk met menu Bestand (nieuw, openen, opslaan, exporteren, projectgegevens, kosten & tijden) · Beeld · Help, de ontwerpen als tabs en de knop Opslaan; links de structuur met inklapbare groepen (onthouden, groep met de selectie klapt vanzelf open); werkbalk boven de viewports met gereedschap (Selecteren V, Binnenwand W, Ruimte R, Inrichting I, opties van het gereedschap ernaast, Unit toevoegen) en weergave (één Lagen-menu met bouwfase voor 2D en 3D, Omgeving, Doorsnede X, Render, Lopen L, Rapport 4, 2D + 3D / 2D / 3D met 1/2/3); rechts het eigenschappenpaneel (Inspector: selectie met kruimelpad, of de woning met kerncijfers en inklapbare secties Gevel, Dak, Binnen, Verlichting, Elektra, Constructie); statusbalk met hint en meldingen; wand- en vloer/dak-editor in dezelfde indeling (paneel rechts met tab Resultaat, terug met ← Woning of Esc); sneltoetsenoverzicht (?), Ctrl+O, Ctrl+\ panelen verbergen; rapport als masonry; dubbele functies weg (lagenbalk, Beeld-tab en Weergave-filter, export en kosten op twee plekken, Snel nieuw); oude onderdelen gelijkgetrokken (dialoogknoppen, materiaalknoppen, chassis als schakelaar, eenheden); weergavelagen (ViewLayers) i.p.v. bouwfasen, elk los aan/uit; render-export met Blender-script voor de lampen; elektraleidingen boven een vals plafond en haaks binnen de units bij een L/T-vorm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:16:43 +02:00

754 lines
34 KiB
JavaScript

/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const FLOOR_TEMPLATE = `
<div id="floor-app" class="ed-app">
<div class="ed-main">
<div class="ed-toolbar" id="floor-toolbar"></div>
<div class="ed-views">
<div id="floor-left-column">
<div id="floor-pane-2d-container">
<div class="pane-2d-sub" id="floor-pane-top">
<div class="pane-label">BOVENAANZICHT</div>
<canvas id="floor-canvas-top"></canvas>
</div>
<div class="pane-2d-sub" id="floor-pane-section">
<div class="pane-label">DOORSNEDE</div>
<canvas id="floor-canvas-section"></canvas>
</div>
</div>
</div>
<div id="floor-right-column">
<div id="floor-pane-3d">
<div class="pane-label">3D</div>
</div>
</div>
</div>
</div>
<div id="floor-control-panel" class="ed-panel">
<div class="io-toolbar">
<input type="file" id="f-file-import-json" accept="application/json" style="display:none;">
<span id="f-io-status" class="io-status"></span>
</div>
<div class="tab-bar">
<button class="tab-btn active" data-tab="floor-tab-main"><i class="bi bi-rulers"></i> Opbouw</button>
<button class="tab-btn" data-tab="floor-tab-openings"><i class="bi bi-bullseye"></i> Doorvoeren</button>
<button class="tab-btn" data-tab="floor-tab-chassis"><i class="bi bi-truck"></i> Chassis</button>
<button class="tab-btn" data-tab="floor-tab-specs"><i class="bi bi-clipboard-data"></i> Resultaat</button>
</div>
<div id="floor-tab-main" class="tab-content active">
<div class="controls-grid">
<div>
<h1><i class="bi bi-rulers"></i> Afmetingen</h1>
<div class="control-group"><label>Breedte</label>
<div><input type="number" id="f-inp-width" value="2000" step="50"><span class="unit">mm</span></div></div>
<div class="control-group"><label>Diepte</label>
<div><input type="number" id="f-inp-depth" value="2000" step="50"><span class="unit">mm</span></div></div>
<div class="divider"></div>
<h1><i class="bi bi-columns-gap"></i> Vloerbalken</h1>
<div class="control-group"><label>Breedte</label>
<div><input type="number" id="f-inp-joist-w" value="70" step="5"><span class="unit">mm</span></div></div>
<div class="control-group"><label>Hoogte</label>
<div><input type="number" id="f-inp-joist-h" value="220" step="5"><span class="unit">mm</span></div></div>
<div class="divider"></div>
<h1><i class="bi bi-layers"></i> Onderlaag OSB</h1>
<div class="control-group"><label>Dikte</label>
<div><input type="number" id="f-inp-underlay-t" value="22" step="1" min="3" max="40"><span class="unit">mm</span></div></div>
</div>
<div>
<h1><i class="bi bi-arrows"></i> Regelwerk</h1>
<div class="control-group"><label>Afstand min.</label>
<div><input type="number" id="f-inp-min-sp" value="300" step="10"><span class="unit">mm</span></div></div>
<div class="control-group"><label>Afstand max.</label>
<div><input type="number" id="f-inp-max-sp" value="600" step="10"><span class="unit">mm</span></div></div>
</div>
</div>
</div>
<div id="floor-tab-openings" class="tab-content">
<button id="f-btn-add-opening" class="add-opening-btn"><i class="bi bi-plus-lg"></i> Doorvoer toevoegen</button>
<div id="f-openings-global-warn" class="global-warning" style="display:none;"></div>
<div id="f-openings-list"></div>
</div>
<div id="floor-tab-chassis" class="tab-content">
<div class="control-group"><label for="f-chassis-on">Chassis-laag</label>
<div><label class="ui-switch"><input type="checkbox" id="f-chassis-on"><span></span></label></div></div>
<div class="controls-grid">
<div>
<div class="control-group"><label>Wieldiameter</label>
<div><input type="number" id="f-inp-wheel-d" value="400" step="10" min="50"><span class="unit">mm</span></div></div>
</div>
<div>
<div class="control-group"><label>Spoorbreedte</label>
<div><input type="number" id="f-inp-track" value="1200" step="50" min="100"><span class="unit">mm</span></div></div>
<div class="control-group"><label>Wielbasis</label>
<div><input type="number" id="f-inp-wheelbase" value="1200" step="50" min="100"><span class="unit">mm</span></div></div>
</div>
</div>
<div id="f-chassis-warning" class="global-warning" style="display:none;"></div>
</div>
<div id="floor-tab-specs" class="tab-content">
<div id="floor-specs-panel-3d">
<div>
<div>
<div class="spec-section">
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
<div class="result-row"><span>Vloerbalken</span><span data-spec="joist-count" class="highlight">-</span></div>
<div class="result-row"><span>Vloeroppervlak</span><span data-spec="floor-area" class="highlight">-</span></div>
</div>
<div class="spec-section">
<h1><i class="bi bi-tree"></i> Houtwerk</h1>
<div class="result-row"><span>Volume balkhout</span><span data-spec="joist-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht balkhout</span><span data-spec="joist-kg" class="highlight">-</span></div>
</div>
</div>
<div>
<div class="spec-section">
<h1><i class="bi bi-layers"></i> OSB onderlaag</h1>
<div class="result-row"><span>Oppervlak</span><span data-spec="osb-m2" class="highlight">-</span></div>
<div class="result-row"><span>Volume</span><span data-spec="osb-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht</span><span data-spec="osb-kg" class="highlight">-</span></div>
</div>
<div class="spec-section">
<h1><i class="bi bi-speedometer2"></i> Isolatie en gewicht</h1>
<div class="result-row"><span>Isolatie</span><span data-spec="void-m3" class="highlight">-</span></div>
<div class="result-row"><span>Totaal gewicht</span><span data-spec="total-kg" class="highlight">-</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="wall-project-overlay">
<div id="wall-project-modal">
<div class="modal-header">
<h2>Project eigenschappen</h2>
<button type="button" id="wall-project-close-x" class="modal-close-x">&times;</button>
</div>
<div class="controls-grid">
<div>
<div class="control-group"><label for="inp-proj-naam">Projectnaam</label>
<div><input type="text" id="inp-proj-naam" value=""></div></div>
<div class="control-group"><label for="inp-proj-plaats">Plaats</label>
<div><input type="text" id="inp-proj-plaats" value=""></div></div>
<div class="control-group"><label for="inp-proj-straat">Straat</label>
<div><input type="text" id="inp-proj-straat" value=""></div></div>
<div class="control-group"><label for="inp-proj-nummer">Projectnummer</label>
<div><input type="text" id="inp-proj-nummer" value=""></div></div>
</div>
<div>
<div class="control-group"><label for="inp-proj-tekenaar">Tekenaar</label>
<div><input type="text" id="inp-proj-tekenaar" value=""></div></div>
<div class="control-group"><label for="inp-proj-titel">Titel</label>
<div><input type="text" id="inp-proj-titel" value="Vloer"></div></div>
<div class="control-group"><label for="inp-proj-element">Elementnummer</label>
<div><input type="text" id="inp-proj-element" value=""></div></div>
<div class="control-group"><label for="inp-proj-opmerking">Opmerking</label>
<div><input type="text" id="inp-proj-opmerking" value=""></div></div>
</div>
</div>
<div class="modal-footer">
<button type="button" id="wall-project-cancel" class="ui-btn">Annuleren</button>
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
</div>
</div>
</div>
`;
function floorTemplateForRoof() {
const r = [
[' Vloerbalken</h1>', ' Dakbalken</h1>'],
['<span>Vloerbalken</span>', '<span>Dakbalken</span>'],
[' Onderlaag OSB</h1>', ' Plafondplaat OSB</h1>'],
[' OSB onderlaag</h1>', ' OSB plafond</h1>'],
['<button class="tab-btn" data-tab="floor-tab-chassis"><i class="bi bi-truck"></i> Chassis</button>', ''],
[' Isolatie en gewicht</h1>', ' Dakleer, isolatie en gewicht</h1>\n <div class="result-row"><span>Dakleer oppervlak</span><span data-spec="dakleer-m2" class="highlight">-</span></div>\n <div class="result-row"><span>Dakleer gewicht</span><span data-spec="dakleer-kg" class="highlight">-</span></div>']
];
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;
if (callbacks && callbacks.lockSize) {
['f-inp-width', 'f-inp-depth'].forEach(id => {
const el = document.getElementById(id);
el.disabled = true;
el.title = callbacks.lockSize;
});
}
let openings = [];
let openingIdCounter = 1;
// Chassis wordt niet geklemd (anders dan doorvoeren), alleen gewaarschuwd buiten de vloerrand.
let chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
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 => `
<div class="opening-card" data-opening-id="${op.id}">
<div class="opening-card-header">
<span class="opening-card-title">Doorvoer #${op.id}</span>
<button class="opening-remove-btn" data-action="remove" data-id="${op.id}">&times;</button>
</div>
<div class="controls-grid">
<div>
<div class="control-group"><label>Diameter</label>
<div><input type="number" data-field="diameter" data-id="${op.id}" value="${op.diameter}" step="5" min="10"><span class="unit">mm</span></div></div>
<div class="control-group"><label>Afstand tot noordzijde</label>
<div><input type="number" data-field="offsetY" data-id="${op.id}" value="${op.offsetY}" step="50"><span class="unit">mm</span></div></div>
</div>
<div>
<div class="control-group"><label>Anker</label>
<div>
<select data-field="anchorX" data-id="${op.id}">
<option value="left" ${op.anchorX === 'left' ? 'selected' : ''}>West</option>
<option value="middle" ${op.anchorX === 'middle' ? 'selected' : ''}>Midden</option>
<option value="right" ${op.anchorX === 'right' ? 'selected' : ''}>Oost</option>
</select>
</div>
</div>
<div class="control-group"><label>${anchorLabel(op.anchorX)}</label>
<div><input type="number" data-field="offsetX" data-id="${op.id}" value="${op.offsetX}" step="50"><span class="unit">mm</span></div></div>
</div>
</div>
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
</div>
`).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() {
document.getElementById('f-chassis-on').checked = chassis.enabled;
}
document.getElementById('f-chassis-on').addEventListener('change', (e) => {
chassis.enabled = e.target.checked;
syncChassisToggleLabel();
updateAll();
});
document.querySelectorAll('#floor-tab-chassis input[type=number]').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();
// Bewust dezelfde id's als wall.js: er is nooit meer dan één editor tegelijk gemount.
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 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 = '';
});
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)
});
}
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;
}
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.';
}
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();
});
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);
}
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);
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();
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);
});
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);
}
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);
}
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 = '';
});
}
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 };
const toolbar = document.getElementById('floor-toolbar');
toolbar.innerHTML = `<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${UI.tbSep()}${UI.viewSeg('split')}`;
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
const viewGroup = toolbar.querySelector('.tb-view');
ViewLayers.createMenu(viewGroup, {
layers: {}, keys: [],
extras: [{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }],
onExtra: (v) => { osbVisible = v.beplating; update3DScene(); }
});
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(viewGroup, (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();
}
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 setView(mode) {
UI.setView(document.getElementById('floor-app'), mode);
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,
setView,
viewState: () => ({ view: UI.viewOf(document.getElementById('floor-app')) }),
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: []
})
};