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
+325
View File
@@ -0,0 +1,325 @@
// Probe: VEKTOR-PDF-Export des Grundrisses.
// 1) App öffnen (EG-Grundriss mit Beispielraum).
// 2) Export-Dialog über den PDF-Button öffnen, A4 / 1:100 wählen, exportieren.
// 3) Den Download (jsPDF blob) abfangen und ins Scratchpad speichern.
// 4) Vektor-Nachweis: Pfad-/Text-Operatoren > 0, KEIN Rasterbild für den Plan.
// 5) Massstab: bekannte Wandlänge (5 m bei 1:100 → 50 mm) im PDF nachmessen.
// 6) PDF → PNG rendern (pdfjs) für die visuelle Prüfung.
import puppeteer from "puppeteer";
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const URL = process.env.PROBE_URL || "http://localhost:5187/";
const OUT = process.env.SCRATCH || "/tmp/pdf-probe";
if (!existsSync(OUT)) mkdirSync(OUT, { recursive: true });
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
await page.goto(URL, { waitUntil: "networkidle0", timeout: 30000 });
await new Promise((r) => setTimeout(r, 600));
// jsPDF (v4) erzeugt das PDF als Blob und übergibt es an URL.createObjectURL,
// bevor es den Download auslöst. Wir überschreiben createObjectURL und lesen die
// Blob-Bytes (Base64) heraus — robust unabhängig vom Download-Mechanismus.
await page.evaluateOnNewDocument(() => {
window.__pdfCaptured = null;
const orig = URL.createObjectURL.bind(URL);
URL.createObjectURL = function (obj) {
try {
if (obj instanceof Blob) {
obj.arrayBuffer().then((buf) => {
const u8 = new Uint8Array(buf);
const head = String.fromCharCode.apply(null, u8.slice(0, 5));
if (head === "%PDF-") {
let bin = "";
for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]);
window.__pdfCaptured = { name: "grundriss.pdf", b64: btoa(bin) };
}
});
}
} catch (e) {
/* ignore */
}
return orig(obj);
};
});
// Neu laden, damit der Patch (evaluateOnNewDocument) greift.
await page.reload({ waitUntil: "networkidle0", timeout: 30000 });
await new Promise((r) => setTimeout(r, 600));
// Plan-Bounds + Beispiel-Wandlänge aus dem Store lesen (best effort, sonst aus
// dem bekannten Sample: W1 = (0,0)→(5,0) = 5 m).
const info = await page.evaluate(() => {
const st = window.__store?.getState?.();
const p = st?.project;
if (!p) return null;
const eg = p.walls.filter((w) => w.floorId === "eg");
const w1 = eg.find((w) => w.id === "W1");
const len = w1 ? Math.hypot(w1.end.x - w1.start.x, w1.end.y - w1.start.y) : null;
return { wallCount: eg.length, w1Len: len };
});
console.log("Store:", JSON.stringify(info));
// PDF-Button in der Oberleiste klicken (Text „PDF"). Öffnet den Export-Dialog.
const clickedBtn = await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll(".tb-btn"));
const b = btns.find((x) => x.textContent.trim() === "PDF");
if (b) {
b.click();
return true;
}
return false;
});
console.log("PDF-Button geklickt?", clickedBtn);
await page.waitForSelector(".imp-dialog", { timeout: 5000 });
await new Promise((r) => setTimeout(r, 200));
await page.screenshot({ path: join(OUT, "export-dialog.png") });
console.log("Dialog-Screenshot:", join(OUT, "export-dialog.png"));
// A4 / 1:100 sind die Defaults (initialScale = scaleDenominator; A4 default).
// Sicherstellen: Massstab-Dropdown auf 1:100 stellen, falls nicht schon. Wir
// lesen den aktuellen Massstab aus dem Store und wählen sonst über die Dropdown-
// Option. Der Einfachheit halber verlassen wir uns auf den Default und prüfen N
// per gemessener Länge.
const exported = await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll(".imp-btn.primary"));
const b = btns.find((x) => x.textContent.includes("Exportieren") || x.textContent.includes("Export"));
if (b) {
b.click();
return true;
}
return false;
});
console.log("Exportieren geklickt?", exported);
// Auf den abgefangenen Blob warten.
let captured = null;
for (let i = 0; i < 60; i++) {
captured = await page.evaluate(() => window.__pdfCaptured);
if (captured) break;
await new Promise((r) => setTimeout(r, 100));
}
if (!captured) {
console.log("FEHLER: kein PDF abgefangen.");
console.log(logs.join("\n"));
await browser.close();
process.exit(1);
}
const pdfBytes = Buffer.from(captured.b64, "base64");
const pdfPath = join(OUT, captured.name.endsWith(".pdf") ? captured.name : captured.name + ".pdf");
writeFileSync(pdfPath, pdfBytes);
// Visuelle Prüfung: das DRUCK-SVG (exakt die Vektor-Quelle des PDF) im Browser
// erzeugen und nach PNG rastern. Zeigt scharfe Linien, Schraffuren, weisses Blatt.
try {
const svgPng = await page.evaluate(async () => {
const gp = await import("/src/plan/generatePlan.ts");
const sp = await import("/src/model/sampleProject.ts");
const ptp = await import("/src/export/planToPrintSvg.ts");
const proj = sp.sampleProject;
const codes = new Set();
const walk = (cs) =>
cs.forEach((c) => {
if (c.visible) {
codes.add(c.code);
if (c.children) walk(c.children);
}
});
walk(proj.layers);
const plan = gp.generatePlan(proj, "eg", codes, undefined, "mittel", false, false);
const print = ptp.planToPrintSvg(plan, {
scaleDenominator: 100,
pageWidthMm: 297,
pageHeightMm: 210,
marginMm: 10,
});
const xml = new XMLSerializer().serializeToString(print.svg);
const blob = new Blob([xml], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const img = new Image();
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
img.src = url;
});
const scale = 4;
const canvas = document.createElement("canvas");
canvas.width = 297 * scale;
canvas.height = 210 * scale;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
});
const b64 = svgPng.split(",")[1];
writeFileSync(join(OUT, "print-svg.png"), Buffer.from(b64, "base64"));
console.log("Druck-SVG → PNG:", join(OUT, "print-svg.png"));
} catch (e) {
console.log("(SVG-Render übersprungen:", e.message + ")");
}
console.log(`PDF gespeichert: ${pdfPath} (${pdfBytes.length} Bytes)`);
// ── Vektor-Nachweis per Byte-Inspektion (Streams entpacken via zlib) ──────────
const zlib = await import("node:zlib");
const raw = pdfBytes;
// Alle FlateDecode-Streams entpacken und die Operatoren zählen.
let allOps = "";
let imageXObjects = 0;
let dctImages = 0;
const text = raw.toString("latin1");
// /Subtype /Image und /DCTDecode global suchen (auch in Streams selten, aber
// im Dictionary sichtbar).
imageXObjects += (text.match(/\/Subtype\s*\/Image/g) || []).length;
dctImages += (text.match(/\/DCTDecode/g) || []).length;
const streamRe = /stream\r?\n([\s\S]*?)\r?\nendstream/g;
let m;
while ((m = streamRe.exec(text)) !== null) {
const chunk = Buffer.from(m[1], "latin1");
let inflated = null;
try {
inflated = zlib.inflateSync(chunk).toString("latin1");
} catch {
try {
inflated = zlib.inflateRawSync(chunk).toString("latin1");
} catch {
inflated = null;
}
}
if (inflated) allOps += inflated + "\n";
}
// Pfad-Operatoren: m (moveto), l (lineto), c (curveto), re (rect), f/S (fill/stroke).
const countOp = (re) => (allOps.match(re) || []).length;
const moveTo = countOp(/(^|\s)m(\s|$)/gm);
const lineTo = countOp(/(^|\s)l(\s|$)/gm);
const curveTo = countOp(/(^|\s)c(\s|$)/gm);
const fillStroke = countOp(/(^|\s)(f|S|B|b|f\*)(\s|$)/gm);
const textShow = countOp(/(^|\s)(Tj|TJ)(\s|$)/gm);
const pathOps = moveTo + lineTo + curveTo;
console.log("\n=== VEKTOR-NACHWEIS ===");
console.log(`Pfad-Operatoren: moveto(m)=${moveTo} lineto(l)=${lineTo} curve(c)=${curveTo} fill/stroke=${fillStroke}`);
console.log(`Gesamt-Pfadbefehle: ${pathOps}`);
console.log(`Text-Operatoren (Tj/TJ): ${textShow}`);
console.log(`Bild-XObjects (/Subtype/Image): ${imageXObjects}`);
console.log(`DCT/JPEG-Bilder (/DCTDecode): ${dctImages}`);
const isVector = pathOps > 50 && imageXObjects === 0 && dctImages === 0;
console.log(`→ VEKTOR (Pfade > 0, kein Rasterbild)? ${isVector ? "JA" : "NEIN"}`);
// ── Massstab-Messung über pdfjs (Pfad-Koordinaten der gezeichneten Linien) ────
let scaleResult = "n/a";
try {
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const doc = await pdfjs.getDocument({ data: new Uint8Array(pdfBytes) }).promise;
const pg = await doc.getPage(1);
const vp = pg.getViewport({ scale: 1 }); // Einheiten = PDF-Punkte (1pt=1/72 inch)
const ptToMm = 25.4 / 72;
const pageWpt = vp.width, pageHpt = vp.height;
const ops = await pg.getOperatorList();
const OPS = pdfjs.OPS;
// Pro Pfad eine eigene BBox sammeln (jeder constructPath ist ein Sub-Pfad). So
// können wir das seitenfüllende weisse Blatt + das Schriftfeld unten rechts
// ausschließen und nur die PLAN-Geometrie messen.
const boxes = [];
for (let i = 0; i < ops.fnArray.length; i++) {
if (ops.fnArray[i] !== OPS.constructPath) continue;
const coords = ops.argsArray[i][1];
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
for (let k = 0; k + 1 < coords.length; k += 2) {
const x = coords[k], y = coords[k + 1];
if (typeof x === "number" && typeof y === "number" && isFinite(x) && isFinite(y)) {
if (x < bx0) bx0 = x; if (x > bx1) bx1 = x;
if (y < by0) by0 = y; if (y > by1) by1 = y;
}
}
if (isFinite(bx0)) boxes.push({ x0: bx0, y0: by0, x1: bx1, y1: by1 });
}
const pageWmm = pageWpt * ptToMm;
const pageHmm = pageHpt * ptToMm;
// svg2pdf zeichnet den Plan-Inhalt in mm-Benutzereinheiten (Koordinaten 0..297
// bei A4-quer). Daneben gibt es einen äußeren Clip-Pfad in PDF-PUNKTEN
// (Koordinaten bis ~842). Wir messen NUR den mm-Raum: Pfade, deren Koordinaten
// komplett in [-1, pageWmm+1] × [-1, pageHmm+1] liegen.
const inMmSpace = (b) =>
b.x0 >= -1 && b.x1 <= pageWmm + 1 && b.y0 >= -1 && b.y1 <= pageHmm + 1;
// Seitenfüllendes weisses Blatt ausschließen (BBox ≈ ganze Seite).
const pageLike = (b) =>
b.x1 - b.x0 > pageWmm * 0.9 && b.y1 - b.y0 > pageHmm * 0.9;
// Schriftfeld unten rechts: ~70mm am rechten Rand. Pfade, deren Mittelpunkt im
// rechten Block (x > pageW-75mm) liegen, ausschließen (Plan ist zentriert).
const titleXmm = pageWmm - 75;
const inTitleBlock = (b) => (b.x0 + b.x1) / 2 > titleXmm;
const plan = boxes.filter(
(b) => inMmSpace(b) && !pageLike(b) && !inTitleBlock(b),
);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const b of plan) {
if (b.x0 < minX) minX = b.x0; if (b.x1 > maxX) maxX = b.x1;
if (b.y0 < minY) minY = b.y0; if (b.y1 > maxY) maxY = b.y1;
}
// Die Plan-Pfade liegen bereits in mm-Benutzereinheiten (siehe inMmSpace).
const widthMm = maxX - minX;
const heightMm = maxY - minY;
console.log("\n=== MASSSTAB ===");
console.log(`Plan-Pfade (ohne Blatt/Schriftfeld): ${plan.length} von ${boxes.length}`);
console.log(`Plan-Inhalt BBox: ${widthMm.toFixed(2)} × ${heightMm.toFixed(2)} mm auf dem Blatt`);
// Bei 1:100: Welt-Breite (W1=5 m + Wanddicke ~0.345 m) ≈ 5.345 m → 53.45 mm.
// Höhe (W2=4 m + Dicke) ≈ 4.345 m → 43.45 mm.
const expectedWmm = 5.345 * (1000 / 100);
const expectedHmm = 4.345 * (1000 / 100);
console.log(`Erwartet bei 1:100: ~${expectedWmm.toFixed(2)} × ~${expectedHmm.toFixed(2)} mm`);
const okW = Math.abs(widthMm - expectedWmm) < 2;
const okH = Math.abs(heightMm - expectedHmm) < 2;
console.log(`→ Massstab korrekt (±2mm)? Breite ${okW ? "OK" : "ABWEICHUNG"}, Höhe ${okH ? "OK" : "ABWEICHUNG"}`);
scaleResult = `${widthMm.toFixed(2)}×${heightMm.toFixed(2)}mm (erwartet ${expectedWmm.toFixed(1)}×${expectedHmm.toFixed(1)})`;
// PDF → PNG rendern (scharfe Linien/Schraffuren sichtbar prüfen). @napi-rs/
// canvas hat vorgebaute Binaries (kein nativer Build nötig). Text wird vom
// Renderer ggf. nicht unterstützt → Geometrie genügt für die Sichtprüfung.
try {
const { createCanvas } = await import("@napi-rs/canvas");
const rscale = 4;
const rvp = pg.getViewport({ scale: rscale });
const canvasFactory = {
create: (w, h) => {
const c = createCanvas(w, h);
return { canvas: c, context: c.getContext("2d") };
},
reset: (co, w, h) => {
co.canvas.width = w;
co.canvas.height = h;
},
destroy: (co) => {
co.canvas.width = 0;
co.canvas.height = 0;
},
};
const canvas = createCanvas(Math.ceil(rvp.width), Math.ceil(rvp.height));
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await pg.render({ canvasContext: ctx, viewport: rvp, canvasFactory }).promise;
writeFileSync(join(OUT, "export.png"), canvas.toBuffer("image/png"));
console.log("PDF→PNG:", join(OUT, "export.png"));
} catch (e) {
console.log("(PNG-Render übersprungen:", e.message + ")");
}
} catch (e) {
console.log("pdfjs-Messung fehlgeschlagen:", e.message);
}
console.log("\n=== Logs ===");
console.log(logs.length ? logs.slice(-15).join("\n") : "(keine)");
await browser.close();