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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user