Files
admGitAICDSandClaude Opus 5 70d239fe9c Begehbare 3D-Visualisierung des Gartens Egglihaldenstrasse 9a
Geometrie massstaeblich aus dem Gartenplan-PDF (1:100) extrahiert: Belaege,
Mauern, Pflanzenstandorte und 46 Hoehenkoten. Wohnhaus nach Gartenplan und
den Fassadenplaenen von EHLE Architekten.

- tools/: Pipeline PDF -> Szenenmodell (MuPDF, Node)
- src/: three.js-Szene mit prozeduralen Texturen, Rundgang und Uebersicht
- Docker/Traefik fuer garten.aicds.ch, Gitea-Workflow auf main

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:59:37 +02:00

265 lines
9.9 KiB
JavaScript

/**
* 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();