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>
352 lines
13 KiB
JavaScript
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.)`);
|