Deploy App / deploy (push) Successful in 27s
- 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>
642 lines
24 KiB
TypeScript
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;
|
|
}
|