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
+244
View File
@@ -0,0 +1,244 @@
/**
* Geometrie-Bausteine: Polygone auf das Gelaende legen, Mauern extrudieren.
*/
import { BufferGeometry, BufferAttribute, ShapeUtils, Vector2 } from 'three';
import type { Vec2 } from '../site';
type Tri = [Vec2, Vec2, Vec2];
/** Zerlegt ein einfaches Polygon in Dreiecke (Ohrenschneiden aus three.js). */
export function triangulate(polygon: Vec2[]): Tri[] {
const contour = polygon.map(([x, z]) => new Vector2(x, z));
const faces = ShapeUtils.triangulateShape(contour, []);
return faces.map(([a, b, c]) => [polygon[a], polygon[b], polygon[c]] as Tri);
}
/**
* Verfeinert Dreiecke, bis keine Kante laenger als `maxEdge` ist. Damit folgen
* grosse Flaechen dem Gelaende, statt es zu ueberspannen.
*/
export function refine(tris: Tri[], maxEdge: number, maxDepth = 10): Tri[] {
let current = tris;
for (let depth = 0; depth < maxDepth; depth++) {
const next: Tri[] = [];
let split = false;
for (const t of current) {
const lens = [0, 1, 2].map((i) => {
const a = t[i], b = t[(i + 1) % 3];
return Math.hypot(a[0] - b[0], a[1] - b[1]);
});
const longest = lens.indexOf(Math.max(...lens));
if (lens[longest] <= maxEdge) { next.push(t); continue; }
split = true;
const a = t[longest], b = t[(longest + 1) % 3], c = t[(longest + 2) % 3];
const m: Vec2 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
next.push([a, m, c], [m, b, c]);
}
current = next;
if (!split) break;
}
return current;
}
/**
* Baut ein Flaechenmesh, das dem Gelaende folgt.
* UVs sind weltbezogen, damit Plattenraster ueber Flaechengrenzen durchlaufen.
*/
export function surfaceGeometry(
polygon: Vec2[],
height: (x: number, z: number) => number,
opts: { offset?: number; tile?: number; maxEdge?: number } = {},
): BufferGeometry {
const { offset = 0.01, tile = 1, maxEdge = 0.3 } = opts;
const tris = refine(triangulate(polygon), maxEdge);
const pos = new Float32Array(tris.length * 9);
const uv = new Float32Array(tris.length * 6);
let p = 0, u = 0;
for (const t of tris) {
for (const [x, z] of t) {
pos[p++] = x;
pos[p++] = height(x, z) + offset;
pos[p++] = z;
uv[u++] = x / tile;
uv[u++] = -z / tile;
}
}
const g = new BufferGeometry();
g.setAttribute('position', new BufferAttribute(pos, 3));
g.setAttribute('uv', new BufferAttribute(uv, 2));
g.computeVertexNormals();
return g;
}
/** Fasst mehrere Polygone zu einem Mesh zusammen. */
export function surfaceGeometryMulti(
polygons: Vec2[][],
height: (x: number, z: number) => number,
opts: { offset?: number; tile?: number; maxEdge?: number } = {},
): BufferGeometry {
const parts = polygons.map((p) => surfaceGeometry(p, height, opts));
return mergeGeometries(parts);
}
/** Einfaches Zusammenfuehren gleichartiger Geometrien (position/uv/normal). */
export function mergeGeometries(parts: BufferGeometry[]): BufferGeometry {
const total = parts.reduce((n, g) => n + g.getAttribute('position').count, 0);
const pos = new Float32Array(total * 3);
const nor = new Float32Array(total * 3);
const uv = new Float32Array(total * 2);
let o = 0;
for (const g of parts) {
const gp = g.getAttribute('position') as BufferAttribute;
const gn = g.getAttribute('normal') as BufferAttribute;
const gu = g.getAttribute('uv') as BufferAttribute;
pos.set(gp.array as Float32Array, o * 3);
nor.set(gn.array as Float32Array, o * 3);
uv.set(gu.array as Float32Array, o * 2);
o += gp.count;
g.dispose();
}
const g = new BufferGeometry();
g.setAttribute('position', new BufferAttribute(pos, 3));
g.setAttribute('normal', new BufferAttribute(nor, 3));
g.setAttribute('uv', new BufferAttribute(uv, 2));
return g;
}
interface Quad { a: [number, number, number]; b: [number, number, number]; c: [number, number, number]; d: [number, number, number] }
/** Haengt ein Viereck (gegen den Uhrzeigersinn) an die Puffer an. */
function pushQuad(pos: number[], uv: number[], q: Quad, uScale: number, vScale: number, uOff = 0) {
const [ax, ay, az] = q.a, [bx, by, bz] = q.b, [cx, cy, cz] = q.c, [dx, dy, dz] = q.d;
const du = Math.hypot(bx - ax, bz - az) * uScale;
const v0 = ay * vScale, v1 = cy * vScale, v2 = dy * vScale, v3 = by * vScale;
pos.push(ax, ay, az, bx, by, bz, cx, cy, cz);
uv.push(uOff, v0, uOff + du, v3, uOff + du, v1);
pos.push(ax, ay, az, cx, cy, cz, dx, dy, dz);
uv.push(uOff, v0, uOff + du, v1, uOff, v2);
}
/**
* Extrudiert eine Mauer entlang einer Mittellinie.
* `top`/`base` liefern fuer einen Parameter t (0..1) die Kronen- bzw. Fusshoehe.
*/
export function wallGeometry(
path: Vec2[],
thickness: number,
top: (t: number, x: number, z: number) => number,
base: (t: number, x: number, z: number) => number,
opts: { segments?: number; tile?: number } = {},
): BufferGeometry {
const { segments = 24, tile = 1.6 } = opts;
const pos: number[] = [];
const uv: number[] = [];
const h = thickness / 2;
const s = 1 / tile;
// Laenge und Richtungen entlang des Pfades
const pts: { x: number; z: number; nx: number; nz: number; t: number }[] = [];
const totalLen = path.slice(1).reduce((L, p, i) => L + Math.hypot(p[0] - path[i][0], p[1] - path[i][1]), 0);
for (let i = 0; i <= segments; i++) {
const t = i / segments;
// Punkt auf dem Polyzug bei Bogenlaenge t * totalLen
let want = t * totalLen;
let x = path[0][0], z = path[0][1], dx = 1, dz = 0;
for (let k = 1; k < path.length; k++) {
const ax = path[k - 1][0], az = path[k - 1][1];
const bx = path[k][0], bz = path[k][1];
const L = Math.hypot(bx - ax, bz - az);
if (want <= L || k === path.length - 1) {
const u = L > 0 ? Math.min(1, want / L) : 0;
x = ax + (bx - ax) * u; z = az + (bz - az) * u;
dx = (bx - ax) / (L || 1); dz = (bz - az) / (L || 1);
break;
}
want -= L;
}
pts.push({ x, z, nx: -dz, nz: dx, t });
}
let uRun = 0;
for (let i = 0; i < segments; i++) {
const p0 = pts[i], p1 = pts[i + 1];
const t0 = top(p0.t, p0.x, p0.z), t1 = top(p1.t, p1.x, p1.z);
const b0 = base(p0.t, p0.x, p0.z), b1 = base(p1.t, p1.x, p1.z);
const segLen = Math.hypot(p1.x - p0.x, p1.z - p0.z);
const l0: Vec2 = [p0.x + p0.nx * h, p0.z + p0.nz * h];
const r0: Vec2 = [p0.x - p0.nx * h, p0.z - p0.nz * h];
const l1: Vec2 = [p1.x + p1.nx * h, p1.z + p1.nz * h];
const r1: Vec2 = [p1.x - p1.nx * h, p1.z - p1.nz * h];
// linke Flanke
pushQuad(pos, uv, {
a: [l0[0], b0, l0[1]], b: [l1[0], b1, l1[1]],
c: [l1[0], t1, l1[1]], d: [l0[0], t0, l0[1]],
}, s, s, uRun * s);
// rechte Flanke
pushQuad(pos, uv, {
a: [r1[0], b1, r1[1]], b: [r0[0], b0, r0[1]],
c: [r0[0], t0, r0[1]], d: [r1[0], t1, r1[1]],
}, s, s, uRun * s);
// Krone
pos.push(l0[0], t0, l0[1], r0[0], t0, r0[1], r1[0], t1, r1[1]);
uv.push(uRun * s, 0, uRun * s, thickness * s, (uRun + segLen) * s, thickness * s);
pos.push(l0[0], t0, l0[1], r1[0], t1, r1[1], l1[0], t1, l1[1]);
uv.push(uRun * s, 0, (uRun + segLen) * s, thickness * s, (uRun + segLen) * s, 0);
uRun += segLen;
}
// Stirnseiten
for (const [p, sign] of [[pts[0], -1], [pts[pts.length - 1], 1]] as const) {
const tp = top(p.t, p.x, p.z), bs = base(p.t, p.x, p.z);
const l: Vec2 = [p.x + p.nx * h, p.z + p.nz * h];
const r: Vec2 = [p.x - p.nx * h, p.z - p.nz * h];
const A = sign < 0 ? l : r, B = sign < 0 ? r : l;
pushQuad(pos, uv, {
a: [A[0], bs, A[1]], b: [B[0], bs, B[1]],
c: [B[0], tp, B[1]], d: [A[0], tp, A[1]],
}, s, s, 0);
}
const g = new BufferGeometry();
g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
g.setAttribute('uv', new BufferAttribute(new Float32Array(uv), 2));
g.computeVertexNormals();
return g;
}
/** Quaderfoermiges Volumen mit weltbezogenen UVs, fuer Bauteile und Moebel. */
export function boxGeometry(
cx: number, cz: number, w: number, d: number, y0: number, y1: number, rotDeg = 0, tile = 1,
): BufferGeometry {
const pos: number[] = [];
const uv: number[] = [];
const a = (rotDeg * Math.PI) / 180;
const ca = Math.cos(a), sa = Math.sin(a);
const P = (u: number, v: number, y: number): [number, number, number] =>
[cx + u * ca - v * sa, y, cz + u * sa + v * ca];
const hw = w / 2, hd = d / 2;
const s = 1 / tile;
const faces: [Vec2, Vec2, number][] = [
[[-hw, -hd], [hw, -hd], 1], [[hw, -hd], [hw, hd], 1],
[[hw, hd], [-hw, hd], 1], [[-hw, hd], [-hw, -hd], 1],
];
let uRun = 0;
for (const [p0, p1] of faces) {
const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
const A = P(p0[0], p0[1], y0), B = P(p1[0], p1[1], y0);
const C = P(p1[0], p1[1], y1), D = P(p0[0], p0[1], y1);
pos.push(...A, ...B, ...C, ...A, ...C, ...D);
uv.push(uRun * s, y0 * s, (uRun + len) * s, y0 * s, (uRun + len) * s, y1 * s);
uv.push(uRun * s, y0 * s, (uRun + len) * s, y1 * s, uRun * s, y1 * s);
uRun += len;
}
// Deckel
const t0 = P(-hw, -hd, y1), t1 = P(hw, -hd, y1), t2 = P(hw, hd, y1), t3 = P(-hw, hd, y1);
pos.push(...t0, ...t1, ...t2, ...t0, ...t2, ...t3);
uv.push(0, 0, w * s, 0, w * s, d * s, 0, 0, w * s, d * s, 0, d * s);
const g = new BufferGeometry();
g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
g.setAttribute('uv', new BufferAttribute(new Float32Array(uv), 2));
g.computeVertexNormals();
return g;
}