v0.6.0: ruimtes (verfemmer: functie, netto m², vloerafwerking, FIBO-wandpanelen, eigen verfkleur, vals plafond, ruimtestaat op de PDF), inrichting (sanitair, keuken, kookeiland, kasten, bed, zithoek, boiler; slepen met snappen op het wandvlak, draaien, kosten), kozijnen met deurrichting, glasdeur en schuifdeuren (inbouw/opbouw, deuren open in de loopmodus) plus dagkanten, gevel stucwerk en wandstukken splitsen met eigen gevel, dak over de hele woning met overkapping, geschilderd plafond, model Amstel als nieuw ontwerp, kosten en prijzen uit kosten/intern.csv en kosten/leveranciers.csv (CostCsv); easter egg verwijderd; overbodige comments opgeschoond (alleen copyright, uitzonderingen en valkuilen blijven)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-11 00:34:27 +02:00
co-authored by Claude Opus 5.5
parent b5510d56c7
commit ceda0b554d
45 changed files with 4117 additions and 3700 deletions
+46 -156
View File
@@ -4,21 +4,6 @@
* 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">
@@ -244,24 +229,18 @@ 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;
// ... en de buitenzijde (gevelbekleding per woning, override per wand).
const lockExterior = callbacks && callbacks.lockExterior;
// Alles op het tabblad Materiaal ligt dan vast: tabblad weg, uitleg op Opbouw.
if (lockExterior && lockCladding) {
const tabBtn = container.querySelector('.tab-btn[data-tab="tab-materials"]');
if (tabBtn) tabBtn.style.display = 'none';
}
// 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);
@@ -269,75 +248,44 @@ function mountWallEditor(container, callbacks) {
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;
// Daklijn met knikken (mansarde/wolfsdak, uit de unit-dakvorm): blijft
// staan tot een hoogte hier met de hand wordt gewijzigd.
let wallTopPts = null;
// Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset, fill? }
// fill = vulling (kozijn, KozijnModel); zonder fill blijft hij open.
// hR = hoogte rechts (schuine bovenzijde); zonder hR is de bovenkant
// recht en is h de hoogte. De onderkant is altijd waterpas.
// '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)
// 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;
// ------------------------------------------------------------
// Interactieve selectie/verslepen van openingen in het vooraanzicht
// (nu het primaire aanzicht). Snap op 50mm, net als de stapgrootte van
// de invoervelden. 9 sleeppunten: 4 hoeken, 4 zijden en het midden
// (verplaatsen). N/Z wijzigen de hoogte, O/W de breedte. De bovenste
// hoeken (NW/NO) verslepen ook alleen hun eigen kant in de hoogte: zo
// ontstaat een schuine bovenzijde. Onderin blijft het altijd waterpas
// (ZW/ZO: breedte + onderkant).
// ------------------------------------------------------------
let selectedOpeningId = null;
let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH, startHR }
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
let dragInfo = null;
let frontView = { scale: 1, centerX: 0, floorY: 0 };
let frontOpeningRects = [];
let frontHandleRects = [];
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'
else op.offset = leftLocalX;
op.w = w;
}
const isSloped = (op) => op.hR !== undefined;
// Ruwe opening zoals opgeslagen (hR alleen bij een schuine bovenzijde).
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;
// Schuin aan/uit veranderd (hoek slepen): kaart opnieuw opbouwen.
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}"]`);
@@ -362,9 +310,6 @@ function mountWallEditor(container, callbacks) {
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';
@@ -389,7 +334,7 @@ function mountWallEditor(container, callbacks) {
}
function toggleFullscreen3D() {
UI.cycle3D(document.getElementById('app-container')); // T: standaard -> 3D groot -> alleen render
UI.cycle3D(document.getElementById('app-container'));
requestAnimationFrame(onWindowResize);
}
@@ -433,16 +378,7 @@ function mountWallEditor(container, callbacks) {
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).
// 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');
@@ -452,7 +388,6 @@ function mountWallEditor(container, callbacks) {
if (wallTopPts) wallTopPts = wallTopPts.map(q => ({ f: 1 - q.f, h: q.h })).reverse();
openings = openings.map(o => {
// Schuine bovenzijde klapt mee om.
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' };
@@ -465,7 +400,6 @@ function mountWallEditor(container, callbacks) {
setIoStatus('Horizontaal gespiegeld.', 'ok');
}
// Vulling (kozijn, KozijnModel): type, rooster/glasstrook en kleuren.
function fillControls(op) {
const f = KozijnModel.normalize(op.fill);
const type = f ? f.type : 'geen';
@@ -475,6 +409,10 @@ function mountWallEditor(container, callbacks) {
<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">
@@ -483,6 +421,15 @@ function mountWallEditor(container, callbacks) {
</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">
@@ -496,7 +443,6 @@ function mountWallEditor(container, callbacks) {
</div>
</div>`;
}
// Kleuren van de laatste opening met een vulling (voor nieuwe openingen).
const lastFill = () => { const o = openings.slice().reverse().find(x => x.fill); return o ? o.fill : null; };
function renderOpeningsList() {
@@ -563,8 +509,6 @@ function mountWallEditor(container, callbacks) {
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')) {
@@ -575,8 +519,6 @@ function mountWallEditor(container, callbacks) {
}
});
// 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;
@@ -584,7 +526,6 @@ function mountWallEditor(container, callbacks) {
const op = openings.find(o => o.id === id);
if (!op) return;
if (field === 'type') {
// Type kiezen = standaardmaat en -vulling 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;
@@ -594,18 +535,20 @@ function mountWallEditor(container, callbacks) {
} else if (field === 'fill') {
const v = e.target.value;
if (v === 'geen') delete op.fill;
else op.fill = Object.assign(KozijnModel.defaultFill(v === 'deur' ? (op.type === 'raam' ? 'deur' : op.type) : 'raam', op.fill || lastFill()), { type: v });
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|Out|In)$/.test(field)) {
} 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', fillOut: 'out', fillIn: 'in' }[field];
op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
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(); // label van 'offset' hangt af van anchor
renderOpeningsList();
} else {
op[field] = parseFloat(e.target.value) || 0;
}
@@ -620,9 +563,6 @@ function mountWallEditor(container, callbacks) {
});
document.getElementById('btn-add-opening').addEventListener('click', addOpening);
// Bovenkant evenwijdig aan de daklijn: hoogte rechts = hoogte links +
// het verschil in daklijn tussen de rechter- en linkerrand (op de
// opgeloste positie; met een nok ertussen het rechte stuk ertussen).
function followRoof(id) {
const op = openings.find(o => o.id === id);
const res = (CONFIG.openings || []).find(o => o.id === id);
@@ -633,10 +573,6 @@ function mountWallEditor(container, callbacks) {
updateAll();
}
// ============================================================
// 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;
@@ -651,7 +587,6 @@ function mountWallEditor(container, callbacks) {
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,
topPts: wallTopPts ? wallTopPts.map(q => Object.assign({}, q)) : undefined,
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
@@ -667,7 +602,7 @@ function mountWallEditor(container, callbacks) {
function getPureState() {
return {
wall: collectWallParams(),
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, rabatColor },
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor },
openings: openings.map(rawOpening)
};
}
@@ -692,9 +627,10 @@ function mountWallEditor(container, callbacks) {
buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek';
cladding = (state.materials && state.materials.cladding) || 'horizontaal';
trasraam = (state.materials && state.materials.trasraam) || null; // woning-instelling
stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen)
rabatColor = (state.materials && state.materials.rabatColor) || null; // woning-instelling (rabat)
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);
@@ -705,10 +641,6 @@ function mountWallEditor(container, callbacks) {
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();
@@ -784,11 +716,6 @@ function mountWallEditor(container, callbacks) {
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');
@@ -842,7 +769,6 @@ function mountWallEditor(container, callbacks) {
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,
@@ -868,7 +794,6 @@ function mountWallEditor(container, callbacks) {
setSpecAll('roof-angle', calcData.angleDeg + '°');
}
// --- 2D CANVAS RENDERER: BOVENAANZICHT ---
const canvas2DTop = document.getElementById('canvas-2d-top');
const ctx = canvas2DTop.getContext('2d');
@@ -916,8 +841,6 @@ function mountWallEditor(container, callbacks) {
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)));
@@ -941,7 +864,6 @@ function mountWallEditor(container, callbacks) {
});
}
// --- 2D CANVAS RENDERER: VOORAANZICHT (ZUID) ---
const canvas2DFront = document.getElementById('canvas-2d-front');
const ctxFront = canvas2DFront.getContext('2d');
@@ -975,9 +897,6 @@ function mountWallEditor(container, callbacks) {
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) {
@@ -990,7 +909,6 @@ function mountWallEditor(container, callbacks) {
}
ctxFront.setLineDash([]);
// Bovenkant trasraam (cement-gebonden plaat, woning-instelling).
if (buitenMateriaal === 'vochtdoek' && trasraam && trasraam.top > 0) {
ctxFront.strokeStyle = '#a0a3a8';
ctxFront.lineWidth = 1.5;
@@ -1005,7 +923,6 @@ function mountWallEditor(container, callbacks) {
ctxFront.fillText('trasraam', toCanvasX(-halfWMM) + 4, toCanvasY(trasraam.top) - 3);
}
// Wand-omtrek (incl. schuine bovenrand)
ctxFront.strokeStyle = '#666';
ctxFront.lineWidth = 2;
ctxFront.beginPath();
@@ -1015,9 +932,6 @@ function mountWallEditor(container, callbacks) {
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;
@@ -1034,8 +948,6 @@ function mountWallEditor(container, callbacks) {
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));
@@ -1047,7 +959,6 @@ function mountWallEditor(container, callbacks) {
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);
// Tussenpunten van de daklijn (nok = het enige hoogste punt, anders knik).
const inner = tl.pts.slice(1, -1);
inner.forEach(q => {
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
@@ -1058,7 +969,6 @@ function mountWallEditor(container, callbacks) {
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;
@@ -1084,8 +994,8 @@ function mountWallEditor(container, callbacks) {
const x0 = toCanvasX(opLeftMM);
const x1 = toCanvasX(opLeftMM + op.w);
const y0 = toCanvasY(op.y);
const y1 = toCanvasY(op.y + op.h); // bovenkant links
const y1r = toCanvasY(op.y + op.hR); // bovenkant rechts (schuine bovenzijde)
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 });
@@ -1128,7 +1038,6 @@ function mountWallEditor(container, callbacks) {
ctxFront.fillStyle = hd.handle === 'c' ? '#ffffff' : '#4da6ff';
ctxFront.strokeStyle = '#0a1a2a';
ctxFront.beginPath();
// Midden (verplaatsen) rond, de rest vierkant.
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();
@@ -1139,7 +1048,6 @@ function mountWallEditor(container, callbacks) {
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;
@@ -1148,8 +1056,6 @@ function mountWallEditor(container, callbacks) {
});
}
// 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);
@@ -1180,7 +1086,6 @@ function mountWallEditor(container, callbacks) {
return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8);
}
function frontOpeningAt(mx, my) {
// Binnen de (evt. schuine) omtrek: canvas-y loopt omlaag.
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);
@@ -1242,22 +1147,18 @@ function mountWallEditor(container, callbacks) {
applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth);
op.y = Math.max(0, snap50(dragInfo.startY + dyMM));
} else if (dragInfo.type === 'resize') {
// Samengestelde handles: 'nw' = bovenkant links + linkerrand, enz.
const hd = dragInfo.handle;
const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op);
if (hd === 'n') {
// Hele bovenkant omhoog/omlaag (helling blijft).
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') {
// Eén bovenhoek: alleen die kant in de hoogte -> schuine bovenzijde.
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; // weer recht
if (op.h === op.hR) delete op.hR;
}
if (hd === 's' || hd === 'sw' || hd === 'se') {
// Onderkant; de bovenkant blijft staan (onderin altijd waterpas).
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);
@@ -1343,7 +1244,6 @@ function mountWallEditor(container, callbacks) {
draw2DSide();
}
// --- THREE.JS 3D RENDERER ---
const pane3D = document.getElementById('pane-3d');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
@@ -1376,16 +1276,14 @@ function mountWallEditor(container, callbacks) {
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.)
// 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)
let stone = null; // steencode bij cementplaat, komt uit de woning-instellingen
let rabatColor = null; // kleur rabat (WallModel.RABAT_COLORS), komt uit de woning-instellingen
let stage = BuildStages.FINAL; // bouwfase van de 3D (schuif linksonder)
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));
@@ -1402,7 +1300,6 @@ function mountWallEditor(container, callbacks) {
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' }
@@ -1411,7 +1308,6 @@ function mountWallEditor(container, callbacks) {
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 = () => {
@@ -1421,26 +1317,21 @@ function mountWallEditor(container, callbacks) {
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); });
// 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, stone, rabatColor, spaanplaatVisible, buitenVisible, stage
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, spaanplaatVisible, buitenVisible, stage
});
wallGroup3D = result.group;
lastWallStats = result.stats;
scene.add(wallGroup3D);
applySky(); // schaduw van de nieuwe wand
applySky();
updateSpecificaties();
}
// ============================================================
// SPECIFICATIES (tab 3)
// ============================================================
function fmt(n, decimals = 1) {
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
@@ -1474,7 +1365,6 @@ function mountWallEditor(container, callbacks) {
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);