/** * Extrahiert die Vektorgeometrie aus dem Gartenplan-PDF (Garten Design AG, 1:100) * und transformiert sie in ein georeferenziertes, hausachsen-paralleles * Koordinatensystem in Metern. * * Ausgabe: tools/out/plan-raw.json * * Koordinatensystem (Site-Frame, entspricht three.js X/Z): * X = Hausachse ENE, Peilung 61.16 deg * Z = senkrecht dazu SSE, Peilung 151.16 deg * Y = Hoehe (hier nicht enthalten, kommt aus den Hoehenkoten) * Ursprung = Fixpunkt "0.00" beim Hauseingang, absolut 651.17 m ue. M. * * Aufruf: node tools/extract-plan.mjs */ import * as mupdf from 'mupdf'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const PDF = path.join(ROOT, 'Info Dateien', 'Gartenplan 1 Jasmin und Damian Kellenberger.pdf'); const OUT_DIR = path.join(__dirname, 'out'); /** Massstab 1:100 -> 1 PDF-Punkt entspricht 0.0352778 m in der Realitaet. */ const PT_TO_M = (25.4 / 72 / 1000) * 100; /** Zeichnungsraster gegenueber Blattkante, aus der Winkelanalyse der Linien. */ const GRID_ANGLE_DEG = -28.8385; /** Nur dieser Blattausschnitt enthaelt den Situationsplan (PDF-Punkte). */ const PLAN_CLIP = { x0: 600, y0: 150, x1: 1800, y1: 1060 }; const cos = Math.cos((GRID_ANGLE_DEG * Math.PI) / 180); const sin = Math.sin((GRID_ANGLE_DEG * Math.PI) / 180); /** * PDF-Punkt -> Site-Meter, noch ohne Ursprungsversatz. * Auf dem Blatt ist Norden oben, also entspricht (x, y) direkt (Ost, Sued). * Projektion auf die beiden Rasterachsen des Plans: * d1 = (cos, sin) bei GRID_ANGLE_DEG -> Site-X (Hausachse ENE) * d2 = (-sin, cos) -> Site-Z (SSE) */ function toSite(x, y) { return [(x * cos + y * sin) * PT_TO_M, (-x * sin + y * cos) * PT_TO_M]; } function bboxOf(segs) { let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; for (const s of segs) { for (let i = 1; i + 1 < s.length; i += 2) { x0 = Math.min(x0, s[i]); x1 = Math.max(x1, s[i]); y0 = Math.min(y0, s[i + 1]); y1 = Math.max(y1, s[i + 1]); } } return [x0, y0, x1, y1]; } /** Flaeche eines geschlossenen Polygonzugs (Shoelace), in m^2. */ function areaOf(points) { let a = 0; for (let i = 0; i < points.length; i++) { const [x0, z0] = points[i]; const [x1, z1] = points[(i + 1) % points.length]; a += x0 * z1 - x1 * z0; } return Math.abs(a) / 2; } /** * Erkennt, ob ein Pfad ein Kreis ist (Pflanzensymbole sind Bezier-Kreise). * Liefert {cx, cz, r} oder null. */ function asCircle(points) { if (points.length < 8) return null; let cx = 0, cz = 0; for (const [x, z] of points) { cx += x; cz += z; } cx /= points.length; cz /= points.length; let rMin = Infinity, rMax = 0, rSum = 0; for (const [x, z] of points) { const r = Math.hypot(x - cx, z - cz); rMin = Math.min(rMin, r); rMax = Math.max(rMax, r); rSum += r; } const r = rSum / points.length; if (r < 0.05) return null; // Toleranz: echte Kreise streuen unter 12 % um den Mittelwert. if ((rMax - rMin) / r > 0.12) return null; return { cx, cz, r }; } function flattenSegments(segs) { // Beziers werden linear unterteilt; 8 Stuetzstellen reichen bei Plangroessen. const rings = []; let cur = null, sx = 0, sy = 0, px = 0, py = 0; const push = (x, y) => { cur.push([x, y]); px = x; py = y; }; for (const s of segs) { switch (s[0]) { case 'M': if (cur && cur.length > 1) rings.push(cur); cur = []; sx = s[1]; sy = s[2]; push(s[1], s[2]); break; case 'L': if (!cur) { cur = []; sx = s[1]; sy = s[2]; } push(s[1], s[2]); break; case 'C': { if (!cur) { cur = []; sx = px; sy = py; cur.push([px, py]); } const [x0, y0] = [px, py]; for (let i = 1; i <= 8; i++) { const t = i / 8, u = 1 - t; const x = u * u * u * x0 + 3 * u * u * t * s[1] + 3 * u * t * t * s[3] + t * t * t * s[5]; const y = u * u * u * y0 + 3 * u * u * t * s[2] + 3 * u * t * t * s[4] + t * t * t * s[6]; cur.push([x, y]); } px = s[5]; py = s[6]; break; } case 'Z': if (cur && cur.length > 1) { rings.push(cur); } cur = [[sx, sy]]; px = sx; py = sy; break; } } if (cur && cur.length > 1) rings.push(cur); return rings; } /** Entfernt doppelte und kollineare Stuetzpunkte. */ function simplify(points, eps = 0.004) { const out = []; for (const p of points) { const last = out[out.length - 1]; if (last && Math.hypot(p[0] - last[0], p[1] - last[1]) < eps) continue; out.push(p); } while (out.length > 1 && Math.hypot(out[0][0] - out[out.length - 1][0], out[0][1] - out[out.length - 1][1]) < eps) { out.pop(); } const res = []; for (let i = 0; i < out.length; i++) { const a = out[(i - 1 + out.length) % out.length], b = out[i], c = out[(i + 1) % out.length]; const cross = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); if (Math.abs(cross) < 1e-5 && out.length > 3) continue; res.push(b); } return res.length >= 3 ? res : out; } function main() { const doc = mupdf.Document.openDocument(fs.readFileSync(PDF), 'application/pdf'); const page = doc.loadPage(0); // --- 1. Vektor-Operationen mitschneiden ------------------------------- const raw = []; const apply = (m, x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]; const walk = (p, m) => { const segs = []; p.walk({ moveTo: (x, y) => segs.push(['M', ...apply(m, x, y)]), lineTo: (x, y) => segs.push(['L', ...apply(m, x, y)]), curveTo: (a, b, c, d, e, f) => segs.push(['C', ...apply(m, a, b), ...apply(m, c, d), ...apply(m, e, f)]), closePath: () => segs.push(['Z']), }); return segs; }; const noop = () => {}; const device = new mupdf.Device({ fillPath: (p, eo, m, cs, color) => raw.push({ op: 'f', segs: walk(p, m), c: Array.from(color || []) }), strokePath: (p, st, m, cs, color) => raw.push({ op: 's', segs: walk(p, m), c: Array.from(color || []), lw: st?.lineWidth ?? 0 }), fillShade: noop, fillImage: noop, fillImageMask: noop, clipPath: noop, clipStrokePath: noop, clipImageMask: noop, popClip: noop, fillText: noop, strokeText: noop, clipText: noop, clipStrokeText: noop, ignoreText: noop, beginMask: noop, endMask: noop, beginGroup: noop, endGroup: noop, beginTile: () => 0, endTile: noop, beginLayer: noop, endLayer: noop, close: noop, }); page.run(device, mupdf.Matrix.identity); device.close(); // --- 2. Auf den Planausschnitt beschraenken und transformieren -------- const shapes = []; for (const it of raw) { if (!it.segs.length) continue; const b = bboxOf(it.segs); if (!(b[0] >= PLAN_CLIP.x0 && b[2] <= PLAN_CLIP.x1 && b[1] >= PLAN_CLIP.y0 && b[3] <= PLAN_CLIP.y1)) continue; for (const ring of flattenSegments(it.segs)) { const pts = simplify(ring.map(([x, y]) => toSite(x, y))); if (pts.length < 2) continue; const rgb = it.c.length >= 3 ? it.c.slice(0, 3).map((v) => Math.round(v * 255)) : [0, 0, 0]; shapes.push({ op: it.op, color: rgb, hex: '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join(''), pts, area: it.op === 'f' ? +areaOf(pts).toFixed(4) : 0, circle: asCircle(pts), lw: it.lw ? +(it.lw * PT_TO_M).toFixed(3) : 0, }); } } // --- 3. Beschriftungen mit Position ----------------------------------- const st = JSON.parse(page.toStructuredText('preserve-whitespace').asJSON()); const labels = []; for (const block of st.blocks ?? []) { if (block.type !== 'text') continue; for (const line of block.lines ?? []) { const t = (line.text ?? '').trim(); if (!t) continue; const { x, y, w, h } = line.bbox; if (x < PLAN_CLIP.x0 || x > PLAN_CLIP.x1 || y < PLAN_CLIP.y0 || y > PLAN_CLIP.y1) continue; const [sx, sz] = toSite(x + w / 2, y + h / 2); labels.push({ text: t, x: +sx.toFixed(3), z: +sz.toFixed(3) }); } } // --- 4. Ursprung auf den Fixpunkt legen ------------------------------- const fix = labels.find((l) => l.text.includes('0.00 Fixpunkt')) ?? labels.find((l) => l.text === 'Eingang'); const ox = fix ? fix.x : 0; const oz = fix ? fix.z : 0; const shift = (p) => [+(p[0] - ox).toFixed(4), +(p[1] - oz).toFixed(4)]; for (const s of shapes) { s.pts = s.pts.map(shift); if (s.circle) { s.circle.cx = +(s.circle.cx - ox).toFixed(4); s.circle.cz = +(s.circle.cz - oz).toFixed(4); s.circle.r = +s.circle.r.toFixed(4); } } for (const l of labels) { l.x = +(l.x - ox).toFixed(3); l.z = +(l.z - oz).toFixed(3); } // --- 5. Hoehenkoten aus den Beschriftungen -------------------------- const spots = labels .filter((l) => /^-?\d\.\d{2}$/.test(l.text)) .map((l) => ({ x: l.x, z: l.z, y: parseFloat(l.text) })); fs.mkdirSync(OUT_DIR, { recursive: true }); const out = { meta: { source: path.basename(PDF), scale: '1:100', ptToM: PT_TO_M, gridAngleDeg: GRID_ANGLE_DEG, northBearingOfSiteX: 61.1615, originLabel: fix?.text ?? null, originAbsoluteMasl: 651.17, note: 'X = Hausachse ENE, Z = SSE, Ursprung = Fixpunkt 0.00 beim Eingang', }, shapes, labels, spots, }; fs.writeFileSync(path.join(OUT_DIR, 'plan-raw.json'), JSON.stringify(out)); const fills = shapes.filter((s) => s.op === 'f'); console.log(`Formen: ${shapes.length} (Fuellungen ${fills.length}, Linien ${shapes.length - fills.length})`); console.log(`Kreise: ${shapes.filter((s) => s.circle).length}`); console.log(`Labels: ${labels.length} Hoehenkoten: ${spots.length}`); const xs = fills.flatMap((s) => s.pts.map((p) => p[0])); const zs = fills.flatMap((s) => s.pts.map((p) => p[1])); console.log(`Ausdehnung: X ${Math.min(...xs).toFixed(1)} .. ${Math.max(...xs).toFixed(1)} m, Z ${Math.min(...zs).toFixed(1)} .. ${Math.max(...zs).toFixed(1)} m`); } main();