Files
Tectum/js/wall/wall.js
T

1436 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const WALL_TEMPLATE = `
<div id="app-container" class="ed-app">
<div class="ed-main">
<div class="ed-toolbar" id="wall-toolbar"></div>
<div class="ed-views">
<div id="left-column">
<div id="pane-2d-container">
<div class="pane-2d-sub pane-2d-primary" id="pane-2d-front">
<div class="pane-label">BUITENAANZICHT</div>
<canvas id="canvas-2d-front"></canvas>
</div>
<div class="pane-2d-sub pane-2d-row" id="pane-2d-bottom-row">
<div class="pane-2d-half" id="pane-2d-top">
<div class="pane-label">BOVENAANZICHT</div>
<canvas id="canvas-2d-top"></canvas>
</div>
<div class="pane-2d-half" id="pane-2d-side">
<div class="pane-label">ZIJAANZICHT</div>
<canvas id="canvas-2d-side"></canvas>
</div>
</div>
</div>
</div>
<div id="right-column">
<div id="pane-3d">
<div class="pane-label">3D</div>
</div>
</div>
</div>
</div>
<div id="control-panel" class="ed-panel">
<div class="io-toolbar">
<input type="file" id="file-import-json" accept="application/json" style="display:none;">
<span id="io-status" class="io-status"></span>
</div>
<div class="tab-bar">
<button class="tab-btn active" data-tab="tab-wall"><i class="bi bi-rulers"></i> Opbouw</button>
<button class="tab-btn" data-tab="tab-opening"><i class="bi bi-door-open"></i> Openingen</button>
<button class="tab-btn" data-tab="tab-materials"><i class="bi bi-layers"></i> Materiaal</button>
<button class="tab-btn" data-tab="tab-specs"><i class="bi bi-clipboard-data"></i> Resultaat</button>
</div>
<!-- TAB 1: WAND -->
<div id="tab-wall" class="tab-content active">
<div class="controls-grid">
<div>
<h1><i class="bi bi-rulers"></i> Afmetingen</h1>
<div class="control-group">
<label for="inp-wall-w">Breedte</label>
<div><input type="number" id="inp-wall-w" value="6000" step="50" min="800" max="10000"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label for="inp-wall-h-all">Hoogte</label>
<div><input type="number" id="inp-wall-h-all" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label for="inp-wall-h-left" id="lbl-wall-h-left">Hoogte links</label>
<div><input type="number" id="inp-wall-h-left" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label for="inp-wall-h-right" id="lbl-wall-h-right">Hoogte rechts</label>
<div><input type="number" id="inp-wall-h-right" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div>
</div>
<div class="control-group" title="Middenpunt van de bovenrand verhogen (nok): links en rechts worden dan goothoogtes.">
<label for="inp-wall-peak">Middenpunt (nok)</label>
<div><label class="ui-switch"><input type="checkbox" id="inp-wall-peak"><span></span></label></div>
</div>
<div class="control-group" id="grp-wall-h-mid" style="display:none">
<label for="inp-wall-h-mid">Nokhoogte</label>
<div><input type="number" id="inp-wall-h-mid" value="3400" step="50" min="500" max="8000"><span class="unit">mm</span></div>
</div>
<div class="divider"></div>
<h1><i class="bi bi-columns-gap"></i> Stijlen</h1>
<div class="control-group">
<label for="inp-stud-w">Dikte</label>
<div><input type="number" id="inp-stud-w" value="38" step="5" min="20" max="300"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label for="inp-stud-d">Diepte</label>
<div><input type="number" id="inp-stud-d" value="235" step="5" min="20" max="400"><span class="unit">mm</span></div>
</div>
</div>
<div>
<h1><i class="bi bi-distribute-vertical"></i> Regels (boven en onder)</h1>
<div class="control-group">
<label for="inp-plate-w">Dikte</label>
<div><input type="number" id="inp-plate-w" value="38" step="5" min="20" max="200"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label for="inp-plate-d">Diepte</label>
<div><input type="number" id="inp-plate-d" value="235" step="5" min="20" max="400"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label for="inp-sheathing-t">Dikte beplating</label>
<div><input type="number" id="inp-sheathing-t" value="10" step="1" min="3" max="30"><span class="unit">mm</span></div>
</div>
<div class="divider"></div>
<h1><i class="bi bi-arrows"></i> Regelwerk</h1>
<div class="control-group">
<label for="inp-min-sp">Afstand min.</label>
<div><input type="number" id="inp-min-sp" value="240" step="10" min="100" max="1000"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label for="inp-max-sp">Afstand max.</label>
<div><input type="number" id="inp-max-sp" value="480" step="10" min="150" max="1500"><span class="unit">mm</span></div>
</div>
</div>
</div>
</div>
<!-- TAB 2: OPENINGEN (meerdere, ankerbaar) -->
<div id="tab-opening" class="tab-content">
<button id="btn-add-opening" class="add-opening-btn"><i class="bi bi-plus-lg"></i> Opening toevoegen</button>
<div id="openings-global-warning" class="global-warning" style="display:none;"></div>
<div id="openings-list"></div>
</div>
<!-- TAB 3: MATERIAALKEUZE -->
<div id="tab-materials" class="tab-content">
<h1><i class="bi bi-layers"></i> Buitenzijde</h1>
<div class="material-toggle-group">
<button type="button" id="btn-mat-vochtdoek" class="material-toggle-btn active" data-material="vochtdoek">Vochtdoek</button>
<button type="button" id="btn-mat-cementplaat" class="material-toggle-btn" data-material="cementplaat">Cementplaat</button>
<button type="button" id="btn-mat-spaanplaat" class="material-toggle-btn" data-material="spaanplaat">Spaanplaat</button>
</div>
<div id="cladding-section">
<div class="divider"></div>
<h1><i class="bi bi-grip-horizontal"></i> Richting gevelbekleding</h1>
<div class="material-toggle-group">
<button type="button" class="material-toggle-btn active" data-cladding="horizontaal">Horizontaal</button>
<button type="button" class="material-toggle-btn" data-cladding="verticaal">Verticaal</button>
</div>
<div class="material-info">Op het vochtdoek komt een kruislat van 20&times;30 mm, 400 mm h.o.h. in beide richtingen. De bovenste lat staat haaks op de gevelbekleding.</div>
</div>
</div>
<div id="tab-specs" class="tab-content">
<div id="specs-panel-3d">
<div>
<div>
<div class="spec-section">
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
<div class="result-row"><span>Verticale balken</span><span data-spec="vert-beams" class="highlight">-</span></div>
<div class="result-row"><span>Horizontale balken</span><span data-spec="horiz-beams" class="highlight">-</span></div>
<div class="result-row"><span>Dakhelling</span><span data-spec="roof-angle" class="highlight">-</span></div>
</div>
<div class="spec-section">
<h1><i class="bi bi-tree"></i> Houtwerk</h1>
<div class="result-row"><span>Volume hout</span><span data-spec="wood-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht hout</span><span data-spec="wood-kg" class="highlight">-</span></div>
<div class="result-row"><span>Inkoop balken</span><span data-spec="stock-list" class="highlight">-</span></div>
<div class="result-row"><span>Snijverlies</span><span data-spec="stock-waste" class="highlight">-</span></div>
</div>
<div class="spec-section">
<h1><i class="bi bi-layers"></i> Binnenzijde: spaanplaat</h1>
<div class="result-row"><span>Oppervlak</span><span data-spec="spaanplaat-m2" class="highlight">-</span></div>
<div class="result-row"><span>Volume</span><span data-spec="spaanplaat-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht</span><span data-spec="spaanplaat-kg" class="highlight">-</span></div>
</div>
</div>
<div>
<div class="spec-section">
<h1><i class="bi bi-layers-half"></i> Buitenzijde: <span data-spec="buiten-materiaalnaam">Vochtdoek</span></h1>
<div class="result-row"><span>Oppervlak</span><span data-spec="buiten-m2" class="highlight">-</span></div>
<div class="result-row"><span>Volume</span><span data-spec="buiten-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht</span><span data-spec="buiten-kg" class="highlight">-</span></div>
<div class="result-row"><span>Latwerk 20×30</span><span data-spec="latten" 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="BuitenWand"></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" data-esc>Annuleren</button>
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
</div>
</div>
</div>
`;
function mountWallEditor(container, callbacks) {
const onSaved = callbacks && callbacks.onSaved;
container.innerHTML = WALL_TEMPLATE;
if (callbacks && callbacks.lockWidth) {
const widthInput = document.getElementById('inp-wall-w');
widthInput.disabled = true;
widthInput.title = callbacks.lockWidth;
}
const lockCladding = callbacks && callbacks.lockCladding;
const lockExterior = callbacks && callbacks.lockExterior;
if (lockExterior && lockCladding) {
const tabBtn = container.querySelector('.tab-btn[data-tab="tab-materials"]');
if (tabBtn) tabBtn.style.display = 'none';
}
if (callbacks && callbacks.lockSpacing) {
['inp-min-sp', 'inp-max-sp'].forEach(id => {
const el = document.getElementById(id);
el.disabled = true;
el.title = callbacks.lockSpacing;
});
}
const seamCuts = (callbacks && callbacks.seamCuts) || [];
const lampMarks = (callbacks && callbacks.lamps) || [];
let CONFIG = {};
let calcData = { fullStuds: [], studs: [] };
let wallGroup3D = null;
let lastBaseHeight = 2400;
let wallTopPts = null;
// offset: left = vanaf de linker wandrand, right = vanaf de rechter, middle = t.o.v. het midden (+ = rechts).
// hR = hoogte rechts bij een schuine bovenzijde; de onderkant is altijd waterpas.
let openings = [
{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }
];
let openingIdCounter = 2;
let selectedOpeningId = null;
let dragInfo = null;
let frontView = { scale: 1, centerX: 0, floorY: 0 };
let frontOpeningRects = [];
let frontHandleRects = [];
function snap50(mm) { return Math.round(mm / 50) * 50; }
function applyOpeningPosition(op, leftLocalX, w, wallWidth) {
if (op.anchor === 'right') op.offset = wallWidth - leftLocalX - w;
else if (op.anchor === 'middle') op.offset = leftLocalX - (wallWidth - w) / 2;
else op.offset = leftLocalX;
op.w = w;
}
const isSloped = (op) => op.hR !== undefined;
const rawOpening = (o) => Object.assign({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset },
o.hR !== undefined ? { hR: o.hR } : {}, o.fill ? { fill: Object.assign({}, o.fill) } : {});
function syncOpeningCardInputs(op) {
const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`);
if (!card) return;
if (!!card.querySelector('input[data-field="hR"]') !== isSloped(op)) { renderOpeningsList(); return; }
['w', 'h', 'hR', 'y', 'offset'].forEach(field => {
const input = card.querySelector(`input[data-field="${field}"]`);
if (input && document.activeElement !== input) input.value = Math.round(op[field]);
});
}
function highlightSelectedCard() {
document.querySelectorAll('.opening-card').forEach(card => {
const id = parseInt(card.dataset.openingId, 10);
card.classList.toggle('opening-selected', id === selectedOpeningId);
});
}
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(ct => ct.classList.remove('active'));
document.querySelector(`.tab-btn[data-tab="${tabId}"]`).classList.add('active');
document.getElementById(tabId).classList.add('active');
}
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
function anchorOffsetLabel(anchor) {
if (anchor === 'right') return 'Afstand tot rechts';
if (anchor === 'middle') return 'Verschuiving t.o.v. midden';
return 'Afstand tot links';
}
function addOpening() {
const n = openings.length;
const preset = WallModel.OPENING_TYPES.raam;
openings.push({
id: openingIdCounter++,
type: 'raam',
w: preset.w,
h: preset.h,
y: preset.y,
anchor: 'left',
offset: 300 + n * 1400,
fill: KozijnModel.defaultFill('raam', lastFill())
});
renderOpeningsList();
updateAll();
}
const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer',
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
let projectPropsSnapshot = null;
function openProjectProperties() {
projectPropsSnapshot = {};
PROJECT_PROP_IDS.forEach(id => { projectPropsSnapshot[id] = document.getElementById(id).value; });
document.getElementById('wall-project-overlay').classList.add('open');
}
function closeProjectProperties() {
document.getElementById('wall-project-overlay').classList.remove('open');
}
document.getElementById('wall-project-save').addEventListener('click', closeProjectProperties);
document.getElementById('wall-project-cancel').addEventListener('click', () => {
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
closeProjectProperties();
});
document.getElementById('wall-project-close-x').addEventListener('click', () => {
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
closeProjectProperties();
});
document.getElementById('wall-project-overlay').addEventListener('click', (e) => {
if (e.target.id === 'wall-project-overlay') {
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
closeProjectProperties();
}
});
function removeOpening(id) {
openings = openings.filter(o => o.id !== id);
if (selectedOpeningId === id) selectedOpeningId = null;
renderOpeningsList();
updateAll();
}
// Verticaal spiegelen bewust niet: een wand mag onderin niet scheef worden (menu-item blijft uit).
function mirrorHorizontal() {
const hLeftInput = document.getElementById('inp-wall-h-left');
const hRightInput = document.getElementById('inp-wall-h-right');
const tmp = hLeftInput.value;
hLeftInput.value = hRightInput.value;
hRightInput.value = tmp;
if (wallTopPts) wallTopPts = wallTopPts.map(q => ({ f: 1 - q.f, h: q.h })).reverse();
openings = openings.map(o => {
if (isSloped(o)) o = { ...o, h: o.hR, hR: o.h };
if (o.anchor === 'left') return { ...o, anchor: 'right' };
if (o.anchor === 'right') return { ...o, anchor: 'left' };
if (o.anchor === 'middle') return { ...o, offset: -o.offset };
return o;
});
renderOpeningsList();
updateAll();
setIoStatus('Horizontaal gespiegeld.', 'ok');
}
function fillControls(op) {
const f = KozijnModel.normalize(op.fill);
const type = f ? f.type : 'geen';
const opts = (entries, cur) => entries.map(([k, label]) => `<option value="${k}" ${k === cur ? 'selected' : ''}>${label}</option>`).join('');
const colors = Object.entries(KozijnModel.COLORS).map(([k, c]) => [k, `${c.label} (${c.ral})`]);
const sw = (field, label, on, title) => `<div class="control-group"${title ? ` title="${title}"` : ''}>
<label>${label}</label>
<div><label class="ui-switch"><input type="checkbox" data-field="${field}" data-id="${op.id}" ${on ? 'checked' : ''}><span></span></label></div>
</div>`;
const pick = (field, label, entries, cur, title) => `<div class="control-group"${title ? ` title="${title}"` : ''}>
<label>${label}</label>
<div><select data-field="${field}" data-id="${op.id}">${opts(Object.entries(entries), cur)}</select></div>
</div>`;
return `<div class="controls-grid opening-fill">
<div>
<div class="control-group">
<label>Vulling</label>
<div><select data-field="fill" data-id="${op.id}">${opts(Object.entries(KozijnModel.FILL_TYPES), type)}</select></div>
</div>
${type === 'raam' ? sw('fillRooster', 'Rooster', f.rooster, 'Ventilatierooster bovenin het kozijn') : ''}
${type === 'deur' ? sw('fillGlas', 'Glasstrook', f.glas, 'Glasstrook in het deurblad') : ''}
${type === 'schuifdeur' ? sw('fillGlas', 'Glazen blad', f.glas, 'Glas in een smal kader; uit = dicht blad') : ''}
${type === 'deur' ? sw('fillVolglas', 'Glasdeur', f.volglas, 'Glas in het hele blad (smal kader)') +
pick('fillLeaf', 'Blad', KozijnModel.HINGES, f.leaf, 'Kant van het deurblad (van buitenaf gezien) als er een zijlicht naast past') +
pick('fillHinge', 'Scharnier', KozijnModel.HINGES, f.hinge, 'Van buitenaf gezien. Openslaande deuren: twee deuren naast elkaar en gekoppeld, bladen naar het midden, scharnieren buiten') +
pick('fillSwing', 'Draait', KozijnModel.SWINGS, f.swing) : ''}
${type === 'schuifdeur' ? pick('fillLeaves', 'Bladen', { 1: '1 blad', 2: '2 bladen (uit elkaar)' }, String(f.leaves)) +
pick('fillMount', 'Montage', KozijnModel.SLIDE_MOUNTS, f.mount) +
(f.mount === 'opbouw' ? pick('fillSide', 'Rail aan', KozijnModel.SLIDE_SIDES, f.side, 'Buitenzijde = +z (bij een binnenwand de "buiten"-kant)') : '') +
(f.leaves === 1 ? pick('fillDir', 'Schuift', KozijnModel.SLIDE_DIRS, f.dir, 'Van buitenaf gezien') : '') : ''}
</div>
<div>
${f ? `<div class="control-group">
<label>Kleur buiten</label>
<div><select data-field="fillOut" data-id="${op.id}">${opts(colors, f.out)}</select></div>
</div>
<div class="control-group">
<label>Kleur binnen</label>
<div><select data-field="fillIn" data-id="${op.id}">${opts(colors, f.in)}</select></div>
</div>` : ''}
</div>
</div>`;
}
const lastFill = () => { const o = openings.slice().reverse().find(x => x.fill); return o ? o.fill : null; };
function renderOpeningsList() {
const list = document.getElementById('openings-list');
list.innerHTML = openings.map(op => `
<div class="opening-card" data-opening-id="${op.id}">
<div class="opening-card-header">
<span class="opening-card-title">${(WallModel.OPENING_TYPES[op.type] || WallModel.OPENING_TYPES.raam).label} #${op.id}</span>
<button class="opening-remove-btn" data-action="remove" data-id="${op.id}" title="Verwijderen">&times;</button>
</div>
<div class="controls-grid">
<div>
<div class="control-group">
<label>Type</label>
<div>
<select data-field="type" data-id="${op.id}">
${Object.entries(WallModel.OPENING_TYPES).map(([k, t]) => `<option value="${k}" ${(op.type || 'raam') === k ? 'selected' : ''}>${t.label}</option>`).join('')}
</select>
</div>
</div>
<div class="control-group">
<label>Breedte</label>
<div><input type="number" data-field="w" data-id="${op.id}" value="${op.w}" step="50" min="400" max="2850"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label>${isSloped(op) ? 'Hoogte links' : 'Hoogte'}</label>
<div><input type="number" data-field="h" data-id="${op.id}" value="${op.h}" step="50" min="400"><span class="unit">mm</span></div>
</div>
${isSloped(op) ? `<div class="control-group">
<label>Hoogte rechts</label>
<div><input type="number" data-field="hR" data-id="${op.id}" value="${op.hR}" step="50" min="400"><span class="unit">mm</span></div>
</div>` : ''}
<div class="control-group">
<label>Afstand tot vloer</label>
<div><input type="number" data-field="y" data-id="${op.id}" value="${op.y}" step="50" min="0"><span class="unit">mm</span></div>
</div>
</div>
<div>
<div class="control-group">
<label>Ankerpunt</label>
<div>
<select data-field="anchor" data-id="${op.id}">
<option value="left" ${op.anchor === 'left' ? 'selected' : ''}>Links</option>
<option value="middle" ${op.anchor === 'middle' ? 'selected' : ''}>Midden</option>
<option value="right" ${op.anchor === 'right' ? 'selected' : ''}>Rechts</option>
</select>
</div>
</div>
<div class="control-group">
<label>${anchorOffsetLabel(op.anchor)}</label>
<div><input type="number" data-field="offset" data-id="${op.id}" value="${op.offset}" step="50"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label>Schuine bovenzijde</label>
<div><label class="ui-switch"><input type="checkbox" data-field="sloped" data-id="${op.id}" ${isSloped(op) ? 'checked' : ''}><span></span></label></div>
</div>
<div class="control-group" title="Tegen de opening ernaast aan: één sparing zonder stijlen, dagkanten of kozijnstijlen ertussen. Twee draaideuren worden openslaand (bladen tegen elkaar), anders één tussenstijl. Onderkant en hoogte gelijk aan de buur; samen max. 2850 mm.">
<label>Koppelen</label>
<div><label class="ui-switch"><input type="checkbox" data-field="koppel" data-id="${op.id}" ${op.koppel ? 'checked' : ''}><span></span></label></div>
</div>
${isSloped(op) ? `<button class="opening-roof-btn" data-action="follow-roof" data-id="${op.id}" title="Hoogte rechts zo zetten dat de bovenkant evenwijdig aan de daklijn loopt"><i class="bi bi-slash-lg"></i> Volg dakhelling</button>` : ''}
</div>
</div>
${fillControls(op)}
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
</div>
`).join('');
highlightSelectedCard();
}
document.getElementById('openings-list').addEventListener('click', (e) => {
const header = e.target.closest('.opening-card-header');
if (header && !e.target.closest('.opening-remove-btn')) {
const card = header.closest('.opening-card');
selectedOpeningId = parseInt(card.dataset.openingId, 10);
highlightSelectedCard();
draw2D();
}
});
document.getElementById('openings-list').addEventListener('input', (e) => {
const id = parseInt(e.target.dataset.id, 10);
const field = e.target.dataset.field;
if (!field) return;
const op = openings.find(o => o.id === id);
if (!op) return;
if (field === 'type') {
const preset = WallModel.OPENING_TYPES[e.target.value];
op.type = e.target.value;
op.w = preset.w; op.h = preset.h; op.y = preset.y;
delete op.hR;
if (op.fill) op.fill = KozijnModel.defaultFill(op.type, op.fill);
renderOpeningsList();
} else if (field === 'fill') {
const v = e.target.value;
if (v === 'geen') delete op.fill;
else op.fill = Object.assign(KozijnModel.defaultFill(v === 'deur' || v === 'schuifdeur' ? (op.type === 'raam' ? 'deur' : op.type) : 'raam', op.fill || lastFill()), { type: v });
renderOpeningsList();
} else if (/^fill(Rooster|Glas|Volglas|Out|In|Hinge|Leaf|Swing|Leaves|Mount|Side|Dir)$/.test(field)) {
if (!op.fill) return;
const key = { fillRooster: 'rooster', fillGlas: 'glas', fillVolglas: 'volglas', fillOut: 'out', fillIn: 'in', fillHinge: 'hinge', fillLeaf: 'leaf', fillSwing: 'swing',
fillLeaves: 'leaves', fillMount: 'mount', fillSide: 'side', fillDir: 'dir' }[field];
op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : key === 'leaves' ? Number(e.target.value) : e.target.value;
if (key === 'leaves' || key === 'mount') renderOpeningsList();
} else if (field === 'koppel') {
if (e.target.checked) op.koppel = true; else delete op.koppel;
} else if (field === 'sloped') {
if (e.target.checked) op.hR = op.h; else delete op.hR;
renderOpeningsList();
} else if (field === 'anchor') {
op.anchor = e.target.value;
renderOpeningsList();
} else {
op[field] = parseFloat(e.target.value) || 0;
}
updateAll();
});
document.getElementById('openings-list').addEventListener('click', (e) => {
if (e.target.dataset.action === 'remove') {
removeOpening(parseInt(e.target.dataset.id, 10));
}
const roofBtn = e.target.closest('[data-action="follow-roof"]');
if (roofBtn) followRoof(parseInt(roofBtn.dataset.id, 10));
});
document.getElementById('btn-add-opening').addEventListener('click', addOpening);
function followRoof(id) {
const op = openings.find(o => o.id === id);
const res = (CONFIG.openings || []).find(o => o.id === id);
if (!op || !res) return;
const at = WallModel.topLine(CONFIG).at, x0 = -CONFIG.wallWidth / 2 + res.x;
op.hR = Math.max(400, Math.round(op.h + at(x0 + res.w) - at(x0)));
syncOpeningCardInputs(op);
updateAll();
}
function setIoStatus(msg, cls) {
const el = document.getElementById('io-status');
el.textContent = msg;
el.className = 'io-status ' + (cls || '');
clearTimeout(setIoStatus._t);
setIoStatus._t = setTimeout(() => { el.textContent = ''; }, 4000);
}
function collectWallParams() {
return {
width: parseFloat(document.getElementById('inp-wall-w').value),
heightAll: parseFloat(document.getElementById('inp-wall-h-all').value),
heightLeft: parseFloat(document.getElementById('inp-wall-h-left').value),
heightRight: parseFloat(document.getElementById('inp-wall-h-right').value),
heightMid: document.getElementById('inp-wall-peak').checked ? parseFloat(document.getElementById('inp-wall-h-mid').value) : null,
topPts: wallTopPts ? wallTopPts.map(q => Object.assign({}, q)) : undefined,
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
plateDepth: parseFloat(document.getElementById('inp-plate-d').value),
sheathingThick: parseFloat(document.getElementById('inp-sheathing-t').value),
minSpacing: parseFloat(document.getElementById('inp-min-sp').value),
maxSpacing: parseFloat(document.getElementById('inp-max-sp').value)
};
}
function getPureState() {
return {
wall: collectWallParams(),
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor },
openings: openings.map(rawOpening)
};
}
function applyPureState(state) {
document.getElementById('inp-wall-w').value = state.wall.width;
document.getElementById('inp-wall-h-all').value = state.wall.heightAll;
document.getElementById('inp-wall-h-left').value = state.wall.heightLeft;
document.getElementById('inp-wall-h-right').value = state.wall.heightRight;
document.getElementById('inp-wall-peak').checked = state.wall.heightMid > 0;
document.getElementById('inp-wall-h-mid').value = state.wall.heightMid > 0 ? state.wall.heightMid
: Math.max(state.wall.heightLeft, state.wall.heightRight) + 800;
document.getElementById('inp-stud-w').value = state.wall.studWidth;
document.getElementById('inp-stud-d').value = state.wall.studDepth;
document.getElementById('inp-plate-w').value = state.wall.plateThick;
document.getElementById('inp-plate-d').value = state.wall.plateDepth;
document.getElementById('inp-sheathing-t').value = state.wall.sheathingThick;
document.getElementById('inp-min-sp').value = state.wall.minSpacing;
document.getElementById('inp-max-sp').value = state.wall.maxSpacing;
lastBaseHeight = state.wall.heightAll;
wallTopPts = Array.isArray(state.wall.topPts) && state.wall.topPts.length ? state.wall.topPts.map(q => Object.assign({}, q)) : null;
buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek';
cladding = (state.materials && state.materials.cladding) || 'horizontaal';
trasraam = (state.materials && state.materials.trasraam) || null;
stone = (state.materials && state.materials.stone) || null;
stuc = (state.materials && state.materials.stuc) || null;
rabatColor = (state.materials && state.materials.rabatColor) || null;
syncMaterialButtons();
openings = state.openings.map(rawOpening);
openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1;
selectedOpeningId = null;
renderOpeningsList();
updateAll();
}
function generateWallPdf() {
recalculate();
const doc = PdfSheet.createDoc();
const project = PdfSheet.readProjectFromDom();
WallPdf.drawSheet(doc, CONFIG, calcData, { buitenMateriaal, cladding, trasraam, project, titel: project.titel, element: project.element });
return doc;
}
function buildDesignObject() {
return { type: 'wall-design', version: 1, exportedAt: new Date().toISOString(), ...getPureState() };
}
function exportDesign() {
const suggested = 'wand-ontwerp-' + new Date().toISOString().slice(0, 10);
let filename = window.prompt('Bestandsnaam voor het opgeslagen ontwerp:', suggested);
if (filename === null) { setIoStatus('Opslaan geannuleerd.', ''); return null; }
filename = filename.trim();
if (!filename) filename = suggested;
if (!filename.toLowerCase().endsWith('.json')) filename += '.json';
const design = buildDesignObject();
const blob = new Blob([JSON.stringify(design, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
const pdfBase = filename.replace(/\.json$/i, '');
try {
const pdfDoc = generateWallPdf();
pdfDoc.save(pdfBase + '.pdf');
setIoStatus(`Opgeslagen als "${filename}" + PDF.`, 'ok');
} catch (err) {
setIoStatus(`Opgeslagen als "${filename}" (PDF mislukt: ${err.message}).`, 'err');
}
if (typeof onSaved === 'function') onSaved(filename);
return filename;
}
function validateDesignJson(design) {
if (!design || design.type !== 'wall-design') throw new Error('geen geldig wand-ontwerp (type ontbreekt of onjuist)');
WallModel.validateState(design);
}
function importDesign(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const design = JSON.parse(e.target.result);
validateDesignJson(design);
applyPureState(design);
setIoStatus(`Geïmporteerd (${openings.length} opening(en)).`, 'ok');
if (typeof onSaved === 'function') onSaved(file.name);
} catch (err) {
setIoStatus('Import mislukt: ' + err.message, 'err');
}
};
reader.onerror = () => setIoStatus('Kon bestand niet lezen.', 'err');
reader.readAsText(file);
}
function triggerImport() {
document.getElementById('file-import-json').click();
}
document.getElementById('file-import-json').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) importDesign(file);
e.target.value = '';
});
function updateOpeningWarnings(resolvedOpenings) {
const anyOverflow = resolvedOpenings.some(o => o.overflowing);
const globalWarn = document.getElementById('openings-global-warning');
if (anyOverflow) {
globalWarn.style.display = 'block';
globalWarn.textContent = 'Let op: de opgegeven openingen passen niet allemaal binnen deze wandbreedte op hun gekozen ankerpunt. Rood gemarkeerde openingen zijn tegen de rand of tegen elkaar aan geduwd.';
} else {
globalWarn.style.display = 'none';
}
resolvedOpenings.forEach(o => {
const card = document.querySelector(`.opening-card[data-opening-id="${o.id}"]`);
const warnEl = document.querySelector(`[data-warning-for="${o.id}"]`);
if (!card || !warnEl) return;
card.classList.toggle('opening-warn', !!o.overflowing);
const msg = o.overflowing ? 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.' : o.koppelWarn ? `Koppelen: ${o.koppelWarn}.` : '';
warnEl.style.display = msg ? 'block' : 'none';
warnEl.textContent = msg;
});
}
function readInputs() {
const wallWidth = Math.round(parseFloat(document.getElementById('inp-wall-w').value) || 3000);
const hAllInput = document.getElementById('inp-wall-h-all');
const hLeftInput = document.getElementById('inp-wall-h-left');
const hRightInput = document.getElementById('inp-wall-h-right');
const hMidInput = document.getElementById('inp-wall-h-mid');
const peakOn = document.getElementById('inp-wall-peak').checked;
let hAll = Math.round(parseFloat(hAllInput.value) || 2400);
let hLeft = Math.round(parseFloat(hLeftInput.value) || 2400);
let hRight = Math.round(parseFloat(hRightInput.value) || 2900);
let hMid = Math.round(parseFloat(hMidInput.value) || 0);
if (hAll !== lastBaseHeight) {
const delta = hAll - lastBaseHeight;
hLeft = Math.max(500, hLeft + delta);
hRight = Math.max(500, hRight + delta);
hMid = Math.max(500, hMid + delta);
hLeftInput.value = hLeft;
hRightInput.value = hRight;
hMidInput.value = hMid;
lastBaseHeight = hAll;
}
// Met nok: links/rechts zijn goothoogtes.
document.getElementById('grp-wall-h-mid').style.display = peakOn ? '' : 'none';
document.getElementById('lbl-wall-h-left').textContent = peakOn ? 'Goothoogte links' : 'Hoogte links';
document.getElementById('lbl-wall-h-right').textContent = peakOn ? 'Goothoogte rechts' : 'Hoogte rechts';
CONFIG = WallModel.buildConfig({
width: wallWidth,
heightLeft: hLeft,
heightRight: hRight,
heightMid: peakOn ? hMid : null,
topPts: wallTopPts || undefined,
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
plateDepth: parseFloat(document.getElementById('inp-plate-d').value),
sheathingThick: parseFloat(document.getElementById('inp-sheathing-t').value),
minSpacing: parseFloat(document.getElementById('inp-min-sp').value),
maxSpacing: parseFloat(document.getElementById('inp-max-sp').value)
}, openings);
updateOpeningWarnings(CONFIG.openings);
}
function recalculate() {
readInputs();
calcData = WallModel.computeFrame(CONFIG);
setSpecAll('vert-beams', calcData.studs.length + ' stuks');
setSpecAll('roof-angle', calcData.angleDeg + '°');
}
const canvas2DTop = document.getElementById('canvas-2d-top');
const ctx = canvas2DTop.getContext('2d');
function draw2DTop() {
const rect = canvas2DTop.parentElement.getBoundingClientRect();
canvas2DTop.width = rect.width;
canvas2DTop.height = rect.height;
const w = canvas2DTop.width;
const h = canvas2DTop.height;
ctx.clearRect(0, 0, w, h);
const maxDepth = Math.max(CONFIG.studDepth, CONFIG.plateDepth);
const scale = Math.min(w, h) / (Math.max(CONFIG.wallWidth, 1500) * 1.4);
const centerX = w / 2;
const centerY = h / 2;
ctx.strokeStyle = '#222';
ctx.lineWidth = 1;
const gridSize = 100 * scale;
for (let x = 0; x < w; x += gridSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
for (let y = 0; y < h; y += gridSize) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
const plateWidthPx = CONFIG.wallWidth * scale;
const sheathingPx = CONFIG.sheathingThick * scale;
const maxDepthPx = maxDepth * scale;
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
ctx.strokeStyle = '#666';
ctx.fillRect(centerX - plateWidthPx / 2, centerY - (maxDepthPx / 2) - sheathingPx, plateWidthPx, sheathingPx);
ctx.strokeRect(centerX - plateWidthPx / 2, centerY - (maxDepthPx / 2) - sheathingPx, plateWidthPx, sheathingPx);
ctx.fillRect(centerX - plateWidthPx / 2, centerY + (maxDepthPx / 2), plateWidthPx, sheathingPx);
ctx.strokeRect(centerX - plateWidthPx / 2, centerY + (maxDepthPx / 2), plateWidthPx, sheathingPx);
const studWidthPx = CONFIG.studWidth * scale;
const studDepthPx = CONFIG.studDepth * scale;
const drawStud2D = (xPos, isJack = false) => {
const screenX = centerX + (xPos * scale) - (studWidthPx / 2);
const screenY = centerY - (studDepthPx / 2);
ctx.fillStyle = isJack ? '#a37c45' : '#c29b62';
ctx.fillRect(screenX, screenY, studWidthPx, studDepthPx);
ctx.strokeStyle = '#8a6836';
ctx.strokeRect(screenX, screenY, studWidthPx, studDepthPx);
};
const fullXs = new Set(calcData.studs.filter(st => st.kind !== 'kreupel').map(st => st.x));
[...new Set(calcData.studs.map(st => st.x))].forEach(x => drawStud2D(x, !fullXs.has(x)));
const startX = -CONFIG.wallWidth / 2;
CONFIG.openings.forEach(op => {
const opLeft = startX + op.x;
const opLeftPx = centerX + (opLeft * scale);
const opW_Px = op.w * scale;
ctx.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)';
ctx.fillRect(opLeftPx, centerY - (maxDepthPx / 2) - sheathingPx, opW_Px, maxDepthPx + sheathingPx * 2);
ctx.strokeStyle = '#ff3333';
ctx.setLineDash([3, 3]);
ctx.strokeRect(opLeftPx, centerY - (maxDepthPx / 2) - sheathingPx, opW_Px, maxDepthPx + sheathingPx * 2);
ctx.setLineDash([]);
ctx.fillStyle = '#fff';
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('#' + op.id, opLeftPx + opW_Px / 2, centerY - (maxDepthPx / 2) - sheathingPx - 4);
});
}
const canvas2DFront = document.getElementById('canvas-2d-front');
const ctxFront = canvas2DFront.getContext('2d');
function draw2DFront() {
const rect = canvas2DFront.parentElement.getBoundingClientRect();
canvas2DFront.width = rect.width;
canvas2DFront.height = rect.height;
const w = canvas2DFront.width;
const h = canvas2DFront.height;
ctxFront.clearRect(0, 0, w, h);
const wallWidthMM = CONFIG.wallWidth;
const hLeftMM = CONFIG.wallHeightLeft;
const hRightMM = CONFIG.wallHeightRight;
const halfWMM = wallWidthMM / 2;
const tl = WallModel.topLine(CONFIG);
const maxRoofMM = tl.maxY;
const scale = Math.min(w / (wallWidthMM * 1.3), h / (maxRoofMM * 1.3));
const centerX = w / 2;
const floorY = h - 20;
frontView = { scale, centerX, floorY };
const toCanvasX = (xmm) => centerX + xmm * scale;
const toCanvasY = (ymm) => floorY - ymm * scale;
ctxFront.strokeStyle = '#222';
ctxFront.lineWidth = 1;
const gridSize = 100 * scale;
for (let x = 0; x < w; x += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(x, 0); ctxFront.lineTo(x, h); ctxFront.stroke(); }
for (let y = 0; y < h; y += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(0, y); ctxFront.lineTo(w, y); ctxFront.stroke(); }
ctxFront.strokeStyle = '#555';
ctxFront.setLineDash([4, 4]);
for (let bandY = 1500; buitenMateriaal === 'vochtdoek' && bandY < maxRoofMM; bandY += 1500) {
WallModel.skinSegmentsAtY(CONFIG, bandY).forEach(([x0, x1]) => {
ctxFront.beginPath();
ctxFront.moveTo(toCanvasX(x0), toCanvasY(bandY));
ctxFront.lineTo(toCanvasX(x1), toCanvasY(bandY));
ctxFront.stroke();
});
}
ctxFront.setLineDash([]);
if (buitenMateriaal === 'vochtdoek' && trasraam && trasraam.top > 0) {
ctxFront.strokeStyle = '#a0a3a8';
ctxFront.lineWidth = 1.5;
WallModel.skinSegmentsAtY(CONFIG, trasraam.top).forEach(([x0, x1]) => {
ctxFront.beginPath();
ctxFront.moveTo(toCanvasX(x0), toCanvasY(trasraam.top));
ctxFront.lineTo(toCanvasX(x1), toCanvasY(trasraam.top));
ctxFront.stroke();
});
ctxFront.fillStyle = '#a0a3a8';
ctxFront.font = '10px sans-serif';
ctxFront.fillText('trasraam', toCanvasX(-halfWMM) + 4, toCanvasY(trasraam.top) - 3);
}
ctxFront.strokeStyle = '#666';
ctxFront.lineWidth = 2;
ctxFront.beginPath();
ctxFront.moveTo(toCanvasX(-halfWMM), toCanvasY(0));
ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(0));
tl.pts.slice().reverse().forEach(p => ctxFront.lineTo(toCanvasX(p.x), toCanvasY(p.y)));
ctxFront.closePath();
ctxFront.stroke();
ctxFront.fillStyle = '#c29b62';
ctxFront.strokeStyle = '#8a6836';
ctxFront.lineWidth = 1;
ctxFront.beginPath();
ctxFront.rect(toCanvasX(-halfWMM), toCanvasY(CONFIG.plateThick), toCanvasX(halfWMM) - toCanvasX(-halfWMM), toCanvasY(0) - toCanvasY(CONFIG.plateThick));
ctxFront.fill();
ctxFront.stroke();
WallModel.topPlateOutlines(tl).forEach(pts => {
ctxFront.beginPath();
pts.forEach((p, i) => (i ? ctxFront.lineTo : ctxFront.moveTo).call(ctxFront, toCanvasX(p.x), toCanvasY(p.y)));
ctxFront.closePath();
ctxFront.fill();
ctxFront.stroke();
});
const xAxisY = Math.min(h - 6, toCanvasY(0) + 16);
drawCanvasDim(ctxFront, toCanvasX(-halfWMM), xAxisY, toCanvasX(halfWMM), xAxisY,
'X: ' + Math.round(wallWidthMM) + 'mm', (toCanvasX(-halfWMM) + toCanvasX(halfWMM)) / 2, Math.min(h - 2, xAxisY + 11));
const yAxisLeftX = Math.max(10, toCanvasX(-halfWMM) - 14);
drawCanvasDim(ctxFront, yAxisLeftX, toCanvasY(0), yAxisLeftX, toCanvasY(hLeftMM),
'Y: ' + Math.round(hLeftMM) + 'mm', Math.max(10, yAxisLeftX - 4), (toCanvasY(0) + toCanvasY(hLeftMM)) / 2);
const yAxisRightX = Math.min(w - 10, toCanvasX(halfWMM) + 14);
drawCanvasDim(ctxFront, yAxisRightX, toCanvasY(0), yAxisRightX, toCanvasY(hRightMM),
'Y: ' + Math.round(hRightMM) + 'mm', Math.min(w - 4, yAxisRightX + 12), (toCanvasY(0) + toCanvasY(hRightMM)) / 2);
const inner = tl.pts.slice(1, -1);
inner.forEach(q => {
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
ctxFront.fillStyle = '#9ab';
ctxFront.font = '11px sans-serif';
ctxFront.textAlign = 'center';
ctxFront.fillText((isNok ? 'nok ' : 'knik ') + Math.round(q.y) + 'mm', toCanvasX(q.x), Math.max(12, toCanvasY(q.y) - 6));
ctxFront.textAlign = 'start';
});
const drawStudFront = (st) => {
const xLeft = st.x - CONFIG.studWidth / 2;
const xRight = st.x + CONFIG.studWidth / 2;
ctxFront.fillStyle = st.kind === 'kreupel' ? '#a37c45' : '#c29b62';
ctxFront.strokeStyle = '#8a6836';
ctxFront.beginPath();
ctxFront.moveTo(toCanvasX(xLeft), toCanvasY(st.y0));
ctxFront.lineTo(toCanvasX(xRight), toCanvasY(st.y0));
if (st.y1 === null) tl.underPts(xLeft, xRight).reverse().forEach(p => ctxFront.lineTo(toCanvasX(p.x), toCanvasY(p.y)));
else { ctxFront.lineTo(toCanvasX(xRight), toCanvasY(st.y1)); ctxFront.lineTo(toCanvasX(xLeft), toCanvasY(st.y1)); }
ctxFront.closePath();
ctxFront.fill();
ctxFront.stroke();
};
calcData.studs.forEach(drawStudFront);
const startXMM = -halfWMM;
frontOpeningRects = [];
frontHandleRects = [];
CONFIG.openings.forEach(op => {
const opLeftMM = startXMM + op.x;
const x0 = toCanvasX(opLeftMM);
const x1 = toCanvasX(opLeftMM + op.w);
const y0 = toCanvasY(op.y);
const y1 = toCanvasY(op.y + op.h);
const y1r = toCanvasY(op.y + op.hR);
const isSelected = op.id === selectedOpeningId;
frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, y1r, localX: op.x, y: op.y, w: op.w, h: op.h, hR: op.hR });
const outline = () => {
ctxFront.beginPath();
ctxFront.moveTo(x0, y0); ctxFront.lineTo(x1, y0); ctxFront.lineTo(x1, y1r); ctxFront.lineTo(x0, y1);
ctxFront.closePath();
};
outline();
ctxFront.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)';
ctxFront.fill();
ctxFront.strokeStyle = isSelected ? '#4da6ff' : '#ff3333';
ctxFront.lineWidth = isSelected ? 2.5 : 1;
ctxFront.setLineDash(isSelected ? [] : [3, 3]);
ctxFront.stroke();
ctxFront.setLineDash([]);
ctxFront.lineWidth = 1;
ctxFront.fillStyle = '#fff';
ctxFront.font = '9px sans-serif';
ctxFront.textAlign = 'center';
ctxFront.fillText('#' + op.id, (x0 + x1) / 2, (y1 + y1r) / 2 - 4);
if (isSelected) {
const midX = (x0 + x1) / 2, midTop = (y1 + y1r) / 2;
const handles = [
{ handle: 'nw', cx: x0, cy: y1 },
{ handle: 'n', cx: midX, cy: midTop },
{ handle: 'ne', cx: x1, cy: y1r },
{ handle: 'w', cx: x0, cy: (y0 + y1) / 2 },
{ handle: 'c', cx: midX, cy: (y0 + midTop) / 2 },
{ handle: 'e', cx: x1, cy: (y0 + y1r) / 2 },
{ handle: 'sw', cx: x0, cy: y0 },
{ handle: 's', cx: midX, cy: y0 },
{ handle: 'se', cx: x1, cy: y0 }
];
handles.forEach(hd => {
frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy });
ctxFront.fillStyle = hd.handle === 'c' ? '#ffffff' : '#4da6ff';
ctxFront.strokeStyle = '#0a1a2a';
ctxFront.beginPath();
if (hd.handle === 'c') ctxFront.arc(hd.cx, hd.cy, 5, 0, Math.PI * 2);
else ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10);
ctxFront.fill();
ctxFront.stroke();
});
}
});
drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM);
lampMarks.forEach(l => {
const x = toCanvasX(-halfWMM + l.u), y = toCanvasY(l.y);
ctxFront.fillStyle = '#ffd36b'; ctxFront.strokeStyle = '#1a1a1a'; ctxFront.lineWidth = 1;
ctxFront.fillRect(x - 4, y - 7, 8, 14);
ctxFront.strokeRect(x - 4, y - 7, 8, 14);
});
}
function drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM) {
seamCuts.forEach(([u0, u1, upTo]) => {
const x0 = toCanvasX(-halfWMM + u0), x1 = toCanvasX(-halfWMM + u1);
const yTop = toCanvasY(upTo > 0 ? Math.min(upTo, maxRoofMM) : maxRoofMM), yBot = toCanvasY(0);
ctxFront.save();
ctxFront.fillStyle = 'rgba(17, 17, 17, 0.8)';
ctxFront.fillRect(x0, yTop, x1 - x0, yBot - yTop);
ctxFront.beginPath();
ctxFront.rect(x0, yTop, x1 - x0, yBot - yTop);
ctxFront.clip();
ctxFront.strokeStyle = '#ff9f43';
ctxFront.lineWidth = 1;
for (let d = -(yBot - yTop); d < x1 - x0; d += 12) {
ctxFront.beginPath();
ctxFront.moveTo(x0 + d, yBot);
ctxFront.lineTo(x0 + d + (yBot - yTop), yTop);
ctxFront.stroke();
}
ctxFront.restore();
ctxFront.fillStyle = '#ff9f43';
ctxFront.font = 'bold 10px sans-serif';
ctxFront.textAlign = 'center';
ctxFront.fillText('NAAD — VERVALT', (x0 + x1) / 2, (yTop + yBot) / 2);
});
}
function frontHandleAt(mx, my) {
return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8);
}
function frontOpeningAt(mx, my) {
return frontOpeningRects.find(r => {
if (mx < r.x0 || mx > r.x1) return false;
const yTop = r.y1 + (mx - r.x0) / ((r.x1 - r.x0) || 1) * (r.y1r - r.y1);
return my <= r.y0 && my >= yTop;
});
}
canvas2DFront.classList.add('interactive');
canvas2DFront.addEventListener('mousedown', (e) => {
const rect = canvas2DFront.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
if (selectedOpeningId !== null) {
const hit = frontHandleAt(mx, my);
if (hit) {
const resolved = frontOpeningRects.find(r => r.id === hit.id);
dragInfo = {
type: hit.handle === 'c' ? 'move' : 'resize', handle: hit.handle, openingId: hit.id,
startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h, startHR: resolved.hR
};
e.preventDefault();
return;
}
}
const bodyHit = frontOpeningAt(mx, my);
if (bodyHit) {
selectedOpeningId = bodyHit.id;
dragInfo = {
type: 'move', openingId: bodyHit.id,
startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h, startHR: bodyHit.hR
};
highlightSelectedCard();
draw2D();
e.preventDefault();
return;
}
if (selectedOpeningId !== null) {
selectedOpeningId = null;
highlightSelectedCard();
draw2D();
}
});
function onFrontMouseMove(e) {
if (!dragInfo) return;
const dxMM = (e.clientX - dragInfo.startMouseX) / frontView.scale;
const dyMM = -(e.clientY - dragInfo.startMouseY) / frontView.scale;
const op = openings.find(o => o.id === dragInfo.openingId);
if (!op) { dragInfo = null; return; }
const wallWidth = CONFIG.wallWidth;
if (dragInfo.type === 'move') {
const newLocalX = snap50(dragInfo.startLocalX + dxMM);
applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth);
op.y = Math.max(0, snap50(dragInfo.startY + dyMM));
} else if (dragInfo.type === 'resize') {
const hd = dragInfo.handle;
const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op);
if (hd === 'n') {
const dh = snap50(dyMM);
op.h = Math.max(400, dragInfo.startH + dh);
if (sloped) op.hR = Math.max(400, dragInfo.startHR + dh);
} else if (hd === 'nw' || hd === 'ne') {
op.h = hd === 'nw' ? Math.max(400, snap50(dragInfo.startH + dyMM)) : dragInfo.startH;
op.hR = hd === 'ne' ? Math.max(400, snap50(dragInfo.startHR + dyMM)) : dragInfo.startHR;
if (op.h === op.hR) delete op.hR;
}
if (hd === 's' || hd === 'sw' || hd === 'se') {
const topL = dragInfo.startY + dragInfo.startH, topR = dragInfo.startY + dragInfo.startHR;
const newY = Math.min(snap50(dragInfo.startY + dyMM), Math.min(topL, topR) - 400);
op.y = Math.max(0, newY);
op.h = topL - op.y;
if (isSloped(op)) op.hR = topR - op.y;
}
if (hd === 'e' || hd === 'ne' || hd === 'se') {
const newW = Math.max(400, snap50(dragInfo.startW + dxMM));
applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth);
} else if (hd === 'w' || hd === 'nw' || hd === 'sw') {
const right = dragInfo.startLocalX + dragInfo.startW;
const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400);
applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth);
}
}
syncOpeningCardInputs(op);
updateAll();
}
function onFrontMouseUp() { dragInfo = null; }
window.addEventListener('mousemove', onFrontMouseMove);
window.addEventListener('mouseup', onFrontMouseUp);
const canvas2DSide = document.getElementById('canvas-2d-side');
const ctxSide = canvas2DSide.getContext('2d');
function draw2DSide() {
const rect = canvas2DSide.parentElement.getBoundingClientRect();
canvas2DSide.width = rect.width;
canvas2DSide.height = rect.height;
const w = canvas2DSide.width, h = canvas2DSide.height;
ctxSide.clearRect(0, 0, w, h);
const buitenThick = CONFIG.sheathingThick;
const spaanThick = CONFIG.sheathingThick;
const totalDepth = buitenThick + CONFIG.studDepth + spaanThick;
const maxH = WallModel.topLine(CONFIG).maxY;
const scale = Math.min((w * 0.45) / totalDepth, (h * 0.8) / maxH);
const originX = w / 2 - (totalDepth * scale) / 2;
const floorY = h - 24;
const toX = (mm) => originX + mm * scale;
const toY = (mm) => floorY - mm * scale;
const layers = [
{ thick: buitenThick, color: { cementplaat: '#808285', spaanplaat: '#1e4620' }[buitenMateriaal] || '#444444' },
{ thick: CONFIG.studDepth, color: '#c29b6255' },
{ thick: spaanThick, color: '#1e4620' }
];
let cursor = 0;
layers.forEach(layer => {
ctxSide.fillStyle = layer.color;
ctxSide.strokeStyle = '#000';
ctxSide.lineWidth = 0.5;
ctxSide.fillRect(toX(cursor), toY(maxH), layer.thick * scale, toY(0) - toY(maxH));
ctxSide.strokeRect(toX(cursor), toY(maxH), layer.thick * scale, toY(0) - toY(maxH));
ctxSide.fillStyle = '#fff';
ctxSide.font = '9px sans-serif';
ctxSide.textAlign = 'center';
ctxSide.fillText(String(layer.thick) + 'mm', toX(cursor) + (layer.thick * scale) / 2, floorY + 14);
cursor += layer.thick;
});
ctxSide.strokeStyle = '#4da6ff';
ctxSide.beginPath();
ctxSide.moveTo(toX(totalDepth) + 15, toY(0));
ctxSide.lineTo(toX(totalDepth) + 15, toY(maxH));
ctxSide.stroke();
ctxSide.fillStyle = '#4da6ff';
ctxSide.font = '9px sans-serif';
ctxSide.save();
ctxSide.translate(toX(totalDepth) + 24, (toY(0) + toY(maxH)) / 2);
ctxSide.rotate(-Math.PI / 2);
ctxSide.textAlign = 'center';
ctxSide.fillText('Y: ' + Math.round(maxH) + 'mm', 0, 0);
ctxSide.restore();
}
function draw2D() {
draw2DTop();
draw2DFront();
draw2DSide();
}
const pane3D = document.getElementById('pane-3d');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
const camera = new THREE.PerspectiveCamera(45, pane3D.clientWidth / pane3D.clientHeight, 0.1, 100);
camera.position.set(3.5, 2.0, 4.5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
pane3D.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
const cameraKey = callbacks && callbacks.cameraKey;
if (!CameraMemory.restore(cameraKey, camera, controls)) {
controls.target.set(0, 1.2, 0);
controls.update();
}
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambient);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(5, 8, 5);
dirLight.castShadow = true;
scene.add(dirLight);
const grid = new THREE.GridHelper(10, 20, 0x444444, 0x222222);
scene.add(grid);
const wallMaterials = WallModel.createMaterials();
let buitenMateriaal = 'vochtdoek';
let cladding = 'horizontaal';
let trasraam = null;
let stone = null;
let stuc = null;
let rabatColor = null;
let layers = ViewLayers.all();
function syncMaterialButtons() {
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding));
document.getElementById('cladding-section').style.display = buitenMateriaal === 'vochtdoek' ? '' : 'none';
if (lockCladding) document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => { b.disabled = true; b.title = lockCladding; });
if (lockExterior) document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => { b.disabled = true; b.title = lockExterior; });
}
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(btn => {
btn.addEventListener('click', () => { buitenMateriaal = btn.dataset.material; syncMaterialButtons(); updateAll(); });
});
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(btn => {
btn.addEventListener('click', () => { cladding = btn.dataset.cladding; syncMaterialButtons(); updateAll(); });
});
let spaanplaatVisible = true;
let buitenVisible = true;
let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene);
const applySky = () => {
skyEnv.fitTo(wallGroup3D, 0);
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
};
const toolbar = document.getElementById('wall-toolbar');
const bar = UI.editorBar(toolbar, document.getElementById('app-container'), {
left: `<div class="tb-group tb-tools">${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}</div>`,
onResize: onWindowResize,
layers: {
layers, keys: ['regelwerk', 'beplating', 'doek', 'latwerk', 'gevel', 'kozijnen', 'afwerking'],
onChange: (l) => { layers = l; update3DScene(); },
extras: [{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }],
onExtra: (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; update3DScene(); }
},
onSky: (m) => { skyMode = m; applySky(); }
});
toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal());
let lastWallStats = null;
function update3DScene() {
if (wallGroup3D) scene.remove(wallGroup3D);
const result = WallModel.build3D(CONFIG, calcData, {
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, spaanplaatVisible, buitenVisible, layers
});
wallGroup3D = result.group;
lastWallStats = result.stats;
scene.add(wallGroup3D);
applySky();
updateSpecificaties();
}
function fmt(n, decimals = 1) {
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
function setSpecAll(key, text) {
document.querySelectorAll(`[data-spec="${key}"]`).forEach(el => { el.textContent = text; });
}
function updateSpecificaties() {
const specs = WallModel.computeSpecs(CONFIG, lastWallStats, buitenMateriaal);
const buitenNaam = WallModel.EXTERIOR_MATERIALS[buitenMateriaal] || 'Vochtdoek';
const buitenVolumeText = specs.buitenVolumeM3 === null ? 'n.v.t. (rolmateriaal)' : fmt(specs.buitenVolumeM3, 3) + ' m3';
setSpecAll('wood-m3', fmt(lastWallStats.woodVolumeM3, 3) + ' m3');
setSpecAll('wood-kg', fmt(specs.woodKg) + ' kg');
setSpecAll('spaanplaat-m2', fmt(lastWallStats.spaanplaatAreaM2) + ' m2');
setSpecAll('spaanplaat-m3', fmt(lastWallStats.spaanplaatVolumeM3, 3) + ' m3');
setSpecAll('spaanplaat-kg', fmt(specs.spaanplaatKg) + ' kg');
setSpecAll('buiten-materiaalnaam', buitenNaam);
setSpecAll('buiten-m2', fmt(specs.buitenAreaM2) + ' m2');
setSpecAll('buiten-m3', buitenVolumeText);
setSpecAll('buiten-kg', fmt(specs.buitenKg) + ' kg');
setSpecAll('latten', lastWallStats.battenCount
? `${lastWallStats.battenCount} st. \u00e0 ${WallModel.BATTEN.length} mm (${fmt(lastWallStats.battenLengthM, 1)} m, ${fmt(specs.battenKg)} kg)`
: 'n.v.t.');
setSpecAll('total-kg', fmt(specs.totalKg) + ' kg');
setSpecAll('void-m3', fmt(specs.voidVolumeM3, 3) + ' m3');
setSpecAll('horiz-beams', lastWallStats.horizBeamCount + ' stuks');
const purchase = WallModel.planPurchase(WallModel.computeBOM(CONFIG, calcData));
const wasteMM = purchase.reduce((a, sec) => a + sec.wasteMM, 0);
const stockMM = purchase.reduce((a, sec) => a + sec.stockMM, 0);
setSpecAll('stock-list', WallModel.purchaseText(purchase));
setSpecAll('stock-waste', fmt(wasteMM / 1000, 2) + ' m (' + fmt(stockMM ? wasteMM / stockMM * 100 : 0) + '%)');
}
function updateAll() {
recalculate();
draw2D();
update3DScene();
}
const inputs = document.querySelectorAll('#tab-wall input');
inputs.forEach(input => {
// Hoogte met de hand gewijzigd: de knikken van een wolfsdak vervallen.
if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; });
input.addEventListener('input', updateAll);
});
let __rafId = null;
function animate() {
__rafId = requestAnimationFrame(animate);
renderer.render(scene, camera);
}
function onWindowResize() {
camera.aspect = pane3D.clientWidth / pane3D.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
draw2D();
}
window.addEventListener('resize', onWindowResize);
renderOpeningsList();
updateAll();
animate();
return {
unmount() {
window.removeEventListener('resize', onWindowResize);
window.removeEventListener('mousemove', onFrontMouseMove);
window.removeEventListener('mouseup', onFrontMouseUp);
if (__rafId !== null) cancelAnimationFrame(__rafId);
CameraMemory.save(cameraKey, camera, controls);
renderer.dispose();
},
exportDesign,
triggerImport,
mirrorHorizontal,
setView: bar.setView,
viewState: bar.viewState,
openProjectProperties,
getStateJson: getPureState,
loadStateJson: applyPureState
};
}
window.WallEditor = {
mount: mountWallEditor,
getDefaultState: () => ({
wall: {
width: 6000, heightAll: 2400, heightLeft: 2400, heightRight: 2400,
studWidth: 38, studDepth: 235, plateThick: 38, plateDepth: 235,
sheathingThick: 10, minSpacing: 240, maxSpacing: 480
},
materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' },
openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }]
})
};