Files
Gartenvisualisierung/src/scene/structures.ts
T
admGitAICDSandClaude Opus 5 1ac99b1660
Deploy App / deploy (push) Successful in 27s
Nordgrenze zum Nachbarn, Torbogen, einfacher Rundgang
Nordgrenze
- Sitzmauer und Travertinmauer waren vertauscht. Der Plan zeichnet aussen,
  auf der Terrasse der Nachbarn, die "Sitzmauer" (0.55 tief, rund 40 cm
  hoch: Terrasse -1.77, Krone -1.35) und direkt dahinter die
  "Travertinmauer", die jetzt bis auf das Niveau der Nachbarn hinunter-
  reicht - sie sehen Naturstein statt Beton.
- Terrasse der Nachbarn fest auf -1.77.
- Schotterwiese (Flaechen 4 und 5) eben: quer kein Hang mehr, laengs folgt
  sie der Feuerwehrzufahrt. Unter der Ligusterhecke fassen Winkelelemente
  den Garten der Nachbarn, so wie der Plan sie dort beschriftet. Wiesen mit
  fester Ebene werden jetzt plan gerendert statt dem Raster zu folgen.
- Eingang fuer die Nachbarn: Travertinstufe in der Luecke zwischen Hecke
  und Mauer ("Stufe" im Plan), darueber ein Torbogen aus Cortenstahl mit
  Kletterrose. Kein Tor.

Rundgang
- Klick auf den Boden laeuft dorthin, Ziehen dreht den Blick. Kein
  Mausfang mehr.
- Weiches Anfahren und Bremsen, gedaempftes Nachfuehren bei Stufen statt
  Fall-und-Aufprall, Rutschen an Hindernissen.
- Travertinmauern mit fester Krone sind begehbar; man steigt ueber die
  Sitzmauer vom Sitzplatz auf den Rasen, statt springen zu muessen.
- Gartentor oeffnet sich beim Naehertreten und schliesst hinter einem, E
  uebersteuert. Schwelle unter dem Tor schliesst die Luecke zum Podest.
- Blickrichtung blieb beim Wechsel Uebersicht/Rundgang nicht erhalten
  (Vorzeichenfehler in yawDegrees).
- mergeGeometries loest indizierte Geometrien vorher auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:50:44 +02:00

748 lines
29 KiB
TypeScript

