/** * 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; }