Begehbare 3D-Visualisierung des Gartens Egglihaldenstrasse 9a

Geometrie massstaeblich aus dem Gartenplan-PDF (1:100) extrahiert: Belaege,
Mauern, Pflanzenstandorte und 46 Hoehenkoten. Wohnhaus nach Gartenplan und
den Fassadenplaenen von EHLE Architekten.

- tools/: Pipeline PDF -> Szenenmodell (MuPDF, Node)
- src/: three.js-Szene mit prozeduralen Texturen, Rundgang und Uebersicht
- Docker/Traefik fuer garten.aicds.ch, Gitea-Workflow auf main

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:59:37 +02:00
co-authored by Claude Opus 5
commit 70d239fe9c
43 changed files with 5614 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
/**
* Gelaende: Basismesh aus dem Hoehenraster, plus eine weite, flache Umgebung,
* damit der Garten nicht im Nichts steht.
*/
import {
BufferAttribute, BufferGeometry, DoubleSide, Mesh, MeshStandardMaterial, Group,
} from 'three';
import type { Terrain } from '../site';
import { rasen, schotterwiese } from './textures';
export function buildTerrain(terrain: Terrain): Group {
const group = new Group();
group.name = 'Gelände';
const { x0, z0, cell, nx, nz } = terrain.grid;
const h = terrain.heights;
// --- Hauptgelaende -------------------------------------------------------
const geo = new BufferGeometry();
const verts = new Float32Array(nx * nz * 3);
const uvs = new Float32Array(nx * nz * 2);
const belag = rasen();
for (let j = 0; j < nz; j++) {
for (let i = 0; i < nx; i++) {
const k = j * nx + i;
const x = x0 + i * cell, z = z0 + j * cell;
verts[k * 3] = x;
verts[k * 3 + 1] = h[k];
verts[k * 3 + 2] = z;
uvs[k * 2] = x / belag.tile;
uvs[k * 2 + 1] = -z / belag.tile;
}
}
const idx: number[] = [];
for (let j = 0; j < nz - 1; j++) {
for (let i = 0; i < nx - 1; i++) {
const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1;
idx.push(a, c, b, b, c, d);
}
}
geo.setAttribute('position', new BufferAttribute(verts, 3));
geo.setAttribute('uv', new BufferAttribute(uvs, 2));
geo.setIndex(idx);
geo.computeVertexNormals();
const ground = new Mesh(geo, new MeshStandardMaterial({
map: belag.map, roughness: belag.roughness, metalness: 0,
}));
ground.name = 'Terrain';
ground.receiveShadow = true;
group.add(ground);
// --- Umland ---------------------------------------------------------------
// Das Grundstueck liegt an einem Hang ueber dem Tal. Das Umland wird als
// Kranz direkt an die Randpunkte des Rasters gehaengt - so entsteht keine
// sichtbare Kante - und faellt gegen aussen ab, vor allem nach Suedwesten.
const wiese = schotterwiese();
const cx = x0 + ((nx - 1) * cell) / 2;
const cz = z0 + ((nz - 1) * cell) / 2;
/**
* Randpunkte des Rasters im Umlauf, bewusst grob abgetastet: bei jedem
* Rasterpunkt entstuenden extrem schlanke Dreiecke, deren Normalen
* unbrauchbar werden und sich als dunkle Streifen abzeichnen.
*/
const STEP = 10;
const border: { x: number; z: number; y: number }[] = [];
const at = (i: number, j: number) => ({
x: x0 + i * cell, z: z0 + j * cell, y: h[j * nx + i] - 0.05,
});
for (let i = 0; i < nx - 1; i += STEP) border.push(at(i, 0));
for (let j = 0; j < nz - 1; j += STEP) border.push(at(nx - 1, j));
for (let i = nx - 1; i > 0; i -= STEP) border.push(at(i, nz - 1));
for (let j = nz - 1; j > 0; j -= STEP) border.push(at(0, j));
// Der Kranz entsteht durch reine Skalierung des Randpolygons um den
// Mittelpunkt. Das kann sich nicht selbst durchdringen, egal wie weit
// hinausskaliert wird, und schliesst luecklos an das Raster an.
const RINGS = 26;
const SCALE_MAX = 18;
const ringPos: number[] = [];
const ringUv: number[] = [];
const ringIdx: number[] = [];
const n = border.length;
for (let k = 0; k <= RINGS; k++) {
const t = (k / RINGS) ** 2.4;
const s = 1 + (SCALE_MAX - 1) * t;
for (const p of border) {
const dx = p.x - cx, dz = p.z - cz;
const x = cx + dx * s;
const z = cz + dz * s;
// Nach Suedwesten faellt das Gelaende deutlich staerker ab (Blick ins Tal).
const sw = (1 - Math.cos(Math.atan2(dz, dx) - 2.4)) / 2;
ringPos.push(x, p.y - t * (6 + 26 * sw * sw), z);
ringUv.push(x / (wiese.tile * 5), -z / (wiese.tile * 5));
}
}
for (let k = 0; k < RINGS; k++) {
for (let i = 0; i < n; i++) {
const a = k * n + i, b = k * n + ((i + 1) % n);
const c = (k + 1) * n + i, d = (k + 1) * n + ((i + 1) % n);
ringIdx.push(a, c, b, b, c, d);
}
}
const far = new BufferGeometry();
far.setAttribute('position', new BufferAttribute(new Float32Array(ringPos), 3));
far.setAttribute('uv', new BufferAttribute(new Float32Array(ringUv), 2));
far.setIndex(ringIdx);
far.computeVertexNormals();
const surroundings = new Mesh(far, new MeshStandardMaterial({
map: wiese.map, roughness: 1, metalness: 0, color: 0x86935f, side: DoubleSide,
}));
surroundings.name = 'Umland';
group.add(surroundings);
return group;
}