/**
* Freistehende Bauteile: Travertinstelen, Gartenhaus, Spielhaus,
* Flammengrill, Baenkli, Maehroboter-Garage, Gartentor, Torbogen und die Leuchten.
* Die Mauern stehen in walls.ts.
*/
import {
BoxGeometry, BufferGeometry, CatmullRomCurve3, CylinderGeometry, Group, IcosahedronGeometry,
Mesh, MeshStandardMaterial, PointLight, SpotLight, TubeGeometry, Vector3,
type MeshStandardMaterialParameters,
} from 'three';
import type { Lamp, Linear, Prop, Stele } from '../site';
import { boxGeometry, mergeGeometries } 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,
});
}
/**
* Travertinstelen: aufrecht gestellte Platten, 50 cm lang und 6 bis 9 cm dick.
* Sie stehen im Dekorkiesbett und wechseln sich mit den Eibengruppen ab - das
* ist der Sichtschutz des Gartens. Hoehe je nach Seite 1.50 oder 1.80 m.
*/
export function buildStelen(stelen: Stele[], height: (x: number, z: number) => number): Group {
const group = new Group();
group.name = 'Travertinstelen';
const stein = tex.travertinStele();
const mat = material(stein, { roughness: 0.62 });
const parts: BufferGeometry[] = [];
for (const s of stelen) {
const y0 = height(s.x, s.z) - 0.25;
// 2.5 cm Stossfuge: die Dreiergruppen des Plans sollen als einzelne
// Platten lesbar bleiben und nicht zu einer glatten Wand verschmelzen.
const len = s.len - 0.025;
const w = s.rot === 0 ? len : s.t;
const d = s.rot === 0 ? s.t : len;
parts.push(boxGeometry(s.x, s.z, w, d, y0, y0 + 0.25 + s.height, 0, stein.tile));
}
if (parts.length) {
const m = new Mesh(mergeGeometries(parts), mat);
m.castShadow = true;
m.receiveShadow = true;
group.add(m);
}
return group;
}
/**
* Schwebende Travertinstufe: dicke Platte, die frei auskragt. Der Schattenfuge
* darunter verdankt sie ihre Wirkung, deshalb sitzt sie auf einem deutlich
* schmaleren Sockel.
*/
function schwebendeStufe(p: Prop, y: number): Group {
const g = new Group();
const belag = tex.travertinSlab();
const platte = material(belag, { roughness: 0.68 });
const top = p.top ?? y;
const slab = new Mesh(
boxGeometry(p.x, p.z, p.w, p.d, top - p.h, top, p.rot, belag.tile), platte,
);
slab.castShadow = true;
slab.receiveShadow = true;
g.add(slab);
// Unterbau, ringsum 12 cm eingezogen: erzeugt die Schattenfuge
const sockel = new Mesh(
boxGeometry(p.x, p.z, p.w - 0.24, p.d - 0.24, top - p.h - 0.30, top - p.h - 0.01, p.rot, 1),
new MeshStandardMaterial({ color: 0x3a3833, roughness: 0.9 }),
);
sockel.receiveShadow = true;
g.add(sockel);
return g;
}
// ---------------------------------------------------------------------------
// Freistehende Objekte
// ---------------------------------------------------------------------------
/**
* Gartenschrank: schmaler Schrank mit Doppeltuer und leicht geneigtem Deckel.
* Der Plan reserviert an dieser Stelle 1.8 x 1.8 m fuer ein Gartenhaus; gebaut
* wird ein Schrank von 1.80 x 0.50 m, rund 2.00 m hoch, mit dem Ruecken in der
* Ecke und den Tueren zum Garten.
*/
function gartenhaus(p: Prop, y: number): Group {
const g = new Group();
// Dunkler gebeizt als die Fassade, damit der Schrank nicht mit dem Haus verschmilzt.
const holz = material(tex.fassadeHolz(), { color: 0x857868, roughness: 0.8 });
const dunkel = new MeshStandardMaterial({ color: 0x3b3934, roughness: 0.62 });
const metall = new MeshStandardMaterial({ color: 0x55565a, roughness: 0.45, metalness: 0.6 });
const a = (p.rot * Math.PI) / 180;
// u laeuft entlang p.w, v entlang p.d - so wie in boxGeometry.
const ux = Math.cos(a), uz = Math.sin(a);
const vx = -Math.sin(a), vz = Math.cos(a);
const body = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, y, y + p.h, p.rot, 1.2), holz);
body.castShadow = true; body.receiveShadow = true;
g.add(body);
// Deckel mit leichtem Ueberstand
const roof = new Mesh(
boxGeometry(p.x + ux * 0.03, p.z + uz * 0.03, p.w + 0.12, p.d + 0.14, y + p.h, y + p.h + 0.06, p.rot, 2),
material(tex.beton(), { color: 0x6a6a66, roughness: 0.9 }),
);
roof.castShadow = true;
g.add(roof);
// Zwei Tuerfluegel auf der Laengsseite, leicht vor der Schrankfront
const front = p.w / 2 + 0.015;
const parts: BufferGeometry[] = [];
for (const s of [-1, 1]) {
const off = s * (p.d / 4);
parts.push(boxGeometry(
p.x - ux * front + vx * off, p.z - uz * front + vz * off,
0.03, p.d / 2 - 0.03, y + 0.06, y + p.h - 0.06, p.rot, 1.2,
));
}
const doors = new Mesh(mergeGeometries(parts), dunkel);
doors.castShadow = true;
g.add(doors);
// Zwei schlanke Griffe
const griffe: BufferGeometry[] = [];
for (const s of [-1, 1]) {
const off = s * 0.06;
griffe.push(boxGeometry(
p.x - ux * (front + 0.035) + vx * off, p.z - uz * (front + 0.035) + vz * off,
0.025, 0.025, y + p.h * 0.45, y + p.h * 0.62, p.rot, 1,
));
}
g.add(new Mesh(mergeGeometries(griffe), metall));
return g;
}
function spielhaus(p: Prop, y: number): Group {
const g = new Group();
const holz = material(tex.fassadeHolz(), { color: 0xd8b98c, roughness: 0.85 });
const dunkel = material(tex.fassadeHolz(), { color: 0xa8895f, roughness: 0.88 });
const dach = material(tex.fassadeHolz(), { color: 0x7d6549, roughness: 0.9 });
const deck = p.top ?? y + 0.9; // Oberkante Podestboden
const hw = p.w / 2, hd = p.d / 2;
const hutW = 1.01; // Huette an der Ostseite
const hutX = p.x + hw - hutW / 2;
const plattW = p.w - hutW; // offene Plattform im Westen
const plattX = p.x - hw + plattW / 2;
// Unter der Huette steht ein Betonklotz - im Plan das graue Rechteck von
// 1.01 x 2.70 m unter dem Podest. Die offene Plattform daneben steht auf
// zwei schlanken Stuetzen.
const klotz = new Mesh(
boxGeometry(hutX, p.z, hutW, p.d, y - 0.45, deck - 0.06, p.rot, 2),
material(tex.beton(), { color: 0x93918c, roughness: 0.92 }),
);
klotz.name = 'Betonsockel Spielhaus';
klotz.castShadow = true; klotz.receiveShadow = true;
g.add(klotz);
const trag: BufferGeometry[] = [];
for (const dz of [-hd + 0.14, hd - 0.14]) {
trag.push(boxGeometry(p.x - hw + 0.14, p.z + dz, 0.11, 0.11, y - 0.35, deck - 0.06, 0, 1));
}
// Podestboden
trag.push(boxGeometry(p.x, p.z, p.w, p.d, deck - 0.06, deck, p.rot, 1.2));
const podest = new Mesh(mergeGeometries(trag), dunkel);
podest.castShadow = true; podest.receiveShadow = true;
g.add(podest);
// Huette: Rueckwand, zwei Seitenwaende, Vorderseite offen
const wand: BufferGeometry[] = [];
wand.push(boxGeometry(hutX, p.z + hd - 0.05, hutW, 0.09, deck, deck + p.h, p.rot, 1.2));
wand.push(boxGeometry(hutX, p.z - hd + 0.05, hutW, 0.09, deck, deck + p.h, p.rot, 1.2));
wand.push(boxGeometry(hutX + hutW / 2 - 0.05, p.z, 0.09, p.d, deck, deck + p.h, p.rot, 1.2));
// Halbe Wand zur Plattform hin, damit ein Durchgang bleibt
wand.push(boxGeometry(hutX - hutW / 2 + 0.05, p.z + hd / 2, 0.09, p.d / 2, deck, deck + p.h, p.rot, 1.2));
const huette = new Mesh(mergeGeometries(wand), holz);
huette.castShadow = true; huette.receiveShadow = true;
g.add(huette);
// Bruestung um die offene Plattform, Pfosten und Handlauf
const gel: BufferGeometry[] = [];
const gy0 = deck + 0.62, gy1 = deck + 0.72;
gel.push(boxGeometry(plattX, p.z + hd - 0.04, plattW, 0.06, gy0, gy1, p.rot, 1));
gel.push(boxGeometry(plattX, p.z - hd + 0.04, plattW, 0.06, gy0, gy1, p.rot, 1));
gel.push(boxGeometry(p.x - hw + 0.04, p.z, 0.06, p.d, gy0, gy1, p.rot, 1));
for (let i = 0; i <= 5; i++) {
const zz = p.z - hd + 0.1 + (i * (p.d - 0.2)) / 5;
gel.push(boxGeometry(p.x - hw + 0.04, zz, 0.05, 0.05, deck, gy1, 0, 1));
}
const bruestung = new Mesh(mergeGeometries(gel), holz);
bruestung.castShadow = true;
g.add(bruestung);
// Leiter an der Westseite
const leiter: BufferGeometry[] = [];
const lx = p.x - hw - 0.16;
for (const dz of [-0.24, 0.24]) {
leiter.push(boxGeometry(lx, p.z + dz, 0.07, 0.07, y, deck + 0.28, 0, 1));
}
const stufen = Math.max(3, Math.round((deck - y) / 0.26));
for (let i = 1; i <= stufen; i++) {
leiter.push(boxGeometry(lx, p.z, 0.05, 0.55, y + (i * (deck - y)) / stufen - 0.03,
y + (i * (deck - y)) / stufen, 0, 1));
}
const l = new Mesh(mergeGeometries(leiter), dunkel);
l.castShadow = true;
g.add(l);
// Pultdach ueber der Huette
const roof = new Mesh(
boxGeometry(hutX, p.z, hutW + 0.3, p.d + 0.26, deck + p.h, deck + p.h + 0.09, p.rot, 2),
dach,
);
roof.castShadow = true;
g.add(roof);
return g;
}
/**
* Flammengrill nach dem Referenzbild des Planers: anthrazitfarbener Stahlkubus,
* unten ein offenes Holzfach, oben ein perforiertes Band, in der Deckplatte die
* runde Feueroeffnung.
*/
function flammengrill(p: Prop, y: number): Group {
const g = new Group();
const stahl = new MeshStandardMaterial({ color: 0x4a4c50, roughness: 0.55, metalness: 0.45 });
const dunkel = new MeshStandardMaterial({ color: 0x25272a, roughness: 0.85, metalness: 0.2 });
const deckel = new MeshStandardMaterial({ color: 0x3f4144, roughness: 0.38, metalness: 0.68 });
const scheiter = material(tex.holzscheiter(), { roughness: 0.92 });
const hw = p.w / 2, hd = p.d / 2;
const holzTop = y + p.h * 0.42; // Oberkante Holzfach
const bandTop = y + p.h - 0.04; // Unterkante Deckplatte
const bandBot = bandTop - 0.16; // perforiertes Kopfband
// Holzfach: sichtbare Scheiter zwischen zwei Stahlwangen
const holz = new Mesh(boxGeometry(p.x, p.z, p.w - 0.06, p.d - 0.06, y + 0.03, holzTop, p.rot, 0.5), scheiter);
holz.receiveShadow = true;
g.add(holz);
for (const s of [-1, 1]) {
const wange = new Mesh(boxGeometry(p.x + s * hw, p.z, 0.04, p.d, y, holzTop, p.rot, 1), stahl);
wange.castShadow = true;
g.add(wange);
}
const rueck = new Mesh(boxGeometry(p.x, p.z + hd, p.w, 0.04, y, holzTop, p.rot, 1), stahl);
g.add(rueck);
// Geschlossener Korpus darueber
const body = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, holzTop, bandBot, p.rot, 1), stahl);
body.castShadow = true; body.receiveShadow = true;
g.add(body);
// Perforiertes Band: einzelne Loecher waeren zu teuer, deshalb ein dunkleres,
// leicht zurueckversetztes Band mit rauer Oberflaeche
const band = new Mesh(boxGeometry(p.x, p.z, p.w - 0.01, p.d - 0.01, bandBot, bandTop, p.rot, 1), dunkel);
band.castShadow = true;
g.add(band);
// Deckplatte mit runder Feueroeffnung
const plate = new Mesh(boxGeometry(p.x, p.z, p.w + 0.05, p.d + 0.05, bandTop, bandTop + 0.04, p.rot, 1), deckel);
plate.castShadow = true; plate.receiveShadow = true;
g.add(plate);
const loch = new Mesh(new CylinderGeometry(p.w * 0.19, p.w * 0.19, 0.06, 24), dunkel);
loch.position.set(p.x, bandTop + 0.035, p.z);
g.add(loch);
return g;
}
function bank(p: Prop, y: number): Group {
const g = new Group();
const belag = tex.travertinMauer();
const stein = material(belag, { roughness: 0.65 });
const seat = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, y + p.h - 0.09, y + p.h, p.rot, belag.tile), stein);
seat.castShadow = true; seat.receiveShadow = true;
g.add(seat);
// Die Fuesse sitzen unter den Enden der Sitzplatte. Der Versatz muss der
// gedrehten v-Achse folgen - sonst stehen sie quer daneben.
const a = (p.rot * Math.PI) / 180;
const vx = -Math.sin(a), vz = Math.cos(a);
for (const s of [-1, 1]) {
const off = s * (p.d / 2 - 0.22);
const m = new Mesh(
boxGeometry(p.x + vx * off, p.z + vz * off, p.w - 0.08, 0.14, y, y + p.h - 0.09, p.rot, belag.tile),
stein,
);
m.castShadow = true;
g.add(m);
}
return g;
}
/** Garage fuer den Maehroboter: flaches Pultdach auf zwei Seitenwangen. */
function roboterGarage(p: Prop, y: number): Group {
const g = new Group();
const holz = material(tex.fassadeHolz(), { color: 0x9aa08e, roughness: 0.82 });
const dach = new MeshStandardMaterial({ color: 0x4e5348, roughness: 0.6, metalness: 0.2 });
const hw = p.w / 2;
for (const s of [-1, 1]) {
const m = new Mesh(boxGeometry(p.x + s * (hw - 0.03), p.z, 0.06, p.d, y, y + p.h, p.rot, 1), holz);
m.castShadow = true; m.receiveShadow = true;
g.add(m);
}
const back = new Mesh(boxGeometry(p.x, p.z + p.d / 2 - 0.03, p.w, 0.06, y, y + p.h, p.rot, 1), holz);
back.castShadow = true;
g.add(back);
const roof = new Mesh(
boxGeometry(p.x, p.z, p.w + 0.1, p.d + 0.12, y + p.h, y + p.h + 0.05, p.rot, 1), dach,
);
roof.castShadow = true;
g.add(roof);
return g;
}
/** Wasserhahn: verzinkter Standrohrsockel mit Auslauf. */
function wasserhahn(p: Prop, y: number): Group {
const g = new Group();
const metal = new MeshStandardMaterial({ color: 0x8d9095, roughness: 0.38, metalness: 0.8 });
const post = new Mesh(new CylinderGeometry(0.045, 0.05, p.h, 12), metal);
post.position.set(p.x, y + p.h / 2, p.z);
post.castShadow = true;
g.add(post);
const spout = new Mesh(new CylinderGeometry(0.016, 0.016, 0.16, 8), metal);
spout.rotation.z = Math.PI / 2;
spout.position.set(p.x + 0.08, y + p.h - 0.08, p.z);
g.add(spout);
return g;
}
/** Schlaufschacht D = 30 cm: buendiger Deckel im Belag. */
function schachtdeckel(p: Prop, y: number): Group {
const g = new Group();
const metal = new MeshStandardMaterial({ color: 0x6f6c66, roughness: 0.55, metalness: 0.6 });
const lid = new Mesh(new CylinderGeometry(p.w / 2, p.w / 2, 0.03, 24), metal);
lid.position.set(p.x, y + 0.012, p.z);
lid.receiveShadow = true;
g.add(lid);
return g;
}
/**
* Gartentor: Stabgitterfluegel aus Stahl. Zwei Pfosten, umlaufender Rahmen aus
* Flachstahl und senkrechte Staebe im Abstand von rund 11 cm - das Tor bleibt
* durchsichtig, es ist kein geschlossenes Blatt.
*/
/**
* Gartentor mit drehbarem Fluegel. Die Pfosten stehen fest, der Fluegel haengt
* in einer eigenen Gruppe am Scharnier - so kann der Rundgang ihn oeffnen und
* schliessen. Er schlaegt nach aussen auf das Podest auf, weil innen gleich
* die beiden Stufen liegen.
*/
function gartentor(p: Prop, y: number): Group {
const g = new Group();
const mat = new MeshStandardMaterial({ color: 0x43464a, roughness: 0.45, metalness: 0.55 });
const a = (p.rot * Math.PI) / 180;
const ux = Math.cos(a), uz = Math.sin(a);
const half = p.w / 2;
const top = y + p.h;
// Pfosten links und rechts, etwas hoeher als der Fluegel
const pfosten: BufferGeometry[] = [];
for (const s of [-half - 0.05, half + 0.05]) {
pfosten.push(boxGeometry(p.x + ux * s, p.z + uz * s, 0.08, 0.08, y - 0.35, top + 0.06, p.rot, 1));
}
const posts = new Mesh(mergeGeometries(pfosten), mat);
posts.castShadow = true;
g.add(posts);
// Fluegel in Scharnier-Koordinaten: er laeuft vom Scharnier aus in lokal -X.
const teile: BufferGeometry[] = [];
for (const [y0, y1] of [[0.08, 0.14], [p.h - 0.08, p.h]] as const) {
teile.push(boxGeometry(-half, 0, p.w - 0.02, 0.035, y0, y1, 0, 1));
}
for (const x of [-0.03, -p.w + 0.03]) teile.push(boxGeometry(x, 0, 0.04, 0.04, 0.08, p.h, 0, 1));
const n = Math.max(4, Math.round(p.w / 0.11));
for (let i = 1; i < n; i++) {
teile.push(boxGeometry(-(i * p.w) / n, 0, 0.016, 0.016, 0.08, p.h, 0, 1));
}
const leaf = new Mesh(mergeGeometries(teile), mat);
leaf.castShadow = true;
// Scharnier am +u-Ende. Lokal -X zeigt im geschlossenen Zustand nach -u:
// (-cos th, sin th) = (-ux, -uz) => th = atan2(-uz, ux).
const pivot = new Group();
pivot.name = 'Torfluegel';
pivot.position.set(p.x + ux * half, y, p.z + uz * half);
const zu = Math.atan2(-uz, ux);
pivot.rotation.y = zu;
pivot.add(leaf);
g.add(pivot);
g.userData.fluegel = pivot;
g.userData.zu = zu;
g.userData.offen = zu + 1.65;
return g;
}
/**
* Torbogen fuer den Eingang der Nachbarn: zwei Baender aus Cortenstahl, die
* als Pfosten aufsteigen und oben einen Halbkreis schlagen, dazwischen
* Querstaebe. An einer Seite rankt eine Kletterrose hoch. Kein Tor - der
* Durchgang bleibt offen.
*/
function torbogen(p: Prop, y: number): Group {
const g = new Group();
const corten = new MeshStandardMaterial({ color: 0x8b4a27, roughness: 0.78, metalness: 0.25 });
const a = (p.rot * Math.PI) / 180;
// u: quer zum Durchgang (Spannweite), v: laengs des Durchgangs (Tiefe)
const ux = Math.cos(a), uz = Math.sin(a);
const vx = -Math.sin(a), vz = Math.cos(a);
const r = p.w / 2;
const P = (u: number, yy: number, v: number) =>
new Vector3(p.x + ux * u + vx * v, yy, p.z + uz * u + vz * v);
const baender: BufferGeometry[] = [];
for (const v of [-p.d / 2, p.d / 2]) {
const pts: Vector3[] = [P(-r, y - 0.2, v), P(-r, y + p.h * 0.5, v), P(-r, y + p.h, v)];
for (let i = 1; i < 16; i++) {
const w = Math.PI - (i / 16) * Math.PI;
pts.push(P(Math.cos(w) * r, y + p.h + Math.sin(w) * r, v));
}
pts.push(P(r, y + p.h, v), P(r, y + p.h * 0.5, v), P(r, y - 0.2, v));
const kurve = new CatmullRomCurve3(pts, false, 'centripetal');
baender.push(new TubeGeometry(kurve, 96, 0.026, 6, false));
}
// Querstaebe laengs des Bogens
for (let i = 0; i <= 8; i++) {
const w = Math.PI - (i / 8) * Math.PI;
const u = Math.cos(w) * r, yy = y + p.h + Math.sin(w) * r;
const kurve = new CatmullRomCurve3([P(u, yy, -p.d / 2), P(u, yy, p.d / 2)]);
baender.push(new TubeGeometry(kurve, 2, 0.012, 5, false));
}
for (const u of [-r, r]) {
for (const yy of [y + 0.45, y + 0.95, y + 1.45]) {
const kurve = new CatmullRomCurve3([P(u, yy, -p.d / 2), P(u, yy, p.d / 2)]);
baender.push(new TubeGeometry(kurve, 2, 0.012, 5, false));
}
}
const bogen = new Mesh(mergeGeometries(baender), corten);
bogen.castShadow = true;
g.add(bogen);
// Kletterrose an einer Seite: viele kleine, unregelmaessige Laubbueschel den
// Pfosten hinauf und ein Stueck ueber den Bogen, dazwischen einzelne Blueten.
const laub = new MeshStandardMaterial({ color: 0x35592a, roughness: 0.92, flatShading: true });
const bluete = new MeshStandardMaterial({ color: 0xd47a8e, roughness: 0.7 });
const laubGeo = new IcosahedronGeometry(1, 0);
let seed = 7;
const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
const ranke: [number, number][] = [];
for (let i = 0; i < 14; i++) ranke.push([-r, y + 0.05 + (i / 13) * p.h]);
for (let i = 1; i <= 7; i++) {
const w = Math.PI - (i / 16) * Math.PI;
ranke.push([Math.cos(w) * r, y + p.h + Math.sin(w) * r]);
}
const blaetter: BufferGeometry[] = [];
const blueten: BufferGeometry[] = [];
for (const [u, yy] of ranke) {
const n = 3 + Math.floor(rnd() * 3);
for (let k = 0; k < n; k++) {
const geo = laubGeo.clone();
geo.scale(0.05 + rnd() * 0.05, 0.04 + rnd() * 0.04, 0.05 + rnd() * 0.05);
geo.rotateY(rnd() * 6.28);
geo.rotateX((rnd() - 0.5) * 0.8);
const q = P(u + (rnd() - 0.5) * 0.16, yy + (rnd() - 0.5) * 0.14, (rnd() - 0.5) * p.d * 1.1);
geo.translate(q.x, q.y, q.z);
blaetter.push(geo);
if (rnd() < 0.22) {
const bg = laubGeo.clone();
bg.scale(0.028, 0.024, 0.028);
bg.translate(q.x + (rnd() - 0.5) * 0.06, q.y + 0.04, q.z + (rnd() - 0.5) * 0.06);
blueten.push(bg);
}
}
}
const rose = new Mesh(mergeGeometries(blaetter), laub);
rose.castShadow = true;
g.add(rose);
if (blueten.length) g.add(new Mesh(mergeGeometries(blueten), bluete));
return g;
}
export function buildProps(props: Prop[], height: (x: number, z: number) => number): Group {
const group = new Group();
group.name = 'Objekte';
for (const p of props) {
const y = p.base ?? height(p.x, p.z);
let obj: Group | Mesh;
switch (p.type) {
case 'gartenhaus': obj = gartenhaus(p, y); break;
case 'spielhaus': obj = spielhaus(p, y); break;
case 'flammengrill': obj = flammengrill(p, y); break;
case 'bank': obj = bank(p, y); break;
case 'tor': obj = gartentor(p, y); break;
case 'torbogen': obj = torbogen(p, y); break;
case 'stufe': obj = schwebendeStufe(p, y); break;
case 'roboter': obj = roboterGarage(p, y); break;
case 'wasserhahn': obj = wasserhahn(p, y); break;
case 'schacht': obj = schachtdeckel(p, y); break;
default: continue;
}
obj.name = p.id;
obj.userData.note = p.note;
group.add(obj);
}
return group;
}
// ---------------------------------------------------------------------------
// Beleuchtung im Garten
// ---------------------------------------------------------------------------
export interface GardenLights {
group: Group;
/** Schaltet die Leuchten je nach Tageszeit. */
setOn(on: boolean): void;
}
export function buildLamps(lamps: Lamp[], height: (x: number, z: number) => number): GardenLights {
const group = new Group();
group.name = 'Beleuchtung';
const bodyMat = new MeshStandardMaterial({ color: 0x33332f, roughness: 0.55, metalness: 0.3 });
const glowMat = new MeshStandardMaterial({
color: 0xffd9a0, emissive: 0xffc26e, emissiveIntensity: 0, roughness: 0.4,
});
const pointLights: PointLight[] = [];
const spots: SpotLight[] = [];
for (const l of lamps) {
const y = height(l.x, l.z);
const g = new Group();
if (l.kind === 'poller') {
const post = new Mesh(new BoxGeometry(0.09, 0.62, 0.09), bodyMat);
post.position.set(l.x, y + 0.31, l.z);
post.castShadow = true;
g.add(post);
const head = new Mesh(new BoxGeometry(0.11, 0.06, 0.11), glowMat);
head.position.set(l.x, y + 0.6, l.z);
g.add(head);
} else {
const ring = new Mesh(new CylinderGeometry(0.07, 0.07, 0.03, 16), bodyMat);
ring.position.set(l.x, y + 0.015, l.z);
g.add(ring);
const lens = new Mesh(new CylinderGeometry(0.05, 0.05, 0.035, 16), glowMat);
lens.position.set(l.x, y + 0.025, l.z);
g.add(lens);
}
const pl = new PointLight(0xffc98a, 0, 6, 2);
pl.position.set(l.x, y + (l.kind === 'poller' ? 0.6 : 0.1), l.z);
pointLights.push(pl);
g.add(pl);
if (l.kind === 'boden') {
// Bodenleuchten sind Strahler: sie leuchten die Travertinstelen und die
// Ziergraeser darueber von unten an. Ohne Schatten, sonst wird die
// Abendszene mit 11 Strahlern zu teuer.
const sp = new SpotLight(0xffd6a2, 0, 5.5, 0.55, 0.55, 1.4);
sp.position.set(l.x, y + 0.04, l.z);
sp.target.position.set(l.x, y + 3.0, l.z);
g.add(sp);
g.add(sp.target);
spots.push(sp);
}
group.add(g);
}
return {
group,
setOn(on: boolean) {
glowMat.emissiveIntensity = on ? 1.6 : 0;
for (const pl of pointLights) pl.intensity = on ? 2.2 : 0;
for (const sp of spots) sp.intensity = on ? 5.0 : 0;
},
};
}
// ---------------------------------------------------------------------------
// Lineare Bauteile: Treppen mit Rampe, Gelaender, Entwaesserungsrinnen
// ---------------------------------------------------------------------------
/**
* Winkel fuer boxGeometry, so dass die lokale v-Achse (Tiefe d) entlang der
* Richtung (dx, dz) liegt. Aus world = (cx + u*cos - v*sin, y, cz + u*sin + v*cos)
* folgt v-Achse = (-sin, cos), also sin = -dx und cos = dz.
*/
function axisAngle(dx: number, dz: number): number {
return (Math.atan2(-dx, dz) * 180) / Math.PI;
}
/**
* Blockstufentreppe. Der Pfad laeuft von oben nach unten; jede Stufe ist ein
* Travertinblock, dessen Trittflaeche eine Steigung tiefer liegt als die
* vorige. Die Bloecke reichen bis unter das untere Niveau, damit die Treppe
* auch von der Seite geschlossen wirkt.
*/
function blockTreppe(f: Linear): Group {
const g = new Group();
const belag = tex.travertinSlab();
const mat = material(belag, { roughness: 0.6 });
const [[x0, z0], [x1, z1]] = [f.path[0], f.path[f.path.length - 1]];
const dx = x1 - x0, dz = z1 - z0;
const len = Math.hypot(dx, dz) || 1;
const rot = axisAngle(dx / len, dz / len);
const steps = f.steps ?? 4;
const width = f.width ?? 1.2;
const fromY = f.fromY ?? 0, toY = f.toY ?? -1;
const riser = (fromY - toY) / steps;
const tread = len / steps;
const parts: BufferGeometry[] = [];
for (let i = 0; i < steps; i++) {
const t = (i + 0.5) / steps;
const top = fromY - (i + 1) * riser;
parts.push(boxGeometry(
x0 + dx * t, z0 + dz * t, width, tread + 0.01,
toY - 0.45, top, rot, belag.tile,
));
}
const m = new Mesh(mergeGeometries(parts), mat);
m.castShadow = true;
m.receiveShadow = true;
g.add(m);
return g;
}
/**
* Gelaender aus Stahlrohr: Pfosten alle rund 1.1 m, Handlauf und Knieholm
* folgen dem Gefaelle des Laufs.
*/
function gelaender(f: Linear, height: (x: number, z: number) => number): Group {
const g = new Group();
const mat = new MeshStandardMaterial({ color: 0x4c4f52, roughness: 0.42, metalness: 0.65 });
const [[x0, z0], [x1, z1]] = [f.path[0], f.path[f.path.length - 1]];
const dx = x1 - x0, dz = z1 - z0;
const len = Math.hypot(dx, dz) || 1;
const rot = axisAngle(dx / len, dz / len);
const fromY = f.fromY ?? 0, toY = f.toY ?? -1;
const h = f.height ?? 0.95;
const parts: BufferGeometry[] = [];
const n = Math.max(2, Math.round(len / 1.1) + 1);
for (let i = 0; i < n; i++) {
const t = i / (n - 1);
const y = fromY + (toY - fromY) * t;
const px = x0 + dx * t, pz = z0 + dz * t;
// Der Pfosten muss bis ins Gelaende reichen, sonst schwebt er neben der
// Treppe in der Luft.
const fuss = Math.min(y, height(px, pz)) - 0.3;
parts.push(boxGeometry(px, pz, 0.06, 0.06, fuss, y + h, rot, 1));
}
// Holme: als flache Bloecke ueber die ganze Laenge, um die Neigung gelegt.
for (const [level, thick] of [[h, 0.055], [h * 0.52, 0.04]] as const) {
const yA = fromY + level, yB = toY + level;
const rise = yA - yB;
const holmLen = Math.hypot(len, rise);
const geo = boxGeometry(0, 0, thick, holmLen, -thick / 2, thick / 2, 0, 1);
geo.rotateX(Math.atan2(rise, len));
geo.rotateY((rot * Math.PI) / 180);
geo.translate((x0 + x1) / 2, (yA + yB) / 2, (z0 + z1) / 2);
parts.push(geo);
}
const m = new Mesh(mergeGeometries(parts), mat);
m.castShadow = true;
g.add(m);
return g;
}
/**
* Entwaesserungsrinne: schmaler Kanal im Belag mit buendigem Stahlrost.
*/
function rinne(f: Linear, height: (x: number, z: number) => number): Group {
const g = new Group();
const beton = new MeshStandardMaterial({ color: 0x8e8a84, roughness: 0.85 });
const rost = new MeshStandardMaterial({ color: 0x6a6d70, roughness: 0.4, metalness: 0.7 });
const w = f.width ?? 0.15;
for (let i = 1; i < f.path.length; i++) {
const [ax, az] = f.path[i - 1], [bx, bz] = f.path[i];
const dx = bx - ax, dz = bz - az;
const len = Math.hypot(dx, dz) || 1;
const rot = axisAngle(dx / len, dz / len);
const cx = (ax + bx) / 2, cz = (az + bz) / 2;
const y = height(cx, cz);
const kasten = new Mesh(boxGeometry(cx, cz, w + 0.09, len, y - 0.30, y - 0.015, rot, 1), beton);
kasten.receiveShadow = true;
g.add(kasten);
// Rost in Segmenten von 50 cm, damit die Stossfugen sichtbar bleiben.
const seg = Math.max(1, Math.round(len / 0.5));
for (let s = 0; s < seg; s++) {
const t = (s + 0.5) / seg;
const r = new Mesh(
boxGeometry(ax + dx * t, az + dz * t, w, len / seg - 0.012, y - 0.035, y + 0.004, rot, 1),
rost,
);
r.receiveShadow = true;
g.add(r);
}
}
return g;
}
/**
* Trittflaechen einer Blockstufentreppe. Der Rundgang braucht sie, damit man
* die Treppen wirklich hinauf- und hinuntergehen kann - sonst waere die Treppe
* fuer den Spieler nur ein Hindernis im Gelaende.
*/
export function treppeTritte(f: Linear): { cx: number; cz: number; hw: number; hd: number; rot: number; top: number }[] {
const [[x0, z0], [x1, z1]] = [f.path[0], f.path[f.path.length - 1]];
const dx = x1 - x0, dz = z1 - z0;
const len = Math.hypot(dx, dz) || 1;
const steps = f.steps ?? 4;
const width = f.width ?? 1.2;
const fromY = f.fromY ?? 0, toY = f.toY ?? -1;
const riser = (fromY - toY) / steps;
const tread = len / steps;
const out = [];
for (let i = 0; i < steps; i++) {
const s = (i + 0.5) / steps;
out.push({
cx: x0 + dx * s, cz: z0 + dz * s,
// Wie in obstacleFromSegment: hw laeuft laengs der Achse, hd quer dazu.
hw: tread / 2, hd: width / 2,
rot: Math.atan2(dz, dx),
top: fromY - (i + 1) * riser,
});
}
return out;
}
/** Baut Treppen, Gelaender und Rinnen aus dem Plan. */
export function buildLinear(features: Linear[], height: (x: number, z: number) => number): Group {
const group = new Group();
group.name = 'Treppen, Gelaender und Rinnen';
for (const f of features) {
let obj: Group;
switch (f.kind) {
case 'treppe': obj = blockTreppe(f); break;
case 'gelaender': obj = gelaender(f, height); break;
case 'rinne': obj = rinne(f, height); break;
default: continue;
}
obj.name = f.id;
obj.userData.note = f.note;
group.add(obj);
}
return group;
}