/** * Mauern als gebaute Elemente, nicht als hochgezogene Flaechen. * * Der Plan nennt vier verschiedene Konstruktionen, die auch verschieden * aussehen muessen: * * - **Travertinmauer / Sitzmauer** – Schichtmauerwerk aus einzelnen Bloecken * mit Lagerfugen und einer ueberstehenden Abdeckplatte. * - **Winkelelemente aus Beton** – Fertigteile von rund 1 m Breite, einzeln * versetzt, mit sichtbaren Stossfugen und leicht geneigter Ansichtsflaeche. * - **Betonmauer Nachbar** – Ortbeton in Schaltafelfeldern mit Ankerloechern * und abgefaster Krone. * - **Lamellenwand** – senkrechte Holzlamellen im Rhythmus des Plans. * - **Holzscheiterwand** – Stahlrahmen, gefuellt mit gestapelten Scheitern. */ import { BufferGeometry, Group, Mesh, MeshStandardMaterial, type MeshStandardMaterialParameters, } from 'three'; import type { Vec2, Wall } from '../site'; import { boxGeometry, mergeGeometries, wallGeometry } from './geometry'; import * as tex from './textures'; function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {}) { return new MeshStandardMaterial({ map: belag.map, normalMap: belag.normalMap ?? null, normalScale: belag.normalScale, roughness: belag.roughness, metalness: 0, ...extra, }); } function rng(seed: number) { let a = seed >>> 0; return () => { a = (a + 0x9e3779b9) >>> 0; let t = Math.imul(a ^ (a >>> 16), 0x21f0aaad); t = Math.imul(t ^ (t >>> 15), 0x735a2d97); return ((t ^ (t >>> 15)) >>> 0) / 4294967296; }; } /** Wertet ein Hoehenprofil (Stuetzelemente) an der Stelle t aus. */ function profileAt(profile: number[] | undefined, t: number, fallback: number): number { if (!profile || !profile.length) return fallback; const f = t * (profile.length - 1); const i = Math.max(0, Math.min(profile.length - 2, Math.floor(f))); const u = f - i; return profile[i] * (1 - u) + profile[i + 1] * u; } interface Axis { /** Gesamtlaenge des Polyzugs. */ length: number; /** Punkt, Richtung und Normale bei Bogenlaenge s. */ at(s: number): { x: number; z: number; dx: number; dz: number; angle: number; t: number }; } function axisOf(path: Vec2[]): Axis { const segs = path.slice(1).map((p, i) => ({ a: path[i], b: p, len: Math.hypot(p[0] - path[i][0], p[1] - path[i][1]), })); const length = segs.reduce((s, g) => s + g.len, 0); return { length, at(s: number) { let want = Math.max(0, Math.min(length, s)); for (let i = 0; i < segs.length; i++) { const g = segs[i]; if (want <= g.len || i === segs.length - 1) { const u = g.len > 0 ? Math.min(1, want / g.len) : 0; const dx = (g.b[0] - g.a[0]) / (g.len || 1); const dz = (g.b[1] - g.a[1]) / (g.len || 1); return { x: g.a[0] + (g.b[0] - g.a[0]) * u, z: g.a[1] + (g.b[1] - g.a[1]) * u, dx, dz, angle: (Math.atan2(dz, dx) * 180) / Math.PI, t: length > 0 ? s / length : 0, }; } want -= g.len; } return { x: path[0][0], z: path[0][1], dx: 1, dz: 0, angle: 0, t: 0 }; }, }; } /** * Schichtmauerwerk: Lage fuer Lage aus einzelnen Bloecken, darueber eine * ueberstehende Abdeckplatte. Fugen entstehen durch echte Zwischenraeume. */ function masonry( axis: Axis, thickness: number, top: (t: number) => number, base: (t: number) => number, seed: number, ): { blocks: BufferGeometry; coping: BufferGeometry } { const COURSE = 0.19; // Schichthoehe const JOINT = 0.012; // Lagerfuge const CAP = 0.06; // Abdeckplatte const rnd = rng(seed); const blocks: BufferGeometry[] = []; const topMid = top(0.5); const baseMid = base(0.5); const wallTop = topMid - CAP; const courses = Math.max(1, Math.round((wallTop - baseMid) / COURSE)); const courseH = (wallTop - baseMid) / courses; for (let c = 0; c < courses; c++) { const y0 = baseMid + c * courseH; const y1 = y0 + courseH - JOINT; // Jede zweite Lage beginnt mit einem halben Stein - so versetzen sich die // Stossfugen, ohne dass Luecken entstehen. let s = 0; let first = true; while (s < axis.length - 0.02) { let len = 0.42 + rnd() * 0.40; if (first && c % 2 === 1) len *= 0.55; first = false; len = Math.min(len, axis.length - s); // Reststueck lieber an den letzten Stein anhaengen als einen Splitter setzen if (axis.length - (s + len) < 0.18) len = axis.length - s; const p = axis.at(s + len / 2); const d = thickness + (rnd() - 0.5) * 0.016; blocks.push(boxGeometry(p.x, p.z, len - JOINT, d, y0, y1, p.angle, 1.6)); s += len; } } // Abdeckplatte, beidseits 2 cm ueberstehend const coping: BufferGeometry[] = []; const capSegs = Math.max(2, Math.round(axis.length / 0.9)); for (let i = 0; i < capSegs; i++) { const s0 = (i / capSegs) * axis.length; const len = axis.length / capSegs; const p = axis.at(s0 + len / 2); const y = top(p.t); coping.push(boxGeometry(p.x, p.z, len - 0.006, thickness + 0.04, y - CAP, y, p.angle, 1.6)); } return { blocks: mergeGeometries(blocks), coping: mergeGeometries(coping) }; } /** * Winkelelemente: Fertigteile von rund 1 m Breite. Sichtbar sind die * Ansichtsflaeche mit den Stossfugen und die schmale Krone. */ function angleElements( axis: Axis, thickness: number, crest: (t: number) => number, foot: (t: number) => number, ): BufferGeometry { const WIDTH = 1.0; const JOINT = 0.015; const parts: BufferGeometry[] = []; const n = Math.max(1, Math.round(axis.length / WIDTH)); const w = axis.length / n; for (let i = 0; i < n; i++) { const p = axis.at(i * w + w / 2); const yTop = crest(p.t); const yBottom = foot(p.t); // Stiel: oben duenn, unten dicker - wie ein echtes L-Element const stem = 3; for (let k = 0; k < stem; k++) { const a = yBottom + ((yTop - yBottom) * k) / stem; const b = yBottom + ((yTop - yBottom) * (k + 1)) / stem; const th = thickness * (1.55 - 0.4 * (k / stem)); parts.push(boxGeometry(p.x, p.z, w - JOINT, th, a, b, p.angle, 2.0)); } // Fussplatte, meist verdeckt, aber am Fuss sichtbar parts.push(boxGeometry( p.x - p.dz * thickness * 1.1, p.z + p.dx * thickness * 1.1, w - JOINT, thickness * 2.4, yBottom, yBottom + 0.14, p.angle, 2.0, )); } return mergeGeometries(parts); } /** Ortbetonwand in Schaltafelfeldern mit abgefaster Krone. */ function concretePanels( axis: Axis, thickness: number, top: (t: number) => number, base: (t: number) => number, ): BufferGeometry { const FIELD = 2.5; const JOINT = 0.012; const parts: BufferGeometry[] = []; const n = Math.max(1, Math.round(axis.length / FIELD)); const w = axis.length / n; for (let i = 0; i < n; i++) { const p = axis.at(i * w + w / 2); const yTop = top(p.t); const yBase = base(p.t); parts.push(boxGeometry(p.x, p.z, w - JOINT, thickness, yBase, yTop - 0.03, p.angle, 2.0)); // abgefaste Krone: schmaler Aufsatz parts.push(boxGeometry(p.x, p.z, w - JOINT, thickness - 0.03, yTop - 0.03, yTop, p.angle, 2.0)); } return mergeGeometries(parts); } export function buildWalls(walls: Wall[], height: (x: number, z: number) => number): Group { const group = new Group(); group.name = 'Mauern und Wände'; const travertin = tex.travertinMauer(); const beton = tex.beton(); const holz = tex.fassadeHolz(); const scheiter = tex.holzscheiter(); const stahl = new MeshStandardMaterial({ color: 0x3f3a35, roughness: 0.5, metalness: 0.6 }); for (const w of walls) { const axis = axisOf(w.path); const top = (t: number) => { if (w.crestProfile) return profileAt(w.crestProfile, t, 0); if (w.top !== null) return w.top; const p = axis.at(t * axis.length); return height(p.x, p.z) + 0.4; }; const base = (t: number) => { if (w.footProfile) return profileAt(w.footProfile, t, -1); if (w.base !== null) return w.base; const p = axis.at(t * axis.length); return height(p.x, p.z) - 0.5; }; const node = new Group(); node.name = w.id; node.userData.note = w.note; if (w.type === 'travertin') { const { blocks, coping } = masonry(axis, w.t, top, base, w.id.length * 977 + 13); const body = new Mesh(blocks, material(travertin)); const cap = new Mesh(coping, material(travertin, { roughness: 0.6 })); for (const m of [body, cap]) { m.castShadow = true; m.receiveShadow = true; node.add(m); } } else if (w.retains) { const m = new Mesh(angleElements(axis, w.t, top, base), material(beton)); m.castShadow = true; m.receiveShadow = true; node.add(m); } else if (w.type === 'beton') { const m = new Mesh(concretePanels(axis, w.t, top, base), material(beton)); m.castShadow = true; m.receiveShadow = true; node.add(m); } else if (w.openSlats) { // Lamellenwand: Rahmen aus Kanthoelzern, dazwischen senkrechte Bretter const mat = material(holz, { roughness: 0.84 }); const parts: BufferGeometry[] = []; const n = Math.max(2, Math.round(axis.length / 0.9)); for (let i = 0; i <= n; i++) { const p = axis.at((i / n) * axis.length); parts.push(boxGeometry(p.x, p.z, 0.10, w.t, base(p.t), top(p.t), p.angle, 1.2)); } const slats = new Mesh(mergeGeometries(parts), mat); slats.castShadow = true; slats.receiveShadow = true; node.add(slats); const band = new Mesh( wallGeometry(w.path, 0.10, (t) => top(t), (t) => base(t), { segments: 8, tile: holz.tile }), mat, ); band.castShadow = true; band.receiveShadow = true; node.add(band); } else if (w.type === 'holzscheiter') { // Stahlrahmen mit Scheiterfuellung const y0 = base(0.5), y1 = top(0.5); const fill = new Mesh( boxGeometry( (w.path[0][0] + w.path[1][0]) / 2, (w.path[0][1] + w.path[1][1]) / 2, axis.length - 0.12, w.t - 0.08, y0 + 0.06, y1 - 0.06, axis.at(0).angle, scheiter.tile, ), material(scheiter, { roughness: 0.92 }), ); fill.castShadow = true; fill.receiveShadow = true; node.add(fill); const frameParts: BufferGeometry[] = []; const cx = (w.path[0][0] + w.path[1][0]) / 2, cz = (w.path[0][1] + w.path[1][1]) / 2; const ang = axis.at(0).angle; frameParts.push(boxGeometry(cx, cz, axis.length, w.t, y0, y0 + 0.06, ang, 1)); frameParts.push(boxGeometry(cx, cz, axis.length, w.t, y1 - 0.06, y1, ang, 1)); for (const s of [-1, 1]) { const p = axis.at(s < 0 ? 0.03 : axis.length - 0.03); frameParts.push(boxGeometry(p.x, p.z, 0.06, w.t, y0, y1, ang, 1)); } const frame = new Mesh(mergeGeometries(frameParts), stahl); frame.castShadow = true; node.add(frame); } group.add(node); } return group; }