Files
Gartenvisualisierung/tools/build-site.mjs
T
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

352 lines
13 KiB
JavaScript

/**
* Baut aus der Planextraktion (tools/out/plan-raw.json) und den handgepflegten
* Ergaenzungen (tools/authoring.mjs) das Szenenmodell public/data/site.json.
*
* Aufruf: node tools/build-site.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
SURFACE_BY_COLOR, PLANT_BY_COLOR, TREES, WALLS, PROPS, STELEN,
BUILDING, NEIGHBOUR_BUILDINGS, TERRAIN_ZONES, EXTRA_SPOTS, EXTRA_SURFACES,
START_VIEWS, SITE_LOCATION,
} from './authoring.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'out', 'plan-raw.json'), 'utf8'));
/** Rasterweite des Hoehenmodells in Metern. */
const CELL = 0.25;
const GRID = { x0: -26, z0: -14.5, x1: 9, z1: 12 };
const round = (v, n = 3) => +v.toFixed(n);
// ---------------------------------------------------------------------------
// Geometrie-Helfer
// ---------------------------------------------------------------------------
function pointInPolygon(x, z, poly) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, zi] = poly[i], [xj, zj] = poly[j];
if ((zi > z) !== (zj > z) && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside;
}
return inside;
}
function polygonArea(poly) {
let a = 0;
for (let i = 0; i < poly.length; i++) {
const [x0, z0] = poly[i], [x1, z1] = poly[(i + 1) % poly.length];
a += x0 * z1 - x1 * z0;
}
return a / 2;
}
function centroid(poly) {
let x = 0, z = 0;
for (const p of poly) { x += p[0]; z += p[1]; }
return [x / poly.length, z / poly.length];
}
/** Ebene y = a*x + b*z + c durch Punkte, kleinste Quadrate. Null bei Entartung. */
function fitPlane(points) {
if (points.length < 3) return null;
let sxx = 0, sxz = 0, szz = 0, sx = 0, sz = 0, n = points.length;
let sxy = 0, szy = 0, sy = 0;
for (const p of points) {
sxx += p.x * p.x; sxz += p.x * p.z; szz += p.z * p.z;
sx += p.x; sz += p.z;
sxy += p.x * p.y; szy += p.z * p.y; sy += p.y;
}
const m = [[sxx, sxz, sx], [sxz, szz, sz], [sx, sz, n]];
const v = [sxy, szy, sy];
const det =
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) -
m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) +
m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
if (Math.abs(det) < 1e-9) return null;
const solve = (col) => {
const mm = m.map((r) => r.slice());
for (let i = 0; i < 3; i++) mm[i][col] = v[i];
return (
mm[0][0] * (mm[1][1] * mm[2][2] - mm[1][2] * mm[2][1]) -
mm[0][1] * (mm[1][0] * mm[2][2] - mm[1][2] * mm[2][0]) +
mm[0][2] * (mm[1][0] * mm[2][1] - mm[1][1] * mm[2][0])
) / det;
};
return { a: solve(0), b: solve(1), c: solve(2) };
}
// ---------------------------------------------------------------------------
// 1. Belagsflaechen
// ---------------------------------------------------------------------------
/** Bauteile, die als Mauer modelliert werden - nicht als begehbare Flaeche. */
const NOT_A_SURFACE = new Set(['travertinmauer', 'winkelelement', 'spielhaus', 'gartenhaus', 'roboter', 'ligusterhecke']);
const surfaces = [];
for (const s of raw.shapes) {
if (s.op !== 'f' || s.circle) continue;
const type = SURFACE_BY_COLOR[s.hex];
if (!type) continue;
if (s.area < 0.35) continue;
if (NOT_A_SURFACE.has(type)) continue;
const poly = polygonArea(s.pts) < 0 ? [...s.pts].reverse() : s.pts;
surfaces.push({ type, area: round(s.area, 2), polygon: poly.map((p) => [round(p[0]), round(p[1])]) });
}
for (const s of EXTRA_SURFACES) {
const poly = polygonArea(s.polygon) < 0 ? [...s.polygon].reverse() : s.polygon;
surfaces.push({ type: s.type, area: round(Math.abs(polygonArea(s.polygon)), 2), polygon: poly, note: s.note });
}
surfaces.sort((a, b) => b.area - a.area);
// ---------------------------------------------------------------------------
// 2. Hecken und Pflanzen aus den Plankreisen
// ---------------------------------------------------------------------------
const treeAt = (x, z, tol = 0.6) => TREES.find((t) => Math.hypot(t.x - x, t.z - z) < tol);
const plants = [];
for (const s of raw.shapes) {
if (s.op !== 'f' || !s.circle) continue;
const type = PLANT_BY_COLOR[s.hex];
if (!type) continue;
const { cx, cz, r } = s.circle;
if (r < 0.06) continue; // Lampensymbole und Punktraster
if (treeAt(cx, cz)) continue; // Solitaere kommen aus dem Authoring
plants.push({ type, x: round(cx), z: round(cz), r: round(r, 2) });
}
// Der Ligusterhecken-Streifen ist im Plan eine Flaeche, keine Kreise.
for (const s of raw.shapes) {
if (s.op === 'f' && s.hex === '#73e900' && s.area > 1) {
plants.push({ type: 'hecke', kind: 'liguster', height: 1.7, width: 0.75, polygon: s.pts.map((p) => [round(p[0]), round(p[1])]) });
}
}
const trees = TREES.map((t) => ({ ...t, x: round(t.x), z: round(t.z) }));
// ---------------------------------------------------------------------------
// 3. Leuchten
// ---------------------------------------------------------------------------
const lamps = [];
const addLamp = (x, z, kind) => {
if (lamps.some((l) => Math.hypot(l.x - x, l.z - z) < 0.35)) return;
lamps.push({ x: round(x), z: round(z), kind });
};
for (const s of raw.shapes) {
if (s.op !== 'f') continue;
const [cx, cz] = centroid(s.pts);
// Rote Quadrate = Wand-/Pollerleuchten an Grenze und Mauern
if (s.hex === '#ff0000' && s.area > 0.02 && s.area < 0.08) addLamp(cx, cz, 'poller');
// Kleine gelbe Marker im Kreissymbol = Bodenstrahler
else if (s.hex === '#ffff00' && s.area < 0.05) addLamp(cx, cz, 'boden');
}
// ---------------------------------------------------------------------------
// 4. Hoehenmodell
// ---------------------------------------------------------------------------
const zoneOf = (x, z) => {
for (const zone of TERRAIN_ZONES) if (pointInPolygon(x, z, zone.polygon)) return zone.id;
return null;
};
const spots = [
...raw.spots.map((s) => ({ x: s.x, z: s.z, y: s.y, zone: zoneOf(s.x, s.z) })),
...EXTRA_SPOTS,
];
// Die Kote 1.47 gehoert zum Spielhauspodest, nicht zum Gelaende.
const terrainSpots = spots.filter((s) => s.zone && s.y < 1.2);
/** Flaechen, die als Ebene ausgebildet sind - dort wird das Raster begradigt. */
const FLAT_TYPES = new Set(['travertin80', 'travertin4060', 'travertinstell', 'keramik', 'verbundstein', 'tegula', 'spielsand']);
const flatSurfaces = [];
for (const s of surfaces) {
if (!FLAT_TYPES.has(s.type) || s.area < 1.0) continue;
const inside = terrainSpots.filter((sp) => pointInPolygon(sp.x, sp.z, s.polygon));
if (!inside.length) continue;
let plane = inside.length >= 3 ? fitPlane(inside) : null;
// Fast kollineare Koten liefern wilde Ebenen. Nur uebernehmen, wenn das
// Gefaelle plausibel ist (< 10 %) und alle Koten getroffen werden.
if (plane) {
const slope = Math.hypot(plane.a, plane.b);
const maxDev = Math.max(...inside.map((p) => Math.abs(plane.a * p.x + plane.b * p.z + plane.c - p.y)));
if (slope > 0.10 || maxDev > 0.06) plane = null;
}
const ys = inside.map((p) => p.y);
const mean = ys.reduce((a, b) => a + b, 0) / ys.length;
flatSurfaces.push({
polygon: s.polygon, plane, mean, type: s.type,
min: Math.min(...ys) - 0.15, max: Math.max(...ys) + 0.15,
});
}
const nx = Math.round((GRID.x1 - GRID.x0) / CELL) + 1;
const nz = Math.round((GRID.z1 - GRID.z0) / CELL) + 1;
const height = new Float32Array(nx * nz);
/**
* Inverse Distanzwichtung innerhalb einer Zone. Der weiche Kern (1/(d^2+c))
* vermeidet Spitzen um die einzelnen Koten - ein 1/d^3-Kern erzeugt Beulen,
* die spaeter durch die Belagsflaechen durchstossen.
*/
function interpolate(x, z, zoneId) {
const pool = terrainSpots.filter((s) => s.zone === zoneId);
if (!pool.length) return TERRAIN_ZONES.find((t) => t.id === zoneId)?.fallback ?? 0;
let num = 0, den = 0;
for (const s of pool) {
const d2 = (s.x - x) ** 2 + (s.z - z) ** 2;
const w = 1 / (d2 * d2 + 0.35);
num += w * s.y; den += w;
}
return num / den;
}
for (let j = 0; j < nz; j++) {
const z = GRID.z0 + j * CELL;
for (let i = 0; i < nx; i++) {
const x = GRID.x0 + i * CELL;
const zone = zoneOf(x, z);
let y;
if (!zone) {
// Ausserhalb aller Zonen: naechstgelegene Zone weiterziehen
let best = null, bd = Infinity;
for (const t of TERRAIN_ZONES) {
const [cx, cz] = centroid(t.polygon);
const d = Math.hypot(cx - x, cz - z);
if (d < bd) { bd = d; best = t; }
}
y = best.fallback;
} else {
y = interpolate(x, z, zone);
for (const f of flatSurfaces) {
if (!pointInPolygon(x, z, f.polygon)) continue;
y = f.plane ? f.plane.a * x + f.plane.b * z + f.plane.c : f.mean;
y = Math.max(f.min, Math.min(f.max, y));
break;
}
}
height[j * nx + i] = y;
}
}
// Zwei Glaettungsrunden, damit weder die Kanten der Ebenen schneiden noch
// einzelne Koten als Beulen stehenbleiben.
let smoothed = Float32Array.from(height);
for (let pass = 0; pass < 2; pass++) {
const src = smoothed;
const dst = Float32Array.from(src);
for (let j = 1; j < nz - 1; j++) {
for (let i = 1; i < nx - 1; i++) {
const k = j * nx + i;
dst[k] = (src[k] * 4 + src[k - 1] + src[k + 1] + src[k - nx] + src[k + nx]) / 8;
}
}
smoothed = dst;
}
// ---------------------------------------------------------------------------
// 5. Mauern: fehlende Hoehen aus dem Gelaende ergaenzen
// ---------------------------------------------------------------------------
function sampleHeight(x, z) {
const fi = (x - GRID.x0) / CELL, fj = (z - GRID.z0) / CELL;
const i = Math.max(0, Math.min(nx - 2, Math.floor(fi)));
const j = Math.max(0, Math.min(nz - 2, Math.floor(fj)));
const tx = Math.max(0, Math.min(1, fi - i)), tz = Math.max(0, Math.min(1, fj - j));
const h = (a, b) => smoothed[b * nx + a];
return (
h(i, j) * (1 - tx) * (1 - tz) + h(i + 1, j) * tx * (1 - tz) +
h(i, j + 1) * (1 - tx) * tz + h(i + 1, j + 1) * tx * tz
);
}
/** Punkt und Normale auf einem Polyzug bei relativer Bogenlaenge t. */
function alongPath(path, t) {
const segs = path.slice(1).map((p, i) => ({
a: path[i], b: p, len: Math.hypot(p[0] - path[i][0], p[1] - path[i][1]),
}));
const total = segs.reduce((s, g) => s + g.len, 0);
let want = t * total;
for (const s of segs) {
if (want <= s.len || s === segs[segs.length - 1]) {
const u = s.len > 0 ? Math.min(1, want / s.len) : 0;
const dx = (s.b[0] - s.a[0]) / (s.len || 1);
const dz = (s.b[1] - s.a[1]) / (s.len || 1);
return { x: s.a[0] + (s.b[0] - s.a[0]) * u, z: s.a[1] + (s.b[1] - s.a[1]) * u, nx: -dz, nz: dx };
}
want -= s.len;
}
return { x: path[0][0], z: path[0][1], nx: 0, nz: 1 };
}
const walls = WALLS.map((w) => {
const out = { ...w };
if (w.retains) {
// Stuetzelement: Krone folgt der hoeheren Seite, Fuss der tieferen.
// Ein Meter beidseits reicht, um die beiden Gelaendeniveaus abzugreifen.
const n = 24;
const crest = [], foot = [];
for (let i = 0; i <= n; i++) {
const { x, z, nx, nz } = alongPath(w.path, i / n);
const hA = sampleHeight(x + nx * 1.0, z + nz * 1.0);
const hB = sampleHeight(x - nx * 1.0, z - nz * 1.0);
crest.push(round(Math.max(hA, hB) + 0.06, 3));
foot.push(round(Math.min(hA, hB) - 0.25, 3));
}
out.crestProfile = crest;
out.footProfile = foot;
}
return out;
});
// ---------------------------------------------------------------------------
// 6. Ausgabe
// ---------------------------------------------------------------------------
const site = {
meta: {
...raw.meta,
generated: new Date().toISOString().slice(0, 10),
location: SITE_LOCATION,
plan: 'Gartenplan 1, Garten Design AG, 14.08.2026, Massstab 1:100',
architecture: 'Haus 9a, EHLE Architekten AG, Projektplaene 27.04.2021',
},
terrain: {
grid: { x0: GRID.x0, z0: GRID.z0, cell: CELL, nx, nz },
heights: Array.from(smoothed, (v) => round(v, 3)),
zones: TERRAIN_ZONES.map((z) => ({ id: z.id, label: z.label, polygon: z.polygon })),
spots: terrainSpots.map((s) => ({ x: round(s.x), z: round(s.z), y: s.y, zone: s.zone })),
},
surfaces,
plants,
trees,
lamps,
walls,
props: PROPS,
stelen: STELEN,
building: BUILDING,
neighbourBuildings: NEIGHBOUR_BUILDINGS,
startViews: START_VIEWS,
};
fs.mkdirSync(path.join(ROOT, 'public', 'data'), { recursive: true });
const file = path.join(ROOT, 'public', 'data', 'site.json');
fs.writeFileSync(file, JSON.stringify(site));
const byType = {};
for (const s of surfaces) byType[s.type] = (byType[s.type] ?? 0) + s.area;
console.log(`site.json ${(fs.statSync(file).size / 1024).toFixed(0)} kB`);
console.log(`Belagsflaechen ${surfaces.length}:`);
for (const [t, a] of Object.entries(byType).sort((x, y) => y[1] - x[1])) {
console.log(' ' + t.padEnd(16) + a.toFixed(1).padStart(7) + ' m2');
}
console.log(`Pflanzen ${plants.length}, Baeume ${trees.length}, Leuchten ${lamps.length}, Mauern ${walls.length}, Objekte ${PROPS.length}, Stelen ${STELEN.length}`);
console.log(`Hoehenraster ${nx} x ${nz} (${CELL} m), Koten ${terrainSpots.length}`);
const hs = Array.from(smoothed);
console.log(`Hoehen ${Math.min(...hs).toFixed(2)} .. ${Math.max(...hs).toFixed(2)} m (absolut ${(651.17 + Math.min(...hs)).toFixed(2)} .. ${(651.17 + Math.max(...hs)).toFixed(2)} m ue. M.)`);