Tectum v0.4.0 — mijlpaal gevelopbouw
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>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/camera-memory.js — onthoudt de 3D-camerastand (positie + draaipunt)
|
||||
// per weergave, zodat wisselen tussen onderdelen/tabbladen of een her-mount
|
||||
// na een wijziging niet terugspringt naar de standaardcamera.
|
||||
// Sleutels komen van de aanroeper (bijv. "tab3:wall:A:voor"). Alleen in het
|
||||
// geheugen van deze sessie.
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
|
||||
window.CameraMemory = (() => {
|
||||
const store = new Map();
|
||||
|
||||
function save(key, camera, controls) {
|
||||
if (!key) return;
|
||||
store.set(key, { position: camera.position.toArray(), target: controls.target.toArray() });
|
||||
}
|
||||
|
||||
// true als er een opgeslagen stand was (en die is toegepast).
|
||||
function restore(key, camera, controls) {
|
||||
const m = key && store.get(key);
|
||||
if (!m) return false;
|
||||
camera.position.fromArray(m.position);
|
||||
controls.target.fromArray(m.target);
|
||||
controls.update();
|
||||
return true;
|
||||
}
|
||||
|
||||
return { save, restore };
|
||||
})();
|
||||
@@ -0,0 +1,41 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/canvas-labels.js — gedeelde stijl voor maat-labels in de 2D-canvassen
|
||||
// (blauwe maatlijn + "X: 6000mm"-tekst, zoals het wand-vooraanzicht). Gebruikt
|
||||
// door wand, vloer en unit, zodat alle labels er hetzelfde uitzien.
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
|
||||
const CANVAS_LABEL_COLOR = '#4da6ff';
|
||||
|
||||
// Tekent een maatlijn van (x1,y1) naar (x2,y2) plus tekst op (textX,textY).
|
||||
// Een verticale lijn (x1 === x2) krijgt de tekst 90° gedraaid.
|
||||
function drawCanvasDim(ctx, x1, y1, x2, y2, text, textX, textY) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = CANVAS_LABEL_COLOR;
|
||||
ctx.fillStyle = CANVAS_LABEL_COLOR;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
|
||||
if (Math.abs(x1 - x2) < 0.5 && Math.abs(y1 - y2) >= 0.5) {
|
||||
ctx.translate(textX, textY);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText(text, 0, 0);
|
||||
} else {
|
||||
ctx.fillText(text, textX, textY);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/*!
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/license.js — de licentietekst voor "Over deze software". Kopie van
|
||||
// LICENTIE.md (dat bestand is leidend; bij een wijziging beide aanpassen).
|
||||
// Als JS-string opgenomen omdat fetch() van lokale bestanden via file://
|
||||
// niet betrouwbaar werkt.
|
||||
// ============================================================
|
||||
|
||||
window.TECTUM_LICENSE_MD = `# Licentie en auteursrecht
|
||||
|
||||
Copyright © 2026 Machiel Heinen. Alle rechten voorbehouden.
|
||||
|
||||
## 1. Rechthebbende
|
||||
|
||||
De software Tectum, inclusief broncode, objectcode, ontwerp, rekenlogica,
|
||||
documentatie, afbeeldingen en alle bijbehorende bestanden (hierna: "de Software"), is
|
||||
auteursrechtelijk beschermd. Alle auteursrechten en overige intellectuele-eigendomsrechten
|
||||
op de Software berusten uitsluitend bij Machiel Heinen, maker van de Software (hierna:
|
||||
"de Rechthebbende").
|
||||
|
||||
De Software is ontwikkeld in opdracht van Van Emous Mantelzorgwoningen, gevestigd te
|
||||
Putten (KvK 91908671). Deze opdracht houdt geen overdracht van rechten in; gebruik door
|
||||
Van Emous Mantelzorgwoningen vindt plaats op basis van toestemming van de Rechthebbende.
|
||||
|
||||
## 2. Gebruik uitsluitend met toestemming
|
||||
|
||||
Zonder voorafgaande, uitdrukkelijke en schriftelijke toestemming van de Rechthebbende
|
||||
is het niet toegestaan de Software geheel of gedeeltelijk te verveelvoudigen, openbaar te
|
||||
maken, te wijzigen, te bewerken, te vertalen, te decompileren, te verspreiden, te verhuren,
|
||||
in sublicentie te geven of op enige andere wijze te gebruiken, behoudens voor zover de wet
|
||||
dit dwingend toestaat.
|
||||
|
||||
## 3. Aard van de toestemming
|
||||
|
||||
Toestemming wordt uitsluitend schriftelijk verleend door de Rechthebbende. Tenzij
|
||||
schriftelijk anders overeengekomen, is toestemming niet-exclusief, niet-overdraagbaar,
|
||||
niet sublicentieerbaar, beperkt tot het daarin omschreven doel en de daarin omschreven
|
||||
duur, en kan zij bij overtreding van de voorwaarden met onmiddellijke ingang worden
|
||||
ingetrokken. Toestemming houdt nimmer een overdracht van rechten in.
|
||||
|
||||
## 4. Aanpassingen en afgeleide werken
|
||||
|
||||
Alle rechten op aanpassingen, uitbreidingen en afgeleide werken van de Software, ongeacht
|
||||
door wie deze zijn gemaakt of in wiens opdracht, berusten bij de Rechthebbende.
|
||||
|
||||
## 5. Geen garantie
|
||||
|
||||
De Software wordt ter beschikking gesteld "zoals deze is" (as is), zonder enige garantie,
|
||||
uitdrukkelijk of stilzwijgend, waaronder begrepen garanties omtrent werking,
|
||||
foutloosheid, geschiktheid voor een bepaald doel, nauwkeurigheid van berekeningen,
|
||||
maatvoering of prijzen, en het niet inbreuk maken op rechten van derden. Uitkomsten van
|
||||
de Software zijn indicatief en vervangen geen constructieve berekening of controle door
|
||||
een deskundige.
|
||||
|
||||
## 6. Aansprakelijkheid
|
||||
|
||||
Voor zover wettelijk toegestaan, is de Rechthebbende niet aansprakelijk voor enige
|
||||
directe of indirecte schade voortvloeiend uit of verband houdend met het gebruik van, of
|
||||
het niet kunnen gebruiken van, de Software, behoudens opzet of bewuste roekeloosheid van
|
||||
de Rechthebbende.
|
||||
|
||||
## 7. Vermeldingen
|
||||
|
||||
Het is niet toegestaan copyright-, licentie- of eigendomsvermeldingen in of op de
|
||||
Software te verwijderen, te wijzigen of onleesbaar te maken.
|
||||
|
||||
## 8. Toepasselijk recht en bevoegde rechter
|
||||
|
||||
Op deze licentie en op alle toestemmingen die op grond daarvan worden verleend is
|
||||
Nederlands recht van toepassing. Geschillen worden uitsluitend voorgelegd aan de
|
||||
bevoegde rechter van de Rechtbank Gelderland.
|
||||
|
||||
Contact en aanvragen voor toestemming: machiel@start-it.nl
|
||||
`;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,134 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/pdf-sheet.js — gedeelde bouwstenen voor de A3-tekenbladen (jsPDF):
|
||||
// kleuren, nieuw document, maatlijn en titelblok. Gebruikt door WallPdf,
|
||||
// FloorPdf en de unit-PDF, zodat elk blad er identiek uitziet.
|
||||
// Plain script (geen ES-module), geladen na jspdf.umd.min.js.
|
||||
// ============================================================
|
||||
|
||||
window.PdfSheet = (() => {
|
||||
// Kleurlogica: rood = houtwerk-omtrekken, blauw = hoofdmaatvoering,
|
||||
// zwart = opening-kruis, posnum-labels en de onderste maatketens.
|
||||
const COLORS = { RED: [190, 25, 25], BLUE: [25, 50, 180], BLACK: [0, 0, 0], GREY: [130, 130, 130] };
|
||||
|
||||
// Veldnamen van de "Project eigenschappen"-modal van de wand-/vloer-editor.
|
||||
const PROJECT_DOM_IDS = {
|
||||
naam: 'inp-proj-naam', plaats: 'inp-proj-plaats', straat: 'inp-proj-straat', nummer: 'inp-proj-nummer',
|
||||
tekenaar: 'inp-proj-tekenaar', titel: 'inp-proj-titel', element: 'inp-proj-element', opmerking: 'inp-proj-opmerking'
|
||||
};
|
||||
|
||||
function createDoc() {
|
||||
const { jsPDF } = window.jspdf;
|
||||
return new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a3' });
|
||||
}
|
||||
|
||||
function readProjectFromDom() {
|
||||
const project = {};
|
||||
Object.entries(PROJECT_DOM_IDS).forEach(([key, id]) => {
|
||||
const el = document.getElementById(id);
|
||||
project[key] = (el && el.value) || '';
|
||||
});
|
||||
return project;
|
||||
}
|
||||
|
||||
// Maatlijn met kleine loodrechte eindstreepjes ("|") op beide
|
||||
// uiteinden, zodat duidelijk is waar de maat precies begint/eindigt
|
||||
// i.p.v. een lijn die zonder markering in de lucht lijkt te eindigen.
|
||||
const DIM_TICK = 1.5;
|
||||
function dimLine(doc, x1, y1, x2, y2) {
|
||||
doc.line(x1, y1, x2, y2);
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const tx = (-dy / len) * (DIM_TICK / 2), ty = (dx / len) * (DIM_TICK / 2);
|
||||
doc.line(x1 - tx, y1 - ty, x1 + tx, y1 + ty);
|
||||
doc.line(x2 - tx, y2 - ty, x2 + tx, y2 + ty);
|
||||
}
|
||||
|
||||
// Titelblok (rechtsonder), in twee kolommen zodat het weinig hoogte
|
||||
// inneemt, met het logo (core/logo-data.js) rechtsonder.
|
||||
// project: { naam, plaats, straat, nummer, tekenaar, opmerking };
|
||||
// opts: { titel, element, blad } (blad bijv. "2 / 7").
|
||||
function titleBlock(doc, project, opts) {
|
||||
const { BLACK, GREY } = COLORS;
|
||||
const p = project || {};
|
||||
const o = opts || {};
|
||||
const tbW = 118, tbH = 36;
|
||||
const tbX0 = 290, tbY0 = 289 - tbH;
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setLineWidth(0.25);
|
||||
doc.rect(tbX0, tbY0, tbW, tbH, 'S');
|
||||
|
||||
const today = new Date().toLocaleDateString('nl-NL');
|
||||
const columns = [
|
||||
[['Projectnaam:', p.naam], ['Plaats:', p.plaats], ['Straat:', p.straat], ['Projectnummer:', p.nummer], ['Tekenaar:', p.tekenaar]],
|
||||
[['Titel:', o.titel], ['Element nummer:', o.element], ['Datum getekend:', today], ['Schaal:', '1:1'], ['Opmerking:', p.opmerking]]
|
||||
];
|
||||
const colW = tbW / 2, labelW = 19;
|
||||
doc.setFontSize(6.2);
|
||||
columns.forEach((rows, c) => {
|
||||
const x = tbX0 + 3 + c * colW;
|
||||
let rowY = tbY0 + 5;
|
||||
rows.forEach(([label, value]) => {
|
||||
doc.setTextColor(...GREY);
|
||||
doc.text(label, x, rowY);
|
||||
doc.setTextColor(...BLACK);
|
||||
const text = String(value || '-');
|
||||
doc.text(doc.splitTextToSize(text, colW - labelW - 5)[0], x + labelW, rowY);
|
||||
rowY += 4.2;
|
||||
});
|
||||
});
|
||||
|
||||
// Scheidingslijn + onderstrook: blad links, logo rechts.
|
||||
const stripY = tbY0 + 25;
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.line(tbX0, stripY, tbX0 + tbW, stripY);
|
||||
if (o.blad) {
|
||||
doc.setTextColor(...GREY);
|
||||
doc.setFontSize(6);
|
||||
doc.text('Blad ' + o.blad, tbX0 + 3, tbY0 + tbH - 3.5);
|
||||
}
|
||||
const logoH = 7.5, logoW = logoH * (window.TECTUM_LOGO ? window.TECTUM_LOGO.aspect : 4.55);
|
||||
if (window.TECTUM_LOGO) {
|
||||
doc.addImage(window.TECTUM_LOGO.png, 'PNG', tbX0 + tbW - 3 - logoW, tbY0 + tbH - 2 - logoH, logoW, logoH, 'tectum-logo', 'FAST'); // alias: 1x in het bestand, gecomprimeerd
|
||||
} else {
|
||||
doc.setTextColor(...GREY);
|
||||
doc.setFontSize(6);
|
||||
doc.text('Van Emous Mantelzorgwoningen', tbX0 + tbW - 3, tbY0 + tbH - 3.5, { align: 'right' });
|
||||
}
|
||||
}
|
||||
|
||||
// Eenvoudige tabel: headers + rijen (arrays van strings) vanaf (x, y) met
|
||||
// vaste kolom-x'en. Geeft de y-positie onder de tabel terug.
|
||||
function table(doc, x, y, colX, headers, rows, opts) {
|
||||
const { BLACK, GREY } = COLORS;
|
||||
const o = opts || {};
|
||||
const rowH = o.rowH || 4.3;
|
||||
doc.setTextColor(...BLACK);
|
||||
if (o.title) {
|
||||
doc.setFontSize(o.titleSize || 9);
|
||||
doc.text(o.title, x, y);
|
||||
y += 6;
|
||||
}
|
||||
doc.setFontSize(o.fontSize || 6.5);
|
||||
headers.forEach((h, i) => doc.text(h, x + colX[i], y));
|
||||
y += 1.5;
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.line(x, y, x + colX[colX.length - 1] + (o.lastColW || 14), y);
|
||||
y += 4;
|
||||
rows.forEach(r => {
|
||||
if (o.maxY && y > o.maxY) return;
|
||||
r.forEach((v, i) => doc.text(String(v), x + colX[i], y, i > 0 && o.alignRight ? { align: 'right' } : undefined));
|
||||
y += rowH;
|
||||
});
|
||||
return y;
|
||||
}
|
||||
|
||||
return { COLORS, createDoc, readProjectFromDom, dimLine, titleBlock, table };
|
||||
})();
|
||||
@@ -0,0 +1,163 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// js/core/pdf-writer.js — minimale, dependency-vrije PDF-generator.
|
||||
// Schrijft geldige PDF 1.4 bytes rechtstreeks (geen jsPDF: geen
|
||||
// netwerktoegang om dat te vendoren). Ondersteunt lijnen, rechthoeken,
|
||||
// tekst (standaard Helvetica, geen embedding nodig) en kleuren, over
|
||||
// meerdere pagina's. Coordinaten in PDF-punten (1pt = 1/72 inch),
|
||||
// oorsprong linksonder — precies zoals PDF dat zelf verwacht.
|
||||
//
|
||||
// Puur string/byte-opbouw, geen browser-specifieke API's nodig om het
|
||||
// DOCUMENT te bouwen (alleen het downloaden ervan is browser-specifiek,
|
||||
// zie savePdfBlob hieronder).
|
||||
// ============================================================
|
||||
|
||||
function createPdfDocument() {
|
||||
const objects = [];
|
||||
let nextObjId = 1;
|
||||
const pageDefs = [];
|
||||
let fontId, catalogId, pagesId;
|
||||
|
||||
function newObj(content) {
|
||||
const id = nextObjId++;
|
||||
objects.push({ id, content });
|
||||
return id;
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function escapePdfText(str) {
|
||||
return str.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
|
||||
function toLatin1(str) {
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
out += code <= 255 ? str[i] : '?';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ruwe schatting van Helvetica-tekstbreedte (geen AFM-metrics embedded).
|
||||
function estimateTextWidth(str, size) {
|
||||
return str.length * size * 0.55;
|
||||
}
|
||||
|
||||
fontId = newObj('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>');
|
||||
|
||||
function addPage(widthPt, heightPt) {
|
||||
const ops = [];
|
||||
let strokeKey = null, fillKey = null, widthKey = null;
|
||||
|
||||
function setStroke(r, g, b) {
|
||||
const key = r + ',' + g + ',' + b;
|
||||
if (key === strokeKey) return;
|
||||
strokeKey = key;
|
||||
ops.push(`${fmt(r)} ${fmt(g)} ${fmt(b)} RG`);
|
||||
}
|
||||
function setFill(r, g, b) {
|
||||
const key = r + ',' + g + ',' + b;
|
||||
if (key === fillKey) return;
|
||||
fillKey = key;
|
||||
ops.push(`${fmt(r)} ${fmt(g)} ${fmt(b)} rg`);
|
||||
}
|
||||
function setLineWidth(w) {
|
||||
if (w === widthKey) return;
|
||||
widthKey = w;
|
||||
ops.push(`${fmt(w)} w`);
|
||||
}
|
||||
function line(x1, y1, x2, y2) {
|
||||
ops.push(`${fmt(x1)} ${fmt(y1)} m ${fmt(x2)} ${fmt(y2)} l S`);
|
||||
}
|
||||
function rect(x, y, w, h, mode) {
|
||||
ops.push(`${fmt(x)} ${fmt(y)} ${fmt(w)} ${fmt(h)} re ${mode || 'S'}`);
|
||||
}
|
||||
function polyline(points, close) {
|
||||
if (!points.length) return;
|
||||
ops.push(`${fmt(points[0][0])} ${fmt(points[0][1])} m`);
|
||||
for (let i = 1; i < points.length; i++) ops.push(`${fmt(points[i][0])} ${fmt(points[i][1])} l`);
|
||||
ops.push(close ? 's' : 'S');
|
||||
}
|
||||
function text(x, y, size, str, align) {
|
||||
const s = escapePdfText(toLatin1(str));
|
||||
let tx = x;
|
||||
if (align === 'center') tx = x - estimateTextWidth(str, size) / 2;
|
||||
else if (align === 'right') tx = x - estimateTextWidth(str, size);
|
||||
ops.push(`BT /F1 ${fmt(size)} Tf ${fmt(tx)} ${fmt(y)} Td (${s}) Tj ET`);
|
||||
}
|
||||
function textRotated(x, y, size, str, degrees) {
|
||||
const rad = (degrees * Math.PI) / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
const s = escapePdfText(toLatin1(str));
|
||||
ops.push(`BT /F1 ${fmt(size)} Tf ${fmt(cos)} ${fmt(sin)} ${fmt(-sin)} ${fmt(cos)} ${fmt(x)} ${fmt(y)} Tm (${s}) Tj ET`);
|
||||
}
|
||||
|
||||
const contentId = newObj('');
|
||||
const pageId = newObj('');
|
||||
pageDefs.push({ pageId, contentId, ops, widthPt, heightPt });
|
||||
|
||||
return { setStroke, setFill, setLineWidth, line, rect, polyline, text, textRotated, estimateTextWidth };
|
||||
}
|
||||
|
||||
function build() {
|
||||
pagesId = newObj('');
|
||||
catalogId = newObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`);
|
||||
|
||||
pageDefs.forEach(p => {
|
||||
const streamBody = p.ops.join('\n');
|
||||
objects.find(o => o.id === p.contentId).content =
|
||||
`<< /Length ${streamBody.length} >>\nstream\n${streamBody}\nendstream`;
|
||||
objects.find(o => o.id === p.pageId).content =
|
||||
`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${fmt(p.widthPt)} ${fmt(p.heightPt)}] ` +
|
||||
`/Resources << /Font << /F1 ${fontId} 0 R >> >> /Contents ${p.contentId} 0 R >>`;
|
||||
});
|
||||
|
||||
objects.find(o => o.id === pagesId).content =
|
||||
`<< /Type /Pages /Kids [${pageDefs.map(p => p.pageId + ' 0 R').join(' ')}] /Count ${pageDefs.length} >>`;
|
||||
|
||||
objects.sort((a, b) => a.id - b.id);
|
||||
|
||||
let out = '%PDF-1.4\n%\xE2\xE3\xCF\xD3\n';
|
||||
const offsets = [0];
|
||||
objects.forEach(obj => {
|
||||
offsets[obj.id] = out.length;
|
||||
out += `${obj.id} 0 obj\n${obj.content}\nendobj\n`;
|
||||
});
|
||||
|
||||
const xrefOffset = out.length;
|
||||
out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let i = 1; i <= objects.length; i++) {
|
||||
out += String(offsets[i]).padStart(10, '0') + ' 00000 n \n';
|
||||
}
|
||||
out += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
return { addPage, build };
|
||||
}
|
||||
|
||||
// Zet een Latin1-string (elk char = 1 byte, zie hierboven) om naar een
|
||||
// Blob en start een download. Browser-specifiek, puur I/O — de opbouw
|
||||
// hierboven is dat niet.
|
||||
function savePdfBlob(latin1String, filename) {
|
||||
const bytes = new Uint8Array(latin1String.length);
|
||||
for (let i = 0; i < latin1String.length; i++) bytes[i] = latin1String.charCodeAt(i) & 0xff;
|
||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/view-filter.js — gedeelde "Weergave"-dropdown met vinkjes voor de
|
||||
// 3D-weergaven (woning, wand, vloer/dak). Vervangt de losse Aan/Uit-knoppen.
|
||||
// Plain script (geen ES-module).
|
||||
//
|
||||
// const filter = createViewFilter(toolbarEl, [
|
||||
// { key: 'beplating', label: 'Beplating', checked: true }, ...
|
||||
// ], (state) => { ... }); // state = { beplating: true, ... }
|
||||
// ============================================================
|
||||
|
||||
function createViewFilter(toolbar, items, onChange) {
|
||||
const state = {};
|
||||
items.forEach(it => { state[it.key] = it.checked !== false; });
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'view-filter';
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn-3d-toggle vf-btn">Weergave ▾</button>
|
||||
<div class="vf-menu">
|
||||
${items.map(it => `<label class="vf-item"><input type="checkbox" data-key="${it.key}" ${state[it.key] ? 'checked' : ''}> ${it.label}</label>`).join('')}
|
||||
</div>`;
|
||||
toolbar.insertBefore(wrap, toolbar.firstChild);
|
||||
|
||||
const menu = wrap.querySelector('.vf-menu');
|
||||
const btn = wrap.querySelector('.vf-btn');
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle('open');
|
||||
btn.classList.toggle('active', menu.classList.contains('open'));
|
||||
});
|
||||
menu.addEventListener('click', (e) => e.stopPropagation());
|
||||
menu.addEventListener('change', (e) => {
|
||||
const key = e.target.dataset.key;
|
||||
if (!key) return;
|
||||
state[key] = e.target.checked;
|
||||
onChange(Object.assign({}, state));
|
||||
});
|
||||
// Klik buiten het menu sluit het (listener verdwijnt met het element).
|
||||
const closeOnOutside = () => {
|
||||
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
|
||||
menu.classList.remove('open');
|
||||
btn.classList.remove('active');
|
||||
};
|
||||
document.addEventListener('click', closeOnOutside);
|
||||
|
||||
return { state: () => Object.assign({}, state) };
|
||||
}
|
||||
Reference in New Issue
Block a user