Geometrie massstaeblich aus dem Gartenplan-PDF (1:100) extrahiert: Belaege, Mauern, Pflanzenstandorte und 46 Hoehenkoten. Wohnhaus nach Gartenplan und den Fassadenplaenen von EHLE Architekten. - tools/: Pipeline PDF -> Szenenmodell (MuPDF, Node) - src/: three.js-Szene mit prozeduralen Texturen, Rundgang und Uebersicht - Docker/Traefik fuer garten.aicds.ch, Gitea-Workflow auf main Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
/**
|
|
* Bauteile: Stuetz- und Sitzmauern, Travertinstelen, Gartenhaus, Spielhaus,
|
|
* Flammengrill, Baenkli, Leuchten.
|
|
*/
|
|
import {
|
|
BoxGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial, PointLight,
|
|
type MeshStandardMaterialParameters,
|
|
} from 'three';
|
|
import type { Lamp, Prop, Stele, Wall } from '../site';
|
|
import { boxGeometry, wallGeometry } from './geometry';
|
|
import * as tex from './textures';
|
|
|
|
function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {}) {
|
|
return new MeshStandardMaterial({
|
|
map: belag.map, roughness: belag.roughness, metalness: 0, ...extra,
|
|
});
|
|
}
|
|
|
|
const WALL_LOOK: Record<Wall['type'], () => tex.Belag> = {
|
|
travertin: tex.travertinMauer,
|
|
beton: tex.beton,
|
|
holzscheiter: tex.holzscheiter,
|
|
lamellen: tex.fassadeHolz,
|
|
};
|
|
|
|
/** Wertet ein Hoehenprofil (Stuetzelemente) an der Stelle t aus. */
|
|
function profileAt(profile: number[] | undefined, t: number, fallback: number): number {
|
|
if (!profile || !profile.length) return fallback;
|
|
const f = t * (profile.length - 1);
|
|
const i = Math.max(0, Math.min(profile.length - 2, Math.floor(f)));
|
|
const u = f - i;
|
|
return profile[i] * (1 - u) + profile[i + 1] * u;
|
|
}
|
|
|
|
export function buildWalls(walls: Wall[], height: (x: number, z: number) => number): Group {
|
|
const group = new Group();
|
|
group.name = 'Mauern und Wände';
|
|
|
|
for (const w of walls) {
|
|
const belag = WALL_LOOK[w.type]();
|
|
const top = (t: number, x: number, z: number) =>
|
|
w.crestProfile ? profileAt(w.crestProfile, t, 0) : (w.top ?? height(x, z) + 0.4);
|
|
const base = (t: number, x: number, z: number) =>
|
|
w.footProfile ? profileAt(w.footProfile, t, -1) : (w.base ?? height(x, z) - 0.5);
|
|
|
|
if (w.openSlats) {
|
|
// Lamellenwand: senkrechte Bretter im Plan-Rhythmus (10 cm breit,
|
|
// 35 cm tief, rund 90 cm Achsabstand), dazu das schmale Randband.
|
|
const slats = new Group();
|
|
const [a, b] = [w.path[0], w.path[w.path.length - 1]];
|
|
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
const n = Math.max(2, Math.round(len / 0.9));
|
|
const mat = material(belag, { roughness: 0.85 });
|
|
const dirX = (b[0] - a[0]) / len, dirZ = (b[1] - a[1]) / len;
|
|
const angle = (Math.atan2(dirZ, dirX) * 180) / Math.PI;
|
|
for (let i = 0; i <= n; i++) {
|
|
const t = i / n;
|
|
const x = a[0] + (b[0] - a[0]) * t;
|
|
const z = a[1] + (b[1] - a[1]) * t;
|
|
const g = boxGeometry(x, z, 0.10, w.t, base(t, x, z), top(t, x, z), angle, 1.2);
|
|
const m = new Mesh(g, mat);
|
|
m.castShadow = true;
|
|
m.receiveShadow = true;
|
|
slats.add(m);
|
|
}
|
|
// durchlaufendes Randband an der Fassadenkante
|
|
const band = new Mesh(
|
|
wallGeometry(w.path, 0.10, top, base, { segments: 6, tile: belag.tile }),
|
|
mat,
|
|
);
|
|
band.castShadow = true;
|
|
band.receiveShadow = true;
|
|
slats.add(band);
|
|
slats.name = w.id;
|
|
slats.userData.note = w.note;
|
|
group.add(slats);
|
|
continue;
|
|
}
|
|
|
|
const geo = wallGeometry(w.path, w.t, top, base, {
|
|
segments: w.crestProfile ? w.crestProfile.length - 1 : 16,
|
|
tile: belag.tile,
|
|
});
|
|
const mesh = new Mesh(geo, material(belag));
|
|
mesh.name = w.id;
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
mesh.userData.note = w.note;
|
|
group.add(mesh);
|
|
}
|
|
|
|
return group;
|
|
}
|
|
|
|
export function buildStelen(stelen: Stele[], height: (x: number, z: number) => number): Group {
|
|
const group = new Group();
|
|
group.name = 'Travertinstelen';
|
|
const mat = material(tex.travertinMauer(), { roughness: 0.68 });
|
|
for (const s of stelen) {
|
|
const y0 = height(s.x, s.z) - 0.3;
|
|
const geo = boxGeometry(s.x, s.z, 0.22, 0.22, y0, y0 + 0.3 + s.h, 0, 1.6);
|
|
const m = new Mesh(geo, mat);
|
|
m.castShadow = true;
|
|
m.receiveShadow = true;
|
|
group.add(m);
|
|
}
|
|
return group;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
|
|
function flammengrill(p: Prop, y: number): Group {
|
|
const g = new Group();
|
|
const steel = new MeshStandardMaterial({ color: 0x3a3b3e, roughness: 0.5, metalness: 0.45 });
|
|
const body = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, y + 0.06, y + p.h, p.rot, 1), steel);
|
|
body.castShadow = true; body.receiveShadow = true;
|
|
g.add(body);
|
|
const plate = new Mesh(
|
|
boxGeometry(p.x, p.z, p.w + 0.1, p.d + 0.1, y + p.h, y + p.h + 0.03, p.rot, 1),
|
|
new MeshStandardMaterial({ color: 0x3a3a3c, roughness: 0.35, metalness: 0.7 }),
|
|
);
|
|
plate.castShadow = true;
|
|
g.add(plate);
|
|
return g;
|
|
}
|
|
|
|
function bank(p: Prop, y: number): Group {
|
|
const g = new Group();
|
|
const stein = material(tex.travertinMauer(), { 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, 1.6), 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, 1.6),
|
|
stein,
|
|
);
|
|
m.castShadow = true;
|
|
g.add(m);
|
|
}
|
|
return g;
|
|
}
|
|
|
|
function simpleBox(p: Prop, y: number, color: number, rough = 0.7): Mesh {
|
|
const m = new Mesh(
|
|
boxGeometry(p.x, p.z, p.w, p.d, y, y + p.h, p.rot, 1),
|
|
new MeshStandardMaterial({ color, roughness: rough }),
|
|
);
|
|
m.castShadow = true;
|
|
m.receiveShadow = true;
|
|
return m;
|
|
}
|
|
|
|
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 'roboter': obj = simpleBox(p, y, 0x33413a, 0.6); break;
|
|
case 'wasserhahn': obj = simpleBox(p, y, 0x8a8d90, 0.4); break;
|
|
case 'schacht': obj = simpleBox(p, y - 0.01, 0x77746e, 0.9); 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;
|
|
},
|
|
};
|
|
}
|