Sichtschutz, schwebende Stufen und Hoehenprofil nach Plandurchgang
Deploy App / deploy (push) Successful in 28s

Nach erneutem Detailstudium des Gartenplans und der Referenzbilder darin:

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 07:40:36 +02:00
co-authored by Claude Opus 5
parent d3732bfed6
commit 5b1cf9d0d9
11 changed files with 474 additions and 127 deletions
+54 -13
View File
@@ -39,7 +39,8 @@ verifiziert: die Keramikplatten messen sich auf 0.600 m genau.
|---|---|---| |---|---|---|
| Feuerwehrzufahrt, Nordwestecke | −2.08 | 648.09 m | | Feuerwehrzufahrt, Nordwestecke | −2.08 | 648.09 m |
| Nachbargarten Zollet (Haus 9, OK FB EG) | −1.70 | 649.47 m | | Nachbargarten Zollet (Haus 9, OK FB EG) | −1.70 | 649.47 m |
| Hauptsitzplatz Travertin | −0.30 … −0.02 | 650.87 … 651.15 m | | Hauptsitzplatz Travertin (untere Ebene) | −0.33 | 650.84 m |
| Rasen und Kiesfeld (obere Ebene) | −0.02 | 651.15 m |
| Fixpunkt Hauseingang | 0.00 | 651.17 m | | Fixpunkt Hauseingang | 0.00 | 651.17 m |
| Ostseite beim Apfelbaum | +0.63 | 651.80 m | | Ostseite beim Apfelbaum | +0.63 | 651.80 m |
| OK Fertigboden Obergeschoss | +2.90 | 654.07 m | | OK Fertigboden Obergeschoss | +2.90 | 654.07 m |
@@ -71,15 +72,50 @@ 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.
### Die beiden Ebenen
Der Garten liegt auf zwei Niveaus, und der Sprung dazwischen ist ein Bauteil:
| | |
|---|---|
| Untere Ebene – Hauptsitzplatz Travertin | **−0.33 m** |
| Travertinsitzmauer, Ansichtshöhe | **0.31 m** |
| Obere Ebene – Rasen, Kiesfeld, Flammengrill | **−0.02 m** |
| Gedeckter Sitzplatz | −0.02 m |
| Zwei schwebende Travertinstufen dazwischen | −0.17 / −0.31 m |
Gegen Osten steigt der Garten laut Plan weiter an (0.26 bis 0.63 m); dort bleibt
die aus den Koten ausgeglichene Ebene stehen. Die beiden Hauptniveaus stehen als
`LEVELS` in `tools/authoring.mjs` und werden nicht geschätzt – Flächen, deren
Schwerpunkt darin liegt, bekommen exakt dieses Niveau.
### Der Sichtschutz
Er ist bewusst **nicht durchgehend**. Der Plan zeichnet ihn als Wechsel von
aufrechten **Travertinstelen** (50 × 6 cm, in Dreiergruppen) und
**Eibengruppen**, alle zusammen in einem **Dekorkiesbett**. Die 28 Stelen kommen
mit Position und Ausrichtung direkt aus dem Plan; die Eiben werden nur innerhalb
einer Gruppe zu einem Heckenkörper verkettet, damit die Lücken für die Stelen
offen bleiben.
Höhen nach Abschirmrichtung:
- gegen den Südnachbarn, entlang der langen Fläche: **1.80 m**
- gegen Zollet und die Feuerwehrzufahrt, untere Ebene: **1.50 m**
### Ebene Beläge statt gewelltem Gelände ### Ebene Beläge statt gewelltem Gelände
Gebaute Flächen sind eben. Für jeden Belag wird aus den Höhenkoten darin und Gebaute Flächen sind eben. Wo kein festes Niveau vorgegeben ist, wird aus den
knapp daneben eine Ebene bestimmt – Gefälle auf 5 % begrenzt, am Schwerpunkt der Höhenkoten eine Ausgleichsebene bestimmt – Gefälle auf 4 % begrenzt, am
Koten aufgehängt, und nur Koten derselben Geländezone. Das Geländeraster wird in Schwerpunkt der Koten aufgehängt. Jede Kote gehört dabei zu genau **einer**
diesen Feldern auf die Ebene gezwungen und dort **nicht** geglättet. Im Rendering Fläche, nämlich der kleinsten, die sie enthält; sonst zieht etwa die Kote einer
bekommt jeder Belag eine senkrechte Sichtkante zum angrenzenden Gelände – erst Stufe die darunterliegende Terrasse mit hoch und aus zwei sauberen Ebenen wird
dadurch wirkt eine Terrasse gebaut und nicht aufgemalt. Wachsende Flächen eine schiefe.
(Rasen, Schotterwiese, Beet) folgen weiterhin dem Gelände.
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 dem Gelände.
### Bauteile statt hochgezogener Flächen ### Bauteile statt hochgezogener Flächen
@@ -95,15 +131,20 @@ aus (`src/scene/walls.ts`):
35 cm tief, 90 cm Achsabstand) vor dem gedeckten Sitzplatz. 35 cm tief, 90 cm Achsabstand) vor dem gedeckten Sitzplatz.
- **Holzscheiterwand** – Stahlrahmen, gefüllt mit gestapelten Scheitern. - **Holzscheiterwand** – Stahlrahmen, gefüllt mit gestapelten Scheitern.
Die **Eibenhecke** entsteht nicht aus Einzelkugeln: die Pflanzstandorte des Plans Die **Eibengruppen** entstehen nicht aus Einzelkugeln: die Pflanzstandorte des
werden zu Reihen verkettet, begradigt, an scharfen Knicken getrennt und als Plans werden gruppenweise verkettet, begradigt, an scharfen Knicken getrennt und
geschnittener Heckenkörper mit gerundetem Querschnitt aufgezogen. Ebenso die als geschnittener Heckenkörper mit gerundetem Querschnitt aufgezogen – siehe
Ligusterhecke (170 cm hoch, 75 cm breit). Das ist der eigentliche Sichtschutz Abschnitt „Der Sichtschutz". Ebenso die Ligusterhecke (170 cm, 75 cm breit).
im Garten.
Die **Travertinplatten 40 × 60 × 3 cm** liegen laut Plan einzeln im Dekorkies – Die **Travertinplatten 40 × 60 × 3 cm** liegen laut Plan einzeln im Dekorkies –
30 Platten, exakt an den Planpositionen, als erhabene Platten auf dem Kiesbett. 30 Platten, exakt an den Planpositionen, als erhabene Platten auf dem Kiesbett.
Materialien und Ausbildung folgen den **Referenzbildern im Plan-PDF**: heller,
geschichteter Travertin für Mauern, Stelen und Platten, die schwebenden Stufen
als auskragende Platten mit Schattenfuge, der Flammengrill als anthrazitfarbener
Stahlkubus mit Holzfach und runder Feueröffnung. Die Bilder lassen sich mit
`node tools/plan-bilder.mjs` aus dem PDF ziehen.
### 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
File diff suppressed because one or more lines are too long
+11
View File
@@ -117,6 +117,17 @@ async function main() {
for (const t of site.trees) { for (const t of site.trees) {
obstacles.push({ cx: t.x, cz: t.z, hw: 0.22, hd: 0.22, rot: 0 }); obstacles.push({ cx: t.x, cz: t.z, hw: 0.22, hd: 0.22, rot: 0 });
} }
// Der Sichtschutz ist mannshoch und damit undurchdringlich: aufrechte
// Travertinstelen und die Eibengruppen dazwischen.
for (const s of site.stelen) {
const hw = s.rot === 0 ? s.len / 2 : s.t / 2;
const hd = s.rot === 0 ? s.t / 2 : s.len / 2;
obstacles.push({ cx: s.x, cz: s.z, hw, hd, rot: 0 });
}
for (const p of site.plants) {
if (p.type !== 'eibe' || p.x === undefined || p.z === undefined) continue;
obstacles.push({ cx: p.x, cz: p.z, hw: 0.26, hd: 0.26, rot: 0 });
}
const walker = new Walker(camera, { height, obstacles, bounds: BOUNDS }); const walker = new Walker(camera, { height, obstacles, bounds: BOUNDS });
walker.attach(canvas); walker.attach(canvas);
+84 -37
View File
@@ -4,11 +4,11 @@
* Die Mauern stehen in walls.ts. * Die Mauern stehen in walls.ts.
*/ */
import { import {
BoxGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial, PointLight, BoxGeometry, BufferGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial,
type MeshStandardMaterialParameters, PointLight, type MeshStandardMaterialParameters,
} from 'three'; } from 'three';
import type { Lamp, Prop, Stele } from '../site'; import type { Lamp, Prop, Stele } from '../site';
import { boxGeometry } from './geometry'; import { boxGeometry, mergeGeometries } 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 = {}) {
@@ -21,15 +21,28 @@ function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {})
...extra, ...extra,
}); });
} }
/**
* Travertinstelen: aufrecht gestellte Platten, 50 cm lang und 6 bis 9 cm dick.
* Sie stehen im Dekorkiesbett und wechseln sich mit den Eibengruppen ab - das
* ist der Sichtschutz des Gartens. Hoehe je nach Seite 1.50 oder 1.80 m.
*/
export function buildStelen(stelen: Stele[], height: (x: number, z: number) => number): Group { 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 stein = tex.travertinMauer(); const stein = tex.travertinStele();
const mat = material(stein, { roughness: 0.66 }); const mat = material(stein, { roughness: 0.62 });
const parts: BufferGeometry[] = [];
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.25;
const geo = boxGeometry(s.x, s.z, 0.22, 0.22, y0, y0 + 0.3 + s.h, 0, stein.tile); // 2.5 cm Stossfuge: die Dreiergruppen des Plans sollen als einzelne
const m = new Mesh(geo, mat); // Platten lesbar bleiben und nicht zu einer glatten Wand verschmelzen.
const len = s.len - 0.025;
const w = s.rot === 0 ? len : s.t;
const d = s.rot === 0 ? s.t : len;
parts.push(boxGeometry(s.x, s.z, w, d, y0, y0 + 0.25 + s.height, 0, stein.tile));
}
if (parts.length) {
const m = new Mesh(mergeGeometries(parts), mat);
m.castShadow = true; m.castShadow = true;
m.receiveShadow = true; m.receiveShadow = true;
group.add(m); group.add(m);
@@ -37,6 +50,32 @@ export function buildStelen(stelen: Stele[], height: (x: number, z: number) => n
return group; return group;
} }
/**
* Schwebende Travertinstufe: dicke Platte, die frei auskragt. Der Schattenfuge
* darunter verdankt sie ihre Wirkung, deshalb sitzt sie auf einem deutlich
* schmaleren Sockel.
*/
function schwebendeStufe(p: Prop, y: number): Group {
const g = new Group();
const belag = tex.travertinSlab();
const platte = material(belag, { roughness: 0.68 });
const top = p.top ?? y;
const slab = new Mesh(
boxGeometry(p.x, p.z, p.w, p.d, top - p.h, top, p.rot, belag.tile), platte,
);
slab.castShadow = true;
slab.receiveShadow = true;
g.add(slab);
// Unterbau, ringsum 12 cm eingezogen: erzeugt die Schattenfuge
const sockel = new Mesh(
boxGeometry(p.x, p.z, p.w - 0.24, p.d - 0.24, top - p.h - 0.30, top - p.h - 0.01, p.rot, 1),
new MeshStandardMaterial({ color: 0x3a3833, roughness: 0.9 }),
);
sockel.receiveShadow = true;
g.add(sockel);
return g;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Freistehende Objekte // Freistehende Objekte
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -96,45 +135,52 @@ function spielhaus(p: Prop, y: number): Group {
} }
/** /**
* Flammengrill: Cortenstahl-Kubus mit umlaufender Grillplatte und offener * Flammengrill nach dem Referenzbild des Planers: anthrazitfarbener Stahlkubus,
* Feuerkammer, wie auf dem Referenzfoto des Plans. * unten ein offenes Holzfach, oben ein perforiertes Band, in der Deckplatte die
* runde Feueroeffnung.
*/ */
function flammengrill(p: Prop, y: number): Group { function flammengrill(p: Prop, y: number): Group {
const g = new Group(); const g = new Group();
const corten = new MeshStandardMaterial({ color: 0x5c3a26, roughness: 0.62, metalness: 0.45 }); const stahl = new MeshStandardMaterial({ color: 0x4a4c50, roughness: 0.55, metalness: 0.45 });
const dark = new MeshStandardMaterial({ color: 0x241d18, roughness: 0.75, metalness: 0.3 }); const dunkel = new MeshStandardMaterial({ color: 0x25272a, roughness: 0.85, metalness: 0.2 });
const plateMat = new MeshStandardMaterial({ color: 0x3b3a37, roughness: 0.34, metalness: 0.72 }); const deckel = new MeshStandardMaterial({ color: 0x3f4144, roughness: 0.38, metalness: 0.68 });
const scheiter = material(tex.holzscheiter(), { roughness: 0.92 });
// Sockel etwas eingezogen, damit der Kubus zu schweben scheint const hw = p.w / 2, hd = p.d / 2;
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); const holzTop = y + p.h * 0.42; // Oberkante Holzfach
g.add(base); const bandTop = y + p.h - 0.04; // Unterkante Deckplatte
const bandBot = bandTop - 0.16; // perforiertes Kopfband
const bodyTop = y + p.h - 0.05; // Holzfach: sichtbare Scheiter zwischen zwei Stahlwangen
const body = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, y + 0.08, bodyTop, p.rot, 1), corten); const holz = new Mesh(boxGeometry(p.x, p.z, p.w - 0.06, p.d - 0.06, y + 0.03, holzTop, p.rot, 0.5), scheiter);
holz.receiveShadow = true;
g.add(holz);
for (const s of [-1, 1]) {
const wange = new Mesh(boxGeometry(p.x + s * hw, p.z, 0.04, p.d, y, holzTop, p.rot, 1), stahl);
wange.castShadow = true;
g.add(wange);
}
const rueck = new Mesh(boxGeometry(p.x, p.z + hd, p.w, 0.04, y, holzTop, p.rot, 1), stahl);
g.add(rueck);
// Geschlossener Korpus darueber
const body = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, holzTop, bandBot, p.rot, 1), stahl);
body.castShadow = true; body.receiveShadow = true; body.castShadow = true; body.receiveShadow = true;
g.add(body); g.add(body);
// Feuerkammer: dunkle Nische in der Vorderseite // Perforiertes Band: einzelne Loecher waeren zu teuer, deshalb ein dunkleres,
const a = (p.rot * Math.PI) / 180; // leicht zurueckversetztes Band mit rauer Oberflaeche
const fx = p.x - Math.sin(a) * (p.d / 2 - 0.02); const band = new Mesh(boxGeometry(p.x, p.z, p.w - 0.01, p.d - 0.01, bandBot, bandTop, p.rot, 1), dunkel);
const fz = p.z + Math.cos(a) * (p.d / 2 - 0.02); band.castShadow = true;
const fire = new Mesh( g.add(band);
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 // Deckplatte mit runder Feueroeffnung
const plate = new Mesh( const plate = new Mesh(boxGeometry(p.x, p.z, p.w + 0.05, p.d + 0.05, bandTop, bandTop + 0.04, p.rot, 1), deckel);
boxGeometry(p.x, p.z, p.w + 0.14, p.d + 0.14, bodyTop, bodyTop + 0.035, p.rot, 1), plateMat, plate.castShadow = true; plate.receiveShadow = true;
);
plate.castShadow = true;
plate.receiveShadow = true;
g.add(plate); g.add(plate);
const ring = new Mesh( const loch = new Mesh(new CylinderGeometry(p.w * 0.19, p.w * 0.19, 0.06, 24), dunkel);
new CylinderGeometry(p.w * 0.24, p.w * 0.24, 0.05, 20), dark, loch.position.set(p.x, bandTop + 0.035, p.z);
); g.add(loch);
ring.position.set(p.x, bodyTop + 0.04, p.z);
g.add(ring);
return g; return g;
} }
@@ -232,6 +278,7 @@ 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 'stufe': obj = schwebendeStufe(p, y); break;
case 'roboter': obj = roboterGarage(p, y); break; case 'roboter': obj = roboterGarage(p, y); break;
case 'wasserhahn': obj = wasserhahn(p, y); break; case 'wasserhahn': obj = wasserhahn(p, y); break;
case 'schacht': obj = schachtdeckel(p, y); break; case 'schacht': obj = schachtdeckel(p, y); break;
+43 -6
View File
@@ -223,7 +223,7 @@ function travertinFinish(ctx: CanvasRenderingContext2D, seed: number) {
const rnd = mulberry32(seed + 7); const rnd = mulberry32(seed + 7);
// Poren und Adern, typisch fuer Travertin // Poren und Adern, typisch fuer Travertin
for (let i = 0; i < 900; i++) { for (let i = 0; i < 900; i++) {
ctx.fillStyle = `rgba(120,105,84,${0.05 + rnd() * 0.10})`; ctx.fillStyle = `rgba(146,130,104,${0.05 + rnd() * 0.09})`;
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 1 + rnd() * 5, 0.6 + rnd() * 1.6, rnd() * 0.6, 0, Math.PI * 2); ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 1 + rnd() * 5, 0.6 + rnd() * 1.6, rnd() * 0.6, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
@@ -242,7 +242,7 @@ function travertinFinish(ctx: CanvasRenderingContext2D, seed: number) {
/** Travertinplatten 80 x 80 x 3 cm, Hauptsitzplatz. */ /** Travertinplatten 80 x 80 x 3 cm, Hauptsitzplatz. */
export const travertin80 = () => makeRelief('travertin80', 3.2, 0.70, 3.2, export const travertin80 = () => makeRelief('travertin80', 3.2, 0.70, 3.2,
(ctx) => { (ctx) => {
slabs(ctx, 3.2, 0.8, 0.8, 'rgb(209,195,168)', 'rgb(158,145,124)', 0.010, 11, 7); slabs(ctx, 3.2, 0.8, 0.8, 'rgb(224,214,192)', 'rgb(176,166,146)', 0.010, 11, 6);
travertinFinish(ctx, 21); travertinFinish(ctx, 21);
}, },
(ctx) => slabHeight(ctx, 3.2, 0.8, 0.8, 0.010, 0.015)); (ctx) => slabHeight(ctx, 3.2, 0.8, 0.8, 0.010, 0.015));
@@ -250,7 +250,7 @@ export const travertin80 = () => makeRelief('travertin80', 3.2, 0.70, 3.2,
/** Einzelne Travertinplatte 40 x 60 x 3 cm - Textur fuer die losen Platten. */ /** Einzelne Travertinplatte 40 x 60 x 3 cm - Textur fuer die losen Platten. */
export const travertinSlab = () => makeRelief('travertinSlab', 0.8, 0.70, 2.2, export const travertinSlab = () => makeRelief('travertinSlab', 0.8, 0.70, 2.2,
(ctx) => { (ctx) => {
ctx.fillStyle = 'rgb(213,199,171)'; ctx.fillStyle = 'rgb(228,218,196)';
ctx.fillRect(0, 0, SIZE, SIZE); ctx.fillRect(0, 0, SIZE, SIZE);
travertinFinish(ctx, 26); travertinFinish(ctx, 26);
}, },
@@ -259,7 +259,7 @@ export const travertinSlab = () => makeRelief('travertinSlab', 0.8, 0.70, 2.2,
/** Travertinstellplatten als schmaler Randstreifen. */ /** Travertinstellplatten als schmaler Randstreifen. */
export const travertinstell = () => makeRelief('travertinstell', 2.4, 0.73, 3.0, export const travertinstell = () => makeRelief('travertinstell', 2.4, 0.73, 3.0,
(ctx) => { (ctx) => {
slabs(ctx, 2.4, 0.4, 0.4, 'rgb(206,192,166)', 'rgb(155,144,124)', 0.012, 13, 8); slabs(ctx, 2.4, 0.4, 0.4, 'rgb(221,210,188)', 'rgb(172,162,142)', 0.012, 13, 7);
travertinFinish(ctx, 23); travertinFinish(ctx, 23);
}, },
(ctx) => slabHeight(ctx, 2.4, 0.4, 0.4, 0.012, 0.012)); (ctx) => slabHeight(ctx, 2.4, 0.4, 0.4, 0.012, 0.012));
@@ -422,7 +422,7 @@ export const spielsand = () => make('spielsand', 1.5, 1.0, (ctx) => {
*/ */
export const travertinMauer = () => makeRelief('travertinMauer', 0.7, 0.68, 1.9, export const travertinMauer = () => makeRelief('travertinMauer', 0.7, 0.68, 1.9,
(ctx) => { (ctx) => {
ctx.fillStyle = 'rgb(203,183,148)'; ctx.fillStyle = 'rgb(221,208,182)';
ctx.fillRect(0, 0, SIZE, SIZE); ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(61); const rnd = mulberry32(61);
// liegende Sedimentbaender, bewusst zurueckhaltend - sonst wirkt der Stein // liegende Sedimentbaender, bewusst zurueckhaltend - sonst wirkt der Stein
@@ -430,7 +430,7 @@ export const travertinMauer = () => makeRelief('travertinMauer', 0.7, 0.68, 1.9,
for (let i = 0; i < 34; i++) { for (let i = 0; i < 34; i++) {
const y = rnd() * SIZE; const y = rnd() * SIZE;
const hgt = 3 + rnd() * 12; const hgt = 3 + rnd() * 12;
ctx.fillStyle = `rgba(${(188 + rnd() * 20) | 0},${(168 + rnd() * 18) | 0},${(134 + rnd() * 16) | 0},0.35)`; ctx.fillStyle = `rgba(${(206 + rnd() * 18) | 0},${(192 + rnd() * 16) | 0},${(166 + rnd() * 14) | 0},0.32)`;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, y); 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 = 0; x <= SIZE; x += 24) ctx.lineTo(x, y + Math.sin(x / 55 + i) * 4);
@@ -654,3 +654,40 @@ function laub(key: string, base: string, seed: number, leaf: number): Belag {
export const laubEibe = () => laub('laubEibe', 'rgb(64,92,52)', 201, 3.4); export const laubEibe = () => laub('laubEibe', 'rgb(64,92,52)', 201, 3.4);
/** Liguster: mittelgruen, groesseres Blatt. */ /** Liguster: mittelgruen, groesseres Blatt. */
export const laubLiguster = () => laub('laubLiguster', 'rgb(85,118,55)', 211, 5.2); export const laubLiguster = () => laub('laubLiguster', 'rgb(85,118,55)', 211, 5.2);
/**
* Travertin fuer die aufrechten Stelen. Der Stein ist gesaegt und zeigt die
* Schichtung als feine, liegende Baender - so wie auf dem Referenzbild des
* Planers. Die Kachel ist klein, weil eine Stele nur 50 cm breit ist.
*/
export const travertinStele = () => makeRelief('travertinStele', 0.5, 0.62, 1.7,
(ctx) => {
ctx.fillStyle = 'rgb(226,214,190)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(91);
for (let i = 0; i < 46; i++) {
const y = rnd() * SIZE;
const hgt = 2 + rnd() * 9;
ctx.fillStyle = `rgba(${(204 + rnd() * 22) | 0},${(190 + rnd() * 20) | 0},${(162 + rnd() * 18) | 0},0.42)`;
ctx.beginPath();
ctx.moveTo(0, y);
for (let x = 0; x <= SIZE; x += 32) ctx.lineTo(x, y + Math.sin(x / 70 + i) * 2.5);
for (let x = SIZE; x >= 0; x -= 32) ctx.lineTo(x, y + hgt + Math.sin(x / 62 + i * 1.4) * 2.5);
ctx.closePath();
ctx.fill();
}
travertinFinish(ctx, 92);
grain(ctx, 93, 0.04, 1);
},
(ctx) => {
ctx.fillStyle = 'rgb(196,196,196)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(94);
for (let i = 0; i < 1800; i++) {
ctx.fillStyle = `rgba(70,70,70,${0.15 + rnd() * 0.3})`;
ctx.beginPath();
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 0.7 + rnd() * 2.0, 0.5 + rnd() * 0.9, 0, 0, Math.PI * 2);
ctx.fill();
}
noiseTile(ctx, 95, 120, 0.06);
});
+41 -15
View File
@@ -156,6 +156,19 @@ function simplifyLine(points: Vec2[], tol: number): Vec2[] {
]; ];
} }
/**
* Hoehe des Sichtschutzes je nach Lage - dieselbe Regel wie fuer die
* Travertinstelen, damit Hecke und Stele auf einer Linie abschliessen:
* gegen den Suednachbarn 1.80 m, gegen Zollet und die Zufahrt 1.50 m.
* Alles andere sind Beetpflanzungen und bleiben niedrig.
*/
function hedgeHeight(x: number, z: number): number {
if (z > 3.0) return 1.80;
if (z < -5.0) return 1.50;
if (x < -19.4) return 1.50;
return 0.95;
}
/** Trennt einen Polyzug an Knicken ueber `maxTurnDeg` in eigene Abschnitte. */ /** Trennt einen Polyzug an Knicken ueber `maxTurnDeg` in eigene Abschnitte. */
function splitAtCorners(line: Vec2[], maxTurnDeg: number): Vec2[][] { function splitAtCorners(line: Vec2[], maxTurnDeg: number): Vec2[][] {
if (line.length <= 2) return [line]; if (line.length <= 2) return [line];
@@ -273,16 +286,26 @@ function hedgeMesh(
pos.push(...A, ...D, ...C, ...A, ...C, ...B); pos.push(...A, ...D, ...C, ...A, ...C, ...B);
uv.push(U0, v0, U0, v1, U1, v1, U0, v0, U1, v1, U1, v0); uv.push(U0, v0, U0, v1, U1, v1, U0, v0, U1, v1, U1, v0);
} }
// Stirnflaechen an den Enden // Stirnflaechen an den Enden. Die UVs folgen dem Querschnitt in Metern,
// sonst faechern sich die Blaetter vom Mittelpunkt aus strahlenfoermig auf.
if (i === 0 || i === stations.length - 2) { if (i === 0 || i === stations.length - 2) {
const s = i === 0 ? s0 : s1; const s = i === 0 ? s0 : s1;
const y = i === 0 ? y0 : y1; const y = i === 0 ? y0 : y1;
const c = at(s, y, [0, 0.5]); const mid: Vec2 = [0, 0.5];
const c = at(s, y, mid);
const capUv = (p: Vec2): [number, number] => [(p[0] * s.w) / 0.55, (p[1] * s.h) / 0.55];
const cu = capUv(mid);
for (let k = 0; k < profile.length - 1; k++) { for (let k = 0; k < profile.length - 1; k++) {
const A = at(s, y, profile[k]); const A = at(s, y, profile[k]);
const B = at(s, y, profile[k + 1]); const B = at(s, y, profile[k + 1]);
if (i === 0) pos.push(...c, ...A, ...B); else pos.push(...c, ...B, ...A); const ua = capUv(profile[k]), ub = capUv(profile[k + 1]);
uv.push(0.5, 0.5, 0, 0, 1, 1); if (i === 0) {
pos.push(...c, ...A, ...B);
uv.push(cu[0], cu[1], ua[0], ua[1], ub[0], ub[1]);
} else {
pos.push(...c, ...B, ...A);
uv.push(cu[0], cu[1], ub[0], ub[1], ua[0], ua[1]);
}
} }
} }
run += seg; run += seg;
@@ -339,29 +362,32 @@ export function buildVegetation(
const r = rng(4242); const r = rng(4242);
// --- Eibenhecke `Hilli` 80/100 cm --------------------------------------- // --- Eibenhecke `Hilli` als Sichtschutz ----------------------------------
// Der Plan setzt die Eiben in dichten Reihen: das ist eine geschnittene // Der Sichtschutz ist bewusst **nicht** durchgehend: im Plan wechseln sich
// Hecke und der eigentliche Sichtschutz im Garten, keine Einzelkugeln. // Eibengruppen und aufrechte Travertinstelen ab, beide stehen im Dekorkies.
// Der geringe Verkettungsabstand haelt die Gruppen deshalb getrennt - dort,
// wo im Plan eine Stele steht, bleibt die Luecke offen.
const eiben = plants.filter((p) => p.type === 'eibe' && p.x !== undefined) const eiben = plants.filter((p) => p.type === 'eibe' && p.x !== undefined)
.map((p) => [p.x!, p.z!] as Vec2); .map((p) => [p.x!, p.z!] as Vec2);
// Reihen begradigen und an scharfen Knicken trennen: eine gemiterte Hecke const rows = chainRows(eiben, 0.95).flatMap((row) => splitAtCorners(simplifyLine(row, 0.22), 32));
// um eine 90-Grad-Ecke herum verdreht sich sonst.
const rows = chainRows(eiben, 2.2).flatMap((row) => splitAtCorners(simplifyLine(row, 0.22), 32));
const eibeLaub = tex.laubEibe(); const eibeLaub = tex.laubEibe();
const eibeMat = new MeshStandardMaterial({ const eibeMat = new MeshStandardMaterial({
map: eibeLaub.map, normalMap: eibeLaub.normalMap ?? null, map: eibeLaub.map, normalMap: eibeLaub.normalMap ?? null,
normalScale: eibeLaub.normalScale, roughness: eibeLaub.roughness, metalness: 0, normalScale: eibeLaub.normalScale?.clone().multiplyScalar(0.6),
roughness: eibeLaub.roughness, metalness: 0,
side: DoubleSide, side: DoubleSide,
}); });
for (const row of rows) { for (const row of rows) {
const [mx, mz] = row.reduce((a, p) => [a[0] + p[0] / row.length, a[1] + p[1] / row.length], [0, 0]);
const h = hedgeHeight(mx, mz);
if (row.length >= 2) { if (row.length >= 2) {
group.add(hedgeMesh(row, 0.95, 0.52, eibeMat, height, r, 'Eibenhecke `Hilli`')); group.add(hedgeMesh(row, h, 0.55, eibeMat, height, r, 'Eibenhecke `Hilli`'));
} else { } else {
// Einzelstand: als Solitaerpolster // Einzelstand: als schmales, aufrechtes Polster
const [x, z] = row[0]; const [x, z] = row[0];
const geo = blob(0.26, 2, 0.05, 3, 1.8); const geo = blob(0.28, 2, 0.05, 3, h / 0.56);
const m = new Mesh(geo, eibeMat); const m = new Mesh(geo, eibeMat);
m.position.set(x, height(x, z) + 0.47, z); m.position.set(x, height(x, z) + h / 2, z);
m.castShadow = true; m.castShadow = true;
m.receiveShadow = true; m.receiveShadow = true;
group.add(m); group.add(m);
+21 -2
View File
@@ -19,6 +19,8 @@ export interface Surface {
polygon: Vec2[]; polygon: Vec2[];
/** Ebene a*x + b*z + c, sofern es sich um einen gebauten Belag handelt. */ /** Ebene a*x + b*z + c, sofern es sich um einen gebauten Belag handelt. */
plane?: [number, number, number]; plane?: [number, number, number];
/** Zugehoerige Hauptebene, sofern fest vorgegeben. */
level?: string;
note?: string; note?: string;
} }
@@ -74,17 +76,34 @@ export interface Wall {
export interface Prop { export interface Prop {
id: string; id: string;
type: 'gartenhaus' | 'spielhaus' | 'flammengrill' | 'bank' | 'roboter' | 'wasserhahn' | 'schacht' | 'tor'; type: 'gartenhaus' | 'spielhaus' | 'flammengrill' | 'bank' | 'roboter' | 'wasserhahn' | 'schacht' | 'tor' | 'stufe';
x: number; x: number;
z: number; z: number;
w: number; w: number;
d: number; d: number;
h: number; h: number;
rot: number; rot: number;
/** Absolute Oberkante, sofern das Bauteil nicht auf dem Gelaende aufsitzt. */
top?: number;
note?: string; note?: string;
} }
export interface Stele { x: number; z: number; h: number } /**
* Aufrechte Travertinstele, 50 x 6 cm im Grundriss. Zusammen mit den
* Eibengruppen bildet sie den Sichtschutz; beide stehen im Dekorkies.
*/
export interface Stele {
x: number;
z: number;
/** Laenge in Ansicht (Plan: 0.50 m). */
len: number;
/** Dicke (Plan: 0.06 bis 0.09 m). */
t: number;
/** Drehung um Y in Grad: 0 = laengs X, 90 = laengs Z. */
rot: number;
height: number;
side: 'sued' | 'nord' | 'west';
}
export interface Opening { export interface Opening {
face: 'north' | 'south' | 'east' | 'west'; face: 'north' | 'south' | 'east' | 'west';
+15 -3
View File
@@ -88,7 +88,7 @@ export function setupHud(opts: HudOptions): Hud {
for (const s of site.surfaces) totals.set(s.type, (totals.get(s.type) ?? 0) + s.area); for (const s of site.surfaces) totals.set(s.type, (totals.get(s.type) ?? 0) + s.area);
const NAMES: Record<string, string> = { const NAMES: Record<string, string> = {
travertin80: 'Travertinplatten 80×80×3 cm', travertin80: 'Travertinplatten 80×80×3 cm',
travertin4060: 'Travertinplatten 40×60×3 cm', dekorkies: 'Dekorkies',
travertinstell: 'Travertinstellplatten', travertinstell: 'Travertinstellplatten',
keramik: 'Keramikplatten 60×60×2 cm', keramik: 'Keramikplatten 60×60×2 cm',
verbundstein: 'Verbundsteine (Nachbar)', verbundstein: 'Verbundsteine (Nachbar)',
@@ -100,6 +100,8 @@ export function setupHud(opts: HudOptions): Hud {
}; };
const heights = site.terrain.spots.filter((s) => s.zone === 'garten').map((s) => s.y); const heights = site.terrain.spots.filter((s) => s.zone === 'garten').map((s) => s.y);
const eibenCount = site.plants.filter((p) => p.type === 'eibe').length; const eibenCount = site.plants.filter((p) => p.type === 'eibe').length;
const hSued = Math.max(0, ...site.stelen.filter((s) => s.side === 'sued').map((s) => s.height)).toFixed(2);
const hNord = Math.min(9, ...site.stelen.filter((s) => s.side !== 'sued').map((s) => s.height)).toFixed(2);
$<HTMLElement>('info-body').innerHTML = ` $<HTMLElement>('info-body').innerHTML = `
<p>Alle Flächen, Mauern und Pflanzstandorte sind massstäblich aus dem Gartenplan <p>Alle Flächen, Mauern und Pflanzstandorte sind massstäblich aus dem Gartenplan
@@ -115,6 +117,13 @@ export function setupHud(opts: HudOptions): Hud {
<dt>Höhen im Garten</dt><dd>${Math.min(...heights).toFixed(2)} bis ${Math.max(...heights).toFixed(2)} m</dd> <dt>Höhen im Garten</dt><dd>${Math.min(...heights).toFixed(2)} bis ${Math.max(...heights).toFixed(2)} m</dd>
</dl> </dl>
<h3>Die beiden Ebenen</h3>
<p>Der Garten liegt auf zwei Niveaus. Der Hauptsitzplatz aus Travertinplatten
liegt auf <strong>&minus;0.33 m</strong>. Die Travertinsitzmauer überwindet
<strong>31 cm</strong>, danach geht es eben weiter auf <strong>&minus;0.02 m</strong>
mit Rasen, Kiesfeld und Flammengrill. Vom gedeckten Sitzplatz führen
<strong>zwei schwebende Travertinstufen</strong> hinunter.</p>
<h3>Beläge</h3> <h3>Beläge</h3>
<dl>${[...totals.entries()] <dl>${[...totals.entries()]
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
@@ -124,10 +133,13 @@ export function setupHud(opts: HudOptions): Hud {
<h3>Bepflanzung und Ausstattung</h3> <h3>Bepflanzung und Ausstattung</h3>
<ul> <ul>
<li>${site.trees.length} Solitärgehölze und Bäume, inkl. Apfelbaum</li> <li>${site.trees.length} Solitärgehölze und Bäume, inkl. Apfelbaum</li>
<li>${eibenCount} Eiben &bdquo;Hilli&ldquo; 80/100 cm als Heckenpflanzung</li> <li>Sichtschutz aus ${site.stelen.length} aufrechten Travertinstelen (50 × 6 cm)
und ${eibenCount} Eiben &bdquo;Hilli&ldquo; im Wechsel, alle im Dekorkiesbett &ndash;
${hSued} m gegen den Südnachbarn, ${hNord} m gegen Zollet</li>
<li>Ligusterhecke 170 cm hoch, 75 cm breit</li> <li>Ligusterhecke 170 cm hoch, 75 cm breit</li>
<li>${site.walls.length} Mauern und Wände, ${site.stelen.length} Travertinstelen</li> <li>${site.walls.length} Mauern und Wände</li>
<li>${site.lamps.length} Leuchten &ndash; sie schalten sich abends automatisch ein</li> <li>${site.lamps.length} Leuchten &ndash; sie schalten sich abends automatisch ein</li>
<li>${site.slabs.length} lose Travertinplatten 40 × 60 × 3 cm im Dekorkies</li>
<li>Gartenhaus 1,8 × 1,8 m, Spielhaus, Flammengrill mit Holzscheiterwand</li> <li>Gartenhaus 1,8 × 1,8 m, Spielhaus, Flammengrill mit Holzscheiterwand</li>
</ul> </ul>
+61 -15
View File
@@ -71,6 +71,37 @@ export const TREES = [
{ name: 'Solitärgehölz Zollet', x: -17.6, z: -10.2, crown: 1.34, height: 4.6, species: 'parrotia', neighbour: true }, { name: 'Solitärgehölz Zollet', x: -17.6, z: -10.2, crown: 1.34, height: 4.6, species: 'parrotia', neighbour: true },
]; ];
/**
* Die beiden Hauptebenen des Gartens.
*
* Der Garten liegt auf zwei Niveaus: der Hauptsitzplatz aus Travertinplatten
* liegt auf -0.33, dahinter steht die Travertinsitzmauer mit 31 cm Ansichtshoehe,
* und direkt hinter ihr geht es eben weiter auf -0.02 mit Rasen, Kiesfeld und
* Flammengrill. Flaechen, deren Schwerpunkt in einem dieser Bereiche liegt,
* werden exakt auf das Niveau gelegt statt aus streuenden Koten geschaetzt.
*
* Gegen Osten steigt der Garten laut Plan weiter an (0.26 bis 0.63) - dort
* bleibt die Ausgleichsebene aus den Koten stehen.
*/
export const LEVELS = [
{
id: 'unten',
level: -0.33,
note: 'Untere Ebene: Hauptsitzplatz Travertin mit Kiesstreifen und Eiben',
polygon: [
[-21.90, -6.65], [-13.75, -6.65], [-13.75, 0.90],
[-18.42, 0.90], [-18.42, 4.15], [-21.90, 4.15],
],
},
{
id: 'oben',
level: -0.02,
note: 'Obere Ebene beim Uebergang: gedeckter Sitzplatz und Rasen an der Mauer',
polygon: [[-14.00, -3.20], [-10.20, -3.20], [-10.20, 1.00], [-14.00, 1.00]],
types: ['keramik'],
},
];
/** /**
* Mauern, Stuetzelemente und Waende. * Mauern, Stuetzelemente und Waende.
* `path` = Mittellinie, `t` = Dicke, `top`/`base` = Hoehe der Krone bzw. des Fusses * `path` = Mittellinie, `t` = Dicke, `top`/`base` = Hoehe der Krone bzw. des Fusses
@@ -79,13 +110,13 @@ export const TREES = [
export const WALLS = [ export const WALLS = [
{ {
id: 'travertinmauer-nord', type: 'travertin', id: 'travertinmauer-nord', type: 'travertin',
note: 'Travertinmauer an der Grenze zum Nachbargrundstueck Zollet, stuetzt rund 1.5 m ab', note: 'Travertinmauer an der Grenze zum Nachbargrundstueck Zollet; Krone auf dem Niveau der unteren Ebene, stuetzt rund 1.5 m ab',
path: [[-18.17, -6.895], [-13.82, -6.895]], t: 0.55, top: -0.30, base: -1.90, path: [[-18.17, -6.895], [-13.82, -6.895]], t: 0.55, top: -0.33, base: -1.95,
}, },
{ {
id: 'sitzmauer-nord', type: 'travertin', id: 'sitzmauer-nord', type: 'travertin',
note: 'Sitzmauer 25 cm ueber der Travertinmauer (gelbe Kote "25" im Plan)', note: 'Sitzmauer 25 cm ueber der unteren Ebene (gelbe Kote "25" im Plan)',
path: [[-20.08, -6.47], [-13.82, -6.47]], t: 0.30, top: -0.03, base: -0.45, path: [[-20.08, -6.47], [-13.82, -6.47]], t: 0.30, top: -0.08, base: -0.55,
}, },
{ {
id: 'winkelelemente-west', type: 'beton', id: 'winkelelemente-west', type: 'beton',
@@ -94,14 +125,17 @@ export const WALLS = [
t: 0.22, top: null, base: null, retains: 'west', t: 0.22, top: null, base: null, retains: 'west',
}, },
{ {
// Diese Mauer ist der Hoehensprung zwischen den beiden Ebenen: Krone
// buendig mit der oberen Ebene (-0.02), Fuss auf der unteren (-0.33).
// Ansichtshoehe damit genau 31 cm.
id: 'travertinsitzmauer-west', type: 'travertin', id: 'travertinsitzmauer-west', type: 'travertin',
note: 'Travertinsitzmauer als Abschluss des Hauptsitzplatzes gegen den Rasen', note: 'Travertinsitzmauer: 31 cm Hoehensprung vom Hauptsitzplatz zum Rasen',
path: [[-18.64, 0.755], [-14.73, 0.755]], t: 0.41, top: 0.40, base: -0.45, seating: true, path: [[-18.64, 0.755], [-14.73, 0.755]], t: 0.41, top: -0.02, base: -0.55, seating: true,
}, },
{ {
id: 'travertinsitzmauer-sued', type: 'travertin', id: 'travertinsitzmauer-sued', type: 'travertin',
note: 'Schenkel der Travertinsitzmauer laengs des Rasens', note: 'Schenkel der Travertinsitzmauer laengs des Rasens, gleicher Hoehensprung',
path: [[-18.44, 0.55], [-18.44, 3.94]], t: 0.40, top: 0.40, base: -0.40, seating: true, path: [[-18.44, 0.55], [-18.44, 3.94]], t: 0.40, top: -0.02, base: -0.55, seating: true,
}, },
{ {
id: 'betonmauer-nachbar', type: 'beton', id: 'betonmauer-nachbar', type: 'beton',
@@ -121,7 +155,7 @@ export const WALLS = [
{ {
id: 'stufe-gartentor', type: 'travertin', id: 'stufe-gartentor', type: 'travertin',
note: 'Blockstufen beim Gartentor an der Suedwestecke', 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, path: [[-21.05, 2.15], [-21.05, 3.75]], t: 0.80, top: -0.18, base: -0.80,
}, },
{ {
id: 'lamellenwand', type: 'lamellen', id: 'lamellenwand', type: 'lamellen',
@@ -146,6 +180,13 @@ export const PROPS = [
{ id: 'schlaufschacht', type: 'schacht', x: -14.35, z: -3.95, w: 0.30, d: 0.30, h: 0.02, rot: 0, { id: 'schlaufschacht', type: 'schacht', x: -14.35, z: -3.95, w: 0.30, d: 0.30, h: 0.02, rot: 0,
note: 'Schlaufschacht D = 30 cm' }, note: 'Schlaufschacht D = 30 cm' },
{ id: 'gartentor', type: 'tor', x: -21.60, z: 3.30, w: 1.00, d: 0.06, h: 1.10, rot: 0 }, { id: 'gartentor', type: 'tor', x: -21.60, z: 3.30, w: 1.00, d: 0.06, h: 1.10, rot: 0 },
// Zwei schwebende Travertinplatten fuehren vom gedeckten Sitzplatz (-0.02)
// auf den Hauptsitzplatz (-0.33). Lage und Format stehen im Plan, die
// Ausbildung als auskragende Platte zeigt das Referenzbild des Planers.
{ id: 'stufe-oben', type: 'stufe', x: -14.335, z: -1.065, w: 0.81, d: 3.25, h: 0.08, rot: 0,
top: -0.17, note: 'Schwebende Travertinstufe, Tritthoehe -0.17' },
{ id: 'stufe-unten', type: 'stufe', x: -14.880, z: -1.480, w: 0.80, d: 3.20, h: 0.08, rot: 0,
top: -0.31, note: 'Schwebende Travertinstufe, Tritthoehe -0.31' },
]; ];
/** /**
@@ -174,13 +215,18 @@ export const EXTRA_SURFACES = [
]; ];
/** /**
* Travertinstelen: aufrechte Steinstelen als Raumteiler entlang der Beete. * Hoehe des Sichtschutzes. Er besteht aus aufrechten Travertinstelen
* Positionen aus den Planbeschriftungen "Travertinstelen". * (50 x 6 cm im Grundriss) und Eibengruppen, die sich abwechseln und
* gemeinsam in einem Dekorkiesbett stehen. Die Stelenpositionen kommen
* direkt aus dem Plan, die Hoehe haengt davon ab, wohin abgeschirmt wird:
*
* - gegen den Suednachbarn, entlang der langen Flaeche: 1.80 m
* - gegen Zollet und die Feuerwehrzufahrt, untere Ebene: 1.50 m
*/ */
export const STELEN = [ export const STELE_HEIGHTS = [
{ x: -19.55, z: -3.10, h: 0.90 }, { x: -19.30, z: -2.30, h: 1.15 }, { x: -19.05, z: -1.55, h: 0.75 }, { id: 'sued', height: 1.80, test: (x, z) => z > 3.0 },
{ x: -13.30, z: 4.45, h: 1.05 }, { x: -12.60, z: 4.45, h: 0.80 }, { x: -10.30, z: 4.60, h: 1.10 }, { id: 'nord', height: 1.50, test: (x, z) => z < -5.0 },
{ x: -9.60, z: 4.60, h: 0.85 }, { x: -1.30, z: 4.55, h: 1.00 }, { x: -0.60, z: 4.55, h: 0.75 }, { id: 'west', height: 1.50, test: (x) => x < -19.4 },
]; ];
/** /**
+94 -24
View File
@@ -8,7 +8,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { import {
SURFACE_BY_COLOR, PLANT_BY_COLOR, TREES, WALLS, PROPS, STELEN, SURFACE_BY_COLOR, PLANT_BY_COLOR, TREES, WALLS, PROPS, LEVELS, STELE_HEIGHTS,
BUILDING, NEIGHBOUR_BUILDINGS, TERRAIN_ZONES, EXTRA_SPOTS, EXTRA_SURFACES, BUILDING, NEIGHBOUR_BUILDINGS, TERRAIN_ZONES, EXTRA_SPOTS, EXTRA_SURFACES,
START_VIEWS, SITE_LOCATION, START_VIEWS, SITE_LOCATION,
} from './authoring.mjs'; } from './authoring.mjs';
@@ -100,24 +100,45 @@ const bboxOf = (pts) => {
*/ */
const slabs = []; const slabs = [];
const surfaces = []; const surfaces = [];
/**
* Travertinstelen: aufrecht gestellte Platten von 50 x 6 cm, im Plan in
* Dreiergruppen. Zusammen mit den Eibengruppen bilden sie den Sichtschutz.
*/
const stelen = [];
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;
let type = SURFACE_BY_COLOR[s.hex]; let type = SURFACE_BY_COLOR[s.hex];
if (!type) continue; if (!type) continue;
const b = bboxOf(s.pts); const b = bboxOf(s.pts);
const cx = (b.x0 + b.x1) / 2, cz = (b.z0 + b.z1) / 2;
// Lose 40x60-Platten (Plan: 0.40 breit, 0.49 bis 0.80 lang) // 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) { 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({ slabs.push({ x: round(cx), z: round(cz), w: round(b.w, 2), d: round(b.d, 2) });
x: round((b.x0 + b.x1) / 2), z: round((b.z0 + b.z1) / 2), continue;
w: round(b.w, 2), d: round(b.d, 2), }
// Aufrechte Stelen: 50 cm lang, 6 bis 9 cm dick, in beiden Achsrichtungen
if (s.hex === '#ddd299' && s.area < 0.06) {
const along = b.w > b.d ? 'x' : 'z';
const len = Math.max(b.w, b.d);
if (len > 0.35 && len < 0.65) {
stelen.push({
x: round(cx), z: round(cz), len: round(len, 2),
t: round(Math.min(b.w, b.d), 2),
rot: along === 'x' ? 0 : 90,
}); });
continue; continue;
} }
}
// Der Plan verwendet denselben hellen Ton fuer Dekorkies und fuer die // Der Plan verwendet denselben hellen Ton fuer Dekorkies und fuer die
// Keramikterrasse des Nachbarn; unterschieden wird ueber die Lage. // Keramikterrasse des Nachbarn; unterschieden wird ueber die Lage.
if (type === 'dekorkies' && (b.z0 + b.z1) / 2 < -6.6) type = 'keramik'; if (type === 'dekorkies' && cz < -6.6) type = 'keramik';
// Die schmalen Baender des Sichtschutzstreifens sind das Kiesbett, in dem
// Stelen und Eiben stehen - kein Plattenbelag.
if (type === 'travertinstell' && cz > 3.2 && Math.min(b.w, b.d) < 0.7) type = 'dekorkies';
if (s.area < 0.3) continue; if (s.area < 0.3) continue;
if (NOT_A_SURFACE.has(type)) continue; if (NOT_A_SURFACE.has(type)) continue;
@@ -130,6 +151,15 @@ for (const s of EXTRA_SURFACES) {
} }
surfaces.sort((a, b) => b.area - a.area); surfaces.sort((a, b) => b.area - a.area);
// Sichtschutzhoehe je nach Lage: gegen den Suednachbarn hoeher als gegen
// Zollet auf der unteren Ebene.
for (const st of stelen) {
const rule = STELE_HEIGHTS.find((r) => r.test(st.x, st.z));
st.height = rule ? rule.height : 1.5;
st.side = rule ? rule.id : 'west';
}
stelen.sort((a, b) => a.z - b.z || a.x - b.x);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 2. Hecken und Pflanzen aus den Plankreisen // 2. Hecken und Pflanzen aus den Plankreisen
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -212,43 +242,80 @@ function distToPolygon(x, z, poly) {
return best; return best;
} }
/**
* Jede Kote gehoert zu genau einer Flaeche: der kleinsten, die sie enthaelt.
* Sonst zieht etwa die Kote einer Stufe die Ebene der darunterliegenden
* Terrasse mit hoch, und aus zwei sauberen Ebenen wird eine schiefe.
*/
const owner = new Map();
for (const sp of terrainSpots) {
let best = null;
for (const s of surfaces) {
if (!FLAT_TYPES.has(s.type)) continue;
if (!pointInPolygon(sp.x, sp.z, s.polygon)) continue;
if (!best || s.area < best.area) best = s;
}
if (best) owner.set(sp, best);
}
/**
* Der Garten hat zwei Hauptebenen. Wo sie feststehen, werden sie fest
* vorgegeben statt aus streuenden Koten geschaetzt - so bleiben die Flaechen
* eben und der Hoehensprung an der Sitzmauer stimmt auf den Zentimeter.
*/
function levelFor(s) {
const [cx, cz] = centroid(s.polygon);
for (const lv of LEVELS) {
if (lv.types && !lv.types.includes(s.type)) continue;
if (pointInPolygon(cx, cz, lv.polygon)) return lv;
}
return null;
}
const flatSurfaces = []; const flatSurfaces = [];
for (const s of surfaces) { for (const s of surfaces) {
if (!FLAT_TYPES.has(s.type) || s.area < 0.6) continue; if (!FLAT_TYPES.has(s.type) || s.area < 0.4) continue;
const inside = terrainSpots.filter((sp) => pointInPolygon(sp.x, sp.z, s.polygon));
// Nur Koten derselben Gelaendezone heranziehen - ueber eine Stuetzmauer const fixed = levelFor(s);
// hinweg gehoert die Nachbarkote nicht zu dieser Flaeche. let plane, ys;
if (fixed) {
plane = { a: 0, b: 0, c: fixed.level };
ys = [fixed.level];
s.level = fixed.id;
} else {
const inside = terrainSpots.filter((sp) => owner.get(sp) === s);
const [pcx, pcz] = centroid(s.polygon); const [pcx, pcz] = centroid(s.polygon);
const zone = zoneOf(pcx, pcz); const zone = zoneOf(pcx, pcz);
const near = terrainSpots.filter( const near = inside.length
(sp) => !inside.includes(sp) && sp.zone === zone && distToPolygon(sp.x, sp.z, s.polygon) < 1.1, ? []
); : terrainSpots.filter((sp) => !owner.has(sp) && sp.zone === zone && distToPolygon(sp.x, sp.z, s.polygon) < 1.2);
const pool = [...inside, ...near]; const pool = [...inside, ...near];
if (!pool.length) continue; if (!pool.length) continue;
const ys = pool.map((p) => p.y); ys = pool.map((p) => p.y);
const mean = ys.reduce((a, b) => a + b, 0) / ys.length; const mean = ys.reduce((a, b) => a + b, 0) / ys.length;
const px = pool.reduce((a, p) => a + p.x, 0) / pool.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; const pz = pool.reduce((a, p) => a + p.z, 0) / pool.length;
let plane = pool.length >= 3 ? fitPlane(pool) : null; let fit = pool.length >= 3 ? fitPlane(pool) : null;
// Ein Belag hat hoechstens rund 5 % Gefaelle - genug fuer die Entwaesserung. Bei fast kollinearen Koten // Ein Belag hat hoechstens rund 4 % Gefaelle. Bei fast kollinearen Koten
// kippt die Ausgleichsebene weg; dann nur das Gefaelle begrenzen und die // kippt die Ausgleichsebene weg; dann nur das Gefaelle begrenzen und die
// Ebene wieder am Schwerpunkt der Koten aufhaengen - nie am Polygonrand, // Ebene am Schwerpunkt der Koten aufhaengen, nie am Polygonrand.
// sonst wandert sie mit dem gekappten Gefaelle davon. if (fit) {
if (plane) { const slope = Math.hypot(fit.a, fit.b);
const slope = Math.hypot(plane.a, plane.b); const k = slope > 0.04 ? 0.04 / slope : 1;
const k = slope > 0.05 ? 0.05 / slope : 1; const a = fit.a * k, b = fit.b * k;
const a = plane.a * k, b = plane.b * k;
plane = { a, b, c: mean - (a * px + b * pz) }; plane = { a, b, c: mean - (a * px + b * pz) };
} else { } else {
plane = { a: 0, b: 0, c: mean }; plane = { a: 0, b: 0, c: mean };
} }
}
s.plane = [round(plane.a, 5), round(plane.b, 5), round(plane.c, 4)]; s.plane = [round(plane.a, 5), round(plane.b, 5), round(plane.c, 4)];
flatSurfaces.push({ flatSurfaces.push({
polygon: s.polygon, plane, type: s.type, polygon: s.polygon, plane, type: s.type,
min: Math.min(...ys) - 0.12, max: Math.max(...ys) + 0.12, min: Math.min(...ys) - 0.10, max: Math.max(...ys) + 0.10,
}); });
} }
@@ -399,7 +466,7 @@ const site = {
lamps, lamps,
walls, walls,
props: PROPS, props: PROPS,
stelen: STELEN, stelen,
building: BUILDING, building: BUILDING,
neighbourBuildings: NEIGHBOUR_BUILDINGS, neighbourBuildings: NEIGHBOUR_BUILDINGS,
startViews: START_VIEWS, startViews: START_VIEWS,
@@ -417,7 +484,10 @@ 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(`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}`);
const bySide = {};
for (const s of stelen) bySide[s.side] = (bySide[s.side] ?? 0) + 1;
console.log(`Travertinstelen ${stelen.length}: ` + Object.entries(bySide).map(([k, v]) => `${k} ${v} Stk auf ${STELE_HEIGHTS.find((r) => r.id === k)?.height ?? '?'} m`).join(', '));
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);
console.log(`Hoehen ${Math.min(...hs).toFixed(2)} .. ${Math.max(...hs).toFixed(2)} m (absolut ${(651.17 + Math.min(...hs)).toFixed(2)} .. ${(651.17 + Math.max(...hs)).toFixed(2)} m ue. M.)`); console.log(`Hoehen ${Math.min(...hs).toFixed(2)} .. ${Math.max(...hs).toFixed(2)} m (absolut ${(651.17 + Math.min(...hs)).toFixed(2)} .. ${(651.17 + Math.max(...hs)).toFixed(2)} m ue. M.)`);
+38
View File
@@ -0,0 +1,38 @@
/**
* Zieht die Referenzfotos aus dem Gartenplan-PDF (Mauerwerk, schwebende Stufen,
* Travertin, Flammengrill, Eibenhecke). Sie sind die Vorlage fuer die
* Materialisierung in der Szene.
*
* Aufruf: node tools/plan-bilder.mjs [Zielordner]
*/
import * as mupdf from 'mupdf'; import fs from 'fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PDF = path.join(__dirname, '..', 'Info Dateien', 'Gartenplan 1 Jasmin und Damian Kellenberger.pdf');
const OUT = process.argv[2] ?? path.join(__dirname, 'out', 'planbilder');
fs.mkdirSync(OUT, { recursive: true });
const doc = mupdf.Document.openDocument(fs.readFileSync(PDF),'application/pdf');
const page = doc.loadPage(0);
let n=0;
const noop=()=>{};
const dev = new mupdf.Device({
fillImage: (img, ctm, alpha) => {
n++;
const w = img.getWidth(), h = img.getHeight();
// Platzierung auf dem Blatt aus der Matrix
const x = ctm[4], y = ctm[5], sx = Math.hypot(ctm[0],ctm[1]), sy = Math.hypot(ctm[2],ctm[3]);
console.log(`Bild ${n}: ${w}x${h}px auf Blatt bei (${x.toFixed(0)}, ${(y-sy).toFixed(0)}) Groesse ${sx.toFixed(0)}x${sy.toFixed(0)}pt`);
try {
const pix = img.toPixmap();
fs.writeFileSync(path.join(OUT, `planbild-${n}.png`), pix.asPNG());
} catch (e) { console.log(' (nicht extrahierbar)', e.message); }
},
fillPath: noop, strokePath: noop, fillShade: noop, fillImageMask: noop,
clipPath: noop, clipStrokePath: noop, clipImageMask: noop, popClip: noop,
fillText: noop, strokeText: noop, clipText: noop, clipStrokeText: noop, ignoreText: noop,
beginMask: noop, endMask: noop, beginGroup: noop, endGroup: noop,
beginTile: () => 0, endTile: noop, beginLayer: noop, endLayer: noop, close: noop,
});
page.run(dev, mupdf.Matrix.identity);
dev.close();