285 lines
14 KiB
JavaScript
285 lines
14 KiB
JavaScript
/*!
|
||
* Tectum
|
||
* Copyright © 2026 Machiel Heinen.
|
||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||
* Zie LICENTIE.md.
|
||
*/
|
||
// DXF R12 (AC1009): het formaat dat elk CAD-pakket opent; DWG maakt het CAD-pakket zelf (Opslaan als).
|
||
// Eenheid mm, tekst in ANSI_1252. Maten zijn echte DIMENSION-entiteiten (met blok), dus aanpasbaar in CAD.
|
||
|
||
window.DxfWriter = (() => {
|
||
const CP1252 = { 0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, 0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a, 0x2039: 0x8b, 0x0152: 0x8c, 0x017d: 0x8e,
|
||
0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b, 0x0153: 0x9c, 0x017e: 0x9e, 0x0178: 0x9f };
|
||
|
||
const LTYPES = {
|
||
CONTINUOUS: { desc: 'Solid line', dashes: [] },
|
||
STREEP: { desc: '__ __ __', dashes: [4, -2] },
|
||
STIP: { desc: '. . . .', dashes: [0.8, -0.8] },
|
||
STREEPSTIP: { desc: '__ . __ .', dashes: [6, -1.5, 0.5, -1.5] }
|
||
};
|
||
|
||
const num = (v) => {
|
||
const r = Math.round(v * 1000) / 1000;
|
||
return Object.is(r, -0) || !isFinite(r) ? '0' : String(r);
|
||
};
|
||
const deg = (rad) => ((rad * 180 / Math.PI) % 360 + 360) % 360;
|
||
|
||
// Een groepswaarde mag geen regeleinde bevatten.
|
||
const clean = (s) => String(s == null ? '' : s).replace(/[\r\n]+/g, ' ');
|
||
|
||
function create(opts) {
|
||
const o = opts || {};
|
||
const scale = o.scale || 50; // plotschaal: tekst/maatvoering/lijntypes in papier-mm × scale
|
||
const layers = new Map([['0', { color: 7, ltype: 'CONTINUOUS' }]]);
|
||
const entities = [];
|
||
const blocks = [];
|
||
let dimCount = 0;
|
||
const ext = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
|
||
const grow = (x, y) => {
|
||
if (x < ext.x0) ext.x0 = x; if (x > ext.x1) ext.x1 = x;
|
||
if (y < ext.y0) ext.y0 = y; if (y > ext.y1) ext.y1 = y;
|
||
};
|
||
|
||
function layer(name, color, ltype) {
|
||
if (!layers.has(name)) layers.set(name, { color: color || 7, ltype: LTYPES[ltype] ? ltype : 'CONTINUOUS' });
|
||
return name;
|
||
}
|
||
|
||
function common(out, lay, eo) {
|
||
out.push(8, lay || '0');
|
||
if (eo && eo.ltype && LTYPES[eo.ltype]) out.push(6, eo.ltype);
|
||
if (eo && eo.color !== undefined && eo.color !== null) out.push(62, eo.color);
|
||
}
|
||
function emit(target, type, lay, eo, body) {
|
||
const out = [0, type];
|
||
common(out, lay, eo);
|
||
target.push(out.concat(body));
|
||
}
|
||
|
||
function line(a, b, lay, eo, target) {
|
||
if (Math.hypot(b.x - a.x, b.y - a.y) < 0.01) return;
|
||
emit(target || entities, 'LINE', lay, eo, [10, num(a.x), 20, num(a.y), 30, 0, 11, num(b.x), 21, num(b.y), 31, 0]);
|
||
if (!target) { grow(a.x, a.y); grow(b.x, b.y); }
|
||
}
|
||
|
||
function polyline(pts, closed, lay, eo, target) {
|
||
const p = pts.filter((q, i) => i === 0 || Math.hypot(q.x - pts[i - 1].x, q.y - pts[i - 1].y) > 0.01);
|
||
if (closed && p.length > 2 && Math.hypot(p[0].x - p[p.length - 1].x, p[0].y - p[p.length - 1].y) < 0.01) p.pop();
|
||
if (p.length < 2) return;
|
||
if (p.length === 2 && !closed) { line(p[0], p[1], lay, eo, target); return; }
|
||
const t = target || entities;
|
||
emit(t, 'POLYLINE', lay, eo, [66, 1, 10, 0, 20, 0, 30, 0, 70, closed ? 1 : 0]);
|
||
p.forEach(q => { emit(t, 'VERTEX', lay, null, [10, num(q.x), 20, num(q.y), 30, 0]); if (!target) grow(q.x, q.y); });
|
||
emit(t, 'SEQEND', lay, null, []);
|
||
}
|
||
|
||
function circle(c, r, lay, eo, target) {
|
||
emit(target || entities, 'CIRCLE', lay, eo, [10, num(c.x), 20, num(c.y), 30, 0, 40, num(r)]);
|
||
if (!target) { grow(c.x - r, c.y - r); grow(c.x + r, c.y + r); }
|
||
}
|
||
|
||
// Hoeken in radialen, tegen de klok in van a0 naar a1.
|
||
function arc(c, r, a0, a1, lay, eo, target) {
|
||
emit(target || entities, 'ARC', lay, eo, [10, num(c.x), 20, num(c.y), 30, 0, 40, num(r), 50, num(deg(a0)), 51, num(deg(a1))]);
|
||
if (!target) { grow(c.x - r, c.y - r); grow(c.x + r, c.y + r); }
|
||
}
|
||
|
||
// h = teksthoogte in model-mm; align: left|center|right; rot in graden.
|
||
function text(p, h, str, lay, to, target) {
|
||
const s = clean(str);
|
||
if (!s) return;
|
||
const t = to || {};
|
||
const hj = t.align === 'center' ? 1 : t.align === 'right' ? 2 : 0;
|
||
const body = [10, num(p.x), 20, num(p.y), 30, 0, 40, num(h), 1, s];
|
||
if (t.rot) body.push(50, num(t.rot));
|
||
body.push(7, 'STANDARD');
|
||
if (hj) body.push(72, hj, 11, num(p.x), 21, num(p.y), 31, 0);
|
||
emit(target || entities, 'TEXT', lay, t, body);
|
||
if (!target) { grow(p.x - s.length * h * 0.35, p.y); grow(p.x + s.length * h * 0.35, p.y + h); }
|
||
}
|
||
|
||
// Lineaire maat tussen p1 en p2, maatlijn door 'at' (evenwijdig aan p1-p2 tenzij dir gegeven).
|
||
const DIM = { txt: 2.5, exo: 1.5, exe: 1.5, tsz: 1.25, gap: 0.8, dle: 1.5 };
|
||
function dim(p1, p2, at, lay, dopts) {
|
||
const d = dopts || {};
|
||
let ux = p2.x - p1.x, uy = p2.y - p1.y;
|
||
if (d.dir) { ux = d.dir.x; uy = d.dir.y; }
|
||
const ul = Math.hypot(ux, uy);
|
||
if (ul < 1e-9) return;
|
||
ux /= ul; uy /= ul;
|
||
const nx = -uy, ny = ux;
|
||
const off = (at.x - p1.x) * nx + (at.y - p1.y) * ny;
|
||
const s1 = (p1.x - at.x) * ux + (p1.y - at.y) * uy, s2 = (p2.x - at.x) * ux + (p2.y - at.y) * uy;
|
||
const measured = Math.abs(s2 - s1);
|
||
if (measured < 1) return;
|
||
const q1 = { x: at.x + ux * s1, y: at.y + uy * s1 }, q2 = { x: at.x + ux * s2, y: at.y + uy * s2 };
|
||
const sg = Math.sign(s2 - s1) || 1, k = scale;
|
||
const name = `*D${++dimCount}`;
|
||
const be = [];
|
||
const BB = { color: 0 };
|
||
// Maatlijn voorbij de streepjes, hulplijnen met afstand tot het object.
|
||
line({ x: q1.x - ux * sg * DIM.dle * k, y: q1.y - uy * sg * DIM.dle * k }, { x: q2.x + ux * sg * DIM.dle * k, y: q2.y + uy * sg * DIM.dle * k }, '0', BB, be);
|
||
[[p1, q1], [p2, q2]].forEach(([p, q]) => {
|
||
const side = Math.sign(off) || 1;
|
||
const a = { x: p.x + nx * side * DIM.exo * k, y: p.y + ny * side * DIM.exo * k };
|
||
const b = { x: q.x + nx * side * DIM.exe * k, y: q.y + ny * side * DIM.exe * k };
|
||
if (Math.abs(off) > DIM.exo * k) line(a, b, '0', BB, be);
|
||
const t = DIM.tsz * k * Math.SQRT1_2;
|
||
line({ x: q.x - (ux + nx) * t, y: q.y - (uy + ny) * t }, { x: q.x + (ux + nx) * t, y: q.y + (uy + ny) * t }, '0', BB, be);
|
||
});
|
||
let ang = Math.atan2(uy, ux) * 180 / Math.PI;
|
||
// Tekst leesbaar: van onder of van rechts.
|
||
let tnx = nx, tny = ny;
|
||
if (ang > 90.01 || ang <= -89.99) { ang += 180; tnx = -nx; tny = -ny; }
|
||
ang = (ang + 360) % 360;
|
||
const mid = { x: (q1.x + q2.x) / 2, y: (q1.y + q2.y) / 2 };
|
||
const label = d.text !== undefined ? d.text : String(Math.round(measured));
|
||
const tp = { x: mid.x + tnx * DIM.gap * k, y: mid.y + tny * DIM.gap * k };
|
||
text(tp, DIM.txt * k, label, '0', { align: 'center', rot: ang, color: 0 }, be);
|
||
blocks.push({ name, entities: be });
|
||
const tm = { x: mid.x + tnx * (DIM.gap + DIM.txt / 2) * k, y: mid.y + tny * (DIM.gap + DIM.txt / 2) * k };
|
||
const body = [2, name, 3, 'TECTUM', 10, num(q2.x), 20, num(q2.y), 30, 0, 11, num(tm.x), 21, num(tm.y), 31, 0, 70, 0];
|
||
if (d.text !== undefined) body.push(1, clean(d.text));
|
||
body.push(13, num(p1.x), 23, num(p1.y), 33, 0, 14, num(p2.x), 24, num(p2.y), 34, 0, 50, num(deg(Math.atan2(uy, ux))));
|
||
emit(entities, 'DIMENSION', lay, null, body);
|
||
[p1, p2, q1, q2, tm].forEach(p => grow(p.x, p.y));
|
||
}
|
||
|
||
function header() {
|
||
const has = isFinite(ext.x0);
|
||
const e = has ? ext : { x0: 0, y0: 0, x1: 10000, y1: 10000 };
|
||
const v = [
|
||
['$ACADVER', 1, 'AC1009'], ['$DWGCODEPAGE', 3, 'ANSI_1252'],
|
||
['$INSBASE', 10, 0, 20, 0, 30, 0],
|
||
['$EXTMIN', 10, num(e.x0), 20, num(e.y0), 30, 0], ['$EXTMAX', 10, num(e.x1), 20, num(e.y1), 30, 0],
|
||
['$LIMMIN', 10, num(e.x0), 20, num(e.y0)], ['$LIMMAX', 10, num(e.x1), 20, num(e.y1)],
|
||
['$LTSCALE', 40, scale], ['$TEXTSIZE', 40, 2.5 * scale], ['$TEXTSTYLE', 7, 'STANDARD'], ['$CLAYER', 8, '0'],
|
||
['$LUNITS', 70, 2], ['$LUPREC', 70, 0], ['$AUNITS', 70, 0], ['$AUPREC', 70, 0],
|
||
['$DIMSTYLE', 2, 'TECTUM'], ['$DIMSCALE', 40, scale], ['$DIMTXT', 40, DIM.txt], ['$DIMTSZ', 40, DIM.tsz], ['$DIMASZ', 40, 2.5],
|
||
['$DIMEXO', 40, DIM.exo], ['$DIMEXE', 40, DIM.exe], ['$DIMDLE', 40, DIM.dle], ['$DIMGAP', 40, DIM.gap],
|
||
['$DIMTAD', 70, 1], ['$DIMTIH', 70, 0], ['$DIMTOH', 70, 0], ['$DIMZIN', 70, 0]
|
||
];
|
||
const out = [0, 'SECTION', 2, 'HEADER'];
|
||
v.forEach(([name, ...rest]) => out.push(9, name, ...rest));
|
||
out.push(0, 'ENDSEC');
|
||
return out;
|
||
}
|
||
|
||
function table(name, rows) {
|
||
const out = [0, 'TABLE', 2, name, 70, rows.length];
|
||
rows.forEach(r => out.push(...r));
|
||
out.push(0, 'ENDTAB');
|
||
return out;
|
||
}
|
||
|
||
function tables() {
|
||
const has = isFinite(ext.x0);
|
||
const e = has ? ext : { x0: 0, y0: 0, x1: 10000, y1: 10000 };
|
||
const w = Math.max(1000, e.x1 - e.x0), h = Math.max(1000, e.y1 - e.y0);
|
||
const vport = [0, 'VPORT', 2, '*ACTIVE', 70, 0, 10, 0, 20, 0, 11, 1, 21, 1,
|
||
12, num((e.x0 + e.x1) / 2), 22, num((e.y0 + e.y1) / 2), 13, 0, 23, 0, 14, 10, 24, 10, 15, 100, 25, 100,
|
||
16, 0, 26, 0, 36, 1, 17, 0, 27, 0, 37, 0, 40, num(Math.max(h, w / 1.6) * 1.08), 41, 1.6, 42, 50, 43, 0, 44, 0,
|
||
50, 0, 51, 0, 71, 0, 72, 100, 73, 1, 74, 3, 75, 0, 76, 0, 77, 0, 78, 0];
|
||
const ltypes = Object.entries(LTYPES).map(([name, lt]) => {
|
||
const total = lt.dashes.reduce((a, d) => a + Math.abs(d), 0);
|
||
const r = [0, 'LTYPE', 2, name, 70, 0, 3, lt.desc, 72, 65, 73, lt.dashes.length, 40, num(total)];
|
||
lt.dashes.forEach(d => r.push(49, num(d)));
|
||
return r;
|
||
});
|
||
const lay = [...layers.entries()].map(([name, l]) => [0, 'LAYER', 2, name, 70, 0, 62, l.color, 6, l.ltype]);
|
||
const style = [[0, 'STYLE', 2, 'STANDARD', 70, 0, 40, 0, 41, 1, 50, 0, 71, 0, 42, 2.5 * scale, 3, 'arial.ttf', 4, '']];
|
||
const dimstyle = [[0, 'DIMSTYLE', 2, 'TECTUM', 70, 0, 3, '', 4, '', 5, '', 6, '', 7, '',
|
||
40, scale, 41, 2.5, 42, DIM.exo, 43, 7, 44, DIM.exe, 45, 0, 46, DIM.dle, 47, 0, 48, 0,
|
||
140, DIM.txt, 141, 0, 142, DIM.tsz, 143, 25.4, 144, 1, 145, 0, 146, 1, 147, DIM.gap,
|
||
71, 0, 72, 0, 73, 0, 74, 0, 75, 0, 76, 0, 77, 1, 78, 0,
|
||
170, 0, 171, 2, 172, 1, 173, 0, 174, 0, 175, 0, 176, 0, 177, 0, 178, 0]];
|
||
return [0, 'SECTION', 2, 'TABLES',
|
||
...table('VPORT', [vport]), ...table('LTYPE', ltypes), ...table('LAYER', lay), ...table('STYLE', style),
|
||
...table('VIEW', []), ...table('UCS', []), ...table('APPID', [[0, 'APPID', 2, 'ACAD', 70, 0]]), ...table('DIMSTYLE', dimstyle),
|
||
0, 'ENDSEC'];
|
||
}
|
||
|
||
function toString() {
|
||
const out = [...header(), ...tables(), 0, 'SECTION', 2, 'BLOCKS'];
|
||
blocks.forEach(b => {
|
||
out.push(0, 'BLOCK', 8, '0', 2, b.name, 70, 1, 10, 0, 20, 0, 30, 0, 3, b.name);
|
||
b.entities.forEach(en => out.push(...en));
|
||
out.push(0, 'ENDBLK', 8, '0');
|
||
});
|
||
out.push(0, 'ENDSEC', 0, 'SECTION', 2, 'ENTITIES');
|
||
entities.forEach(en => out.push(...en));
|
||
out.push(0, 'ENDSEC', 0, 'EOF');
|
||
const lines = [];
|
||
for (let i = 0; i < out.length; i += 2) lines.push(String(out[i]).padStart(3, ' '), String(out[i + 1]));
|
||
return lines.join('\r\n') + '\r\n';
|
||
}
|
||
|
||
function toBytes() {
|
||
const s = toString();
|
||
const bytes = new Uint8Array(s.length);
|
||
for (let i = 0; i < s.length; i++) {
|
||
const c = s.charCodeAt(i);
|
||
bytes[i] = c < 0x80 || (c >= 0xa0 && c <= 0xff) ? c : (CP1252[c] || 0x3f);
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
function save(filename) {
|
||
const blob = new Blob([toBytes()], { type: 'application/dxf' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
return { scale, layer, line, polyline, circle, arc, text, dim, toString, toBytes, save, extents: () => Object.assign({}, ext) };
|
||
}
|
||
|
||
// jsPDF-achtige tekenlaag voor bestaande PDF-tekencode (papier-mm, y omlaag) op schaal 1:scale in een laag.
|
||
// Kleur zwart = BYLAYER, anders de dichtstbijzijnde ACI-kleur; vullingen worden alleen omtrek.
|
||
const ACI = [[1, 255, 0, 0], [2, 255, 255, 0], [3, 0, 255, 0], [4, 0, 255, 255], [5, 0, 0, 255], [6, 255, 0, 255],
|
||
[8, 128, 128, 128], [9, 192, 192, 192], [30, 255, 127, 0], [40, 255, 191, 0], [32, 165, 82, 0], [94, 0, 127, 0], [12, 165, 0, 0]];
|
||
function aciOf(r, g, b) {
|
||
if (r + g + b < 60) return null;
|
||
let best = null, bd = Infinity;
|
||
ACI.forEach(([i, R, G, B]) => { const d = (r - R) ** 2 + (g - G) ** 2 + (b - B) ** 2; if (d < bd) { bd = d; best = i; } });
|
||
return best;
|
||
}
|
||
function pdfAdapter(dxf, lay, map) {
|
||
const k = dxf.scale;
|
||
const P = map || ((x, y) => ({ x: x * k, y: -y * k }));
|
||
let draw = null, txt = null, fontPt = 10, dashed = false;
|
||
const eo = (c) => ({ color: c, ltype: dashed ? 'STIP' : undefined });
|
||
const ptToMm = 0.3528 * 0.72;
|
||
const doc = {
|
||
setDrawColor(r, g, b) { draw = aciOf(r, g === undefined ? r : g, b === undefined ? r : b); },
|
||
setTextColor(r, g, b) { txt = aciOf(r, g === undefined ? r : g, b === undefined ? r : b); },
|
||
setFillColor() {}, setLineWidth() {}, setFont() {},
|
||
setFontSize(pt) { fontPt = pt; },
|
||
setLineDashPattern(p) { dashed = !!(p && p.length); },
|
||
getTextWidth(s) { return String(s).length * fontPt * 0.3528 * 0.5; },
|
||
line(x1, y1, x2, y2) { dxf.line(P(x1, y1), P(x2, y2), lay, eo(draw)); },
|
||
rect(x, y, w, h) { dxf.polyline([P(x, y), P(x + w, y), P(x + w, y + h), P(x, y + h)], true, lay, eo(draw)); },
|
||
circle(x, y, r) { const c = P(x, y); dxf.circle(c, r * k, lay, eo(draw)); },
|
||
triangle(x1, y1, x2, y2, x3, y3) { dxf.polyline([P(x1, y1), P(x2, y2), P(x3, y3)], true, lay, eo(draw)); },
|
||
lines(segs, x, y, sc, style, closed) {
|
||
const pts = [P(x, y)];
|
||
let cx = x, cy = y;
|
||
segs.forEach(([dx, dy]) => { cx += dx; cy += dy; pts.push(P(cx, cy)); });
|
||
dxf.polyline(pts, !!closed, lay, eo(draw));
|
||
},
|
||
text(str, x, y, to) {
|
||
const t = to || {};
|
||
dxf.text(P(x, y), fontPt * ptToMm * k, Array.isArray(str) ? str.join(' ') : str, lay, { align: t.align, rot: t.angle || 0, color: txt });
|
||
}
|
||
};
|
||
return doc;
|
||
}
|
||
|
||
return { create, pdfAdapter, LTYPES };
|
||
})();
|