Qualitaetssprung: ebene Belaege, echte Bauteile, Sichtschutz
Deploy App / deploy (push) Successful in 49s

Nach der Plankontrolle ueberarbeitet:

- Belaege sind eben. Je Belag wird aus den Hoehenkoten eine Ebene bestimmt
  (Gefaelle <= 5 %, am Kotenschwerpunkt aufgehaengt, nur Koten derselben
  Gelaendezone). Das Raster wird darauf gezwungen und dort nicht geglaettet.
  Jeder Belag bekommt eine senkrechte Sichtkante zum Gelaende.
- Mauern sind Bauteile: Travertin-Schichtmauerwerk mit versetzten Stossfugen
  und Abdeckplatte, einzelne Winkelelemente mit Fussplatte, Ortbeton in
  Schaltafelfeldern.
- Sichtschutz: die Eibenreihen des Plans werden zu geschnittenen Hecken
  verkettet, ebenso die Ligusterhecke. Lamellenwand im Planrhythmus.
- Der Plan zeichnet die Travertinplatten 40x60 einzeln im Dekorkies - jetzt
  30 erhabene Platten an den Planpositionen statt geschlossenem Belag.
- Normalmaps aus dem Fugenbild fuer Platten, Pflaster, Holzschalung und Laub.
- Nachbarhaus Zollet richtig platziert (stand auf dem Nachbargarten) und
  wie Haus 9a ausgebildet.

Behobene Fehler: boxGeometry erzeugte Seiten und Deckel mit umgekehrtem
Wickelsinn - saemtliche Boxen waren innen nach aussen gekehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 07:47:47 +02:00
co-authored by Claude Opus 5
parent f2b7b1b236
commit 8979775895
24 changed files with 1452 additions and 319 deletions
+35 -1
View File
@@ -71,6 +71,39 @@ Zur Kontrolle: `node tools/debug-svg.mjs` zeichnet die extrahierte Geometrie als
SVG-Draufsicht, `node tools/inspect-colors.mjs` listet die Planfarben mit den
nächstgelegenen Beschriftungen.
### Ebene Beläge statt gewelltem Gelände
Gebaute Flächen sind eben. Für jeden Belag wird aus den Höhenkoten darin und
knapp daneben eine Ebene bestimmt – Gefälle auf 5 % begrenzt, am Schwerpunkt der
Koten aufgehängt, und nur Koten derselben Geländezone. Das Geländeraster wird in
diesen Feldern auf die Ebene gezwungen und dort **nicht** geglättet. Im Rendering
bekommt jeder Belag eine senkrechte Sichtkante zum angrenzenden Gelände – erst
dadurch wirkt eine Terrasse gebaut und nicht aufgemalt. Wachsende Flächen
(Rasen, Schotterwiese, Beet) folgen weiterhin dem Gelände.
### Bauteile statt hochgezogener Flächen
Der Plan nennt vier verschiedene Konstruktionen, und sie sehen auch verschieden
aus (`src/scene/walls.ts`):
- **Travertinmauer / Sitzmauern** – Schichtmauerwerk aus einzelnen Blöcken mit
versetzten Stossfugen und überstehender Abdeckplatte.
- **Winkelelemente aus Beton** – Fertigteile von 1 m Breite, einzeln versetzt,
mit Stossfugen, nach unten dicker werdendem Stiel und Fussplatte.
- **Betonmauer Nachbar** – Ortbeton in Schaltafelfeldern mit abgefaster Krone.
- **Lamellenwand** – senkrechte Bretter im Rhythmus des Plans (10 cm breit,
35 cm tief, 90 cm Achsabstand) vor dem gedeckten Sitzplatz.
- **Holzscheiterwand** – Stahlrahmen, gefüllt mit gestapelten Scheitern.
Die **Eibenhecke** entsteht nicht aus Einzelkugeln: die Pflanzstandorte des Plans
werden zu Reihen verkettet, begradigt, an scharfen Knicken getrennt und als
geschnittener Heckenkörper mit gerundetem Querschnitt aufgezogen. Ebenso die
Ligusterhecke (170 cm hoch, 75 cm breit). Das ist der eigentliche Sichtschutz
im Garten.
Die **Travertinplatten 40 × 60 × 3 cm** liegen laut Plan einzeln im Dekorkies –
30 Platten, exakt an den Planpositionen, als erhabene Platten auf dem Kiesbett.
### Warum Zonen statt eines einzigen Geländes
Das Grundstück fällt über rund 3.5 m ab, die Sprünge liegen aber nicht im
@@ -117,7 +150,8 @@ src/
scene/geometry.ts Polygone auf das Gelände legen, Mauern extrudieren
scene/terrain.ts Geländeraster und Umland
scene/surfaces.ts Beläge
scene/structures.ts Mauern, Stelen, Objekte, Leuchten
scene/walls.ts Mauern als Bauteile: Schichtmauerwerk, Winkelelemente, Lamellen
scene/structures.ts Stelen, Objekte, Leuchten
scene/vegetation.ts Gehölze, Hecken, Gräser, Stauden
scene/building.ts Wohnhaus 9a und Nachbargebäude
scene/environment.ts Himmel, Sonnenstand, Licht
Binary file not shown.

Before

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 729 KiB

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 685 KiB

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

After

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 779 KiB

After

Width:  |  Height:  |  Size: 846 KiB

