394 lines
20 KiB
JavaScript
394 lines
20 KiB
JavaScript
/*!
|
||
* 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 };
|
||
})();
|