Files
Gartenvisualisierung/tools/build-site.mjs
T
admGitAICDSandClaude Opus 5 fad23a34c3
Deploy App / deploy (push) Successful in 28s
Wege, Ebenen, Stufen und ein Sprung
Rueckmeldung Punkt fuer Punkt:

Weg rund ums Haus
- Der Ostweg ist jetzt durchgehend gepflastert von der Verbundstein-
  Anpassung (x 2.86) bis zur Grenze (x 4.45) - vorher 0.83 m breit mit
  Rasenstreifen daneben. Der Weg an der Suedgrenze reicht von der Grenze
  bis an die Nachbarmauer (z 4.49 bis 5.97).
- Winkelelemente Ost auf die Grenzlinie x 4.45; die Linie bei z 4.96 im
  Sueden war keine eigene Stuetzwand, das ist die Nachbarmauer selbst.

Hoehen und Gefaelle
- LEVELS koennen jetzt auch eine geneigte Ebene vorgeben. Die Wege sind
  Rampen: Ostweg rund 12%, Suedweg rund 3%, Feuerwehrzufahrt 13.6% aus
  den Plankoten -2.08 und -1.08. Der Architekturplan nennt an denselben
  Stellen "Steigung 9.9%" und "Steigung 13.0%".
- Ausserhalb der Zonen wird die Interpolation der naechsten Zone
  fortgesetzt statt ihres konstanten Ersatzwerts - die Feuerwehrzufahrt
  stand vorher als schwebende Platte ueber einer Gelaendekante.
- Gelaende oestlich der Grenze korrigiert: dort lag ein Huegel ueber dem
  Weg, obwohl der Plan -0.72 und -1.57 angibt.

Obere Ebene
- Der Rasen ist eben auf -0.02. Die Koten 0.09 bis 0.26 liegen schon im
  angehobenen Kiesbett des Sichtschutzes, nicht mehr im Rasen; ohne feste
  Vorgabe zog die Interpolation den Rasen huegelig hoch.

Stufen
- Beim gedeckten Sitzplatz stimmen jetzt beide schwebenden Platten: der
  Plan zeichnet zwei versetzte Rechtecke mit den Koten -0.02 und -0.17,
  bisher lagen sie auf -0.17 und -0.31 - die untere verschwand im Belag.
- Das Gartentor steht auf dem oberen Niveau (-0.13), von dort zwei Stufen
  hinunter auf den Hauptsitzplatz.
- Westtreppe ueber den ganzen Treppenblock (5 Stufen statt 4), die
  Gelaenderpfosten reichen bis ins Gelaende statt in der Luft zu enden.

Rundgang
- Treppen und schwebende Stufen sind begehbar: ihre Trittflaechen gehen in
  die Hoehenabfrage ein, die Stufen sind kein Hindernis mehr.
- Springen mit der Leertaste, rund 55 cm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:49:46 +02:00

