Configurator voor Van Emous Mantelzorgwoningen: wanden, vloeren, units, woningen (L-vorm), plat dak met dakrand, buitenverlichting, latwerk op vochtdoek, trasraam, sandwichpanelen en PDF-tekenbladen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
347 lines
15 KiB
JavaScript
347 lines
15 KiB
JavaScript
/*!
|
|
* Tectum
|
|
* Copyright © 2026 Machiel Heinen.
|
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
* Zie LICENTIE.md.
|
|
*/
|
|
// ============================================================
|
|
// core/geometry.js — gedeelde, pure math. Geen DOM, geen THREE-afhankelijkheid
|
|
// behalve shapeAreaWithHoles (die werkt met elk object dat .getPoints()/.holes
|
|
// heeft, dus ook met THREE.Shape). Plain <script> (geen module), zodat dit
|
|
// ook werkt bij rechtstreeks openen van index.html (file://) — daar breken
|
|
// ES-module-imports door browser-CORS.
|
|
//
|
|
// Wordt geladen VOOR wall.js en floor.js: de functies hieronder zijn dus
|
|
// gewoon globaal beschikbaar in beide, zonder duplicatie.
|
|
// ============================================================
|
|
|
|
// Regelwerk-verdeling (h.o.h.): bepaalt hoeveel balken/stijlen er in een
|
|
// gegeven overspanning passen, gegeven een minimale/maximale hart-op-hart-
|
|
// afstand. Gebruikt door wand-stijlen EN vloerbalken (zelfde algoritme).
|
|
function calculateSegmentPositions(spanWidth, minSp, maxSp) {
|
|
if (spanWidth <= 0) return [];
|
|
let numBays = Math.ceil(spanWidth / maxSp);
|
|
let spacing = spanWidth / numBays;
|
|
|
|
if (spacing < minSp && numBays > 1) {
|
|
numBays = Math.floor(spanWidth / minSp);
|
|
spacing = spanWidth / numBays;
|
|
}
|
|
|
|
const positions = [];
|
|
for (let i = 1; i < numBays; i++) {
|
|
positions.push(i * spacing);
|
|
}
|
|
return positions;
|
|
}
|
|
|
|
// Oppervlakte van een simpele polygon (shoelace-formule).
|
|
function polygonArea(points) {
|
|
let area = 0;
|
|
for (let i = 0; i < points.length; i++) {
|
|
const p1 = points[i];
|
|
const p2 = points[(i + 1) % points.length];
|
|
area += p1.x * p2.y - p2.x * p1.y;
|
|
}
|
|
return Math.abs(area) / 2;
|
|
}
|
|
|
|
// Sutherland-Hodgman clip van een (convexe) polygon tegen een horizontale
|
|
// halfvlak-grens. Gebruikt om bijv. de wand-omtrek (het schuine trapezium) te
|
|
// knippen tot losse horizontale banen voor het vochtdoek.
|
|
function clipPolygonYLE(points, yLimit) {
|
|
// Houdt het deel met y <= yLimit
|
|
const result = [];
|
|
for (let i = 0; i < points.length; i++) {
|
|
const cur = points[i];
|
|
const prev = points[(i - 1 + points.length) % points.length];
|
|
const curIn = cur.y <= yLimit + 1e-12;
|
|
const prevIn = prev.y <= yLimit + 1e-12;
|
|
if (curIn) {
|
|
if (!prevIn) {
|
|
const t = (yLimit - prev.y) / (cur.y - prev.y);
|
|
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
|
|
}
|
|
result.push(cur);
|
|
} else if (prevIn) {
|
|
const t = (yLimit - prev.y) / (cur.y - prev.y);
|
|
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
function clipPolygonYGE(points, yLimit) {
|
|
// Houdt het deel met y >= yLimit
|
|
const result = [];
|
|
for (let i = 0; i < points.length; i++) {
|
|
const cur = points[i];
|
|
const prev = points[(i - 1 + points.length) % points.length];
|
|
const curIn = cur.y >= yLimit - 1e-12;
|
|
const prevIn = prev.y >= yLimit - 1e-12;
|
|
if (curIn) {
|
|
if (!prevIn) {
|
|
const t = (yLimit - prev.y) / (cur.y - prev.y);
|
|
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
|
|
}
|
|
result.push(cur);
|
|
} else if (prevIn) {
|
|
const t = (yLimit - prev.y) / (cur.y - prev.y);
|
|
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Oppervlakte van een THREE.Shape inclusief aftrek van zijn holes.
|
|
function shapeAreaWithHoles(shape) {
|
|
let area = polygonArea(shape.getPoints());
|
|
(shape.holes || []).forEach(hole => { area -= polygonArea(hole.getPoints()); });
|
|
return area;
|
|
}
|
|
|
|
// Simpele euclidische afstand — gebruikt door floor.js voor de
|
|
// cirkel-overlapcheck (2D, dus geen 1D-ankerlogica zoals bij wand-openingen).
|
|
function dist2D(a, b) {
|
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
}
|
|
|
|
// Zaagplan: verdeelt stukken (lengtes in mm) over handelslengtes met zo min
|
|
// mogelijk snijverlies. Stukken langer dan de langste handelslengte worden
|
|
// eerst opgeknipt (las/stuik in het werk). Daarna best-fit-decreasing: elk
|
|
// stuk gaat in de staaf waar het het krapst past; tot slot krijgt elke staaf
|
|
// de kortste handelslengte waar zijn stukken nog in passen.
|
|
// Resultaat: [{ stock, cuts: [lengtes], used, waste }]. Zaagsnede-breedte
|
|
// wordt verwaarloosd.
|
|
function planStockCutting(pieceLengths, stockLengths) {
|
|
const stocks = stockLengths.slice().sort((a, b) => a - b);
|
|
const maxStock = stocks[stocks.length - 1];
|
|
|
|
const parts = [];
|
|
pieceLengths.forEach(len => {
|
|
let rest = Math.round(len);
|
|
while (rest > maxStock) { parts.push(maxStock); rest -= maxStock; }
|
|
if (rest > 0) parts.push(rest);
|
|
});
|
|
parts.sort((a, b) => b - a);
|
|
|
|
const bars = [];
|
|
parts.forEach(p => {
|
|
let best = null, bestRest = Infinity;
|
|
bars.forEach(bar => {
|
|
const r = maxStock - bar.used - p;
|
|
if (r >= 0 && r < bestRest) { best = bar; bestRest = r; }
|
|
});
|
|
if (!best) { best = { used: 0, cuts: [] }; bars.push(best); }
|
|
best.cuts.push(p);
|
|
best.used += p;
|
|
});
|
|
bars.forEach(bar => {
|
|
bar.stock = stocks.find(s => s >= bar.used);
|
|
bar.waste = bar.stock - bar.used;
|
|
});
|
|
return bars;
|
|
}
|
|
|
|
// Samenvatting van een zaagplan: aantal staven per handelslengte + totalen.
|
|
function summarizeStockPlan(bars) {
|
|
const byStock = new Map();
|
|
bars.forEach(b => byStock.set(b.stock, (byStock.get(b.stock) || 0) + 1));
|
|
return {
|
|
perStock: Array.from(byStock.entries()).sort((a, b) => a[0] - b[0]).map(([stock, count]) => ({ stock, count })),
|
|
stockMM: bars.reduce((a, b) => a + b.stock, 0),
|
|
wasteMM: bars.reduce((a, b) => a + b.waste, 0)
|
|
};
|
|
}
|
|
|
|
// 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.
|
|
// 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).
|
|
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 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])]);
|
|
|
|
// 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;
|
|
|
|
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
|
|
|
|
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);
|
|
};
|
|
|
|
// 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);
|
|
};
|
|
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]);
|
|
}
|
|
}
|
|
|
|
// Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg).
|
|
const loops = [];
|
|
edges.forEach((list, startKey) => {
|
|
while (list.length) {
|
|
const loop = [];
|
|
let cur = startKey.split(',').map(Number);
|
|
let next = list.pop();
|
|
loop.push(cur);
|
|
while (next.join(',') !== startKey) {
|
|
loop.push(next);
|
|
cur = next;
|
|
const outs = edges.get(cur.join(','));
|
|
next = outs.pop();
|
|
}
|
|
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; });
|
|
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;
|
|
});
|
|
});
|
|
|
|
const signedArea = (pts) => pts.reduce((s, p, k) => {
|
|
const q = pts[(k + 1) % pts.length];
|
|
return s + (p.x * q.y - q.x * p.y);
|
|
}, 0) / 2;
|
|
const inside = (pt, poly) => {
|
|
let c = false;
|
|
for (let a = 0, b = poly.length - 1; a < poly.length; b = a++) {
|
|
if ((poly[a].y > pt.y) !== (poly[b].y > pt.y) &&
|
|
pt.x < (poly[b].x - poly[a].x) * (pt.y - poly[a].y) / (poly[b].y - poly[a].y) + poly[a].x) c = !c;
|
|
}
|
|
return c;
|
|
};
|
|
const outers = polys.filter(p => p.length >= 3 && signedArea(p) > 1e-12).map(p => ({ outer: p, holes: [] }));
|
|
polys.filter(p => p.length >= 3 && signedArea(p) < -1e-12).forEach(hole => {
|
|
const owner = outers.find(o => inside(hole[0], o.outer)) || outers[0];
|
|
if (owner) owner.holes.push(hole);
|
|
});
|
|
return outers;
|
|
}
|
|
|
|
// Omtrek van de vereniging van as-evenwijdige rechthoeken ({x0, x1, z0, z1}),
|
|
// als gesloten lussen van punten {x, z}. Buitenlussen zijn tegen de klok in
|
|
// (positieve oppervlakte in het x/z-vlak), gaten met de klok mee. Collineaire
|
|
// tussenpunten zijn verwijderd. Gesorteerd op oppervlakte (grootste eerst).
|
|
// Gebruikt voor de dakrand (boeidelen/daktrim) over meerdere units heen.
|
|
function rectUnionLoops(rects) {
|
|
const eps = 1e-6;
|
|
const uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps);
|
|
const xs = uniq(rects.flatMap(r => [r.x0, r.x1]));
|
|
const zs = uniq(rects.flatMap(r => [r.z0, r.z1]));
|
|
const nx = xs.length - 1, nz = zs.length - 1;
|
|
const filled = (i, j) => {
|
|
if (i < 0 || j < 0 || i >= nx || j >= nz) return false;
|
|
const xm = (xs[i] + xs[i + 1]) / 2, zm = (zs[j] + zs[j + 1]) / 2;
|
|
return rects.some(r => xm > r.x0 && xm < r.x1 && zm > r.z0 && zm < r.z1);
|
|
};
|
|
const edges = new Map();
|
|
const add = (a, b) => { const k = a.join(','); if (!edges.has(k)) edges.set(k, []); edges.get(k).push(b); };
|
|
for (let i = 0; i < nx; i++) {
|
|
for (let j = 0; j < nz; j++) {
|
|
if (!filled(i, j)) continue;
|
|
if (!filled(i, j - 1)) add([i, j], [i + 1, j]);
|
|
if (!filled(i + 1, j)) add([i + 1, j], [i + 1, j + 1]);
|
|
if (!filled(i, j + 1)) add([i + 1, j + 1], [i, j + 1]);
|
|
if (!filled(i - 1, j)) add([i, j + 1], [i, j]);
|
|
}
|
|
}
|
|
const loops = [];
|
|
edges.forEach((list, startKey) => {
|
|
while (list.length) {
|
|
const loop = [startKey.split(',').map(Number)];
|
|
let next = list.pop();
|
|
while (next.join(',') !== startKey) {
|
|
loop.push(next);
|
|
next = edges.get(next.join(',')).pop();
|
|
}
|
|
const pts = loop.map(([i, j]) => ({ x: xs[i], z: zs[j] }));
|
|
loops.push(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.z - a.z) - (p.z - a.z) * (b.x - a.x)) > eps;
|
|
}));
|
|
}
|
|
});
|
|
const area = (pts) => pts.reduce((s, p, k) => { const q = pts[(k + 1) % pts.length]; return s + p.x * q.z - q.x * p.z; }, 0) / 2;
|
|
return loops.map(pts => ({ pts, area: area(pts) })).sort((a, b) => Math.abs(b.area) - Math.abs(a.area));
|
|
}
|
|
|
|
// Buitenwaartse normaal (as-evenwijdig, lengte 1) van ribbe a -> b van een
|
|
// lus tegen de klok in (positieve oppervlakte in x/z).
|
|
function rectilinearNormal(a, b) {
|
|
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
|
|
return { x: dz / len, z: -dx / len };
|
|
}
|
|
|
|
// Verschuift een rechthoekige (as-evenwijdige) lus tegen de klok in over
|
|
// afstand d naar buiten. Collineaire tussenpunten (bijv. een unit-hoek midden
|
|
// in een rechte gevel) blijven behouden en schuiven loodrecht mee.
|
|
function offsetRectilinearLoop(pts, d) {
|
|
const n = pts.length;
|
|
return pts.map((p, k) => {
|
|
const prev = pts[(k - 1 + n) % n], next = pts[(k + 1) % n];
|
|
const n1 = rectilinearNormal(prev, p), n2 = rectilinearNormal(p, next);
|
|
const same = Math.abs(n1.x - n2.x) < 1e-9 && Math.abs(n1.z - n2.z) < 1e-9;
|
|
return same ? { x: p.x + n1.x * d, z: p.z + n1.z * d } : { x: p.x + (n1.x + n2.x) * d, z: p.z + (n1.z + n2.z) * d };
|
|
});
|
|
}
|
|
|
|
// Gevels: groepeert lijnstukken in de plattegrond tot doorlopende gevels
|
|
// (zelfde as + normaal + gevelvlak, aansluitend). segs: [{ axis ('x'|'z'),
|
|
// normal {x, z}, line (positie op de andere as), s0, s1 (s0 < s1) }, ...].
|
|
// Geeft [{ axis, normal, line, s0, s1, segs (op s0 gesorteerd) }].
|
|
// Gebruikt door de woning (latwerk/doek-patroon) en de buitenverlichting.
|
|
function groupFacadeRuns(segs, opts) {
|
|
const lineTol = (opts && opts.lineTol) || 1, gapTol = (opts && opts.gapTol) || 5;
|
|
const runs = [];
|
|
segs.slice().sort((a, b) => a.s0 - b.s0).forEach(sg => {
|
|
const run = runs.find(r => r.axis === sg.axis && Math.abs(r.normal.x - sg.normal.x) < 1e-6 && Math.abs(r.normal.z - sg.normal.z) < 1e-6 &&
|
|
Math.abs(r.line - sg.line) < lineTol && Math.abs(r.s1 - sg.s0) < gapTol);
|
|
if (run) { run.segs.push(sg); run.s1 = Math.max(run.s1, sg.s1); }
|
|
else runs.push(Object.assign({}, sg, { s0: sg.s0, s1: sg.s1, segs: [sg] }));
|
|
});
|
|
return runs;
|
|
}
|