Deploy App / deploy (push) Successful in 49s
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>
360 lines
13 KiB
TypeScript
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();
|
|
}
|