1439 lines
66 KiB
JavaScript
1439 lines
66 KiB
JavaScript
/*!
|
||
* Tectum
|
||
* Copyright © 2026 Machiel Heinen.
|
||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||
* Zie LICENTIE.md.
|
||
*/
|
||
|
||
const WALL_TEMPLATE = `
|
||
<div id="app-container">
|
||
<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 id="control-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>
|
||
</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 class="divider"></div>
|
||
<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>
|
||
</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×30 mm, 400 mm h.o.h. in beide richtingen. De bovenste lat staat haaks op de gevelbekleding.</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div id="right-column">
|
||
<div id="pane-3d">
|
||
<div class="pane-label">3D</div>
|
||
<div class="toolbar-3d" id="wall-toolbar-3d"></div>
|
||
</div>
|
||
|
||
<div id="specs-panel-3d">
|
||
<div class="controls-grid">
|
||
<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 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">×</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="io-btn">Annuleren</button>
|
||
<button type="button" id="wall-project-save" class="material-toggle-btn active" style="flex:none; padding:6px 18px;">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();
|
||
}
|
||
|
||
function toggleFullscreen3D() {
|
||
UI.cycle3D(document.getElementById('app-container'));
|
||
requestAnimationFrame(onWindowResize);
|
||
}
|
||
|
||
function toggleFullscreen2D() {
|
||
UI.toggle2D(document.getElementById('app-container'));
|
||
requestAnimationFrame(onWindowResize);
|
||
}
|
||
|
||
const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer',
|
||
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
|
||
let projectPropsSnapshot = null;
|
||
|
||
function openProjectProperties() {
|
||
projectPropsSnapshot = {};
|
||
PROJECT_PROP_IDS.forEach(id => { projectPropsSnapshot[id] = document.getElementById(id).value; });
|
||
document.getElementById('wall-project-overlay').classList.add('open');
|
||
}
|
||
function closeProjectProperties() {
|
||
document.getElementById('wall-project-overlay').classList.remove('open');
|
||
}
|
||
document.getElementById('wall-project-save').addEventListener('click', closeProjectProperties);
|
||
document.getElementById('wall-project-cancel').addEventListener('click', () => {
|
||
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
|
||
closeProjectProperties();
|
||
});
|
||
document.getElementById('wall-project-close-x').addEventListener('click', () => {
|
||
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
|
||
closeProjectProperties();
|
||
});
|
||
document.getElementById('wall-project-overlay').addEventListener('click', (e) => {
|
||
if (e.target.id === 'wall-project-overlay') {
|
||
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
|
||
closeProjectProperties();
|
||
}
|
||
});
|
||
|
||
function removeOpening(id) {
|
||
openings = openings.filter(o => o.id !== id);
|
||
if (selectedOpeningId === id) selectedOpeningId = null;
|
||
renderOpeningsList();
|
||
updateAll();
|
||
}
|
||
|
||
// 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, 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">×</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>
|
||
${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 === '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);
|
||
if (o.overflowing) {
|
||
warnEl.style.display = 'block';
|
||
warnEl.textContent = 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.';
|
||
} else {
|
||
warnEl.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
function readInputs() {
|
||
const wallWidth = Math.round(parseFloat(document.getElementById('inp-wall-w').value) || 3000);
|
||
const hAllInput = document.getElementById('inp-wall-h-all');
|
||
const hLeftInput = document.getElementById('inp-wall-h-left');
|
||
const hRightInput = document.getElementById('inp-wall-h-right');
|
||
|
||
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();
|
||
|
||
// Alleen knoppen met data-material/-cladding: de "Opslaan"-knop van Project eigenschappen heeft ook de klasse material-toggle-btn.
|
||
let buitenMateriaal = 'vochtdoek';
|
||
let cladding = 'horizontaal';
|
||
let trasraam = null;
|
||
let stone = null;
|
||
let stuc = null;
|
||
let rabatColor = null;
|
||
let stage = BuildStages.FINAL;
|
||
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;
|
||
createViewFilter(document.getElementById('wall-toolbar-3d'), [
|
||
{ key: 'binnen', label: 'Beplating binnenzijde' },
|
||
{ key: 'buiten', label: 'Beplating buitenzijde' }
|
||
], (v) => {
|
||
spaanplaatVisible = v.binnen;
|
||
buitenVisible = v.buiten;
|
||
update3DScene();
|
||
});
|
||
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 });
|
||
};
|
||
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
|
||
BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); });
|
||
|
||
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, stage
|
||
});
|
||
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 mansarde-/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,
|
||
toggleFullscreen3D,
|
||
toggleFullscreen2D,
|
||
openProjectProperties,
|
||
getStateJson: getPureState,
|
||
loadStateJson: applyPureState
|
||
};
|
||
}
|
||
|
||
window.WallEditor = {
|
||
mount: mountWallEditor,
|
||
getDefaultState: () => ({
|
||
wall: {
|
||
width: 6000, heightAll: 2400, heightLeft: 2400, heightRight: 2400,
|
||
studWidth: 38, studDepth: 235, plateThick: 38, plateDepth: 235,
|
||
sheathingThick: 10, minSpacing: 240, maxSpacing: 480
|
||
},
|
||
materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' },
|
||
openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }]
|
||
})
|
||
};
|