Files
Gartenvisualisierung/src/scene/structures.ts
T
admGitAICDSandClaude Opus 5 5b1cf9d0d9
Deploy App / deploy (push) Successful in 28s
Sichtschutz, schwebende Stufen und Hoehenprofil nach Plandurchgang
Nach erneutem Detailstudium des Gartenplans und der Referenzbilder darin:

- Sichtschutz ist nicht durchgehend: 28 aufrechte Travertinstelen (50x6 cm,
  Dreiergruppen, Positionen direkt aus dem Plan) wechseln sich mit
  Eibengruppen ab, alle in einem Dekorkiesbett. Hoehe 1.80 m gegen den
  Suednachbarn, 1.50 m gegen Zollet auf der unteren Ebene.
- Zwei schwebende Travertinstufen fuehren vom gedeckten Sitzplatz (-0.02)
  auf den Hauptsitzplatz hinunter, als auskragende Platten mit Schattenfuge.
- Hoehenprofil auf zwei klare Ebenen gebracht: untere Ebene -0.33,
  Travertinsitzmauer mit 31 cm Ansicht, obere Ebene -0.02. Die Niveaus stehen
  fest in LEVELS statt aus streuenden Koten geschaetzt zu werden.
- Jede Hoehenkote gehoert neu zu genau einer Flaeche, der kleinsten, die sie
  enthaelt - sonst kippen benachbarte Ebenen ineinander.
- Der schmale Streifen am Sichtschutz ist Kiesbett, nicht Plattenbelag.
- Travertin nach den Referenzbildern deutlich heller, eigene Steintextur fuer
  die Stelen; Flammengrill als Stahlkubus mit Holzfach und Feueroeffnung.
- Stirnflaechen der Hecken hatten entartete UVs und zeigten Strahlenmuster.
- tools/plan-bilder.mjs zieht die Referenzfotos aus dem Plan-PDF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:40:36 +02:00

347 lines
13 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, 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;
},
};
}