import { AfterViewChecked, Component, ChangeDetectionStrategy, ChangeDetectorRef, ElementRef, Inject, OnDestroy, PLATFORM_ID, ViewChild } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { decodeDxf, readDxf } from './dxf-read'; import { Built, FOREGROUND, Shape, build } from './dxf-geometry'; import { ACI } from './aci'; import { dwgToDxf, isDwg } from './dwg'; import { NotesComponent } from '../tool-notes/notes.component'; import { InBrowserComponent } from '../tool-notes/in-browser.component'; interface LayerRow { name: string; /** RGB, or FOREGROUND for ACI 7. */ rgb: number; count: number; visible: boolean; } interface Batch { color: number; kind: 'line' | 'fill'; tint: boolean; dash: number[] | null; width: number; path: Path2D; } const UNITS: Record = { 1: 'inches', 2: 'feet', 3: 'miles', 4: 'millimetres', 5: 'centimetres', 6: 'metres', 7: 'kilometres', 8: 'microinches', 9: 'mils', 10: 'yards', 11: 'ångströms', 12: 'nanometres', 13: 'microns', 14: 'decimetres', }; const UNIT_SHORT: Record = { 1: 'in', 2: 'ft', 4: 'mm', 5: 'cm', 6: 'm', 7: 'km', 10: 'yd', 14: 'dm' }; /** AutoCAD text height is the height of a capital; a font's em box is taller by about this much. */ const CAP_TO_EM = 1 / 0.716; @Component({ selector: 'app-dxf-viewer', imports: [NotesComponent, InBrowserComponent, FormsModule], templateUrl: './dxf-viewer.component.html', changeDetection: ChangeDetectionStrategy.Eager, styleUrl: './dxf-viewer.component.scss' }) export class DxfViewerComponent implements AfterViewChecked, OnDestroy { @ViewChild('canvas') canvasRef?: ElementRef; loadError = ''; dragOver = false; busy = ''; fileName = ''; /** The file was a DWG, converted on opening; it can be saved back out as the DXF it became. */ fromDwg = false; private dxfBytes: Uint8Array | null = null; version = ''; units = 0; built: Built | null = null; layers: LayerRow[] = []; layerFilter = ''; dark = true; tool: 'pan' | 'measure' = 'pan'; measure: { ax: number; ay: number; bx: number | null; by: number | null } | null = null; private view = { cx: 0, cy: 0, scale: 1 }; private batches: Batch[] = []; private texts: Shape[] = []; private points: Shape[] = []; private pointers = new Map(); private drag: { x: number; y: number; cx: number; cy: number; moved: boolean } | null = null; private pinch: { dist: number; scale: number } | null = null; private frame = 0; /** Until the reader pans or zooms, a resize re-fits the drawing rather than keeping the view. */ private autoFit = true; private resizeObserver: ResizeObserver | null = null; private observed: HTMLCanvasElement | null = null; constructor(private cdr: ChangeDetectorRef, @Inject(PLATFORM_ID) private platformId: object) {} /** * The canvas exists only while a drawing is open, and is a new element each * time one is. Once it is on the page, a resize observer is attached to it; * the observer's first callback, which arrives with the laid-out size, fits * the drawing to it. */ ngAfterViewChecked(): void { const canvas = this.canvasRef?.nativeElement; if (!canvas || canvas === this.observed) return; this.resizeObserver?.disconnect(); this.observed = canvas; this.resizeObserver = new ResizeObserver(() => (this.autoFit ? this.fit() : this.redraw())); this.resizeObserver.observe(canvas); } ngOnDestroy(): void { // Prerendering destroys the component under Node, where there are no animation frames. if (!isPlatformBrowser(this.platformId)) return; this.resizeObserver?.disconnect(); cancelAnimationFrame(this.frame); } /* ── Input ─────────────────────────────────────────────────────────────── */ onDragOver(e: DragEvent): void { e.preventDefault(); this.dragOver = true; } onDragLeave(): void { this.dragOver = false; } onDrop(e: DragEvent): void { e.preventDefault(); this.dragOver = false; const file = e.dataTransfer?.files?.[0]; if (file) this.open(file); } onFileInput(e: Event): void { const el = e.target as HTMLInputElement; const file = el.files?.[0]; el.value = ''; if (file) this.open(file); } private async open(file: File): Promise { this.loadError = ''; this.busy = `Reading ${file.name}…`; this.cdr.detectChanges(); try { let bytes = new Uint8Array(await file.arrayBuffer()); const head = new TextDecoder('latin1').decode(bytes.subarray(0, 22)); const dwg = isDwg(bytes); let dwgLayers: Map | null = null; if (dwg) { this.busy = `Converting ${file.name} from DWG - the first time, the converter downloads (about 3 MB)…`; this.cdr.detectChanges(); try { const converted = await dwgToDxf(bytes); bytes = converted.dxf as Uint8Array; dwgLayers = converted.layers; } catch { throw new Error(`${file.name} could not be converted. It may be from a DWG version the converter does not handle, or damaged. If it opens in a CAD program, saving it as DXF from there will work here.`); } } else if (head.startsWith('AutoCAD Binary DXF')) { throw new Error(`${file.name} is a binary DXF, a rarely used variant this viewer does not read. Saving it again as an ASCII DXF will open here.`); } // Let the message paint before a large file ties up the page. await new Promise((r) => setTimeout(r, 20)); const drawing = readDxf(decodeDxf(bytes)); if (dwgLayers?.size) { for (const [name, layer] of drawing.layers) { const real = dwgLayers.get(name); if (real) { layer.off = real.off; layer.frozen = real.frozen; } else { layer.off = false; } } } const built = build(drawing); if (!built.shapes.length) { const solids = (built.skipped.get('3DSOLID') ?? 0) + (built.skipped.get('BODY') ?? 0) + (built.skipped.get('REGION') ?? 0); throw new Error(solids ? `${file.name} is a 3D model made of solids, which this 2D viewer cannot draw.` : `${file.name} has nothing in its model space to draw.`); } this.fromDwg = dwg; this.dxfBytes = dwg ? bytes : null; this.fileName = file.name; this.version = acadVersion(drawing.version); this.units = drawing.units; this.built = built; this.measure = null; this.layers = [...built.layerCounts.entries()] .map(([name, count]) => { const l = drawing.layers.get(name); const aci = l?.color ?? 7; return { name, count, rgb: l?.trueColor ?? (aci === 7 ? FOREGROUND : ACI[aci] ?? FOREGROUND), visible: !(l?.off || l?.frozen) }; }) .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); this.rebuildBatches(); this.autoFit = true; this.cdr.detectChanges(); this.fit(); } catch (err: any) { this.loadError = err?.message === 'Not a DXF file' ? `${file.name} is not a DWG or DXF file this viewer can read.` : err?.message ?? String(err); } finally { this.busy = ''; this.cdr.detectChanges(); } } /* ── Layers ────────────────────────────────────────────────────────────── */ get visibleLayers(): LayerRow[] { const f = this.layerFilter.trim().toLowerCase(); return f ? this.layers.filter((l) => l.name.toLowerCase().includes(f)) : this.layers; } toggleLayer(l: LayerRow): void { l.visible = !l.visible; this.rebuildBatches(); this.redraw(); } setAllLayers(visible: boolean): void { this.layers.forEach((l) => (l.visible = visible)); this.rebuildBatches(); this.redraw(); } toggleDark(): void { this.dark = !this.dark; this.redraw(); } /** * Shapes are grouped by colour, dash and kind into one Path2D each, built in * world coordinates once per layer change. Drawing is then a handful of * stroke() calls whatever the zoom, which is what keeps a floor plan with * tens of thousands of lines responsive while panning. */ private rebuildBatches(): void { const visible = new Set(this.layers.filter((l) => l.visible).map((l) => l.name)); const map = new Map(); this.texts = []; this.points = []; for (const s of this.built?.shapes ?? []) { if (!visible.has(s.layer)) continue; if (s.kind === 'text') { this.texts.push(s); continue; } if (s.kind === 'point') { this.points.push(s); continue; } const key = `${s.kind}|${s.color}|${s.tint ? 1 : 0}|${s.dash?.join(',') ?? ''}|${s.width ?? 0}`; let b = map.get(key); if (!b) { b = { color: s.color, kind: s.kind, tint: !!s.tint, dash: s.dash ?? null, width: s.width ?? 0, path: new Path2D() }; map.set(key, b); } for (const r of s.rings ?? []) { b.path.moveTo(r[0], r[1]); for (let i = 2; i < r.length; i += 2) b.path.lineTo(r[i], r[i + 1]); if (s.closed || s.kind === 'fill') b.path.closePath(); } } // Fills first, so lines and text sit on top of hatching. this.batches = [...map.values()].sort((a, b) => (a.kind === b.kind ? 0 : a.kind === 'fill' ? -1 : 1)); } /* ── Drawing ───────────────────────────────────────────────────────────── */ fit(): void { const b = this.built; const canvas = this.canvasRef?.nativeElement; if (!b || !canvas) return; const w = canvas.clientWidth || 800; const h = canvas.clientHeight || 600; const dx = Math.max(b.maxX - b.minX, 1e-9); const dy = Math.max(b.maxY - b.minY, 1e-9); this.view = { cx: (b.minX + b.maxX) / 2, cy: (b.minY + b.maxY) / 2, scale: Math.min(w / dx, h / dy) * 0.94 }; this.redraw(); } zoomBy(f: number, sx?: number, sy?: number): void { const canvas = this.canvasRef?.nativeElement; if (!canvas) return; const w = canvas.clientWidth; const h = canvas.clientHeight; const px = sx ?? w / 2; const py = sy ?? h / 2; // Keep the world point under the cursor where it is. const [wx, wy] = this.toWorld(px, py); this.autoFit = false; this.view.scale *= f; this.view.cx = wx - (px - w / 2) / this.view.scale; this.view.cy = wy + (py - h / 2) / this.view.scale; this.redraw(); } private toWorld(sx: number, sy: number): [number, number] { const canvas = this.canvasRef!.nativeElement; return [this.view.cx + (sx - canvas.clientWidth / 2) / this.view.scale, this.view.cy - (sy - canvas.clientHeight / 2) / this.view.scale]; } private toScreen(x: number, y: number): [number, number] { const canvas = this.canvasRef!.nativeElement; return [(x - this.view.cx) * this.view.scale + canvas.clientWidth / 2, canvas.clientHeight / 2 - (y - this.view.cy) * this.view.scale]; } redraw(): void { cancelAnimationFrame(this.frame); this.frame = requestAnimationFrame(() => this.draw()); } private draw(): void { const canvas = this.canvasRef?.nativeElement; if (!canvas || !this.built) return; const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth; const h = canvas.clientHeight; if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); } const ctx = canvas.getContext('2d')!; this.paint(ctx, w, h, dpr, this.view, this.dark); this.paintMeasure(ctx, dpr); } /** Paints the drawing into any 2D context: the screen, or a canvas for the PNG. */ private paint(ctx: CanvasRenderingContext2D, w: number, h: number, dpr: number, view: { cx: number; cy: number; scale: number }, dark: boolean): void { const s = view.scale; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = dark ? '#1d1f27' : '#ffffff'; ctx.fillRect(0, 0, w * dpr, h * dpr); ctx.setTransform(dpr * s, 0, 0, -dpr * s, dpr * (w / 2 - view.cx * s), dpr * (h / 2 + view.cy * s)); ctx.lineWidth = 1 / s; ctx.lineJoin = 'round'; for (const b of this.batches) { const css = cssColor(b.color, dark); if (b.kind === 'fill') { ctx.globalAlpha = b.tint ? 0.28 : 1; ctx.fillStyle = css; ctx.fill(b.path, 'evenodd'); ctx.globalAlpha = 1; } else { // A dash pattern that would be under two pixels on screen is drawn solid: // it would only read as a grey smudge, and is slow to rasterise. const dashLen = b.dash?.reduce((a, v) => a + v, 0) ?? 0; ctx.setLineDash(b.dash && dashLen * s > 4 ? b.dash : []); ctx.strokeStyle = css; ctx.lineWidth = Math.max(1 / s, b.width); ctx.stroke(b.path); } } ctx.setLineDash([]); // Points and text in screen space: canvas text at a sub-pixel font size // does not render reliably, so world sizes are converted here. ctx.setTransform(dpr, 0, 0, dpr, 0, 0); for (const p of this.points) { const r = p.rings![0]; const x = (r[0] - view.cx) * s + w / 2; const y = h / 2 - (r[1] - view.cy) * s; ctx.fillStyle = cssColor(p.color, dark); ctx.fillRect(x - 1.5, y - 1.5, 3, 3); } for (const t of this.texts) { const tx = t.text!; const size = tx.height * s * CAP_TO_EM; if (size < 2.5) continue; const x = (tx.x - view.cx) * s + w / 2; const y = h / 2 - (tx.y - view.cy) * s; if (x < -w || x > 2 * w || y < -h || y > 2 * h) continue; ctx.save(); ctx.translate(x, y); ctx.rotate(-tx.rotation); ctx.scale(tx.widthFactor * (tx.mirrored ? -1 : 1), 1); ctx.font = `${size}px Arial, Helvetica, sans-serif`; ctx.fillStyle = cssColor(t.color, dark); ctx.textAlign = tx.align; ctx.textBaseline = tx.lines.length > 1 && tx.baseline === 'alphabetic' ? 'bottom' : tx.baseline; const step = tx.height * s * tx.lineSpacing; const n = tx.lines.length; // Multi-line blocks are anchored by their top, middle or bottom edge as a whole. const first = tx.baseline === 'middle' ? -((n - 1) * step) / 2 : tx.baseline === 'bottom' ? -(n - 1) * step : 0; tx.lines.forEach((line, i) => ctx.fillText(line, 0, first + i * step)); ctx.restore(); } } private paintMeasure(ctx: CanvasRenderingContext2D, dpr: number): void { const m = this.measure; if (!m) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const [ax, ay] = this.toScreen(m.ax, m.ay); ctx.strokeStyle = '#ff5ca8'; ctx.fillStyle = '#ff5ca8'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(ax, ay, 4, 0, Math.PI * 2); ctx.fill(); if (m.bx !== null && m.by !== null) { const [bx, by] = this.toScreen(m.bx, m.by); ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke(); ctx.beginPath(); ctx.arc(bx, by, 4, 0, Math.PI * 2); ctx.fill(); } } /* ── Pointer ───────────────────────────────────────────────────────────── */ onWheel(e: WheelEvent): void { e.preventDefault(); const rect = (e.target as HTMLElement).getBoundingClientRect(); this.zoomBy(e.deltaY < 0 ? 1.2 : 1 / 1.2, e.clientX - rect.left, e.clientY - rect.top); } onPointerDown(e: PointerEvent): void { (e.target as HTMLElement).setPointerCapture(e.pointerId); this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (this.pointers.size === 2) { const [a, b] = [...this.pointers.values()]; this.pinch = { dist: Math.hypot(a.x - b.x, a.y - b.y), scale: this.view.scale }; this.drag = null; return; } this.drag = { x: e.clientX, y: e.clientY, cx: this.view.cx, cy: this.view.cy, moved: false }; } onPointerMove(e: PointerEvent): void { if (!this.pointers.has(e.pointerId)) return; this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (this.pinch && this.pointers.size === 2) { const [a, b] = [...this.pointers.values()]; const rect = (e.target as HTMLElement).getBoundingClientRect(); const f = (this.pinch.scale * Math.hypot(a.x - b.x, a.y - b.y)) / this.pinch.dist / this.view.scale; this.zoomBy(f, (a.x + b.x) / 2 - rect.left, (a.y + b.y) / 2 - rect.top); return; } const d = this.drag; if (!d) return; const dx = e.clientX - d.x; const dy = e.clientY - d.y; if (Math.abs(dx) + Math.abs(dy) > 3) d.moved = true; if (d.moved) { this.autoFit = false; this.view.cx = d.cx - dx / this.view.scale; this.view.cy = d.cy + dy / this.view.scale; this.redraw(); } } onPointerUp(e: PointerEvent): void { this.pointers.delete(e.pointerId); if (this.pointers.size < 2) this.pinch = null; const d = this.drag; this.drag = null; if (d && !d.moved && this.tool === 'measure') { const rect = (e.target as HTMLElement).getBoundingClientRect(); this.measureClick(e.clientX - rect.left, e.clientY - rect.top); } } /** Two clicks, each snapped to the nearest drawn vertex within ten pixels. */ private measureClick(sx: number, sy: number): void { const [wx, wy] = this.snap(sx, sy); if (!this.measure || this.measure.bx !== null) { this.measure = { ax: wx, ay: wy, bx: null, by: null }; } else { this.measure.bx = wx; this.measure.by = wy; } this.redraw(); } private snap(sx: number, sy: number): [number, number] { const [wx, wy] = this.toWorld(sx, sy); const limit = 10 / this.view.scale; let best = limit * limit; let out: [number, number] = [wx, wy]; const visible = new Set(this.layers.filter((l) => l.visible).map((l) => l.name)); for (const s of this.built?.shapes ?? []) { if (!visible.has(s.layer) || !s.rings) continue; for (const r of s.rings) { for (let i = 0; i < r.length; i += 2) { const d = (r[i] - wx) ** 2 + (r[i + 1] - wy) ** 2; if (d < best) { best = d; out = [r[i], r[i + 1]]; } } } } return out; } setTool(t: 'pan' | 'measure'): void { this.tool = t; if (t === 'pan') this.measure = null; this.redraw(); } get measurement(): { d: string; dx: string; dy: string; angle: string } | null { const m = this.measure; if (!m || m.bx === null || m.by === null) return null; const dx = m.bx - m.ax; const dy = m.by - m.ay; const u = UNIT_SHORT[this.units] ? ` ${UNIT_SHORT[this.units]}` : ''; const f = (v: number) => `${+v.toPrecision(7)}${u}`; return { d: f(Math.hypot(dx, dy)), dx: f(Math.abs(dx)), dy: f(Math.abs(dy)), angle: `${+((Math.atan2(dy, dx) * 180) / Math.PI).toFixed(2)}°` }; } /* ── Output ────────────────────────────────────────────────────────────── */ swatch(l: LayerRow): string { return cssColor(l.rgb, this.dark); } get unitsLabel(): string { return UNITS[this.units] ?? ''; } get skippedLabel(): string { const s = this.built?.skipped; if (!s?.size) return ''; return [...s.entries()].map(([t, n]) => `${n} ${t}`).join(', '); } get baseName(): string { return this.fileName.replace(/\.(dxf|dwg)$/i, '') || 'drawing'; } saveDxf(): void { if (this.dxfBytes) save(new Blob([this.dxfBytes as BlobPart], { type: 'application/dxf' }), `${this.baseName}.dxf`); } savePng(): void { this.canvasRef?.nativeElement.toBlob((blob) => blob && save(blob, `${this.baseName}.png`), 'image/png'); } /** The whole drawing as SVG, dark-on-light, whatever the screen is showing. */ saveSvg(): void { const b = this.built; if (!b) return; const visible = new Set(this.layers.filter((l) => l.visible).map((l) => l.name)); const w = b.maxX - b.minX || 1; const h = b.maxY - b.minY || 1; const pad = Math.max(w, h) * 0.02; const stroke = Math.max(w, h) / 1500; const parts: string[] = []; for (const s of b.shapes) { if (!visible.has(s.layer)) continue; const col = cssColor(s.color, false); if (s.kind === 'text') { const t = s.text!; const size = t.height * CAP_TO_EM; const anchor = t.align === 'center' ? 'middle' : t.align === 'right' ? 'end' : 'start'; const base = t.baseline === 'top' ? 'hanging' : t.baseline === 'middle' ? 'middle' : 'auto'; const rot = (-t.rotation * 180) / Math.PI; const tspans = t.lines.map((l, i) => `${xml(l)}`).join(''); parts.push(`${tspans}`); continue; } const d = (s.rings ?? []).map((r) => { let p = `M${r[0]} ${-r[1]}`; for (let i = 2; i < r.length; i += 2) p += `L${r[i]} ${-r[i + 1]}`; return p + (s.closed || s.kind === 'fill' ? 'Z' : ''); }).join(''); if (s.kind === 'fill') parts.push(``); else if (s.kind === 'point') parts.push(``); else parts.push(``); } const svg = `` + `${parts.join('')}`; save(new Blob([svg], { type: 'image/svg+xml' }), `${this.baseName}.svg`); } reset(): void { this.built = null; this.layers = []; this.fileName = ''; this.measure = null; this.tool = 'pan'; this.batches = []; this.texts = []; this.points = []; this.resizeObserver?.disconnect(); this.resizeObserver = null; this.observed = null; this.loadError = ''; } } /* ── Helpers ───────────────────────────────────────────────────────────── */ function cssColor(c: number, dark: boolean): string { if (c === FOREGROUND) return dark ? '#f2f2f2' : '#111111'; // Pure white on a light background, or pure black on a dark one, would vanish. if (!dark && c === 0xffffff) return '#111111'; if (dark && c === 0x000000) return '#f2f2f2'; return `#${c.toString(16).padStart(6, '0')}`; } function acadVersion(v: string): string { const map: Record = { AC1009: 'R12', AC1012: 'R13', AC1014: 'R14', AC1015: '2000', AC1018: '2004', AC1021: '2007', AC1024: '2010', AC1027: '2013', AC1032: '2018', }; return map[v] ? `AutoCAD ${map[v]} DXF` : v ? `DXF (${v})` : 'DXF'; } function xml(s: string): string { return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!); } function save(blob: Blob, name: string): void { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }