/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ 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', 'schuine regel': 'Schuine regel' }; const PLAN_ELEMENT = 'PL-1'; const VIEWS_ELEMENT = 'AZ-1'; function fmt(n, decimals = 1) { return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } 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)); } 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 })); const cap = u.roof.unitRoof && u.roof.unitRoof.cap; if (cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: cap.kind === 'join' ? `Unit ${u.id} dak (aansluiting)` : `Unit ${u.id} dak bovenste deel (wolfsdak)`, 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' }); list.push({ kind: 'saw', element: 'ZAAG', label: 'Zaaglijst & zaagmachine' }); if (parts.elektra && parts.elektra.enabled) list.push({ kind: 'elektra', element: 'EL', label: 'Elektra' }); return list; } // customer (light-versie): zonder onderdelenlijst, elementcodes en elektra; de tekening schuift dan naar links. function drawPlanSheet(doc, parts, sheets, sheetOpts) { const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS; const customer = !!sheetOpts.customer; doc.setFont('helvetica', 'normal'); if (!customer) 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 = customer ? 15 : 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); }; 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' }); }); const R = parts.rooms; const light = (hex, k) => [1, 3, 5].map(i => Math.round(255 - (255 - parseInt(hex.slice(i, i + 2), 16)) * k)); R.rooms.forEach(r => { doc.setFillColor(...light(r.color, 0.35)); r.rects.forEach(c => doc.rect(px(c.x0), py(c.z0), (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale, 'F')); }); 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.filter(op => op.type !== 'schoorsteen').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)); if (op.fill) { const W = p.place.width, T = p.place.thickness; KozijnModel.planSymbol(op, op.fill, { zIn: -T / 2, zOut: T / 2 }).forEach(sh => { doc.setLineWidth(sh.kind === 'leaf' ? 0.25 : 0.12); doc.setLineDashPattern(sh.kind === 'arc' || sh.kind === 'rail' ? [0.8, 0.8] : [], 0); pline(sh.pts.map(pt => UnitModel.localToWorld(p.place, -W / 2 + op.x + pt.x, pt.z)), !!sh.closed); }); doc.setLineDashPattern([], 0); doc.setLineWidth(0.2); } }); }; const pline = (pts, closed) => { if (pts.length < 2) return; doc.lines(pts.slice(1).map((q, i) => [px(q.x) - px(pts[i].x), py(q.z) - py(pts[i].z)]), px(pts[0].x), py(pts[0].z), [1, 1], 'S', closed); }; doc.setDrawColor(110, 110, 110); (parts.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => { doc.setLineWidth(sh.kind === 'thin' ? 0.1 : 0.18); doc.setLineDashPattern(sh.kind === 'dash' ? [0.8, 0.6] : [], 0); if (sh.circle) doc.circle(px(sh.circle.x), py(sh.circle.z), Math.max(0.2, sh.circle.r * scale), 'S'); else if (sh.text) { doc.setFontSize(4); doc.setTextColor(110, 110, 110); doc.text(sh.text, px(sh.at.x), py(sh.at.z) + 0.6, { align: 'center' }); } else pline(sh.pts, !!sh.closed); })); doc.setLineDashPattern([], 0); HouseModel.allWallPieces(parts).forEach(drawPiece); 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); doc.setDrawColor(...BLACK); doc.setLineWidth(0.2); doc.setFillColor(160, 124, 84); parts.posts.posts.forEach(pp => doc.rect(px(pp.x - pp.size / 2), py(pp.z - pp.size / 2), pp.size * scale, pp.size * scale, 'FD')); doc.setFillColor(235, 235, 232); parts.posts.walls.forEach(w => doc.rect(px(w.x0), py(w.z0), (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale, 'FD')); (parts.chimneys || []).forEach(ch => { doc.setFillColor(150, 150, 150); poly(ch.footprint, 'FD'); doc.setFillColor(30, 30, 30); poly(ch.flueFootprint, 'F'); }); 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'); }); if (!customer) ElektraPdf.drawPlan(doc, parts.elektra, px, py); 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); 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 = customer ? String(p.place.width) : `${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); if (!customer) 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 }); }); R.rooms.concat(R.free).forEach(r => { const x = px(r.label.x), y = py(r.label.z), named = !!r.name; const lines = [[named ? r.name : 'Ruimte (geen functie)', named ? 'bold' : 'italic', 7], [`${fmt(r.areaM2, 2)} m²`, 'normal', 6.5]]; if (r.ceilingActive) lines.push([`plafond ${r.ceiling}`, 'normal', 5.5]); doc.setTextColor(...(named ? BLACK : GREY)); lines.forEach(([t, style, size], i) => { doc.setFont('helvetica', style); doc.setFontSize(size); doc.text(t, x, y + (i - (lines.length - 1) / 2) * 3 + 1, { align: 'center' }); }); }); doc.setFont('helvetica', 'normal'); if (R.rooms.length || R.free.length) { doc.setFontSize(6); const fit = (t, mm) => { let v = String(t); while (v.length > 3 && doc.getTextWidth(v) > mm) v = v.slice(0, -2); return v === String(t) ? v : v + '.'; }; const rows = R.rooms.map(r => [fit(r.name, 32), fit(r.typeLabel, 26), fmt(r.areaM2, 2), fit(RoomModel.ceilingLabel(r), 30), fit(RoomModel.floorLabel(r), 30), fit(RoomModel.wallLabel(r), 38)]) .concat(R.free.map(r => ['Ruimte', '(geen functie)', fmt(r.areaM2, 2), '', '', ''])); rows.push(['Totaal netto', '', fmt(R.rooms.concat(R.free).reduce((a, r) => a + r.areaM2, 0), 2), '', '', '']); const many = rows.length > 8; PdfSheet.table(doc, 105, 242, [0, 34, 62, 78, 110, 142], ['Ruimte', 'Functie', 'Netto m²', 'Plafond', 'Vloer', 'Wanden'], rows, { title: 'Ruimtestaat', titleSize: 8, lastColW: 40, maxY: 287, rowH: many ? 3.1 : 3.8, fontSize: many ? 5.5 : 6 }); } 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 }); 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, kleurvlak = ruimte (netto m²)', drawX0, drawY1 + 4); if (!customer && parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5); } 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 } ]; 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, p.stuc), 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(WallModel.skinConfig(p.config, p.skinAbove || 0, p.skinTop)); const top = (x) => Math.min(tl.at(x), tl.at(Math.max(-W / 2, Math.min(W / 2, x)))); 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 }))) .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, p.stuc); const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : p.stuc ? WallModel.stucHex(p.stuc) : p.fels ? WallModel.felsHex(p.fels) : WallModel.rabatHex(state.settings.facade.rabatColor); faces.push({ kind: 'wand', pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 }); 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({ kind: 'trasraam', pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 }); // Afdekker: neus over de bovenkant van de steen (alleen in 3D onderbroken bij openingen). if (p.stats && p.stats.trasraamCapM > 0) { const C = WallModel.TRAS_CAP, yLo = tr.top - C.DROP_MM, yHi = tr.top + C.THICK_MM; faces.push({ kind: 'trasraam', pts: [[-W / 2, yLo], [W / 2, yLo], [W / 2, yHi], [-W / 2, yHi]].map(([lx, ly]) => toWorld(p, lx, ly, zT + C.GAP_MM + C.THICK_MM)), n, color: WallModel.trimHex(tr.capColor), bias: 1.5 }); } } (p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => { if (WallModel.openingTopMax(op) <= y0) return; 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; const exterior = p.buitenMateriaal === 'vochtdoek' || p.buitenMateriaal === 'cementplaat'; const detail = op.fill ? KozijnModel.viewLines(op, op.fill, exterior).map(pl => pl.map(q => toWorld(p, x0 + q.x, op.y + q.y, zOut))) : []; faces.push({ kind: 'opening', pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true, detail }); }); }; 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)); 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; const pushRoof = (pts) => { 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({ kind: 'dak', pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 }); }; RoofModel.capRegions(r.unitRoof).forEach(rg => { pushRoof(rg.poly.map(pt => { const ax = r.unitRoof.alongX, x = u.rect.cx + (ax ? pt.x : pt.z), z = u.rect.cz + (ax ? pt.z : pt.x); return { x, y: u.layout.floorTop + rg.under(pt) + rg.vThick, z }; })); }); 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]]; pushRoof(corners.map(([x, z]) => ({ x, y: sh.axis === 'x' ? r.topAt(inward(x), z) : r.topAt(x, inward(z)), z }))); }); }); const edge = parts.roofEdge; if (edge) { const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, z: a.z + (b.z - a.z) * t }); edge.segments.forEach(sg => { const h = RoofModel.edgeHeight(edge, sg); const n = Math.max(1, Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / 100)); const top = [], bottom = []; for (let k = 0; k <= n; k++) { const t = k / n, I = lerp(sg.a, sg.b, t), O = lerp(sg.outerA, sg.outerB, t); const tI = sg.topAt(I), tO = sg.topAt(O); top.push({ x: O.x, y: Math.max(tI, tO), z: O.z }); bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z }); } // Rooster op de gevel, onder de onderplaat. if (sg.fascia && sg.facadeUnder && edge.vent) { const V = RoofModel.VENT, d = RoofModel.ventFace(sg) + V.THICK_MM - (sg.a.x * sg.normal.x + sg.a.z * sg.normal.z); const vTop = []; for (let k = 0; k <= n; k++) { const I = lerp(sg.a, sg.b, k / n), q = { x: I.x + sg.normal.x * d, z: I.z + sg.normal.z * d }; vTop.push({ x: q.x, y: sg.topAt(q) - h, z: q.z }); } faces.push({ kind: 'dakrand', pts: vTop.concat(vTop.map(q => ({ x: q.x, y: q.y - V.HEIGHT_MM, z: q.z })).reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: WallModel.trimHex(edge.ventColor), bias: 2.5 }); } faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: RoofModel.edgeColor(edge, sg), bias: 3 }); }); } (parts.chimneys || []).forEach(ch => { const S = ChimneyModel.SIDES[ch.side]; const W = (t, s, y) => S.axis === 'z' ? { x: ch.plane + S.n.x * s, y, z: t } : { x: t, y, z: ch.plane + S.n.z * s }; const color = ch.outside.type === 'steen' ? (WallModel.STONE_COLORS[ch.outside.stone] || '#8a6a5c') : WallModel.stucHex(ch.outside.stuc); const tN = S.axis === 'z' ? { x: 0, z: 1 } : { x: 1, z: 0 }; const side = (a, b, ya, n) => faces.push({ kind: 'schoorsteen', pts: [W(a.t, a.s, ya), W(b.t, b.s, ya), W(b.t, b.s, ch.yTop), W(a.t, a.s, ch.yTop)], n, color, bias: 4 }); side({ t: ch.t0, s: ch.sFront }, { t: ch.t1, s: ch.sFront }, ch.yGround, S.n); side({ t: ch.t0, s: ch.sIn }, { t: ch.t0, s: ch.sFront }, ch.yGround, { x: -tN.x, z: -tN.z }); side({ t: ch.t1, s: ch.sIn }, { t: ch.t1, s: ch.sFront }, ch.yGround, tN); side({ t: ch.t0, s: ch.sIn }, { t: ch.t1, s: ch.sIn }, ch.yUnder, { x: -S.n.x, z: -S.n.z }); }); return faces; } function materialLegend(state, parts) { const items = []; const st = state.settings; const seen = new Set(); const facades = []; state.units.forEach(u => Object.keys(u.unit.walls).forEach(side => { facades.push(HouseModel.facadeFor(state, u, side)); UnitModel.normalizeSplits(u.unit.walls[side].splits).filter(sp => sp.facade).forEach(sp => facades.push(HouseModel.facadeFor(state, u, side, sp.facade))); })); facades.forEach(fc => { const key = fc.type === 'steen' ? 'steen:' + fc.stone : fc.type === 'stuc' ? 'stuc:' + fc.stuc : fc.type === 'fels' ? 'fels:' + fc.fels : '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] } : fc.type === 'stuc' ? { label: 'Gevel', value: `Stucwerk ${((WallModel.STUC_COLORS[fc.stuc] || {}).label || '').toLowerCase()}`, color: WallModel.stucHex(fc.stuc) } : fc.type === 'fels' ? { label: 'Gevel', value: `Felsplaten ${((WallModel.FELS.COLORS[fc.fels] || {}).label || '').toLowerCase()} (staande naad)`, color: WallModel.felsHex(fc.fels) } : { label: 'Gevel', value: `Rabat ${((WallModel.RABAT_COLORS[st.facade.rabatColor] || {}).label || '').toLowerCase()} (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: WallModel.rabatHex(st.facade.rabatColor) }); }); 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 }); const tc = WallModel.TRIM_COLORS[st.trasraam.capColor]; if (parts && tc && HouseModel.allWallPieces(parts).some(p => p.stats && p.stats.trasraamCapM > 0)) items.push({ label: 'Afdekker trasraam', value: tc.label, color: tc.hex }); } const vc = parts && parts.roofEdge && parts.roofEdge.vent && parts.roofEdge.ventLengthMM > 0 && WallModel.TRIM_COLORS[parts.roofEdge.ventColor]; if (vc) items.push({ label: 'Ventilatierooster', value: vc.label, color: vc.hex }); (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; } 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); }); } 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); }); } // Per gevel de zichtbare vlakken in (u, hoogte), van achter naar voor. function viewProjections(parts, state) { const faces = viewFaces(parts, state); const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0); 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); return { groundY, views }; } function drawViewsSheet(doc, parts, state, sheetOpts) { const { BLACK } = PdfSheet.COLORS; const { groundY, views } = viewProjections(parts, state); 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; 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); }); 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); } const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', kilkeper: 'Kilkeper', nokbalk: 'Nokbalk', knikbalk: 'Knikbalk', kopbalk: 'Kopbalk' }; function drawCapSheet(doc, u, sheetOpts) { const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS; const ur = u.roof.unitRoof, c = ur.cap; const xz = (pt) => ur.alongX ? { x: pt.x, z: pt.z } : { x: pt.z, z: pt.x }; const band = [{ x: c.pA, z: c.q0 }, { x: c.pB, z: c.q0 }, { x: c.pB, z: c.q1 }, { x: c.pA, z: c.q1 }].map(xz); const xs = band.map(p => p.x), zs = band.map(p => p.z); const X0 = 20, X1 = 275, Y0 = 32, Y1 = 230; const sc = Math.min((X1 - X0) / (Math.max(...xs) - Math.min(...xs)), (Y1 - Y0) / (Math.max(...zs) - Math.min(...zs))); const ox = (X0 + X1) / 2 - (Math.min(...xs) + Math.max(...xs)) / 2 * sc, oz = (Y0 + Y1) / 2 - (Math.min(...zs) + Math.max(...zs)) / 2 * sc; const P = (p) => [ox + p.x * sc, oz + p.z * sc]; const poly = (pts, style) => { const q = pts.map(P); doc.lines(q.slice(1).map((b, k) => [b[0] - q[k][0], b[1] - q[k][1]]), q[0][0], q[0][1], [1, 1], style, true); }; doc.setTextColor(...BLACK); doc.setFontSize(11); const join = c.kind === 'join'; doc.text(join ? 'Bovenaanzicht dak (aansluiting: kil- en hoekkepers, kreupele sporen)' : 'Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20); doc.setDrawColor(...GREY); doc.setLineWidth(0.15); c.regions.forEach(rg => poly(rg.poly.map(xz), 'S')); doc.setDrawColor(...RED); doc.setLineWidth(0.2); c.beams.forEach(bm => poly(bm.poly.map(xz), 'S')); doc.setDrawColor(...BLUE); doc.setTextColor(...BLUE); doc.setFontSize(7); const bx0 = Math.min(...xs), bx1 = Math.max(...xs), bz0 = Math.min(...zs), bz1 = Math.max(...zs); const [ax, ay] = P({ x: bx0, z: bz1 }), [bx, by] = P({ x: bx1, z: bz1 }); PdfSheet.dimLine(doc, ax, ay + 8, bx, by + 8); doc.text(`${Math.round(bx1 - bx0)}`, (ax + bx) / 2, ay + 7, { align: 'center' }); const [cx0, cy0] = P({ x: bx1, z: bz0 }), [cx1, cy1] = P({ x: bx1, z: bz1 }); PdfSheet.dimLine(doc, cx0 + 8, cy0, cx1 + 8, cy1); doc.text(`${Math.round(bz1 - bz0)}`, cx0 + 9.5, (cy0 + cy1) / 2, { angle: 90 }); const groups = new Map(); c.beams.forEach(bm => { const key = `${bm.kind}|${Math.round(bm.length / 10) * 10}|${bm.angle}`; const g = groups.get(key) || { kind: bm.kind, length: Math.round(bm.length / 10) * 10, angle: bm.angle, n: 0 }; g.n++; groups.set(key, g); }); const order = Object.keys(CAP_KINDS); const rows = [...groups.values()].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.length - a.length) .map(g => [CAP_KINDS[g.kind] || g.kind, String(g.length), String(g.angle).replace('.', ','), String(g.n)]); const H = (p) => RoofModel.profileHeight(ur.shape.profile, p); const deg = (t) => String(Math.round(Math.atan(t) * 1800 / Math.PI) / 10).replace('.', ','); const hip = c.regions.find(rg => rg.key === 'h1'); const facts = (join ? [ ['Goot (onderkant dak)', `${Math.round(c.eave)} mm`], ['Nok (onderkant dak)', `${Math.round(c.ridgeH)} mm`], ['Helling dakvlakken', `${deg(c.slope)}°`] ] : [ ['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`], ['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`], ['Helling dakvlakken', `${deg(c.slope)}°`], ['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'] ]).concat([ ['Balken', `${c.joistW} x ${c.joistH} mm`], ['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`], ['Hout', `${fmt(c.specs.joistWoodM3, 3)} m³`] ]); doc.setTextColor(...BLACK); let y = PdfSheet.table(doc, 290, 20, [0, 52], ['Gegeven', 'Waarde'], facts, { title: join ? 'Dak met aansluiting' : 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 }); PdfSheet.table(doc, 290, y + 6, [0, 32, 56, 78], ['Soort', 'Lengte (mm)', 'Hoek (°)', 'Aantal'], rows, { title: 'Stuklijst balken', fontSize: 7, rowH: 4.6, lastColW: 14, maxY: 240 }); doc.setFontSize(7); doc.setTextColor(...GREY); doc.text('Hoogtes vanaf bovenkant vloer. Lengte = langs de balk; hoek = helling van de balk (zaagsnede aan beide koppen). Grijs = dakvlakken, rood = houtwerk.', X0, 240); 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.nokbalken.length) { y += 4; y = PdfSheet.table(doc, 120, y, [0, 22, 60], ['Unit', 'Profiel', 'Lengte, gewicht, vulhout'], totals.nokbalken.map(n => [ n.unit, `${n.name} S235${n.ok ? '' : ' (te licht!)'}`, `${fmt(n.lengthM, 2)} m, ${fmt(n.kg)} kg, ${fmt(n.fillM3, 3)} m3${n.below ? `, ${n.below} mm onder plafond` : ''}`]), tableOpts('Stalen nokbalk (IPE, vulhout in de holling)')); } 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`]); if (e.ventLengthM > 0) rows.push(['Ventilatierooster', `${fmt(e.ventLengthM, 2)} m op de gevel onder de boeidelen, ${RoofModel.VENT.HEIGHT_MM} mm hoog, ${e.ventColor.toLowerCase()}`]); if (totals.trasraamCap && totals.trasraamCap.lengthM > 0) rows.push(['Afdekker trasraam', `${fmt(totals.trasraamCap.lengthM, 2)} m, ${totals.trasraamCap.color.toLowerCase()}`]); 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)`]); if (totals.posts) { const pp = totals.posts; rows.push(['Palen overkapping', `${pp.count} stuks ${pp.wood.toLowerCase()} ${pp.size}x${pp.size} mm, ${fmt(pp.lengthM, 2)} m, ${fmt(pp.m3, 3)} m3, op paalvoeten`]); } PdfSheet.table(doc, 120, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting')); } PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); } function drawSawSheet(doc, totals, cost, 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; PdfSheet.table(doc, 290, 30, [0, 55], ['Onderdeel', 'Waarde'], [ ['Zaagmeters', fmt(sw.meters, 1) + ' m'], ['Zaagtijd robot', CostModel.uur(cost.saw.robotSec / 3600) + ' uur'], [`Zaagtijd handmatig (\u00d7${cost.settings.zagen.handFactor})`, CostModel.uur(cost.saw.handSec / 3600) + ' uur'], ['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); } const COST_ROW_H = 3.4; const COST_COLS = [{ x: 15, yMax: 278 }, { x: 150, yMax: 278 }, { x: 285, yMax: 246 }]; const COST_Y0 = 30; const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + '\u2026' : t); function costDetailRows(cost) { return cost.elements.flatMap(el => elementCostRows(el)); } function elementCostRows(el) { const rows = []; if (el.lines.length) { rows.push({ head: true, label: `${el.element} ${el.label}`, mat: CostModel.eur(el.mat), time: CostModel.uur(el.sec / 3600) }); el.lines.forEach(l => rows.push({ label: l.label, qty: `${fmt(l.qty, ['st.', 'sneden', 'sectie'].includes(l.unit) ? 0 : 2)} ${l.unit}`, mat: l.mat ? CostModel.eur(l.mat, 2) : '', time: l.sec ? CostModel.uur(l.sec / 3600) : '' })); } return rows; } function drawElementCost(doc, el, extraLabel) { if (!el || !el.lines.length) return; const x = 132, y = 236, yMax = 287; const rows = elementCostRows(el); rows[0] = Object.assign({}, rows[0], { label: `Kosten ${el.element}${extraLabel ? ' (' + extraLabel + ')' : ''}` }); rows.push({ head: true, label: 'Kostprijs', mat: CostModel.eur(el.kostprijs), time: '' }); const fit = Math.floor((yMax - y - 5) / COST_ROW_H); let shown = rows; if (rows.length > fit) { shown = rows.slice(0, fit - 2).concat([{ label: `+ ${rows.length - fit + 1} regels: zie kostenblad detail`, mat: '', time: '' }, rows[rows.length - 1]]); } drawCostTable(doc, x, y, shown); } function paginateCostRows(rows) { const pages = []; let i = 0; while (i < rows.length) { const cols = COST_COLS.map(c => { const n = Math.floor((c.yMax - COST_Y0 - 6) / COST_ROW_H); const chunk = rows.slice(i, i + n); i += chunk.length; return chunk; }); pages.push(cols); } return pages; } function drawCostTable(doc, x, y, rows) { const { BLACK, GREY } = PdfSheet.COLORS; doc.setFontSize(6.5); doc.setTextColor(...GREY); doc.text('Omschrijving', x, y); doc.text('Hoeveelheid', x + 84, y, { align: 'right' }); doc.text('Materiaal', x + 104, y, { align: 'right' }); doc.text('Manuur', x + 124, y, { align: 'right' }); doc.setDrawColor(...GREY); doc.setLineWidth(0.15); doc.line(x, y + 1.5, x + 126, y + 1.5); y += 5; rows.forEach(r => { doc.setTextColor(...BLACK); doc.setFont('helvetica', r.head ? 'bold' : 'normal'); doc.text(clip(r.label, r.head ? 60 : 52), r.head ? x : x + 2, y); if (r.qty) doc.text(r.qty, x + 84, y, { align: 'right' }); doc.text(r.mat, x + 104, y, { align: 'right' }); doc.text(r.time, x + 124, y, { align: 'right' }); y += COST_ROW_H; }); doc.setFont('helvetica', 'normal'); } function drawCostSheet(doc, cost, sheetOpts) { const { BLACK } = PdfSheet.COLORS; const s = cost.summary, S = cost.settings, eur = (n) => CostModel.eur(n); doc.setFont('helvetica', 'normal'); doc.setTextColor(...BLACK); doc.setFontSize(14); doc.text('Kostenraming (indicatief)', 15, 18); const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 24 }); let y = PdfSheet.table(doc, 15, 30, [0, 82], ['Post', 'Bedrag'], s.rows.map(r => [r.label, eur(r.amount)]).concat([['Totaal incl. btw', eur(s.inclBtw)]]), tableOpts('Samenvatting')); y = PdfSheet.table(doc, 15, y + 6, [0, 82], ['Groep', 'Materiaal'], cost.categories.filter(r => r.mat > 0).map(r => [r.name, eur(r.mat)]), tableOpts('Materiaal per groep')); PdfSheet.table(doc, 15, y + 6, [0, 82], ['Werkzaamheid', 'Manuur'], cost.activities.filter(r => r.sec > 0).map(r => [r.name, CostModel.uur(r.sec / 3600)]) .concat([[`Toeslag indirect ${S.arbeid.toeslagUren}%`, CostModel.uur(s.hoursIndirect)], [`QA ${S.arbeid.qa}%`, CostModel.uur(s.hoursQa)], ['Totaal', CostModel.uur(s.hours)]]), tableOpts('Arbeid per werkzaamheid')); PdfSheet.table(doc, 130, 30, [0, 72, 97, 122], ['Element / onderdeel', 'Materiaal', 'Manuur', 'Kostprijs*'], cost.elements.filter(el => el.lines.length).map(el => [clip(`${el.element} ${el.label}`, 48), eur(el.mat), CostModel.uur(el.sec / 3600), eur(el.kostprijs)]), Object.assign(tableOpts('Per element'), { maxY: 278 })); PdfSheet.table(doc, 290, 30, [0, 40, 65, 88], ['Unit', 'Materiaal', 'Manuur', 'Kostprijs*'], cost.units.map(u => [u.id ? `Unit ${u.id}` : 'Woning-breed', eur(u.mat), CostModel.uur(u.sec / 3600), eur(u.kostprijs)]), tableOpts('Per unit')); doc.setFontSize(6.5); doc.text('* Kostprijs: materiaal incl. verlies en klein materiaal + arbeid (manuren incl. bezetting, indirect en QA) + afschrijving/huisvesting', 290, 236); doc.text(' (+ transport per unit, kraan bij woning-breed); zonder algemene kosten, marge en btw.', 290, 240); doc.text('Schattingen op basis van Instellingen > Kosten & tijden; bedragen excl. btw tenzij vermeld.', 290, 244); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); } function drawCostDetailSheet(doc, cols, sheetOpts) { doc.setFont('helvetica', 'normal'); doc.setTextColor(...PdfSheet.COLORS.BLACK); doc.setFontSize(14); doc.text('Kostenraming per element (detail)', 15, 18); cols.forEach((rows, i) => { if (rows.length) drawCostTable(doc, COST_COLS[i].x, COST_Y0, rows); }); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); } // opts.customer (light-versie): alleen aanzichten en plattegrond, zonder productie-informatie en kosten. // opts.doc: verder in een bestaand document (nieuwe pagina's erachter); opts.pageOffset/pageTotal: bladnummering // als er pagina's omheen komen (voorblad e.d.). function generate(state, opts) { const o = opts || {}; const customer = !!o.customer; const parts = HouseModel.computeParts(state, { wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(), elektraRough: false }); const sheets = sheetList(parts).filter(s => !customer || s.kind === 'views' || s.kind === 'plan'); const totals = customer ? null : HouseModel.computeTotals(parts); const cost = customer ? null : CostModel.compute(parts, totals); const costOf = (element) => cost.elements.find(el => el.element === element); const drawWallWithCost = (sheet, opts) => { 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 || [], overlay: ElektraPdf.wallOverlay(parts.elektra, sheet.piece) }, opts)); drawElementCost(doc, costOf(sheet.element)); }; if (!customer) { const detailPages = paginateCostRows(costDetailRows(cost)); sheets.push({ kind: 'cost', element: 'KOST', label: 'Kostenraming' }); detailPages.forEach((cols, i) => sheets.push({ kind: 'costdetail', element: `KOST-${i + 1}`, label: 'Kostenraming detail' + (detailPages.length > 1 ? ` (${i + 1})` : ''), cols })); } const project = state.project; const doc = o.doc || PdfSheet.createDoc(); const offset = o.pageOffset || 0, total = o.pageTotal || sheets.length; sheets.forEach((sheet, i) => { if (i > 0 || o.doc) doc.addPage('a3', 'landscape'); const opts = { project, titel: sheet.label, element: sheet.element, blad: `${offset + i + 1} / ${total}`, customer }; 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, Object.assign({ overlay: ElektraPdf.floorOverlay(parts.elektra, sheet.unit) }, opts)); drawElementCost(doc, costOf(sheet.element)); } else if (sheet.kind === 'roof') { const pu = parts.units.find(u => u.roof.planes.includes(sheet.plane)); FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true, overlay: pu && ElektraPdf.roofOverlay(parts.elektra, pu, sheet.plane) }, opts)); if (/-D1$/.test(sheet.element)) drawElementCost(doc, costOf(sheet.element.replace(/\d+$/, '')), 'alle dakvlakken'); } else if (sheet.kind === 'cap') drawCapSheet(doc, sheet.unit, opts); else if (sheet.kind === 'wall') drawWallWithCost(sheet, opts); else if (sheet.kind === 'saw') drawSawSheet(doc, totals, cost, opts); else if (sheet.kind === 'elektra') ElektraPdf.drawSheet(doc, parts.elektra, opts); else if (sheet.kind === 'cost') drawCostSheet(doc, cost, opts); else if (sheet.kind === 'costdetail') drawCostDetailSheet(doc, sheet.cols, opts); else drawTotalsSheet(doc, totals, opts); }); return doc; } return { generate, sheetList, materialThumbUrls, viewFaces, viewProjections, VIEWS }; })();