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:
Machiel
2026-09-30 11:38:54 +02:00
co-authored by Claude Opus 5.5
commit c690e72d13
36 changed files with 9954 additions and 0 deletions
+35
View File
@@ -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 };
})();
+41
View File
@@ -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();
}
+346
View File
@@ -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;
}
+79
View File
@@ -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
+134
View File
@@ -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 };
})();
+163
View File
@@ -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);
}
+53
View File
@@ -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 &#9662;</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) };
}