/** * 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(); }