522 lines
20 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, LEVELS, STELE_HEIGHTS, LINEAR,
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', 'holzscheiter']);
const bboxOf = (pts) => {
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
for (const [x, z] of pts) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); z0 = Math.min(z0, z); z1 = Math.max(z1, z); }
return { x0, z0, x1, z1, w: x1 - x0, d: z1 - z0 };
};
/**
* Einzelne Travertinplatten 40x60x3 cm. Der Plan zeichnet sie als Reihe
* loser Platten im Dekorkies - kein geschlossener Belag. Sie werden als
* erhabene Platten modelliert, nicht als Flaeche.
*/
const slabs = [];
const surfaces = [];
/**
* Travertinstelen: aufrecht gestellte Platten von 50 x 6 cm, im Plan in
* Dreiergruppen. Zusammen mit den Eibengruppen bilden sie den Sichtschutz.
*/
const stelen = [];
for (const s of raw.shapes) {
if (s.op !== 'f' || s.circle) continue;
let type = SURFACE_BY_COLOR[s.hex];
if (!type) continue;
const b = bboxOf(s.pts);
const cx = (b.x0 + b.x1) / 2, cz = (b.z0 + b.z1) / 2;
// Lose 40x60-Platten (Plan: 0.40 breit, 0.49 bis 0.80 lang)
if (s.hex === '#ffffcc' && s.area < 0.6 && Math.abs(b.w - 0.4) < 0.06 && b.d > 0.4 && b.d < 0.9) {
slabs.push({ x: round(cx), z: round(cz), w: round(b.w, 2), d: round(b.d, 2) });
continue;
}
// Aufrechte Stelen: 50 cm lang, 6 bis 9 cm dick, in beiden Achsrichtungen
if (s.hex === '#ddd299' && s.area < 0.06) {
const along = b.w > b.d ? 'x' : 'z';
const len = Math.max(b.w, b.d);
if (len > 0.35 && len < 0.65) {
stelen.push({
x: round(cx), z: round(cz), len: round(len, 2),
t: round(Math.min(b.w, b.d), 2),
rot: along === 'x' ? 0 : 90,
});
continue;
}
}
// Rasenrandplatten: derselbe Ton wie die Stelen, aber als durchgehendes
// Band von 6 cm Breite entlang der Rasenkante. Sie stehen hochkant und
// trennen den Rasen vom Kiesbett des Sichtschutzes.
const laenge = Math.max(b.w, b.d);
if (s.hex === '#ddd299' && laenge > 0.8 && s.area / laenge < 0.15) {
const poly = polygonArea(s.pts) < 0 ? [...s.pts].reverse() : s.pts;
surfaces.push({
type: 'randplatte', area: round(s.area, 2),
polygon: poly.map((p) => [round(p[0]), round(p[1])]),
});
continue;
}
// Der Plan verwendet denselben hellen Ton fuer Dekorkies und fuer die
// Keramikterrasse des Nachbarn; unterschieden wird ueber die Lage.
if (type === 'dekorkies' && cz < -6.6) type = 'keramik';
// Die schmalen Baender des Sichtschutzstreifens sind das Kiesbett, in dem
// Stelen und Eiben stehen - kein Plattenbelag.
if (type === 'travertinstell' && cz > 3.2 && Math.min(b.w, b.d) < 0.7) type = 'dekorkies';
if (s.area < 0.3) 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);
// Sichtschutzhoehe je nach Lage: gegen den Suednachbarn hoeher als gegen
// Zollet auf der unteren Ebene.
for (const st of stelen) {
const rule = STELE_HEIGHTS.find((r) => r.test(st.x, st.z));
st.height = rule ? rule.height : 1.5;
st.side = rule ? rule.id : 'west';
}
stelen.sort((a, b) => a.z - b.z || a.x - b.x);
// ---------------------------------------------------------------------------
// 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);
/**
* Belaege sind gebaute Flaechen und muessen eben sein. Fuer jede solche Flaeche
* wird eine Ebene bestimmt - aus den Koten darin und knapp daneben, mit
* begrenztem Gefaelle. Die Ebene geht als `plane` mit ins Szenenmodell; das
* Gelaenderaster wird darauf gezwungen und in diesen Feldern nicht geglaettet.
*/
const FLAT_TYPES = new Set([
'travertin80', 'travertin4060', 'travertinstell', 'keramik',
'verbundstein', 'tegula', 'spielsand', 'dekorkies',
]);
/** Abstand eines Punktes zum Polygonrand (positiv aussen wie innen). */
function distToPolygon(x, z, poly) {
let best = Infinity;
for (let i = 0; i < poly.length; i++) {
const [ax, az] = poly[i], [bx, bz] = poly[(i + 1) % poly.length];
const dx = bx - ax, dz = bz - az;
const l2 = dx * dx + dz * dz;
const t = l2 > 0 ? Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / l2)) : 0;
best = Math.min(best, Math.hypot(x - (ax + dx * t), z - (az + dz * t)));
}
return best;
}
/**
* Jede Kote gehoert zu genau einer Flaeche: der kleinsten, die sie enthaelt.
* Sonst zieht etwa die Kote einer Stufe die Ebene der darunterliegenden
* Terrasse mit hoch, und aus zwei sauberen Ebenen wird eine schiefe.
*/
const owner = new Map();
for (const sp of terrainSpots) {
let best = null;
for (const s of surfaces) {
if (!FLAT_TYPES.has(s.type)) continue;
if (!pointInPolygon(sp.x, sp.z, s.polygon)) continue;
if (!best || s.area < best.area) best = s;
}
if (best) owner.set(sp, best);
}
/**
* Der Garten hat zwei Hauptebenen. Wo sie feststehen, werden sie fest
* vorgegeben statt aus streuenden Koten geschaetzt - so bleiben die Flaechen
* eben und der Hoehensprung an der Sitzmauer stimmt auf den Zentimeter.
*/
function levelFor(s) {
const [cx, cz] = centroid(s.polygon);
for (const lv of LEVELS) {
if (lv.types && !lv.types.includes(s.type)) continue;
if (pointInPolygon(cx, cz, lv.polygon)) return lv;
}
return null;
}
const flatSurfaces = [];
for (const s of surfaces) {
if (!FLAT_TYPES.has(s.type) || s.area < 0.4) continue;
const fixed = levelFor(s);
let plane, ys;
if (fixed) {
// Eine Ebene darf auch geneigt vorgegeben sein - Wege sind Rampen.
plane = fixed.plane
? { a: fixed.plane[0], b: fixed.plane[1], c: fixed.plane[2] }
: { a: 0, b: 0, c: fixed.level };
const corners = s.polygon.map(([x, z]) => plane.a * x + plane.b * z + plane.c);
ys = [Math.min(...corners), Math.max(...corners)];
s.level = fixed.id;
} else {
const inside = terrainSpots.filter((sp) => owner.get(sp) === s);
const [pcx, pcz] = centroid(s.polygon);
const zone = zoneOf(pcx, pcz);
const near = inside.length
? []
: terrainSpots.filter((sp) => !owner.has(sp) && sp.zone === zone && distToPolygon(sp.x, sp.z, s.polygon) < 1.2);
const pool = [...inside, ...near];
if (!pool.length) continue;
ys = pool.map((p) => p.y);
const mean = ys.reduce((a, b) => a + b, 0) / ys.length;
const px = pool.reduce((a, p) => a + p.x, 0) / pool.length;
const pz = pool.reduce((a, p) => a + p.z, 0) / pool.length;
let fit = pool.length >= 3 ? fitPlane(pool) : null;
// Ein Belag hat hoechstens rund 4 % Gefaelle. Bei fast kollinearen Koten
// kippt die Ausgleichsebene weg; dann nur das Gefaelle begrenzen und die
// Ebene am Schwerpunkt der Koten aufhaengen, nie am Polygonrand.
if (fit) {
const slope = Math.hypot(fit.a, fit.b);
const k = slope > 0.04 ? 0.04 / slope : 1;
const a = fit.a * k, b = fit.b * k;
plane = { a, b, c: mean - (a * px + b * pz) };
} else {
plane = { a: 0, b: 0, c: mean };
}
}
s.plane = [round(plane.a, 5), round(plane.b, 5), round(plane.c, 4)];
flatSurfaces.push({
polygon: s.polygon, plane, type: s.type,
min: Math.min(...ys) - 0.10, max: Math.max(...ys) + 0.10,
});
}
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;
}
/** Merkt sich, welche Rasterfelder von einem Belag festgelegt sind. */
const locked = new Uint8Array(nx * nz);
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. Dabei wird
// deren Interpolation fortgesetzt, nicht ihr konstanter Ersatzwert -
// sonst entsteht am Zonenrand eine Gelaendekante.
let best = null, bd = Infinity;
for (const t of TERRAIN_ZONES) {
const d = distToPolygon(x, z, t.polygon);
if (d < bd) { bd = d; best = t; }
}
y = interpolate(x, z, best.id);
} else {
y = interpolate(x, z, zone);
// Ausdruecklich flach vorgegebene Ebenen: der Rasen der oberen Ebene
// ist eben, ohne Vorgabe zieht ihn die Interpolation huegelig hoch.
for (const lv of LEVELS) {
if (!lv.flatten || !pointInPolygon(x, z, lv.polygon)) continue;
y = lv.level;
locked[j * nx + i] = 1;
break;
}
for (const f of flatSurfaces) {
if (!pointInPolygon(x, z, f.polygon)) continue;
y = Math.max(f.min, Math.min(f.max, f.plane.a * x + f.plane.b * z + f.plane.c));
locked[j * nx + i] = 1;
break;
}
}
height[j * nx + i] = y;
}
}
// Glaetten - aber nur die freien Felder. Die Belaege bleiben exakt eben.
let smoothed = Float32Array.from(height);
for (let pass = 0; pass < 3; 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;
if (locked[k]) continue;
dst[k] = (src[k] * 3 + src[k - 1] + src[k + 1] + src[k - nx] + src[k + nx]) / 7;
}
}
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,
slabs,
plants,
trees,
lamps,
walls,
props: PROPS,
linear: LINEAR,
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(`Lose Travertinplatten 40x60: ${slabs.length}`);
console.log(`Pflanzen ${plants.length}, Baeume ${trees.length}, Leuchten ${lamps.length}, Mauern ${walls.length}, Objekte ${PROPS.length}`);
const bySide = {};
for (const s of stelen) bySide[s.side] = (bySide[s.side] ?? 0) + 1;
console.log(`Lineare Bauteile ${LINEAR.length}: ` + LINEAR.map((l) => l.kind).join(', '));
console.log(`Travertinstelen ${stelen.length}: ` + Object.entries(bySide).map(([k, v]) => `${k} ${v} Stk auf ${STELE_HEIGHTS.find((r) => r.id === k)?.height ?? '?'} m`).join(', '));
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.)`);