From 4b93ac9cbb2905dde861fb2f4effc684e71e9556 Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 14:29:05 +0200 Subject: [PATCH] DXF-Import: TEXT/MTEXT -> editierbarer Plan-Text (Drawing2D text-Primitiv, SVG-Render) --- src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/io/dxfParser.test.ts | 62 ++++++++++++++++++++++++++++++++++++++- src/io/dxfParser.ts | 57 +++++++++++++++++++++++++++++++++-- src/io/dxfToDrawings.ts | 36 ++++++++++++++++++++++- src/model/types.ts | 14 +++++++++ src/plan/PlanView.tsx | 33 ++++++++++++++++++++- src/plan/generatePlan.ts | 31 +++++++++++++++++++- src/plan/toRenderScene.ts | 2 +- src/ui/ImportDialog.tsx | 18 ++++++++++-- 10 files changed, 245 insertions(+), 10 deletions(-) diff --git a/src/i18n/de.ts b/src/i18n/de.ts index bad9887..b096488 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -779,6 +779,7 @@ export const de = { "import.file": "Datei", "import.summary.heading": "Inhalt", "import.summary.contours": "{n} Konturen", + "import.summary.texts": "{n} Texte", "import.summary.meshes": "{n} Meshes", "import.summary.layers": "Layer: {layers}", "import.summary.noLayers": "keine Layer-Namen", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index ff54146..14573e3 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -768,6 +768,7 @@ export const en: Record = { "import.file": "File", "import.summary.heading": "Contents", "import.summary.contours": "{n} contours", + "import.summary.texts": "{n} texts", "import.summary.meshes": "{n} meshes", "import.summary.layers": "Layers: {layers}", "import.summary.noLayers": "no layer names", diff --git a/src/io/dxfParser.test.ts b/src/io/dxfParser.test.ts index ec36c0d..35491bb 100644 --- a/src/io/dxfParser.test.ts +++ b/src/io/dxfParser.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; import { parseDxf } from "./dxfParser"; -import { contoursToDrawings } from "./dxfToDrawings"; +import { contoursToDrawings, textsToDrawings } from "./dxfToDrawings"; import type { Contour, ContourSet, Vec2 } from "../model/types"; /** Minimales DXF aus Gruppencode/Wert-Paaren; nur eine ENTITIES-Sektion. */ @@ -411,6 +411,66 @@ describe("parseDxf — HATCH", () => { }); }); +// ── TEXT / MTEXT ────────────────────────────────────────────────────────────── + +/** TEXT-Entity: Position, Höhe, Rotation (Grad), String. */ +function textEnt(x: number, y: number, h: number, rotDeg: number, s: string): string[] { + return ["0", "TEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", `${rotDeg}`, "1", s]; +} + +/** MTEXT-Entity: Position, Höhe, String (Rotation 0). */ +function mtextEnt(x: number, y: number, h: number, s: string): string[] { + return ["0", "MTEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", "0", "1", s]; +} + +describe("parseDxf — TEXT/MTEXT", () => { + it("TEXT → ImportedText mit Position/Höhe/Winkel (Grad→Radiant)", () => { + const res = parseDxf(dxf(textEnt(5, 3, 0.5, 90, "Hallo"))); + expect(res.texts).toHaveLength(1); + const t0 = res.texts![0]; + expect(t0.at.x).toBeCloseTo(5, 9); + expect(t0.at.y).toBeCloseTo(3, 9); + expect(t0.height).toBeCloseTo(0.5, 9); + expect(t0.angle).toBeCloseTo(Math.PI / 2, 9); + expect(t0.text).toBe("Hallo"); + }); + + it("MTEXT → Formatcodes gesäubert (\\P → Leerzeichen)", () => { + const res = parseDxf(dxf(mtextEnt(1, 2, 0.3, "Welt\\Pzeile2"))); + expect(res.texts![0].text).toBe("Welt zeile2"); + expect(res.texts![0].at.x).toBeCloseTo(1, 9); + expect(res.texts![0].height).toBeCloseTo(0.3, 9); + }); + + it("leerer Text wird übersprungen", () => { + const res = parseDxf(dxf(textEnt(0, 0, 0.2, 0, ""))); + expect(res.texts ?? []).toHaveLength(0); + }); + + it("fehlende Höhe → positiver Default", () => { + const g = ["0", "TEXT", "8", "0", "10", "0", "20", "0", "30", "0", "1", "X"]; + const res = parseDxf(dxf(g)); + expect(res.texts![0].height).toBeGreaterThan(0); + }); +}); + +describe("textsToDrawings", () => { + it("ImportedText → Drawing2D {shape:\"text\"}", () => { + const drawings = textsToDrawings( + [{ at: { x: 1, y: 2 }, text: "Hi", height: 0.4, angle: 0, layer: "L" }], + "lvl", "active", "CODE", (s) => s, + ); + expect(drawings).toHaveLength(1); + const g = drawings[0].geom; + expect(g.shape).toBe("text"); + if (g.shape === "text") { + expect(g.text).toBe("Hi"); + expect(g.height).toBeCloseTo(0.4, 9); + expect(g.at.x).toBeCloseTo(1, 9); + } + }); +}); + describe("contoursToDrawings — HATCH-Füllung", () => { it("gefüllte Kontur → Drawing2D mit fillColor und polyline-Form", () => { const set: ContourSet = { diff --git a/src/io/dxfParser.ts b/src/io/dxfParser.ts index 3f87692..a451a8e 100644 --- a/src/io/dxfParser.ts +++ b/src/io/dxfParser.ts @@ -24,9 +24,10 @@ // • INSERT → Block-Konturen, transformiert (Scale/Rot/Array, verschachtelt). // • HATCH → gefüllte Kontur(en) je Randpfad (Custom-Handler, // Polyline- + Linien-/Bogen-/Ellipsen-/Spline-Kanten; Fill via `Contour.filled`). +// • TEXT / MTEXT → ImportedText (Position/Höhe/Winkel) → Drawing2D `{shape:"text"}`. import DxfParser from "dxf-parser"; -import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types"; +import type { Contour, ContourSet, ImportedMesh, ImportedText, Vec2 } from "../model/types"; /** * Optionale Import-Diagnose: Histogramm der gelesenen Entity-Typen (Typ → Anzahl) @@ -41,10 +42,12 @@ export interface ImportDiagnostics { total: number; } -/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */ +/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze + Texte. */ export interface DxfImportResult { meshes: ImportedMesh[]; contours: ContourSet[]; + /** Importierte TEXT/MTEXT-Elemente (optional; der DWG-Parser füllt sie nicht). */ + texts?: ImportedText[]; /** Optionale Diagnose (Entity-Typ-Histogramm); vom DWG-Parser gefüllt. */ diagnostics?: ImportDiagnostics; } @@ -97,6 +100,12 @@ interface DxfEntity { rowSpacing?: number; // HATCH: rohe Gruppencodes (vom Custom-Handler gesammelt, siehe HatchHandler). rawCodes?: RawCode[]; + // TEXT/MTEXT. `position` (MTEXT-Einfügepunkt) wird tolerant gelesen (oben als + // MESH-Vertexliste getippt). `rotation` (Grad) ist bereits oben deklariert. + startPoint?: DxfVertex; // TEXT-Einfügepunkt + textHeight?: number; // TEXT-Höhe (Code 40) + height?: number; // MTEXT-Höhe (Code 40) + text?: string; // Textinhalt (Code 1, MTEXT auch 3) [k: string]: unknown; } /** Ein rohes DXF-Gruppencode/Wert-Paar (HATCH-Randpfad-Auswertung). */ @@ -140,6 +149,7 @@ export function parseDxf(text: string): DxfImportResult { const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…) const meshIndices: number[] = []; const contours: Contour[] = []; + const texts: ImportedText[] = []; for (const e of entities) { const type = (e.type ?? "").toUpperCase(); @@ -156,6 +166,11 @@ export function parseDxf(text: string): DxfImportResult { addPolyfaceMesh(meshTriangles, meshIndices, e); continue; } + if (type === "TEXT" || type === "MTEXT") { + const it = textEntity(e, type); + if (it) texts.push(it); + continue; + } // Alle übrigen Kontur-Entities (inkl. SPLINE + INSERT-Block-Expansion) über // den gemeinsamen Sammler — dieselbe Logik nutzt die INSERT-Rekursion. collectContours(e, blocks, contours, 0); @@ -182,7 +197,43 @@ export function parseDxf(text: string): DxfImportResult { }); } - return { meshes, contours: contourSets }; + return { meshes, contours: contourSets, texts }; +} + +// ── TEXT / MTEXT ───────────────────────────────────────────────────────────── + +/** Fallback-Schrifthöhe (Meter), wenn die DXF-Höhe fehlt/0 ist. */ +const DEFAULT_TEXT_HEIGHT = 0.25; + +/** + * MTEXT-Inhalt von Formatierungscodes säubern (grober erster Wurf): \P → Leer- + * zeichen, \~ → Leerzeichen, `\f…;`/`\H…;`/`\C…;`/`\A…;`-Sequenzen und die {}- + * Gruppenklammern entfernen, `\{`/`\}`/`\\` entschärfen. Komplexe Verschachte- + * lungen werden flachgeklopft (dokumentierte Grenze). + */ +function sanitizeMText(s: string): string { + return s + .replace(/\\P/gi, " ") + .replace(/\\~/g, " ") + .replace(/\\[A-Za-z][^;\\]*;/g, "") + .replace(/[{}]/g, "") + .replace(/\\([{}\\])/g, "$1") + .trim(); +} + +/** TEXT/MTEXT-Entity → ImportedText (Position/Höhe/Winkel), oder null bei leer. */ +function textEntity(e: DxfEntity, type: string): ImportedText | null { + const rawText = typeof e.text === "string" ? e.text : ""; + const content = type === "MTEXT" ? sanitizeMText(rawText) : rawText; + if (!content) return null; + const at: Vec2 = + type === "MTEXT" + ? { x: coord(e.position, "x"), y: coord(e.position, "y") } + : { x: e.startPoint?.x ?? 0, y: e.startPoint?.y ?? 0 }; + const rawHeight = type === "MTEXT" ? numOr(e.height, 0) : numOr(e.textHeight, 0); + const height = rawHeight > 0 ? rawHeight : DEFAULT_TEXT_HEIGHT; + const angle = (numOr(e.rotation, 0) * Math.PI) / 180; // DXF: Grad CCW → Radiant + return { at, text: content, height, angle, layer: e.layer }; } // ── Mesh-Entities ──────────────────────────────────────────────────────────── diff --git a/src/io/dxfToDrawings.ts b/src/io/dxfToDrawings.ts index 2606c5f..caf1ef9 100644 --- a/src/io/dxfToDrawings.ts +++ b/src/io/dxfToDrawings.ts @@ -15,7 +15,7 @@ // // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). -import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types"; +import type { Contour, ContourSet, Drawing2D, Drawing2DGeom, ImportedText } from "../model/types"; /** Wie DXF-Layer auf Kategorien abgebildet werden. */ export type CategoryMode = "active" | "byLayer"; @@ -66,6 +66,40 @@ export function contoursToDrawings( return out; } +/** + * Wandelt importierte Texte in Drawing2D-`text`-Elemente um — analog + * `contoursToDrawings` (gleiche Layer→Kategorie-Zuordnung). Winkel/Höhe kommen + * 1:1 aus `ImportedText` (Höhe in Modell-Metern, Winkel in Radiant). + */ +export function textsToDrawings( + texts: ImportedText[], + levelId: string, + categoryMode: CategoryMode, + fallbackCode: string, + layerToCode: (layerName: string) => string, +): Drawing2D[] { + const out: Drawing2D[] = []; + for (const it of texts) { + const layerName = it.layer ?? ""; + const categoryCode = + categoryMode === "byLayer" && layerName ? layerToCode(layerName) : fallbackCode; + out.push({ + id: nextId(), + type: "drawing2d", + levelId, + categoryCode, + geom: { + shape: "text", + at: { ...it.at }, + text: it.text, + height: it.height, + angle: it.angle, + }, + }); + } + return out; +} + /** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */ function contourGeom(contour: Contour): Drawing2DGeom | null { const pts = contour.pts; diff --git a/src/model/types.ts b/src/model/types.ts index 9ddd0a8..cc7325b 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -1100,6 +1100,20 @@ export interface Contour { filled?: boolean; } +/** + * Ein importiertes Text-Element (DXF TEXT/MTEXT). `at` = Einfügepunkt in Metern, + * `height` = Schrifthöhe in Modell-Metern, `angle` = Drehung in RADIANT (CCW). + * Wird beim Drawing-Import zu einem `{shape:"text"}`-Drawing2D. + */ +export interface ImportedText { + at: Vec2; + text: string; + height: number; + angle: number; + /** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */ + layer?: string; +} + /** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */ export interface ContourSet { id: string; diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index d5d709d..8b7f80e 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -1640,7 +1640,15 @@ export const PlanView = forwardRef( // Raum-Stempel selbst (glyphon) — das SVG darf ihn NICHT zusätzlich // zeichnen (sonst doppelter Text), also entfällt dort auch das Textoverlay. // Sonst (reines SVG): alles außer den gebündelten 2D-Zeichenlinien. - (useGpuRenderer ? wantWasm || p.kind !== "text" : p.kind === "line" && p.drawingId) ? null : ( + // `drawingText` (importierter Einzeltext) rendert IMMER im SVG — der GL- + // wie der WASM-Renderer zeichnet ihn nicht. Der Raum-Stempel (`text`) + // bleibt: unter WASM rastert ihn render2d selbst (sonst doppelt). + (useGpuRenderer + ? wantWasm + ? p.kind !== "drawingText" + : p.kind !== "text" && p.kind !== "drawingText" + : p.kind === "line" && p.drawingId) + ? null : ( ); } + case "drawingText": { + // Modellverankerter Einzeltext (DXF-Import): Höhe in Metern → viewBox- + // Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links). + const c = toScreen(p.at); + const fontSize = p.heightM * PX_PER_M; + // Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg. + const deg = -(p.angle * 180) / Math.PI; + return ( + + {p.text} + + ); + } } } diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index eb3907a..5d2e131 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -294,6 +294,23 @@ export type Primitive = /** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */ roomId?: string; greyed?: boolean; + } + | { + // Schlichter, modellverankerter Einzeltext (aus Drawing2D `{shape:"text"}`, + // z. B. DXF-Import). Anders als `kind:"text"` (Raum-Stempel, RichText) trägt + // er nur einen String mit Höhe in Metern + Drehung — rein SVG-gerendert. + kind: "drawingText"; + /** Ankerpunkt im Modell (Meter). */ + at: Vec2; + text: string; + /** Schrifthöhe in Modell-Metern. */ + heightM: number; + /** Drehung in RADIANT (CCW im Modell). */ + angle: number; + color: string; + /** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */ + drawingId?: string; + greyed?: boolean; }; /** @@ -1013,7 +1030,19 @@ function addDrawing2D( for (let i = 0; i < N; i++) seg(pts[i], pts[(i + 1) % N]); break; } - // arc/text: Primitive-Erweiterungen folgen später. + case "text": + out.push({ + kind: "drawingText", + at: g.at, + text: g.text, + heightM: g.height, + angle: g.angle, + color, + greyed, + drawingId: d.id, + }); + break; + // arc: Primitive-Erweiterung folgt später. default: break; } diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index 523f688..5c0635c 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -486,7 +486,7 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL widthMm: p.weightMm, dash: p.dash ?? null, }); - } else { + } else if (p.kind === "text") { // "text": zeilenweise flachen — die ECHTEN Glyphen rastert der Rust- // Renderer über einen GPU-Glyphen-Atlas (glyphon, dieselbe Font-Familie // wie im Browser). Layout identisch zum SVG-Pfad in PlanView (case diff --git a/src/ui/ImportDialog.tsx b/src/ui/ImportDialog.tsx index 92dadbf..8e9a638 100644 --- a/src/ui/ImportDialog.tsx +++ b/src/ui/ImportDialog.tsx @@ -21,6 +21,7 @@ import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types"; import type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser"; import { contoursToDrawings, + textsToDrawings, countContours, distinctLayers, } from "../io/dxfToDrawings"; @@ -107,9 +108,11 @@ export function ImportDialog({ const contourSets = parsed?.contours ?? []; const meshes: ImportedMesh[] = parsed?.meshes ?? []; + const texts = parsed?.texts ?? []; const contourCount = countContours(contourSets); + const textCount = texts.length; const layers = distinctLayers(contourSets); - const hasDrawings = contourCount > 0; + const hasDrawings = contourCount > 0 || textCount > 0; const hasMeshes = meshes.length > 0; // ── Formular-Zustand ────────────────────────────────────────────────────── @@ -187,6 +190,9 @@ export function ImportDialog({
  • {t("import.summary.contours", { n: contourCount })}
  • + {textCount > 0 && ( +
  • {t("import.summary.texts", { n: textCount })}
  • + )}
  • {t("import.summary.meshes", { n: meshes.length })}
  • {layers.length > 0 @@ -348,11 +354,19 @@ export function buildDrawings( for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code); const layerToCode = (layerName: string): string => byName.get(layerName.toLowerCase()) ?? activeCategoryCode; - return contoursToDrawings( + const fromContours = contoursToDrawings( decision.parsed.contours, levelId, decision.categoryMode, activeCategoryCode, layerToCode, ); + const fromTexts = textsToDrawings( + decision.parsed.texts ?? [], + levelId, + decision.categoryMode, + activeCategoryCode, + layerToCode, + ); + return [...fromContours, ...fromTexts]; }