/** * 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.)`);