import { ACI } from './aci'; import { Block, Drawing, Entity, all, has, num, str } from './dxf-read'; /** * Turns a DXF drawing into a flat list of shapes in world coordinates, which is * all a renderer needs: lines, filled areas, text and points, each with its * layer and a resolved colour. Blocks are expanded here, through 2D affine * transforms, so a chair inserted fifty times becomes fifty sets of lines. */ /** Colour of a shape: an RGB number, or FOREGROUND for ACI 7, which follows the background. */ export const FOREGROUND = -1; export interface Shape { layer: string; color: number; kind: 'line' | 'fill' | 'text' | 'point'; /** Flat [x0, y0, x1, y1, ...] for lines and fills; several rings for a fill with holes. */ rings?: number[][]; closed?: boolean; dash?: number[] | null; /** Filled at reduced strength: a hatch pattern shown as a tint rather than its lines. */ tint?: boolean; /** A polyline drawn with a constant width, in world units; otherwise a hairline. */ width?: number; text?: TextShape; } export interface TextShape { lines: string[]; x: number; y: number; height: number; /** Radians, counter-clockwise, in world space. */ rotation: number; widthFactor: number; align: 'left' | 'center' | 'right'; baseline: 'alphabetic' | 'middle' | 'top' | 'bottom'; lineSpacing: number; mirrored: boolean; } export interface Built { shapes: Shape[]; minX: number; minY: number; maxX: number; maxY: number; /** Entity types present in the file that are not drawn, with counts. */ skipped: Map; layerCounts: Map; } /** [a, b, c, d, e, f]: x' = a x + c y + e, y' = b x + d y + f. */ type Matrix = [number, number, number, number, number, number]; const IDENTITY: Matrix = [1, 0, 0, 1, 0, 0]; function mul(m: Matrix, n: Matrix): Matrix { return [ m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3], m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5], ]; } const translate = (x: number, y: number): Matrix => [1, 0, 0, 1, x, y]; const scale = (x: number, y: number): Matrix => [x, 0, 0, y, 0, 0]; const rotate = (r: number): Matrix => [Math.cos(r), Math.sin(r), -Math.sin(r), Math.cos(r), 0, 0]; /** * An entity whose extrusion points down -Z is drawn in a mirrored coordinate * system - AutoCAD writes these when something is mirrored. Only the +Z and * -Z cases are handled; a drawing tilted in 3D is not a 2D drawing. */ function ocs(e: Entity): Matrix { return has(e, 230) && num(e, 230) < 0 ? [-1, 0, 0, 1, 0, 0] : IDENTITY; } interface Context { m: Matrix; /** The layer an entity on layer 0 inside a block takes on: the insert's. */ layer: string; /** Colour for BYBLOCK: the insert's resolved colour. */ color: number; lineType: string; depth: number; } const NOT_DRAWN = new Set(['VIEWPORT', 'ATTDEF', 'SEQEND', 'XLINE', 'RAY']); export function build(d: Drawing): Built { const shapes: Shape[] = []; const skipped = new Map(); const layerCounts = new Map(); const measurer = textMeasurer(); const colorOf = (e: Entity, layer: string, ctx: Context): number => { if (has(e, 420)) return num(e, 420); const aci = has(e, 62) ? num(e, 62) : 256; if (aci === 0) return ctx.color; if (aci === 256) { const l = d.layers.get(layer); if (l?.trueColor != null) return l.trueColor; return aciColor(l?.color ?? 7); } return aciColor(Math.abs(aci)); }; const dashOf = (e: Entity, layer: string, ctx: Context): number[] | null => { let name = str(e, 6, 'BYLAYER').toUpperCase(); if (name === 'BYLAYER') name = (d.layers.get(layer)?.lineType ?? 'CONTINUOUS').toUpperCase(); if (name === 'BYBLOCK') name = ctx.lineType.toUpperCase(); const pattern = d.lineTypes.get(name); if (!pattern) return null; const s = d.ltScale * (has(e, 48) ? num(e, 48) : 1); // Canvas dashes alternate on/off with positive lengths; DXF marks gaps as // negative and dots as zero. const out: number[] = []; for (const v of pattern) out.push(Math.max(Math.abs(v) * s, 0.0001 * s)); return out.length % 2 ? [...out, ...out] : out; }; const walk = (entities: Entity[], ctx: Context): void => { for (const e of entities) { // Paper space (layouts, title blocks on a sheet) is not the model. if (num(e, 67) === 1) continue; const own = str(e, 8, '0'); const layer = own === '0' && ctx.depth > 0 ? ctx.layer : own; const color = colorOf(e, layer, ctx); const dash = dashOf(e, layer, ctx); const m = mul(ctx.m, ocs(e)); const line = (pts: number[], closed = false) => { if (pts.length >= 4) shapes.push({ layer, color, kind: 'line', rings: [transform(ctx.m, pts)], closed, dash }); }; switch (e.type) { case 'LINE': line([num(e, 10), num(e, 20), num(e, 11), num(e, 21)]); break; case 'LWPOLYLINE': { const xs = all(e, 10); const ys = all(e, 20); const bulges = lwBulges(e); const closed = (num(e, 70) & 1) === 1; shapes.push({ layer, color, kind: 'line', rings: [transform(m, bulgePath(xs, ys, bulges, closed))], closed, dash, width: num(e, 43) * scaleOf(m) || undefined }); break; } case 'POLYLINE': polyline(e, m, layer, color, dash, shapes); break; case 'CIRCLE': shapes.push({ layer, color, kind: 'line', rings: [transform(m, arcPoints(num(e, 10), num(e, 20), num(e, 40), 0, 2 * Math.PI))], closed: true, dash }); break; case 'ARC': { const s = (num(e, 50) * Math.PI) / 180; let t = (num(e, 51) * Math.PI) / 180; if (t <= s) t += 2 * Math.PI; shapes.push({ layer, color, kind: 'line', rings: [transform(m, arcPoints(num(e, 10), num(e, 20), num(e, 40), s, t))], dash }); break; } case 'ELLIPSE': { // Centre and axis are world coordinates; a downward extrusion flips // which side the minor axis lies, which a negative ratio expresses. const ratio = num(e, 40, 1) * (has(e, 230) && num(e, 230) < 0 ? -1 : 1); shapes.push({ layer, color, kind: 'line', rings: [transform(ctx.m, ellipsePoints(num(e, 10), num(e, 20), num(e, 11), num(e, 21), ratio, num(e, 41), num(e, 42, 2 * Math.PI)))], dash }); break; } case 'SPLINE': { const pts = splinePoints(e); if (pts.length >= 4) shapes.push({ layer, color, kind: 'line', rings: [transform(ctx.m, pts)], closed: (num(e, 70) & 1) === 1, dash }); break; } case 'LEADER': { const xs = all(e, 10); const ys = all(e, 20); line(xs.flatMap((x, i) => [x, ys[i]])); break; } case 'MULTILEADER': case 'MLEADER': { const ml = multiLeader(e, measurer); for (const pts of ml.lines) line(pts); if (ml.text) shapes.push({ layer, color, kind: 'text', text: placeText(ml.text, ctx.m) }); break; } case 'SOLID': case 'TRACE': { // SOLID lists its corners in a Z order: 1, 2, 4, 3 goes round the outside. const p = [num(e, 10), num(e, 20), num(e, 11), num(e, 21), num(e, 13, num(e, 12)), num(e, 23, num(e, 22)), num(e, 12), num(e, 22)]; shapes.push({ layer, color, kind: 'fill', rings: [transform(m, p)] }); break; } case '3DFACE': { const p = [num(e, 10), num(e, 20), num(e, 11), num(e, 21), num(e, 12), num(e, 22), num(e, 13, num(e, 12)), num(e, 23, num(e, 22))]; shapes.push({ layer, color, kind: 'line', rings: [transform(ctx.m, p)], closed: true, dash }); break; } case 'POINT': shapes.push({ layer, color, kind: 'point', rings: [transform(ctx.m, [num(e, 10), num(e, 20)])] }); break; case 'HATCH': { const rings = hatchRings(e).map((r) => transform(m, r)).filter((r) => r.length >= 6); if (rings.length) shapes.push({ layer, color, kind: 'fill', rings, tint: num(e, 70) !== 1 }); break; } case 'TEXT': case 'ATTRIB': { if (e.type === 'ATTRIB' && (num(e, 70) & 1) === 1) break; const t = singleLineText(e, e.type === 'ATTRIB' ? 74 : 73); if (t) shapes.push({ layer, color, kind: 'text', text: placeText(t, m) }); break; } case 'MTEXT': { // MTEXT is placed in world coordinates, unlike TEXT. const t = multiLineText(e, measurer); if (t) shapes.push({ layer, color, kind: 'text', text: placeText(t, ctx.m) }); break; } case 'INSERT': case 'DIMENSION': { const block = d.blocks.get(str(e, 2)); if (!block || ctx.depth > 16) break; const inner: Context = { m: ctx.m, layer, color, lineType: str(e, 6, 'BYLAYER'), depth: ctx.depth + 1 }; if (e.type === 'DIMENSION') { // A dimension's block is already in world coordinates. walk(block.entities, inner); } else { for (const im of insertMatrices(e, block)) walk(block.entities, { ...inner, m: mul(ctx.m, mul(ocs(e), im)) }); // Attribute values are placed in the insert's parent space, not the block's. walk(e.children, { ...ctx, layer, color }); } break; } default: if (!NOT_DRAWN.has(e.type)) skipped.set(e.type, (skipped.get(e.type) ?? 0) + 1); } } }; walk(d.entities, { m: IDENTITY, layer: '0', color: FOREGROUND, lineType: 'CONTINUOUS', depth: 0 }); // Extents from what is actually drawn: the header's $EXTMIN/$EXTMAX is // often stale, and sometimes the placeholder 1e20. let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; const grow = (x: number, y: number) => { if (!isFinite(x) || !isFinite(y)) return; if (x < minX) minX = x; if (y < minY) minY = y; if (x > maxX) maxX = x; if (y > maxY) maxY = y; }; for (const s of shapes) { layerCounts.set(s.layer, (layerCounts.get(s.layer) ?? 0) + 1); if (s.text) for (const [x, y] of textCorners(s.text, measurer)) grow(x, y); for (const r of s.rings ?? []) for (let i = 0; i < r.length; i += 2) grow(r[i], r[i + 1]); } return { shapes, minX, minY, maxX, maxY, skipped, layerCounts }; } function aciColor(n: number): number { return n === 7 ? FOREGROUND : ACI[n] ?? FOREGROUND; } function transform(m: Matrix, pts: number[]): number[] { if (m === IDENTITY) return pts; const out = new Array(pts.length); for (let i = 0; i < pts.length; i += 2) { const x = pts[i]; const y = pts[i + 1]; out[i] = m[0] * x + m[2] * y + m[4]; out[i + 1] = m[1] * x + m[3] * y + m[5]; } return out; } /** One matrix per copy: an INSERT can be a rows-by-columns array of its block. */ function insertMatrices(e: Entity, block: Block): Matrix[] { const sx = has(e, 41) ? num(e, 41) : 1; const sy = has(e, 42) ? num(e, 42) : 1; const rot = (num(e, 50) * Math.PI) / 180; const cols = Math.max(1, num(e, 70, 1)); const rows = Math.max(1, num(e, 71, 1)); const out: Matrix[] = []; for (let r = 0; r < Math.min(rows, 500); r++) { for (let c = 0; c < Math.min(cols, 500); c++) { out.push( mul(translate(num(e, 10), num(e, 20)), mul(rotate(rot), mul(translate(c * num(e, 44), r * num(e, 45)), mul(scale(sx, sy), translate(-block.baseX, -block.baseY))))), ); } } return out; } /* ── Curves ────────────────────────────────────────────────────────────── */ function arcPoints(cx: number, cy: number, r: number, a0: number, a1: number): number[] { const n = Math.max(8, Math.ceil((Math.abs(a1 - a0) / (2 * Math.PI)) * 96)); const out: number[] = []; for (let i = 0; i <= n; i++) { const a = a0 + ((a1 - a0) * i) / n; out.push(cx + r * Math.cos(a), cy + r * Math.sin(a)); } return out; } function ellipsePoints(cx: number, cy: number, mx: number, my: number, ratio: number, t0: number, t1: number): number[] { if (t1 <= t0) t1 += 2 * Math.PI; const n = Math.max(8, Math.ceil(((t1 - t0) / (2 * Math.PI)) * 96)); const out: number[] = []; for (let i = 0; i <= n; i++) { const t = t0 + ((t1 - t0) * i) / n; const c = Math.cos(t); const s = Math.sin(t) * ratio; out.push(cx + c * mx - s * my, cy + c * my + s * mx); } return out; } /** The arc a polyline bulge describes between two vertices; bulge = tan(sweep / 4). */ function bulgeArc(x1: number, y1: number, x2: number, y2: number, bulge: number, out: number[]): void { const sweep = 4 * Math.atan(bulge); const chord = Math.hypot(x2 - x1, y2 - y1); if (chord === 0) return; const r = chord / (2 * Math.sin(Math.abs(sweep) / 2)); const mx = (x1 + x2) / 2; const my = (y1 + y2) / 2; const h = Math.sqrt(Math.max(r * r - (chord / 2) ** 2, 0)) * Math.sign(bulge) * (Math.abs(bulge) > 1 ? -1 : 1); const cx = mx - (h * (y2 - y1)) / chord; const cy = my + (h * (x2 - x1)) / chord; const a0 = Math.atan2(y1 - cy, x1 - cx); const n = Math.max(4, Math.ceil((Math.abs(sweep) / (2 * Math.PI)) * 96)); for (let i = 1; i < n; i++) { const a = a0 + (sweep * i) / n; out.push(cx + r * Math.cos(a), cy + r * Math.sin(a)); } } function bulgePath(xs: number[], ys: number[], bulges: number[], closed: boolean): number[] { const out: number[] = []; const n = xs.length; for (let i = 0; i < n; i++) { out.push(xs[i], ys[i]); const j = i + 1 < n ? i + 1 : closed ? 0 : -1; if (j >= 0 && bulges[i]) bulgeArc(xs[i], ys[i], xs[j], ys[j], bulges[i], out); } if (closed && n) out.push(xs[0], ys[0]); return out; } /** An LWPOLYLINE's bulges, one per vertex: a 42 belongs to the 10 before it. */ function lwBulges(e: Entity): number[] { const out: number[] = []; for (const [code, v] of e.pairs) { if (code === 10) out.push(0); else if (code === 42 && out.length) out[out.length - 1] = parseFloat(v) || 0; } return out; } function polyline(e: Entity, m: Matrix, layer: string, color: number, dash: number[] | null, shapes: Shape[]): void { const flags = num(e, 70); const verts = e.children.filter((v) => v.type === 'VERTEX'); if (flags & 64) { // Polyface mesh: vertices first, then face records that index them (1-based, negative = hidden edge). const points = verts.filter((v) => (num(v, 70) & 128) && (num(v, 70) & 64)); const faces = verts.filter((v) => (num(v, 70) & 128) && !(num(v, 70) & 64)); for (const f of faces) { const idx = [71, 72, 73, 74].map((c) => Math.abs(num(f, c))).filter((i) => i > 0 && i <= points.length); const pts = idx.flatMap((i) => [num(points[i - 1], 10), num(points[i - 1], 20)]); if (pts.length >= 4) shapes.push({ layer, color, kind: 'line', rings: [transform(m, pts)], closed: true, dash }); } return; } if (flags & 16) return; // Polygon mesh: a 3D surface, not a 2D drawing. const xs = verts.map((v) => num(v, 10)); const ys = verts.map((v) => num(v, 20)); const bulges = verts.map((v) => num(v, 42)); const closed = (flags & 1) === 1; // Only a constant width is honoured; a width that tapers along the line is drawn at its start width. const width = num(e, 40) * scaleOf(m) || undefined; shapes.push({ layer, color, kind: 'line', rings: [transform(m, bulgePath(xs, ys, bulges, closed))], closed, dash, width }); } /** How much a transform stretches lengths, for widths carried through a scaled block. */ function scaleOf(m: Matrix): number { return Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])); } /** NURBS by de Boor when the file has control points; a Catmull-Rom curve through the fit points when it has only those. */ function splinePoints(e: Entity): number[] { const degree = num(e, 71, 3); const cx = all(e, 10); const cy = all(e, 20); if (cx.length >= 2) { let knots = all(e, 40); const weights = all(e, 41); const n = cx.length; if (knots.length !== n + degree + 1) knots = clampedKnots(n, degree); const w = weights.length === n ? weights : new Array(n).fill(1); const lo = knots[degree]; const hi = knots[n]; const steps = Math.min(Math.max(n * 12, 32), 2000); const out: number[] = []; for (let i = 0; i <= steps; i++) { const [x, y] = deBoor(lo + ((hi - lo) * i) / steps, degree, knots, cx, cy, w); out.push(x, y); } return out; } const fx = all(e, 11); const fy = all(e, 21); if (fx.length < 2) return []; const out: number[] = []; const P = (i: number) => [fx[Math.min(Math.max(i, 0), fx.length - 1)], fy[Math.min(Math.max(i, 0), fy.length - 1)]]; for (let i = 0; i < fx.length - 1; i++) { const [p0, p1, p2, p3] = [P(i - 1), P(i), P(i + 1), P(i + 2)]; for (let k = 0; k < 16; k++) { const t = k / 16; const t2 = t * t; const t3 = t2 * t; const f = (a: number, b: number, c: number, d: number) => 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3); out.push(f(p0[0], p1[0], p2[0], p3[0]), f(p0[1], p1[1], p2[1], p3[1])); } } out.push(fx[fx.length - 1], fy[fy.length - 1]); return out; } function clampedKnots(n: number, p: number): number[] { const k: number[] = []; for (let i = 0; i <= n + p; i++) k.push(i <= p ? 0 : i >= n ? n - p : i - p); return k; } function deBoor(t: number, p: number, knots: number[], xs: number[], ys: number[], w: number[]): [number, number] { const n = xs.length; let k = p; while (k < n - 1 && t >= knots[k + 1]) k++; const dx: number[] = []; const dy: number[] = []; const dw: number[] = []; for (let j = 0; j <= p; j++) { const i = Math.min(Math.max(j + k - p, 0), n - 1); dx.push(xs[i] * w[i]); dy.push(ys[i] * w[i]); dw.push(w[i]); } for (let r = 1; r <= p; r++) { for (let j = p; j >= r; j--) { const i = j + k - p; const denom = knots[i + p - r + 1] - knots[i]; const a = denom === 0 ? 0 : (t - knots[i]) / denom; dx[j] = (1 - a) * dx[j - 1] + a * dx[j]; dy[j] = (1 - a) * dy[j - 1] + a * dy[j]; dw[j] = (1 - a) * dw[j - 1] + a * dw[j]; } } return [dx[p] / dw[p], dy[p] / dw[p]]; } /* ── Hatches ───────────────────────────────────────────────────────────── */ /** * A hatch's boundary loops, each flattened to a ring. The loops are written as * a stream of group codes whose meaning depends on what came before, so they * are read with a cursor rather than looked up. */ function hatchRings(e: Entity): number[][] { const p = e.pairs; let i = p.findIndex(([c]) => c === 91); if (i < 0) return []; const loops = parseInt(p[i][1], 10) || 0; i++; const next = (code: number): number => { while (i < p.length && p[i][0] !== code) i++; return i < p.length ? parseFloat(p[i++][1]) : 0; }; const rings: number[][] = []; for (let l = 0; l < loops && i < p.length; l++) { const flags = next(92); const ring: number[] = []; if (flags & 2) { const hasBulge = next(72); next(73); const count = next(93); const xs: number[] = []; const ys: number[] = []; const bs: number[] = []; for (let v = 0; v < count; v++) { xs.push(next(10)); ys.push(next(20)); bs.push(hasBulge && p[i]?.[0] === 42 ? parseFloat(p[i++][1]) : 0); } ring.push(...bulgePath(xs, ys, bs, true)); } else { const edges = next(93); for (let k = 0; k < edges; k++) { const type = next(72); if (type === 1) { ring.push(next(10), next(20), next(11), next(21)); } else if (type === 2) { const cx = next(10), cy = next(20), r = next(40), s = next(50), t = next(51), ccw = next(73); let a0 = (s * Math.PI) / 180; let a1 = (t * Math.PI) / 180; if (!ccw) { a0 = -a0; a1 = -a1; if (a1 >= a0) a1 -= 2 * Math.PI; } else if (a1 <= a0) a1 += 2 * Math.PI; ring.push(...arcPoints(cx, cy, r, a0, a1)); } else if (type === 3) { const cx = next(10), cy = next(20), mx = next(11), my = next(21), ratio = next(40), s = next(50), t = next(51), ccw = next(73); let a0 = (s * Math.PI) / 180; let a1 = (t * Math.PI) / 180; if (!ccw) { a0 = -a0; a1 = -a1; if (a1 >= a0) a1 -= 2 * Math.PI; } ring.push(...(ccw ? ellipsePoints(cx, cy, mx, my, ratio, a0, a1) : ellipsePoints(cx, cy, mx, my, ratio, a1, a0).reverse())); } else if (type === 4) { const degree = next(94); next(73); next(74); const nk = next(95); const nc = next(96); const knots: number[] = []; for (let q = 0; q < nk; q++) knots.push(next(40)); const xs: number[] = []; const ys: number[] = []; for (let q = 0; q < nc; q++) { xs.push(next(10)); ys.push(next(20)); } const fake: Entity = { type: 'SPLINE', children: [], pairs: [[71, String(degree)], ...knots.map((k) => [40, String(k)] as [number, string]), ...xs.flatMap((x, q) => [[10, String(x)], [20, String(ys[q])]] as [number, string][])] }; ring.push(...splinePoints(fake)); } } } if (ring.length >= 6) rings.push(ring); } return rings; } /* ── Text ──────────────────────────────────────────────────────────────── */ interface RawText { lines: string[]; x: number; y: number; height: number; rotation: number; widthFactor: number; align: TextShape['align']; baseline: TextShape['baseline']; lineSpacing: number; } const SPECIALS: [RegExp, string][] = [ [/%%[cC]/g, String.fromCodePoint(0x2300)], [/%%[dD]/g, String.fromCodePoint(0xb0)], [/%%[pP]/g, String.fromCodePoint(0xb1)], [/%%[uUoOkK]/g, ''], [/%%%/g, '%'], ]; function unescapeText(s: string): string { for (const [re, to] of SPECIALS) s = s.replace(re, to); // Caret notation for control characters: ^I is a tab, ^J a line feed, and // "^ " a literal caret. Anything else after a caret is left as written. s = s.replace(/\^([IJM ])/g, (_, c) => (c === 'I' ? ' ' : c === 'J' ? '\n' : c === ' ' ? '^' : '')); return s.replace(/\\U\+([0-9a-fA-F]{4})/g, (_, h) => String.fromCodePoint(parseInt(h, 16))); } function singleLineText(e: Entity, vCode: number): RawText | null { const text = unescapeText(str(e, 1)); if (!text.trim()) return null; const h = num(e, 72); const v = num(e, vCode); // Anything but plain left/baseline is positioned by the second point - // except "aligned" and "fit" (3 and 5), which run from the first point to // the second and are drawn here from the first. const useSecond = (h !== 0 || v !== 0) && h !== 3 && h !== 5 && has(e, 11); return { lines: [text], x: useSecond ? num(e, 11) : num(e, 10), y: useSecond ? num(e, 21) : num(e, 20), height: num(e, 40, 1), rotation: (num(e, 50) * Math.PI) / 180, widthFactor: num(e, 41, 1) || 1, align: h === 1 || h === 4 ? 'center' : h === 2 ? 'right' : 'left', baseline: h === 4 || v === 2 ? 'middle' : v === 3 ? 'top' : v === 1 ? 'bottom' : 'alphabetic', lineSpacing: 1, }; } /** MTEXT: formatting codes stripped, paragraphs split, and wrapped to the box width when it has one. */ function multiLineText(e: Entity, measure: (s: string, h: number) => number): RawText | null { const raw = e.pairs.filter(([c]) => c === 3).map(([, v]) => v).join('') + str(e, 1); const plain = unescapeText(stripMtext(raw)); if (!plain.trim()) return null; const height = num(e, 40, 1); const width = num(e, 41); const attach = num(e, 71, 1); let lines = plain.split('\n'); if (width > 0) lines = lines.flatMap((l) => wrap(l, width, height, measure)); // Code 50 is documented as radians for MTEXT, but AutoCAD and every file // tested write degrees - a value of 1.86 is a nearly level label. const rotation = has(e, 11) ? Math.atan2(num(e, 21), num(e, 11)) : (num(e, 50) * Math.PI) / 180; const col = (attach - 1) % 3; const row = Math.floor((attach - 1) / 3); return { lines, x: num(e, 10), y: num(e, 20), height, rotation, widthFactor: 1, align: col === 0 ? 'left' : col === 1 ? 'center' : 'right', baseline: row === 0 ? 'top' : row === 1 ? 'middle' : 'bottom', lineSpacing: (5 / 3) * num(e, 44, 1), }; } /** * MULTILEADER: the label's text and its leader lines, without arrowheads. The * entity nests its data in sections opened by "{" markers - CONTEXT_DATA, then * each LEADER, then each LEADER_LINE - and the same group codes mean different * things in each, so the pairs are read in order with the section tracked. */ function multiLeader(e: Entity, measure: (s: string, h: number) => number): { lines: number[][]; text: RawText | null } { const lines: number[][] = []; let section = ''; let text = ''; let tx = 0, ty = 0, dx = 1, dy = 0, height = 1, width = 0, attach = 1; let landing: [number, number] | null = null; let dogX = 0, dogY = 0, dogLen = 0; let pts: number[] = []; let pendingX = 0; const endLeader = () => { if (landing && dogLen > 0) lines.push([landing[0], landing[1], landing[0] + dogX * dogLen, landing[1] + dogY * dogLen]); landing = null; dogLen = 0; }; for (const [c, v] of e.pairs) { if (c === 300 && v === 'CONTEXT_DATA{') section = 'context'; else if (c === 302 && v === 'LEADER{') section = 'leader'; else if (c === 304 && v === 'LEADER_LINE{') { section = 'line'; pts = []; } else if (c === 305 && v === '}') { if (landing) pts.push(landing[0], landing[1]); lines.push(pts); section = 'leader'; } else if (c === 303 && v === '}') { endLeader(); section = 'context'; } else if (c === 301 && v === '}') section = 'done'; else if (section === 'context') { const n = parseFloat(v); if (c === 304) text = v; else if (c === 12) tx = n; else if (c === 22) ty = n; else if (c === 13) dx = n; else if (c === 23) dy = n; else if (c === 41) height = n || 1; else if (c === 43) width = n; else if (c === 171) attach = n || 1; } else if (section === 'leader') { const n = parseFloat(v); if (c === 10) pendingX = n; else if (c === 20) landing = [pendingX, n]; else if (c === 11) dogX = n; else if (c === 21) dogY = n; else if (c === 40) dogLen = n; } else if (section === 'line') { const n = parseFloat(v); if (c === 10) pendingX = n; else if (c === 20) pts.push(pendingX, n); } } const plain = unescapeText(stripMtext(text)); if (!plain.trim()) return { lines, text: null }; let rows = plain.split('\n'); if (width > 0) rows = rows.flatMap((l) => wrap(l, width, height, measure)); const col = (attach - 1) % 3; const row = Math.floor((attach - 1) / 3); return { lines, text: { lines: rows, x: tx, y: ty, height, rotation: Math.atan2(dy, dx), widthFactor: 1, align: col === 0 ? 'left' : col === 1 ? 'center' : 'right', baseline: row === 0 ? 'top' : row === 1 ? 'middle' : 'bottom', lineSpacing: 5 / 3, }, }; } function stripMtext(s: string): string { let out = ''; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (ch === '{' || ch === '}') continue; if (ch !== '\\') { out += ch; continue; } const code = s[i + 1]; i++; if (code === 'P') out += '\n'; else if (code === '~') out += ' '; else if (code === '\\' || code === '{' || code === '}') out += code; else if (code === 'S') { // Stacked fraction: \S1^2; or \S1/2; or \S1#2; const end = s.indexOf(';', i); const body = s.slice(i + 1, end < 0 ? s.length : end); out += body.replace(/[\^#]/, '/'); i = end < 0 ? s.length : end; } else if (code === 'U' && s[i + 1] === '+') { out += '\\U'; } else if ('ACcFfHQTWp'.includes(code)) { const end = s.indexOf(';', i); i = end < 0 ? s.length : end; } // L l O o K k N: on/off switches with no argument - dropped. } return out; } function wrap(line: string, width: number, height: number, measure: (s: string, h: number) => number): string[] { const words = line.split(' '); const out: string[] = []; let cur = ''; for (const w of words) { const test = cur ? `${cur} ${w}` : w; if (cur && measure(test, height) > width * 1.02) { out.push(cur); cur = w; } else { cur = test; } } out.push(cur); return out; } function textMeasurer(): (s: string, h: number) => number { const ctx = typeof document !== 'undefined' ? document.createElement('canvas').getContext('2d') : null; if (ctx) ctx.font = '100px Arial, sans-serif'; return (s, h) => (ctx ? (ctx.measureText(s).width / 100) * h : s.length * h * 0.6); } /** * The four corners of a text's box, so a title in large letters counts towards * the drawing's extents and is not cut off by the first "fit". */ function textCorners(t: TextShape, measure: (s: string, h: number) => number): [number, number][] { const w = Math.max(...t.lines.map((l) => measure(l, t.height))) * t.widthFactor; const total = t.height * (1 + (t.lines.length - 1) * t.lineSpacing); const x0 = t.align === 'left' ? 0 : t.align === 'center' ? -w / 2 : -w; const y0 = t.baseline === 'top' ? -total : t.baseline === 'middle' ? -total / 2 : t.baseline === 'bottom' ? 0 : -t.height * 0.25; const y1 = t.baseline === 'alphabetic' ? t.height : y0 + total; const c = Math.cos(t.rotation); const s = Math.sin(t.rotation); const flip = t.mirrored ? -1 : 1; return [[x0, y0], [x0 + w, y0], [x0, y1], [x0 + w, y1]].map(([lx, ly]) => [t.x + flip * lx * c - ly * s, t.y + flip * lx * s + ly * c]); } /** Carries a text's anchor, direction and size through a transform, including a mirror. */ function placeText(t: RawText, m: Matrix): TextShape { const [x, y] = transform(m, [t.x, t.y]); const ux = Math.cos(t.rotation); const uy = Math.sin(t.rotation); const ax = m[0] * ux + m[2] * uy; const ay = m[1] * ux + m[3] * uy; const vx = m[0] * -uy + m[2] * ux; const vy = m[1] * -uy + m[3] * ux; const det = m[0] * m[3] - m[1] * m[2]; return { lines: t.lines, x, y, height: t.height * Math.hypot(vx, vy), rotation: Math.atan2(ay, ax), widthFactor: t.widthFactor * (Math.hypot(ax, ay) / (Math.hypot(vx, vy) || 1)), align: t.align, baseline: t.baseline, lineSpacing: t.lineSpacing, mirrored: det < 0, }; }