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>
@@ -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
|
SVG-Draufsicht, `node tools/inspect-colors.mjs` listet die Planfarben mit den
|
||||||
nächstgelegenen Beschriftungen.
|
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
|
### 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
|
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/geometry.ts Polygone auf das Gelände legen, Mauern extrudieren
|
||||||
scene/terrain.ts Geländeraster und Umland
|
scene/terrain.ts Geländeraster und Umland
|
||||||
scene/surfaces.ts Beläge
|
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/vegetation.ts Gehölze, Hecken, Gräser, Stauden
|
||||||
scene/building.ts Wohnhaus 9a und Nachbargebäude
|
scene/building.ts Wohnhaus 9a und Nachbargebäude
|
||||||
scene/environment.ts Himmel, Sonnenstand, Licht
|
scene/environment.ts Himmel, Sonnenstand, Licht
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 808 KiB |
|
Before Width: | Height: | Size: 834 KiB |
|
Before Width: | Height: | Size: 729 KiB After Width: | Height: | Size: 986 KiB |
|
Before Width: | Height: | Size: 685 KiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 806 KiB After Width: | Height: | Size: 950 KiB |
|
Before Width: | Height: | Size: 948 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 974 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 779 KiB After Width: | Height: | Size: 846 KiB |
@@ -13,7 +13,8 @@ import './style.css';
|
|||||||
import { loadSite, makeHeightSampler, type SiteData } from './site';
|
import { loadSite, makeHeightSampler, type SiteData } from './site';
|
||||||
import { buildTerrain } from './scene/terrain';
|
import { buildTerrain } from './scene/terrain';
|
||||||
import { buildSurfaces } from './scene/surfaces';
|
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 { buildVegetation } from './scene/vegetation';
|
||||||
import { buildBuilding, buildNeighbourBuildings } from './scene/building';
|
import { buildBuilding, buildNeighbourBuildings } from './scene/building';
|
||||||
import { buildEnvironment } from './scene/environment';
|
import { buildEnvironment } from './scene/environment';
|
||||||
@@ -52,7 +53,7 @@ async function main() {
|
|||||||
scene.add(buildTerrain(site.terrain));
|
scene.add(buildTerrain(site.terrain));
|
||||||
|
|
||||||
await progress(38, 'Beläge werden verlegt …');
|
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 …');
|
await progress(52, 'Mauern und Bauteile …');
|
||||||
scene.add(buildWalls(site.walls, height));
|
scene.add(buildWalls(site.walls, height));
|
||||||
|
|||||||
@@ -244,30 +244,73 @@ export function buildBuilding(b: Building): Group {
|
|||||||
return 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 {
|
export function buildNeighbourBuildings(list: NeighbourBuilding[]): Group {
|
||||||
const group = new Group();
|
const group = new Group();
|
||||||
group.name = 'Nachbargebäude';
|
group.name = 'Nachbargebäude';
|
||||||
const holz = tex.fassadeHolz();
|
const holz = tex.fassadeHolz();
|
||||||
const mat = new MeshStandardMaterial({ map: holz.map, roughness: holz.roughness });
|
const holzMat = new MeshStandardMaterial({
|
||||||
const attika = new MeshStandardMaterial({ color: 0x8b8377, roughness: 0.7 });
|
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) {
|
for (const b of list) {
|
||||||
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
|
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); }
|
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(
|
const body = new Mesh(
|
||||||
boxGeometry((x0 + x1) / 2, (z0 + z1) / 2, x1 - x0, z1 - z0, b.floorLevel - 0.8, b.roofLevel, 0, holz.tile),
|
boxGeometry(cx, cz, w, d, b.floorLevel + 0.35, b.roofLevel, 0, holz.tile), holzMat,
|
||||||
mat,
|
|
||||||
);
|
);
|
||||||
body.name = b.name;
|
for (const m of [sockel, body]) { m.castShadow = true; m.receiveShadow = true; node.add(m); }
|
||||||
body.castShadow = true;
|
|
||||||
body.receiveShadow = true;
|
// Begruentes Flachdach hinter der Attika
|
||||||
group.add(body);
|
const roof = new Mesh(
|
||||||
const cap = new Mesh(
|
boxGeometry(cx, cz, w - 0.3, d - 0.3, b.roofLevel - 0.06, b.roofLevel + 0.02, 0, gruen.tile),
|
||||||
boxGeometry((x0 + x1) / 2, (z0 + z1) / 2, x1 - x0 + 0.2, z1 - z0 + 0.2, b.roofLevel, b.roofLevel + 0.3, 0, 2),
|
new MeshStandardMaterial({ map: gruen.map, roughness: 1 }),
|
||||||
attika,
|
|
||||||
);
|
);
|
||||||
cap.castShadow = true;
|
roof.receiveShadow = true;
|
||||||
group.add(cap);
|
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;
|
return group;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,9 +218,11 @@ export function boxGeometry(
|
|||||||
[cx + u * ca - v * sa, y, cz + u * sa + v * ca];
|
[cx + u * ca - v * sa, y, cz + u * sa + v * ca];
|
||||||
const hw = w / 2, hd = d / 2;
|
const hw = w / 2, hd = d / 2;
|
||||||
const s = 1 / tile;
|
const s = 1 / tile;
|
||||||
const faces: [Vec2, Vec2, number][] = [
|
// Umlauf im Uhrzeigersinn in (u, v). Damit zeigen die Flaechennormalen nach
|
||||||
[[-hw, -hd], [hw, -hd], 1], [[hw, -hd], [hw, hd], 1],
|
// aussen - andersherum sieht man in die Box hinein.
|
||||||
[[hw, hd], [-hw, hd], 1], [[-hw, hd], [-hw, -hd], 1],
|
const faces: [Vec2, Vec2][] = [
|
||||||
|
[[-hw, -hd], [-hw, hd]], [[-hw, hd], [hw, hd]],
|
||||||
|
[[hw, hd], [hw, -hd]], [[hw, -hd], [-hw, -hd]],
|
||||||
];
|
];
|
||||||
let uRun = 0;
|
let uRun = 0;
|
||||||
for (const [p0, p1] of faces) {
|
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);
|
uv.push(uRun * s, y0 * s, (uRun + len) * s, y1 * s, uRun * s, y1 * s);
|
||||||
uRun += len;
|
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);
|
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);
|
uv.push(0, 0, w * s, 0, w * s, d * s, 0, 0, w * s, d * s, 0, d * s);
|
||||||
const g = new BufferGeometry();
|
const g = new BufferGeometry();
|
||||||
g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
|
g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
|
||||||
@@ -242,3 +248,112 @@ export function boxGeometry(
|
|||||||
g.computeVertexNormals();
|
g.computeVertexNormals();
|
||||||
return g;
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,104 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* Bauteile: Stuetz- und Sitzmauern, Travertinstelen, Gartenhaus, Spielhaus,
|
* Freistehende Bauteile: Travertinstelen, Gartenhaus, Spielhaus,
|
||||||
* Flammengrill, Baenkli, Leuchten.
|
* Flammengrill, Baenkli, Maehroboter-Garage, Gartentor und die Leuchten.
|
||||||
|
* Die Mauern stehen in walls.ts.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
BoxGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial, PointLight,
|
BoxGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial, PointLight,
|
||||||
type MeshStandardMaterialParameters,
|
type MeshStandardMaterialParameters,
|
||||||
} from 'three';
|
} from 'three';
|
||||||
import type { Lamp, Prop, Stele, Wall } from '../site';
|
import type { Lamp, Prop, Stele } from '../site';
|
||||||
import { boxGeometry, wallGeometry } from './geometry';
|
import { boxGeometry } from './geometry';
|
||||||
import * as tex from './textures';
|
import * as tex from './textures';
|
||||||
|
|
||||||
function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {}) {
|
function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {}) {
|
||||||
return new MeshStandardMaterial({
|
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 {
|
export function buildStelen(stelen: Stele[], height: (x: number, z: number) => number): Group {
|
||||||
const group = new Group();
|
const group = new Group();
|
||||||
group.name = 'Travertinstelen';
|
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) {
|
for (const s of stelen) {
|
||||||
const y0 = height(s.x, s.z) - 0.3;
|
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);
|
const m = new Mesh(geo, mat);
|
||||||
m.castShadow = true;
|
m.castShadow = true;
|
||||||
m.receiveShadow = true;
|
m.receiveShadow = true;
|
||||||
@@ -165,30 +95,59 @@ function spielhaus(p: Prop, y: number): Group {
|
|||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flammengrill: Cortenstahl-Kubus mit umlaufender Grillplatte und offener
|
||||||
|
* Feuerkammer, wie auf dem Referenzfoto des Plans.
|
||||||
|
*/
|
||||||
function flammengrill(p: Prop, y: number): Group {
|
function flammengrill(p: Prop, y: number): Group {
|
||||||
const g = new Group();
|
const g = new Group();
|
||||||
const steel = new MeshStandardMaterial({ color: 0x3a3b3e, roughness: 0.5, metalness: 0.45 });
|
const corten = new MeshStandardMaterial({ color: 0x5c3a26, roughness: 0.62, 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 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;
|
body.castShadow = true; body.receiveShadow = true;
|
||||||
g.add(body);
|
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(
|
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),
|
boxGeometry(p.x, p.z, p.w + 0.14, p.d + 0.14, bodyTop, bodyTop + 0.035, p.rot, 1), plateMat,
|
||||||
new MeshStandardMaterial({ color: 0x3a3a3c, roughness: 0.35, metalness: 0.7 }),
|
|
||||||
);
|
);
|
||||||
plate.castShadow = true;
|
plate.castShadow = true;
|
||||||
|
plate.receiveShadow = true;
|
||||||
g.add(plate);
|
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;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bank(p: Prop, y: number): Group {
|
function bank(p: Prop, y: number): Group {
|
||||||
const g = new Group();
|
const g = new Group();
|
||||||
const stein = material(tex.travertinMauer(), { roughness: 0.65 });
|
const belag = tex.travertinMauer();
|
||||||
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 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;
|
seat.castShadow = true; seat.receiveShadow = true;
|
||||||
g.add(seat);
|
g.add(seat);
|
||||||
for (const s of [-1, 1]) {
|
for (const s of [-1, 1]) {
|
||||||
const m = new Mesh(
|
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,
|
stein,
|
||||||
);
|
);
|
||||||
m.castShadow = true;
|
m.castShadow = true;
|
||||||
@@ -197,14 +156,52 @@ function bank(p: Prop, y: number): Group {
|
|||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
function simpleBox(p: Prop, y: number, color: number, rough = 0.7): Mesh {
|
/** Garage fuer den Maehroboter: flaches Pultdach auf zwei Seitenwangen. */
|
||||||
const m = new Mesh(
|
function roboterGarage(p: Prop, y: number): Group {
|
||||||
boxGeometry(p.x, p.z, p.w, p.d, y, y + p.h, p.rot, 1),
|
const g = new Group();
|
||||||
new MeshStandardMaterial({ color, roughness: rough }),
|
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;
|
roof.castShadow = true;
|
||||||
m.receiveShadow = true;
|
g.add(roof);
|
||||||
return m;
|
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 {
|
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 'flammengrill': obj = flammengrill(p, y); break;
|
||||||
case 'bank': obj = bank(p, y); break;
|
case 'bank': obj = bank(p, y); break;
|
||||||
case 'tor': obj = gartentor(p, y); break;
|
case 'tor': obj = gartentor(p, y); break;
|
||||||
case 'roboter': obj = simpleBox(p, y, 0x33413a, 0.6); break;
|
case 'roboter': obj = roboterGarage(p, y); break;
|
||||||
case 'wasserhahn': obj = simpleBox(p, y, 0x8a8d90, 0.4); break;
|
case 'wasserhahn': obj = wasserhahn(p, y); break;
|
||||||
case 'schacht': obj = simpleBox(p, y - 0.01, 0x77746e, 0.9); break;
|
case 'schacht': obj = schachtdeckel(p, y); break;
|
||||||
default: continue;
|
default: continue;
|
||||||
}
|
}
|
||||||
obj.name = p.id;
|
obj.name = p.id;
|
||||||
|
|||||||
@@ -1,27 +1,68 @@
|
|||||||
/**
|
/**
|
||||||
* Belagsflaechen aus dem Gartenplan: Travertin, Keramik, Tegula, Rasen,
|
* Belagsflaechen aus dem Gartenplan.
|
||||||
* Schotterwiese, Beete, Spielsand.
|
*
|
||||||
|
* 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 {
|
||||||
import type { Surface, SurfaceType } from '../site';
|
BufferGeometry, DoubleSide, Group, InstancedMesh, Mesh, MeshStandardMaterial,
|
||||||
import { surfaceGeometryMulti } from './geometry';
|
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';
|
import * as tex from './textures';
|
||||||
|
|
||||||
const BELAG: Record<SurfaceType, { belag: () => tex.Belag; offset: number; label: string }> = {
|
interface BelagDef {
|
||||||
travertin80: { belag: tex.travertin80, offset: 0.042, label: 'Travertinplatten 80×80×3 cm' },
|
belag: () => tex.Belag;
|
||||||
travertin4060: { belag: tex.travertin4060, offset: 0.042, label: 'Travertinplatten 40×60×3 cm mit Dekorkies' },
|
label: string;
|
||||||
travertinstell: { belag: tex.travertinstell, offset: 0.038, label: 'Travertinstellplatten' },
|
/** Aufbauhoehe ueber der Ebene bzw. ueber dem Gelaende. */
|
||||||
keramik: { belag: tex.keramik, offset: 0.032, label: 'Keramikplatten 60×60×2 cm' },
|
lift: number;
|
||||||
verbundstein: { belag: tex.verbundstein, offset: 0.030, label: 'Verbundsteine (Anpassung Nachbar)' },
|
/** Gebaute Flaeche: eben, mit Sichtkante. */
|
||||||
tegula: { belag: tex.tegula, offset: 0.034, label: 'Tegula-Pflastersteine grau' },
|
built: boolean;
|
||||||
rasen: { belag: tex.rasen, offset: 0.022, label: 'Rasen' },
|
/** Kante mit Metallband einfassen. */
|
||||||
schotterwiese: { belag: tex.schotterwiese, offset: 0.020, label: 'Schotterwiese' },
|
edging?: boolean;
|
||||||
beet: { belag: tex.beet, offset: 0.018, label: 'Pflanzbeet' },
|
/** Material der Sichtkante, falls abweichend. */
|
||||||
spielsand: { belag: tex.spielsand, offset: 0.016, label: 'Spielsand' },
|
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(
|
export function buildSurfaces(
|
||||||
surfaces: Surface[],
|
surfaces: Surface[],
|
||||||
|
slabs: Slab[],
|
||||||
height: (x: number, z: number) => number,
|
height: (x: number, z: number) => number,
|
||||||
): Group {
|
): Group {
|
||||||
const group = new Group();
|
const group = new Group();
|
||||||
@@ -34,29 +75,87 @@ export function buildSurfaces(
|
|||||||
byType.set(s.type, list);
|
byType.set(s.type, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const edgingParts: BufferGeometry[] = [];
|
||||||
|
|
||||||
for (const [type, list] of byType) {
|
for (const [type, list] of byType) {
|
||||||
const def = BELAG[type];
|
const def = BELAG[type];
|
||||||
if (!def) continue;
|
if (!def) continue;
|
||||||
const belag = def.belag();
|
const belag = def.belag();
|
||||||
const geo = surfaceGeometryMulti(
|
|
||||||
list.map((s) => s.polygon),
|
const tops: BufferGeometry[] = [];
|
||||||
height,
|
const sides: BufferGeometry[] = [];
|
||||||
{ offset: def.offset, tile: belag.tile, maxEdge: 0.28 },
|
|
||||||
);
|
for (const s of list) {
|
||||||
const mesh = new Mesh(geo, new MeshStandardMaterial({
|
const plane = def.built ? s.plane : undefined;
|
||||||
map: belag.map,
|
if (plane) {
|
||||||
roughness: belag.roughness,
|
const topAt = (x: number, z: number) => plane[0] * x + plane[1] * z + plane[2] + def.lift;
|
||||||
metalness: 0,
|
tops.push(planarSurfaceGeometry(s.polygon, plane, belag.tile, def.lift));
|
||||||
side: DoubleSide,
|
sides.push(surfaceEdgeGeometry(
|
||||||
polygonOffset: true,
|
s.polygon, topAt,
|
||||||
polygonOffsetFactor: -1,
|
(x, z) => Math.min(height(x, z), topAt(x, z)) - 0.16,
|
||||||
polygonOffsetUnits: -1,
|
(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.name = def.label;
|
||||||
mesh.receiveShadow = true;
|
mesh.receiveShadow = true;
|
||||||
mesh.userData.surfaceType = type;
|
mesh.userData.surfaceType = type;
|
||||||
group.add(mesh);
|
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;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
BufferAttribute, BufferGeometry, DoubleSide, Mesh, MeshStandardMaterial, Group,
|
BufferAttribute, BufferGeometry, DoubleSide, Mesh, MeshStandardMaterial, Group,
|
||||||
} from 'three';
|
} from 'three';
|
||||||
import type { Terrain } from '../site';
|
import type { Terrain } from '../site';
|
||||||
import { rasen, schotterwiese } from './textures';
|
import { rohboden } from './textures';
|
||||||
|
|
||||||
export function buildTerrain(terrain: Terrain): Group {
|
export function buildTerrain(terrain: Terrain): Group {
|
||||||
const group = new Group();
|
const group = new Group();
|
||||||
@@ -18,7 +18,7 @@ export function buildTerrain(terrain: Terrain): Group {
|
|||||||
const geo = new BufferGeometry();
|
const geo = new BufferGeometry();
|
||||||
const verts = new Float32Array(nx * nz * 3);
|
const verts = new Float32Array(nx * nz * 3);
|
||||||
const uvs = new Float32Array(nx * nz * 2);
|
const uvs = new Float32Array(nx * nz * 2);
|
||||||
const belag = rasen();
|
const belag = rohboden();
|
||||||
for (let j = 0; j < nz; j++) {
|
for (let j = 0; j < nz; j++) {
|
||||||
for (let i = 0; i < nx; i++) {
|
for (let i = 0; i < nx; i++) {
|
||||||
const k = j * 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
|
// Das Grundstueck liegt an einem Hang ueber dem Tal. Das Umland wird als
|
||||||
// Kranz direkt an die Randpunkte des Rasters gehaengt - so entsteht keine
|
// Kranz direkt an die Randpunkte des Rasters gehaengt - so entsteht keine
|
||||||
// sichtbare Kante - und faellt gegen aussen ab, vor allem nach Suedwesten.
|
// 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 cx = x0 + ((nx - 1) * cell) / 2;
|
||||||
const cz = z0 + ((nz - 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).
|
// Nach Suedwesten faellt das Gelaende deutlich staerker ab (Blick ins Tal).
|
||||||
const sw = (1 - Math.cos(Math.atan2(dz, dx) - 2.4)) / 2;
|
const sw = (1 - Math.cos(Math.atan2(dz, dx) - 2.4)) / 2;
|
||||||
ringPos.push(x, p.y - t * (6 + 26 * sw * sw), z);
|
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++) {
|
for (let k = 0; k < RINGS; k++) {
|
||||||
@@ -107,7 +107,7 @@ export function buildTerrain(terrain: Terrain): Group {
|
|||||||
far.setIndex(ringIdx);
|
far.setIndex(ringIdx);
|
||||||
far.computeVertexNormals();
|
far.computeVertexNormals();
|
||||||
const surroundings = new Mesh(far, new MeshStandardMaterial({
|
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';
|
surroundings.name = 'Umland';
|
||||||
group.add(surroundings);
|
group.add(surroundings);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
|
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
|
||||||
|
LinearSRGBColorSpace, Vector2,
|
||||||
} from 'three';
|
} from 'three';
|
||||||
|
|
||||||
const SIZE = 512;
|
const SIZE = 512;
|
||||||
@@ -102,6 +103,9 @@ function slabs(
|
|||||||
|
|
||||||
export interface Belag {
|
export interface Belag {
|
||||||
map: CanvasTexture;
|
map: CanvasTexture;
|
||||||
|
/** Aus dem Fugenbild abgeleitete Normalmap - gibt den Belaegen Tiefe. */
|
||||||
|
normalMap?: CanvasTexture;
|
||||||
|
normalScale?: Vector2;
|
||||||
tile: number;
|
tile: number;
|
||||||
roughness: number;
|
roughness: number;
|
||||||
color?: number;
|
color?: number;
|
||||||
@@ -119,75 +123,210 @@ function make(key: string, tile: number, roughness: number, draw: (ctx: CanvasRe
|
|||||||
return belag;
|
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
|
// Belaege
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Travertinplatten 80 x 80 x 3 cm, warmes Creme mit feiner Poren-Struktur. */
|
/** Travertin-Oberflaeche: warme Wolken und feine Poren. */
|
||||||
export const travertin80 = () => make('travertin80', 3.2, 0.72, (ctx) => {
|
function travertinFinish(ctx: CanvasRenderingContext2D, seed: number) {
|
||||||
slabs(ctx, 3.2, 0.8, 0.8, 'rgb(209,195,168)', 'rgb(170,157,134)', 0.008, 11, 7);
|
noiseTile(ctx, seed, 48, 0.035);
|
||||||
noiseTile(ctx, 21, 32, 0.05);
|
noiseTile(ctx, seed + 100, 144, 0.025);
|
||||||
grain(ctx, 31, 0.05, 2);
|
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. */
|
/** Travertinplatten 80 x 80 x 3 cm, Hauptsitzplatz. */
|
||||||
export const travertin4060 = () => make('travertin4060', 2.4, 0.75, (ctx) => {
|
export const travertin80 = () => makeRelief('travertin80', 3.2, 0.70, 3.2,
|
||||||
slabs(ctx, 2.4, 0.4, 0.6, 'rgb(212,199,172)', 'rgb(158,150,133)', 0.02, 12, 8);
|
(ctx) => {
|
||||||
noiseTile(ctx, 22, 32, 0.05);
|
slabs(ctx, 3.2, 0.8, 0.8, 'rgb(209,195,168)', 'rgb(158,145,124)', 0.010, 11, 7);
|
||||||
grain(ctx, 32, 0.06, 2);
|
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. */
|
/** Travertinstellplatten als schmaler Randstreifen. */
|
||||||
export const travertinstell = () => make('travertinstell', 2.4, 0.75, (ctx) => {
|
export const travertinstell = () => makeRelief('travertinstell', 2.4, 0.73, 3.0,
|
||||||
slabs(ctx, 2.4, 0.4, 0.4, 'rgb(206,192,166)', 'rgb(164,154,135)', 0.012, 13, 8);
|
(ctx) => {
|
||||||
noiseTile(ctx, 23, 32, 0.05);
|
slabs(ctx, 2.4, 0.4, 0.4, 'rgb(206,192,166)', 'rgb(155,144,124)', 0.012, 13, 8);
|
||||||
grain(ctx, 33, 0.05, 2);
|
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). */
|
/** Keramikplatten 60 x 60 x 2 cm, grau - bauseits vorhanden (siehe Fotos). */
|
||||||
export const keramik = () => make('keramik', 2.4, 0.55, (ctx) => {
|
export const keramik = () => makeRelief('keramik', 2.4, 0.52, 2.6,
|
||||||
slabs(ctx, 2.4, 0.6, 0.6, 'rgb(160,158,154)', 'rgb(128,127,124)', 0.006, 14, 5);
|
(ctx) => {
|
||||||
noiseTile(ctx, 24, 48, 0.035);
|
slabs(ctx, 2.4, 0.6, 0.6, 'rgb(160,158,154)', 'rgb(118,117,114)', 0.006, 14, 5);
|
||||||
grain(ctx, 34, 0.04, 1);
|
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. */
|
/** Tegula-Pflastersteine grau, kleinformatig und leicht unregelmaessig. */
|
||||||
export const tegula = () => make('tegula', 1.2, 0.85, (ctx) => {
|
export const tegula = () => makeRelief('tegula', 1.2, 0.84, 3.4,
|
||||||
const px = SIZE / 1.2;
|
(ctx) => {
|
||||||
ctx.fillStyle = 'rgb(118,116,114)';
|
const px = SIZE / 1.2;
|
||||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
ctx.fillStyle = 'rgb(104,102,100)';
|
||||||
const rnd = mulberry32(15);
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
const sw = 0.15, sh = 0.1;
|
const rnd = mulberry32(15);
|
||||||
const cols = Math.round(1.2 / sw), rows = Math.round(1.2 / sh);
|
const sw = 0.15, sh = 0.1;
|
||||||
for (let row = 0; row < rows; row++) {
|
const cols = Math.round(1.2 / sw), rows = Math.round(1.2 / sh);
|
||||||
const off = (row % 2) * 0.5;
|
for (let row = 0; row < rows; row++) {
|
||||||
for (let col = -1; col <= cols; col++) {
|
const off = (row % 2) * 0.5;
|
||||||
const v = 140 + rnd() * 34;
|
for (let col = -1; col <= cols; col++) {
|
||||||
ctx.fillStyle = `rgb(${v | 0},${(v - 2) | 0},${(v - 5) | 0})`;
|
const v = 138 + rnd() * 36;
|
||||||
const x = (col + off) * sw * px + 1.2;
|
ctx.fillStyle = `rgb(${v | 0},${(v - 2) | 0},${(v - 6) | 0})`;
|
||||||
const y = row * sh * px + 1.2;
|
ctx.fillRect((col + off) * sw * px + 1.6, row * sh * px + 1.6, sw * px - 3.2, sh * px - 3.2);
|
||||||
ctx.fillRect(x, y, sw * px - 2.4, sh * px - 2.4);
|
}
|
||||||
}
|
}
|
||||||
}
|
noiseTile(ctx, 25, 24, 0.06);
|
||||||
noiseTile(ctx, 25, 24, 0.06);
|
grain(ctx, 35, 0.10, 1);
|
||||||
grain(ctx, 35, 0.09, 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. */
|
/** Verbundsteine des Nachbarn, etwas dunkler als Tegula. */
|
||||||
export const verbundstein = () => make('verbundstein', 1.2, 0.88, (ctx) => {
|
export const verbundstein = () => makeRelief('verbundstein', 1.2, 0.88, 3.0,
|
||||||
const px = SIZE / 1.2;
|
(ctx) => {
|
||||||
ctx.fillStyle = 'rgb(110,108,106)';
|
const px = SIZE / 1.2;
|
||||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
ctx.fillStyle = 'rgb(100,98,96)';
|
||||||
const rnd = mulberry32(16);
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
for (let row = 0; row < 8; row++) {
|
const rnd = mulberry32(16);
|
||||||
for (let col = 0; col < 8; col++) {
|
for (let row = 0; row < 8; row++) {
|
||||||
const v = 126 + rnd() * 30;
|
for (let col = 0; col < 8; col++) {
|
||||||
ctx.fillStyle = `rgb(${v | 0},${v | 0},${(v - 4) | 0})`;
|
const v = 124 + rnd() * 32;
|
||||||
ctx.fillRect(col * 0.15 * px + 1.5, row * 0.15 * px + 1.5, 0.15 * px - 3, 0.15 * px - 3);
|
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. */
|
/** Rasen: gemaehte Flaeche mit leichten Bahnen. */
|
||||||
export const rasen = () => make('rasen', 2.0, 0.95, (ctx) => {
|
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. */
|
/** Dekorkies in den Pflanzflaechen und um den Flammengrill. */
|
||||||
export const dekorkies = () => make('dekorkies', 1.0, 0.99, (ctx) => {
|
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);
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
const rnd = mulberry32(46);
|
const rnd = mulberry32(46);
|
||||||
for (let i = 0; i < 6500; i++) {
|
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.fillStyle = `rgb(${v | 0},${(v - 5) | 0},${(v - 16) | 0})`;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 2 + rnd() * 4, 2 + rnd() * 3, rnd() * 3.14, 0, Math.PI * 2);
|
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
|
// Bauteile
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Travertin-Mauerwerk in Schichten, wie auf den Referenzfotos des Plans. */
|
/**
|
||||||
export const travertinMauer = () => make('travertinMauer', 1.6, 0.7, (ctx) => {
|
* Travertin als Steinoberflaeche - warm, geschichtet, mit den typischen
|
||||||
const px = SIZE / 1.6;
|
* Poren. Die Schichtung der Mauer entsteht in walls.ts aus echten Bloecken,
|
||||||
ctx.fillStyle = 'rgb(168,152,126)';
|
* die Textur liefert nur das Material.
|
||||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
*/
|
||||||
const rnd = mulberry32(61);
|
export const travertinMauer = () => makeRelief('travertinMauer', 0.7, 0.68, 1.9,
|
||||||
const h = 0.2;
|
(ctx) => {
|
||||||
for (let row = 0; row < Math.round(1.6 / h); row++) {
|
ctx.fillStyle = 'rgb(203,183,148)';
|
||||||
let x = -((row % 2) * 0.3);
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
while (x < 1.6) {
|
const rnd = mulberry32(61);
|
||||||
const w = 0.4 + rnd() * 0.4;
|
// liegende Sedimentbaender, bewusst zurueckhaltend - sonst wirkt der Stein
|
||||||
const v = 202 + rnd() * 26;
|
// aus der Naehe fleckig statt geschichtet
|
||||||
ctx.fillStyle = `rgb(${v | 0},${(v - 17) | 0},${(v - 44) | 0})`;
|
for (let i = 0; i < 34; i++) {
|
||||||
ctx.fillRect(x * px + 1, row * h * px + 1, w * px - 2, h * px - 2);
|
const y = rnd() * SIZE;
|
||||||
x += w;
|
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();
|
||||||
}
|
}
|
||||||
}
|
travertinFinish(ctx, 62);
|
||||||
noiseTile(ctx, 62, 40, 0.045);
|
grain(ctx, 63, 0.05, 2);
|
||||||
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. */
|
/** Sichtbeton der Winkelelemente und der Nachbarmauer. */
|
||||||
export const beton = () => make('beton', 2.0, 0.8, (ctx) => {
|
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. */
|
/** Vertikale Holzschalung der Fassade - graubraun vergraut, wie auf den Fotos. */
|
||||||
export const fassadeHolz = () => make('fassadeHolz', 1.2, 0.78, (ctx) => {
|
export const fassadeHolz = () => makeRelief('fassadeHolz', 1.2, 0.78, 3.6,
|
||||||
const px = SIZE / 1.2;
|
(ctx) => {
|
||||||
ctx.fillStyle = 'rgb(124,114,100)';
|
const px = SIZE / 1.2;
|
||||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
ctx.fillStyle = 'rgb(96,88,76)';
|
||||||
const rnd = mulberry32(71);
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
const w = 0.06;
|
const rnd = mulberry32(71);
|
||||||
for (let col = 0; col < Math.round(1.2 / w); col++) {
|
const w = 0.06;
|
||||||
const v = 152 + rnd() * 32;
|
for (let col = 0; col < Math.round(1.2 / w); col++) {
|
||||||
ctx.fillStyle = `rgb(${v | 0},${(v - 8) | 0},${(v - 20) | 0})`;
|
const v = 150 + rnd() * 34;
|
||||||
ctx.fillRect(col * w * px + 1.5, 0, w * px - 3, SIZE);
|
ctx.fillStyle = `rgb(${v | 0},${(v - 9) | 0},${(v - 22) | 0})`;
|
||||||
ctx.strokeStyle = 'rgba(84,76,66,0.45)';
|
ctx.fillRect(col * w * px + 1.5, 0, w * px - 3, SIZE);
|
||||||
ctx.lineWidth = 1;
|
// Maserung in Faserrichtung
|
||||||
for (let i = 0; i < 14; i++) {
|
ctx.strokeStyle = 'rgba(84,76,66,0.42)';
|
||||||
const y = rnd() * SIZE;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
for (let i = 0; i < 16; i++) {
|
||||||
ctx.moveTo(col * w * px + 2, y);
|
const y = rnd() * SIZE;
|
||||||
ctx.lineTo(col * w * px + w * px - 3, y + (rnd() - 0.5) * 5);
|
ctx.beginPath();
|
||||||
ctx.stroke();
|
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.035);
|
||||||
noiseTile(ctx, 72, 32, 0.03);
|
},
|
||||||
});
|
(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. */
|
/** Heller Betonsockel unter der Holzschalung. */
|
||||||
export const sockel = () => make('sockel', 2.0, 0.72, (ctx) => {
|
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();
|
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);
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
* statt Blattgeometrie - das bleibt auch auf dem Handy fluessig.
|
* statt Blattgeometrie - das bleibt auch auf dem Handy fluessig.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
BoxGeometry, BufferAttribute, Color, CylinderGeometry, Group, IcosahedronGeometry,
|
BoxGeometry, BufferAttribute, BufferGeometry, Color, CylinderGeometry, DoubleSide,
|
||||||
InstancedMesh, Matrix4, Mesh, MeshStandardMaterial, Object3D, Quaternion, Vector3,
|
Group, IcosahedronGeometry, InstancedMesh, Matrix4, Mesh, MeshStandardMaterial,
|
||||||
type BufferGeometry,
|
Object3D, Quaternion, Vector3,
|
||||||
} from 'three';
|
} from 'three';
|
||||||
import type { Plant, Tree } from '../site';
|
import type { Plant, Tree, Vec2 } from '../site';
|
||||||
import { boxGeometry } from './geometry';
|
import * as tex from './textures';
|
||||||
|
|
||||||
function rng(seed: number) {
|
function rng(seed: number) {
|
||||||
let a = seed >>> 0;
|
let a = seed >>> 0;
|
||||||
@@ -108,6 +108,197 @@ function buildTree(t: Tree, y: number): Group {
|
|||||||
return g;
|
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. */
|
/** Baut eine InstancedMesh aus Transformationen. */
|
||||||
function instances(
|
function instances(
|
||||||
geo: BufferGeometry, mat: MeshStandardMaterial,
|
geo: BufferGeometry, mat: MeshStandardMaterial,
|
||||||
@@ -148,16 +339,34 @@ export function buildVegetation(
|
|||||||
|
|
||||||
const r = rng(4242);
|
const r = rng(4242);
|
||||||
|
|
||||||
// --- Eiben `Hilli` 80/100 cm: aufrechte, dichte Polster ------------------
|
// --- Eibenhecke `Hilli` 80/100 cm ---------------------------------------
|
||||||
// Plan: Pflanzhoehe 80/100 cm, Breite rund 45 cm. Die Streckung steckt in
|
// Der Plan setzt die Eiben in dichten Reihen: das ist eine geschnittene
|
||||||
// der Geometrie, die Instanzen werden nur gleichmaessig skaliert.
|
// Hecke und der eigentliche Sichtschutz im Garten, keine Einzelkugeln.
|
||||||
const eiben = plants.filter((p) => p.type === 'eibe' && p.x !== undefined);
|
const eiben = plants.filter((p) => p.type === 'eibe' && p.x !== undefined)
|
||||||
const eibeGeo = blob(1, 2, 0.028, 3, 1.9);
|
.map((p) => [p.x!, p.z!] as Vec2);
|
||||||
const eibeMesh = instances(eibeGeo, foliage(LAUB.eibe, 0.96), eiben.map((p) => {
|
// Reihen begradigen und an scharfen Knicken trennen: eine gemiterte Hecke
|
||||||
const s = Math.max(0.22, (p.r ?? 0.2) * 1.1) * (0.92 + r() * 0.18);
|
// um eine 90-Grad-Ecke herum verdreht sich sonst.
|
||||||
return { x: p.x!, y: height(p.x!, p.z!) + s * 1.9, z: p.z!, s, rot: r() * 6.28 };
|
const rows = chainRows(eiben, 2.2).flatMap((row) => splitAtCorners(simplifyLine(row, 0.22), 32));
|
||||||
}), 'Eiben `Hilli`');
|
const eibeLaub = tex.laubEibe();
|
||||||
if (eibeMesh) group.add(eibeMesh);
|
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 ----------------------------------
|
// --- Beerenstraeucher und Heidelbeeren ----------------------------------
|
||||||
const straeucher = plants.filter((p) => p.type === 'strauch' && p.x !== undefined);
|
const straeucher = plants.filter((p) => p.type === 'strauch' && p.x !== undefined);
|
||||||
@@ -205,20 +414,22 @@ export function buildVegetation(
|
|||||||
if (krautMesh) group.add(krautMesh);
|
if (krautMesh) group.add(krautMesh);
|
||||||
|
|
||||||
// --- Ligusterhecke, Hoehe 170 cm, Breite 75 cm --------------------------
|
// --- 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) {
|
for (const p of plants) {
|
||||||
if (p.type !== 'hecke' || !p.polygon) continue;
|
if (p.type !== 'hecke' || !p.polygon) continue;
|
||||||
const poly = p.polygon;
|
const poly = p.polygon;
|
||||||
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
|
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); }
|
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;
|
// Laengsachse des Streifens bestimmen und als Hecke daran entlang bauen
|
||||||
const y = height(cx, cz);
|
const along: Vec2[] = (x1 - x0) > (z1 - z0)
|
||||||
const h = p.height ?? 1.7;
|
? [[x0, (z0 + z1) / 2], [x1, (z0 + z1) / 2]]
|
||||||
const geo = boxGeometry(cx, cz, x1 - x0, z1 - z0, y, y + h, 0, 0.8);
|
: [[(x0 + x1) / 2, z0], [(x0 + x1) / 2, z1]];
|
||||||
const m = new Mesh(geo, foliage(LAUB.liguster, 0.97));
|
group.add(hedgeMesh(along, p.height ?? 1.7, p.width ?? 0.75, ligusterMat, height, r, 'Ligusterhecke'));
|
||||||
m.castShadow = true;
|
|
||||||
m.receiveShadow = true;
|
|
||||||
m.name = 'Ligusterhecke';
|
|
||||||
group.add(m);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return group;
|
return group;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -10,13 +10,24 @@
|
|||||||
export type Vec2 = [number, number];
|
export type Vec2 = [number, number];
|
||||||
|
|
||||||
export type SurfaceType =
|
export type SurfaceType =
|
||||||
| 'travertin80' | 'travertin4060' | 'travertinstell' | 'keramik'
|
| 'travertin80' | 'dekorkies' | 'travertinstell' | 'keramik'
|
||||||
| 'verbundstein' | 'tegula' | 'rasen' | 'schotterwiese' | 'beet' | 'spielsand';
|
| 'verbundstein' | 'tegula' | 'rasen' | 'schotterwiese' | 'beet' | 'spielsand';
|
||||||
|
|
||||||
export interface Surface {
|
export interface Surface {
|
||||||
type: SurfaceType;
|
type: SurfaceType;
|
||||||
area: number;
|
area: number;
|
||||||
polygon: Vec2[];
|
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 {
|
export interface Plant {
|
||||||
@@ -138,6 +149,7 @@ export interface SiteData {
|
|||||||
};
|
};
|
||||||
terrain: Terrain;
|
terrain: Terrain;
|
||||||
surfaces: Surface[];
|
surfaces: Surface[];
|
||||||
|
slabs: Slab[];
|
||||||
plants: Plant[];
|
plants: Plant[];
|
||||||
trees: Tree[];
|
trees: Tree[];
|
||||||
lamps: Lamp[];
|
lamps: Lamp[];
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
|
|
||||||
/** Farbe im Plan -> Belagsart. Reihenfolge egal, Zuordnung ueber Hex. */
|
/** Farbe im Plan -> Belagsart. Reihenfolge egal, Zuordnung ueber Hex. */
|
||||||
export const SURFACE_BY_COLOR = {
|
export const SURFACE_BY_COLOR = {
|
||||||
'#ffffcc': 'travertin80', // Travertinplatten 80x80x3, Hauptsitzplatz West
|
'#ffffcc': 'travertin80', // Travertinplatten: grosse Felder 80x80x3, kleine Rechtecke 40x60x3
|
||||||
'#fcffe7': 'travertin4060', // Travertinplatten 40x60x3 mit Dekorkies + Keramikterrasse Zollet
|
'#fcffe7': 'dekorkies', // Dekorkies-Bett; noerdlich der Grenze die Keramikterrasse Zollet
|
||||||
'#f8ffcc': 'travertinstell',// Travertinstellplatten-Streifen
|
'#f8ffcc': 'travertinstell',// Travertinstellplatten-Streifen
|
||||||
'#eee8cc': 'travertinmauer',// Mauerkronen, Sitzmauern, schwebende Stufen
|
'#eee8cc': 'travertinmauer',// Mauerkronen, Sitzmauern, schwebende Stufen
|
||||||
'#e5ffe5': 'rasen',
|
'#e5ffe5': 'rasen',
|
||||||
@@ -115,8 +115,13 @@ export const WALLS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'holzscheiterwand', type: 'holzscheiter',
|
id: 'holzscheiterwand', type: 'holzscheiter',
|
||||||
note: 'Holzscheiterwand 150 x 200 cm hinter dem Flammengrill',
|
note: 'Holzscheiterwand 150 cm breit, 200 cm hoch, hinter dem Flammengrill',
|
||||||
path: [[-5.75, 4.35], [-4.25, 4.35]], t: 0.45, top: 1.75, base: 0.26,
|
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',
|
id: 'lamellenwand', type: 'lamellen',
|
||||||
@@ -153,26 +158,19 @@ export const EXTRA_SURFACES = [
|
|||||||
note: 'Gedeckter Sitzplatz 14.4 m2, Keramikplatten 60x60x2 cm',
|
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]],
|
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',
|
type: 'tegula',
|
||||||
note: 'Feuerwehrzufahrt und Zugangsweg, Tegula-Pflastersteine grau',
|
note: 'Feuerwehrzufahrt und Zugangsweg, Tegula-Pflastersteine grau',
|
||||||
polygon: [[-25.60, -9.00], [-22.10, -9.00], [-22.10, 6.40], [-25.60, 6.40]],
|
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 = [
|
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',
|
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,
|
floorLevel: -1.70, roofLevel: 4.45,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -87,12 +87,39 @@ function fitPlane(points) {
|
|||||||
/** Bauteile, die als Mauer modelliert werden - nicht als begehbare Flaeche. */
|
/** Bauteile, die als Mauer modelliert werden - nicht als begehbare Flaeche. */
|
||||||
const NOT_A_SURFACE = new Set(['travertinmauer', 'winkelelement', 'spielhaus', 'gartenhaus', 'roboter', 'ligusterhecke']);
|
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 = [];
|
const surfaces = [];
|
||||||
|
|
||||||
for (const s of raw.shapes) {
|
for (const s of raw.shapes) {
|
||||||
if (s.op !== 'f' || s.circle) continue;
|
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 (!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;
|
if (NOT_A_SURFACE.has(type)) continue;
|
||||||
const poly = polygonArea(s.pts) < 0 ? [...s.pts].reverse() : s.pts;
|
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])]) });
|
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.
|
// Die Kote 1.47 gehoert zum Spielhauspodest, nicht zum Gelaende.
|
||||||
const terrainSpots = spots.filter((s) => s.zone && s.y < 1.2);
|
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 = [];
|
const flatSurfaces = [];
|
||||||
for (const s of surfaces) {
|
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));
|
const inside = terrainSpots.filter((sp) => pointInPolygon(sp.x, sp.z, s.polygon));
|
||||||
if (!inside.length) continue;
|
// Nur Koten derselben Gelaendezone heranziehen - ueber eine Stuetzmauer
|
||||||
let plane = inside.length >= 3 ? fitPlane(inside) : null;
|
// hinweg gehoert die Nachbarkote nicht zu dieser Flaeche.
|
||||||
// Fast kollineare Koten liefern wilde Ebenen. Nur uebernehmen, wenn das
|
const [pcx, pcz] = centroid(s.polygon);
|
||||||
// Gefaelle plausibel ist (< 10 %) und alle Koten getroffen werden.
|
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) {
|
if (plane) {
|
||||||
const slope = Math.hypot(plane.a, plane.b);
|
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)));
|
const k = slope > 0.05 ? 0.05 / slope : 1;
|
||||||
if (slope > 0.10 || maxDev > 0.06) plane = null;
|
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({
|
flatSurfaces.push({
|
||||||
polygon: s.polygon, plane, mean, type: s.type,
|
polygon: s.polygon, plane, type: s.type,
|
||||||
min: Math.min(...ys) - 0.15, max: Math.max(...ys) + 0.15,
|
min: Math.min(...ys) - 0.12, max: Math.max(...ys) + 0.12,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +273,9 @@ function interpolate(x, z, zoneId) {
|
|||||||
return num / den;
|
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++) {
|
for (let j = 0; j < nz; j++) {
|
||||||
const z = GRID.z0 + j * CELL;
|
const z = GRID.z0 + j * CELL;
|
||||||
for (let i = 0; i < nx; i++) {
|
for (let i = 0; i < nx; i++) {
|
||||||
@@ -225,8 +295,8 @@ for (let j = 0; j < nz; j++) {
|
|||||||
y = interpolate(x, z, zone);
|
y = interpolate(x, z, zone);
|
||||||
for (const f of flatSurfaces) {
|
for (const f of flatSurfaces) {
|
||||||
if (!pointInPolygon(x, z, f.polygon)) continue;
|
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, f.plane.a * x + f.plane.b * z + f.plane.c));
|
||||||
y = Math.max(f.min, Math.min(f.max, y));
|
locked[j * nx + i] = 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,16 +304,16 @@ for (let j = 0; j < nz; j++) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zwei Glaettungsrunden, damit weder die Kanten der Ebenen schneiden noch
|
// Glaetten - aber nur die freien Felder. Die Belaege bleiben exakt eben.
|
||||||
// einzelne Koten als Beulen stehenbleiben.
|
|
||||||
let smoothed = Float32Array.from(height);
|
let smoothed = Float32Array.from(height);
|
||||||
for (let pass = 0; pass < 2; pass++) {
|
for (let pass = 0; pass < 3; pass++) {
|
||||||
const src = smoothed;
|
const src = smoothed;
|
||||||
const dst = Float32Array.from(src);
|
const dst = Float32Array.from(src);
|
||||||
for (let j = 1; j < nz - 1; j++) {
|
for (let j = 1; j < nz - 1; j++) {
|
||||||
for (let i = 1; i < nx - 1; i++) {
|
for (let i = 1; i < nx - 1; i++) {
|
||||||
const k = j * nx + 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;
|
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 })),
|
spots: terrainSpots.map((s) => ({ x: round(s.x), z: round(s.z), y: s.y, zone: s.zone })),
|
||||||
},
|
},
|
||||||
surfaces,
|
surfaces,
|
||||||
|
slabs,
|
||||||
plants,
|
plants,
|
||||||
trees,
|
trees,
|
||||||
lamps,
|
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])) {
|
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(' ' + 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(`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}`);
|
console.log(`Hoehenraster ${nx} x ${nz} (${CELL} m), Koten ${terrainSpots.length}`);
|
||||||
const hs = Array.from(smoothed);
|
const hs = Array.from(smoothed);
|
||||||
|
|||||||