Files
Gartenvisualisierung/src/scene/structures.ts
T
admGitAICDSandClaude Opus 5 866bedf64d
Deploy App / deploy (push) Successful in 27s
Westpodest, Osttreppe, Gartenschrank, Start in der Uebersicht
- Weg um das Haus: der westliche Arm fehlte. Vor dem Gartentor liegt
  jetzt ein Podest auf -0.14 (Plan: dreimal -0.14 bis zur Suedwestecke),
  das an der Ecke in den Weg entlang der Suedgrenze uebergeht. Gegen die
  tiefer liegende Feuerwehrzufahrt schliesst ein Betonabschluss ab.
- Westtreppe gedreht: die Trittlinien liegen im Plan parallel zur Rinne,
  die Treppe laeuft also laengs der Grenze nach Norden, vom Podest auf die
  Feuerwehrzufahrt (-0.14 auf -1.08). Die Zufahrt ist dort ausgespart.
- Osttreppe war unter Rasen verschwunden. Unter Treppen wird das Gelaende
  jetzt unter die unterste Trittkante gelegt; die Treppe reicht vom
  Gelaender bis an die Winkelelemente. Der Belagstreifen, der sie
  zugedeckt hatte, ist weg.
- Belaege und feste Ebenen legen das Gelaende auch ausserhalb der Zonen
  fest. Vorher stand am Fuss der Osttreppe ein Grashuegel ueber dem
  Podest. Podest und Stellplatte liegen gemeinsam auf -1.57.
- Gartenschrank um 90 Grad gedreht: Rueckwand zum Beerenbeet, Doppeltueren
  zum Hauszugang. Dunkler gebeizt, damit er nicht mit der Fassade
  verschmilzt.
- Schwebender Travertinblock an der Nordwestecke entfernt.
- Die App oeffnet im Uebersichtsmodus.

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

642 lines
24 KiB
TypeScript

/**
* Freistehende Bauteile: Travertinstelen, Gartenhaus, Spielhaus,
* Flammengrill, Baenkli, Maehroboter-Garage, Gartentor und die Leuchten.
* Die Mauern stehen in walls.ts.
*/
import {
BoxGeometry, BufferGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial,
PointLight, SpotLight, 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.
*/
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;
// Laengsrichtung des Fluegels in der Ebene, quer dazu die Blattdicke.
const ux = Math.cos(a), uz = Math.sin(a);
const at = (t: number): [number, number] => [p.x + ux * t, p.z + uz * t];
const parts: BufferGeometry[] = [];
const half = p.w / 2;
const top = y + p.h;
// Pfosten links und rechts, etwas hoeher als der Fluegel
for (const t of [-half - 0.05, half + 0.05]) {
const [x, z] = at(t);
parts.push(boxGeometry(x, z, 0.08, 0.08, y - 0.35, top + 0.06, p.rot, 1));
}
// Rahmen: unterer und oberer Riegel
for (const [y0, y1] of [[y + 0.08, y + 0.14], [top - 0.08, top]] as const) {
parts.push(boxGeometry(p.x, p.z, p.w, 0.035, y0, y1, p.rot, 1));
}
// Senkrechte Staebe
const n = Math.max(4, Math.round(p.w / 0.11));
for (let i = 0; i <= n; i++) {
const [x, z] = at(-half + (i * p.w) / n);
parts.push(boxGeometry(x, z, 0.016, 0.016, y + 0.08, top, p.rot, 1));
}
const m = new Mesh(mergeGeometries(parts), mat);
m.castShadow = true;
g.add(m);
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 '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;
}