Etappe D: Solitaergehoelze, Kronenschattierung, Nachbargelaende Sued
Deploy App / deploy (push) Successful in 27s

- blob(): die Auslenkung war ein Zufallswert je Vertex. IcosahedronGeometry
  ist unindiziert, jeder Eckpunkt kommt mehrfach vor - die Flaeche riss in
  Splitter auf. Jetzt eine stetige Funktion der Richtung, dazu wahlweise
  die Normalen der ungestoerten Kugel: die Krone wird weich schattiert,
  die Silhouette bleibt unregelmaessig.
- Baumkronen mit eigener Laubtextur (laubBaum) statt flacher Farbe.
- Parrotia als mehrstaemmiges Solitaergehoelz: die drei Staemme laufen aus
  einem Punkt schraeg auseinander, die Kronenblasen sitzen darueber.
- Gelaendezonen: Grenze Garten/Sued liegt jetzt auf den Winkelelementen
  bei z 4.96, Grenze Garten/Ost auf den Winkelelementen bei x 3.74.
  Das Nachbargrundstueck Sued liegt hoeher als der Garten - dafuer stehen
  dort Winkelelemente und die bestehende Betonmauer. Koten setzen den
  Trend der Grenzkoten fort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 18:04:05 +02:00
co-authored by Claude Opus 5
parent 32ceba7de0
commit 5d5bc50b38
4 changed files with 73 additions and 27 deletions
File diff suppressed because one or more lines are too long
+2
View File
@@ -652,6 +652,8 @@ function laub(key: string, base: string, seed: number, leaf: number): Belag {
/** Eibe: dunkel, feinnadelig. */
export const laubEibe = () => laub('laubEibe', 'rgb(64,92,52)', 201, 3.4);
/** Baumkrone: groesseres, helleres Blatt mit staerkerer Streuung. */
export const laubBaum = () => laub('laubBaum', 'rgb(105,132,68)', 223, 7.0);
/** Liguster: mittelgruen, groesseres Blatt. */
export const laubLiguster = () => laub('laubLiguster', 'rgb(85,118,55)', 211, 5.2);
+54 -19
View File
@@ -29,18 +29,41 @@ function rng(seed: number) {
* wichtig: eine ungleichmaessige Instanz-Skalierung verzerrt die Normalen und
* erzeugt harte, glaenzende Kanten.
*/
function blob(radius: number, detail: number, roughness: number, seed: number, stretchY = 1): BufferGeometry {
function blob(
radius: number, detail: number, roughness: number, seed: number, stretchY = 1,
smooth = false, uvScale = 1,
): BufferGeometry {
const g = new IcosahedronGeometry(radius, detail);
const pos = g.getAttribute('position') as BufferAttribute;
const r = rng(seed);
// Die Auslenkung muss eine Funktion der Richtung sein, nicht des Vertex:
// IcosahedronGeometry ist unindiziert, jeder Eckpunkt kommt mehrfach vor.
// Ein Zufallswert je Vertex reisst die Flaeche in Splitter auf.
const ph = [r() * 6.28, r() * 6.28, r() * 6.28, r() * 6.28];
const wob = (x: number, y: number, z: number) =>
Math.sin(x * 2.7 + ph[0]) * Math.sin(y * 3.1 + ph[1]) * Math.sin(z * 2.3 + ph[2]) * 0.62
+ Math.sin(x * 5.3 + y * 4.1 + ph[3]) * 0.38;
const v = new Vector3();
const normals = smooth ? new Float32Array(pos.count * 3) : null;
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i);
const f = 1 + (r() - 0.5) * 2 * roughness;
const n = v.clone().normalize();
if (normals) {
// Normale der ungestoerten Kugel: die Krone wird dadurch weich
// schattiert, waehrend die Silhouette unregelmaessig bleibt. Mit den
// Normalen der verbeulten Flaeche zerfaellt sie in sichtbare Facetten.
normals[i * 3] = n.x; normals[i * 3 + 1] = n.y; normals[i * 3 + 2] = n.z;
}
const f = 1 + wob(n.x, n.y, n.z) * roughness;
v.multiplyScalar(f);
pos.setXYZ(i, v.x, v.y * stretchY, v.z);
}
g.computeVertexNormals();
if (normals) g.setAttribute('normal', new BufferAttribute(normals, 3));
else g.computeVertexNormals();
if (uvScale !== 1) {
const uv = g.getAttribute('uv') as BufferAttribute;
for (let i = 0; i < uv.count; i++) uv.setXY(i, uv.getX(i) * uvScale, uv.getY(i) * uvScale);
}
return g;
}
@@ -68,39 +91,51 @@ function buildTree(t: Tree, y: number): Group {
const crownR = Math.max(0.9, t.crown);
const trunkH = t.height * 0.42;
// Parrotia persica steht im Plan als mehrstaemmiges Solitaergehoelz: die
// Staemme laufen aus einem Punkt am Boden schraeg auseinander.
const stems = t.species === 'parrotia' ? 3 : 1;
const tips: [number, number][] = [];
for (let s = 0; s < stems; s++) {
const lean = stems > 1 ? 0.13 : 0;
const ox = (r() - 0.5) * 2 * lean * trunkH;
const oz = (r() - 0.5) * 2 * lean * trunkH;
const geo = new CylinderGeometry(0.05 + r() * 0.015, 0.075 + r() * 0.02, trunkH, 9, 1);
const a = (s / stems) * Math.PI * 2 + r() * 0.8;
const spread = stems > 1 ? crownR * (0.32 + r() * 0.22) : 0;
const ox = Math.cos(a) * spread, oz = Math.sin(a) * spread;
const len = Math.hypot(trunkH, Math.hypot(ox, oz));
const geo = new CylinderGeometry(0.035 + r() * 0.015, 0.07 + r() * 0.025, len, 10, 1);
const m = new Mesh(geo, RINDE);
m.position.set(t.x + ox / 2, y + trunkH / 2, t.z + oz / 2);
m.rotation.z = ox / trunkH;
m.rotation.x = -oz / trunkH;
m.rotation.z = -Math.atan2(ox, trunkH);
m.rotation.x = Math.atan2(oz, trunkH);
m.castShadow = true;
g.add(m);
tips.push([ox, oz]);
}
// Krone aus mehreren, leicht unterschiedlich getoenten Blasen
const laub = tex.laubBaum();
const blobs = 5 + Math.round(r() * 2);
for (let i = 0; i < blobs; i++) {
const rr = crownR * (0.6 + r() * 0.35);
const geo = blob(rr, 2, 0.07, i * 17 + 5);
const geo = blob(rr, 3, 0.18, i * 17 + 5, 0.82 + r() * 0.2, true, Math.max(2, rr * 4));
const shade = 1 - r() * 0.16;
const mat = foliage(
new Color(LAUB[t.species]).multiplyScalar(shade).getHex(),
0.94,
);
const mat = new MeshStandardMaterial({
map: laub.map,
normalMap: laub.normalMap ?? null,
normalScale: laub.normalScale,
color: new Color(LAUB[t.species]).multiplyScalar(shade * 1.35),
roughness: 0.95,
metalness: 0,
});
const m = new Mesh(geo, mat);
const a = (i / blobs) * Math.PI * 2 + r();
const rad = crownR * 0.3 * (i === 0 ? 0 : 1);
// Die Blasen sitzen ueber den Stammspitzen und greifen weiter aus, damit
// die Krone nicht als eine glatte Kugel liest.
const tip = tips[i % tips.length];
const rad = crownR * (i === 0 ? 0.12 : 0.42 + r() * 0.28);
m.position.set(
t.x + Math.cos(a) * rad,
y + trunkH + crownR * (0.4 + r() * 0.45),
t.z + Math.sin(a) * rad,
t.x + tip[0] * 0.8 + Math.cos(a) * rad,
y + trunkH + crownR * (0.15 + r() * 0.7),
t.z + tip[1] * 0.8 + Math.sin(a) * rad,
);
m.scale.y = 0.86;
m.castShadow = true;
m.receiveShadow = true;
g.add(m);
+16 -7
View File
@@ -393,7 +393,7 @@ export const NEIGHBOUR_BUILDINGS = [
export const TERRAIN_ZONES = [
{
id: 'garten', label: 'Garten Kellenberger',
polygon: [[-20.90, -6.60], [3.00, -6.60], [3.00, 5.95], [-20.90, 5.95]],
polygon: [[-20.90, -6.60], [3.74, -6.60], [3.74, 4.96], [-20.90, 4.96]],
fallback: -0.05,
},
{
@@ -407,13 +407,16 @@ export const TERRAIN_ZONES = [
fallback: -1.70,
},
{
// Die Grenze der Zone liegt auf den Winkelelementen bei z 4.96: dort
// faengt der Nachbar sein hoeher liegendes Terrain, dahinter steht die
// bestehende Betonmauer.
id: 'sued', label: 'Nachbargrundstück Süd',
polygon: [[-22.00, 5.95], [6.00, 5.95], [6.00, 12.00], [-22.00, 12.00]],
fallback: -0.20,
polygon: [[-22.00, 4.96], [6.00, 4.96], [6.00, 12.00], [-22.00, 12.00]],
fallback: 0.45,
},
{
id: 'ost', label: 'Wiese Ost',
polygon: [[3.00, -6.60], [9.00, -6.60], [9.00, 5.95], [3.00, 5.95]],
polygon: [[3.74, -6.60], [9.00, -6.60], [9.00, 4.96], [3.74, 4.96]],
fallback: 0.55,
},
];
@@ -430,9 +433,15 @@ export const EXTRA_SPOTS = [
{ x: -20.0, z: -12.5, y: -1.75, zone: 'zollet' },
{ x: -8.0, z: -12.5, y: -1.70, zone: 'zollet' },
{ x: 2.0, z: -11.5, y: -1.65, zone: 'zollet' },
{ x: -18.0, z: 9.0, y: -0.40, zone: 'sued' },
{ x: -6.0, z: 9.0, y: 0.10, zone: 'sued' },
{ x: 4.0, z: 9.0, y: 0.60, zone: 'sued' },
// Das Nachbargrundstueck im Sueden liegt hoeher als der Garten - genau
// deshalb stehen dort Winkelelemente und die bestehende Betonmauer. Die
// Werte setzen den Trend der Grenzkoten (-0.04 / 0.26 / 0.63) fort.
{ x: -18.0, z: 5.6, y: 0.10, zone: 'sued' },
{ x: -6.0, z: 5.6, y: 0.45, zone: 'sued' },
{ x: 4.0, z: 5.6, y: 0.85, zone: 'sued' },
{ x: -18.0, z: 9.0, y: 0.25, zone: 'sued' },
{ x: -6.0, z: 9.0, y: 0.60, zone: 'sued' },
{ x: 4.0, z: 9.0, y: 1.00, zone: 'sued' },
{ x: 7.5, z: -5.0, y: 0.10, zone: 'ost' },
{ x: 7.5, z: 0.0, y: 0.45, zone: 'ost' },
{ x: 7.5, z: 5.0, y: 0.80, zone: 'ost' },