File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -13,7 +13,8 @@ import './style.css';
import { loadSite, makeHeightSampler, type SiteData } from './site';
import { buildTerrain } from './scene/terrain';
import { buildSurfaces } from './scene/surfaces';
import { buildWalls, buildStelen, buildProps, buildLamps } from './scene/structures';
import { buildStelen, buildProps, buildLamps } from './scene/structures';
import { buildWalls } from './scene/walls';
import { buildVegetation } from './scene/vegetation';
import { buildBuilding, buildNeighbourBuildings } from './scene/building';
import { buildEnvironment } from './scene/environment';
@@ -52,7 +53,7 @@ async function main() {
scene.add(buildTerrain(site.terrain));
await progress(38, 'Beläge werden verlegt …');
scene.add(buildSurfaces(site.surfaces, height));
scene.add(buildSurfaces(site.surfaces, site.slabs, height));
await progress(52, 'Mauern und Bauteile …');
scene.add(buildWalls(site.walls, height));
+56 -13
View File
@@ -244,30 +244,73 @@ export function buildBuilding(b: Building): Group {
return group;
}
/**
* Nachbargebaeude als Kontextbau: gleiche Machart wie Haus 9a - Holzschalung,
* Betonsockel, Attika und begruentes Flachdach, dazu einige Oeffnungen, damit
* es nicht als glatter Klotz wirkt.
*/
export function buildNeighbourBuildings(list: NeighbourBuilding[]): Group {
const group = new Group();
group.name = 'Nachbargebäude';
const holz = tex.fassadeHolz();
const mat = new MeshStandardMaterial({ map: holz.map, roughness: holz.roughness });
const attika = new MeshStandardMaterial({ color: 0x8b8377, roughness: 0.7 });
const holzMat = new MeshStandardMaterial({
map: holz.map, normalMap: holz.normalMap ?? null,
normalScale: holz.normalScale, roughness: holz.roughness,
});
const sockelTex = tex.sockel();
const sockelMat = new MeshStandardMaterial({ map: sockelTex.map, roughness: sockelTex.roughness });
const attikaMat = new MeshStandardMaterial({ color: 0x8b8377, roughness: 0.7, metalness: 0.15 });
const gruen = tex.gruendach();
const glasMat = glassMaterial();
for (const b of list) {
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
for (const [x, z] of b.outline) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); z0 = Math.min(z0, z); z1 = Math.max(z1, z); }
const cx = (x0 + x1) / 2, cz = (z0 + z1) / 2;
const w = x1 - x0, d = z1 - z0;
const node = new Group();
node.name = b.name;
const sockel = new Mesh(
boxGeometry(cx, cz, w, d, b.floorLevel - 0.9, b.floorLevel + 0.35, 0, sockelTex.tile), sockelMat,
);
const body = new Mesh(
boxGeometry((x0 + x1) / 2, (z0 + z1) / 2, x1 - x0, z1 - z0, b.floorLevel - 0.8, b.roofLevel, 0, holz.tile),
mat,
boxGeometry(cx, cz, w, d, b.floorLevel + 0.35, b.roofLevel, 0, holz.tile), holzMat,
);
body.name = b.name;
body.castShadow = true;
body.receiveShadow = true;
group.add(body);
const cap = new Mesh(
boxGeometry((x0 + x1) / 2, (z0 + z1) / 2, x1 - x0 + 0.2, z1 - z0 + 0.2, b.roofLevel, b.roofLevel + 0.3, 0, 2),
attika,
for (const m of [sockel, body]) { m.castShadow = true; m.receiveShadow = true; node.add(m); }
// Begruentes Flachdach hinter der Attika
const roof = new Mesh(
boxGeometry(cx, cz, w - 0.3, d - 0.3, b.roofLevel - 0.06, b.roofLevel + 0.02, 0, gruen.tile),
new MeshStandardMaterial({ map: gruen.map, roughness: 1 }),
);
cap.castShadow = true;
group.add(cap);
roof.receiveShadow = true;
node.add(roof);
for (const [ax, az, aw, ad] of [
[cx, z0, w + 0.16, 0.16], [cx, z1, w + 0.16, 0.16],
[x0, cz, 0.16, d + 0.16], [x1, cz, 0.16, d + 0.16],
] as const) {
const m = new Mesh(boxGeometry(ax, az, aw, ad, b.roofLevel, b.roofLevel + 0.32, 0, 2), attikaMat);
m.castShadow = true;
node.add(m);
}
// Fensteroeffnungen auf den beiden Langseiten
for (const side of [-1, 1] as const) {
const zf = side < 0 ? z0 + 0.02 : z1 - 0.02;
for (const [u, wdt, sill, head] of [
[-0.30, 2.6, 0.9, 2.4], [0.18, 1.8, 0.9, 2.4],
[-0.28, 2.2, 3.7, 5.0], [0.20, 1.6, 3.7, 5.0],
] as const) {
const px = cx + u * w;
const m = new Mesh(
boxGeometry(px, zf, wdt, 0.06, b.floorLevel + sill, b.floorLevel + head, 0, 1), glasMat,
);
node.add(m);
}
}
group.add(node);
}
return group;
}
+120 -5
View File
@@ -218,9 +218,11 @@ export function boxGeometry(
[cx + u * ca - v * sa, y, cz + u * sa + v * ca];
const hw = w / 2, hd = d / 2;
const s = 1 / tile;
const faces: [Vec2, Vec2, number][] = [
[[-hw, -hd], [hw, -hd], 1], [[hw, -hd], [hw, hd], 1],
[[hw, hd], [-hw, hd], 1], [[-hw, hd], [-hw, -hd], 1],
// Umlauf im Uhrzeigersinn in (u, v). Damit zeigen die Flaechennormalen nach
// aussen - andersherum sieht man in die Box hinein.
const faces: [Vec2, Vec2][] = [
[[-hw, -hd], [-hw, hd]], [[-hw, hd], [hw, hd]],
[[hw, hd], [hw, -hd]], [[hw, -hd], [-hw, -hd]],
];
let uRun = 0;
for (const [p0, p1] of faces) {
@@ -232,9 +234,13 @@ export function boxGeometry(
uv.push(uRun * s, y0 * s, (uRun + len) * s, y1 * s, uRun * s, y1 * s);
uRun += len;
}
// Deckel
// Deckel, ebenfalls nach aussen (oben) gewickelt
const t0 = P(-hw, -hd, y1), t1 = P(hw, -hd, y1), t2 = P(hw, hd, y1), t3 = P(-hw, hd, y1);
pos.push(...t0, ...t1, ...t2, ...t0, ...t2, ...t3);
pos.push(...t0, ...t3, ...t2, ...t0, ...t2, ...t1);
uv.push(0, 0, 0, d * s, w * s, d * s, 0, 0, w * s, d * s, w * s, 0);
// Boden, damit die Box auch von unten geschlossen ist
const b0 = P(-hw, -hd, y0), b1 = P(hw, -hd, y0), b2 = P(hw, hd, y0), b3 = P(-hw, hd, y0);
pos.push(...b0, ...b1, ...b2, ...b0, ...b2, ...b3);
uv.push(0, 0, w * s, 0, w * s, d * s, 0, 0, w * s, d * s, 0, d * s);
const g = new BufferGeometry();
g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
@@ -242,3 +248,112 @@ export function boxGeometry(
g.computeVertexNormals();
return g;
}
/**
* Ebener Belag: das Polygon liegt exakt in der Ebene a*x + b*z + c.
* Gebaute Flaechen sind eben - sie duerfen dem Gelaende nicht folgen.
*/
export function planarSurfaceGeometry(
polygon: Vec2[],
plane: [number, number, number],
tile: number,
lift = 0,
): BufferGeometry {
const [a, b, c] = plane;
const tris = refine(triangulate(polygon), 1.6);
const pos = new Float32Array(tris.length * 9);
const uv = new Float32Array(tris.length * 6);
let p = 0, u = 0;
for (const t of tris) {
for (const [x, z] of t) {
pos[p++] = x;
pos[p++] = a * x + b * z + c + lift;
pos[p++] = z;
uv[u++] = x / tile;
uv[u++] = -z / tile;
}
}
const g = new BufferGeometry();
g.setAttribute('position', new BufferAttribute(pos, 3));
g.setAttribute('uv', new BufferAttribute(uv, 2));
g.computeVertexNormals();
return g;
}
/**
* Sichtkante eines Belags: senkrechtes Band von der Belagsoberkante hinunter
* bis unter das Gelaende. Erst damit wirkt eine Terrasse wie gebaut und nicht
* wie aufgemalt.
*/
export function surfaceEdgeGeometry(
polygon: Vec2[],
topAt: (x: number, z: number) => number,
bottomAt: (x: number, z: number) => number,
tile: number,
step = 0.4,
): BufferGeometry {
const pos: number[] = [];
const uv: number[] = [];
let run = 0;
const s = 1 / tile;
const n = polygon.length;
for (let i = 0; i < n; i++) {
const [ax, az] = polygon[i];
const [bx, bz] = polygon[(i + 1) % n];
const len = Math.hypot(bx - ax, bz - az);
const parts = Math.max(1, Math.round(len / step));
for (let k = 0; k < parts; k++) {
const t0 = k / parts, t1 = (k + 1) / parts;
const x0 = ax + (bx - ax) * t0, z0 = az + (bz - az) * t0;
const x1 = ax + (bx - ax) * t1, z1 = az + (bz - az) * t1;
const ty0 = topAt(x0, z0), ty1 = topAt(x1, z1);
const by0 = Math.min(bottomAt(x0, z0), ty0 - 0.012);
const by1 = Math.min(bottomAt(x1, z1), ty1 - 0.012);
const u0 = run, u1 = run + len / parts;
// Aussenseite: Polygon laeuft im Gegenuhrzeigersinn, also liegt die
// Sichtseite rechts der Laufrichtung.
pos.push(x0, ty0, z0, x0, by0, z0, x1, by1, z1);
uv.push(u0 * s, ty0 * s, u0 * s, by0 * s, u1 * s, by1 * s);
pos.push(x0, ty0, z0, x1, by1, z1, x1, ty1, z1);
uv.push(u0 * s, ty0 * s, u1 * s, by1 * s, u1 * s, ty1 * s);
run = u1;
}
}
const g = new BufferGeometry();
g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
g.setAttribute('uv', new BufferAttribute(new Float32Array(uv), 2));
g.computeVertexNormals();
return g;
}
/**
* Metallband: schmales Stahlband entlang eines Polygonrandes, wie es der Plan
* zwischen Kies, Rasen und Beeten zeigt.
*/
export function edgingGeometry(
polygon: Vec2[],
topAt: (x: number, z: number) => number,
thickness = 0.005,
proud = 0.022,
depth = 0.12,
): BufferGeometry {
const parts: BufferGeometry[] = [];
const n = polygon.length;
for (let i = 0; i < n; i++) {
const [ax, az] = polygon[i];
const [bx, bz] = polygon[(i + 1) % n];
const len = Math.hypot(bx - ax, bz - az);
if (len < 0.12) continue;
const steps = Math.max(1, Math.round(len / 1.2));
for (let k = 0; k < steps; k++) {
const t0 = k / steps, t1 = (k + 1) / steps;
const x0 = ax + (bx - ax) * t0, z0 = az + (bz - az) * t0;
const x1 = ax + (bx - ax) * t1, z1 = az + (bz - az) * t1;
const cx = (x0 + x1) / 2, cz = (z0 + z1) / 2;
const y = topAt(cx, cz);
const ang = (Math.atan2(z1 - z0, x1 - x0) * 180) / Math.PI;
parts.push(boxGeometry(cx, cz, len / steps + 0.004, thickness, y + proud - depth, y + proud, ang, 1));
}
}
return parts.length ? mergeGeometries(parts) : new BufferGeometry();
}
+98 -101
View File
@@ -1,104 +1,34 @@
/**
* Bauteile: Stuetz- und Sitzmauern, Travertinstelen, Gartenhaus, Spielhaus,
* Flammengrill, Baenkli, Leuchten.
* Freistehende Bauteile: Travertinstelen, Gartenhaus, Spielhaus,
* Flammengrill, Baenkli, Maehroboter-Garage, Gartentor und die Leuchten.
* Die Mauern stehen in walls.ts.
*/
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 type { Lamp, Prop, Stele } from '../site';
import { boxGeometry } 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,
map: belag.map,
normalMap: belag.normalMap ?? null,
normalScale: belag.normalScale,
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 });
const stein = tex.travertinMauer();
const mat = material(stein, { roughness: 0.66 });
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 geo = boxGeometry(s.x, s.z, 0.22, 0.22, y0, y0 + 0.3 + s.h, 0, stein.tile);
const m = new Mesh(geo, mat);
m.castShadow = true;
m.receiveShadow = true;
@@ -165,30 +95,59 @@ function spielhaus(p: Prop, y: number): Group {
return g;
}
/**
* Flammengrill: Cortenstahl-Kubus mit umlaufender Grillplatte und offener
* Feuerkammer, wie auf dem Referenzfoto des Plans.
*/
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);
const corten = new MeshStandardMaterial({ color: 0x5c3a26, roughness: 0.62, metalness: 0.45 });
const dark = new MeshStandardMaterial({ color: 0x241d18, roughness: 0.75, metalness: 0.3 });
const plateMat = new MeshStandardMaterial({ color: 0x3b3a37, roughness: 0.34, metalness: 0.72 });
// Sockel etwas eingezogen, damit der Kubus zu schweben scheint
const base = new Mesh(boxGeometry(p.x, p.z, p.w - 0.12, p.d - 0.12, y, y + 0.08, p.rot, 1), dark);
g.add(base);
const bodyTop = y + p.h - 0.05;
const body = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, y + 0.08, bodyTop, p.rot, 1), corten);
body.castShadow = true; body.receiveShadow = true;
g.add(body);
// Feuerkammer: dunkle Nische in der Vorderseite
const a = (p.rot * Math.PI) / 180;
const fx = p.x - Math.sin(a) * (p.d / 2 - 0.02);
const fz = p.z + Math.cos(a) * (p.d / 2 - 0.02);
const fire = new Mesh(
boxGeometry(fx, fz, p.w * 0.62, 0.06, y + 0.24, y + 0.52, p.rot, 1), dark,
);
g.add(fire);
// Grillplatte mit Ausschnitt fuer das Feuer
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 }),
boxGeometry(p.x, p.z, p.w + 0.14, p.d + 0.14, bodyTop, bodyTop + 0.035, p.rot, 1), plateMat,
);
plate.castShadow = true;
plate.receiveShadow = true;
g.add(plate);
const ring = new Mesh(
new CylinderGeometry(p.w * 0.24, p.w * 0.24, 0.05, 20), dark,
);
ring.position.set(p.x, bodyTop + 0.04, p.z);
g.add(ring);
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);
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, 1.6),
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;
@@ -197,14 +156,52 @@ function bank(p: Prop, y: number): Group {
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 }),
/** 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,
);
m.castShadow = true;
m.receiveShadow = true;
return m;
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 {
@@ -235,9 +232,9 @@ export function buildProps(props: Prop[], height: (x: number, z: number) => numb
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;
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;
+128 -29
View File
@@ -1,27 +1,68 @@
/**
* Belagsflaechen aus dem Gartenplan: Travertin, Keramik, Tegula, Rasen,
* Schotterwiese, Beete, Spielsand.
* Belagsflaechen aus dem Gartenplan.
*
* Gebaute Belaege (Travertin, Keramik, Tegula, Verbundstein, Spielsand,
* Dekorkies) sind **eben**: sie liegen in der Ebene, die im Szenenmodell aus
* den Hoehenkoten bestimmt wurde, und bekommen eine senkrechte Sichtkante zum
* angrenzenden Gelaende. Wachsende Flaechen (Rasen, Schotterwiese, Beet)
* folgen dem Gelaende.
*
* Dazu die Details, die den Plan ausmachen: das Metallband als Einfassung und
* die einzeln im Kies verlegten Travertinplatten 40x60x3 cm.
*/
import { Group, Mesh, MeshStandardMaterial, DoubleSide } from 'three';
import type { Surface, SurfaceType } from '../site';
import { surfaceGeometryMulti } from './geometry';
import {
BufferGeometry, DoubleSide, Group, InstancedMesh, Mesh, MeshStandardMaterial,
Object3D,
} from 'three';
import type { Slab, Surface, SurfaceType, Vec2 } from '../site';
import {
boxGeometry, edgingGeometry, mergeGeometries, planarSurfaceGeometry,
surfaceEdgeGeometry, surfaceGeometryMulti,
} from './geometry';
import * as tex from './textures';
const BELAG: Record<SurfaceType, { belag: () => tex.Belag; offset: number; label: string }> = {
travertin80: { belag: tex.travertin80, offset: 0.042, label: 'Travertinplatten 80×80×3 cm' },
travertin4060: { belag: tex.travertin4060, offset: 0.042, label: 'Travertinplatten 40×60×3 cm mit Dekorkies' },
travertinstell: { belag: tex.travertinstell, offset: 0.038, label: 'Travertinstellplatten' },
keramik: { belag: tex.keramik, offset: 0.032, label: 'Keramikplatten 60×60×2 cm' },
verbundstein: { belag: tex.verbundstein, offset: 0.030, label: 'Verbundsteine (Anpassung Nachbar)' },
tegula: { belag: tex.tegula, offset: 0.034, label: 'Tegula-Pflastersteine grau' },
rasen: { belag: tex.rasen, offset: 0.022, label: 'Rasen' },
schotterwiese: { belag: tex.schotterwiese, offset: 0.020, label: 'Schotterwiese' },
beet: { belag: tex.beet, offset: 0.018, label: 'Pflanzbeet' },
spielsand: { belag: tex.spielsand, offset: 0.016, label: 'Spielsand' },
interface BelagDef {
belag: () => tex.Belag;
label: string;
/** Aufbauhoehe ueber der Ebene bzw. ueber dem Gelaende. */
lift: number;
/** Gebaute Flaeche: eben, mit Sichtkante. */
built: boolean;
/** Kante mit Metallband einfassen. */
edging?: boolean;
/** Material der Sichtkante, falls abweichend. */
edge?: () => tex.Belag;
}
const BELAG: Record<SurfaceType, BelagDef> = {
travertin80: { belag: tex.travertin80, label: 'Travertinplatten 80×80×3 cm', lift: 0.030, built: true, edge: tex.travertinMauer },
travertinstell: { belag: tex.travertinstell, label: 'Travertinstellplatten', lift: 0.028, built: true, edge: tex.travertinMauer },
keramik: { belag: tex.keramik, label: 'Keramikplatten 60×60×2 cm', lift: 0.020, built: true, edge: tex.beton },
verbundstein: { belag: tex.verbundstein, label: 'Verbundsteine (Nachbar)', lift: 0.014, built: true, edge: tex.beton },
tegula: { belag: tex.tegula, label: 'Tegula-Pflastersteine grau', lift: 0.014, built: true, edge: tex.beton },
dekorkies: { belag: tex.dekorkies, label: 'Dekorkies', lift: 0.004, built: true, edging: true, edge: tex.beton },
spielsand: { belag: tex.spielsand, label: 'Spielsand', lift: 0.002, built: true, edging: true, edge: tex.beton },
rasen: { belag: tex.rasen, label: 'Rasen', lift: 0.012, built: false },
schotterwiese: { belag: tex.schotterwiese, label: 'Schotterwiese', lift: 0.010, built: false },
beet: { belag: tex.beet, label: 'Pflanzbeet', lift: 0.008, built: false, edging: true },
};
const STAHL = () => new MeshStandardMaterial({ color: 0x8a877f, roughness: 0.55, metalness: 0.62 });
function material(belag: tex.Belag, extra: Record<string, unknown> = {}) {
return new MeshStandardMaterial({
map: belag.map,
normalMap: belag.normalMap ?? null,
normalScale: belag.normalScale,
roughness: belag.roughness,
metalness: 0,
...extra,
});
}
export function buildSurfaces(
surfaces: Surface[],
slabs: Slab[],
height: (x: number, z: number) => number,
): Group {
const group = new Group();
@@ -34,29 +75,87 @@ export function buildSurfaces(
byType.set(s.type, list);
}
const edgingParts: BufferGeometry[] = [];
for (const [type, list] of byType) {
const def = BELAG[type];
if (!def) continue;
const belag = def.belag();
const geo = surfaceGeometryMulti(
list.map((s) => s.polygon),
height,
{ offset: def.offset, tile: belag.tile, maxEdge: 0.28 },
);
const mesh = new Mesh(geo, new MeshStandardMaterial({
map: belag.map,
roughness: belag.roughness,
metalness: 0,
side: DoubleSide,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -1,
const tops: BufferGeometry[] = [];
const sides: BufferGeometry[] = [];
for (const s of list) {
const plane = def.built ? s.plane : undefined;
if (plane) {
const topAt = (x: number, z: number) => plane[0] * x + plane[1] * z + plane[2] + def.lift;
tops.push(planarSurfaceGeometry(s.polygon, plane, belag.tile, def.lift));
sides.push(surfaceEdgeGeometry(
s.polygon, topAt,
(x, z) => Math.min(height(x, z), topAt(x, z)) - 0.16,
(def.edge ?? def.belag)().tile,
));
if (def.edging) edgingParts.push(edgingGeometry(s.polygon, topAt));
} else {
tops.push(surfaceGeometryMulti([s.polygon], height, {
offset: def.lift, tile: belag.tile, maxEdge: 0.28,
}));
if (def.edging) {
edgingParts.push(edgingGeometry(s.polygon, (x, z) => height(x, z) + def.lift));
}
}
}
const mesh = new Mesh(mergeGeometries(tops), material(belag, {
side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1,
}));
mesh.name = def.label;
mesh.receiveShadow = true;
mesh.userData.surfaceType = type;
group.add(mesh);
if (sides.length) {
const edgeBelag = (def.edge ?? def.belag)();
const edgeMesh = new Mesh(mergeGeometries(sides), material(edgeBelag, { side: DoubleSide }));
edgeMesh.name = `${def.label} – Sichtkante`;
edgeMesh.castShadow = true;
edgeMesh.receiveShadow = true;
group.add(edgeMesh);
}
}
if (edgingParts.length) {
const band = new Mesh(mergeGeometries(edgingParts), STAHL());
band.name = 'Metallband';
band.castShadow = true;
band.receiveShadow = true;
group.add(band);
}
// --- Einzeln verlegte Travertinplatten 40x60x3 cm ------------------------
if (slabs.length) {
const belag = tex.travertinSlab();
const proto = boxGeometry(0, 0, 0.40, 0.60, 0, 0.05, 0, belag.tile);
const mesh = new InstancedMesh(proto, material(belag), slabs.length);
const dummy = new Object3D();
slabs.forEach((s, i) => {
dummy.position.set(s.x, height(s.x, s.z) + 0.005, s.z);
dummy.scale.set(s.w / 0.4, 1, s.d / 0.6);
dummy.rotation.set(0, 0, 0);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
});
mesh.instanceMatrix.needsUpdate = true;
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.name = 'Travertinplatten 40×60×3 cm im Dekorkies';
group.add(mesh);
}
return group;
}
/** Randpolygone der Belaege, damit andere Bauteile sauber anschliessen. */
export function surfacePolygons(surfaces: Surface[], type: SurfaceType): Vec2[][] {
return surfaces.filter((s) => s.type === type).map((s) => s.polygon);
}
+5 -5
View File
@@ -6,7 +6,7 @@ import {
BufferAttribute, BufferGeometry, DoubleSide, Mesh, MeshStandardMaterial, Group,
} from 'three';
import type { Terrain } from '../site';
import { rasen, schotterwiese } from './textures';
import { rohboden } from './textures';
export function buildTerrain(terrain: Terrain): Group {
const group = new Group();
@@ -18,7 +18,7 @@ export function buildTerrain(terrain: Terrain): Group {
const geo = new BufferGeometry();
const verts = new Float32Array(nx * nz * 3);
const uvs = new Float32Array(nx * nz * 2);
const belag = rasen();
const belag = rohboden();
for (let j = 0; j < nz; j++) {
for (let i = 0; i < nx; i++) {
const k = j * nx + i;
@@ -53,7 +53,7 @@ export function buildTerrain(terrain: Terrain): Group {
// Das Grundstueck liegt an einem Hang ueber dem Tal. Das Umland wird als
// Kranz direkt an die Randpunkte des Rasters gehaengt - so entsteht keine
// sichtbare Kante - und faellt gegen aussen ab, vor allem nach Suedwesten.
const wiese = schotterwiese();
const wiese = rohboden();
const cx = x0 + ((nx - 1) * cell) / 2;
const cz = z0 + ((nz - 1) * cell) / 2;
@@ -91,7 +91,7 @@ export function buildTerrain(terrain: Terrain): Group {
// Nach Suedwesten faellt das Gelaende deutlich staerker ab (Blick ins Tal).
const sw = (1 - Math.cos(Math.atan2(dz, dx) - 2.4)) / 2;
ringPos.push(x, p.y - t * (6 + 26 * sw * sw), z);
ringUv.push(x / (wiese.tile * 5), -z / (wiese.tile * 5));
ringUv.push(x / wiese.tile, -z / wiese.tile);
}
}
for (let k = 0; k < RINGS; k++) {
@@ -107,7 +107,7 @@ export function buildTerrain(terrain: Terrain): Group {
far.setIndex(ringIdx);
far.computeVertexNormals();
const surroundings = new Mesh(far, new MeshStandardMaterial({
map: wiese.map, roughness: 1, metalness: 0, color: 0x86935f, side: DoubleSide,
map: wiese.map, roughness: 1, metalness: 0, color: 0xa9b096, side: DoubleSide,
}));
surroundings.name = 'Umland';
group.add(surroundings);
+351 -96
View File
@@ -8,6 +8,7 @@
*/
import {
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
LinearSRGBColorSpace, Vector2,
} from 'three';
const SIZE = 512;
@@ -102,6 +103,9 @@ function slabs(
export interface Belag {
map: CanvasTexture;
/** Aus dem Fugenbild abgeleitete Normalmap - gibt den Belaegen Tiefe. */
normalMap?: CanvasTexture;
normalScale?: Vector2;
tile: number;
roughness: number;
color?: number;
@@ -119,75 +123,210 @@ function make(key: string, tile: number, roughness: number, draw: (ctx: CanvasRe
return belag;
}
/**
* Rechnet ein Hoehenbild in eine Normalmap um (Sobel, kachelnd).
* Damit bekommen Fugen und Steinkanten echte Tiefe im Streiflicht.
*/
function normalFromHeight(src: HTMLCanvasElement, strength: number): CanvasTexture {
const sctx = src.getContext('2d', { willReadFrequently: true })!;
const h = sctx.getImageData(0, 0, SIZE, SIZE).data;
const [c, ctx] = canvas();
const out = ctx.createImageData(SIZE, SIZE);
const at = (x: number, y: number) =>
h[(((y + SIZE) % SIZE) * SIZE + ((x + SIZE) % SIZE)) * 4] / 255;
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
const dx =
at(x - 1, y - 1) + 2 * at(x - 1, y) + at(x - 1, y + 1) -
at(x + 1, y - 1) - 2 * at(x + 1, y) - at(x + 1, y + 1);
const dy =
at(x - 1, y - 1) + 2 * at(x, y - 1) + at(x + 1, y - 1) -
at(x - 1, y + 1) - 2 * at(x, y + 1) - at(x + 1, y + 1);
const nx = dx * strength, ny = dy * strength, nz = 1;
const len = Math.hypot(nx, ny, nz);
const k = (y * SIZE + x) * 4;
out.data[k] = ((nx / len) * 0.5 + 0.5) * 255;
out.data[k + 1] = ((ny / len) * 0.5 + 0.5) * 255;
out.data[k + 2] = ((nz / len) * 0.5 + 0.5) * 255;
out.data[k + 3] = 255;
}
}
ctx.putImageData(out, 0, 0);
const t = new CanvasTexture(c);
t.wrapS = t.wrapT = RepeatWrapping;
t.colorSpace = LinearSRGBColorSpace;
t.anisotropy = 8;
t.minFilter = LinearMipmapLinearFilter;
return t;
}
/**
* Wie `make`, zusaetzlich mit einem Hoehenbild fuer die Normalmap.
* `drawHeight` zeichnet Graustufen: hell = hoch, dunkel = Fuge.
*/
function makeRelief(
key: string, tile: number, roughness: number, normalStrength: number,
draw: (ctx: CanvasRenderingContext2D) => void,
drawHeight: (ctx: CanvasRenderingContext2D) => void,
): Belag {
const hit = cache.get(key);
if (hit) return hit;
const [c, ctx] = canvas();
draw(ctx);
const [hc, hctx] = canvas();
drawHeight(hctx);
const belag: Belag = {
map: texture(c),
normalMap: normalFromHeight(hc, normalStrength),
normalScale: new Vector2(1, 1),
tile,
roughness,
};
cache.set(key, belag);
return belag;
}
/** Zeichnet dasselbe Plattenraster als Hoehenbild: Platte hell, Fuge dunkel. */
function slabHeight(
ctx: CanvasRenderingContext2D, tile: number, slabW: number, slabH: number,
jointM: number, chamfer = 0.012,
) {
const px = SIZE / tile;
ctx.fillStyle = 'rgb(40,40,40)';
ctx.fillRect(0, 0, SIZE, SIZE);
const cols = Math.round(tile / slabW);
const rows = Math.round(tile / slabH);
const j = Math.max(1.5, jointM * px);
const ch = Math.max(1, chamfer * px);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * slabW * px + j / 2;
const y = row * slabH * px + j / 2;
const w = slabW * px - j, hgt = slabH * px - j;
// Fase aussen, Plattenspiegel innen
ctx.fillStyle = 'rgb(170,170,170)';
ctx.fillRect(x, y, w, hgt);
ctx.fillStyle = 'rgb(235,235,235)';
ctx.fillRect(x + ch, y + ch, w - 2 * ch, hgt - 2 * ch);
}
}
}
// ---------------------------------------------------------------------------
// Belaege
// ---------------------------------------------------------------------------
/** Travertinplatten 80 x 80 x 3 cm, warmes Creme mit feiner Poren-Struktur. */
export const travertin80 = () => make('travertin80', 3.2, 0.72, (ctx) => {
slabs(ctx, 3.2, 0.8, 0.8, 'rgb(209,195,168)', 'rgb(170,157,134)', 0.008, 11, 7);
noiseTile(ctx, 21, 32, 0.05);
grain(ctx, 31, 0.05, 2);
});
/** Travertin-Oberflaeche: warme Wolken und feine Poren. */
function travertinFinish(ctx: CanvasRenderingContext2D, seed: number) {
noiseTile(ctx, seed, 48, 0.035);
noiseTile(ctx, seed + 100, 144, 0.025);
const rnd = mulberry32(seed + 7);
// Poren und Adern, typisch fuer Travertin
for (let i = 0; i < 900; i++) {
ctx.fillStyle = `rgba(120,105,84,${0.05 + rnd() * 0.10})`;
ctx.beginPath();
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 1 + rnd() * 5, 0.6 + rnd() * 1.6, rnd() * 0.6, 0, Math.PI * 2);
ctx.fill();
}
for (let i = 0; i < 40; i++) {
ctx.strokeStyle = `rgba(146,130,104,${0.06 + rnd() * 0.08})`;
ctx.lineWidth = 1 + rnd() * 2;
ctx.beginPath();
const y = rnd() * SIZE;
ctx.moveTo(0, y);
for (let x = 0; x <= SIZE; x += 32) ctx.lineTo(x, y + Math.sin(x / 40 + i) * 3);
ctx.stroke();
}
}
/** Travertinplatten 40 x 60 x 3 cm, im Plan mit Dekorkies in den Fugen. */
export const travertin4060 = () => make('travertin4060', 2.4, 0.75, (ctx) => {
slabs(ctx, 2.4, 0.4, 0.6, 'rgb(212,199,172)', 'rgb(158,150,133)', 0.02, 12, 8);
noiseTile(ctx, 22, 32, 0.05);
grain(ctx, 32, 0.06, 2);
});
/** Travertinplatten 80 x 80 x 3 cm, Hauptsitzplatz. */
export const travertin80 = () => makeRelief('travertin80', 3.2, 0.70, 3.2,
(ctx) => {
slabs(ctx, 3.2, 0.8, 0.8, 'rgb(209,195,168)', 'rgb(158,145,124)', 0.010, 11, 7);
travertinFinish(ctx, 21);
},
(ctx) => slabHeight(ctx, 3.2, 0.8, 0.8, 0.010, 0.015));
/** Einzelne Travertinplatte 40 x 60 x 3 cm - Textur fuer die losen Platten. */
export const travertinSlab = () => makeRelief('travertinSlab', 0.8, 0.70, 2.2,
(ctx) => {
ctx.fillStyle = 'rgb(213,199,171)';
ctx.fillRect(0, 0, SIZE, SIZE);
travertinFinish(ctx, 26);
},
(ctx) => { ctx.fillStyle = 'rgb(210,210,210)'; ctx.fillRect(0, 0, SIZE, SIZE); noiseTile(ctx, 27, 64, 0.06); });
/** Travertinstellplatten als schmaler Randstreifen. */
export const travertinstell = () => make('travertinstell', 2.4, 0.75, (ctx) => {
slabs(ctx, 2.4, 0.4, 0.4, 'rgb(206,192,166)', 'rgb(164,154,135)', 0.012, 13, 8);
noiseTile(ctx, 23, 32, 0.05);
grain(ctx, 33, 0.05, 2);
});
export const travertinstell = () => makeRelief('travertinstell', 2.4, 0.73, 3.0,
(ctx) => {
slabs(ctx, 2.4, 0.4, 0.4, 'rgb(206,192,166)', 'rgb(155,144,124)', 0.012, 13, 8);
travertinFinish(ctx, 23);
},
(ctx) => slabHeight(ctx, 2.4, 0.4, 0.4, 0.012, 0.012));
/** Keramikplatten 60 x 60 x 2 cm, grau - bauseits vorhanden (siehe Fotos). */
export const keramik = () => make('keramik', 2.4, 0.55, (ctx) => {
slabs(ctx, 2.4, 0.6, 0.6, 'rgb(160,158,154)', 'rgb(128,127,124)', 0.006, 14, 5);
noiseTile(ctx, 24, 48, 0.035);
grain(ctx, 34, 0.04, 1);
});
export const keramik = () => makeRelief('keramik', 2.4, 0.52, 2.6,
(ctx) => {
slabs(ctx, 2.4, 0.6, 0.6, 'rgb(160,158,154)', 'rgb(118,117,114)', 0.006, 14, 5);
noiseTile(ctx, 24, 48, 0.035);
noiseTile(ctx, 124, 160, 0.02);
grain(ctx, 34, 0.04, 1);
},
(ctx) => slabHeight(ctx, 2.4, 0.6, 0.6, 0.006, 0.008));
/** Tegula-Pflastersteine grau, kleinformatig und leicht unregelmaessig. */
export const tegula = () => make('tegula', 1.2, 0.85, (ctx) => {
const px = SIZE / 1.2;
ctx.fillStyle = 'rgb(118,116,114)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(15);
const sw = 0.15, sh = 0.1;
const cols = Math.round(1.2 / sw), rows = Math.round(1.2 / sh);
for (let row = 0; row < rows; row++) {
const off = (row % 2) * 0.5;
for (let col = -1; col <= cols; col++) {
const v = 140 + rnd() * 34;
ctx.fillStyle = `rgb(${v | 0},${(v - 2) | 0},${(v - 5) | 0})`;
const x = (col + off) * sw * px + 1.2;
const y = row * sh * px + 1.2;
ctx.fillRect(x, y, sw * px - 2.4, sh * px - 2.4);
export const tegula = () => makeRelief('tegula', 1.2, 0.84, 3.4,
(ctx) => {
const px = SIZE / 1.2;
ctx.fillStyle = 'rgb(104,102,100)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(15);
const sw = 0.15, sh = 0.1;
const cols = Math.round(1.2 / sw), rows = Math.round(1.2 / sh);
for (let row = 0; row < rows; row++) {
const off = (row % 2) * 0.5;
for (let col = -1; col <= cols; col++) {
const v = 138 + rnd() * 36;
ctx.fillStyle = `rgb(${v | 0},${(v - 2) | 0},${(v - 6) | 0})`;
ctx.fillRect((col + off) * sw * px + 1.6, row * sh * px + 1.6, sw * px - 3.2, sh * px - 3.2);
}
}
}
noiseTile(ctx, 25, 24, 0.06);
grain(ctx, 35, 0.09, 1);
});
noiseTile(ctx, 25, 24, 0.06);
grain(ctx, 35, 0.10, 1);
},
(ctx) => {
const px = SIZE / 1.2;
ctx.fillStyle = 'rgb(40,40,40)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(115);
const sw = 0.15, sh = 0.1;
for (let row = 0; row < Math.round(1.2 / sh); row++) {
const off = (row % 2) * 0.5;
for (let col = -1; col <= Math.round(1.2 / sw); col++) {
const v = 200 + rnd() * 45;
ctx.fillStyle = `rgb(${v | 0},${v | 0},${v | 0})`;
ctx.fillRect((col + off) * sw * px + 2.2, row * sh * px + 2.2, sw * px - 4.4, sh * px - 4.4);
}
}
});
/** Verbundsteine des Nachbarn, etwas dunkler als Tegula. */
export const verbundstein = () => make('verbundstein', 1.2, 0.88, (ctx) => {
const px = SIZE / 1.2;
ctx.fillStyle = 'rgb(110,108,106)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(16);
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
const v = 126 + rnd() * 30;
ctx.fillStyle = `rgb(${v | 0},${v | 0},${(v - 4) | 0})`;
ctx.fillRect(col * 0.15 * px + 1.5, row * 0.15 * px + 1.5, 0.15 * px - 3, 0.15 * px - 3);
export const verbundstein = () => makeRelief('verbundstein', 1.2, 0.88, 3.0,
(ctx) => {
const px = SIZE / 1.2;
ctx.fillStyle = 'rgb(100,98,96)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(16);
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
const v = 124 + rnd() * 32;
ctx.fillStyle = `rgb(${v | 0},${v | 0},${(v - 4) | 0})`;
ctx.fillRect(col * 0.15 * px + 1.8, row * 0.15 * px + 1.8, 0.15 * px - 3.6, 0.15 * px - 3.6);
}
}
}
grain(ctx, 36, 0.08, 1);
});
grain(ctx, 36, 0.08, 1);
},
(ctx) => slabHeight(ctx, 1.2, 0.15, 0.15, 0.008, 0.006));
/** Rasen: gemaehte Flaeche mit leichten Bahnen. */
export const rasen = () => make('rasen', 2.0, 0.95, (ctx) => {
@@ -234,11 +373,11 @@ export const schotterwiese = () => make('schotterwiese', 2.0, 0.98, (ctx) => {
/** Dekorkies in den Pflanzflaechen und um den Flammengrill. */
export const dekorkies = () => make('dekorkies', 1.0, 0.99, (ctx) => {
ctx.fillStyle = 'rgb(150,144,132)';
ctx.fillStyle = 'rgb(118,111,99)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(46);
for (let i = 0; i < 6500; i++) {
const v = 128 + rnd() * 90;
const v = 104 + rnd() * 78;
ctx.fillStyle = `rgb(${v | 0},${(v - 5) | 0},${(v - 16) | 0})`;
ctx.beginPath();
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 2 + rnd() * 4, 2 + rnd() * 3, rnd() * 3.14, 0, Math.PI * 2);
@@ -276,26 +415,45 @@ export const spielsand = () => make('spielsand', 1.5, 1.0, (ctx) => {
// Bauteile
// ---------------------------------------------------------------------------
/** Travertin-Mauerwerk in Schichten, wie auf den Referenzfotos des Plans. */
export const travertinMauer = () => make('travertinMauer', 1.6, 0.7, (ctx) => {
const px = SIZE / 1.6;
ctx.fillStyle = 'rgb(168,152,126)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(61);
const h = 0.2;
for (let row = 0; row < Math.round(1.6 / h); row++) {
let x = -((row % 2) * 0.3);
while (x < 1.6) {
const w = 0.4 + rnd() * 0.4;
const v = 202 + rnd() * 26;
ctx.fillStyle = `rgb(${v | 0},${(v - 17) | 0},${(v - 44) | 0})`;
ctx.fillRect(x * px + 1, row * h * px + 1, w * px - 2, h * px - 2);
x += w;
/**
* Travertin als Steinoberflaeche - warm, geschichtet, mit den typischen
* Poren. Die Schichtung der Mauer entsteht in walls.ts aus echten Bloecken,
* die Textur liefert nur das Material.
*/
export const travertinMauer = () => makeRelief('travertinMauer', 0.7, 0.68, 1.9,
(ctx) => {
ctx.fillStyle = 'rgb(203,183,148)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(61);
// liegende Sedimentbaender, bewusst zurueckhaltend - sonst wirkt der Stein
// aus der Naehe fleckig statt geschichtet
for (let i = 0; i < 34; i++) {
const y = rnd() * SIZE;
const hgt = 3 + rnd() * 12;
ctx.fillStyle = `rgba(${(188 + rnd() * 20) | 0},${(168 + rnd() * 18) | 0},${(134 + rnd() * 16) | 0},0.35)`;
ctx.beginPath();
ctx.moveTo(0, y);
for (let x = 0; x <= SIZE; x += 24) ctx.lineTo(x, y + Math.sin(x / 55 + i) * 4);
for (let x = SIZE; x >= 0; x -= 24) ctx.lineTo(x, y + hgt + Math.sin(x / 48 + i * 1.7) * 4);
ctx.closePath();
ctx.fill();
}
}
noiseTile(ctx, 62, 40, 0.045);
grain(ctx, 63, 0.05, 2);
});
travertinFinish(ctx, 62);
grain(ctx, 63, 0.05, 2);
},
(ctx) => {
ctx.fillStyle = 'rgb(190,190,190)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(64);
// Poren als feine Vertiefungen
for (let i = 0; i < 2400; i++) {
ctx.fillStyle = `rgba(60,60,60,${0.18 + rnd() * 0.32})`;
ctx.beginPath();
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 0.8 + rnd() * 2.2, 0.6 + rnd() * 1.1, rnd() * 0.7, 0, Math.PI * 2);
ctx.fill();
}
noiseTile(ctx, 65, 96, 0.07);
});
/** Sichtbeton der Winkelelemente und der Nachbarmauer. */
export const beton = () => make('beton', 2.0, 0.8, (ctx) => {
@@ -340,28 +498,46 @@ export const holzscheiter = () => make('holzscheiter', 1.0, 0.9, (ctx) => {
});
/** Vertikale Holzschalung der Fassade - graubraun vergraut, wie auf den Fotos. */
export const fassadeHolz = () => make('fassadeHolz', 1.2, 0.78, (ctx) => {
const px = SIZE / 1.2;
ctx.fillStyle = 'rgb(124,114,100)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(71);
const w = 0.06;
for (let col = 0; col < Math.round(1.2 / w); col++) {
const v = 152 + rnd() * 32;
ctx.fillStyle = `rgb(${v | 0},${(v - 8) | 0},${(v - 20) | 0})`;
ctx.fillRect(col * w * px + 1.5, 0, w * px - 3, SIZE);
ctx.strokeStyle = 'rgba(84,76,66,0.45)';
ctx.lineWidth = 1;
for (let i = 0; i < 14; i++) {
const y = rnd() * SIZE;
ctx.beginPath();
ctx.moveTo(col * w * px + 2, y);
ctx.lineTo(col * w * px + w * px - 3, y + (rnd() - 0.5) * 5);
ctx.stroke();
export const fassadeHolz = () => makeRelief('fassadeHolz', 1.2, 0.78, 3.6,
(ctx) => {
const px = SIZE / 1.2;
ctx.fillStyle = 'rgb(96,88,76)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(71);
const w = 0.06;
for (let col = 0; col < Math.round(1.2 / w); col++) {
const v = 150 + rnd() * 34;
ctx.fillStyle = `rgb(${v | 0},${(v - 9) | 0},${(v - 22) | 0})`;
ctx.fillRect(col * w * px + 1.5, 0, w * px - 3, SIZE);
// Maserung in Faserrichtung
ctx.strokeStyle = 'rgba(84,76,66,0.42)';
ctx.lineWidth = 1;
for (let i = 0; i < 16; i++) {
const y = rnd() * SIZE;
ctx.beginPath();
ctx.moveTo(col * w * px + 2, y);
ctx.lineTo(col * w * px + w * px - 3, y + (rnd() - 0.5) * 6);
ctx.stroke();
}
}
}
noiseTile(ctx, 72, 32, 0.03);
});
noiseTile(ctx, 72, 32, 0.035);
},
(ctx) => {
// Hoehenbild: jede Latte gewoelbt, dazwischen die Schattenfuge
const px = SIZE / 1.2;
ctx.fillStyle = 'rgb(30,30,30)';
ctx.fillRect(0, 0, SIZE, SIZE);
const w = 0.06;
for (let col = 0; col < Math.round(1.2 / w); col++) {
const x0 = col * w * px + 2.2, wPx = w * px - 4.4;
const g = ctx.createLinearGradient(x0, 0, x0 + wPx, 0);
g.addColorStop(0, 'rgb(150,150,150)');
g.addColorStop(0.5, 'rgb(240,240,240)');
g.addColorStop(1, 'rgb(150,150,150)');
ctx.fillStyle = g;
ctx.fillRect(x0, 0, wPx, SIZE);
}
});
/** Heller Betonsockel unter der Holzschalung. */
export const sockel = () => make('sockel', 2.0, 0.72, (ctx) => {
@@ -399,3 +575,82 @@ export const spritzschutz = () => make('spritzschutz', 1.0, 0.98, (ctx) => {
ctx.fill();
}
});
/**
* Rohboden mit spaerlichem Bewuchs. Das ist der Grundton des Gelaendes -
* die eigentlichen Flaechen des Plans liegen als eigene Meshes darueber.
*/
export const rohboden = () => make('rohboden', 2.0, 0.99, (ctx) => {
ctx.fillStyle = 'rgb(104,122,68)';
ctx.fillRect(0, 0, SIZE, SIZE);
noiseTile(ctx, 81, 14, 0.11);
noiseTile(ctx, 82, 56, 0.06);
const rnd = mulberry32(83);
// Wiesenhalme, unregelmaessiger und matter als der gemaehte Rasen
for (let i = 0; i < 4200; i++) {
const x = rnd() * SIZE, y = rnd() * SIZE;
const v = rnd();
ctx.strokeStyle = `rgba(${(88 + v * 66) | 0},${(112 + v * 62) | 0},${(52 + v * 44) | 0},0.6)`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (rnd() - 0.5) * 4, y - 3 - rnd() * 5);
ctx.stroke();
}
// vereinzelt offene Bodenstellen
for (let i = 0; i < 500; i++) {
ctx.fillStyle = `rgba(122,110,84,${0.15 + rnd() * 0.25})`;
ctx.beginPath();
ctx.arc(rnd() * SIZE, rnd() * SIZE, 2 + rnd() * 6, 0, Math.PI * 2);
ctx.fill();
}
grain(ctx, 84, 0.04, 1);
});
/**
* Laub fuer geschnittene Hecken: dichte, kleinblaettrige Oberflaeche mit
* Relief. Ohne das wirken die Heckenkoerper wie lackierte Bloecke.
*/
function laub(key: string, base: string, seed: number, leaf: number): Belag {
return makeRelief(key, 0.55, 0.97, 2.6,
(ctx) => {
ctx.fillStyle = base;
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(seed);
const [r, g, b] = base.match(/\d+/g)!.map(Number);
for (let i = 0; i < 5200; i++) {
const v = (rnd() - 0.45) * 70;
ctx.fillStyle = `rgb(${Math.max(0, Math.min(255, r + v)) | 0},${Math.max(0, Math.min(255, g + v * 1.15)) | 0},${Math.max(0, Math.min(255, b + v * 0.7)) | 0})`;
ctx.save();
ctx.translate(rnd() * SIZE, rnd() * SIZE);
ctx.rotate(rnd() * Math.PI);
ctx.beginPath();
ctx.ellipse(0, 0, leaf * (0.7 + rnd() * 0.6), leaf * (0.4 + rnd() * 0.35), 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
noiseTile(ctx, seed + 5, 12, 0.10);
},
(ctx) => {
ctx.fillStyle = 'rgb(120,120,120)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(seed + 11);
for (let i = 0; i < 3600; i++) {
const v = 90 + rnd() * 150;
ctx.fillStyle = `rgb(${v | 0},${v | 0},${v | 0})`;
ctx.save();
ctx.translate(rnd() * SIZE, rnd() * SIZE);
ctx.rotate(rnd() * Math.PI);
ctx.beginPath();
ctx.ellipse(0, 0, leaf * 0.9, leaf * 0.5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
noiseTile(ctx, seed + 13, 10, 0.16);
});
}
/** Eibe: dunkel, feinnadelig. */
export const laubEibe = () => laub('laubEibe', 'rgb(64,92,52)', 201, 3.4);
/** Liguster: mittelgruen, groesseres Blatt. */
export const laubLiguster = () => laub('laubLiguster', 'rgb(85,118,55)', 211, 5.2);
+235 -24
View File
@@ -6,12 +6,12 @@
* statt Blattgeometrie - das bleibt auch auf dem Handy fluessig.
*/
import {
BoxGeometry, BufferAttribute, Color, CylinderGeometry, Group, IcosahedronGeometry,
InstancedMesh, Matrix4, Mesh, MeshStandardMaterial, Object3D, Quaternion, Vector3,
type BufferGeometry,
BoxGeometry, BufferAttribute, BufferGeometry, Color, CylinderGeometry, DoubleSide,
Group, IcosahedronGeometry, InstancedMesh, Matrix4, Mesh, MeshStandardMaterial,
Object3D, Quaternion, Vector3,
} from 'three';
import type { Plant, Tree } from '../site';
import { boxGeometry } from './geometry';
import type { Plant, Tree, Vec2 } from '../site';
import * as tex from './textures';
function rng(seed: number) {
let a = seed >>> 0;
@@ -108,6 +108,197 @@ function buildTree(t: Tree, y: number): Group {
return g;
}
/**
* Fasst Pflanzstandorte zu Reihen zusammen: benachbarte Punkte werden zu einem
* Polyzug verkettet. So wird aus einer Reihe Einzelpflanzen eine Hecke.
*/
function chainRows(points: Vec2[], maxGap: number): Vec2[][] {
const open = points.map((p) => [...p] as Vec2);
const rows: Vec2[][] = [];
while (open.length) {
const row: Vec2[] = [open.pop()!];
// in beide Richtungen weitersuchen
for (const end of [1, 0]) {
for (;;) {
const from = end ? row[row.length - 1] : row[0];
let best = -1, bd = maxGap;
for (let i = 0; i < open.length; i++) {
const d = Math.hypot(open[i][0] - from[0], open[i][1] - from[1]);
if (d < bd) { bd = d; best = i; }
}
if (best < 0) break;
const [p] = open.splice(best, 1);
if (end) row.push(p); else row.unshift(p);
}
}
rows.push(row);
}
return rows;
}
/** Douglas-Peucker: begradigt eine Punktreihe zu einem knappen Polyzug. */
function simplifyLine(points: Vec2[], tol: number): Vec2[] {
if (points.length <= 2) return points;
const first = points[0], last = points[points.length - 1];
let idx = -1, maxD = tol;
const dx = last[0] - first[0], dz = last[1] - first[1];
const l2 = dx * dx + dz * dz;
for (let i = 1; i < points.length - 1; i++) {
const px = points[i][0] - first[0], pz = points[i][1] - first[1];
const t = l2 > 0 ? Math.max(0, Math.min(1, (px * dx + pz * dz) / l2)) : 0;
const d = Math.hypot(px - dx * t, pz - dz * t);
if (d > maxD) { maxD = d; idx = i; }
}
if (idx < 0) return [first, last];
return [
...simplifyLine(points.slice(0, idx + 1), tol).slice(0, -1),
...simplifyLine(points.slice(idx), tol),
];
}
/** Trennt einen Polyzug an Knicken ueber `maxTurnDeg` in eigene Abschnitte. */
function splitAtCorners(line: Vec2[], maxTurnDeg: number): Vec2[][] {
if (line.length <= 2) return [line];
const out: Vec2[][] = [];
let current: Vec2[] = [line[0]];
for (let i = 1; i < line.length - 1; i++) {
current.push(line[i]);
const a = Math.atan2(line[i][1] - line[i - 1][1], line[i][0] - line[i - 1][0]);
const b = Math.atan2(line[i + 1][1] - line[i][1], line[i + 1][0] - line[i][0]);
let turn = Math.abs(((b - a) * 180) / Math.PI) % 360;
if (turn > 180) turn = 360 - turn;
if (turn > maxTurnDeg) {
out.push(current);
current = [line[i]];
}
}
current.push(line[line.length - 1]);
out.push(current);
return out.filter((p) => p.length >= 2);
}
/**
* Geschnittene Hecke entlang eines Polyzugs: kompakter Koerper mit leicht
* unruhiger, weicher Oberkante - so wirkt sie als Sichtschutz und nicht als
* Reihe von Kugeln.
*/
function hedgeMesh(
row: Vec2[], hgt: number, width: number,
mat: MeshStandardMaterial, height: (x: number, z: number) => number,
rnd: () => number, name: string,
): Mesh {
// Querschnitt einer geschnittenen Hecke: senkrechte Flanken, oben gerundet.
// (u quer zur Reihe, v in der Hoehe, beide relativ zu Breite und Hoehe)
// Der Fuss liegt bewusst unter Terrain, damit die Hecke nirgends schwebt.
const profile: Vec2[] = [
[-0.44, -0.14], [-0.50, 0.06], [-0.50, 0.72], [-0.46, 0.88], [-0.34, 0.97],
[0.00, 1.00],
[0.34, 0.97], [0.46, 0.88], [0.50, 0.72], [0.50, 0.06], [0.44, -0.14],
];
// Die Pflanzstandorte streuen leicht. Erst begradigen, sonst verdreht sich
// das Profil an jedem Zickzack.
const line = row;
if (line.length < 2) return new Mesh(new BufferGeometry(), mat);
// Enden etwas ueberstehen lassen, damit die Hecke nicht abrupt abbricht
const ends: Vec2[] = [...line];
const grow = (from: Vec2, to: Vec2, by: number): Vec2 => {
const dx = to[0] - from[0], dz = to[1] - from[1];
const l = Math.hypot(dx, dz) || 1;
return [to[0] + (dx / l) * by, to[1] + (dz / l) * by];
};
ends[0] = grow(line[1], line[0], 0.22);
ends[ends.length - 1] = grow(line[line.length - 2], line[line.length - 1], 0.22);
// Stationen mit gemittelter Normale an den Knicken
const stations: { x: number; z: number; nx: number; nz: number; h: number; w: number }[] = [];
const dirs = ends.slice(1).map((p, i) => {
const dx = p[0] - ends[i][0], dz = p[1] - ends[i][1];
const l = Math.hypot(dx, dz) || 1;
return [dx / l, dz / l] as Vec2;
});
const normalAt = (vertexIndex: number): Vec2 => {
const a = dirs[Math.max(0, vertexIndex - 1)];
const b = dirs[Math.min(dirs.length - 1, vertexIndex)];
const nx = -(a[1] + b[1]) / 2, nz = (a[0] + b[0]) / 2;
const l = Math.hypot(nx, nz) || 1;
return [nx / l, nz / l];
};
for (let i = 0; i < ends.length - 1; i++) {
const [ax, az] = ends[i];
const [bx, bz] = ends[i + 1];
const len = Math.hypot(bx - ax, bz - az);
const steps = Math.max(1, Math.round(len / 0.35));
const n0 = normalAt(i), n1 = normalAt(i + 1);
for (let k = i === 0 ? 0 : 1; k <= steps; k++) {
const t = k / steps;
const nx = n0[0] * (1 - t) + n1[0] * t;
const nz = n0[1] * (1 - t) + n1[1] * t;
const l = Math.hypot(nx, nz) || 1;
stations.push({
x: ax + (bx - ax) * t, z: az + (bz - az) * t,
nx: nx / l, nz: nz / l,
h: hgt * (0.96 + rnd() * 0.08),
w: width * (0.96 + rnd() * 0.08),
});
}
}
const pos: number[] = [];
const uv: number[] = [];
let run = 0;
for (let i = 0; i < stations.length - 1; i++) {
const s0 = stations[i], s1 = stations[i + 1];
const y0 = height(s0.x, s0.z) - 0.06;
const y1 = height(s1.x, s1.z) - 0.06;
const seg = Math.hypot(s1.x - s0.x, s1.z - s0.z);
const at = (s: typeof s0, y: number, p: Vec2) =>
[s.x + s.nx * p[0] * s.w, y + p[1] * s.h, s.z + s.nz * p[0] * s.w] as [number, number, number];
// v folgt der Bogenlaenge des Profils, sonst zieht sich das Laub in die Laenge
let arc = 0;
const U0 = run / 0.55, U1 = (run + seg) / 0.55;
for (let k = 0; k < profile.length - 1; k++) {
const A = at(s0, y0, profile[k]);
const B = at(s1, y1, profile[k]);
const C = at(s1, y1, profile[k + 1]);
const D = at(s0, y0, profile[k + 1]);
const step = Math.hypot(
(profile[k + 1][0] - profile[k][0]) * s0.w,
(profile[k + 1][1] - profile[k][1]) * s0.h,
);
const v0 = arc / 0.55, v1 = (arc + step) / 0.55;
arc += step;
// Wickelsinn so, dass die Normalen nach aussen zeigen
pos.push(...A, ...D, ...C, ...A, ...C, ...B);
uv.push(U0, v0, U0, v1, U1, v1, U0, v0, U1, v1, U1, v0);
}
// Stirnflaechen an den Enden
if (i === 0 || i === stations.length - 2) {
const s = i === 0 ? s0 : s1;
const y = i === 0 ? y0 : y1;
const c = at(s, y, [0, 0.5]);
for (let k = 0; k < profile.length - 1; k++) {
const A = at(s, y, profile[k]);
const B = at(s, y, profile[k + 1]);
if (i === 0) pos.push(...c, ...A, ...B); else pos.push(...c, ...B, ...A);
uv.push(0.5, 0.5, 0, 0, 1, 1);
}
}
run += seg;
}
const geo = new BufferGeometry();
geo.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
geo.setAttribute('uv', new BufferAttribute(new Float32Array(uv), 2));
geo.computeVertexNormals();
const mesh = new Mesh(geo, mat);
mesh.name = name;
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
/** Baut eine InstancedMesh aus Transformationen. */
function instances(
geo: BufferGeometry, mat: MeshStandardMaterial,
@@ -148,16 +339,34 @@ export function buildVegetation(
const r = rng(4242);
// --- Eiben `Hilli` 80/100 cm: aufrechte, dichte Polster ------------------
// Plan: Pflanzhoehe 80/100 cm, Breite rund 45 cm. Die Streckung steckt in
// der Geometrie, die Instanzen werden nur gleichmaessig skaliert.
const eiben = plants.filter((p) => p.type === 'eibe' && p.x !== undefined);
const eibeGeo = blob(1, 2, 0.028, 3, 1.9);
const eibeMesh = instances(eibeGeo, foliage(LAUB.eibe, 0.96), eiben.map((p) => {
const s = Math.max(0.22, (p.r ?? 0.2) * 1.1) * (0.92 + r() * 0.18);
return { x: p.x!, y: height(p.x!, p.z!) + s * 1.9, z: p.z!, s, rot: r() * 6.28 };
}), 'Eiben `Hilli`');
if (eibeMesh) group.add(eibeMesh);
// --- Eibenhecke `Hilli` 80/100 cm ---------------------------------------
// Der Plan setzt die Eiben in dichten Reihen: das ist eine geschnittene
// Hecke und der eigentliche Sichtschutz im Garten, keine Einzelkugeln.
const eiben = plants.filter((p) => p.type === 'eibe' && p.x !== undefined)
.map((p) => [p.x!, p.z!] as Vec2);
// Reihen begradigen und an scharfen Knicken trennen: eine gemiterte Hecke
// um eine 90-Grad-Ecke herum verdreht sich sonst.
const rows = chainRows(eiben, 2.2).flatMap((row) => splitAtCorners(simplifyLine(row, 0.22), 32));
const eibeLaub = tex.laubEibe();
const eibeMat = new MeshStandardMaterial({
map: eibeLaub.map, normalMap: eibeLaub.normalMap ?? null,
normalScale: eibeLaub.normalScale, roughness: eibeLaub.roughness, metalness: 0,
side: DoubleSide,
});
for (const row of rows) {
if (row.length >= 2) {
group.add(hedgeMesh(row, 0.95, 0.52, eibeMat, height, r, 'Eibenhecke `Hilli`'));
} else {
// Einzelstand: als Solitaerpolster
const [x, z] = row[0];
const geo = blob(0.26, 2, 0.05, 3, 1.8);
const m = new Mesh(geo, eibeMat);
m.position.set(x, height(x, z) + 0.47, z);
m.castShadow = true;
m.receiveShadow = true;
group.add(m);
}
}
// --- Beerenstraeucher und Heidelbeeren ----------------------------------
const straeucher = plants.filter((p) => p.type === 'strauch' && p.x !== undefined);
@@ -205,20 +414,22 @@ export function buildVegetation(
if (krautMesh) group.add(krautMesh);
// --- Ligusterhecke, Hoehe 170 cm, Breite 75 cm --------------------------
const ligusterLaub = tex.laubLiguster();
const ligusterMat = new MeshStandardMaterial({
map: ligusterLaub.map, normalMap: ligusterLaub.normalMap ?? null,
normalScale: ligusterLaub.normalScale, roughness: ligusterLaub.roughness, metalness: 0,
side: DoubleSide,
});
for (const p of plants) {
if (p.type !== 'hecke' || !p.polygon) continue;
const poly = p.polygon;
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
for (const [x, z] of poly) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); z0 = Math.min(z0, z); z1 = Math.max(z1, z); }
const cx = (x0 + x1) / 2, cz = (z0 + z1) / 2;
const y = height(cx, cz);
const h = p.height ?? 1.7;
const geo = boxGeometry(cx, cz, x1 - x0, z1 - z0, y, y + h, 0, 0.8);
const m = new Mesh(geo, foliage(LAUB.liguster, 0.97));
m.castShadow = true;
m.receiveShadow = true;
m.name = 'Ligusterhecke';
group.add(m);
// Laengsachse des Streifens bestimmen und als Hecke daran entlang bauen
const along: Vec2[] = (x1 - x0) > (z1 - z0)
? [[x0, (z0 + z1) / 2], [x1, (z0 + z1) / 2]]
: [[(x0 + x1) / 2, z0], [(x0 + x1) / 2, z1]];
group.add(hedgeMesh(along, p.height ?? 1.7, p.width ?? 0.75, ligusterMat, height, r, 'Ligusterhecke'));
}
return group;
+293
View File
@@ -0,0 +1,293 @@
/**
* Mauern als gebaute Elemente, nicht als hochgezogene Flaechen.
*
* Der Plan nennt vier verschiedene Konstruktionen, die auch verschieden
* aussehen muessen:
*
* - **Travertinmauer / Sitzmauer** – Schichtmauerwerk aus einzelnen Bloecken
* mit Lagerfugen und einer ueberstehenden Abdeckplatte.
* - **Winkelelemente aus Beton** – Fertigteile von rund 1 m Breite, einzeln
* versetzt, mit sichtbaren Stossfugen und leicht geneigter Ansichtsflaeche.
* - **Betonmauer Nachbar** – Ortbeton in Schaltafelfeldern mit Ankerloechern
* und abgefaster Krone.
* - **Lamellenwand** – senkrechte Holzlamellen im Rhythmus des Plans.
* - **Holzscheiterwand** – Stahlrahmen, gefuellt mit gestapelten Scheitern.
*/
import {
BufferGeometry, Group, Mesh, MeshStandardMaterial,
type MeshStandardMaterialParameters,
} from 'three';
import type { Vec2, Wall } from '../site';
import { boxGeometry, mergeGeometries, wallGeometry } 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,
});
}
function rng(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x9e3779b9) >>> 0;
let t = Math.imul(a ^ (a >>> 16), 0x21f0aaad);
t = Math.imul(t ^ (t >>> 15), 0x735a2d97);
return ((t ^ (t >>> 15)) >>> 0) / 4294967296;
};
}
/** 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;
}
interface Axis {
/** Gesamtlaenge des Polyzugs. */
length: number;
/** Punkt, Richtung und Normale bei Bogenlaenge s. */
at(s: number): { x: number; z: number; dx: number; dz: number; angle: number; t: number };
}
function axisOf(path: Vec2[]): Axis {
const segs = path.slice(1).map((p, i) => ({
a: path[i], b: p, len: Math.hypot(p[0] - path[i][0], p[1] - path[i][1]),
}));
const length = segs.reduce((s, g) => s + g.len, 0);
return {
length,
at(s: number) {
let want = Math.max(0, Math.min(length, s));
for (let i = 0; i < segs.length; i++) {
const g = segs[i];
if (want <= g.len || i === segs.length - 1) {
const u = g.len > 0 ? Math.min(1, want / g.len) : 0;
const dx = (g.b[0] - g.a[0]) / (g.len || 1);
const dz = (g.b[1] - g.a[1]) / (g.len || 1);
return {
x: g.a[0] + (g.b[0] - g.a[0]) * u,
z: g.a[1] + (g.b[1] - g.a[1]) * u,
dx, dz,
angle: (Math.atan2(dz, dx) * 180) / Math.PI,
t: length > 0 ? s / length : 0,
};
}
want -= g.len;
}
return { x: path[0][0], z: path[0][1], dx: 1, dz: 0, angle: 0, t: 0 };
},
};
}
/**
* Schichtmauerwerk: Lage fuer Lage aus einzelnen Bloecken, darueber eine
* ueberstehende Abdeckplatte. Fugen entstehen durch echte Zwischenraeume.
*/
function masonry(
axis: Axis, thickness: number,
top: (t: number) => number, base: (t: number) => number,
seed: number,
): { blocks: BufferGeometry; coping: BufferGeometry } {
const COURSE = 0.19; // Schichthoehe
const JOINT = 0.012; // Lagerfuge
const CAP = 0.06; // Abdeckplatte
const rnd = rng(seed);
const blocks: BufferGeometry[] = [];
const topMid = top(0.5);
const baseMid = base(0.5);
const wallTop = topMid - CAP;
const courses = Math.max(1, Math.round((wallTop - baseMid) / COURSE));
const courseH = (wallTop - baseMid) / courses;
for (let c = 0; c < courses; c++) {
const y0 = baseMid + c * courseH;
const y1 = y0 + courseH - JOINT;
// Jede zweite Lage beginnt mit einem halben Stein - so versetzen sich die
// Stossfugen, ohne dass Luecken entstehen.
let s = 0;
let first = true;
while (s < axis.length - 0.02) {
let len = 0.42 + rnd() * 0.40;
if (first && c % 2 === 1) len *= 0.55;
first = false;
len = Math.min(len, axis.length - s);
// Reststueck lieber an den letzten Stein anhaengen als einen Splitter setzen
if (axis.length - (s + len) < 0.18) len = axis.length - s;
const p = axis.at(s + len / 2);
const d = thickness + (rnd() - 0.5) * 0.016;
blocks.push(boxGeometry(p.x, p.z, len - JOINT, d, y0, y1, p.angle, 1.6));
s += len;
}
}
// Abdeckplatte, beidseits 2 cm ueberstehend
const coping: BufferGeometry[] = [];
const capSegs = Math.max(2, Math.round(axis.length / 0.9));
for (let i = 0; i < capSegs; i++) {
const s0 = (i / capSegs) * axis.length;
const len = axis.length / capSegs;
const p = axis.at(s0 + len / 2);
const y = top(p.t);
coping.push(boxGeometry(p.x, p.z, len - 0.006, thickness + 0.04, y - CAP, y, p.angle, 1.6));
}
return { blocks: mergeGeometries(blocks), coping: mergeGeometries(coping) };
}
/**
* Winkelelemente: Fertigteile von rund 1 m Breite. Sichtbar sind die
* Ansichtsflaeche mit den Stossfugen und die schmale Krone.
*/
function angleElements(
axis: Axis, thickness: number,
crest: (t: number) => number, foot: (t: number) => number,
): BufferGeometry {
const WIDTH = 1.0;
const JOINT = 0.015;
const parts: BufferGeometry[] = [];
const n = Math.max(1, Math.round(axis.length / WIDTH));
const w = axis.length / n;
for (let i = 0; i < n; i++) {
const p = axis.at(i * w + w / 2);
const yTop = crest(p.t);
const yBottom = foot(p.t);
// Stiel: oben duenn, unten dicker - wie ein echtes L-Element
const stem = 3;
for (let k = 0; k < stem; k++) {
const a = yBottom + ((yTop - yBottom) * k) / stem;
const b = yBottom + ((yTop - yBottom) * (k + 1)) / stem;
const th = thickness * (1.55 - 0.4 * (k / stem));
parts.push(boxGeometry(p.x, p.z, w - JOINT, th, a, b, p.angle, 2.0));
}
// Fussplatte, meist verdeckt, aber am Fuss sichtbar
parts.push(boxGeometry(
p.x - p.dz * thickness * 1.1, p.z + p.dx * thickness * 1.1,
w - JOINT, thickness * 2.4, yBottom, yBottom + 0.14, p.angle, 2.0,
));
}
return mergeGeometries(parts);
}
/** Ortbetonwand in Schaltafelfeldern mit abgefaster Krone. */
function concretePanels(
axis: Axis, thickness: number,
top: (t: number) => number, base: (t: number) => number,
): BufferGeometry {
const FIELD = 2.5;
const JOINT = 0.012;
const parts: BufferGeometry[] = [];
const n = Math.max(1, Math.round(axis.length / FIELD));
const w = axis.length / n;
for (let i = 0; i < n; i++) {
const p = axis.at(i * w + w / 2);
const yTop = top(p.t);
const yBase = base(p.t);
parts.push(boxGeometry(p.x, p.z, w - JOINT, thickness, yBase, yTop - 0.03, p.angle, 2.0));
// abgefaste Krone: schmaler Aufsatz
parts.push(boxGeometry(p.x, p.z, w - JOINT, thickness - 0.03, yTop - 0.03, yTop, p.angle, 2.0));
}
return mergeGeometries(parts);
}
export function buildWalls(walls: Wall[], height: (x: number, z: number) => number): Group {
const group = new Group();
group.name = 'Mauern und Wände';
const travertin = tex.travertinMauer();
const beton = tex.beton();
const holz = tex.fassadeHolz();
const scheiter = tex.holzscheiter();
const stahl = new MeshStandardMaterial({ color: 0x3f3a35, roughness: 0.5, metalness: 0.6 });
for (const w of walls) {
const axis = axisOf(w.path);
const top = (t: number) => {
if (w.crestProfile) return profileAt(w.crestProfile, t, 0);
if (w.top !== null) return w.top;
const p = axis.at(t * axis.length);
return height(p.x, p.z) + 0.4;
};
const base = (t: number) => {
if (w.footProfile) return profileAt(w.footProfile, t, -1);
if (w.base !== null) return w.base;
const p = axis.at(t * axis.length);
return height(p.x, p.z) - 0.5;
};
const node = new Group();
node.name = w.id;
node.userData.note = w.note;
if (w.type === 'travertin') {
const { blocks, coping } = masonry(axis, w.t, top, base, w.id.length * 977 + 13);
const body = new Mesh(blocks, material(travertin));
const cap = new Mesh(coping, material(travertin, { roughness: 0.6 }));
for (const m of [body, cap]) { m.castShadow = true; m.receiveShadow = true; node.add(m); }
} else if (w.retains) {
const m = new Mesh(angleElements(axis, w.t, top, base), material(beton));
m.castShadow = true; m.receiveShadow = true;
node.add(m);
} else if (w.type === 'beton') {
const m = new Mesh(concretePanels(axis, w.t, top, base), material(beton));
m.castShadow = true; m.receiveShadow = true;
node.add(m);
} else if (w.openSlats) {
// Lamellenwand: Rahmen aus Kanthoelzern, dazwischen senkrechte Bretter
const mat = material(holz, { roughness: 0.84 });
const parts: BufferGeometry[] = [];
const n = Math.max(2, Math.round(axis.length / 0.9));
for (let i = 0; i <= n; i++) {
const p = axis.at((i / n) * axis.length);
parts.push(boxGeometry(p.x, p.z, 0.10, w.t, base(p.t), top(p.t), p.angle, 1.2));
}
const slats = new Mesh(mergeGeometries(parts), mat);
slats.castShadow = true; slats.receiveShadow = true;
node.add(slats);
const band = new Mesh(
wallGeometry(w.path, 0.10, (t) => top(t), (t) => base(t), { segments: 8, tile: holz.tile }),
mat,
);
band.castShadow = true; band.receiveShadow = true;
node.add(band);
} else if (w.type === 'holzscheiter') {
// Stahlrahmen mit Scheiterfuellung
const y0 = base(0.5), y1 = top(0.5);
const fill = new Mesh(
boxGeometry(
(w.path[0][0] + w.path[1][0]) / 2, (w.path[0][1] + w.path[1][1]) / 2,
axis.length - 0.12, w.t - 0.08, y0 + 0.06, y1 - 0.06, axis.at(0).angle, scheiter.tile,
),
material(scheiter, { roughness: 0.92 }),
);
fill.castShadow = true; fill.receiveShadow = true;
node.add(fill);
const frameParts: BufferGeometry[] = [];
const cx = (w.path[0][0] + w.path[1][0]) / 2, cz = (w.path[0][1] + w.path[1][1]) / 2;
const ang = axis.at(0).angle;
frameParts.push(boxGeometry(cx, cz, axis.length, w.t, y0, y0 + 0.06, ang, 1));
frameParts.push(boxGeometry(cx, cz, axis.length, w.t, y1 - 0.06, y1, ang, 1));
for (const s of [-1, 1]) {
const p = axis.at(s < 0 ? 0.03 : axis.length - 0.03);
frameParts.push(boxGeometry(p.x, p.z, 0.06, w.t, y0, y1, ang, 1));
}
const frame = new Mesh(mergeGeometries(frameParts), stahl);
frame.castShadow = true;
node.add(frame);
}
group.add(node);
}
return group;
}
+13 -1
View File
@@ -10,13 +10,24 @@
export type Vec2 = [number, number];
export type SurfaceType =
| 'travertin80' | 'travertin4060' | 'travertinstell' | 'keramik'
| 'travertin80' | 'dekorkies' | 'travertinstell' | 'keramik'
| 'verbundstein' | 'tegula' | 'rasen' | 'schotterwiese' | 'beet' | 'spielsand';
export interface Surface {
type: SurfaceType;
area: number;
polygon: Vec2[];
/** Ebene a*x + b*z + c, sofern es sich um einen gebauten Belag handelt. */
plane?: [number, number, number];
note?: string;
}
/** Einzeln verlegte Travertinplatte 40x60x3 cm im Dekorkies. */
export interface Slab {
x: number;
z: number;
w: number;
d: number;
}
export interface Plant {
@@ -138,6 +149,7 @@ export interface SiteData {
};
terrain: Terrain;
surfaces: Surface[];
slabs: Slab[];
plants: Plant[];
trees: Tree[];
lamps: Lamp[];
+21 -20
View File
@@ -14,8 +14,8 @@
/** Farbe im Plan -> Belagsart. Reihenfolge egal, Zuordnung ueber Hex. */
export const SURFACE_BY_COLOR = {
'#ffffcc': 'travertin80', // Travertinplatten 80x80x3, Hauptsitzplatz West
'#fcffe7': 'travertin4060', // Travertinplatten 40x60x3 mit Dekorkies + Keramikterrasse Zollet
'#ffffcc': 'travertin80', // Travertinplatten: grosse Felder 80x80x3, kleine Rechtecke 40x60x3
'#fcffe7': 'dekorkies', // Dekorkies-Bett; noerdlich der Grenze die Keramikterrasse Zollet
'#f8ffcc': 'travertinstell',// Travertinstellplatten-Streifen
'#eee8cc': 'travertinmauer',// Mauerkronen, Sitzmauern, schwebende Stufen
'#e5ffe5': 'rasen',
@@ -115,8 +115,13 @@ export const WALLS = [
},
{
id: 'holzscheiterwand', type: 'holzscheiter',
note: 'Holzscheiterwand 150 x 200 cm hinter dem Flammengrill',
path: [[-5.75, 4.35], [-4.25, 4.35]], t: 0.45, top: 1.75, base: 0.26,
note: 'Holzscheiterwand 150 cm breit, 200 cm hoch, hinter dem Flammengrill',
path: [[-5.75, 4.35], [-4.25, 4.35]], t: 0.45, top: 2.26, base: 0.20,
},
{
id: 'stufe-gartentor', type: 'travertin',
note: 'Blockstufen beim Gartentor an der Suedwestecke',
path: [[-21.05, 2.15], [-21.05, 3.75]], t: 0.80, top: -0.13, base: -0.75,
},
{
id: 'lamellenwand', type: 'lamellen',
@@ -153,26 +158,19 @@ export const EXTRA_SURFACES = [
note: 'Gedeckter Sitzplatz 14.4 m2, Keramikplatten 60x60x2 cm',
polygon: [[-13.93, -3.06], [-8.73, -3.06], [-8.73, 0.86], [-13.93, 0.86]],
},
{
type: 'travertin80',
note: 'Schwebende Stufe Travertin, obere Tritthoehe -0.17',
polygon: [[-14.74, -2.69], [-13.93, -2.69], [-13.93, 0.56], [-14.74, 0.56]],
},
{
type: 'travertin80',
note: 'Schwebende Stufe Travertin, untere Tritthoehe -0.33',
polygon: [[-15.28, -3.08], [-14.48, -3.08], [-14.48, 0.12], [-15.28, 0.12]],
},
{
type: 'travertin80',
note: 'Schwebende Stufen beim Gartentor an der Suedwestecke',
polygon: [[-21.45, 2.15], [-20.65, 2.15], [-20.66, 3.75], [-21.45, 3.75]],
},
{
type: 'tegula',
note: 'Feuerwehrzufahrt und Zugangsweg, Tegula-Pflastersteine grau',
polygon: [[-25.60, -9.00], [-22.10, -9.00], [-22.10, 6.40], [-25.60, 6.40]],
},
{
// Die schwebenden Stufen im Plan: eine Zwischenebene zwischen dem
// gedeckten Sitzplatz (-0.02) und dem Hauptsitzplatz (rund -0.15).
// Die Hoehensprünge zeigen sich als Sichtkanten der Belaege.
type: 'travertin80',
note: 'Schwebende Stufen (Travertin) zwischen den beiden Sitzplaetzen',
polygon: [[-15.32, -3.12], [-13.87, -3.12], [-13.87, 0.60], [-15.32, 0.60]],
},
];
/**
@@ -237,8 +235,11 @@ export const BUILDING = {
*/
export const NEIGHBOUR_BUILDINGS = [
{
// Gleicher Baukoerper wie 9a, gegen Nordwesten versetzt und 1.70 m tiefer.
// Die Westkante endet bei x = -13.9, damit die Keramikterrasse des
// Nachbargartens (Plan: Schwerpunkt -17.1 / -8.6) frei bleibt.
id: 'haus-9', name: 'Haus 9 - Sharon und Alan Zollet',
outline: [[-15.60, -13.60], [-1.20, -13.60], [-1.20, -6.87], [-15.60, -6.87]],
outline: [[-13.90, -14.70], [0.20, -14.70], [0.20, -6.87], [-13.90, -6.87]],
floorLevel: -1.70, roofLevel: 4.45,
},
];
+93 -21
View File
@@ -87,12 +87,39 @@ function fitPlane(points) {
/** Bauteile, die als Mauer modelliert werden - nicht als begehbare Flaeche. */
const NOT_A_SURFACE = new Set(['travertinmauer', 'winkelelement', 'spielhaus', 'gartenhaus', 'roboter', 'ligusterhecke']);
const bboxOf = (pts) => {
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
for (const [x, z] of pts) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); z0 = Math.min(z0, z); z1 = Math.max(z1, z); }
return { x0, z0, x1, z1, w: x1 - x0, d: z1 - z0 };
};
/**
* Einzelne Travertinplatten 40x60x3 cm. Der Plan zeichnet sie als Reihe
* loser Platten im Dekorkies - kein geschlossener Belag. Sie werden als
* erhabene Platten modelliert, nicht als Flaeche.
*/
const slabs = [];
const surfaces = [];
for (const s of raw.shapes) {
if (s.op !== 'f' || s.circle) continue;
const type = SURFACE_BY_COLOR[s.hex];
let type = SURFACE_BY_COLOR[s.hex];
if (!type) continue;
if (s.area < 0.35) continue;
const b = bboxOf(s.pts);
// Lose 40x60-Platten (Plan: 0.40 breit, 0.49 bis 0.80 lang)
if (s.hex === '#ffffcc' && s.area < 0.6 && Math.abs(b.w - 0.4) < 0.06 && b.d > 0.4 && b.d < 0.9) {
slabs.push({
x: round((b.x0 + b.x1) / 2), z: round((b.z0 + b.z1) / 2),
w: round(b.w, 2), d: round(b.d, 2),
});
continue;
}
// Der Plan verwendet denselben hellen Ton fuer Dekorkies und fuer die
// Keramikterrasse des Nachbarn; unterschieden wird ueber die Lage.
if (type === 'dekorkies' && (b.z0 + b.z1) / 2 < -6.6) type = 'keramik';
if (s.area < 0.3) continue;
if (NOT_A_SURFACE.has(type)) continue;
const poly = polygonArea(s.pts) < 0 ? [...s.pts].reverse() : s.pts;
surfaces.push({ type, area: round(s.area, 2), polygon: poly.map((p) => [round(p[0]), round(p[1])]) });
@@ -162,26 +189,66 @@ const spots = [
// Die Kote 1.47 gehoert zum Spielhauspodest, nicht zum Gelaende.
const terrainSpots = spots.filter((s) => s.zone && s.y < 1.2);
/** Flaechen, die als Ebene ausgebildet sind - dort wird das Raster begradigt. */
const FLAT_TYPES = new Set(['travertin80', 'travertin4060', 'travertinstell', 'keramik', 'verbundstein', 'tegula', 'spielsand']);
/**
* Belaege sind gebaute Flaechen und muessen eben sein. Fuer jede solche Flaeche
* wird eine Ebene bestimmt - aus den Koten darin und knapp daneben, mit
* begrenztem Gefaelle. Die Ebene geht als `plane` mit ins Szenenmodell; das
* Gelaenderaster wird darauf gezwungen und in diesen Feldern nicht geglaettet.
*/
const FLAT_TYPES = new Set([
'travertin80', 'travertin4060', 'travertinstell', 'keramik',
'verbundstein', 'tegula', 'spielsand', 'dekorkies',
]);
/** Abstand eines Punktes zum Polygonrand (positiv aussen wie innen). */
function distToPolygon(x, z, poly) {
let best = Infinity;
for (let i = 0; i < poly.length; i++) {
const [ax, az] = poly[i], [bx, bz] = poly[(i + 1) % poly.length];
const dx = bx - ax, dz = bz - az;
const l2 = dx * dx + dz * dz;
const t = l2 > 0 ? Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / l2)) : 0;
best = Math.min(best, Math.hypot(x - (ax + dx * t), z - (az + dz * t)));
}
return best;
}
const flatSurfaces = [];
for (const s of surfaces) {
if (!FLAT_TYPES.has(s.type) || s.area < 1.0) continue;
if (!FLAT_TYPES.has(s.type) || s.area < 0.6) continue;
const inside = terrainSpots.filter((sp) => pointInPolygon(sp.x, sp.z, s.polygon));
if (!inside.length) continue;
let plane = inside.length >= 3 ? fitPlane(inside) : null;
// Fast kollineare Koten liefern wilde Ebenen. Nur uebernehmen, wenn das
// Gefaelle plausibel ist (< 10 %) und alle Koten getroffen werden.
// Nur Koten derselben Gelaendezone heranziehen - ueber eine Stuetzmauer
// hinweg gehoert die Nachbarkote nicht zu dieser Flaeche.
const [pcx, pcz] = centroid(s.polygon);
const zone = zoneOf(pcx, pcz);
const near = terrainSpots.filter(
(sp) => !inside.includes(sp) && sp.zone === zone && distToPolygon(sp.x, sp.z, s.polygon) < 1.1,
);
const pool = [...inside, ...near];
if (!pool.length) continue;
const ys = pool.map((p) => p.y);
const mean = ys.reduce((a, b) => a + b, 0) / ys.length;
const px = pool.reduce((a, p) => a + p.x, 0) / pool.length;
const pz = pool.reduce((a, p) => a + p.z, 0) / pool.length;
let plane = pool.length >= 3 ? fitPlane(pool) : null;
// Ein Belag hat hoechstens rund 5 % Gefaelle - genug fuer die Entwaesserung. Bei fast kollinearen Koten
// kippt die Ausgleichsebene weg; dann nur das Gefaelle begrenzen und die
// Ebene wieder am Schwerpunkt der Koten aufhaengen - nie am Polygonrand,
// sonst wandert sie mit dem gekappten Gefaelle davon.
if (plane) {
const slope = Math.hypot(plane.a, plane.b);
const maxDev = Math.max(...inside.map((p) => Math.abs(plane.a * p.x + plane.b * p.z + plane.c - p.y)));
if (slope > 0.10 || maxDev > 0.06) plane = null;
const k = slope > 0.05 ? 0.05 / slope : 1;
const a = plane.a * k, b = plane.b * k;
plane = { a, b, c: mean - (a * px + b * pz) };
} else {
plane = { a: 0, b: 0, c: mean };
}
const ys = inside.map((p) => p.y);
const mean = ys.reduce((a, b) => a + b, 0) / ys.length;
s.plane = [round(plane.a, 5), round(plane.b, 5), round(plane.c, 4)];
flatSurfaces.push({
polygon: s.polygon, plane, mean, type: s.type,
min: Math.min(...ys) - 0.15, max: Math.max(...ys) + 0.15,
polygon: s.polygon, plane, type: s.type,
min: Math.min(...ys) - 0.12, max: Math.max(...ys) + 0.12,
});
}
@@ -206,6 +273,9 @@ function interpolate(x, z, zoneId) {
return num / den;
}
/** Merkt sich, welche Rasterfelder von einem Belag festgelegt sind. */
const locked = new Uint8Array(nx * nz);
for (let j = 0; j < nz; j++) {
const z = GRID.z0 + j * CELL;
for (let i = 0; i < nx; i++) {
@@ -225,8 +295,8 @@ for (let j = 0; j < nz; j++) {
y = interpolate(x, z, zone);
for (const f of flatSurfaces) {
if (!pointInPolygon(x, z, f.polygon)) continue;
y = f.plane ? f.plane.a * x + f.plane.b * z + f.plane.c : f.mean;
y = Math.max(f.min, Math.min(f.max, y));
y = Math.max(f.min, Math.min(f.max, f.plane.a * x + f.plane.b * z + f.plane.c));
locked[j * nx + i] = 1;
break;
}
}
@@ -234,16 +304,16 @@ for (let j = 0; j < nz; j++) {
}
}
// Zwei Glaettungsrunden, damit weder die Kanten der Ebenen schneiden noch
// einzelne Koten als Beulen stehenbleiben.
// Glaetten - aber nur die freien Felder. Die Belaege bleiben exakt eben.
let smoothed = Float32Array.from(height);
for (let pass = 0; pass < 2; pass++) {
for (let pass = 0; pass < 3; pass++) {
const src = smoothed;
const dst = Float32Array.from(src);
for (let j = 1; j < nz - 1; j++) {
for (let i = 1; i < nx - 1; i++) {
const k = j * nx + i;
dst[k] = (src[k] * 4 + src[k - 1] + src[k + 1] + src[k - nx] + src[k + nx]) / 8;
if (locked[k]) continue;
dst[k] = (src[k] * 3 + src[k - 1] + src[k + 1] + src[k - nx] + src[k + nx]) / 7;
}
}
smoothed = dst;
@@ -323,6 +393,7 @@ const site = {
spots: terrainSpots.map((s) => ({ x: round(s.x), z: round(s.z), y: s.y, zone: s.zone })),
},
surfaces,
slabs,
plants,
trees,
lamps,
@@ -345,6 +416,7 @@ console.log(`Belagsflaechen ${surfaces.length}:`);
for (const [t, a] of Object.entries(byType).sort((x, y) => y[1] - x[1])) {
console.log(' ' + t.padEnd(16) + a.toFixed(1).padStart(7) + ' m2');
}
console.log(`Lose Travertinplatten 40x60: ${slabs.length}`);
console.log(`Pflanzen ${plants.length}, Baeume ${trees.length}, Leuchten ${lamps.length}, Mauern ${walls.length}, Objekte ${PROPS.length}, Stelen ${STELEN.length}`);
console.log(`Hoehenraster ${nx} x ${nz} (${CELL} m), Koten ${terrainSpots.length}`);
const hs = Array.from(smoothed);