d27622d581
Neues strokeFont.ts (kompakte Einstrich-Schrift: A-Z, 0-9, Symbole inkl. ²/·). toRenderScene wickelt Text-Primitive (Doc-Zeilen + Live-Zusatzzeilen) zu Glyph- Polylinien in Modell-Metern ab (vertikal um den Anker zentriert, Ausrichtung je Absatz) und speist sie wie die Schraffuren in die render2d-Szene. Erste Fassung in Versalien; render2d bleibt textrenderer-frei. Damit zeigt der native Grundriss den Raumstempel (Name/Fläche/SIA) analog zum Browser.
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
// Flacht einen {@link Plan} (die eine Wahrheit aus generatePlan) auf das
|
|
// GPU-nahe Szenenformat des nativen render2d-Renderers ab: Füllpolygone,
|
|
// Umrisse und Linien. Hatch und Text bleiben (wie im Rust-Renderer, siehe
|
|
// render2d/src/types.rs) vorerst außen vor — sie sind Overlay/späteres M3.
|
|
//
|
|
// Das erzeugte Objekt matcht 1:1 die serde-Structs render2d::types::Scene
|
|
// { fills:[{pts,color}], outlines:[{pts,color,widthMm}], lines:[{a,b,color,widthMm}] }
|
|
// mit Point = [x,y] (Meter) und Rgba = [r,g,b,a] (0..1).
|
|
|
|
import type { Plan, Primitive } from "./generatePlan";
|
|
import { applyDashRuns, buildHatchRuns } from "./glPlan/glPlanHatch";
|
|
import { CAP, layoutLine } from "./strokeFont";
|
|
|
|
type LinePrim = Extract<Primitive, { kind: "line" }>;
|
|
|
|
/** Modell-Meter pro Schriftpunkt (Referenz-Massstab 1:100, wie im SVG-Pfad). */
|
|
const METERS_PER_PT = (1 / 72) * 0.0254 * 100;
|
|
/** Strichbreite der Text-Glyphen in Papier-mm (Haarlinie). */
|
|
const TEXT_WIDTH_MM = 0.15;
|
|
|
|
export type RPoint = [number, number];
|
|
export type RRgba = [number, number, number, number];
|
|
|
|
export interface RFill {
|
|
pts: RPoint[];
|
|
color: RRgba;
|
|
}
|
|
export interface ROutline {
|
|
pts: RPoint[];
|
|
color: RRgba;
|
|
widthMm: number;
|
|
}
|
|
export interface RPolyline {
|
|
pts: RPoint[];
|
|
color: RRgba;
|
|
widthMm: number;
|
|
}
|
|
export interface RLine {
|
|
a: RPoint;
|
|
b: RPoint;
|
|
color: RRgba;
|
|
widthMm: number;
|
|
}
|
|
export interface RScene {
|
|
fills: RFill[];
|
|
outlines: ROutline[];
|
|
polylines: RPolyline[];
|
|
lines: RLine[];
|
|
}
|
|
|
|
const DEFAULT_LINE = "#1a1a1a";
|
|
|
|
/** Wenige benannte Farben, die in Klassen/Defaults vorkommen können. */
|
|
const NAMED: Record<string, string> = {
|
|
black: "#000000",
|
|
white: "#ffffff",
|
|
red: "#ff0000",
|
|
none: "none",
|
|
transparent: "none",
|
|
};
|
|
|
|
/** Hex/Named-Farbe → [r,g,b,a] in 0..1, oder null bei "none"/leer. */
|
|
function toRgba(input: string | undefined, alpha = 1): RRgba | null {
|
|
if (!input) return null;
|
|
let h = input.trim().toLowerCase();
|
|
if (h === "none" || h === "transparent") return null;
|
|
if (h[0] !== "#") {
|
|
const mapped = NAMED[h];
|
|
if (!mapped) return [0, 0, 0, alpha];
|
|
if (mapped === "none") return null;
|
|
h = mapped;
|
|
}
|
|
h = h.slice(1);
|
|
// Kurzformen auf Langform expandieren (#rgb → #rrggbb, #rgba → #rrggbbaa).
|
|
if (h.length === 3 || h.length === 4) h = h.split("").map((c) => c + c).join("");
|
|
if (h.length !== 6 && h.length !== 8) return [0, 0, 0, alpha];
|
|
const r = parseInt(h.slice(0, 2), 16) / 255;
|
|
const g = parseInt(h.slice(2, 4), 16) / 255;
|
|
const b = parseInt(h.slice(4, 6), 16) / 255;
|
|
// 8-stelliges Hex trägt den Alpha-Kanal selbst; sonst der Parameter.
|
|
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : alpha;
|
|
if (![r, g, b, a].every((v) => Number.isFinite(v))) return [0, 0, 0, alpha];
|
|
return [r, g, b, a];
|
|
}
|
|
|
|
/** Kreisbogen in Liniensegmente zerlegen (kürzerer Sweep, ~24 Segmente). */
|
|
function tessellateArc(
|
|
center: { x: number; y: number },
|
|
from: { x: number; y: number },
|
|
to: { x: number; y: number },
|
|
r: number,
|
|
): RPoint[] {
|
|
const a0 = Math.atan2(from.y - center.y, from.x - center.x);
|
|
let a1 = Math.atan2(to.y - center.y, to.x - center.x);
|
|
// Kürzeren Bogen wählen (generatePlan liefert keine largeArc-Info mit).
|
|
let delta = a1 - a0;
|
|
while (delta > Math.PI) delta -= 2 * Math.PI;
|
|
while (delta < -Math.PI) delta += 2 * Math.PI;
|
|
a1 = a0 + delta;
|
|
const segs = Math.max(2, Math.ceil((Math.abs(delta) / (Math.PI * 2)) * 48));
|
|
const pts: RPoint[] = [];
|
|
for (let i = 0; i <= segs; i++) {
|
|
const t = a0 + (delta * i) / segs;
|
|
pts.push([center.x + Math.cos(t) * r, center.y + Math.sin(t) * r]);
|
|
}
|
|
return pts;
|
|
}
|
|
|
|
/** Zwei Modell-Punkte (Meter) als gleich behandeln (Verkettungs-Toleranz). */
|
|
function samePt(a: RPoint, b: RPoint): boolean {
|
|
return Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6;
|
|
}
|
|
|
|
/** Gleicher Linienstil (für das Verketten aufeinanderfolgender 2D-Zeichenlinien). */
|
|
function sameLineStyle(a: LinePrim, b: LinePrim): boolean {
|
|
return (
|
|
a.cls === b.cls &&
|
|
a.weightMm === b.weightMm &&
|
|
(a.color ?? "") === (b.color ?? "") &&
|
|
JSON.stringify(a.dash ?? null) === JSON.stringify(b.dash ?? null)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Zerlegt einen Polygon-Ring in zusammenhängende Läufe SICHTBARER Kanten (die
|
|
* `noStroke`-Kanten werden ausgelassen). Jeder Lauf beginnt an einer sichtbaren
|
|
* Kante, deren Vorgänger unterdrückt ist. Portiert aus PlanView.visibleEdgeRuns —
|
|
* so bekommen die inneren Ecken eine Gehrung statt Stumpfkappen.
|
|
*/
|
|
function visibleEdgeRuns(pts: RPoint[], noStroke: number[]): RPoint[][] {
|
|
const n = pts.length;
|
|
const skip = new Set(noStroke.map((i) => ((i % n) + n) % n));
|
|
if (skip.size >= n || n < 2) return [];
|
|
const visible = (i: number) => !skip.has(((i % n) + n) % n);
|
|
const runs: RPoint[][] = [];
|
|
for (let s = 0; s < n; s++) {
|
|
if (!visible(s) || visible(s - 1 + n)) continue; // kein Lauf-Anfang
|
|
const run: RPoint[] = [pts[s]];
|
|
let j = s;
|
|
while (visible(j) && j - s < n) {
|
|
run.push(pts[(j + 1) % n]);
|
|
j++;
|
|
}
|
|
runs.push(run);
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
/**
|
|
* Wandelt einen Plan in eine native render2d-Szene um. Zusammenhängende Umriss-
|
|
* und Zeichnungskanten werden zu OFFENEN Polylinien verkettet (gehrte Ecken);
|
|
* nur genuin einzelne Segmente (Türblätter, Referenzlinien) bleiben `lines`.
|
|
*/
|
|
export function planToRenderScene(plan: Plan): RScene {
|
|
const fills: RFill[] = [];
|
|
const outlines: ROutline[] = [];
|
|
const polylines: RPolyline[] = [];
|
|
const lines: RLine[] = [];
|
|
|
|
// Laufender, verketteter 2D-Zeichnungs-Zug (gleiche drawingId + Stil, End-an-Start).
|
|
let curPts: RPoint[] | null = null;
|
|
let curSrc: LinePrim | null = null;
|
|
const flushRun = () => {
|
|
if (curPts && curSrc && curPts.length >= 2) {
|
|
const col = toRgba(curSrc.color ?? DEFAULT_LINE, 1) ?? [0.1, 0.1, 0.1, 1];
|
|
const closed = curPts.length > 2 && samePt(curPts[0], curPts[curPts.length - 1]);
|
|
if (closed) {
|
|
outlines.push({ pts: curPts.slice(0, -1), color: col, widthMm: curSrc.weightMm });
|
|
} else {
|
|
polylines.push({ pts: curPts, color: col, widthMm: curSrc.weightMm });
|
|
}
|
|
}
|
|
curPts = null;
|
|
curSrc = null;
|
|
};
|
|
|
|
for (const p of plan.primitives) {
|
|
if (p.kind === "polygon") {
|
|
flushRun();
|
|
const pts: RPoint[] = p.pts.map((v) => [v.x, v.y]);
|
|
if (pts.length < 2) continue;
|
|
|
|
// Füllung
|
|
const fillCol = toRgba(p.fill, 1);
|
|
if (fillCol && pts.length >= 3) fills.push({ pts, color: fillCol });
|
|
|
|
// Schraffur: die aufs Polygon geclippten Musterlinien wie im Browser
|
|
// (glPlanHatch — identische Geometrie), als Polylinien. "none"/"solid"
|
|
// brauchen keine Linien (solid deckt die Füllung farbig ab).
|
|
if (p.hatch && p.hatch.pattern !== "none" && p.hatch.pattern !== "solid") {
|
|
const hatchCol = toRgba(p.hatch.color, 1) ?? [0.1, 0.1, 0.1, 1];
|
|
const hatchMm = p.hatch.lineWeight > 0 ? p.hatch.lineWeight : 0.13;
|
|
const runs = applyDashRuns(buildHatchRuns(p.pts, p.hatch), p.hatch.dash);
|
|
for (const run of runs) {
|
|
if (run.length >= 2) {
|
|
polylines.push({ pts: run.map((v) => [v.x, v.y]), color: hatchCol, widthMm: hatchMm });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Umriss
|
|
const strokeCol = toRgba(p.stroke, 1);
|
|
if (strokeCol && p.strokeWidthMm > 0) {
|
|
const suppressed = p.noStrokeEdges ?? [];
|
|
if (suppressed.length === 0) {
|
|
// Ganzer Ring → geschlossener, gehrter Umriss.
|
|
outlines.push({ pts, color: strokeCol, widthMm: p.strokeWidthMm });
|
|
} else {
|
|
// Nur die sichtbaren Kanten, als zusammenhängende (offene) Läufe.
|
|
for (const run of visibleEdgeRuns(pts, suppressed)) {
|
|
polylines.push({ pts: run, color: strokeCol, widthMm: p.strokeWidthMm });
|
|
}
|
|
}
|
|
}
|
|
} else if (p.kind === "line") {
|
|
if (p.drawingId) {
|
|
// Aufeinanderfolgende 2D-Zeichenlinien gleicher drawingId/Stil verketten.
|
|
const a: RPoint = [p.a.x, p.a.y];
|
|
const b: RPoint = [p.b.x, p.b.y];
|
|
if (curPts && curSrc && sameLineStyle(curSrc, p) && samePt(curPts[curPts.length - 1], a)) {
|
|
curPts.push(b);
|
|
} else {
|
|
flushRun();
|
|
curPts = [a, b];
|
|
curSrc = p;
|
|
}
|
|
} else {
|
|
// Genuin einzelnes Segment (Türblatt, Referenzlinie, Symbol) → Stumpfkappen ok.
|
|
flushRun();
|
|
const col = toRgba(p.color ?? DEFAULT_LINE, 1) ?? [0.1, 0.1, 0.1, 1];
|
|
lines.push({ a: [p.a.x, p.a.y], b: [p.b.x, p.b.y], color: col, widthMm: p.weightMm });
|
|
}
|
|
} else if (p.kind === "arc") {
|
|
flushRun();
|
|
const col = toRgba(DEFAULT_LINE, 1) ?? [0.1, 0.1, 0.1, 1];
|
|
// Bogen als EINE zusammenhängende Polylinie (gehrte Sehnen-Ecken).
|
|
const poly = tessellateArc(p.center, p.from, p.to, p.r);
|
|
if (poly.length >= 2) polylines.push({ pts: poly, color: col, widthMm: p.weightMm });
|
|
} else if (p.kind === "text") {
|
|
flushRun();
|
|
// Raumstempel: Doc-Zeilen + Live-Zusatzzeilen als Einstrich-Glyph-Polylinien
|
|
// (Modell-Meter, vertikal um den Anker zentriert). Erste Fassung: Versalien.
|
|
const col = toRgba(p.color, 1) ?? [0.1, 0.1, 0.1, 1];
|
|
const docLines = p.doc.paragraphs.map((par) => ({
|
|
text: par.runs.map((r) => r.text).join(""),
|
|
align: par.align ?? "left",
|
|
sizePt: p.basePt,
|
|
}));
|
|
const extraLines = p.extraLines.map((t) => ({
|
|
text: t,
|
|
align: "center" as const,
|
|
sizePt: p.basePt * 0.8,
|
|
}));
|
|
const allLines = [...docLines, ...extraLines].filter((l) => l.text.length > 0);
|
|
if (allLines.length > 0) {
|
|
const gaps = allLines.map((l) => l.sizePt * METERS_PER_PT * 1.3);
|
|
const totalH = gaps.reduce((a, b) => a + b, 0);
|
|
let cursorY = p.at.y + totalH / 2;
|
|
allLines.forEach((line, i) => {
|
|
const scale = (line.sizePt * METERS_PER_PT * 0.7) / CAP;
|
|
cursorY -= gaps[i];
|
|
const baseY = cursorY;
|
|
const { strokes, width } = layoutLine(line.text);
|
|
const wModel = width * scale;
|
|
const startX =
|
|
line.align === "center"
|
|
? p.at.x - wModel / 2
|
|
: line.align === "right"
|
|
? p.at.x - wModel
|
|
: p.at.x;
|
|
for (const s of strokes) {
|
|
if (s.length < 2) continue;
|
|
polylines.push({
|
|
pts: s.map(([x, y]) => [startX + x * scale, baseY + y * scale] as RPoint),
|
|
color: col,
|
|
widthMm: TEXT_WIDTH_MM,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
flushRun();
|
|
}
|
|
}
|
|
flushRun();
|
|
|
|
return { fills, outlines, polylines, lines };
|
|
}
|