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>
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|