535 lines
28 KiB
JavaScript
535 lines
28 KiB
JavaScript
/*!
|
|
* Tectum
|
|
* Copyright © 2026 Machiel Heinen.
|
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
* Zie LICENTIE.md.
|
|
*/
|
|
// ============================================================
|
|
// house/house-pdf.js — HousePdf: de samenhangende woning-PDF.
|
|
// Blad 1 aanzichten noord/oost/zuid/west (vlakke "render", vectorvormen)
|
|
// Blad 2 plattegrond woning (units, wandstukken, naden, binnenwanden)
|
|
// daarna per unit: vloer (FloorPdf) + elk wandstuk (WallPdf)
|
|
// daarna elke binnenwand (WallPdf)
|
|
// daarna totalen (balken, inkoop, glaswol, plaatwerk, gewicht, dakrand)
|
|
// laatste zaaglijst vuren + zaagmachine-overzicht (tijdelijk)
|
|
// De losse bladen komen uit de gedeelde WallPdf/FloorPdf.
|
|
// Plain script, geladen na house-model.js.
|
|
// ============================================================
|
|
|
|
window.HousePdf = (() => {
|
|
const KIND_LABELS = {
|
|
'vloerbalk': 'Vloerbalk', 'dakbalk': 'Dakbalk', 'onderregel': 'Onderregel', 'bovenregel': 'Bovenregel',
|
|
'verticale balk': 'Verticale balk', 'kreupele stijl': 'Kreupele stijl',
|
|
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel'
|
|
};
|
|
const PLAN_ELEMENT = 'PL-1';
|
|
const VIEWS_ELEMENT = 'AZ-1';
|
|
|
|
function fmt(n, decimals = 1) {
|
|
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
|
}
|
|
|
|
// Rechthoek van een wandstuk (bovenaanzicht) als 4 hoekpunten.
|
|
function pieceCorners(place, lx0, lx1) {
|
|
const t = place.thickness / 2;
|
|
return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([lx, lz]) => UnitModel.localToWorld(place, lx, lz));
|
|
}
|
|
|
|
// Bladvolgorde + elementnummers; gedeeld door generate() en de
|
|
// onderdelenlijst op blad 1.
|
|
function sheetList(parts) {
|
|
const list = [
|
|
{ kind: 'views', element: VIEWS_ELEMENT, label: 'Aanzichten woning' },
|
|
{ kind: 'plan', element: PLAN_ELEMENT, label: 'Plattegrond woning' }
|
|
];
|
|
parts.units.forEach(u => {
|
|
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u });
|
|
u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })));
|
|
u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl }));
|
|
});
|
|
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
|
|
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
|
|
list.push({ kind: 'saw', element: 'ZAAG', label: 'Zaaglijst & zaagmachine' });
|
|
return list;
|
|
}
|
|
|
|
function drawPlanSheet(doc, parts, sheets, sheetOpts) {
|
|
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
|
doc.setFont('helvetica', 'normal');
|
|
|
|
PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'],
|
|
sheets.map((s, i) => [s.element, s.label, String(i + 1)]).filter(r => r[0] !== PLAN_ELEMENT),
|
|
{ title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
|
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
|
|
|
const rects = parts.rects;
|
|
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1));
|
|
const bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1));
|
|
const drawX0 = 105, drawX1 = 405, drawY0 = 20, drawY1 = 228;
|
|
const margin = 32;
|
|
const scale = Math.min((drawX1 - drawX0 - margin * 2) / (bx1 - bx0), (drawY1 - drawY0 - margin * 2) / (bz1 - bz0));
|
|
const ox = (drawX0 + drawX1) / 2 - ((bx0 + bx1) / 2) * scale;
|
|
const oy = (drawY0 + drawY1) / 2 - ((bz0 + bz1) / 2) * scale;
|
|
const px = (x) => ox + x * scale, py = (z) => oy + z * scale;
|
|
const poly = (pts, style) => {
|
|
doc.lines(pts.slice(1).map((p, i) => [px(p.x) - px(pts[i].x), py(p.z) - py(pts[i].z)]), px(pts[0].x), py(pts[0].z), [1, 1], style, true);
|
|
};
|
|
|
|
// Vloeren + unit-letter
|
|
rects.forEach(r => {
|
|
doc.setDrawColor(...GREY);
|
|
doc.setLineWidth(0.2);
|
|
doc.rect(px(r.x0), py(r.z0), r.W * scale, r.D * scale, 'S');
|
|
doc.setTextColor(...GREY);
|
|
doc.setFontSize(16);
|
|
doc.text(r.id, px(r.cx), py(r.cz) + 2, { align: 'center' });
|
|
});
|
|
|
|
// Wandstukken (buiten + binnen) met openingen
|
|
const drawPiece = (p) => {
|
|
doc.setDrawColor(...RED);
|
|
doc.setLineWidth(0.3);
|
|
poly(pieceCorners(p.place, -p.place.width / 2, p.place.width / 2), 'S');
|
|
doc.setDrawColor(...BLACK);
|
|
doc.setFillColor(255, 255, 255);
|
|
doc.setLineWidth(0.2);
|
|
p.config.openings.forEach(op => {
|
|
const c = pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w);
|
|
poly(c, 'FD');
|
|
doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z));
|
|
doc.line(px(c[1].x), py(c[1].z), px(c[3].x), py(c[3].z));
|
|
});
|
|
};
|
|
HouseModel.allWallPieces(parts).forEach(drawPiece);
|
|
|
|
// Dak: dakranden (grijs gestippeld), boeideel/daktrim-buitenrand, spots
|
|
doc.setLineDashPattern([1, 1.2], 0);
|
|
doc.setDrawColor(...GREY);
|
|
doc.setLineWidth(0.15);
|
|
parts.units.forEach(u => doc.rect(px(u.roof.rect.x0), py(u.roof.rect.z0), u.roof.rect.W * scale, u.roof.rect.D * scale, 'S'));
|
|
if (parts.roofEdge) {
|
|
doc.setLineWidth(0.25);
|
|
parts.roofEdge.segments.forEach(sg => {
|
|
doc.setDrawColor(...(sg.fascia ? BLACK : GREY));
|
|
doc.line(px(sg.outerA.x), py(sg.outerA.z), px(sg.outerB.x), py(sg.outerB.z));
|
|
});
|
|
doc.setLineDashPattern([], 0);
|
|
doc.setDrawColor(...BLACK);
|
|
doc.setFillColor(255, 255, 255);
|
|
parts.roofEdge.spots.forEach(sp => doc.circle(px(sp.x), py(sp.z), 0.8, 'FD'));
|
|
}
|
|
doc.setLineDashPattern([], 0);
|
|
|
|
// Gevellampen
|
|
doc.setDrawColor(...BLACK);
|
|
doc.setFillColor(255, 211, 107);
|
|
doc.setLineWidth(0.15);
|
|
parts.lighting.lamps.forEach(l => {
|
|
doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD');
|
|
});
|
|
|
|
// Naden: gestippeld oranje
|
|
doc.setDrawColor(230, 130, 30);
|
|
doc.setLineWidth(0.4);
|
|
doc.setLineDashPattern([1.5, 1], 0);
|
|
parts.seams.forEach(s => doc.line(px(s.x0), py(s.z0), px(s.x1), py(s.z1)));
|
|
doc.setLineDashPattern([], 0);
|
|
|
|
// Maatlijnen buiten elk buiten-wandstuk + labels binnenwanden
|
|
doc.setDrawColor(...BLUE);
|
|
doc.setTextColor(...BLUE);
|
|
doc.setLineWidth(0.15);
|
|
doc.setFontSize(6);
|
|
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
|
|
const n = p.place.normal, off = 6;
|
|
const a = UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2);
|
|
const b = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2);
|
|
const x0 = px(a.x) + n.x * off, y0 = py(a.z) + n.y * off, x1 = px(b.x) + n.x * off, y1 = py(b.z) + n.y * off;
|
|
PdfSheet.dimLine(doc, x0, y0, x1, y1);
|
|
const text = `${p.element}: ${p.place.width}`;
|
|
const tx = (x0 + x1) / 2 + n.x * 2.5, ty = (y0 + y1) / 2 + n.y * 2.5 + (n.y > 0 ? 1.5 : 0);
|
|
if (n.y === 0) doc.text(text, tx, ty, { align: 'center', angle: 90 });
|
|
else doc.text(text, tx, ty, { align: 'center' });
|
|
})));
|
|
doc.setTextColor(...BLACK);
|
|
parts.interior.forEach(p => {
|
|
doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: Math.abs(Math.sin(p.place.rotY)) < 0.5 ? 0 : 90 });
|
|
});
|
|
|
|
// Buitenmaten woning
|
|
doc.setDrawColor(...BLUE);
|
|
doc.setTextColor(...BLUE);
|
|
doc.setFontSize(7);
|
|
const topY = py(bz0) - 20, leftX = px(bx0) - 20;
|
|
PdfSheet.dimLine(doc, px(bx0), topY, px(bx1), topY);
|
|
doc.text('X: ' + Math.round(bx1 - bx0), (px(bx0) + px(bx1)) / 2, topY - 1.5, { align: 'center' });
|
|
PdfSheet.dimLine(doc, leftX, py(bz0), leftX, py(bz1));
|
|
doc.text('Y: ' + Math.round(bz1 - bz0), leftX - 2, (py(bz0) + py(bz1)) / 2, { align: 'center', angle: 90 });
|
|
|
|
// Noordpijl (boven = noord)
|
|
doc.setFillColor(...BLUE);
|
|
doc.triangle(drawX1 - 8, drawY0, drawX1 - 11, drawY0 + 9, drawX1 - 5, drawY0 + 9, 'F');
|
|
doc.setTextColor(...BLUE);
|
|
doc.setFontSize(8);
|
|
doc.text('N', drawX1 - 8, drawY0 + 14, { align: 'center' });
|
|
|
|
doc.setTextColor(...GREY);
|
|
doc.setFontSize(6);
|
|
doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' +
|
|
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp', drawX0, drawY1 + 4);
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// Aanzichten: per windrichting een vlakke "render" van de gevels. Alle
|
|
// zichtbare vlakken (gevels incl. trasraam en openingen, boeideel/daktrim,
|
|
// hellende dakvlakken) als polygonen in het aanzicht, van achter naar voren
|
|
// getekend (schildersalgoritme), in egale kleuren uit het model.
|
|
// ------------------------------------------------------------
|
|
const hexRgb = (h) => { const n = parseInt(String(h).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
|
|
const numRgb = (n) => [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
const VIEW_COLORS = {
|
|
rabat: '#a0805f', cementplaat: '#808285', glas: '#a9c9dc', deur: '#5b4a3b', lijn: [60, 60, 60], grond: [120, 120, 120]
|
|
};
|
|
// d = richting van woning naar kijker (x, z); u = horizontale schermas.
|
|
const VIEWS = [
|
|
{ key: 'noord', title: 'Noordgevel', d: { x: 0, z: -1 }, u: (p) => -p.x },
|
|
{ key: 'oost', title: 'Oostgevel', d: { x: 1, z: 0 }, u: (p) => -p.z },
|
|
{ key: 'zuid', title: 'Zuidgevel', d: { x: 0, z: 1 }, u: (p) => p.x },
|
|
{ key: 'west', title: 'Westgevel', d: { x: -1, z: 0 }, u: (p) => p.z }
|
|
];
|
|
|
|
// Alle vlakken in wereldcoördinaten: { pts: [{x, y, z}], n: {x, z} | null (dakvlak: nH), color, bias }.
|
|
function viewFaces(parts, state) {
|
|
const faces = [];
|
|
const wallFace = (p, y0, color, depthBias) => {
|
|
const W = p.place.width, T = p.place.thickness;
|
|
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone), e = p.skinExt || {};
|
|
const xl = -W / 2 - (e.left > 0 ? e.left + c : 0), xr = W / 2 + (e.right > 0 ? e.right + c : 0);
|
|
const tl = WallModel.topLine(p.config), up = p.skinAbove || 0;
|
|
const top = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))) + up;
|
|
const loc = [{ x: xl, y: y0 }, { x: xr, y: y0 }, { x: xr, y: top(xr) }]
|
|
.concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y + up })))
|
|
.concat([{ x: xl, y: top(xl) }]);
|
|
return loc;
|
|
};
|
|
const toWorld = (p, lx, y, lz) => { const w = UnitModel.localToWorld(p.place, lx, lz); return { x: w.x, y: p.place.floorTop + y, z: w.z }; };
|
|
const addWall = (p, y0) => {
|
|
const n = { x: p.place.normal.x, z: p.place.normal.y };
|
|
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone);
|
|
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : VIEW_COLORS.rabat;
|
|
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
|
// Trasraam (strook onderaan, iets naar voren)
|
|
const tr = p.trasraam;
|
|
if (tr && tr.top > y0) {
|
|
const W = p.place.width;
|
|
const band = wallFace(p, y0).map(q => ({ x: q.x, y: Math.min(q.y, tr.top) }));
|
|
const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat;
|
|
const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12);
|
|
faces.push({ pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
|
|
}
|
|
// Openingen (glas / deur), vóór de gevel
|
|
(p.config.openings || []).forEach(op => {
|
|
if (WallModel.openingTopMax(op) <= y0) return;
|
|
// Schuine bovenzijde: bovenkant links/rechts.
|
|
const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y);
|
|
const ybL = Math.max(ya, op.y + op.h), ybR = Math.max(ya, op.y + op.hR);
|
|
const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur;
|
|
faces.push({ pts: [[x0, ya], [x1, ya], [x1, 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.interior.filter(p => p.skinFrom !== undefined && p.place.normal).forEach(p => addWall(p, p.skinFrom));
|
|
|
|
// Hellende dakvlakken (bovenkant, egaal in de kleur van de bedekking).
|
|
parts.units.forEach(u => {
|
|
const r = u.roof, sh = r.shape;
|
|
if (sh.type === 'plat') return;
|
|
const cov = RoofModel.COVERINGS[r.covering] || RoofModel.COVERINGS.dakleer;
|
|
const ctr = sh.axis === 'x' ? u.rect.cx : u.rect.cz;
|
|
r.planes.forEach(pl => {
|
|
const a0 = ctr + pl.p0, a1 = ctr + pl.p1, mid = (a0 + a1) / 2;
|
|
// Hoogte net binnen het vlak (op de nok geeft topAt het hoogste vlak).
|
|
const inward = (t) => t + Math.sign(mid - t) * 0.5;
|
|
const corners = sh.axis === 'x'
|
|
? [[a0, r.rect.z0], [a1, r.rect.z0], [a1, r.rect.z1], [a0, r.rect.z1]]
|
|
: [[r.rect.x0, a0], [r.rect.x1, a0], [r.rect.x1, a1], [r.rect.x0, a1]];
|
|
const pts = corners.map(([x, z]) => ({ x, y: sh.axis === 'x' ? r.topAt(inward(x), z) : r.topAt(x, inward(z)), z }));
|
|
// Normaal (omhoog) uit het vlak.
|
|
const [A, B, C] = pts;
|
|
const ux = B.x - A.x, uy = B.y - A.y, uz = B.z - A.z, vx = C.x - A.x, vy = C.y - A.y, vz = C.z - A.z;
|
|
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
|
|
const len = Math.hypot(nx, ny, nz) || 1;
|
|
faces.push({ pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
|
|
});
|
|
});
|
|
|
|
// Dakrand: buitenvlak van elk boeideel / elke daktrim.
|
|
const edge = parts.roofEdge;
|
|
if (edge) {
|
|
const fc = (RoofModel.FASCIA_COLORS[edge.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet).hex;
|
|
edge.segments.forEach(sg => {
|
|
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
|
|
const A = sg.outerA, B = sg.outerB, tA = sg.topAt(A), tB = sg.topAt(B);
|
|
const pts = [{ x: A.x, y: tA, z: A.z }, { x: B.x, y: tB, z: B.z }, { x: B.x, y: tB - h, z: B.z }, { x: A.x, y: tA - h, z: A.z }];
|
|
faces.push({ pts, n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
|
|
});
|
|
}
|
|
return faces;
|
|
}
|
|
|
|
// Legenda "Afwerking": gevel(s) (incl. overrides), trasraam, dak per unit,
|
|
// vloer, boeideel/daktrim en binnenwanden. Per item een voorvertoning
|
|
// (thumb, via PdfSheet.preloadImage vooraf geladen) of een kleurvlak.
|
|
function materialLegend(state, parts) {
|
|
const items = [];
|
|
const st = state.settings;
|
|
const seen = new Set();
|
|
state.units.forEach(u => Object.keys(u.unit.walls).forEach(side => {
|
|
const fc = HouseModel.facadeFor(state, u, side);
|
|
const key = fc.type === 'steen' ? 'steen:' + fc.stone : 'rabat';
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
items.push(fc.type === 'steen'
|
|
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
|
|
: { label: 'Gevel', value: `Rabat (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: VIEW_COLORS.rabat });
|
|
}));
|
|
if (st.trasraam.enabled) {
|
|
const tf = st.trasraam.finish;
|
|
items.push(WallModel.isStoneFinish(tf)
|
|
? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] }
|
|
: { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat });
|
|
}
|
|
(parts ? parts.units : state.units).forEach(u => {
|
|
const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer');
|
|
const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer;
|
|
items.push({ label: `Dak unit ${u.id}`, value: c.label, thumb: RoofModel.coveringThumbUrl(cov, 64), color: c.color });
|
|
});
|
|
const ff = FloorModel.FINISHES[st.floorFinish] || FloorModel.FINISHES.geen;
|
|
items.push({ label: 'Vloer', value: ff.label, thumb: FloorModel.finishThumbUrl(st.floorFinish, 64), color: ff.color });
|
|
const fascia = state.units.some(u => u.roof.fascia !== false), trim = state.units.some(u => u.roof.fascia === false);
|
|
if (fascia) {
|
|
const fcol = RoofModel.FASCIA_COLORS[st.roof.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet;
|
|
items.push({ label: 'Boeideel', value: fcol.label, color: fcol.hex });
|
|
}
|
|
if (trim) items.push({ label: 'Daktrim', value: 'Aluminium', color: 0xb8bcc0 });
|
|
items.push(st.interiorPaint
|
|
? { label: 'Binnenwanden', value: `Geschilderd ${st.interiorColor}`, color: st.interiorColor }
|
|
: { label: 'Binnenwanden', value: 'Spaanplaat', color: '#1e4620' });
|
|
return items;
|
|
}
|
|
// Voorvertoningen die de legenda gebruikt (om vooraf te laden).
|
|
function materialThumbUrls(state) {
|
|
return materialLegend(state, null).map(i => i.thumb).filter(Boolean);
|
|
}
|
|
|
|
function drawMaterialLegend(doc, items, x0, y0, x1, y1) {
|
|
const { BLACK, GREY } = PdfSheet.COLORS;
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(8);
|
|
doc.setTextColor(...BLACK);
|
|
doc.text('Afwerking', x0, y0 + 3);
|
|
doc.setFont('helvetica', 'normal');
|
|
const cellW = 45, cellH = 11, sw = 9, top = y0 + 6;
|
|
const cols = Math.max(1, Math.floor((x1 - x0) / cellW)), rows = Math.max(1, Math.floor((y1 - top) / cellH));
|
|
items.slice(0, cols * rows).forEach((it, i) => {
|
|
const cx = x0 + Math.floor(i / rows) * cellW, cy = top + (i % rows) * cellH;
|
|
const data = PdfSheet.imageData(it.thumb);
|
|
if (data) doc.addImage(data, 'PNG', cx, cy, sw, sw);
|
|
else {
|
|
doc.setFillColor(...(typeof it.color === 'number' ? numRgb(it.color) : hexRgb(it.color || '#cccccc')));
|
|
doc.rect(cx, cy, sw, sw, 'F');
|
|
}
|
|
doc.setDrawColor(...GREY);
|
|
doc.setLineWidth(0.1);
|
|
doc.rect(cx, cy, sw, sw, 'S');
|
|
doc.setFontSize(6);
|
|
doc.setTextColor(...GREY);
|
|
doc.text(it.label, cx + sw + 1.5, cy + 3.2);
|
|
doc.setFontSize(7);
|
|
doc.setTextColor(...BLACK);
|
|
doc.text(doc.splitTextToSize(it.value, cellW - sw - 3)[0], cx + sw + 1.5, cy + 7.2);
|
|
});
|
|
}
|
|
|
|
// Afmetingen (zelfde rijen als het infopaneel in de app), in twee kolommen.
|
|
function drawDimensions(doc, rows, x0, y0, x1) {
|
|
const { BLACK, GREY } = PdfSheet.COLORS;
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(8);
|
|
doc.setTextColor(...BLACK);
|
|
doc.text('Afmetingen', x0, y0 + 3);
|
|
doc.setFont('helvetica', 'normal');
|
|
const half = Math.ceil(rows.length / 2), colW = (x1 - x0) / 2, rowH = 4.6;
|
|
rows.forEach(([label, value, unit], i) => {
|
|
const cx = x0 + Math.floor(i / half) * colW, cy = y0 + 8 + (i % half) * rowH;
|
|
doc.setFontSize(5.5);
|
|
doc.setTextColor(...GREY);
|
|
doc.text(label, cx, cy - 1.7);
|
|
doc.setFontSize(7);
|
|
doc.setTextColor(...BLACK);
|
|
doc.text(`${value}${unit ? ' ' + unit : ''}`, cx, cy + 0.9);
|
|
});
|
|
}
|
|
|
|
function drawViewsSheet(doc, parts, state, sheetOpts) {
|
|
const { BLACK } = PdfSheet.COLORS;
|
|
const faces = viewFaces(parts, state);
|
|
const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
|
|
// Per aanzicht de zichtbare vlakken in (u, v) mm, met diepte.
|
|
const views = VIEWS.map(v => {
|
|
const dot = (p) => p.x * v.d.x + p.z * v.d.z;
|
|
const list = faces.filter(f => f.roof ? (f.n.x * v.d.x + f.n.z * v.d.z) > 0.01 : (f.n.x * v.d.x + f.n.z * v.d.z) > 0.5)
|
|
.map(f => ({ f, depth: f.pts.reduce((a, p) => a + dot(p), 0) / f.pts.length + f.bias, pts: f.pts.map(p => ({ u: v.u(p), v: p.y })) }))
|
|
.sort((a, b) => a.depth - b.depth);
|
|
const us = list.flatMap(l => l.pts.map(p => p.u)), vs = list.flatMap(l => l.pts.map(p => p.v)).concat([groundY]);
|
|
return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) };
|
|
}).filter(x => x.list.length);
|
|
|
|
// Raster 2 x 2 boven het titelblok; één schaal voor alle aanzichten.
|
|
const X0 = 12, X1 = 408, Y0 = 14, Y1 = 248, gap = 10, titleH = 7;
|
|
const cellW = (X1 - X0 - gap) / 2, cellH = (Y1 - Y0 - gap) / 2;
|
|
const scale = Math.min(...views.map(w => Math.min((cellW - 16) / (w.u1 - w.u0), (cellH - titleH - 10) / (w.v1 - w.v0))));
|
|
doc.setFont('helvetica', 'normal');
|
|
views.forEach((w, i) => {
|
|
const cx = X0 + (i % 2) * (cellW + gap), cy = Y0 + Math.floor(i / 2) * (cellH + gap);
|
|
const ox = cx + cellW / 2 - (w.u0 + w.u1) / 2 * scale;
|
|
const base = cy + cellH - 6; // grondlijn
|
|
const P = (p) => [ox + p.u * scale, base - (p.v - groundY) * scale];
|
|
doc.setTextColor(...BLACK);
|
|
doc.setFontSize(11);
|
|
doc.text(w.v.title, cx, cy + 5);
|
|
doc.setLineWidth(0.1);
|
|
doc.setDrawColor(...VIEW_COLORS.lijn);
|
|
w.list.forEach(l => {
|
|
const pts = l.pts.map(P);
|
|
const col = typeof l.f.color === 'number' ? numRgb(l.f.color) : hexRgb(l.f.color);
|
|
doc.setFillColor(...col);
|
|
doc.lines(pts.slice(1).map((q, k) => [q[0] - pts[k][0], q[1] - pts[k][1]]), pts[0][0], pts[0][1], [1, 1], 'FD', true);
|
|
});
|
|
// Grondlijn
|
|
doc.setDrawColor(...VIEW_COLORS.grond);
|
|
doc.setLineWidth(0.4);
|
|
doc.line(ox + w.u0 * scale - 6, base, ox + w.u1 * scale + 6, base);
|
|
});
|
|
doc.setFontSize(7);
|
|
doc.setTextColor(...BLACK);
|
|
doc.text(`Aanzichten (vlakke weergave, schaal ca. 1:${Math.round(1 / scale)}); kleuren: gemiddelde kleur van de gekozen steen / bedekking.`, X0, Y1 + 5);
|
|
drawMaterialLegend(doc, materialLegend(state, parts), X0, Y1 + 7, 192, 289);
|
|
drawDimensions(doc, HouseModel.dimensionRows(HouseModel.computeDimensions(parts, state.settings.groundLevel), fmt), 197, Y1 + 7, 285);
|
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
|
}
|
|
|
|
function drawTotalsSheet(doc, totals, sheetOpts) {
|
|
const { BLACK } = PdfSheet.COLORS;
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setTextColor(...BLACK);
|
|
doc.setFontSize(14);
|
|
doc.text('Totalen woning', 15, 18);
|
|
const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 16 });
|
|
|
|
let y = 30;
|
|
const kindRows = totals.kinds.map(k => [KIND_LABELS[k.kind] || k.kind, String(k.count), fmt(k.lengthMM / 1000, 2)]);
|
|
kindRows.push(['Totaal', String(totals.beamCount), fmt(totals.kinds.reduce((a, k) => a + k.lengthMM, 0) / 1000, 2)]);
|
|
y = PdfSheet.table(doc, 15, y, [0, 45, 65], ['Soort', 'Aantal', 'Lengte (m)'], kindRows, tableOpts('Houtwerk (balken)'));
|
|
y += 4;
|
|
y = PdfSheet.table(doc, 15, y, [0, 45], ['', ''], [
|
|
['Volume hout', fmt(totals.woodM3, 3) + ' m3'],
|
|
['Gewicht hout', fmt(totals.woodKg) + ' kg'],
|
|
['Latwerk 20x30 (kruislat)', `${totals.battens.count} st. \u00e0 ${WallModel.BATTEN.length} (${fmt(totals.battens.lengthM, 1)} m)`]
|
|
], tableOpts('Houtwerk totaal'));
|
|
y += 4;
|
|
const purchaseRows = [];
|
|
totals.purchase.forEach(sec => sec.perStock.forEach(st => purchaseRows.push([`${sec.width}x${sec.depth}`, String(st.stock), String(st.count)])));
|
|
purchaseRows.push(['Snijverlies', fmt(totals.purchaseWasteMM / 1000, 2) + ' m',
|
|
fmt(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0) + '%']);
|
|
PdfSheet.table(doc, 15, y, [0, 30, 55], ['Doorsnede', 'Lengte', 'Aantal'], purchaseRows, tableOpts('Inkoop wandbalken (alle wanden)'));
|
|
|
|
y = 30;
|
|
const isoRows = totals.isolatie.map(r => [r.label, fmt(r.m3, 3) + ' m3']);
|
|
isoRows.push(['Totaal glaswol', fmt(totals.isolatieM3, 3) + ' m3']);
|
|
y = PdfSheet.table(doc, 120, y, [0, 50], ['Onderdeel', 'Inhoud'], isoRows, tableOpts('Isolatie (glaswol, spouwvullend)'));
|
|
y += 4;
|
|
y = PdfSheet.table(doc, 120, y, [0, 58, 80, 102], ['Materiaal', 'Opp. (m2)', 'Vol. (m3)', 'Gewicht'],
|
|
totals.plaatwerk.map(p => [p.naam, fmt(p.m2, 2), p.m3 === null ? 'n.v.t.' : fmt(p.m3, 3), fmt(p.kg) + ' kg']),
|
|
tableOpts('Plaatwerk'));
|
|
y += 4;
|
|
const wRows = totals.weights.map(r => [r.label, fmt(r.kg) + ' kg']);
|
|
wRows.push(['Totaal woning', fmt(totals.totalKg) + ' kg']);
|
|
y = PdfSheet.table(doc, 120, y, [0, 50], ['Onderdeel', 'Gewicht'], wRows, tableOpts('Gewicht'));
|
|
if (totals.roofEdge) {
|
|
y += 4;
|
|
const e = totals.roofEdge, L = totals.lamps;
|
|
const rows = [];
|
|
if (e.fasciaLengthM > 0) {
|
|
rows.push(['Boeidelen', `${fmt(e.fasciaLengthM, 2)} m, ${e.fasciaHeight} mm hoog, overstek ${e.overhang} mm${e.fasciaColor ? ', ' + e.fasciaColor.toLowerCase() : ''}`]);
|
|
rows.push(['Onderplaat boeideel', fmt(e.soffitM2, 2) + ' m2']);
|
|
}
|
|
if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]);
|
|
rows.push(['Boeideel-spots', e.spots + ' stuks']);
|
|
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
|
|
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
|
|
PdfSheet.table(doc, 120, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting'));
|
|
}
|
|
|
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
|
}
|
|
|
|
// Eigen blad na de totalen: zaaglijst vuren (in kolommen) + zaagmachine.
|
|
function drawSawSheet(doc, totals, sheetOpts) {
|
|
const { BLACK } = PdfSheet.COLORS;
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setTextColor(...BLACK);
|
|
doc.setFontSize(14);
|
|
doc.text('Zaaglijst', 15, 18);
|
|
const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 16 });
|
|
const rows = totals.sawList.map(g => [String(g.width), String(g.depth), String(g.length), String(g.count)]);
|
|
const perCol = 52, cols = [15, 100, 185];
|
|
for (let c = 0; c < cols.length && c * perCol < rows.length; c++) {
|
|
PdfSheet.table(doc, cols[c], 30, [0, 20, 40, 62], ['Dikte', 'Hoogte', 'Lengte', 'Aantal'], rows.slice(c * perCol, (c + 1) * perCol),
|
|
tableOpts(c === 0 ? 'Zaaglijst vuren (totaal)' : 'Zaaglijst (vervolg)'));
|
|
}
|
|
if (rows.length > perCol * cols.length) {
|
|
doc.setFontSize(7);
|
|
doc.text(`+ ${rows.length - perCol * cols.length} regels (niet getoond)`, 15, 285);
|
|
}
|
|
|
|
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
|
|
const sw = totals.saw;
|
|
const hm = (h) => `${Math.floor(h)}:${String(Math.round((h % 1) * 60)).padStart(2, '0')}`;
|
|
PdfSheet.table(doc, 290, 30, [0, 55], ['Onderdeel', 'Waarde'], [
|
|
['Zaagmeters', fmt(sw.meters, 1) + ' m'],
|
|
[`Zaagtijd (${HouseModel.SAW_M_PER_H} m/u)`, hm(sw.hours) + ' u:mm'],
|
|
['Zaagsneden', sw.cuts + ' st.'],
|
|
['Verschillende hoeken', String(sw.angles.length)]
|
|
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0\u00b0)' : `Schuin ${fmt(a.deg, 1)}\u00b0`, a.count + ' sneden'])), tableOpts('Zaagmachine (tijdelijk)'));
|
|
|
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
|
}
|
|
|
|
function generate(state) {
|
|
const parts = HouseModel.computeParts(state, {
|
|
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
|
|
});
|
|
const totals = HouseModel.computeTotals(parts);
|
|
const sheets = sheetList(parts);
|
|
const project = state.project;
|
|
const doc = PdfSheet.createDoc();
|
|
|
|
sheets.forEach((sheet, i) => {
|
|
if (i > 0) doc.addPage('a3', 'landscape');
|
|
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
|
|
if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts);
|
|
else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
|
|
else if (sheet.kind === 'floor') FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts);
|
|
else if (sheet.kind === 'roof') FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true }, opts));
|
|
else if (sheet.kind === 'wall') WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [] }, opts));
|
|
else if (sheet.kind === 'saw') drawSawSheet(doc, totals, opts);
|
|
else drawTotalsSheet(doc, totals, opts);
|
|
});
|
|
return doc;
|
|
}
|
|
|
|
return { generate, sheetList, materialThumbUrls };
|
|
})();
|