/*! * 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 plattegrond woning (units, wandstukken, naden, binnenwanden) // daarna per unit: vloer (FloorPdf) + elk wandstuk (WallPdf) // daarna elke binnenwand (WallPdf) // laatste totalen (balken, inkoop, zaaglijst, glaswol, plaatwerk, gewicht) // 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'; 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: '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 }))); list.push({ kind: 'roof', element: u.roof.element, label: `Unit ${u.id} dak (plat)`, unit: u }); }); parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })); list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' }); 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.slice(1).map((s, i) => [s.element, s.label, String(i + 2)]), { 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: p.place.rotY === 0 ? 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); } 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)')); PdfSheet.table(doc, 120, 30, [0, 20, 40, 62], ['Dikte', 'Hoogte', 'Lengte', 'Aantal'], totals.sawList.map(g => [String(g.width), String(g.depth), String(g.length), String(g.count)]), Object.assign(tableOpts('Zaaglijst vuren (totaal)'), { maxY: 285 })); 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, 215, y, [0, 50], ['Onderdeel', 'Inhoud'], isoRows, tableOpts('Isolatie (glaswol, spouwvullend)')); y += 4; y = PdfSheet.table(doc, 215, 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, 215, 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`]); 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})`]); PdfSheet.table(doc, 215, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & buitenverlichting')); } 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 === '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.unit.roof.data.calcData, sheet.unit.roof.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, lamps: sheet.piece.lamps || [] }, opts)); else drawTotalsSheet(doc, totals, opts); }); return doc; } return { generate, sheetList }; })();