Deploy App / deploy (push) Successful in 28s
Systematischer Abgleich Plan gegen Szene, Bereich fuer Bereich. Neu gebaut - Lineare Bauteile (LINEAR): Treppe und Rampe am Ost- und Westzugang, Stahlrohrgelaender laengs des Gefaelles, Entwaesserungsrinnen mit buendigem Rost. Geometrie aus den Trittlinien des Plans abgegriffen (Ost: 8 Trittlinien z -4.18 bis -6.52, Koten -0.37/-1.57; West: Trittlinien alle 20 cm ab x -21.94, Koten -0.28/-1.08). - Rasenrandplatten: das durchgehende Band von 6 cm Breite entlang der Rasenkante wird als hochkant stehende Randplatte mit Sichtkante aufgebaut, nicht mehr als Belag (neuer Typ randplatte, 1.2 m2). - Zwei schwebende Travertinstufen am Gartentor, Koten -0.13 und -0.32. - Winkelelemente entlang der Suedgrenze, Tegula-Streifen des Nachbarn. Korrigiert - Holzscheiterwand: der Plan zeigt zwei Felder 1.50 x 0.46 m, nicht eines. Farbe #ffaa00 war faelschlich als Tegula klassiert. - Gartentor: Fluegel laeuft ueber z, nicht ueber x - das Tor stand um 90 Grad verdreht. Jetzt Stabgitter mit Pfosten und senkrechten Staeben statt geschlossenem schwarzem Blatt. - Winkelelemente Ost auf x 3.735 (Plan: Doppellinie 3.69/3.78), Fussweg auf x 2.86 bis 3.69 zwischen Verbundstein-Anpassung und Winkelelementen. - Rinne beim Gartentor auf die Schraffur des Plans (z 2.295, x -23.34 bis -21.74). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
509 lines
19 KiB
JavaScript
509 lines
19 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) {
|
|
plane = { a: 0, b: 0, c: fixed.level };
|
|
ys = [fixed.level];
|
|
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
|
|
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 = 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.)`);
|