1// UQO Éval — Métré : lecteur DXF minimal (ASCII, groupes code/valeur) — LINE, LWPOLYLINE, POLYLINE/VERTEX, CIRCLE, calques, $INSUNITS. Module PUR.2/**3 * Pas de bibliothèque tierce : le format DXF ASCII est une suite de paires4 * « code de groupe / valeur ». On ne lit que la géométrie 2D utile au métré ;5 * ARC, SPLINE, INSERT (blocs), HATCH et TEXT sont comptés mais ignorés et6 * signalés à l'utilisateur. Unités : $INSUNITS (1 po, 2 pi, 4 mm, 5 cm, 6 m) ;7 * si absent/0, l'utilisateur doit calibrer manuellement.8 */9import type { LengthUnit, Point } from "../types";1011export const DXF_PARSER_VERSION = "dxf-1.0";12export const MAX_DXF_ENTITIES = 20000;1314export interface DxfEntity { type: "line" | "polyline" | "circle"; layer: string; points: Point[]; closed: boolean }15export interface DxfDrawing {16 unit: LengthUnit | null; // depuis $INSUNITS17 insunits: number | null;18 entities: DxfEntity[];19 layers: string[];20 skipped: Record<string, number>;21 bounds: { minX: number; minY: number; maxX: number; maxY: number } | null;22}2324const INSUNITS: Record<number, LengthUnit> = { 1: "in", 2: "ft", 4: "mm", 5: "cm", 6: "m" };2526export function parseDxf(text: string): DxfDrawing {27 const lines = text.split(/\r\n|\r|\n/);28 const pairs: { code: number; value: string }[] = [];29 for (let i = 0; i + 1 < lines.length; i += 2) {30 const code = Number(lines[i].trim());31 if (!Number.isFinite(code)) continue;32 pairs.push({ code, value: lines[i + 1].trim() });33 }34 let insunits: number | null = null;35 const entities: DxfEntity[] = [];36 const skipped: Record<string, number> = {};37 const layers = new Set<string>();38 let section = "";39 let i = 0;40 const circlePoly = (cx: number, cy: number, r: number): Point[] => Array.from({ length: 32 }, (_, k) => ({ x: cx + r * Math.cos((2 * Math.PI * k) / 32), y: cy + r * Math.sin((2 * Math.PI * k) / 32) }));41 while (i < pairs.length) {42 const p = pairs[i];43 if (p.code === 0 && p.value === "SECTION") { section = pairs[i + 1]?.code === 2 ? pairs[i + 1].value : ""; i += 2; continue; }44 if (p.code === 0 && p.value === "ENDSEC") { section = ""; i++; continue; }45 if (section === "HEADER" && p.code === 9 && p.value === "$INSUNITS") { const v = pairs[i + 1]; if (v?.code === 70) insunits = Number(v.value); i += 2; continue; }46 if (section === "ENTITIES" && p.code === 0) {47 const type = p.value;48 // collecte des paires jusqu'au prochain code 049 let j = i + 1; const attrs: { code: number; value: string }[] = [];50 while (j < pairs.length && pairs[j].code !== 0) { attrs.push(pairs[j]); j++; }51 const layer = attrs.find((a) => a.code === 8)?.value ?? "0";52 const num = (code: number, idx = 0): number | null => { let k = 0; for (const a of attrs) if (a.code === code) { if (k === idx) return Number(a.value); k++; } return null; };53 if (entities.length >= MAX_DXF_ENTITIES) { skipped["limite"] = (skipped["limite"] ?? 0) + 1; i = j; continue; }54 if (type === "LINE") {55 const pts = [{ x: num(10) ?? 0, y: num(20) ?? 0 }, { x: num(11) ?? 0, y: num(21) ?? 0 }];56 entities.push({ type: "line", layer, points: pts, closed: false }); layers.add(layer);57 } else if (type === "LWPOLYLINE") {58 const xs = attrs.filter((a) => a.code === 10).map((a) => Number(a.value)), ys = attrs.filter((a) => a.code === 20).map((a) => Number(a.value));59 const flags = num(70) ?? 0;60 const pts = xs.map((x, k) => ({ x, y: ys[k] ?? 0 }));61 if (pts.length >= 2) { entities.push({ type: "polyline", layer, points: pts, closed: (flags & 1) === 1 }); layers.add(layer); }62 } else if (type === "POLYLINE") {63 const flags = num(70) ?? 0; const pts: Point[] = [];64 // les VERTEX suivent jusqu'à SEQEND65 let k = j;66 while (k < pairs.length) {67 if (pairs[k].code === 0 && pairs[k].value === "VERTEX") { let m = k + 1; let x = 0, y = 0; while (m < pairs.length && pairs[m].code !== 0) { if (pairs[m].code === 10) x = Number(pairs[m].value); if (pairs[m].code === 20) y = Number(pairs[m].value); m++; } pts.push({ x, y }); k = m; continue; }68 if (pairs[k].code === 0 && pairs[k].value === "SEQEND") { k++; break; }69 if (pairs[k].code === 0) break;70 k++;71 }72 if (pts.length >= 2) { entities.push({ type: "polyline", layer, points: pts, closed: (flags & 1) === 1 }); layers.add(layer); }73 i = k; continue;74 } else if (type === "CIRCLE") {75 const r = num(40) ?? 0;76 if (r > 0) { entities.push({ type: "circle", layer, points: circlePoly(num(10) ?? 0, num(20) ?? 0, r), closed: true }); layers.add(layer); }77 } else if (["ARC", "SPLINE", "INSERT", "HATCH", "TEXT", "MTEXT", "DIMENSION", "ELLIPSE", "POINT", "SOLID", "3DFACE"].includes(type)) {78 skipped[type] = (skipped[type] ?? 0) + 1;79 }80 i = j; continue;81 }82 i++;83 }84 let bounds: DxfDrawing["bounds"] = null;85 for (const e of entities) for (const q of e.points) {86 if (!Number.isFinite(q.x) || !Number.isFinite(q.y)) continue;87 bounds = bounds ? { minX: Math.min(bounds.minX, q.x), minY: Math.min(bounds.minY, q.y), maxX: Math.max(bounds.maxX, q.x), maxY: Math.max(bounds.maxY, q.y) } : { minX: q.x, minY: q.y, maxX: q.x, maxY: q.y };88 }89 return { unit: insunits != null ? INSUNITS[insunits] ?? null : null, insunits, entities, layers: [...layers], skipped, bounds };90}9192/**93 * Projette le dessin sur une page raster : marge, inversion de l'axe Y (DXF : Y vers le haut),94 * échelle px par unité DXF choisie pour tenir dans `maxPx`. Renvoie les points en pixels,95 * la taille de page et les deux points de calibration exacts (1 unité DXF connue).96 */97export function projectDxf(d: DxfDrawing, maxPx = 4000, marginPx = 80): { widthPx: number; heightPx: number; pxPerUnit: number; entities: { entity: DxfEntity; points: Point[] }[]; calibration: { p1: Point; p2: Point; realLength: number } | null } {98 if (!d.bounds) return { widthPx: 800, heightPx: 600, pxPerUnit: 1, entities: [], calibration: null };99 const w = Math.max(1e-9, d.bounds.maxX - d.bounds.minX), h = Math.max(1e-9, d.bounds.maxY - d.bounds.minY);100 const pxPerUnit = Math.min((maxPx - 2 * marginPx) / w, (maxPx - 2 * marginPx) / h);101 const widthPx = Math.ceil(w * pxPerUnit + 2 * marginPx), heightPx = Math.ceil(h * pxPerUnit + 2 * marginPx);102 const map = (q: Point): Point => ({ x: marginPx + (q.x - d.bounds!.minX) * pxPerUnit, y: marginPx + (d.bounds!.maxY - q.y) * pxPerUnit });103 const entities = d.entities.map((entity) => ({ entity, points: entity.points.map(map) }));104 // calibration : segment horizontal d'une longueur ronde en unités DXF (la plus grande puissance de 10 ≤ largeur)105 const L = Math.pow(10, Math.floor(Math.log10(w)));106 const p1 = map({ x: d.bounds.minX, y: d.bounds.minY }), p2 = map({ x: d.bounds.minX + L, y: d.bounds.minY });107 return { widthPx, heightPx, pxPerUnit, entities, calibration: { p1, p2, realLength: L } };108}109110/** SVG blanc des entités projetées (rasterisé par sharp côté serveur). */111export function dxfToSvg(proj: ReturnType<typeof projectDxf>): string {112 const parts = proj.entities.map(({ entity, points }) => {113 const d = points.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" ");114 return entity.closed ? `<polygon points="${d}" fill="none" stroke="#1a1a1a" stroke-width="1.5"/>` : `<polyline points="${d}" fill="none" stroke="#1a1a1a" stroke-width="1.5"/>`;115 });116 return `<svg xmlns="http://www.w3.org/2000/svg" width="${proj.widthPx}" height="${proj.heightPx}" viewBox="0 0 ${proj.widthPx} ${proj.heightPx}"><rect width="100%" height="100%" fill="#ffffff"/>${parts.join("")}</svg>`;117}118