diff --git a/css/app.css b/css/app.css index 9740b0a..07e0bd3 100644 --- a/css/app.css +++ b/css/app.css @@ -654,3 +654,10 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: #app-bar .submenu .menu-info { padding: 6px 12px 2px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted, #8a8f96); } #app-bar .submenu .menu-help { cursor: default; opacity: .85; } #app-bar .submenu .menu-help:hover { background: transparent; color: var(--nav-link-color); } + +/* Openingen: "Volg dakhelling" (schuine bovenzijde) */ +.opening-roof-btn { + display: inline-flex; align-items: center; gap: 5px; margin-top: 2px; padding: 4px 8px; font-size: 11.5px; + background: var(--surface); border: 1px solid var(--border); color: var(--primary); border-radius: 4px; cursor: pointer; +} +.opening-roof-btn:hover { background: var(--primary-soft); border-color: var(--primary); } diff --git a/js/app.js b/js/app.js index 84a9bd8..08ff517 100644 --- a/js/app.js +++ b/js/app.js @@ -4,7 +4,7 @@ * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ -const APP_VERSION = '0.4.16'; +const APP_VERSION = '0.4.17'; // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang. let tabs = []; diff --git a/js/core/geometry.js b/js/core/geometry.js index 5bc5dc8..1ce935f 100644 --- a/js/core/geometry.js +++ b/js/core/geometry.js @@ -154,87 +154,126 @@ function summarizeStockPlan(bars) { } // Verticale strook [x0, x1] van y = yBottom tot een (evt. schuine, lineaire) -// bovenrand topYAt(x), MIN een set rechthoekige uitsparingen -// ({ x0, x1, y0, y1 }). Uitsparingen die over de rand van de strook vallen -// worden een echte inkeping in de omtrek (i.p.v. een gat dat net binnen de -// rand wordt afgekapt — dat liet een haarfijn reepje materiaal met zichtbare -// randlijnen door de opening lopen). Een uitsparing over de volle breedte -// splitst de strook in losse delen. +// bovenrand topYAt(x), MIN een set uitsparingen ({ x0, x1, y0, y1, y1r? }): +// rechthoeken, of met y1r een schuine bovenkant (lineair van (x0, y1) naar +// (x1, y1r); schuin bovenin mag, onderin niet). Uitsparingen die over de +// rand van de strook vallen worden een echte inkeping in de omtrek (i.p.v. +// een gat dat net binnen de rand wordt afgekapt — dat liet een haarfijn +// reepje materiaal met zichtbare randlijnen door de opening lopen). Een +// uitsparing over de volle breedte splitst de strook in losse delen; een +// uitsparing tot in de (schuine) bovenrand wordt een inkeping in de bovenrand. // opts (optioneel): -// yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan); -// topYAt mag dan min(yCap, daklijn) zijn. Uitsparingen die tot -// boven yCap reiken worden er netjes op afgekapt (inkeping in de -// bovenrand). -// extraXs — knikpunten van topYAt (waar de daklijn yCap kruist), zodat de -// bovenrand tussen twee hoekpunten steeds lineair is. -// Resultaat: [{ outer: [{x,y}...] (CCW), holes: [[{x,y}...] (CW)] }], of null -// als een uitsparing tot in de schuine bovenrand reikt (niet ondersteund). +// yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan). +// extraXs — knikpunten van topYAt (bijv. waar de daklijn yCap kruist), zodat +// de bovenrand tussen twee hoekpunten steeds lineair is. +// Werkwijze: de strook wordt in kolommen geknipt (op alle x-randen, knikken +// en snijpunten van de lijnen), zodat binnen een kolom elke lijn (onderkant, +// onder-/bovenkant uitsparing, bovenrand) lineair is en ze elkaar niet +// kruisen; de cellen tussen twee lijnen zijn vol of leeg, en de randen +// tussen vol en leeg worden tot lussen aaneengeregen. +// Resultaat: [{ outer: [{x,y}...] (CCW), holes: [[{x,y}...] (CW)] }]. function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) { const eps = 1e-9; const yCap = (opts && opts.yCap !== undefined) ? opts.yCap : Infinity; - const extraXs = ((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps); + const top = (x) => Math.min(yCap, topYAt(x)); const uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps); const rs = rects - .map(r => ({ x0: Math.max(x0, r.x0), x1: Math.min(x1, r.x1), y0: Math.max(yBottom, r.y0), y1: Math.min(yCap, r.y1) })) - .filter(r => r.x1 - r.x0 > eps && r.y1 - r.y0 > eps); - const xs = uniq([x0, x1, ...extraXs, ...rs.flatMap(r => [r.x0, r.x1])]); + .filter(r => Math.min(x1, r.x1) - Math.max(x0, r.x0) > eps) + .map(r => { + const y1r = r.y1r !== undefined ? r.y1r : r.y1, s = (y1r - r.y1) / (r.x1 - r.x0); + return { x0: Math.max(x0, r.x0), x1: Math.min(x1, r.x1), y0: r.y0, topAt: (x) => r.y1 + (x - r.x0) * s }; + }); + const inRect = (x, y) => rs.some(r => x > r.x0 && x < r.x1 && y > r.y0 && y < r.topAt(x)); - // Laagste bovenrand binnen [a, b] (topYAt is lineair tussen de knikpunten). - const minTopIn = (a, b) => Math.min(...xs.filter(x => x >= a - eps && x <= b + eps).map(topYAt)); - const reachesTop = (r) => r.y1 >= yCap - eps && minTopIn(r.x0, r.x1) >= yCap - eps; // tot de vlakke bovenrand - if (rs.some(r => !reachesTop(r) && r.y1 >= minTopIn(r.x0, r.x1) - eps)) return null; + // Lijnen (ongeklemd); geklemd tussen yBottom en de bovenrand. + const raw = [() => yBottom, top]; + rs.forEach(r => raw.push(() => r.y0, r.topAt)); + const clamp = (f) => (x) => Math.min(top(x), Math.max(yBottom, f(x))); + const lines = raw.map(clamp); - const ys = uniq([yBottom, ...rs.flatMap(r => reachesTop(r) ? [r.y0] : [r.y0, r.y1]).filter(y => y > yBottom + eps)]); - const nCols = xs.length - 1, nRows = ys.length; // bovenste rij loopt tot de daklijn + let xs = uniq([x0, x1, ...((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps), ...rs.flatMap(r => [r.x0, r.x1])]); + // Snijpunten van de lijnen (ook met de bovenrand/onderkant) als extra kolomranden. + const cuts = []; + for (let i = 0; i < xs.length - 1; i++) { + const a = xs[i], b = xs[i + 1]; + for (let p = 0; p < raw.length; p++) { + for (let q = p + 1; q < raw.length; q++) { + const da = raw[p](a) - raw[q](a), db = raw[p](b) - raw[q](b); + if ((da > eps && db < -eps) || (da < -eps && db > eps)) cuts.push(a + (b - a) * da / (da - db)); + } + } + } + xs = uniq(xs.concat(cuts)); - const vertex = (i, j) => ({ x: xs[i], y: j < nRows ? ys[j] : topYAt(xs[i]) }); - const filled = (i, j) => { - if (i < 0 || i >= nCols || j < 0 || j >= nRows) return false; - const xm = (xs[i] + xs[i + 1]) / 2; - const ym = j < nRows - 1 ? (ys[j] + ys[j + 1]) / 2 : (ys[j] + topYAt(xm)) / 2; - return !rs.some(r => xm > r.x0 && xm < r.x1 && ym > r.y0 && ym < r.y1); + // Per kolom: cellen tussen opeenvolgende (verschillende) lijnen. + const cols = []; + for (let i = 0; i < xs.length - 1; i++) { + const a = xs[i], b = xs[i + 1], m = (a + b) / 2; + const vals = lines.map(f => ({ a: f(a), b: f(b), m: f(m) })) + .sort((p, q) => p.m - q.m) + .filter((v, k, arr) => k === 0 || v.m - arr[k - 1].m > eps || Math.abs(v.a - arr[k - 1].a) > eps || Math.abs(v.b - arr[k - 1].b) > eps); + const cells = []; + for (let k = 0; k < vals.length - 1; k++) { + const lo = vals[k], hi = vals[k + 1]; + if (hi.m - lo.m <= eps) continue; + cells.push({ lo, hi, filled: !inRect(m, (lo.m + hi.m) / 2) }); + } + cols.push({ a, b, cells }); + } + + // Hoekpunten gedeeld via een register (bijna-gelijke punten = hetzelfde punt). + const verts = []; + const vid = (x, y) => { + let k = verts.findIndex(v => Math.abs(v.x - x) < 1e-7 && Math.abs(v.y - y) < 1e-7); + if (k < 0) { k = verts.length; verts.push({ x, y }); } + return k; }; - - // Gerichte randen van gevulde cellen (CCW), alleen waar de buur leeg is. - const edges = new Map(); // "i,j" -> [[i2,j2], ...] - const addEdge = (a, b) => { - const key = a.join(','); - if (!edges.has(key)) edges.set(key, []); - edges.get(key).push(b); + const edges = new Map(); // van -> [naar, ...] + const addEdge = (p, q) => { + const a = vid(p.x, p.y), b = vid(q.x, q.y); + if (a === b) return; + if (!edges.has(a)) edges.set(a, []); + edges.get(a).push(b); }; - for (let i = 0; i < nCols; i++) { - for (let j = 0; j < nRows; j++) { - if (!filled(i, j)) continue; - if (!filled(i, j - 1)) addEdge([i, j], [i + 1, j]); - if (!filled(i + 1, j)) addEdge([i + 1, j], [i + 1, j + 1]); - if (!filled(i, j + 1)) addEdge([i + 1, j + 1], [i, j + 1]); - if (!filled(i - 1, j)) addEdge([i, j + 1], [i, j]); + // Onder-/bovenranden binnen een kolom (CCW: onder naar rechts, boven naar links). + cols.forEach(col => col.cells.forEach((c, k) => { + if (!c.filled) return; + if (!(k > 0 && col.cells[k - 1].filled && col.cells[k - 1].hi === c.lo)) addEdge({ x: col.a, y: c.lo.a }, { x: col.b, y: c.lo.b }); + if (!(k < col.cells.length - 1 && col.cells[k + 1].filled && col.cells[k + 1].lo === c.hi)) addEdge({ x: col.b, y: c.hi.b }, { x: col.a, y: c.hi.a }); + })); + // Verticale randen op elke kolomgrens: waar links vol en rechts leeg is (of andersom). + for (let i = 0; i < xs.length; i++) { + const x = xs[i]; + const left = i > 0 ? cols[i - 1].cells.filter(c => c.filled).map(c => [c.lo.b, c.hi.b]) : []; + const right = i < cols.length ? cols[i].cells.filter(c => c.filled).map(c => [c.lo.a, c.hi.a]) : []; + const ys = uniq(left.concat(right).flat()); + for (let k = 0; k < ys.length - 1; k++) { + const ya = ys[k], yb = ys[k + 1], ym = (ya + yb) / 2; + if (yb - ya <= eps) continue; + const lf = left.some(([p, q]) => ym > p && ym < q), rf = right.some(([p, q]) => ym > p && ym < q); + if (lf && !rf) addEdge({ x, y: ya }, { x, y: yb }); + else if (rf && !lf) addEdge({ x, y: yb }, { x, y: ya }); } } // Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg). const loops = []; - edges.forEach((list, startKey) => { + edges.forEach((list, start) => { while (list.length) { - const loop = []; - let cur = startKey.split(',').map(Number); - let next = list.pop(); - loop.push(cur); - while (next.join(',') !== startKey) { + const loop = [start]; + let next = list.pop(), guard = 0; + while (next !== start && guard++ < 100000) { loop.push(next); - cur = next; - const outs = edges.get(cur.join(',')); + const outs = edges.get(next); + if (!outs || !outs.length) break; next = outs.pop(); } - loops.push(loop); + if (next === start) loops.push(loop); } }); const polys = loops.map(loop => { - // Eerst dubbele punten weg (bijv. een spits toelopend uiteinde waar de - // bovenrand de onderrand raakt), dan collineaire tussenpunten. - const pts = loop.map(([i, j]) => vertex(i, j)) - .filter((p, k, arr) => { const q = arr[(k + 1) % arr.length]; return Math.hypot(p.x - q.x, p.y - q.y) > 1e-9; }); + const pts = loop.map(k => verts[k]); return pts.filter((p, k) => { const a = pts[(k - 1 + pts.length) % pts.length], b = pts[(k + 1) % pts.length]; return Math.abs((p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x)) > 1e-12; @@ -254,8 +293,11 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) { return c; }; const outers = polys.filter(p => p.length >= 3 && signedArea(p) > 1e-12).map(p => ({ outer: p, holes: [] })); + // Gat bij de kleinste buitenlus die het bevat. + outers.sort((p, q) => signedArea(p.outer) - signedArea(q.outer)); polys.filter(p => p.length >= 3 && signedArea(p) < -1e-12).forEach(hole => { - const owner = outers.find(o => inside(hole[0], o.outer)) || outers[0]; + const mid = { x: (hole[0].x + hole[1].x) / 2, y: (hole[0].y + hole[1].y) / 2 }; + const owner = outers.find(o => inside(mid, o.outer)) || outers[0]; if (owner) owner.holes.push(hole); }); return outers; diff --git a/js/house/house-model.js b/js/house/house-model.js index a5237fe..d9cb867 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -681,8 +681,10 @@ window.HouseModel = (() => { const ops = firstConfig.openings.map(op => { const wa = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x, 0); const wb = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x + op.w, 0); - const u = Math.min(toLocalU(lf.place, wa), toLocalU(lf.place, wb)); - return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y, anchor: 'left', offset: Math.round(u) }; + const ua = toLocalU(lf.place, wa), ub = toLocalU(lf.place, wb); + // Gespiegeld (andere kijkrichting): schuine bovenzijde klapt om. + const flip = ua > ub; + return { id: op.id, type: op.type, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) }; }); config = WallModel.buildConfig(paramsOf(lf), ops); } @@ -827,11 +829,11 @@ window.HouseModel = (() => { const poly = [{ x: -W / 2, y: y0 }, { x: W / 2, y: y0 }, ...tl.pts.slice().reverse()]; return Math.max(0, polygonArea(poly)) + (p.skinAbove || 0) * W; }; - const openingsMM2 = (p) => (p.config.openings || []).reduce((a, op) => a + op.w * op.h, 0); + const openingsMM2 = (p) => (p.config.openings || []).reduce((a, op) => a + WallModel.openingArea(op), 0); const facadePieces = ext.concat(parts.interior.filter(p => p.skinFrom !== undefined)); const gevelBruto = facadePieces.reduce((a, p) => a + wallArea(p, p.skinFrom || 0), 0); const gevelOpen = ext.reduce((a, p) => a + openingsMM2(p), 0); - const glas = ext.reduce((a, p) => a + (p.config.openings || []).filter(op => op.type === 'raam').reduce((b, op) => b + op.w * op.h, 0), 0); + const glas = ext.reduce((a, p) => a + (p.config.openings || []).filter(op => op.type === 'raam').reduce((b, op) => b + WallModel.openingArea(op), 0), 0); const binnen = all.reduce((a, p) => a + ((p.stats && p.stats.spaanplaatAreaM2) || 0) + ((p.stats && p.stats.spaanplaatBuitenAreaM2) || 0), 0); const dakM2 = parts.units.reduce((a, u) => a + (u.roof.specs.dakleerM2 || 0) + (u.roof.specs.rietM2 || 0), 0); const dakPlanMM2 = parts.units.reduce((a, u) => a + u.roof.rect.W * u.roof.rect.D, 0); diff --git a/js/house/house-pdf.js b/js/house/house-pdf.js index 83369bf..86ee07b 100644 --- a/js/house/house-pdf.js +++ b/js/house/house-pdf.js @@ -229,10 +229,12 @@ window.HousePdf = (() => { } // Openingen (glas / deur), vóór de gevel (p.config.openings || []).forEach(op => { - if (op.y + op.h <= y0) return; - const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y), yb = op.y + op.h; + if (WallModel.openingTopMax(op) <= y0) return; + // Schuine bovenzijde: bovenkant links/rechts. + const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y); + const ybL = Math.max(ya, op.y + op.h), ybR = Math.max(ya, op.y + op.hR); const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur; - faces.push({ pts: [[x0, ya], [x1, ya], [x1, yb], [x0, yb]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true }); + faces.push({ pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true }); }); }; parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0)))); diff --git a/js/house/house.js b/js/house/house.js index 92f4477..d674426 100644 --- a/js/house/house.js +++ b/js/house/house.js @@ -350,8 +350,8 @@ function mountHouseEditor(container, callbacks) { } const e = p.skinExt || {}; const xL = -W / 2 - (e.left > 0 ? e.left + out : 0), xR = W / 2 + (e.right > 0 ? e.right + out : 0); - const gaps = p.config.openings.filter(op => op.y < y && op.y + op.h > y) - .map(op => [-W / 2 + op.x, -W / 2 + op.x + op.w]).sort((a, b) => a[0] - b[0]); + const gaps = p.config.openings.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean) + .map(([a, b]) => [-W / 2 + a, -W / 2 + b]).sort((a, b) => a[0] - b[0]); const spans = []; let cur = xL; gaps.forEach(([a, b]) => { if (a > cur) spans.push([cur, Math.min(a, xR)]); cur = Math.max(cur, b); }); @@ -1118,7 +1118,7 @@ function mountHouseEditor(container, callbacks) { const out = p.place.normal ? WallModel.claddingOffset(p.buitenMateriaal, p.stone) : 0; if (lz < -T / 2 - WALK_RADIUS || lz > T / 2 + out + WALK_RADIUS || Math.abs(lx) > W / 2 + WALK_RADIUS) return false; // Door een deur(opening) mag je lopen. - return !(p.config.openings || []).some(op => op.y < 100 && op.y + op.h > 1800 && + return !(p.config.openings || []).some(op => op.y < 100 && op.y + Math.min(op.h, op.hR) > 1800 && lx > -W / 2 + op.x + WALK_RADIUS * 0.6 && lx < -W / 2 + op.x + op.w - WALK_RADIUS * 0.6); }); } diff --git a/js/lighting/lighting-model.js b/js/lighting/lighting-model.js index d3c530c..7ad1a15 100644 --- a/js/lighting/lighting-model.js +++ b/js/lighting/lighting-model.js @@ -191,7 +191,7 @@ window.LightingModel = (() => { .filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u)))); return; } - cfg.openings.filter(op => op.y - sW <= H && op.y + op.h + sW >= H).forEach(op => { + cfg.openings.filter(op => op.y - sW <= H && WallModel.openingTopMax(op) + sW >= H).forEach(op => { const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW); blocked.push([Math.min(a, b), Math.max(a, b)]); }); diff --git a/js/unit/unit-model.js b/js/unit/unit-model.js index 54964bc..02e649a 100644 --- a/js/unit/unit-model.js +++ b/js/unit/unit-model.js @@ -204,7 +204,7 @@ window.UnitModel = (() => { const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => { const openings = fullConfig.openings .filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS) - .map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y, anchor: 'left', offset: op.x - a }; }); + .map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; }); const hasPeak = hM !== null && a < pU - 1 && b > pU + 1; const params = Object.assign({}, wallState.wall, { width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b)), @@ -233,7 +233,7 @@ window.UnitModel = (() => { if (Math.min(hA, hB) < MIN_RAISE_MM) return; const openings = fullConfig.openings .filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS) - .map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y - base, anchor: 'left', offset: op.x - a }; }); + .map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; }); const params = Object.assign({}, wallState.wall, { width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB), heightMid: hasPeak ? hM - base : null, peakX: hasPeak ? pU - a : undefined diff --git a/js/wall/wall-model.js b/js/wall/wall-model.js index 4ecef58..4d86e84 100644 --- a/js/wall/wall-model.js +++ b/js/wall/wall-model.js @@ -15,7 +15,7 @@ // // Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export): // { wall: {width, heightAll, heightLeft, heightRight, heightMid?, studWidth, ...}, -// materials: {interior, exterior}, openings: [{id,w,h,y,anchor,offset}] } +// materials: {interior, exterior}, openings: [{id,w,h,hR?,y,anchor,offset}] } // // Plain script (geen ES-module), moet na core/geometry.js geladen worden. // ============================================================ @@ -173,6 +173,24 @@ window.WallModel = (() => { // koningsstijl (stevigheid, computeFrame). const isDoor = (op) => op.type === 'deur' || op.type === 'voordeur'; + // Schuine bovenzijde (zoals de wand zelf: schuin bovenin mag, onderin + // niet): h = hoogte links, hR = hoogte rechts. Ruwe openingen hebben hR + // alleen als hij (bewust) schuin is; opgeloste openingen altijd. + // x is lokaal (0..wandbreedte), zoals op.x. + const hRightOf = (op) => op.hR !== undefined ? op.hR : op.h; + const openingTopAt = (op, x) => op.y + op.h + (x - op.x) / op.w * (hRightOf(op) - op.h); + const openingTopMax = (op) => op.y + Math.max(op.h, hRightOf(op)); + const openingArea = (op) => op.w * (op.h + hRightOf(op)) / 2; + // Stuk [x0, x1] van een vlak met waterpas onderkant y0 en schuine + // bovenkant (yl links, yr rechts) op hoogte y, of null. + function slopedSpanAtY(x0, x1, y0, yl, yr, y) { + if (y <= y0 || y >= Math.max(yl, yr)) return null; + if (y <= Math.min(yl, yr)) return [x0, x1]; + const xc = x0 + (y - yl) / (yr - yl) * (x1 - x0); + return yr > yl ? [xc, x1] : [x0, xc]; + } + const openingSpanAtY = (op, y) => slopedSpanAtY(op.x, op.x + op.w, op.y, op.y + op.h, op.y + hRightOf(op), y); + // Buitenzijde-materialen (sleutel -> weergavenaam). 'spaanplaat' is voor // binnenwanden (aan beide zijden spaanplaat). // 'geen': geen beplating aan de buitenzijde — naadwand-deel dat tegen het @@ -287,7 +305,7 @@ window.WallModel = (() => { else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset; else x = raw.offset; - return { id: raw.id, type: raw.type || 'raam', w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawY: raw.y }; + return { id: raw.id, type: raw.type || 'raam', w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y }; }); resolved.sort((a, b) => a.x - b.x); @@ -301,10 +319,11 @@ window.WallModel = (() => { // 2x stijldikte ertussen) moeten naast elkaar; staan ze boven elkaar // (bijv. een raam boven een deur), dan mogen ze in x overlappen. // Hoogte hier op basis van de invoer; na fase 2 wordt het nog gecontroleerd. + // (Schuine bovenzijde: de hoogste kant telt.) const vBand = (o, h, y) => [y > plateThick ? y - studWidth : 0, y + h + studWidth]; const stacked = (a, b, useFinal) => { - const [a0, a1] = useFinal ? vBand(a, a.h, a.y) : vBand(a, Math.max(400, a.rawH), a.rawY); - const [b0, b1] = useFinal ? vBand(b, b.h, b.y) : vBand(b, Math.max(400, b.rawH), b.rawY); + const [a0, a1] = useFinal ? vBand(a, Math.max(a.h, a.hR), a.y) : vBand(a, Math.max(400, a.rawH, a.rawHR), a.rawY); + const [b0, b1] = useFinal ? vBand(b, Math.max(b.h, b.hR), b.y) : vBand(b, Math.max(400, b.rawH, b.rawHR), b.rawY); return a1 <= b0 + 0.5 || b1 <= a0 + 0.5; }; // Tussenruimte naast elkaar: 2x stijldikte i.p.v. 1x: de OSB-uitsparing @@ -337,23 +356,22 @@ window.WallModel = (() => { if (cur.x > maxX) cur.x = maxX; } - // Fase 2: hoogte (h) en verticale positie (y), per opening geklemd tegen - // de daklijn zoals die loopt op de EIGEN (nu definitieve) x-positie van - // die opening — het punt waar een verticale lijn op die plek de - // (eventueel hellende) horizontale topbalk zou snijden. Bij een - // opening die zelf een breedte heeft, wordt het laagste van de twee - // uiteinden aangehouden (de daklijn is lineair of heeft een nok als - // hoogste punt, dus dat is voldoende). - const localRoofHeightAt = roofAt; - + // Fase 2: hoogte (h links, hR rechts) en verticale positie (y), per + // opening geklemd tegen de daklijn zoals die loopt op de EIGEN (nu + // definitieve) x-positie van die opening — per kant: de linkerhoogte + // tegen de daklijn boven de linkerrand, de rechterhoogte tegen die + // boven de rechterrand. Zo kan een schuine bovenzijde het dak volgen. + // Daartussen blijft de bovenkant onder het dak: de daklijn is lineair + // of heeft een nok als hoogste punt (bol), dus de rechte lijn tussen + // twee punten eronder blijft eronder. resolved.forEach(o => { - const localCeiling = Math.min(localRoofHeightAt(o.x), localRoofHeightAt(o.x + o.w)); - const maxAllowedOpH = Math.max(400, localCeiling - (plateThick * 2)); - const h = Math.min(Math.max(400, o.rawH), maxAllowedOpH); - const maxOpY = Math.max(0, localCeiling - h - plateThick); - const y = Math.max(0, Math.min(o.rawY, maxOpY)); - o.h = h; - o.y = y; + const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w); + const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - plateThick * 2)); + const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - plateThick * 2)); + const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick); + o.y = Math.max(0, Math.min(o.rawY, maxOpY)); + o.h = hL; + o.hR = hR; }); // Detecteer of het na dit alles nog steeds niet past (te veel/te brede @@ -457,10 +475,16 @@ window.WallModel = (() => { // kreupel boven/onder een opening, binnen de openingbreedte const sw = CONFIG.studWidth; const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings - const bands = ops.map(op => ({ - op, x0: startX + op.x, x1: startX + op.x + op.w, - y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + op.h + sw - })); + // Bovendorpel volgt een schuine bovenzijde (loodrechte dikte sw): + // topAt(x) = bovenkant dorpel, y1 = het hoogste punt daarvan. + const bands = ops.map(op => { + const x0 = startX + op.x, s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s); + return { + op, x0, x1: x0 + op.w, + y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + Math.max(op.h, op.hR) + vo, + topAt: (x) => op.y + op.h + (x - x0) * s + vo + }; + }); const cands = []; cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' })); ops.forEach(op => { @@ -497,8 +521,11 @@ window.WallModel = (() => { cands.forEach(c => { const x = Math.round(c.x); const hit = bands.filter(bd => Math.min(bd.x1, x + sw / 2) - Math.max(bd.x0, x - sw / 2) > 0.5).sort((p, q) => p.y0 - q.y0); - let cur = CONFIG.plateThick, below = null; - const push = (y0, y1, above) => { + // cur = onderkant van het volgende stuk [links, rechts]: schuin als + // het op een schuine bovendorpel staat (dan ook y0l/y0r; y0 = laagste). + let cur = [CONFIG.plateThick, CONFIG.plateThick], below = null; + const curMax = () => Math.max(cur[0], cur[1]); + const push = (y1, above) => { if (c.kind === 'kreupel') { // Alleen direct boven of onder de eigen opening; onder de opening // niet als hij op een andere opening uitkomt (dat stuk vult de @@ -506,13 +533,16 @@ window.WallModel = (() => { const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner; if (!onTop && !(underneath && !below)) return; } - segs.push({ x, y0, y1, kind: c.kind }); + const sg = { x, y0: Math.min(cur[0], cur[1]), y1, kind: c.kind }; + if (Math.abs(cur[0] - cur[1]) > 0.05) { sg.y0l = cur[0]; sg.y0r = cur[1]; } + segs.push(sg); }; hit.forEach(bd => { - if (bd.y0 > cur + 1) push(cur, bd.y0, bd); - if (bd.y1 > cur) { cur = bd.y1; below = bd; } + if (bd.y0 > curMax() + 1) push(bd.y0, bd); + const t = [bd.topAt(x - sw / 2), bd.topAt(x + sw / 2)]; + if (Math.max(t[0], t[1]) > curMax()) { cur = t; below = bd; } }); - if (roofMin(x) > cur + 1) push(cur, null, null); + if (roofMin(x) > curMax() + 1) push(null, null); }); // Dubbelingen (bijv. koningsstijl op de hoekstijl) weg: hoogste prioriteit wint. const studs = []; @@ -536,19 +566,30 @@ window.WallModel = (() => { // bij een sterk hellende wand met een opening dicht bij de lage kant kan // de marge anders lokaal boven het dak uitkomen (kapotte uitsparing). // Gebruikt door build3D (fill) én de 2D-tekening (naad-/baanlijnen). + // Schuine bovenzijde: hy1 = bovenkant links (bij hx0), hy1r = rechts (bij + // hx1), langs de bovenkant van de schuine bovendorpel; per kant geklemd + // (de daklijn is bol, dus de lijn ertussen blijft eronder). function sheathingHoles(CONFIG) { const halfW = CONFIG.wallWidth / 2; const topYAt = topLine(CONFIG).at; const roofSafetyMargin = 10; + const sw = CONFIG.studWidth; return CONFIG.openings.map(op => { - const hx0 = -halfW + op.x - CONFIG.studWidth; - const hx1 = -halfW + op.x + op.w + CONFIG.studWidth; - let hy1 = op.y + op.h + CONFIG.studWidth; - const localRoofCeiling = Math.min(topYAt(hx0), topYAt(hx1)) - roofSafetyMargin; - if (hy1 > localRoofCeiling) hy1 = Math.max(op.y + roofSafetyMargin, localRoofCeiling); - return { hx0, hx1, hy0: op.y, hy1 }; + const hx0 = -halfW + op.x - sw; + const hx1 = -halfW + op.x + op.w + sw; + const s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s); + const clampTop = (y, x) => { + const ceil = topYAt(x) - roofSafetyMargin; + return y > ceil ? Math.max(op.y + roofSafetyMargin, ceil) : y; + }; + const hy1 = clampTop(op.y + op.h + vo - s * sw, hx0); + const hy1r = clampTop(op.y + op.hR + vo + s * sw, hx1); + return { hx0, hx1, hy0: op.y, hy1, hy1r }; }); } + const holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1; + const holeTopAt = (h, x) => h.hy1 + (x - h.hx0) / (h.hx1 - h.hx0) * (holeTopR(h) - h.hy1); + const holeRect = (h) => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1, y1r: holeTopR(h) }); // Stukken van een horizontale lijn op hoogte y (mm) die OP de beplating // liggen: binnen de wand-omtrek (incl. schuine top) en buiten de @@ -559,12 +600,15 @@ window.WallModel = (() => { const range = topLine(CONFIG).rangeAbove(y); if (!range) return []; let segments = [range]; - sheathingHoles(CONFIG).concat(extraHoles || []).filter(h => y > h.hy0 && y < h.hy1).forEach(h => { + sheathingHoles(CONFIG).concat(extraHoles || []).forEach(h => { + const span = slopedSpanAtY(h.hx0, h.hx1, h.hy0, h.hy1, holeTopR(h), y); + if (!span) return; + const [hx0, hx1] = span; segments = segments.flatMap(([a, b]) => { - if (h.hx1 <= a || h.hx0 >= b) return [[a, b]]; + if (hx1 <= a || hx0 >= b) return [[a, b]]; const out = []; - if (h.hx0 > a) out.push([a, h.hx0]); - if (h.hx1 < b) out.push([h.hx1, b]); + if (hx0 > a) out.push([a, hx0]); + if (hx1 < b) out.push([hx1, b]); return out; }); }); @@ -625,11 +669,13 @@ window.WallModel = (() => { const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2)); let segs = [[y0, yTop]]; holes.filter(h => x + w / 2 > h.hx0 && x - w / 2 < h.hx1).forEach(h => { + // Schuine bovenkant: de hoogste waarde over de latbreedte. + const hy1 = Math.max(holeTopAt(h, Math.max(h.hx0, x - w / 2)), holeTopAt(h, Math.min(h.hx1, x + w / 2))); segs = segs.flatMap(([a, b]) => { - if (h.hy1 <= a || h.hy0 >= b) return [[a, b]]; + if (hy1 <= a || h.hy0 >= b) return [[a, b]]; const out = []; if (h.hy0 > a) out.push([a, h.hy0]); - if (h.hy1 < b) out.push([h.hy1, b]); + if (hy1 < b) out.push([hy1, b]); return out; }); }); @@ -815,7 +861,8 @@ window.WallModel = (() => { xMin: startXM + op.x * toM, xMax: startXM + (op.x + op.w) * toM, yMin: op.y * toM, - yMax: (op.y + op.h) * toM, + yMax: (op.y + op.h) * toM, // bovenkant links + yMaxR: (op.y + op.hR) * toM, // bovenkant rechts (schuine bovenzijde) wM: op.w * toM })); @@ -864,21 +911,24 @@ window.WallModel = (() => { const xM = st.x * toM; const xLeft = xM - (sWidthM / 2); const xRight = xM + (sWidthM / 2); - const y0 = st.y0 * toM; + // Onderkant schuin als het stuk op een schuine bovendorpel staat. + const y0l = (st.y0l !== undefined ? st.y0l : st.y0) * toM, y0r = (st.y0r !== undefined ? st.y0r : st.y0) * toM; const pts = st.y1 === null - ? [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, ...underPts(xLeft, xRight).reverse()] - : [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }]; + ? [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, ...underPts(xLeft, xRight).reverse()] + : [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }]; wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2)); }); // D. DORPELS, per opening opsM.forEach(op => { - // Bovendorpel / linteel (horizontale balk) + // Bovendorpel / linteel (liggende balk, volgt een schuine bovenzijde; + // loodrechte dikte = stijldikte) + const s = (op.yMaxR - op.yMax) / op.wM, vo = sWidthM * Math.sqrt(1 + s * s); const opTopShape = new THREE.Shape(); opTopShape.moveTo(op.xMin, op.yMax); - opTopShape.lineTo(op.xMax, op.yMax); - opTopShape.lineTo(op.xMax, op.yMax + sWidthM); - opTopShape.lineTo(op.xMin, op.yMax + sWidthM); + opTopShape.lineTo(op.xMax, op.yMaxR); + opTopShape.lineTo(op.xMax, op.yMaxR + vo); + opTopShape.lineTo(op.xMin, op.yMax + vo); opTopShape.closePath(); wallGroup3D.add(createExtrudedBeam(opTopShape, sDepthM, -sDepthM / 2)); horizBeamCount++; @@ -949,7 +999,7 @@ window.WallModel = (() => { // Hoekdoorloop per buitenlaag, in m (zie cornerExt). const extM = (depthMM) => { const e = cornerExt(skinExt, depthMM); return { left: e.left * toM, right: e.right * toM }; }; const holeExtents = sheathingHoles(CONFIG).map(hole => ({ - hx0: hole.hx0 * toM, hx1: hole.hx1 * toM, hy0: hole.hy0 * toM, hy1: hole.hy1 * toM + hx0: hole.hx0 * toM, hx1: hole.hx1 * toM, hy0: hole.hy0 * toM, hy1: hole.hy1 * toM, hy1r: hole.hy1r * toM })); // Binnenhoek-doorloop (negatieve skinExt): dat stuk zit binnen de wand // en het dak van de buur-unit; daar geen buitenlagen, tot opts.trimTop @@ -1008,13 +1058,13 @@ window.WallModel = (() => { const xL = -halfW - ext.left, xR = halfW + ext.right; const capL = ext.left > 0 ? xL + tAll : xL, capR = ext.right > 0 ? xR - tAll : xR; const topClamped = (x) => tlOut.at(Math.max(-halfW, Math.min(halfW, x))); - const rects = outerHoles.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })); + const rects = outerHoles.map(holeRect); const shapesIn = (a, b) => { if (b - a <= edgeEps) return []; const capped = yCap === Infinity; const pieces = columnMinusRects(a, b, y0, capped ? topClamped : (x) => Math.min(yCap, topClamped(x)), rects, capped ? { extraXs: tlOut.kinks } : { yCap, extraXs: tlOut.kinks.concat(tlOut.crossings(yCap)) }); - return (pieces || []).map(piece => { + return pieces.map(piece => { const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y))))); return shape; @@ -1078,42 +1128,13 @@ window.WallModel = (() => { // over een plaatnaad wordt een inkeping in de plaatrand, zodat er // geen reepje plaat (met randlijnen) door de opening blijft lopen. const pieces = columnMinusRects(ex0, ex1, yB, skinTopAt, - HX.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })), + HX.map(holeRect), { yCap: range ? yCap : undefined, extraXs: range ? TL.kinks.concat(TL.crossings(yCap)) : TL.kinks }); - let boardShapes; - if (pieces) { - boardShapes = pieces.map(piece => { - const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y))); - piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y))))); - return shape; - }); - } else { - // Terugval (uitsparing tot in de schuine bovenrand): oude - // gat-methode, net binnen de plaatrand afgekapt. - const boardShape = new THREE.Shape(); - boardShape.moveTo(ex0, yB); - boardShape.lineTo(ex1, yB); - boardShape.lineTo(ex1, skinTopAt(ex1)); - TL.kinks.filter(x => x > ex0 && x < ex1).forEach(x => boardShape.lineTo(x, skinTopAt(x))); - boardShape.lineTo(ex0, skinTopAt(ex0)); - boardShape.closePath(); - holeExtents.forEach(h => { - const chx0 = Math.max(ex0 + edgeEps, h.hx0); - const chx1 = Math.min(ex1 - edgeEps, h.hx1); - if (chx1 > chx0) { - const holePath = new THREE.Path(); - const chy0 = Math.max(yB + edgeEps, h.hy0), chy1 = Math.min(yCap - edgeEps, h.hy1); - if (chy1 <= chy0) return; - holePath.moveTo(chx0, chy0); - holePath.lineTo(chx0, chy1); - holePath.lineTo(chx1, chy1); - holePath.lineTo(chx1, chy0); - holePath.closePath(); - boardShape.holes.push(holePath); - } - }); - boardShapes = [boardShape]; - } + const boardShapes = pieces.map(piece => { + const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y))); + piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y))))); + return shape; + }); if (!boardShapes.length) continue; const boardExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false }; @@ -1236,7 +1257,6 @@ window.WallModel = (() => { // transparant zijn). const doekZStepM = 0.0002; const DOEK_OUT_M = 0.001; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte - const wallOutlinePts = [{ x: -halfW, y: 0 }, { x: halfW, y: 0 }, ...tlOut.pts.slice().reverse()]; const maxRoofY = tlOut.maxY; let rowY0 = skinFrom * toM; @@ -1251,41 +1271,13 @@ window.WallModel = (() => { const [rx0, rx1] = tlOut.rangeAbove(rowY0) || [0, 0]; const extraXs = tlOut.crossings(rowY1).concat(tlOut.kinks); const pieces = rx1 - rx0 > edgeEps ? columnMinusRects(rx0, rx1, rowY0, (x) => Math.min(rowY1, roofAt(x)), - outerHoles.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })), { yCap: rowY1, extraXs }) : []; + outerHoles.map(holeRect), { yCap: rowY1, extraXs }) : []; - let rowShapes; - if (pieces) { - rowShapes = pieces.map(piece => { - const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y))); - piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y))))); - return shape; - }); - } else { - // Terugval (uitsparing tot in de schuine bovenrand): oude methode. - let rowPts = clipPolygonYLE(wallOutlinePts, rowY1); - rowPts = clipPolygonYGE(rowPts, rowY0); - rowShapes = []; - if (rowPts.length >= 3) { - const rowShape = new THREE.Shape(); - rowShape.moveTo(rowPts[0].x, rowPts[0].y); - for (let k = 1; k < rowPts.length; k++) rowShape.lineTo(rowPts[k].x, rowPts[k].y); - rowShape.closePath(); - holeExtents.forEach(h => { - const chy0 = Math.max(rowY0 + edgeEps, h.hy0); - const chy1 = Math.min(rowY1 - edgeEps, h.hy1); - if (chy1 > chy0) { - const holePath = new THREE.Path(); - holePath.moveTo(h.hx0, chy0); - holePath.lineTo(h.hx0, chy1); - holePath.lineTo(h.hx1, chy1); - holePath.lineTo(h.hx1, chy0); - holePath.closePath(); - rowShape.holes.push(holePath); - } - }); - rowShapes.push(rowShape); - } - } + const rowShapes = pieces.map(piece => { + const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y))); + piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y))))); + return shape; + }); if (rowShapes.length) { const rowExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false }; const rowGeo = shiftUV(new THREE.ExtrudeGeometry(rowShapes, rowExtrudeSettings)); @@ -1388,9 +1380,7 @@ window.WallModel = (() => { const envelopeAreaM2 = polygonArea(envelopePoints); const envelopeVolumeM3 = envelopeAreaM2 * sDepthM; - const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => { - return sum + (op.w * toM) * (op.h * toM) * sDepthM; - }, 0); + const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => sum + openingArea(op) * toM * toM * sDepthM, 0); let voidVolumeM3 = envelopeVolumeM3 - stats.woodVolumeM3 - openingsVolumeM3; if (voidVolumeM3 < 0) voidVolumeM3 = 0; @@ -1474,11 +1464,14 @@ window.WallModel = (() => { const x = st.x, xLeft = x - CONFIG.studWidth / 2, xRight = x + CONFIG.studWidth / 2; const yBottom = st.y0; const kind = st.kind === 'kreupel' ? 'kreupele stijl' : 'verticale balk'; + // Schuine onderkant: stuk op een schuine bovendorpel. + const yBottomLeft = st.y0l !== undefined ? st.y0l : yBottom, yBottomRight = st.y0r !== undefined ? st.y0r : yBottom; + const botCut = cutDeg(yBottomRight - yBottomLeft, xRight - xLeft); if (st.y1 !== null) { pieces.push({ length: st.y1 - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x, y: (yBottom + st.y1) / 2, - yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft: st.y1, yTopRight: st.y1, cutAngles: [0, 0] + yBottomLeft, yBottomRight, yTopLeft: st.y1, yTopRight: st.y1, cutAngles: [botCut, 0] }); return; } @@ -1486,16 +1479,24 @@ window.WallModel = (() => { const yTopLeft = topPts[0].y, yTopRight = topPts[topPts.length - 1].y; pieces.push({ length: Math.max(...topPts.map(p => p.y)) - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x, - y: (yBottom + yBottom + yTopLeft + yTopRight) / 4, - yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft, yTopRight, - outline: [{ x: xLeft, y: yBottom }, { x: xRight, y: yBottom }, ...topPts.slice().reverse()], - cutAngles: [0].concat(topPts.slice(1).map((q, k) => cutDeg(q.y - topPts[k].y, q.x - topPts[k].x))) + y: (yBottomLeft + yBottomRight + yTopLeft + yTopRight) / 4, + yBottomLeft, yBottomRight, yTopLeft, yTopRight, + outline: [{ x: xLeft, y: yBottomLeft }, { x: xRight, y: yBottomRight }, ...topPts.slice().reverse()], + cutAngles: [botCut].concat(topPts.slice(1).map((q, k) => cutDeg(q.y - topPts[k].y, q.x - topPts[k].x))) }); }); CONFIG.openings.forEach(op => { const opXWorld = startX + op.x; - pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'bovendorpel', x: opXWorld + op.w / 2, y: op.y + op.h + CONFIG.studWidth / 2, cutAngles: [0, 0] }); + // Schuine bovenzijde: dorpel langs de helling (loodrechte sneden, + // zoals de bovenregel), met de werkelijke omtrek voor de tekening. + const dh = op.hR - op.h; + const header = { length: Math.hypot(op.w, dh), width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'bovendorpel', x: opXWorld + op.w / 2, y: op.y + (op.h + op.hR) / 2 + CONFIG.studWidth / 2, cutAngles: [cutDeg(dh, op.w), cutDeg(dh, op.w)] }; + if (Math.abs(dh) > 0.5) { + const vo = CONFIG.studWidth * Math.hypot(op.w, dh) / op.w; + header.outline = [{ x: opXWorld, y: op.y + op.h }, { x: opXWorld + op.w, y: op.y + op.hR }, { x: opXWorld + op.w, y: op.y + op.hR + vo }, { x: opXWorld, y: op.y + op.h + vo }]; + } + pieces.push(header); if (op.y > CONFIG.plateThick) { pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'onderdorpel', x: opXWorld + op.w / 2, y: op.y - CONFIG.studWidth / 2, cutAngles: [0, 0] }); } @@ -1573,6 +1574,9 @@ window.WallModel = (() => { if (typeof o.w !== 'number' || typeof o.h !== 'number' || typeof o.y !== 'number' || typeof o.offset !== 'number') { throw new Error(`opening #${i + 1}: w/h/y/offset moeten getallen zijn`); } + if (o.hR !== undefined && (typeof o.hR !== 'number' || !isFinite(o.hR))) { + throw new Error(`opening #${i + 1}: hR (hoogte rechts) moet een getal zijn`); + } if (o.type !== undefined && !OPENING_TYPES[o.type]) { throw new Error(`opening #${i + 1}: type moet ${Object.keys(OPENING_TYPES).join('/')} zijn (was: ${o.type})`); } @@ -1584,7 +1588,7 @@ window.WallModel = (() => { return { STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText, - sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines, + sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY, validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame, createMaterials, build3D, computeSpecs }; diff --git a/js/wall/wall-pdf.js b/js/wall/wall-pdf.js index b4d1c6b..942dba1 100644 --- a/js/wall/wall-pdf.js +++ b/js/wall/wall-pdf.js @@ -154,16 +154,17 @@ window.WallPdf = (() => { doc.setLineWidth(0.2); CONFIG.openings.forEach(op => { const opXWorld = -wallWidth / 2 + op.x; - const x0 = px(opXWorld), y0 = py(op.y + op.h), x1 = px(opXWorld + op.w), y1 = py(op.y); - doc.rect(x0, y0, x1 - x0, y1 - y0, 'S'); + // Schuine bovenzijde: y0 = bovenkant links, y0r = rechts. + const x0 = px(opXWorld), y0 = py(op.y + op.h), y0r = py(op.y + op.hR), x1 = px(opXWorld + op.w), y1 = py(op.y); + [[x0, y1, x1, y1], [x1, y1, x1, y0r], [x1, y0r, x0, y0], [x0, y0, x0, y1]].forEach(l => doc.line(...l)); doc.line(x0, y0, x1, y1); - doc.line(x0, y1, x1, y0); + doc.line(x0, y1, x1, y0r); doc.setTextColor(...BLACK); doc.setFontSize(6); // Dagmaat-label BINNEN de opening zetten (net onder de bovenkant), // i.p.v. erboven — daar overlapte het met de maatketen/omtrek // erboven. - doc.text(`${op.w}x${op.h}`, (x0 + x1) / 2, y0 + 5, { align: 'center' }); + doc.text(op.hR !== op.h ? `${op.w}x${op.h}/${op.hR}` : `${op.w}x${op.h}`, (x0 + x1) / 2, Math.max(y0, y0r) + 5, { align: 'center' }); }); const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2); diff --git a/js/wall/wall.js b/js/wall/wall.js index b7f52ec..2ebc3d4 100644 --- a/js/wall/wall.js +++ b/js/wall/wall.js @@ -280,7 +280,9 @@ function mountWallEditor(container, callbacks) { let wallGroup3D = null; let lastBaseHeight = 2400; - // Elke opening: { id, w, h, y, anchor: 'left'|'middle'|'right', offset } + // Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset } + // 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 @@ -293,11 +295,14 @@ function mountWallEditor(container, callbacks) { // ------------------------------------------------------------ // 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. + // 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 } + 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 @@ -315,10 +320,17 @@ function mountWallEditor(container, callbacks) { 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 } : {}); + function syncOpeningCardInputs(op) { const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`); if (!card) return; - ['w', 'h', 'y', 'offset'].forEach(field => { + // 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}"]`); if (input && document.activeElement !== input) input.value = Math.round(op[field]); }); @@ -429,6 +441,8 @@ function mountWallEditor(container, callbacks) { hRightInput.value = tmp; 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' }; if (o.anchor === 'middle') return { ...o, offset: -o.offset }; @@ -463,9 +477,13 @@ function mountWallEditor(container, callbacks) {