2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand

Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent 7b3b597abc
commit 8fd8987b70
184 changed files with 29421 additions and 669 deletions
+212
View File
@@ -0,0 +1,212 @@
// Render-Adapter für das Rich-Text-Modell (richText.ts): einmal nach HTML/CSS
// (für den WYSIWYG-Editor und die Bildschirm-Vorschau auf dem Canvas) und einmal
// nach SVG-<tspan> (für den vektoriellen Grundriss-/Print-Renderer). Beide Wege
// bilden dieselbe Mark→Stil-Zuordnung ab, damit Editor-Vorschau und gezeichneter
// Text identisch aussehen. Reines Modul: keine React-/App-Abhängigkeit.
import type { Marks, Paragraph, RichTextDoc, TextRun } from "./richText";
// ── Text-Escaping ───────────────────────────────────────────────────────────
/** Escapt Text für HTML/XML/SVG-Inhalt (kein Attribut). */
export function escapeText(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/** Escapt einen Wert für ein doppelt-gequotetes XML-Attribut. */
function escapeAttr(s: string): string {
return escapeText(s).replace(/"/g, "&quot;");
}
// ── Gemeinsame Mark→Stil-Zuordnung ──────────────────────────────────────────
/** Kombiniert underline + strike zu einem CSS/SVG-text-decoration-Wert. */
function textDecoration(marks: Marks): string | undefined {
const parts: string[] = [];
if (marks.underline) parts.push("underline");
if (marks.strike) parts.push("line-through");
return parts.length ? parts.join(" ") : undefined;
}
/** Faktor, um den super-/sub-Text zu verkleinern. */
const SCRIPT_SCALE = 0.7;
/**
* CSS-Deklarationen (als `{prop: value}`) für einen Run. `basePt` ist die
* Basisschriftgrösse in pt, falls der Run selbst keine `sizePt` trägt.
*/
export function runCssProps(marks: Marks, basePt = 12): Record<string, string> {
const css: Record<string, string> = {};
if (marks.bold) css["font-weight"] = "700";
if (marks.italic) css["font-style"] = "italic";
const deco = textDecoration(marks);
if (deco) css["text-decoration"] = deco;
if (marks.font) css["font-family"] = marks.font;
const sizePt = (marks.sizePt ?? basePt) * (marks.super || marks.sub ? SCRIPT_SCALE : 1);
css["font-size"] = `${round(sizePt)}pt`;
if (marks.color) css["color"] = marks.color;
if (marks.super) css["vertical-align"] = "super";
if (marks.sub) css["vertical-align"] = "sub";
return css;
}
function cssPropsToString(css: Record<string, string>): string {
return Object.entries(css)
.map(([k, v]) => `${k}:${v}`)
.join(";");
}
function round(n: number): number {
return Math.round(n * 1000) / 1000;
}
// ── HTML-Render (Editor / Canvas-Vorschau) ──────────────────────────────────
export interface HtmlOptions {
/** Basisschriftgrösse in pt für Runs ohne eigene Grösse. */
basePt?: number;
/** Standardfarbe (CSS) falls kein Run eine Farbe setzt. */
color?: string;
}
/**
* Rendert das Dokument zu einem sanitisierten HTML-String: ein `<div>` pro
* Absatz (mit `text-align`), darin ein `<span>` pro Run mit Inline-Styles.
* Leere Absätze erhalten ein `<br>`, damit die Zeilenhöhe erhalten bleibt.
*/
export function docToHtml(doc: RichTextDoc, opts: HtmlOptions = {}): string {
const basePt = opts.basePt ?? 12;
return doc.paragraphs
.map((p) => paragraphToHtml(p, basePt, opts.color))
.join("");
}
function paragraphToHtml(p: Paragraph, basePt: number, color?: string): string {
const align = p.align && p.align !== "left" ? ` style="text-align:${p.align}"` : "";
if (p.runs.length === 0) return `<div${align}><br></div>`;
const inner = p.runs.map((r) => runToHtmlSpan(r, basePt, color)).join("");
return `<div${align}>${inner}</div>`;
}
function runToHtmlSpan(run: TextRun, basePt: number, color?: string): string {
const css = runCssProps(run.marks, basePt);
if (color && !css["color"]) css["color"] = color;
const style = cssPropsToString(css);
const text = escapeText(run.text) || "";
return `<span style="${escapeAttr(style)}">${text}</span>`;
}
// ── SVG-Render (Grundriss / Print) ──────────────────────────────────────────
/** Beschreibung eines einzelnen <tspan> (ein Run innerhalb einer Zeile). */
export interface SvgTspan {
text: string;
/** Schriftgrösse in Benutzereinheiten (bereits umgerechnet). */
fontSize: number;
fontWeight?: "700";
fontStyle?: "italic";
fontFamily?: string;
fill: string;
textDecoration?: string;
/** Vertikaler Versatz für Hoch-/Tiefstellung, in Benutzereinheiten. */
baselineShift?: number;
}
/** Eine gerenderte Zeile (= Absatz) mit ihren Runs. */
export interface SvgLine {
tspans: SvgTspan[];
align: "left" | "center" | "right";
}
export interface SvgTextOptions {
/** Ankerpunkt X in Benutzereinheiten. */
x: number;
/** Baseline der ERSTEN Zeile, Y in Benutzereinheiten. */
y: number;
/** Zeilenvorschub (Baseline zu Baseline) in Benutzereinheiten. */
lineHeight: number;
/**
* Umrechnung Schriftgrösse: `sizePt` × `unitPerPt` = Grösse in Benutzereinheiten.
* Der Aufrufer bestimmt so, ob Text in px, mm oder Metern gezeichnet wird.
*/
unitPerPt: number;
/** Basis-`sizePt` für Runs ohne eigene Grösse. */
basePt?: number;
/** Standardfarbe "#rrggbb" für Runs ohne Farbe. */
color?: string;
}
/**
* Zerlegt das Dokument in Zeilen/Tspans mit bereits in Benutzereinheiten
* umgerechneten Grössen. Der Aufrufer positioniert die Zeilen selbst (Baseline
* der Zeile i = `y + i·lineHeight`).
*/
export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] {
const basePt = opts.basePt ?? 12;
const color = opts.color ?? "#000000";
return doc.paragraphs.map((p) => ({
align: p.align ?? "left",
tspans: p.runs
.filter((r) => r.text.length > 0)
.map((r) => runToTspan(r, basePt, opts.unitPerPt, color)),
}));
}
function runToTspan(run: TextRun, basePt: number, unitPerPt: number, color: string): SvgTspan {
const m = run.marks;
const script = m.super || m.sub;
const sizePt = (m.sizePt ?? basePt) * (script ? SCRIPT_SCALE : 1);
const fontSize = sizePt * unitPerPt;
const tspan: SvgTspan = {
text: run.text,
fontSize: round(fontSize),
fill: m.color ?? color,
};
if (m.bold) tspan.fontWeight = "700";
if (m.italic) tspan.fontStyle = "italic";
if (m.font) tspan.fontFamily = m.font;
const deco = textDecoration(m);
if (deco) tspan.textDecoration = deco;
if (m.super) tspan.baselineShift = round(fontSize * 0.35);
if (m.sub) tspan.baselineShift = round(-fontSize * 0.2);
return tspan;
}
/**
* Baut ein komplettes `<text>`-SVG-Element als String. Jede Zeile ist ein
* `<tspan x=… dy=…>`, darin ein `<tspan>` je Run. `text-anchor` folgt der
* Absatz-Ausrichtung (left→start, center→middle, right→end).
*/
export function docToSvgText(doc: RichTextDoc, opts: SvgTextOptions): string {
const lines = docToLines(doc, opts);
const parts: string[] = [];
lines.forEach((line, i) => {
const dy = i === 0 ? 0 : opts.lineHeight;
const anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start";
const runSpans = line.tspans.map((t) => tspanToSvg(t)).join("");
// Leere Zeile: ein Zero-Width-Space, damit dy dennoch wirkt.
const content = runSpans || "&#8203;";
parts.push(
`<tspan x="${round(opts.x)}" dy="${round(dy)}" text-anchor="${anchor}">${content}</tspan>`,
);
});
return `<text x="${round(opts.x)}" y="${round(opts.y)}">${parts.join("")}</text>`;
}
function tspanToSvg(t: SvgTspan): string {
const attrs: string[] = [`font-size="${t.fontSize}"`, `fill="${escapeAttr(t.fill)}"`];
if (t.fontWeight) attrs.push(`font-weight="${t.fontWeight}"`);
if (t.fontStyle) attrs.push(`font-style="${t.fontStyle}"`);
if (t.fontFamily) attrs.push(`font-family="${escapeAttr(t.fontFamily)}"`);
if (t.textDecoration) attrs.push(`text-decoration="${t.textDecoration}"`);
if (t.baselineShift) attrs.push(`dy="${-t.baselineShift}"`);
const body = escapeText(t.text);
// Bei baseline-shift den dy nach dem Run zurücksetzen, damit die Grundlinie hält.
if (t.baselineShift) {
return `<tspan ${attrs.join(" ")}>${body}</tspan><tspan dy="${t.baselineShift}">&#8203;</tspan>`;
}
return `<tspan ${attrs.join(" ")}>${body}</tspan>`;
}