Files
Gartenvisualisierung/src/scene/geometry.ts
T
admGitAICDSandClaude Opus 5 8979775895
Deploy App / deploy (push) Successful in 49s
Qualitaetssprung: ebene Belaege, echte Bauteile, Sichtschutz
Nach der Plankontrolle ueberarbeitet:

- Belaege sind eben. Je Belag wird aus den Hoehenkoten eine Ebene bestimmt
  (Gefaelle <= 5 %, am Kotenschwerpunkt aufgehaengt, nur Koten derselben
  Gelaendezone). Das Raster wird darauf gezwungen und dort nicht geglaettet.
  Jeder Belag bekommt eine senkrechte Sichtkante zum Gelaende.
- Mauern sind Bauteile: Travertin-Schichtmauerwerk mit versetzten Stossfugen
  und Abdeckplatte, einzelne Winkelelemente mit Fussplatte, Ortbeton in
  Schaltafelfeldern.
- Sichtschutz: die Eibenreihen des Plans werden zu geschnittenen Hecken
  verkettet, ebenso die Ligusterhecke. Lamellenwand im Planrhythmus.
- Der Plan zeichnet die Travertinplatten 40x60 einzeln im Dekorkies - jetzt
  30 erhabene Platten an den Planpositionen statt geschlossenem Belag.
- Normalmaps aus dem Fugenbild fuer Platten, Pflaster, Holzschalung und Laub.
- Nachbarhaus Zollet richtig platziert (stand auf dem Nachbargarten) und
  wie Haus 9a ausgebildet.

Behobene Fehler: boxGeometry erzeugte Seiten und Deckel mit umgekehrtem
Wickelsinn - saemtliche Boxen waren innen nach aussen gekehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:47:47 +02:00

360 lines
13 KiB
TypeScript

/**
* 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;
// Umlauf im Uhrzeigersinn in (u, v). Damit zeigen die Flaechennormalen nach
// aussen - andersherum sieht man in die Box hinein.
const faces: [Vec2, Vec2][] = [
[[-hw, -hd], [-hw, hd]], [[-hw, hd], [hw, hd]],
[[hw, hd], [hw, -hd]], [[hw, -hd], [-hw, -hd]],
];
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, ebenfalls nach aussen (oben) gewickelt
const t0 = P(-hw, -hd, y1), t1 = P(hw, -hd, y1), t2 = P(hw, hd, y1), t3 = P(-hw, hd, y1);
pos.push(...t0, ...t3, ...t2, ...t0, ...t2, ...t1);
uv.push(0, 0, 0, d * s, w * s, d * s, 0, 0, w * s, d * s, w * s, 0);
// Boden, damit die Box auch von unten geschlossen ist
const b0 = P(-hw, -hd, y0), b1 = P(hw, -hd, y0), b2 = P(hw, hd, y0), b3 = P(-hw, hd, y0);
pos.push(...b0, ...b1, ...b2, ...b0, ...b2, ...b3);
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;
}
/**
* Ebener Belag: das Polygon liegt exakt in der Ebene a*x + b*z + c.
* Gebaute Flaechen sind eben - sie duerfen dem Gelaende nicht folgen.
*/
export function planarSurfaceGeometry(
polygon: Vec2[],
plane: [number, number, number],
tile: number,
lift = 0,
): BufferGeometry {
const [a, b, c] = plane;
const tris = refine(triangulate(polygon), 1.6);
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++] = a * x + b * z + c + lift;
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;
}
/**
* Sichtkante eines Belags: senkrechtes Band von der Belagsoberkante hinunter
* bis unter das Gelaende. Erst damit wirkt eine Terrasse wie gebaut und nicht
* wie aufgemalt.
*/
export function surfaceEdgeGeometry(
polygon: Vec2[],
topAt: (x: number, z: number) => number,
bottomAt: (x: number, z: number) => number,
tile: number,
step = 0.4,
): BufferGeometry {
const pos: number[] = [];
const uv: number[] = [];
let run = 0;
const s = 1 / tile;
const n = polygon.length;
for (let i = 0; i < n; i++) {
const [ax, az] = polygon[i];
const [bx, bz] = polygon[(i + 1) % n];
const len = Math.hypot(bx - ax, bz - az);
const parts = Math.max(1, Math.round(len / step));
for (let k = 0; k < parts; k++) {
const t0 = k / parts, t1 = (k + 1) / parts;
const x0 = ax + (bx - ax) * t0, z0 = az + (bz - az) * t0;
const x1 = ax + (bx - ax) * t1, z1 = az + (bz - az) * t1;
const ty0 = topAt(x0, z0), ty1 = topAt(x1, z1);
const by0 = Math.min(bottomAt(x0, z0), ty0 - 0.012);
const by1 = Math.min(bottomAt(x1, z1), ty1 - 0.012);
const u0 = run, u1 = run + len / parts;
// Aussenseite: Polygon laeuft im Gegenuhrzeigersinn, also liegt die
// Sichtseite rechts der Laufrichtung.
pos.push(x0, ty0, z0, x0, by0, z0, x1, by1, z1);
uv.push(u0 * s, ty0 * s, u0 * s, by0 * s, u1 * s, by1 * s);
pos.push(x0, ty0, z0, x1, by1, z1, x1, ty1, z1);
uv.push(u0 * s, ty0 * s, u1 * s, by1 * s, u1 * s, ty1 * s);
run = u1;
}
}
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;
}
/**
* Metallband: schmales Stahlband entlang eines Polygonrandes, wie es der Plan
* zwischen Kies, Rasen und Beeten zeigt.
*/
export function edgingGeometry(
polygon: Vec2[],
topAt: (x: number, z: number) => number,
thickness = 0.005,
proud = 0.022,
depth = 0.12,
): BufferGeometry {
const parts: BufferGeometry[] = [];
const n = polygon.length;
for (let i = 0; i < n; i++) {
const [ax, az] = polygon[i];
const [bx, bz] = polygon[(i + 1) % n];
const len = Math.hypot(bx - ax, bz - az);
if (len < 0.12) continue;
const steps = Math.max(1, Math.round(len / 1.2));
for (let k = 0; k < steps; k++) {
const t0 = k / steps, t1 = (k + 1) / steps;
const x0 = ax + (bx - ax) * t0, z0 = az + (bz - az) * t0;
const x1 = ax + (bx - ax) * t1, z1 = az + (bz - az) * t1;
const cx = (x0 + x1) / 2, cz = (z0 + z1) / 2;
const y = topAt(cx, cz);
const ang = (Math.atan2(z1 - z0, x1 - x0) * 180) / Math.PI;
parts.push(boxGeometry(cx, cz, len / steps + 0.004, thickness, y + proud - depth, y + proud, ang, 1));
}
}
return parts.length ? mergeGeometries(parts) : new BufferGeometry();
}