Files
admGitAICDSandClaude Opus 5 1ac99b1660
Deploy App / deploy (push) Successful in 27s
Nordgrenze zum Nachbarn, Torbogen, einfacher Rundgang
Nordgrenze
- Sitzmauer und Travertinmauer waren vertauscht. Der Plan zeichnet aussen,
  auf der Terrasse der Nachbarn, die "Sitzmauer" (0.55 tief, rund 40 cm
  hoch: Terrasse -1.77, Krone -1.35) und direkt dahinter die
  "Travertinmauer", die jetzt bis auf das Niveau der Nachbarn hinunter-
  reicht - sie sehen Naturstein statt Beton.
- Terrasse der Nachbarn fest auf -1.77.
- Schotterwiese (Flaechen 4 und 5) eben: quer kein Hang mehr, laengs folgt
  sie der Feuerwehrzufahrt. Unter der Ligusterhecke fassen Winkelelemente
  den Garten der Nachbarn, so wie der Plan sie dort beschriftet. Wiesen mit
  fester Ebene werden jetzt plan gerendert statt dem Raster zu folgen.
- Eingang fuer die Nachbarn: Travertinstufe in der Luecke zwischen Hecke
  und Mauer ("Stufe" im Plan), darueber ein Torbogen aus Cortenstahl mit
  Kletterrose. Kein Tor.

Rundgang
- Klick auf den Boden laeuft dorthin, Ziehen dreht den Blick. Kein
  Mausfang mehr.
- Weiches Anfahren und Bremsen, gedaempftes Nachfuehren bei Stufen statt
  Fall-und-Aufprall, Rutschen an Hindernissen.
- Travertinmauern mit fester Krone sind begehbar; man steigt ueber die
  Sitzmauer vom Sitzplatz auf den Rasen, statt springen zu muessen.
- Gartentor oeffnet sich beim Naehertreten und schliesst hinter einem, E
  uebersteuert. Schwelle unter dem Tor schliesst die Luecke zum Podest.
- Blickrichtung blieb beim Wechsel Uebersicht/Rundgang nicht erhalten
  (Vorzeichenfehler in yawDegrees).
- mergeGeometries loest indizierte Geometrien vorher auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:50:44 +02:00

563 lines
21 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);
/**
* Die gruenen Punkte, die im Plan zwischen den Travertinstelen im Kiesbett
* stehen, sind keine Polster, sondern Ziergraeser - sie wachsen an den
* Stelen hoch und werden von den Bodenleuchten angestrahlt.
*/
function imKiesbett(x, z) {
return stelen.some((s) => Math.hypot(s.x - x, s.z - z) < 1.1);
}
// ---------------------------------------------------------------------------
// 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
// Kraeuter im Kiesbett des Sichtschutzes sind Ziergraeser.
const art = (type === 'kraut' || type === 'gras') && imKiesbett(cx, cz) ? 'gras' : type;
plants.push({ type: art, 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) {
// Auch eine Wiese ist eben, wenn eine feste Ebene sie ausdruecklich nennt -
// sonst folgt ihr Netz dem Raster und zackt an den Raendern auf.
const fixed = levelFor(s);
const genannt = fixed?.types?.includes(s.type) && fixed.flatten;
if ((!FLAT_TYPES.has(s.type) && !genannt) || s.area < 0.4) continue;
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);
}
// Feste Ebenen und Belaege gelten auch ausserhalb der Zonen. Vorher
// griffen sie nur innerhalb - das Podest am Fuss der Osttreppe liegt
// aber zwischen den Zonen, und dort stand ein Grashuegel darueber.
for (const lv of LEVELS) {
if (!lv.flatten || !pointInPolygon(x, z, lv.polygon)) continue;
y = lv.plane ? lv.plane[0] * x + lv.plane[1] * z + lv.plane[2] : 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;
}
}
// Unter den Treppen muss das Gelaende unter der untersten Trittkante liegen.
// Sonst waechst die Boeschung durch die Blockstufen hindurch und die Treppe
// verschwindet unter Rasen - genau das war an der Osttreppe passiert.
for (const f of LINEAR) {
if (f.kind !== 'treppe') continue;
const [ax, az] = f.path[0], [bx, bz] = f.path[f.path.length - 1];
const len = Math.hypot(bx - ax, bz - az) || 1;
const ux = (bx - ax) / len, uz = (bz - az) / len;
const riser = (f.fromY - f.toY) / f.steps;
const half = f.width / 2 + 0.08;
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 s = (x - ax) * ux + (z - az) * uz;
const q = -(x - ax) * uz + (z - az) * ux;
if (s < -0.1 || s > len + 0.1 || Math.abs(q) > half) continue;
const t = Math.max(0, Math.min(1, s / len));
const unter = f.fromY + (f.toY - f.fromY) * t - riser - 0.03;
const k = j * nx + i;
height[k] = Math.min(height[k], unter);
locked[k] = 1;
}
}
}
// 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.)`);