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