Files
Tectum/js/wall/wall.js
T

1403 lines
64 KiB
JavaScript
Raw Permalink 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.
*/
// ============================================================
// wall/wall.js — Wand-editor (ruwbouw compleet: regelwerk, openingen, OSB
// achterzijde, vochtdoek voorzijde, isolatie/specificaties). Ongewijzigde
// logica t.o.v. de vorige single-file versie, alleen verpakt als los te
// laden/lossen module via mount()/unmount(), en met de gedeelde geometrie-
// functies (calculateSegmentPositions, polygonArea, clip*, shapeAreaWithHoles)
// verplaatst naar core/geometry.js (moet VOOR dit bestand geladen worden).
//
// De rekenlogica (ankerresolutie, regelwerk, 3D-opbouw, specificaties) staat
// in wall/wall-model.js (WallModel) — gedeeld met de unit-editor. Dit bestand
// bevat alleen de UI: invoer, 2D-tekeningen, interactie, JSON/PDF-export.
//
// Plain script (geen ES-module) — werkt zo ook bij rechtstreeks openen van
// index.html via file://.
// ============================================================
const WALL_TEMPLATE = `
<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&times;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">&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="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;
// Binnen een unit volgt de wandbreedte uit de vloer (zie unit/unit.js);
// callbacks.lockWidth is dan de uitleg-tekst voor de tooltip.
if (callbacks && callbacks.lockWidth) {
const widthInput = document.getElementById('inp-wall-w');
widthInput.disabled = true;
widthInput.title = callbacks.lockWidth;
}
// Binnen een woning is de richting van de gevelbekleding (voorlopig) globaal.
const lockCladding = callbacks && callbacks.lockCladding;
// Binnen een woning is de h.o.h. globaal (woning-instellingen), niet per wand.
if (callbacks && callbacks.lockSpacing) {
['inp-min-sp', 'inp-max-sp'].forEach(id => {
const el = document.getElementById(id);
el.disabled = true;
el.title = callbacks.lockSpacing;
});
}
// Binnen een woning: delen van de wand die op een naad met een andere unit
// vallen (en dus vervallen), als [vanaf-links, tot] in mm.
const seamCuts = (callbacks && callbacks.seamCuts) || [];
// Binnen een woning: gevellampen op deze wand ({ u vanaf links, y }), zie LightingModel.
const lampMarks = (callbacks && callbacks.lamps) || [];
// ============================================================
// STATE
// ============================================================
// Dichtheden/gewichten: zie WallModel (wall/wall-model.js).
let CONFIG = {};
let calcData = { fullStuds: [], studs: [] };
let wallGroup3D = null;
let lastBaseHeight = 2400;
// Elke opening: { id, w, h, y, anchor: 'left'|'middle'|'right', offset }
// 'offset' betekent afhankelijk van anchor:
// left -> afstand vanaf linker wandrand tot linkerkant opening
// right -> afstand vanaf rechter wandrand tot rechterkant opening
// middle -> verschuiving t.o.v. het midden (0 = gecentreerd, + = naar rechts)
let openings = [
{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }
];
let openingIdCounter = 2;
// ------------------------------------------------------------
// Interactieve selectie/verslepen van openingen in het vooraanzicht
// (nu het primaire aanzicht). Snap op 50mm, net als de stapgrootte van
// de invoervelden. N/Z-handles wijzigen alleen de hoogte, O/W-handles
// alleen de breedte — precies zoals bij de meeste 2D-tekentools.
// ------------------------------------------------------------
let selectedOpeningId = null;
let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH }
let frontView = { scale: 1, centerX: 0, floorY: 0 }; // bijgewerkt door draw2DFront()
let frontOpeningRects = []; // hit-test data, bijgewerkt door draw2DFront()
let frontHandleRects = []; // idem, alleen voor de geselecteerde opening
function snap50(mm) { return Math.round(mm / 50) * 50; }
// Zet offset (en evt. breedte) zodat de opening's linkerrand op
// `leftLocalX` komt te liggen met breedte `w`, ongeacht welk ankertype
// actief is — dit is de kern die verslepen/resizen mogelijk maakt zonder
// dat het ankertype zelf hoeft te veranderen.
function applyOpeningPosition(op, leftLocalX, w, wallWidth) {
if (op.anchor === 'right') op.offset = wallWidth - leftLocalX - w;
else if (op.anchor === 'middle') op.offset = leftLocalX - (wallWidth - w) / 2;
else op.offset = leftLocalX; // 'left'
op.w = w;
}
function syncOpeningCardInputs(op) {
const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`);
if (!card) return;
['w', 'h', 'y', 'offset'].forEach(field => {
const input = card.querySelector(`input[data-field="${field}"]`);
if (input && document.activeElement !== input) input.value = Math.round(op[field]);
});
}
function highlightSelectedCard() {
document.querySelectorAll('.opening-card').forEach(card => {
const id = parseInt(card.dataset.openingId, 10);
card.classList.toggle('opening-selected', id === selectedOpeningId);
});
}
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(ct => ct.classList.remove('active'));
document.querySelector(`.tab-btn[data-tab="${tabId}"]`).classList.add('active');
document.getElementById(tabId).classList.add('active');
}
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
// ============================================================
// OPENINGEN: UI (lijst met kaarten, ankerkeuze, toevoegen/verwijderen)
// ============================================================
function anchorOffsetLabel(anchor) {
if (anchor === 'right') return 'Afstand 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
});
renderOpeningsList();
updateAll();
}
function toggleFullscreen3D() {
UI.cycle3D(document.getElementById('app-container')); // T: standaard -> 3D groot -> alleen render
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();
}
// Horizontaal spiegelen (Bewerken-menu): wisselt links/rechts om. De
// hoogte-links/-rechts worden verwisseld (de schuine kant "klapt om"),
// en elke opening wordt zo herankerd dat 'ie fysiek op dezelfde plek
// blijft t.o.v. zijn (nu omgewisselde) referentierand:
// - anchor 'left' <-> 'right', offset ONGEWIJZIGD (het is en blijft
// een afstand-tot-een-rand, alleen de rand zelf wisselt)
// - anchor 'middle': offset wordt genegeerd (spiegelbeeld t.o.v. midden)
// Verticaal spiegelen is bewust niet geïmplementeerd: een wand mag aan de
// onderzijde niet scheef worden, dus dat menu-item blijft permanent
// disabled (zie index.html/app.js).
function mirrorHorizontal() {
const hLeftInput = document.getElementById('inp-wall-h-left');
const hRightInput = document.getElementById('inp-wall-h-right');
const tmp = hLeftInput.value;
hLeftInput.value = hRightInput.value;
hRightInput.value = tmp;
openings = openings.map(o => {
if (o.anchor === 'left') return { ...o, anchor: 'right' };
if (o.anchor === 'right') return { ...o, anchor: 'left' };
if (o.anchor === 'middle') return { ...o, offset: -o.offset };
return o;
});
renderOpeningsList();
updateAll();
setIoStatus('Horizontaal gespiegeld.', 'ok');
}
function renderOpeningsList() {
const list = document.getElementById('openings-list');
list.innerHTML = openings.map(op => `
<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>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>
<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>
</div>
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
</div>
`).join('');
highlightSelectedCard();
}
// Klik op de titelbalk van een kaart selecteert 'm ook (symmetrisch met
// klikken op de opening in het vooraanzicht).
document.getElementById('openings-list').addEventListener('click', (e) => {
const header = e.target.closest('.opening-card-header');
if (header && !e.target.closest('.opening-remove-btn')) {
const card = header.closest('.opening-card');
selectedOpeningId = parseInt(card.dataset.openingId, 10);
highlightSelectedCard();
draw2D();
}
});
// Event delegation: 1 listener voor alle inputs/knoppen in de openingen-lijst,
// in plaats van opnieuw listeners koppelen na elke re-render.
document.getElementById('openings-list').addEventListener('input', (e) => {
const id = parseInt(e.target.dataset.id, 10);
const field = e.target.dataset.field;
if (!field) return;
const op = openings.find(o => o.id === id);
if (!op) return;
if (field === 'type') {
// Type kiezen = standaardmaat van dat type overnemen (daarna vrij aan te passen).
const preset = WallModel.OPENING_TYPES[e.target.value];
op.type = e.target.value;
op.w = preset.w; op.h = preset.h; op.y = preset.y;
renderOpeningsList();
} else if (field === 'anchor') {
op.anchor = e.target.value;
renderOpeningsList(); // label van 'offset' hangt af van anchor
} else {
op[field] = parseFloat(e.target.value) || 0;
}
updateAll();
});
document.getElementById('openings-list').addEventListener('click', (e) => {
if (e.target.dataset.action === 'remove') {
removeOpening(parseInt(e.target.dataset.id, 10));
}
});
document.getElementById('btn-add-opening').addEventListener('click', addOpening);
// ============================================================
// JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters +
// ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug.
// ============================================================
function setIoStatus(msg, cls) {
const el = document.getElementById('io-status');
el.textContent = msg;
el.className = 'io-status ' + (cls || '');
clearTimeout(setIoStatus._t);
setIoStatus._t = setTimeout(() => { el.textContent = ''; }, 4000);
}
function collectWallParams() {
return {
width: parseFloat(document.getElementById('inp-wall-w').value),
heightAll: parseFloat(document.getElementById('inp-wall-h-all').value),
heightLeft: parseFloat(document.getElementById('inp-wall-h-left').value),
heightRight: parseFloat(document.getElementById('inp-wall-h-right').value),
// Middenpunt (nok); null = rechte bovenrand.
heightMid: document.getElementById('inp-wall-peak').checked ? parseFloat(document.getElementById('inp-wall-h-mid').value) : null,
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
plateDepth: parseFloat(document.getElementById('inp-plate-d').value),
sheathingThick: parseFloat(document.getElementById('inp-sheathing-t').value),
minSpacing: parseFloat(document.getElementById('inp-min-sp').value),
maxSpacing: parseFloat(document.getElementById('inp-max-sp').value)
};
}
function getPureState() {
return {
wall: collectWallParams(),
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam },
openings: openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset }))
};
}
function applyPureState(state) {
document.getElementById('inp-wall-w').value = state.wall.width;
document.getElementById('inp-wall-h-all').value = state.wall.heightAll;
document.getElementById('inp-wall-h-left').value = state.wall.heightLeft;
document.getElementById('inp-wall-h-right').value = state.wall.heightRight;
document.getElementById('inp-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;
buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek';
cladding = (state.materials && state.materials.cladding) || 'horizontaal';
trasraam = (state.materials && state.materials.trasraam) || null; // woning-instelling
syncMaterialButtons();
openings = state.openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset }));
openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1;
selectedOpeningId = null;
renderOpeningsList();
updateAll();
}
// ------------------------------------------------------------
// PDF-export: het tekenblad zelf komt uit WallPdf (wall/wall-pdf.js),
// gedeeld met de unit-PDF.
// ------------------------------------------------------------
function generateWallPdf() {
recalculate();
const doc = PdfSheet.createDoc();
const project = PdfSheet.readProjectFromDom();
WallPdf.drawSheet(doc, CONFIG, calcData, { buitenMateriaal, cladding, trasraam, project, titel: project.titel, element: project.element });
return doc;
}
function buildDesignObject() {
return { type: 'wall-design', version: 1, exportedAt: new Date().toISOString(), ...getPureState() };
}
function exportDesign() {
const suggested = 'wand-ontwerp-' + new Date().toISOString().slice(0, 10);
let filename = window.prompt('Bestandsnaam voor het opgeslagen ontwerp:', suggested);
if (filename === null) { setIoStatus('Opslaan geannuleerd.', ''); return null; }
filename = filename.trim();
if (!filename) filename = suggested;
if (!filename.toLowerCase().endsWith('.json')) filename += '.json';
const design = buildDesignObject();
const blob = new Blob([JSON.stringify(design, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
const pdfBase = filename.replace(/\.json$/i, '');
try {
const pdfDoc = generateWallPdf();
pdfDoc.save(pdfBase + '.pdf');
setIoStatus(`Opgeslagen als "${filename}" + PDF.`, 'ok');
} catch (err) {
setIoStatus(`Opgeslagen als "${filename}" (PDF mislukt: ${err.message}).`, 'err');
}
if (typeof onSaved === 'function') onSaved(filename);
return filename;
}
function validateDesignJson(design) {
if (!design || design.type !== 'wall-design') throw new Error('geen geldig wand-ontwerp (type ontbreekt of onjuist)');
WallModel.validateState(design);
}
function importDesign(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const design = JSON.parse(e.target.result);
validateDesignJson(design);
applyPureState(design);
setIoStatus(`Geïmporteerd (${openings.length} opening(en)).`, 'ok');
if (typeof onSaved === 'function') onSaved(file.name);
} catch (err) {
setIoStatus('Import mislukt: ' + err.message, 'err');
}
};
reader.onerror = () => setIoStatus('Kon bestand niet lezen.', 'err');
reader.readAsText(file);
}
function triggerImport() {
document.getElementById('file-import-json').click();
}
document.getElementById('file-import-json').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) importDesign(file);
e.target.value = '';
});
// ============================================================
// OPENINGEN: ankerresolutie + overlap-handling — zie
// WallModel.resolveOpenings (wall/wall-model.js).
// ============================================================
function updateOpeningWarnings(resolvedOpenings) {
const anyOverflow = resolvedOpenings.some(o => o.overflowing);
const globalWarn = document.getElementById('openings-global-warning');
if (anyOverflow) {
globalWarn.style.display = 'block';
globalWarn.textContent = 'Let op: de opgegeven openingen passen niet allemaal binnen deze wandbreedte op hun gekozen ankerpunt. Rood gemarkeerde openingen zijn tegen de rand of tegen elkaar aan geduwd.';
} else {
globalWarn.style.display = 'none';
}
resolvedOpenings.forEach(o => {
const card = document.querySelector(`.opening-card[data-opening-id="${o.id}"]`);
const warnEl = document.querySelector(`[data-warning-for="${o.id}"]`);
if (!card || !warnEl) return;
card.classList.toggle('opening-warn', !!o.overflowing);
if (o.overflowing) {
warnEl.style.display = 'block';
warnEl.textContent = 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.';
} else {
warnEl.style.display = 'none';
}
});
}
function readInputs() {
const wallWidth = Math.round(parseFloat(document.getElementById('inp-wall-w').value) || 3000);
const hAllInput = document.getElementById('inp-wall-h-all');
const hLeftInput = document.getElementById('inp-wall-h-left');
const hRightInput = document.getElementById('inp-wall-h-right');
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';
// Afronding/fallbacks + ankerresolutie van de openingen: WallModel.
CONFIG = WallModel.buildConfig({
width: wallWidth,
heightLeft: hLeft,
heightRight: hRight,
heightMid: peakOn ? hMid : null,
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 + '°');
}
// --- 2D CANVAS RENDERER: BOVENAANZICHT ---
const canvas2DTop = document.getElementById('canvas-2d-top');
const ctx = canvas2DTop.getContext('2d');
function draw2DTop() {
const rect = canvas2DTop.parentElement.getBoundingClientRect();
canvas2DTop.width = rect.width;
canvas2DTop.height = rect.height;
const w = canvas2DTop.width;
const h = canvas2DTop.height;
ctx.clearRect(0, 0, w, h);
const maxDepth = Math.max(CONFIG.studDepth, CONFIG.plateDepth);
const scale = Math.min(w, h) / (Math.max(CONFIG.wallWidth, 1500) * 1.4);
const centerX = w / 2;
const centerY = h / 2;
ctx.strokeStyle = '#222';
ctx.lineWidth = 1;
const gridSize = 100 * scale;
for (let x = 0; x < w; x += gridSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
for (let y = 0; y < h; y += gridSize) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
const plateWidthPx = CONFIG.wallWidth * scale;
const sheathingPx = CONFIG.sheathingThick * scale;
const maxDepthPx = maxDepth * scale;
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
ctx.strokeStyle = '#666';
ctx.fillRect(centerX - plateWidthPx / 2, centerY - (maxDepthPx / 2) - sheathingPx, plateWidthPx, sheathingPx);
ctx.strokeRect(centerX - plateWidthPx / 2, centerY - (maxDepthPx / 2) - sheathingPx, plateWidthPx, sheathingPx);
ctx.fillRect(centerX - plateWidthPx / 2, centerY + (maxDepthPx / 2), plateWidthPx, sheathingPx);
ctx.strokeRect(centerX - plateWidthPx / 2, centerY + (maxDepthPx / 2), plateWidthPx, sheathingPx);
const studWidthPx = CONFIG.studWidth * scale;
const studDepthPx = CONFIG.studDepth * scale;
const drawStud2D = (xPos, isJack = false) => {
const screenX = centerX + (xPos * scale) - (studWidthPx / 2);
const screenY = centerY - (studDepthPx / 2);
ctx.fillStyle = isJack ? '#a37c45' : '#c29b62';
ctx.fillRect(screenX, screenY, studWidthPx, studDepthPx);
ctx.strokeStyle = '#8a6836';
ctx.strokeRect(screenX, screenY, studWidthPx, studDepthPx);
};
// Bovenaanzicht: één doorsnede per stijlpositie (kreupel alleen als er
// op die plek geen volle stijl staat).
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);
});
}
// --- 2D CANVAS RENDERER: VOORAANZICHT (ZUID) ---
const canvas2DFront = document.getElementById('canvas-2d-front');
const ctxFront = canvas2DFront.getContext('2d');
function draw2DFront() {
const rect = canvas2DFront.parentElement.getBoundingClientRect();
canvas2DFront.width = rect.width;
canvas2DFront.height = rect.height;
const w = canvas2DFront.width;
const h = canvas2DFront.height;
ctxFront.clearRect(0, 0, w, h);
const wallWidthMM = CONFIG.wallWidth;
const hLeftMM = CONFIG.wallHeightLeft;
const hRightMM = CONFIG.wallHeightRight;
const halfWMM = wallWidthMM / 2;
const 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(); }
// Referentielijnen vochtdoek-banen (1500mm h.o.h. vanaf de vloer).
// Zelfde uitsparingen als de fill (WallModel.skinSegmentsAtY): de
// lijnen stoppen bij openingen en bij de (schuine) bovenrand.
ctxFront.strokeStyle = '#555';
ctxFront.setLineDash([4, 4]);
for (let bandY = 1500; buitenMateriaal === 'vochtdoek' && bandY < maxRoofMM; bandY += 1500) {
WallModel.skinSegmentsAtY(CONFIG, bandY).forEach(([x0, x1]) => {
ctxFront.beginPath();
ctxFront.moveTo(toCanvasX(x0), toCanvasY(bandY));
ctxFront.lineTo(toCanvasX(x1), toCanvasY(bandY));
ctxFront.stroke();
});
}
ctxFront.setLineDash([]);
// Bovenkant trasraam (cement-gebonden plaat, woning-instelling).
if (buitenMateriaal === 'vochtdoek' && trasraam && trasraam.top > 0) {
ctxFront.strokeStyle = '#a0a3a8';
ctxFront.lineWidth = 1.5;
WallModel.skinSegmentsAtY(CONFIG, trasraam.top).forEach(([x0, x1]) => {
ctxFront.beginPath();
ctxFront.moveTo(toCanvasX(x0), toCanvasY(trasraam.top));
ctxFront.lineTo(toCanvasX(x1), toCanvasY(trasraam.top));
ctxFront.stroke();
});
ctxFront.fillStyle = '#a0a3a8';
ctxFront.font = '10px sans-serif';
ctxFront.fillText('trasraam', toCanvasX(-halfWMM) + 4, toCanvasY(trasraam.top) - 3);
}
// Wand-omtrek (incl. schuine bovenrand)
ctxFront.strokeStyle = '#666';
ctxFront.lineWidth = 2;
ctxFront.beginPath();
ctxFront.moveTo(toCanvasX(-halfWMM), toCanvasY(0));
ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(0));
tl.pts.slice().reverse().forEach(p => ctxFront.lineTo(toCanvasX(p.x), toCanvasY(p.y)));
ctxFront.closePath();
ctxFront.stroke();
// Onder- en bovenregel als volwaardig gevuld vlak (was alleen de
// omtreklijn hierboven — dat liet lege ruimtes zien i.p.v. de
// daadwerkelijke leggers).
ctxFront.fillStyle = '#c29b62';
ctxFront.strokeStyle = '#8a6836';
ctxFront.lineWidth = 1;
ctxFront.beginPath();
ctxFront.rect(toCanvasX(-halfWMM), toCanvasY(CONFIG.plateThick), toCanvasX(halfWMM) - toCanvasX(-halfWMM), toCanvasY(0) - toCanvasY(CONFIG.plateThick));
ctxFront.fill();
ctxFront.stroke();
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();
});
// X/Y-assen (lijn + label), altijd binnen het canvas gehouden zodat er
// niks buiten het zichtbare venster valt. Stijl: core/canvas-labels.js.
const xAxisY = Math.min(h - 6, toCanvasY(0) + 16);
drawCanvasDim(ctxFront, toCanvasX(-halfWMM), xAxisY, toCanvasX(halfWMM), xAxisY,
'X: ' + Math.round(wallWidthMM) + 'mm', (toCanvasX(-halfWMM) + toCanvasX(halfWMM)) / 2, Math.min(h - 2, xAxisY + 11));
const yAxisLeftX = Math.max(10, toCanvasX(-halfWMM) - 14);
drawCanvasDim(ctxFront, yAxisLeftX, toCanvasY(0), yAxisLeftX, toCanvasY(hLeftMM),
'Y: ' + Math.round(hLeftMM) + 'mm', Math.max(10, yAxisLeftX - 4), (toCanvasY(0) + toCanvasY(hLeftMM)) / 2);
const yAxisRightX = Math.min(w - 10, toCanvasX(halfWMM) + 14);
drawCanvasDim(ctxFront, yAxisRightX, toCanvasY(0), yAxisRightX, toCanvasY(hRightMM),
'Y: ' + Math.round(hRightMM) + 'mm', Math.min(w - 4, yAxisRightX + 12), (toCanvasY(0) + toCanvasY(hRightMM)) / 2);
if (tl.peak) {
const pxC = toCanvasX(tl.peak.x);
ctxFront.fillStyle = '#9ab';
ctxFront.font = '11px sans-serif';
ctxFront.textAlign = 'center';
ctxFront.fillText('nok ' + Math.round(tl.peak.y) + 'mm', pxC, Math.max(12, toCanvasY(tl.peak.y) - 6));
ctxFront.textAlign = 'start';
}
// Stijlstuk van y0 tot y1 (of tot de daklijn), zie WallModel.computeFrame.
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 isSelected = op.id === selectedOpeningId;
frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, localX: op.x, y: op.y, w: op.w, h: op.h });
ctxFront.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)';
ctxFront.fillRect(x0, y1, x1 - x0, y0 - y1);
ctxFront.strokeStyle = isSelected ? '#4da6ff' : '#ff3333';
ctxFront.lineWidth = isSelected ? 2.5 : 1;
ctxFront.setLineDash(isSelected ? [] : [3, 3]);
ctxFront.strokeRect(x0, y1, x1 - x0, y0 - y1);
ctxFront.setLineDash([]);
ctxFront.lineWidth = 1;
ctxFront.fillStyle = '#fff';
ctxFront.font = '9px sans-serif';
ctxFront.textAlign = 'center';
ctxFront.fillText('#' + op.id, (x0 + x1) / 2, y1 - 4);
if (isSelected) {
const midX = (x0 + x1) / 2, midY = (y0 + y1) / 2;
const handles = [
{ handle: 'n', cx: midX, cy: y1 },
{ handle: 's', cx: midX, cy: y0 },
{ handle: 'e', cx: x1, cy: midY },
{ handle: 'w', cx: x0, cy: midY }
];
handles.forEach(hd => {
frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy });
ctxFront.fillStyle = '#4da6ff';
ctxFront.strokeStyle = '#0a1a2a';
ctxFront.beginPath();
ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10);
ctxFront.fill();
ctxFront.stroke();
});
}
});
drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM);
// Gevellampen (buitenverlichting woning)
lampMarks.forEach(l => {
const x = toCanvasX(-halfWMM + l.u), y = toCanvasY(l.y);
ctxFront.fillStyle = '#ffd36b'; ctxFront.strokeStyle = '#1a1a1a'; ctxFront.lineWidth = 1;
ctxFront.fillRect(x - 4, y - 7, 8, 14);
ctxFront.strokeRect(x - 4, y - 7, 8, 14);
});
}
// Naad-zones (wand vervalt daar, zie woning) als gearceerde overlay.
// Met upTo (mm): opbouw boven het lagere dak, alleen gearceerd tot daar.
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 =>
mx >= Math.min(r.x0, r.x1) && mx <= Math.max(r.x0, r.x1) &&
my >= Math.min(r.y0, r.y1) && my <= Math.max(r.y0, r.y1));
}
canvas2DFront.classList.add('interactive');
canvas2DFront.addEventListener('mousedown', (e) => {
const rect = canvas2DFront.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
if (selectedOpeningId !== null) {
const hit = frontHandleAt(mx, my);
if (hit) {
const resolved = frontOpeningRects.find(r => r.id === hit.id);
dragInfo = {
type: 'resize', handle: hit.handle, openingId: hit.id,
startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h
};
e.preventDefault();
return;
}
}
const bodyHit = frontOpeningAt(mx, my);
if (bodyHit) {
selectedOpeningId = bodyHit.id;
dragInfo = {
type: 'move', openingId: bodyHit.id,
startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h
};
highlightSelectedCard();
draw2D();
e.preventDefault();
return;
}
if (selectedOpeningId !== null) {
selectedOpeningId = null;
highlightSelectedCard();
draw2D();
}
});
function onFrontMouseMove(e) {
if (!dragInfo) return;
const dxMM = (e.clientX - dragInfo.startMouseX) / frontView.scale;
const dyMM = -(e.clientY - dragInfo.startMouseY) / frontView.scale;
const op = openings.find(o => o.id === dragInfo.openingId);
if (!op) { dragInfo = null; return; }
const wallWidth = CONFIG.wallWidth;
if (dragInfo.type === 'move') {
const newLocalX = snap50(dragInfo.startLocalX + dxMM);
applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth);
op.y = Math.max(0, snap50(dragInfo.startY + dyMM));
} else if (dragInfo.type === 'resize') {
if (dragInfo.handle === 'n') {
op.h = Math.max(400, snap50(dragInfo.startH + dyMM));
} else if (dragInfo.handle === 's') {
const top = dragInfo.startY + dragInfo.startH;
const newY = Math.min(snap50(dragInfo.startY + dyMM), top - 400);
op.y = Math.max(0, newY);
op.h = top - op.y;
} else if (dragInfo.handle === 'e') {
const newW = Math.max(400, snap50(dragInfo.startW + dxMM));
applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth);
} else if (dragInfo.handle === 'w') {
const right = dragInfo.startLocalX + dragInfo.startW;
const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400);
applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth);
}
}
syncOpeningCardInputs(op);
updateAll();
}
function onFrontMouseUp() { dragInfo = null; }
window.addEventListener('mousemove', onFrontMouseMove);
window.addEventListener('mouseup', onFrontMouseUp);
const canvas2DSide = document.getElementById('canvas-2d-side');
const ctxSide = canvas2DSide.getContext('2d');
function draw2DSide() {
const rect = canvas2DSide.parentElement.getBoundingClientRect();
canvas2DSide.width = rect.width;
canvas2DSide.height = rect.height;
const w = canvas2DSide.width, h = canvas2DSide.height;
ctxSide.clearRect(0, 0, w, h);
const buitenThick = CONFIG.sheathingThick;
const spaanThick = CONFIG.sheathingThick;
const totalDepth = buitenThick + CONFIG.studDepth + spaanThick;
const maxH = 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();
}
// --- THREE.JS 3D RENDERER ---
const pane3D = document.getElementById('pane-3d');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
const camera = new THREE.PerspectiveCamera(45, pane3D.clientWidth / pane3D.clientHeight, 0.1, 100);
camera.position.set(3.5, 2.0, 4.5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
pane3D.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
const cameraKey = callbacks && callbacks.cameraKey;
if (!CameraMemory.restore(cameraKey, camera, controls)) {
controls.target.set(0, 1.2, 0);
controls.update();
}
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();
// Buitenzijde-materiaalkeuze (tab 3): 'vochtdoek' of 'cementplaat',
// wederzijds exclusief. Binnenzijde is altijd spaanplaat (geen alternatief).
// (Alleen knoppen met data-material/-cladding: de "Opslaan"-knop van
// Project eigenschappen heeft ook de klasse material-toggle-btn.)
let buitenMateriaal = 'vochtdoek';
let cladding = 'horizontaal'; // richting gevelbekleding (alleen bij vochtdoek)
let trasraam = null; // { bottom, top } mm, komt uit de woning-instellingen (WallModel.trasraamFor)
function syncMaterialButtons() {
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding));
document.getElementById('cladding-section').style.display = buitenMateriaal === 'vochtdoek' ? '' : 'none';
if (lockCladding) document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => { b.disabled = true; b.title = lockCladding; });
}
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(btn => {
btn.addEventListener('click', () => { buitenMateriaal = btn.dataset.material; syncMaterialButtons(); updateAll(); });
});
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(btn => {
btn.addEventListener('click', () => { cladding = btn.dataset.cladding; syncMaterialButtons(); updateAll(); });
});
let spaanplaatVisible = true;
let buitenVisible = true;
// Weergave-filter (core/view-filter.js): beplating binnen/buiten + isolatie.
createViewFilter(document.getElementById('wall-toolbar-3d'), [
{ key: 'binnen', label: 'Beplating binnenzijde' },
{ key: 'buiten', label: 'Beplating buitenzijde' }
], (v) => {
spaanplaatVisible = v.binnen;
buitenVisible = v.buiten;
update3DScene();
});
// Omgeving (core/skybox.js: geen/dag/nacht); de wand staat op de tegelvloer.
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(); });
// 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld
// met de unit-editor).
let lastWallStats = null;
function update3DScene() {
if (wallGroup3D) scene.remove(wallGroup3D);
const result = WallModel.build3D(CONFIG, calcData, {
materials: wallMaterials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible
});
wallGroup3D = result.group;
lastWallStats = result.stats;
scene.add(wallGroup3D);
applySky(); // schaduw van de nieuwe wand
updateSpecificaties();
}
// ============================================================
// SPECIFICATIES (tab 3)
// ============================================================
function fmt(n, decimals = 1) {
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
function setSpecAll(key, text) {
document.querySelectorAll(`[data-spec="${key}"]`).forEach(el => { el.textContent = text; });
}
function updateSpecificaties() {
const specs = WallModel.computeSpecs(CONFIG, lastWallStats, buitenMateriaal);
const buitenNaam = WallModel.EXTERIOR_MATERIALS[buitenMateriaal] || 'Vochtdoek';
const buitenVolumeText = specs.buitenVolumeM3 === null ? 'n.v.t. (rolmateriaal)' : fmt(specs.buitenVolumeM3, 3) + ' m3';
setSpecAll('wood-m3', fmt(lastWallStats.woodVolumeM3, 3) + ' m3');
setSpecAll('wood-kg', fmt(specs.woodKg) + ' kg');
setSpecAll('spaanplaat-m2', fmt(lastWallStats.spaanplaatAreaM2) + ' m2');
setSpecAll('spaanplaat-m3', fmt(lastWallStats.spaanplaatVolumeM3, 3) + ' m3');
setSpecAll('spaanplaat-kg', fmt(specs.spaanplaatKg) + ' kg');
setSpecAll('buiten-materiaalnaam', buitenNaam);
setSpecAll('buiten-m2', fmt(specs.buitenAreaM2) + ' m2');
setSpecAll('buiten-m3', buitenVolumeText);
setSpecAll('buiten-kg', fmt(specs.buitenKg) + ' kg');
setSpecAll('latten', lastWallStats.battenCount
? `${lastWallStats.battenCount} st. \u00e0 ${WallModel.BATTEN.length} mm (${fmt(lastWallStats.battenLengthM, 1)} m, ${fmt(specs.battenKg)} kg)`
: 'n.v.t.');
setSpecAll('total-kg', fmt(specs.totalKg) + ' kg');
setSpecAll('void-m3', fmt(specs.voidVolumeM3, 3) + ' m3');
setSpecAll('horiz-beams', lastWallStats.horizBeamCount + ' stuks');
// Inkoop in handelslengtes (zaagplan uit WallModel).
const purchase = WallModel.planPurchase(WallModel.computeBOM(CONFIG, calcData));
const wasteMM = purchase.reduce((a, sec) => a + sec.wasteMM, 0);
const stockMM = purchase.reduce((a, sec) => a + sec.stockMM, 0);
setSpecAll('stock-list', WallModel.purchaseText(purchase));
setSpecAll('stock-waste', fmt(wasteMM / 1000, 2) + ' m (' + fmt(stockMM ? wasteMM / stockMM * 100 : 0) + '%)');
}
function updateAll() {
recalculate();
draw2D();
update3DScene();
}
const inputs = document.querySelectorAll('#tab-wall input');
inputs.forEach(input => {
input.addEventListener('input', updateAll);
});
let __rafId = null;
function animate() {
__rafId = requestAnimationFrame(animate);
renderer.render(scene, camera);
}
function onWindowResize() {
camera.aspect = pane3D.clientWidth / pane3D.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
draw2D();
}
window.addEventListener('resize', onWindowResize);
renderOpeningsList();
updateAll();
animate();
return {
unmount() {
window.removeEventListener('resize', onWindowResize);
window.removeEventListener('mousemove', onFrontMouseMove);
window.removeEventListener('mouseup', onFrontMouseUp);
if (__rafId !== null) cancelAnimationFrame(__rafId);
CameraMemory.save(cameraKey, camera, controls);
renderer.dispose();
},
exportDesign,
triggerImport,
mirrorHorizontal,
toggleFullscreen3D,
toggleFullscreen2D,
openProjectProperties,
getStateJson: getPureState,
loadStateJson: applyPureState
};
}
window.WallEditor = {
mount: mountWallEditor,
getDefaultState: () => ({
wall: {
width: 6000, heightAll: 2400, heightLeft: 2400, heightRight: 2400,
studWidth: 38, studDepth: 235, plateThick: 38, plateDepth: 235,
sheathingThick: 10, minSpacing: 240, maxSpacing: 480
},
materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' },
openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }]
})
};