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