1 Commits
11 changed files with 374 additions and 242 deletions
+7
View File
@@ -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-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 { cursor: default; opacity: .85; }
#app-bar .submenu .menu-help:hover { background: transparent; color: var(--nav-link-color); } #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); }
+1 -1
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * 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. // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = []; let tabs = [];
+100 -58
View File
@@ -154,87 +154,126 @@ function summarizeStockPlan(bars) {
} }
// Verticale strook [x0, x1] van y = yBottom tot een (evt. schuine, lineaire) // Verticale strook [x0, x1] van y = yBottom tot een (evt. schuine, lineaire)
// bovenrand topYAt(x), MIN een set rechthoekige uitsparingen // bovenrand topYAt(x), MIN een set uitsparingen ({ x0, x1, y0, y1, y1r? }):
// ({ x0, x1, y0, y1 }). Uitsparingen die over de rand van de strook vallen // rechthoeken, of met y1r een schuine bovenkant (lineair van (x0, y1) naar
// worden een echte inkeping in de omtrek (i.p.v. een gat dat net binnen de // (x1, y1r); schuin bovenin mag, onderin niet). Uitsparingen die over de
// rand wordt afgekapt — dat liet een haarfijn reepje materiaal met zichtbare // rand van de strook vallen worden een echte inkeping in de omtrek (i.p.v.
// randlijnen door de opening lopen). Een uitsparing over de volle breedte // een gat dat net binnen de rand wordt afgekapt — dat liet een haarfijn
// splitst de strook in losse delen. // 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): // opts (optioneel):
// yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan); // yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan).
// topYAt mag dan min(yCap, daklijn) zijn. Uitsparingen die tot // extraXs — knikpunten van topYAt (bijv. waar de daklijn yCap kruist), zodat
// boven yCap reiken worden er netjes op afgekapt (inkeping in de // de bovenrand tussen twee hoekpunten steeds lineair is.
// bovenrand). // Werkwijze: de strook wordt in kolommen geknipt (op alle x-randen, knikken
// extraXs — knikpunten van topYAt (waar de daklijn yCap kruist), zodat de // en snijpunten van de lijnen), zodat binnen een kolom elke lijn (onderkant,
// bovenrand tussen twee hoekpunten steeds lineair is. // onder-/bovenkant uitsparing, bovenrand) lineair is en ze elkaar niet
// Resultaat: [{ outer: [{x,y}...] (CCW), holes: [[{x,y}...] (CW)] }], of null // kruisen; de cellen tussen twee lijnen zijn vol of leeg, en de randen
// als een uitsparing tot in de schuine bovenrand reikt (niet ondersteund). // 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) { function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
const eps = 1e-9; const eps = 1e-9;
const yCap = (opts && opts.yCap !== undefined) ? opts.yCap : Infinity; 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 uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps);
const rs = rects 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 => Math.min(x1, r.x1) - Math.max(x0, r.x0) > eps)
.filter(r => r.x1 - r.x0 > eps && r.y1 - r.y0 > eps); .map(r => {
const xs = uniq([x0, x1, ...extraXs, ...rs.flatMap(r => [r.x0, r.x1])]); 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). // Lijnen (ongeklemd); geklemd tussen yBottom en de bovenrand.
const minTopIn = (a, b) => Math.min(...xs.filter(x => x >= a - eps && x <= b + eps).map(topYAt)); const raw = [() => yBottom, top];
const reachesTop = (r) => r.y1 >= yCap - eps && minTopIn(r.x0, r.x1) >= yCap - eps; // tot de vlakke bovenrand rs.forEach(r => raw.push(() => r.y0, r.topAt));
if (rs.some(r => !reachesTop(r) && r.y1 >= minTopIn(r.x0, r.x1) - eps)) return null; 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)]); let xs = uniq([x0, x1, ...((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps), ...rs.flatMap(r => [r.x0, r.x1])]);
const nCols = xs.length - 1, nRows = ys.length; // bovenste rij loopt tot de daklijn // 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]) }); // Per kolom: cellen tussen opeenvolgende (verschillende) lijnen.
const filled = (i, j) => { const cols = [];
if (i < 0 || i >= nCols || j < 0 || j >= nRows) return false; for (let i = 0; i < xs.length - 1; i++) {
const xm = (xs[i] + xs[i + 1]) / 2; const a = xs[i], b = xs[i + 1], m = (a + b) / 2;
const ym = j < nRows - 1 ? (ys[j] + ys[j + 1]) / 2 : (ys[j] + topYAt(xm)) / 2; const vals = lines.map(f => ({ a: f(a), b: f(b), m: f(m) }))
return !rs.some(r => xm > r.x0 && xm < r.x1 && ym > r.y0 && ym < r.y1); .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;
}; };
const edges = new Map(); // van -> [naar, ...]
// Gerichte randen van gevulde cellen (CCW), alleen waar de buur leeg is. const addEdge = (p, q) => {
const edges = new Map(); // "i,j" -> [[i2,j2], ...] const a = vid(p.x, p.y), b = vid(q.x, q.y);
const addEdge = (a, b) => { if (a === b) return;
const key = a.join(','); if (!edges.has(a)) edges.set(a, []);
if (!edges.has(key)) edges.set(key, []); edges.get(a).push(b);
edges.get(key).push(b);
}; };
for (let i = 0; i < nCols; i++) { // Onder-/bovenranden binnen een kolom (CCW: onder naar rechts, boven naar links).
for (let j = 0; j < nRows; j++) { cols.forEach(col => col.cells.forEach((c, k) => {
if (!filled(i, j)) continue; if (!c.filled) return;
if (!filled(i, j - 1)) addEdge([i, j], [i + 1, j]); 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 (!filled(i + 1, j)) addEdge([i + 1, j], [i + 1, j + 1]); 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 });
if (!filled(i, j + 1)) addEdge([i + 1, j + 1], [i, j + 1]); }));
if (!filled(i - 1, j)) addEdge([i, j + 1], [i, j]); // 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). // Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg).
const loops = []; const loops = [];
edges.forEach((list, startKey) => { edges.forEach((list, start) => {
while (list.length) { while (list.length) {
const loop = []; const loop = [start];
let cur = startKey.split(',').map(Number); let next = list.pop(), guard = 0;
let next = list.pop(); while (next !== start && guard++ < 100000) {
loop.push(cur);
while (next.join(',') !== startKey) {
loop.push(next); loop.push(next);
cur = next; const outs = edges.get(next);
const outs = edges.get(cur.join(',')); if (!outs || !outs.length) break;
next = outs.pop(); next = outs.pop();
} }
loops.push(loop); if (next === start) loops.push(loop);
} }
}); });
const polys = loops.map(loop => { const polys = loops.map(loop => {
// Eerst dubbele punten weg (bijv. een spits toelopend uiteinde waar de const pts = loop.map(k => verts[k]);
// 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; });
return pts.filter((p, k) => { return pts.filter((p, k) => {
const a = pts[(k - 1 + pts.length) % pts.length], b = pts[(k + 1) % pts.length]; 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; 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; return c;
}; };
const outers = polys.filter(p => p.length >= 3 && signedArea(p) > 1e-12).map(p => ({ outer: p, holes: [] })); 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 => { 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); if (owner) owner.holes.push(hole);
}); });
return outers; return outers;
+6 -4
View File
@@ -681,8 +681,10 @@ window.HouseModel = (() => {
const ops = firstConfig.openings.map(op => { const ops = firstConfig.openings.map(op => {
const wa = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x, 0); 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 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)); const ua = toLocalU(lf.place, wa), ub = 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) }; // 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); 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()]; 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; 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 facadePieces = ext.concat(parts.interior.filter(p => p.skinFrom !== undefined));
const gevelBruto = facadePieces.reduce((a, p) => a + wallArea(p, p.skinFrom || 0), 0); const gevelBruto = facadePieces.reduce((a, p) => a + wallArea(p, p.skinFrom || 0), 0);
const gevelOpen = ext.reduce((a, p) => a + openingsMM2(p), 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 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 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); const dakPlanMM2 = parts.units.reduce((a, u) => a + u.roof.rect.W * u.roof.rect.D, 0);
+5 -3
View File
@@ -229,10 +229,12 @@ window.HousePdf = (() => {
} }
// Openingen (glas / deur), vóór de gevel // Openingen (glas / deur), vóór de gevel
(p.config.openings || []).forEach(op => { (p.config.openings || []).forEach(op => {
if (op.y + op.h <= y0) return; if (WallModel.openingTopMax(op) <= 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; // 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; 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)))); parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0))));
+3 -3
View File
@@ -350,8 +350,8 @@ function mountHouseEditor(container, callbacks) {
} }
const e = p.skinExt || {}; 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 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) const gaps = p.config.openings.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean)
.map(op => [-W / 2 + op.x, -W / 2 + op.x + op.w]).sort((a, b) => a[0] - b[0]); .map(([a, b]) => [-W / 2 + a, -W / 2 + b]).sort((a, b) => a[0] - b[0]);
const spans = []; const spans = [];
let cur = xL; let cur = xL;
gaps.forEach(([a, b]) => { if (a > cur) spans.push([cur, Math.min(a, xR)]); cur = Math.max(cur, b); }); 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; 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; 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. // 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); lx > -W / 2 + op.x + WALK_RADIUS * 0.6 && lx < -W / 2 + op.x + op.w - WALK_RADIUS * 0.6);
}); });
} }
+1 -1
View File
@@ -191,7 +191,7 @@ window.LightingModel = (() => {
.filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u)))); .filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u))));
return; 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); 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)]); blocked.push([Math.min(a, b), Math.max(a, b)]);
}); });
+2 -2
View File
@@ -204,7 +204,7 @@ window.UnitModel = (() => {
const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => { const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => {
const openings = fullConfig.openings const openings = fullConfig.openings
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS) .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 hasPeak = hM !== null && a < pU - 1 && b > pU + 1;
const params = Object.assign({}, wallState.wall, { const params = Object.assign({}, wallState.wall, {
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b)), 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; if (Math.min(hA, hB) < MIN_RAISE_MM) return;
const openings = fullConfig.openings const openings = fullConfig.openings
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS) .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, { const params = Object.assign({}, wallState.wall, {
width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB), width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB),
heightMid: hasPeak ? hM - base : null, peakX: hasPeak ? pU - a : undefined heightMid: hasPeak ? hM - base : null, peakX: hasPeak ? pU - a : undefined
+129 -125
View File
@@ -15,7 +15,7 @@
// //
// Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export): // Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export):
// { wall: {width, heightAll, heightLeft, heightRight, heightMid?, studWidth, ...}, // { 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. // Plain script (geen ES-module), moet na core/geometry.js geladen worden.
// ============================================================ // ============================================================
@@ -173,6 +173,24 @@ window.WallModel = (() => {
// koningsstijl (stevigheid, computeFrame). // koningsstijl (stevigheid, computeFrame).
const isDoor = (op) => op.type === 'deur' || op.type === 'voordeur'; 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 // Buitenzijde-materialen (sleutel -> weergavenaam). 'spaanplaat' is voor
// binnenwanden (aan beide zijden spaanplaat). // binnenwanden (aan beide zijden spaanplaat).
// 'geen': geen beplating aan de buitenzijde — naadwand-deel dat tegen het // '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 if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
else x = 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); resolved.sort((a, b) => a.x - b.x);
@@ -301,10 +319,11 @@ window.WallModel = (() => {
// 2x stijldikte ertussen) moeten naast elkaar; staan ze boven elkaar // 2x stijldikte ertussen) moeten naast elkaar; staan ze boven elkaar
// (bijv. een raam boven een deur), dan mogen ze in x overlappen. // (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. // 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 vBand = (o, h, y) => [y > plateThick ? y - studWidth : 0, y + h + studWidth];
const stacked = (a, b, useFinal) => { 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 [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, b.h, b.y) : vBand(b, Math.max(400, b.rawH), b.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; return a1 <= b0 + 0.5 || b1 <= a0 + 0.5;
}; };
// Tussenruimte naast elkaar: 2x stijldikte i.p.v. 1x: de OSB-uitsparing // 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; if (cur.x > maxX) cur.x = maxX;
} }
// Fase 2: hoogte (h) en verticale positie (y), per opening geklemd tegen // Fase 2: hoogte (h links, hR rechts) en verticale positie (y), per
// de daklijn zoals die loopt op de EIGEN (nu definitieve) x-positie van // opening geklemd tegen de daklijn zoals die loopt op de EIGEN (nu
// die opening — het punt waar een verticale lijn op die plek de // definitieve) x-positie van die opening — per kant: de linkerhoogte
// (eventueel hellende) horizontale topbalk zou snijden. Bij een // tegen de daklijn boven de linkerrand, de rechterhoogte tegen die
// opening die zelf een breedte heeft, wordt het laagste van de twee // boven de rechterrand. Zo kan een schuine bovenzijde het dak volgen.
// uiteinden aangehouden (de daklijn is lineair of heeft een nok als // Daartussen blijft de bovenkant onder het dak: de daklijn is lineair
// hoogste punt, dus dat is voldoende). // of heeft een nok als hoogste punt (bol), dus de rechte lijn tussen
const localRoofHeightAt = roofAt; // twee punten eronder blijft eronder.
resolved.forEach(o => { resolved.forEach(o => {
const localCeiling = Math.min(localRoofHeightAt(o.x), localRoofHeightAt(o.x + o.w)); const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w);
const maxAllowedOpH = Math.max(400, localCeiling - (plateThick * 2)); const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - plateThick * 2));
const h = Math.min(Math.max(400, o.rawH), maxAllowedOpH); const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - plateThick * 2));
const maxOpY = Math.max(0, localCeiling - h - plateThick); const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick);
const y = Math.max(0, Math.min(o.rawY, maxOpY)); o.y = Math.max(0, Math.min(o.rawY, maxOpY));
o.h = h; o.h = hL;
o.y = y; o.hR = hR;
}); });
// Detecteer of het na dit alles nog steeds niet past (te veel/te brede // 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 // kreupel boven/onder een opening, binnen de openingbreedte
const sw = CONFIG.studWidth; const sw = CONFIG.studWidth;
const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings
const bands = ops.map(op => ({ // Bovendorpel volgt een schuine bovenzijde (loodrechte dikte sw):
op, x0: startX + op.x, x1: startX + op.x + op.w, // topAt(x) = bovenkant dorpel, y1 = het hoogste punt daarvan.
y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + op.h + sw 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 = []; const cands = [];
cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' })); cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
ops.forEach(op => { ops.forEach(op => {
@@ -497,8 +521,11 @@ window.WallModel = (() => {
cands.forEach(c => { cands.forEach(c => {
const x = Math.round(c.x); 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); 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; // cur = onderkant van het volgende stuk [links, rechts]: schuin als
const push = (y0, y1, above) => { // 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') { if (c.kind === 'kreupel') {
// Alleen direct boven of onder de eigen opening; onder de opening // Alleen direct boven of onder de eigen opening; onder de opening
// niet als hij op een andere opening uitkomt (dat stuk vult de // 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; const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
if (!onTop && !(underneath && !below)) return; 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 => { hit.forEach(bd => {
if (bd.y0 > cur + 1) push(cur, bd.y0, bd); if (bd.y0 > curMax() + 1) push(bd.y0, bd);
if (bd.y1 > cur) { cur = bd.y1; below = 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. // Dubbelingen (bijv. koningsstijl op de hoekstijl) weg: hoogste prioriteit wint.
const studs = []; const studs = [];
@@ -536,19 +566,30 @@ window.WallModel = (() => {
// bij een sterk hellende wand met een opening dicht bij de lage kant kan // bij een sterk hellende wand met een opening dicht bij de lage kant kan
// de marge anders lokaal boven het dak uitkomen (kapotte uitsparing). // de marge anders lokaal boven het dak uitkomen (kapotte uitsparing).
// Gebruikt door build3D (fill) én de 2D-tekening (naad-/baanlijnen). // 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) { function sheathingHoles(CONFIG) {
const halfW = CONFIG.wallWidth / 2; const halfW = CONFIG.wallWidth / 2;
const topYAt = topLine(CONFIG).at; const topYAt = topLine(CONFIG).at;
const roofSafetyMargin = 10; const roofSafetyMargin = 10;
const sw = CONFIG.studWidth;
return CONFIG.openings.map(op => { return CONFIG.openings.map(op => {
const hx0 = -halfW + op.x - CONFIG.studWidth; const hx0 = -halfW + op.x - sw;
const hx1 = -halfW + op.x + op.w + CONFIG.studWidth; const hx1 = -halfW + op.x + op.w + sw;
let hy1 = op.y + op.h + CONFIG.studWidth; const s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s);
const localRoofCeiling = Math.min(topYAt(hx0), topYAt(hx1)) - roofSafetyMargin; const clampTop = (y, x) => {
if (hy1 > localRoofCeiling) hy1 = Math.max(op.y + roofSafetyMargin, localRoofCeiling); const ceil = topYAt(x) - roofSafetyMargin;
return { hx0, hx1, hy0: op.y, hy1 }; 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 // Stukken van een horizontale lijn op hoogte y (mm) die OP de beplating
// liggen: binnen de wand-omtrek (incl. schuine top) en buiten de // liggen: binnen de wand-omtrek (incl. schuine top) en buiten de
@@ -559,12 +600,15 @@ window.WallModel = (() => {
const range = topLine(CONFIG).rangeAbove(y); const range = topLine(CONFIG).rangeAbove(y);
if (!range) return []; if (!range) return [];
let segments = [range]; 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]) => { 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 = []; const out = [];
if (h.hx0 > a) out.push([a, h.hx0]); if (hx0 > a) out.push([a, hx0]);
if (h.hx1 < b) out.push([h.hx1, b]); if (hx1 < b) out.push([hx1, b]);
return out; return out;
}); });
}); });
@@ -625,11 +669,13 @@ window.WallModel = (() => {
const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2)); const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2));
let segs = [[y0, yTop]]; let segs = [[y0, yTop]];
holes.filter(h => x + w / 2 > h.hx0 && x - w / 2 < h.hx1).forEach(h => { 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]) => { 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 = []; const out = [];
if (h.hy0 > a) out.push([a, h.hy0]); 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; return out;
}); });
}); });
@@ -815,7 +861,8 @@ window.WallModel = (() => {
xMin: startXM + op.x * toM, xMin: startXM + op.x * toM,
xMax: startXM + (op.x + op.w) * toM, xMax: startXM + (op.x + op.w) * toM,
yMin: op.y * 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 wM: op.w * toM
})); }));
@@ -864,21 +911,24 @@ window.WallModel = (() => {
const xM = st.x * toM; const xM = st.x * toM;
const xLeft = xM - (sWidthM / 2); const xLeft = xM - (sWidthM / 2);
const xRight = 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 const pts = st.y1 === null
? [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, ...underPts(xLeft, xRight).reverse()] ? [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, ...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 }, { 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)); wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2));
}); });
// D. DORPELS, per opening // D. DORPELS, per opening
opsM.forEach(op => { 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(); const opTopShape = new THREE.Shape();
opTopShape.moveTo(op.xMin, op.yMax); opTopShape.moveTo(op.xMin, op.yMax);
opTopShape.lineTo(op.xMax, op.yMax); opTopShape.lineTo(op.xMax, op.yMaxR);
opTopShape.lineTo(op.xMax, op.yMax + sWidthM); opTopShape.lineTo(op.xMax, op.yMaxR + vo);
opTopShape.lineTo(op.xMin, op.yMax + sWidthM); opTopShape.lineTo(op.xMin, op.yMax + vo);
opTopShape.closePath(); opTopShape.closePath();
wallGroup3D.add(createExtrudedBeam(opTopShape, sDepthM, -sDepthM / 2)); wallGroup3D.add(createExtrudedBeam(opTopShape, sDepthM, -sDepthM / 2));
horizBeamCount++; horizBeamCount++;
@@ -949,7 +999,7 @@ window.WallModel = (() => {
// Hoekdoorloop per buitenlaag, in m (zie cornerExt). // 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 extM = (depthMM) => { const e = cornerExt(skinExt, depthMM); return { left: e.left * toM, right: e.right * toM }; };
const holeExtents = sheathingHoles(CONFIG).map(hole => ({ 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 // Binnenhoek-doorloop (negatieve skinExt): dat stuk zit binnen de wand
// en het dak van de buur-unit; daar geen buitenlagen, tot opts.trimTop // 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 xL = -halfW - ext.left, xR = halfW + ext.right;
const capL = ext.left > 0 ? xL + tAll : xL, capR = ext.right > 0 ? xR - tAll : xR; 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 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) => { const shapesIn = (a, b) => {
if (b - a <= edgeEps) return []; if (b - a <= edgeEps) return [];
const capped = yCap === Infinity; const capped = yCap === Infinity;
const pieces = columnMinusRects(a, b, y0, capped ? topClamped : (x) => Math.min(yCap, topClamped(x)), rects, 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)) }); 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))); 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))))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y)))));
return shape; return shape;
@@ -1078,42 +1128,13 @@ window.WallModel = (() => {
// over een plaatnaad wordt een inkeping in de plaatrand, zodat er // over een plaatnaad wordt een inkeping in de plaatrand, zodat er
// geen reepje plaat (met randlijnen) door de opening blijft lopen. // geen reepje plaat (met randlijnen) door de opening blijft lopen.
const pieces = columnMinusRects(ex0, ex1, yB, skinTopAt, 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 }); { yCap: range ? yCap : undefined, extraXs: range ? TL.kinks.concat(TL.crossings(yCap)) : TL.kinks });
let boardShapes; const boardShapes = pieces.map(piece => {
if (pieces) {
boardShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y))); 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))))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape; 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];
}
if (!boardShapes.length) continue; if (!boardShapes.length) continue;
const boardExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false }; const boardExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
@@ -1236,7 +1257,6 @@ window.WallModel = (() => {
// transparant zijn). // transparant zijn).
const doekZStepM = 0.0002; const doekZStepM = 0.0002;
const DOEK_OUT_M = 0.001; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte 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; const maxRoofY = tlOut.maxY;
let rowY0 = skinFrom * toM; let rowY0 = skinFrom * toM;
@@ -1251,41 +1271,13 @@ window.WallModel = (() => {
const [rx0, rx1] = tlOut.rangeAbove(rowY0) || [0, 0]; const [rx0, rx1] = tlOut.rangeAbove(rowY0) || [0, 0];
const extraXs = tlOut.crossings(rowY1).concat(tlOut.kinks); const extraXs = tlOut.crossings(rowY1).concat(tlOut.kinks);
const pieces = rx1 - rx0 > edgeEps ? columnMinusRects(rx0, rx1, rowY0, (x) => Math.min(rowY1, roofAt(x)), 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; const rowShapes = pieces.map(piece => {
if (pieces) {
rowShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y))); 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))))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape; 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);
}
}
if (rowShapes.length) { if (rowShapes.length) {
const rowExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false }; const rowExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
const rowGeo = shiftUV(new THREE.ExtrudeGeometry(rowShapes, rowExtrudeSettings)); const rowGeo = shiftUV(new THREE.ExtrudeGeometry(rowShapes, rowExtrudeSettings));
@@ -1388,9 +1380,7 @@ window.WallModel = (() => {
const envelopeAreaM2 = polygonArea(envelopePoints); const envelopeAreaM2 = polygonArea(envelopePoints);
const envelopeVolumeM3 = envelopeAreaM2 * sDepthM; const envelopeVolumeM3 = envelopeAreaM2 * sDepthM;
const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => { const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => sum + openingArea(op) * toM * toM * sDepthM, 0);
return sum + (op.w * toM) * (op.h * toM) * sDepthM;
}, 0);
let voidVolumeM3 = envelopeVolumeM3 - stats.woodVolumeM3 - openingsVolumeM3; let voidVolumeM3 = envelopeVolumeM3 - stats.woodVolumeM3 - openingsVolumeM3;
if (voidVolumeM3 < 0) voidVolumeM3 = 0; 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 x = st.x, xLeft = x - CONFIG.studWidth / 2, xRight = x + CONFIG.studWidth / 2;
const yBottom = st.y0; const yBottom = st.y0;
const kind = st.kind === 'kreupel' ? 'kreupele stijl' : 'verticale balk'; 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) { if (st.y1 !== null) {
pieces.push({ pieces.push({
length: st.y1 - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x, length: st.y1 - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + st.y1) / 2, 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; return;
} }
@@ -1486,16 +1479,24 @@ window.WallModel = (() => {
const yTopLeft = topPts[0].y, yTopRight = topPts[topPts.length - 1].y; const yTopLeft = topPts[0].y, yTopRight = topPts[topPts.length - 1].y;
pieces.push({ pieces.push({
length: Math.max(...topPts.map(p => p.y)) - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x, length: Math.max(...topPts.map(p => p.y)) - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + yBottom + yTopLeft + yTopRight) / 4, y: (yBottomLeft + yBottomRight + yTopLeft + yTopRight) / 4,
yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft, yTopRight, yBottomLeft, yBottomRight, yTopLeft, yTopRight,
outline: [{ x: xLeft, y: yBottom }, { x: xRight, y: yBottom }, ...topPts.slice().reverse()], outline: [{ x: xLeft, y: yBottomLeft }, { x: xRight, y: yBottomRight }, ...topPts.slice().reverse()],
cutAngles: [0].concat(topPts.slice(1).map((q, k) => cutDeg(q.y - topPts[k].y, q.x - topPts[k].x))) cutAngles: [botCut].concat(topPts.slice(1).map((q, k) => cutDeg(q.y - topPts[k].y, q.x - topPts[k].x)))
}); });
}); });
CONFIG.openings.forEach(op => { CONFIG.openings.forEach(op => {
const opXWorld = startX + op.x; 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) { 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] }); 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') { 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`); 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]) { 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})`); throw new Error(`opening #${i + 1}: type moet ${Object.keys(OPENING_TYPES).join('/')} zijn (was: ${o.type})`);
} }
@@ -1584,7 +1588,7 @@ window.WallModel = (() => {
return { 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, 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, validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs createMaterials, build3D, computeSpecs
}; };
+5 -4
View File
@@ -154,16 +154,17 @@ window.WallPdf = (() => {
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
CONFIG.openings.forEach(op => { CONFIG.openings.forEach(op => {
const opXWorld = -wallWidth / 2 + op.x; 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); // Schuine bovenzijde: y0 = bovenkant links, y0r = rechts.
doc.rect(x0, y0, x1 - x0, y1 - y0, 'S'); 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, y0, x1, y1);
doc.line(x0, y1, x1, y0); doc.line(x0, y1, x1, y0r);
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
doc.setFontSize(6); doc.setFontSize(6);
// Dagmaat-label BINNEN de opening zetten (net onder de bovenkant), // Dagmaat-label BINNEN de opening zetten (net onder de bovenkant),
// i.p.v. erboven — daar overlapte het met de maatketen/omtrek // i.p.v. erboven — daar overlapte het met de maatketen/omtrek
// erboven. // 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); const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2);
+107 -33
View File
@@ -280,7 +280,9 @@ function mountWallEditor(container, callbacks) {
let wallGroup3D = null; let wallGroup3D = null;
let lastBaseHeight = 2400; 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: // 'offset' betekent afhankelijk van anchor:
// left -> afstand vanaf linker wandrand tot linkerkant opening // left -> afstand vanaf linker wandrand tot linkerkant opening
// right -> afstand vanaf rechter wandrand tot rechterkant 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 // Interactieve selectie/verslepen van openingen in het vooraanzicht
// (nu het primaire aanzicht). Snap op 50mm, net als de stapgrootte van // (nu het primaire aanzicht). Snap op 50mm, net als de stapgrootte van
// de invoervelden. N/Z-handles wijzigen alleen de hoogte, O/W-handles // de invoervelden. 9 sleeppunten: 4 hoeken, 4 zijden en het midden
// alleen de breedte — precies zoals bij de meeste 2D-tekentools. // (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 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 frontView = { scale: 1, centerX: 0, floorY: 0 }; // bijgewerkt door draw2DFront()
let frontOpeningRects = []; // hit-test data, bijgewerkt door draw2DFront() let frontOpeningRects = []; // hit-test data, bijgewerkt door draw2DFront()
let frontHandleRects = []; // idem, alleen voor de geselecteerde opening let frontHandleRects = []; // idem, alleen voor de geselecteerde opening
@@ -315,10 +320,17 @@ function mountWallEditor(container, callbacks) {
op.w = w; 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) { function syncOpeningCardInputs(op) {
const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`); const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`);
if (!card) return; 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}"]`); const input = card.querySelector(`input[data-field="${field}"]`);
if (input && document.activeElement !== input) input.value = Math.round(op[field]); if (input && document.activeElement !== input) input.value = Math.round(op[field]);
}); });
@@ -429,6 +441,8 @@ function mountWallEditor(container, callbacks) {
hRightInput.value = tmp; hRightInput.value = tmp;
openings = openings.map(o => { 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 === 'left') return { ...o, anchor: 'right' };
if (o.anchor === 'right') return { ...o, anchor: 'left' }; if (o.anchor === 'right') return { ...o, anchor: 'left' };
if (o.anchor === 'middle') return { ...o, offset: -o.offset }; if (o.anchor === 'middle') return { ...o, offset: -o.offset };
@@ -463,9 +477,13 @@ function mountWallEditor(container, callbacks) {
<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><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>
<div class="control-group"> <div class="control-group">
<label>Hoogte</label> <label>${isSloped(op) ? 'Hoogte links' : 'Hoogte'}</label>
<div><input type="number" data-field="h" data-id="${op.id}" value="${op.h}" step="50" min="400"><span class="unit">mm</span></div> <div><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>
${isSloped(op) ? `<div class="control-group">
<label>Hoogte rechts</label>
<div><input type="number" data-field="hR" data-id="${op.id}" value="${op.hR}" step="50" min="400"><span class="unit">mm</span></div>
</div>` : ''}
<div class="control-group"> <div class="control-group">
<label>Afstand tot vloer</label> <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><input type="number" data-field="y" data-id="${op.id}" value="${op.y}" step="50" min="0"><span class="unit">mm</span></div>
@@ -486,6 +504,11 @@ function mountWallEditor(container, callbacks) {
<label>${anchorOffsetLabel(op.anchor)}</label> <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><input type="number" data-field="offset" data-id="${op.id}" value="${op.offset}" step="50"><span class="unit">mm</span></div>
</div> </div>
<div class="control-group">
<label>Schuine bovenzijde</label>
<div><label class="ui-switch"><input type="checkbox" data-field="sloped" data-id="${op.id}" ${isSloped(op) ? 'checked' : ''}><span></span></label></div>
</div>
${isSloped(op) ? `<button class="opening-roof-btn" data-action="follow-roof" data-id="${op.id}" title="Hoogte rechts zo zetten dat de bovenkant evenwijdig aan de daklijn loopt"><i class="bi bi-slash-lg"></i> Volg dakhelling</button>` : ''}
</div> </div>
</div> </div>
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div> <div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
@@ -519,6 +542,10 @@ function mountWallEditor(container, callbacks) {
const preset = WallModel.OPENING_TYPES[e.target.value]; const preset = WallModel.OPENING_TYPES[e.target.value];
op.type = e.target.value; op.type = e.target.value;
op.w = preset.w; op.h = preset.h; op.y = preset.y; op.w = preset.w; op.h = preset.h; op.y = preset.y;
delete op.hR;
renderOpeningsList();
} else if (field === 'sloped') {
if (e.target.checked) op.hR = op.h; else delete op.hR;
renderOpeningsList(); renderOpeningsList();
} else if (field === 'anchor') { } else if (field === 'anchor') {
op.anchor = e.target.value; op.anchor = e.target.value;
@@ -532,9 +559,24 @@ function mountWallEditor(container, callbacks) {
if (e.target.dataset.action === 'remove') { if (e.target.dataset.action === 'remove') {
removeOpening(parseInt(e.target.dataset.id, 10)); removeOpening(parseInt(e.target.dataset.id, 10));
} }
const roofBtn = e.target.closest('[data-action="follow-roof"]');
if (roofBtn) followRoof(parseInt(roofBtn.dataset.id, 10));
}); });
document.getElementById('btn-add-opening').addEventListener('click', addOpening); 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);
if (!op || !res) return;
const at = WallModel.topLine(CONFIG).at, x0 = -CONFIG.wallWidth / 2 + res.x;
op.hR = Math.max(400, Math.round(op.h + at(x0 + res.w) - at(x0)));
syncOpeningCardInputs(op);
updateAll();
}
// ============================================================ // ============================================================
// JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters + // JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters +
// ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug. // ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug.
@@ -569,7 +611,7 @@ function mountWallEditor(container, callbacks) {
return { return {
wall: collectWallParams(), wall: collectWallParams(),
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone }, materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone },
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 })) openings: openings.map(rawOpening)
}; };
} }
@@ -596,7 +638,7 @@ function mountWallEditor(container, callbacks) {
stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen) stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen)
syncMaterialButtons(); 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 })); openings = state.openings.map(rawOpening);
openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1; openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1;
selectedOpeningId = null; selectedOpeningId = null;
@@ -980,39 +1022,53 @@ function mountWallEditor(container, callbacks) {
const x0 = toCanvasX(opLeftMM); const x0 = toCanvasX(opLeftMM);
const x1 = toCanvasX(opLeftMM + op.w); const x1 = toCanvasX(opLeftMM + op.w);
const y0 = toCanvasY(op.y); const y0 = toCanvasY(op.y);
const y1 = toCanvasY(op.y + op.h); const y1 = toCanvasY(op.y + op.h); // bovenkant links
const y1r = toCanvasY(op.y + op.hR); // bovenkant rechts (schuine bovenzijde)
const isSelected = op.id === selectedOpeningId; 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 }); frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, y1r, localX: op.x, y: op.y, w: op.w, h: op.h, hR: op.hR });
const outline = () => {
ctxFront.beginPath();
ctxFront.moveTo(x0, y0); ctxFront.lineTo(x1, y0); ctxFront.lineTo(x1, y1r); ctxFront.lineTo(x0, y1);
ctxFront.closePath();
};
outline();
ctxFront.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)'; ctxFront.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)';
ctxFront.fillRect(x0, y1, x1 - x0, y0 - y1); ctxFront.fill();
ctxFront.strokeStyle = isSelected ? '#4da6ff' : '#ff3333'; ctxFront.strokeStyle = isSelected ? '#4da6ff' : '#ff3333';
ctxFront.lineWidth = isSelected ? 2.5 : 1; ctxFront.lineWidth = isSelected ? 2.5 : 1;
ctxFront.setLineDash(isSelected ? [] : [3, 3]); ctxFront.setLineDash(isSelected ? [] : [3, 3]);
ctxFront.strokeRect(x0, y1, x1 - x0, y0 - y1); ctxFront.stroke();
ctxFront.setLineDash([]); ctxFront.setLineDash([]);
ctxFront.lineWidth = 1; ctxFront.lineWidth = 1;
ctxFront.fillStyle = '#fff'; ctxFront.fillStyle = '#fff';
ctxFront.font = '9px sans-serif'; ctxFront.font = '9px sans-serif';
ctxFront.textAlign = 'center'; ctxFront.textAlign = 'center';
ctxFront.fillText('#' + op.id, (x0 + x1) / 2, y1 - 4); ctxFront.fillText('#' + op.id, (x0 + x1) / 2, (y1 + y1r) / 2 - 4);
if (isSelected) { if (isSelected) {
const midX = (x0 + x1) / 2, midY = (y0 + y1) / 2; const midX = (x0 + x1) / 2, midTop = (y1 + y1r) / 2;
const handles = [ const handles = [
{ handle: 'n', cx: midX, cy: y1 }, { handle: 'nw', cx: x0, cy: y1 },
{ handle: 'n', cx: midX, cy: midTop },
{ handle: 'ne', cx: x1, cy: y1r },
{ handle: 'w', cx: x0, cy: (y0 + y1) / 2 },
{ handle: 'c', cx: midX, cy: (y0 + midTop) / 2 },
{ handle: 'e', cx: x1, cy: (y0 + y1r) / 2 },
{ handle: 'sw', cx: x0, cy: y0 },
{ handle: 's', cx: midX, cy: y0 }, { handle: 's', cx: midX, cy: y0 },
{ handle: 'e', cx: x1, cy: midY }, { handle: 'se', cx: x1, cy: y0 }
{ handle: 'w', cx: x0, cy: midY }
]; ];
handles.forEach(hd => { handles.forEach(hd => {
frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy }); frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy });
ctxFront.fillStyle = '#4da6ff'; ctxFront.fillStyle = hd.handle === 'c' ? '#ffffff' : '#4da6ff';
ctxFront.strokeStyle = '#0a1a2a'; ctxFront.strokeStyle = '#0a1a2a';
ctxFront.beginPath(); ctxFront.beginPath();
ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10); // 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(); ctxFront.fill();
ctxFront.stroke(); ctxFront.stroke();
}); });
@@ -1062,9 +1118,12 @@ function mountWallEditor(container, callbacks) {
return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8); return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8);
} }
function frontOpeningAt(mx, my) { function frontOpeningAt(mx, my) {
return frontOpeningRects.find(r => // Binnen de (evt. schuine) omtrek: canvas-y loopt omlaag.
mx >= Math.min(r.x0, r.x1) && mx <= Math.max(r.x0, r.x1) && return frontOpeningRects.find(r => {
my >= Math.min(r.y0, r.y1) && my <= Math.max(r.y0, r.y1)); if (mx < r.x0 || mx > r.x1) return false;
const yTop = r.y1 + (mx - r.x0) / ((r.x1 - r.x0) || 1) * (r.y1r - r.y1);
return my <= r.y0 && my >= yTop;
});
} }
canvas2DFront.classList.add('interactive'); canvas2DFront.classList.add('interactive');
@@ -1077,9 +1136,9 @@ function mountWallEditor(container, callbacks) {
if (hit) { if (hit) {
const resolved = frontOpeningRects.find(r => r.id === hit.id); const resolved = frontOpeningRects.find(r => r.id === hit.id);
dragInfo = { dragInfo = {
type: 'resize', handle: hit.handle, openingId: hit.id, type: hit.handle === 'c' ? 'move' : 'resize', handle: hit.handle, openingId: hit.id,
startMouseX: e.clientX, startMouseY: e.clientY, startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h, startHR: resolved.hR
}; };
e.preventDefault(); e.preventDefault();
return; return;
@@ -1092,7 +1151,7 @@ function mountWallEditor(container, callbacks) {
dragInfo = { dragInfo = {
type: 'move', openingId: bodyHit.id, type: 'move', openingId: bodyHit.id,
startMouseX: e.clientX, startMouseY: e.clientY, startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h, startHR: bodyHit.hR
}; };
highlightSelectedCard(); highlightSelectedCard();
draw2D(); draw2D();
@@ -1121,17 +1180,32 @@ function mountWallEditor(container, callbacks) {
applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth); applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth);
op.y = Math.max(0, snap50(dragInfo.startY + dyMM)); op.y = Math.max(0, snap50(dragInfo.startY + dyMM));
} else if (dragInfo.type === 'resize') { } else if (dragInfo.type === 'resize') {
if (dragInfo.handle === 'n') { // Samengestelde handles: 'nw' = bovenkant links + linkerrand, enz.
op.h = Math.max(400, snap50(dragInfo.startH + dyMM)); const hd = dragInfo.handle;
} else if (dragInfo.handle === 's') { const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op);
const top = dragInfo.startY + dragInfo.startH; if (hd === 'n') {
const newY = Math.min(snap50(dragInfo.startY + dyMM), top - 400); // 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 (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); op.y = Math.max(0, newY);
op.h = top - op.y; op.h = topL - op.y;
} else if (dragInfo.handle === 'e') { if (isSloped(op)) op.hR = topR - op.y;
}
if (hd === 'e' || hd === 'ne' || hd === 'se') {
const newW = Math.max(400, snap50(dragInfo.startW + dxMM)); const newW = Math.max(400, snap50(dragInfo.startW + dxMM));
applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth); applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth);
} else if (dragInfo.handle === 'w') { } else if (hd === 'w' || hd === 'nw' || hd === 'sw') {
const right = dragInfo.startLocalX + dragInfo.startW; const right = dragInfo.startLocalX + dragInfo.startW;
const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400); const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400);
applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth); applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth);