/** * 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, type MeshStandardMaterialParameters, } from 'three'; import type { Lamp, 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 // --------------------------------------------------------------------------- function gartenhaus(p: Prop, y: number): Group { const g = new Group(); const holz = material(tex.fassadeHolz(), { roughness: 0.8 }); 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); const roof = new Mesh( boxGeometry(p.x, p.z, p.w + 0.16, p.d + 0.16, y + p.h, y + p.h + 0.09, p.rot, 2), material(tex.beton(), { color: 0x6a6a66, roughness: 0.9 }), ); roof.castShadow = true; g.add(roof); // Tuer const door = new Mesh( new BoxGeometry(0.78, 1.95, 0.04), new MeshStandardMaterial({ color: 0x3b3934, roughness: 0.6 }), ); door.position.set(p.x, y + 0.98, p.z - p.d / 2 - 0.02); g.add(door); return g; } /** Spielhaus durch Zimmermann: Podest, Bretterwaende, Gelaender, Pultdach. */ 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 podestTop = y + 0.55; const podest = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, y - 0.1, podestTop, p.rot, 1.2), dunkel); podest.castShadow = true; podest.receiveShadow = true; g.add(podest); const hw = p.w / 2, hd = p.d / 2; // Rueckwand und zwei Seitenwaende, Vorderseite offen const back = new Mesh(boxGeometry(p.x, p.z + hd - 0.05, p.w, 0.09, podestTop, y + p.h, p.rot, 1.2), holz); const left = new Mesh(boxGeometry(p.x - hw + 0.05, p.z, 0.09, p.d, podestTop, y + p.h, p.rot, 1.2), holz); const right = new Mesh(boxGeometry(p.x + hw - 0.05, p.z - hd / 3, 0.09, p.d / 3, podestTop, y + p.h, p.rot, 1.2), holz); for (const m of [back, left, right]) { m.castShadow = true; m.receiveShadow = true; g.add(m); } // Bruestung an der offenen Seite const rail = new Mesh(boxGeometry(p.x, p.z - hd + 0.05, p.w, 0.07, podestTop + 0.32, podestTop + 0.45, p.rot, 1.2), holz); rail.castShadow = true; g.add(rail); const roof = new Mesh( boxGeometry(p.x, p.z, p.w + 0.26, p.d + 0.26, y + p.h, y + p.h + 0.09, p.rot, 2), material(tex.fassadeHolz(), { color: 0x7d6549, roughness: 0.9 }), ); 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); for (const s of [-1, 1]) { const m = new Mesh( boxGeometry(p.x, p.z + (s * (p.d / 2 - 0.22)), 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; } function gartentor(p: Prop, y: number): Group { const g = new Group(); const mat = new MeshStandardMaterial({ color: 0x4a4a4c, roughness: 0.5, metalness: 0.4 }); const frame = new Mesh(boxGeometry(p.x, p.z, p.w, 0.05, y, y + p.h, p.rot, 1), mat); g.add(frame); for (let i = 1; i < 6; i++) { const bar = new Mesh( boxGeometry(p.x - p.w / 2 + (i * p.w) / 6, p.z, 0.03, 0.03, y, y + p.h, p.rot, 1), mat, ); g.add(bar); } g.children.forEach((c) => { (c as Mesh).castShadow = true; }); 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 = 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[] = []; 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); 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; }, }; }