/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // CAD-export van de woning (DXF): plattegrond en vier gevels in modelruimte, 1:1 in mm, met lagen per onderdeel. // Plattegrond: noord boven, zuidwesthoek van de units = 0,0. Gevels in een 2×2-raster eronder, maaiveld als basis. // Tekst, maatvoering en lijntypes zijn afgestemd op plotten op 1:50. window.HouseDxf = (() => { const SCALE = 50; const CUT = 1500; // snijvlak plattegrond boven de vloer: openingen die lager beginnen zijn doorgesneden const LAYERS = { 'PL-WAND-BUITEN': [7], 'PL-WAND-OPBOUW': [8], 'PL-GEVELBEKLEDING': [32], 'PL-WAND-BINNEN': [7], 'PL-KOZIJNEN': [4], 'PL-DRAAIRICHTING': [151], 'PL-SPARING-BOVEN': [4, 'STREEP'], 'PL-RUIMTE-GRENS': [3], 'PL-RUIMTE-TEKST': [7], 'PL-INRICHTING': [8], 'PL-DAKRAND': [8, 'STREEP'], 'PL-PALEN': [32], 'PL-SCHOORSTEEN': [8], 'PL-VERLICHTING': [2], 'PL-ELEKTRA': [6], 'PL-NADEN': [30, 'STREEPSTIP'], 'PL-ELEMENTCODES': [8], 'AZ-GEVEL': [7], 'AZ-TRASRAAM': [7], 'AZ-KOZIJNEN': [4], 'AZ-DAK': [7], 'AZ-DAKRAND': [8], 'AZ-SCHOORSTEEN': [8], 'AZ-MAAIVELD': [7], 'MAATVOERING': [5], 'TEKST': [7] }; const FACE_LAYERS = { wand: 'AZ-GEVEL', opening: 'AZ-GEVEL', trasraam: 'AZ-TRASRAAM', dak: 'AZ-DAK', dakrand: 'AZ-DAKRAND', schoorsteen: 'AZ-SCHOORSTEEN' }; const mm = (paper) => paper * SCALE; // Intervallen [a, b]: samenvoegen en aftrekken. function mergeIv(list) { const s = list.filter(([a, b]) => b > a).sort((p, q) => p[0] - q[0]); const out = []; s.forEach(([a, b]) => { const l = out[out.length - 1]; if (l && a <= l[1] + 1e-9) l[1] = Math.max(l[1], b); else out.push([a, b]); }); return out; } function subtractIv(from, cuts) { let out = from.map(iv => iv.slice()); mergeIv(cuts).forEach(([c0, c1]) => { out = out.flatMap(([a, b]) => { if (c1 <= a || c0 >= b) return [[a, b]]; const r = []; if (c0 > a) r.push([a, c0]); if (c1 < b) r.push([c1, b]); return r; }); }); return out; } // Randen van een ruimte (RoomModel: as x/z, waarde v, s0..s1) tot gesloten lussen. function roomLoops(edges) { const key = (p) => Math.round(p.x) + ',' + Math.round(p.z); const segs = edges.map(e => e.axis === 'x' ? [{ x: e.v, z: e.s0 }, { x: e.v, z: e.s1 }] : [{ x: e.s0, z: e.v }, { x: e.s1, z: e.v }]); const at = new Map(); segs.forEach((s, i) => s.forEach(p => { const k = key(p); if (!at.has(k)) at.set(k, []); at.get(k).push(i); })); const used = new Set(), out = []; segs.forEach((s, i) => { if (used.has(i)) return; used.add(i); const loop = [s[0], s[1]]; let cur = s[1]; while (key(cur) !== key(loop[0])) { const j = (at.get(key(cur)) || []).find(n => !used.has(n)); if (j === undefined) break; used.add(j); cur = key(segs[j][0]) === key(cur) ? segs[j][1] : segs[j][0]; loop.push(cur); } const closed = key(loop[loop.length - 1]) === key(loop[0]); if (closed) loop.pop(); const n = loop.length; const pts = loop.filter((p, k) => { if (!closed && (k === 0 || k === n - 1)) return true; const a = loop[(k - 1 + n) % n], b = loop[(k + 1) % n]; return !((Math.abs(a.x - p.x) < 0.5 && Math.abs(b.x - p.x) < 0.5) || (Math.abs(a.z - p.z) < 0.5 && Math.abs(b.z - p.z) < 0.5)); }); out.push({ pts, closed }); }); return out; } // Cirkelboog door het eerste, middelste en laatste punt van een polylijn (draaicirkel van een deur). function arcThrough(pts) { const a = pts[0], b = pts[Math.floor(pts.length / 2)], c = pts[pts.length - 1]; const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); if (Math.abs(d) < 1e-9) return null; const s = (p) => p.x * p.x + p.y * p.y; const ctr = { x: (s(a) * (b.y - c.y) + s(b) * (c.y - a.y) + s(c) * (a.y - b.y)) / d, y: (s(a) * (c.x - b.x) + s(b) * (a.x - c.x) + s(c) * (b.x - a.x)) / d }; const ang = (p) => Math.atan2(p.y - ctr.y, p.x - ctr.x); const ccw = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) > 0; return { c: ctr, r: Math.hypot(a.x - ctr.x, a.y - ctr.y), a0: ccw ? ang(a) : ang(c), a1: ccw ? ang(c) : ang(a) }; } // Tekstrichting langs een wand, altijd leesbaar (van onder of van rechts). const readable = (deg) => { let a = ((deg % 360) + 360) % 360; if (a > 90.01 && a <= 270.01) a -= 180; return a; }; function drawPlan(dxf, parts, T) { const W2 = (place, lx, lz) => T(UnitModel.localToWorld(place, lx, lz)); const ext = new Set(); parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.add(p)))); const P = KozijnModel.PROFILE; HouseModel.allWallPieces(parts).forEach(p => { const pl = p.place; if (pl.baseY) return; const isExt = ext.has(p); const W = pl.width, zi = -pl.thickness / 2, zo = pl.thickness / 2; const ops = (p.config.openings || []).filter(op => op.type !== 'schoorsteen'); const cutOps = ops.filter(op => op.y < CUT); const holes = mergeIv(cutOps.map(op => [Math.max(-W / 2, -W / 2 + op.x), Math.min(W / 2, -W / 2 + op.x + op.w)])); const solid = subtractIv([[-W / 2, W / 2]], holes); const lay = isExt ? 'PL-WAND-BUITEN' : 'PL-WAND-BINNEN'; const box = (a, b, z0, z1, layer, eo) => dxf.polyline([W2(pl, a, z0), W2(pl, b, z0), W2(pl, b, z1), W2(pl, a, z1)], true, layer, eo); solid.forEach(([a, b]) => box(a, b, zi, zo, lay)); const c = isExt ? WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc) : 0; if (isExt) { const sh = p.config.sheathingThick || 0; if (sh > 0) solid.forEach(([a, b]) => [zi + sh, zo - sh].forEach(z => dxf.line(W2(pl, a, z), W2(pl, b, z), 'PL-WAND-OPBOUW'))); if (c > 0) { // Hoek: buitenhoek loopt de bekleding door over de buurwand, binnenhoek stopt ze ervoor (skinExt < 0). const e = p.skinExt || {}; const xl = -W / 2 - (e.left > 0 ? e.left + c : (e.left || 0)), xr = W / 2 + (e.right > 0 ? e.right + c : (e.right || 0)); subtractIv([[xl, xr]], holes).forEach(([a, b]) => box(a, b, zo, zo + c, 'PL-GEVELBEKLEDING')); } } cutOps.forEach(op => { const f = KozijnModel.normalize(op.fill); if (!f) return; const x0 = -W / 2 + op.x, x1 = x0 + op.w; const exterior = p.buitenMateriaal === 'vochtdoek' || p.buitenMateriaal === 'cementplaat'; const kz1 = zo, kz0 = exterior ? Math.max(zi, zo - P.depth) : zi; const a = x0 + P.gap, b = x1 - P.gap; if (f.type === 'raam') { box(a, b, kz0, kz1, 'PL-KOZIJNEN'); [a + P.kader, b - P.kader].forEach(x => dxf.line(W2(pl, x, kz0), W2(pl, x, kz1), 'PL-KOZIJNEN')); dxf.line(W2(pl, a + P.kader, kz1 - 50), W2(pl, b - P.kader, kz1 - 50), 'PL-KOZIJNEN'); return; } box(a, a + P.kader, kz0, kz1, 'PL-KOZIJNEN'); box(b - P.kader, b, kz0, kz1, 'PL-KOZIJNEN'); KozijnModel.planSymbol(op, op.fill, { zIn: zi, zOut: zo }).forEach(sh => { const pts = sh.pts.map(q => W2(pl, x0 + q.x, q.z)); if (sh.kind === 'arc') { const arc = arcThrough(pts); if (arc) dxf.arc(arc.c, arc.r, arc.a0, arc.a1, 'PL-DRAAIRICHTING'); } else if (sh.kind === 'rail') dxf.polyline(pts, !!sh.closed, 'PL-DRAAIRICHTING', { ltype: 'STIP' }); else dxf.polyline(pts, !!sh.closed, sh.kind === 'arrow' ? 'PL-DRAAIRICHTING' : 'PL-KOZIJNEN'); }); }); // Sparingen boven het snijvlak (bovenlichten, hoge ramen) gestreept, tenzij ze boven een doorgesneden opening liggen. ops.filter(op => op.y >= CUT).forEach(op => { const x0 = -W / 2 + op.x, x1 = x0 + op.w; if (holes.some(([a, b]) => x0 >= a - 1 && x1 <= b + 1)) return; box(x0, x1, zi, zo, 'PL-SPARING-BOVEN'); }); // Elementcode binnen langs de wand, verticaal gecentreerd op 2,5 mm (papier) van het binnenvlak. const rot = readable(Math.atan2(W2(pl, 1, 0).y - W2(pl, 0, 0).y, W2(pl, 1, 0).x - W2(pl, 0, 0).x) * 180 / Math.PI) * Math.PI / 180; const mid = W2(pl, 0, zi - mm(2.5)), th = mm(2); dxf.text({ x: mid.x + Math.sin(rot) * th / 2, y: mid.y - Math.cos(rot) * th / 2 }, th, p.element, 'PL-ELEMENTCODES', { align: 'center', rot: rot * 180 / Math.PI }); if (isExt) { const xs = [-W / 2, W / 2]; holes.forEach(([a, b]) => xs.push(a, b)); const ticks = [...new Set(xs.map(x => Math.round(x * 10) / 10))].sort((m, n) => m - n); const zf = zo + c, row1 = zf + 700, row2 = row1 + 450; for (let i = 0; i < ticks.length - 1; i++) { dxf.dim(W2(pl, ticks[i], zf), W2(pl, ticks[i + 1], zf), W2(pl, (ticks[i] + ticks[i + 1]) / 2, row1), 'MAATVOERING'); } if (ticks.length > 2) dxf.dim(W2(pl, -W / 2, zf), W2(pl, W / 2, zf), W2(pl, 0, row2), 'MAATVOERING'); } }); const R = parts.rooms; R.rooms.concat(R.free).forEach(r => { roomLoops(r.edges || []).forEach(lp => dxf.polyline(lp.pts.map(T), lp.closed, 'PL-RUIMTE-GRENS')); const named = !!r.name; const lines = [[named ? r.name : 'Ruimte (geen functie)', 3.5], [`${r.areaM2.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} m²`, 2.5]]; if (r.ceilingActive) lines.push([`plafond ${r.ceiling}`, 2]); const c = T(r.label), step = mm(4.5); lines.forEach(([t, size], i) => dxf.text({ x: c.x, y: c.y + ((lines.length - 1) / 2 - i) * step - mm(size) / 2 }, mm(size), t, 'PL-RUIMTE-TEKST', { align: 'center' })); }); (parts.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => { const eo = sh.kind === 'dash' ? { ltype: 'STREEP' } : null; if (sh.circle) dxf.circle(T(sh.circle), sh.circle.r, 'PL-INRICHTING', eo); else if (sh.text) { const q = T(sh.at); dxf.text({ x: q.x, y: q.y - mm(0.7) }, mm(1.4), sh.text, 'PL-INRICHTING', { align: 'center' }); } else dxf.polyline(sh.pts.map(T), !!sh.closed, 'PL-INRICHTING', eo); })); if (parts.roofEdge) { parts.roofEdge.segments.forEach(sg => dxf.line(T(sg.outerA), T(sg.outerB), 'PL-DAKRAND')); parts.roofEdge.spots.forEach(sp => dxf.circle(T(sp), mm(0.8), 'PL-VERLICHTING')); } else { parts.units.forEach(u => { const r = u.roof.rect; dxf.polyline([{ x: r.x0, z: r.z0 }, { x: r.x1, z: r.z0 }, { x: r.x1, z: r.z1 }, { x: r.x0, z: r.z1 }].map(T), true, 'PL-DAKRAND'); }); } parts.posts.posts.forEach(pp => { const s = pp.size / 2; dxf.polyline([{ x: pp.x - s, z: pp.z - s }, { x: pp.x + s, z: pp.z - s }, { x: pp.x + s, z: pp.z + s }, { x: pp.x - s, z: pp.z + s }].map(T), true, 'PL-PALEN'); }); parts.posts.walls.forEach(w => dxf.polyline([{ x: w.x0, z: w.z0 }, { x: w.x1, z: w.z0 }, { x: w.x1, z: w.z1 }, { x: w.x0, z: w.z1 }].map(T), true, 'PL-PALEN')); (parts.chimneys || []).forEach(ch => { dxf.polyline(ch.footprint.map(T), true, 'PL-SCHOORSTEEN'); dxf.polyline(ch.flueFootprint.map(T), true, 'PL-SCHOORSTEEN'); }); parts.lighting.lamps.forEach(l => { const q = T({ x: l.x + l.normal.x * 80, z: l.z + l.normal.z * 80 }), s = mm(0.9); dxf.polyline([{ x: q.x - s, y: q.y - s }, { x: q.x + s, y: q.y - s }, { x: q.x + s, y: q.y + s }, { x: q.x - s, y: q.y + s }], true, 'PL-VERLICHTING'); }); const o = T({ x: 0, z: 0 }); ElektraPdf.drawPlan(DxfWriter.pdfAdapter(dxf, 'PL-ELEKTRA'), parts.elektra, (x) => (x + o.x) / SCALE, (z) => (z - o.y) / SCALE); parts.seams.forEach(s => dxf.line(T({ x: s.x0, z: s.z0 }), T({ x: s.x1, z: s.z1 }), 'PL-NADEN')); parts.rects.forEach(r => { const q = T({ x: r.x0 + 450, z: r.z0 + 450 }); dxf.text({ x: q.x, y: q.y - mm(8) }, mm(8), r.id, 'PL-ELEMENTCODES'); }); // Totaalmaten en noordpijl buiten alles. 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 e = dxf.extents(); dxf.dim(T({ x: bx0, z: bz0 }), T({ x: bx1, z: bz0 }), { x: 0, y: e.y1 + 700 }, 'MAATVOERING'); dxf.dim(T({ x: bx0, z: bz1 }), T({ x: bx0, z: bz0 }), { x: e.x0 - 700, y: 0 }, 'MAATVOERING'); const na = { x: e.x1 + mm(10), y: e.y1 - mm(4) }, ns = mm(4); dxf.polyline([{ x: na.x, y: na.y + ns }, { x: na.x - ns * 0.4, y: na.y - ns * 0.6 }, { x: na.x, y: na.y - ns * 0.3 }, { x: na.x + ns * 0.4, y: na.y - ns * 0.6 }], true, 'TEKST'); dxf.text({ x: na.x, y: na.y - ns * 0.6 - mm(5) }, mm(3.5), 'N', 'TEKST', { align: 'center' }); } function simplifyPoly(pts) { let p = pts.filter((q, i) => { const r = pts[(i + 1) % pts.length]; return Math.hypot(r.x - q.x, r.y - q.y) > 0.5; }); let changed = true; while (changed && p.length > 3) { changed = false; for (let i = 0; i < p.length; i++) { const a = p[(i - 1 + p.length) % p.length], b = p[i], c = p[(i + 1) % p.length]; const len = Math.hypot(c.x - a.x, c.y - a.y); if (len > 0 && Math.abs((c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x)) / len < 0.5) { p.splice(i, 1); changed = true; break; } } } return p; } const insidePoly = (q, poly) => { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if ((a.y > q.y) !== (b.y > q.y) && q.x < (b.x - a.x) * (q.y - a.y) / (b.y - a.y) + a.x) inside = !inside; } return inside; }; const distSeg = (q, a, b) => { const dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy; const t = l2 ? Math.max(0, Math.min(1, ((q.x - a.x) * dx + (q.y - a.y) * dy) / l2)) : 0; return Math.hypot(q.x - a.x - t * dx, q.y - a.y - t * dy); }; // Echt binnen (niet op de rand): lijnen die samenvallen met de rand van een vlak ervoor blijven zichtbaar. const strictlyInside = (q, poly) => insidePoly(q, poly) && poly.every((a, i) => distSeg(q, a, poly[(i + 1) % poly.length]) > 0.5); // Delen [t0, t1] van segment a-b die door poly worden afgedekt. function coveredBy(a, b, poly) { const ts = [0, 1], dx = b.x - a.x, dy = b.y - a.y; poly.forEach((c, i) => { const d = poly[(i + 1) % poly.length], ex = d.x - c.x, ey = d.y - c.y; const den = dx * ey - dy * ex; if (Math.abs(den) < 1e-9) return; const t = ((c.x - a.x) * ey - (c.y - a.y) * ex) / den, s = ((c.x - a.x) * dy - (c.y - a.y) * dx) / den; if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t); }); ts.sort((m, n) => m - n); const out = []; for (let i = 0; i < ts.length - 1; i++) { const t0 = ts[i], t1 = ts[i + 1]; if (t1 - t0 < 1e-7) continue; const tm = (t0 + t1) / 2; if (!strictlyInside({ x: a.x + dx * tm, y: a.y + dy * tm }, poly)) continue; const l = out[out.length - 1]; if (l && Math.abs(l[1] - t0) < 1e-7) l[1] = t1; else out.push([t0, t1]); } return out; } // Verborgen lijnen weg: vlakken van achter naar voor, elke rand wordt afgedekt door de vlakken die later komen. function hiddenLines(faces) { const boxOf = (pts) => ({ x0: Math.min(...pts.map(p => p.x)), x1: Math.max(...pts.map(p => p.x)), y0: Math.min(...pts.map(p => p.y)), y1: Math.max(...pts.map(p => p.y)) }); const hit = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1; faces.forEach(f => { f.box = boxOf(f.poly); }); const out = []; faces.forEach((f, i) => { const segs = []; const ring = (pts, layer) => pts.forEach((a, k) => segs.push({ a, b: pts[(k + 1) % pts.length], layer })); ring(f.poly, f.layer); f.detail.forEach(pl => ring(pl, 'AZ-KOZIJNEN')); segs.forEach(sg => { const len = Math.hypot(sg.b.x - sg.a.x, sg.b.y - sg.a.y); if (len < 1) return; const sb = boxOf([sg.a, sg.b]); sb.x0 -= 1; sb.y0 -= 1; sb.x1 += 1; sb.y1 += 1; let vis = [[0, 1]]; for (let j = i + 1; j < faces.length && vis.length; j++) { if (!hit(sb, faces[j].box)) continue; const cov = coveredBy(sg.a, sg.b, faces[j].poly); if (cov.length) vis = subtractIv(vis, cov); } const at = (t) => ({ x: sg.a.x + (sg.b.x - sg.a.x) * t, y: sg.a.y + (sg.b.y - sg.a.y) * t }); vis.forEach(([t0, t1]) => { if ((t1 - t0) * len > 1) out.push({ a: at(t0), b: at(t1), layer: sg.layer }); }); }); }); return out; } // Samenvallende en in elkaars verlengde liggende stukken per laag samenvoegen (gedeelde randen maar één keer). function mergeCollinear(segs) { const groups = new Map(); segs.forEach(s => { let dx = s.b.x - s.a.x, dy = s.b.y - s.a.y; const len = Math.hypot(dx, dy); dx /= len; dy /= len; if (dx < -1e-9 || (Math.abs(dx) < 1e-9 && dy < 0)) { dx = -dx; dy = -dy; } const off = -dy * s.a.x + dx * s.a.y; const key = `${s.layer}|${Math.round(Math.atan2(dy, dx) * 2000)}|${Math.round(off)}`; if (!groups.has(key)) groups.set(key, { dx, dy, off, layer: s.layer, iv: [] }); const g = groups.get(key); const ta = s.a.x * dx + s.a.y * dy, tb = s.b.x * dx + s.b.y * dy; g.iv.push([Math.min(ta, tb), Math.max(ta, tb)]); }); const out = []; groups.forEach(g => mergeIv(g.iv).forEach(([t0, t1]) => { const p = (t) => ({ x: g.dx * t - g.dy * g.off, y: g.dy * t + g.dx * g.off }); out.push({ a: p(t0), b: p(t1), layer: g.layer }); })); return out; } function drawViews(dxf, parts, state, planExt) { const { groundY, views } = HousePdf.viewProjections(parts, state); if (!views.length) return; const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop)); const maxW = Math.max(...views.map(w => w.u1 - w.u0)), maxH = Math.max(...views.map(w => w.v1 - groundY)); const gapX = 5000, cellH = maxH + 4500; views.forEach((w, i) => { const col = i % 2, row = Math.floor(i / 2); const ox = planExt.x0 + col * (maxW + gapX) - w.u0; const base = planExt.y0 - 3500 - maxH - row * cellH; const P = (u, v) => ({ x: ox + u, y: base + (v - groundY) }); const faces = w.list.map(l => ({ poly: simplifyPoly(l.pts.map(p => ({ x: p.u, y: p.v }))), layer: FACE_LAYERS[l.f.kind] || 'AZ-GEVEL', detail: (l.f.detail || []).map(pl => simplifyPoly(pl.map(p => ({ x: w.v.u(p), y: p.y })))) })).filter(f => f.poly.length >= 3); mergeCollinear(hiddenLines(faces)).forEach(s => dxf.line(P(s.a.x, s.a.y), P(s.b.x, s.b.y), s.layer)); dxf.line(P(w.u0 - 1000, groundY), P(w.u1 + 1000, groundY), 'AZ-MAAIVELD'); const left = P(w.u0, groundY), xd = left.x - 900; if (floorTop - groundY > 1) dxf.dim(P(w.u0, groundY), P(w.u0, floorTop), { x: xd, y: 0 }, 'MAATVOERING', { dir: { x: 0, y: 1 } }); dxf.dim(P(w.u0, floorTop), P(w.u0, w.v1), { x: xd, y: 0 }, 'MAATVOERING', { dir: { x: 0, y: 1 } }); const peil = P(w.u0, floorTop); dxf.line({ x: xd - mm(10), y: peil.y }, { x: xd - mm(2), y: peil.y }, 'MAATVOERING'); dxf.text({ x: xd - mm(10), y: peil.y + mm(1) }, mm(2), 'peil', 'MAATVOERING'); dxf.dim(P(w.u0, groundY), P(w.u1, groundY), { x: 0, y: base - 700 }, 'MAATVOERING'); dxf.text(P((w.u0 + w.u1) / 2, groundY - 1300 - mm(5)), mm(5), w.v.title.toUpperCase(), 'TEKST', { align: 'center' }); }); } function generate(state) { const parts = HouseModel.computeParts(state, { skip3D: true }); if (!parts.units.length) throw new Error('geen units in het ontwerp'); const dxf = DxfWriter.create({ scale: SCALE }); Object.entries(LAYERS).forEach(([name, [color, ltype]]) => dxf.layer(name, color, ltype)); const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1)); const T = (p) => ({ x: p.x - bx0, y: bz1 - p.z }); drawPlan(dxf, parts, T); const planExt = dxf.extents(); dxf.text({ x: planExt.x0, y: planExt.y0 - mm(12) }, mm(5), 'PLATTEGROND', 'TEKST'); drawViews(dxf, parts, state, planExt); const pr = state.project || {}; const version = typeof APP_VERSION !== 'undefined' ? ` ${APP_VERSION}` : ''; const info = [ pr.naam, [pr.straat, pr.nummer].filter(Boolean).join(' '), pr.plaats, `Tectum${version}, ${new Date().toLocaleDateString('nl-NL')}`, 'Maten in mm, peil = bovenkant vloer; tekst en maatvoering voor 1:50.' ].filter(Boolean); info.forEach((t, i) => dxf.text({ x: planExt.x1 + mm(30), y: planExt.y1 - mm(5) - i * mm(4.5) }, mm(i === 0 && pr.naam ? 3.5 : 2.5), t, 'TEKST')); return dxf; } return { generate, LAYERS }; })();