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 |
| 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 |
| Ostseite beim Apfelbaum | +0.63 | 651.80 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
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
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.
Gebaute Flächen sind eben. Wo kein festes Niveau vorgegeben ist, wird aus den
Höhenkoten eine Ausgleichsebene bestimmt – Gefälle auf 4 % begrenzt, am
Schwerpunkt der Koten aufgehängt. Jede Kote gehört dabei zu genau **einer**
Fläche, nämlich der kleinsten, die sie enthält; sonst zieht etwa die Kote einer
Stufe die darunterliegende Terrasse mit hoch und aus zwei sauberen Ebenen wird
eine schiefe.
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
@@ -95,15 +131,20 @@ aus (`src/scene/walls.ts`):
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 **Eibengruppen** entstehen nicht aus Einzelkugeln: die Pflanzstandorte des
Plans werden gruppenweise verkettet, begradigt, an scharfen Knicken getrennt und
als geschnittener Heckenkörper mit gerundetem Querschnitt aufgezogen – siehe
Abschnitt „Der Sichtschutz". Ebenso die Ligusterhecke (170 cm, 75 cm breit).
Die **Travertinplatten 40 × 60 × 3 cm** liegen laut Plan einzeln im Dekorkies –
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
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) {
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 });
walker.attach(canvas);
+84 -37
View File
@@ -4,11 +4,11 @@
* Die Mauern stehen in walls.ts.
*/
import {
BoxGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial, PointLight,
type MeshStandardMaterialParameters,
BoxGeometry, BufferGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial,
PointLight, type MeshStandardMaterialParameters,
} from 'three';
import type { Lamp, Prop, Stele } from '../site';
import { boxGeometry } from './geometry';
import { boxGeometry, mergeGeometries } from './geometry';
import * as tex from './textures';
function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {}) {
@@ -21,15 +21,28 @@ function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {})
...extra,
});
}
/**
* Travertinstelen: aufrecht gestellte Platten, 50 cm lang und 6 bis 9 cm dick.
* Sie stehen im Dekorkiesbett und wechseln sich mit den Eibengruppen ab - das
* ist der Sichtschutz des Gartens. Hoehe je nach Seite 1.50 oder 1.80 m.
*/
export function buildStelen(stelen: Stele[], height: (x: number, z: number) => number): Group {
const group = new Group();
group.name = 'Travertinstelen';
const stein = tex.travertinMauer();
const mat = material(stein, { roughness: 0.66 });
const stein = tex.travertinStele();
const mat = material(stein, { roughness: 0.62 });
const parts: BufferGeometry[] = [];
for (const s of stelen) {
const y0 = height(s.x, s.z) - 0.3;
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 y0 = height(s.x, s.z) - 0.25;
// 2.5 cm Stossfuge: die Dreiergruppen des Plans sollen als einzelne
// Platten lesbar bleiben und nicht zu einer glatten Wand verschmelzen.
const len = s.len - 0.025;
const w = s.rot === 0 ? len : s.t;
const d = s.rot === 0 ? s.t : len;
parts.push(boxGeometry(s.x, s.z, w, d, y0, y0 + 0.25 + s.height, 0, stein.tile));
}
if (parts.length) {
const m = new Mesh(mergeGeometries(parts), mat);
m.castShadow = true;
m.receiveShadow = true;
group.add(m);
@@ -37,6 +50,32 @@ export function buildStelen(stelen: Stele[], height: (x: number, z: number) => n
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
// ---------------------------------------------------------------------------
@@ -96,45 +135,52 @@ function spielhaus(p: Prop, y: number): Group {
}
/**
* Flammengrill: Cortenstahl-Kubus mit umlaufender Grillplatte und offener
* Feuerkammer, wie auf dem Referenzfoto des Plans.
* Flammengrill nach dem Referenzbild des Planers: anthrazitfarbener Stahlkubus,
* unten ein offenes Holzfach, oben ein perforiertes Band, in der Deckplatte die
* runde Feueroeffnung.
*/
function flammengrill(p: Prop, y: number): Group {
const g = new Group();
const corten = new MeshStandardMaterial({ color: 0x5c3a26, roughness: 0.62, metalness: 0.45 });
const dark = new MeshStandardMaterial({ color: 0x241d18, roughness: 0.75, metalness: 0.3 });
const plateMat = new MeshStandardMaterial({ color: 0x3b3a37, roughness: 0.34, metalness: 0.72 });
const stahl = new MeshStandardMaterial({ color: 0x4a4c50, roughness: 0.55, metalness: 0.45 });
const dunkel = new MeshStandardMaterial({ color: 0x25272a, roughness: 0.85, metalness: 0.2 });
const deckel = new MeshStandardMaterial({ color: 0x3f4144, roughness: 0.38, metalness: 0.68 });
const scheiter = material(tex.holzscheiter(), { roughness: 0.92 });
// 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 hw = p.w / 2, hd = p.d / 2;
const holzTop = y + p.h * 0.42; // Oberkante Holzfach
const bandTop = y + p.h - 0.04; // Unterkante Deckplatte
const bandBot = bandTop - 0.16; // perforiertes Kopfband
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);
// Holzfach: sichtbare Scheiter zwischen zwei Stahlwangen
const holz = new Mesh(boxGeometry(p.x, p.z, p.w - 0.06, p.d - 0.06, y + 0.03, holzTop, p.rot, 0.5), scheiter);
holz.receiveShadow = true;
g.add(holz);
for (const s of [-1, 1]) {
const wange = new Mesh(boxGeometry(p.x + s * hw, p.z, 0.04, p.d, y, holzTop, p.rot, 1), stahl);
wange.castShadow = true;
g.add(wange);
}
const rueck = new Mesh(boxGeometry(p.x, p.z + hd, p.w, 0.04, y, holzTop, p.rot, 1), stahl);
g.add(rueck);
// Geschlossener Korpus darueber
const body = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, holzTop, bandBot, p.rot, 1), stahl);
body.castShadow = true; body.receiveShadow = true;
g.add(body);
// 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);
// Perforiertes Band: einzelne Loecher waeren zu teuer, deshalb ein dunkleres,
// leicht zurueckversetztes Band mit rauer Oberflaeche
const band = new Mesh(boxGeometry(p.x, p.z, p.w - 0.01, p.d - 0.01, bandBot, bandTop, p.rot, 1), dunkel);
band.castShadow = true;
g.add(band);
// Grillplatte mit Ausschnitt fuer das Feuer
const plate = new Mesh(
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;
// Deckplatte mit runder Feueroeffnung
const plate = new Mesh(boxGeometry(p.x, p.z, p.w + 0.05, p.d + 0.05, bandTop, bandTop + 0.04, p.rot, 1), deckel);
plate.castShadow = true; plate.receiveShadow = true;
g.add(plate);
const 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);
const loch = new Mesh(new CylinderGeometry(p.w * 0.19, p.w * 0.19, 0.06, 24), dunkel);
loch.position.set(p.x, bandTop + 0.035, p.z);
g.add(loch);
return g;
}
@@ -232,6 +278,7 @@ export function buildProps(props: Prop[], height: (x: number, z: number) => numb
case 'flammengrill': obj = flammengrill(p, y); break;
case 'bank': obj = bank(p, y); break;
case 'tor': obj = gartentor(p, y); break;
case 'stufe': obj = schwebendeStufe(p, y); break;
case 'roboter': obj = roboterGarage(p, y); break;
case 'wasserhahn': obj = wasserhahn(p, y); break;
case 'schacht': obj = schachtdeckel(p, y); break;
+43 -6
View File
@@ -223,7 +223,7 @@ function travertinFinish(ctx: CanvasRenderingContext2D, seed: number) {
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.fillStyle = `rgba(146,130,104,${0.05 + rnd() * 0.09})`;
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();
@@ -242,7 +242,7 @@ function travertinFinish(ctx: CanvasRenderingContext2D, seed: number) {
/** Travertinplatten 80 x 80 x 3 cm, Hauptsitzplatz. */
export const travertin80 = () => makeRelief('travertin80', 3.2, 0.70, 3.2,
(ctx) => {
slabs(ctx, 3.2, 0.8, 0.8, 'rgb(209,195,168)', 'rgb(158,145,124)', 0.010, 11, 7);
slabs(ctx, 3.2, 0.8, 0.8, 'rgb(224,214,192)', 'rgb(176,166,146)', 0.010, 11, 6);
travertinFinish(ctx, 21);
},
(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. */
export const travertinSlab = () => makeRelief('travertinSlab', 0.8, 0.70, 2.2,
(ctx) => {
ctx.fillStyle = 'rgb(213,199,171)';
ctx.fillStyle = 'rgb(228,218,196)';
ctx.fillRect(0, 0, SIZE, SIZE);
travertinFinish(ctx, 26);
},
@@ -259,7 +259,7 @@ export const travertinSlab = () => makeRelief('travertinSlab', 0.8, 0.70, 2.2,
/** Travertinstellplatten als schmaler Randstreifen. */
export const travertinstell = () => makeRelief('travertinstell', 2.4, 0.73, 3.0,
(ctx) => {
slabs(ctx, 2.4, 0.4, 0.4, 'rgb(206,192,166)', 'rgb(155,144,124)', 0.012, 13, 8);
slabs(ctx, 2.4, 0.4, 0.4, 'rgb(221,210,188)', 'rgb(172,162,142)', 0.012, 13, 7);
travertinFinish(ctx, 23);
},
(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,
(ctx) => {
ctx.fillStyle = 'rgb(203,183,148)';
ctx.fillStyle = 'rgb(221,208,182)';
ctx.fillRect(0, 0, SIZE, SIZE);
const rnd = mulberry32(61);
// 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++) {
const y = rnd() * SIZE;
const hgt = 3 + rnd() * 12;
ctx.fillStyle = `rgba(${(188 + rnd() * 20) | 0},${(168 + rnd() * 18) | 0},${(134 + rnd() * 16) | 0},0.35)`;
ctx.fillStyle = `rgba(${(206 + rnd() * 18) | 0},${(192 + rnd() * 16) | 0},${(166 + rnd() * 14) | 0},0.32)`;
ctx.beginPath();
ctx.moveTo(0, y);
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);
/** Liguster: mittelgruen, groesseres Blatt. */
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. */
function splitAtCorners(line: Vec2[], maxTurnDeg: number): Vec2[][] {
if (line.length <= 2) return [line];
@@ -273,16 +286,26 @@ function hedgeMesh(
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
// 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) {
const s = i === 0 ? s0 : s1;
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++) {
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);
const ua = capUv(profile[k]), ub = capUv(profile[k + 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;
@@ -339,29 +362,32 @@ export function buildVegetation(
const r = rng(4242);
// --- Eibenhecke `Hilli` 80/100 cm ---------------------------------------
// Der Plan setzt die Eiben in dichten Reihen: das ist eine geschnittene
// Hecke und der eigentliche Sichtschutz im Garten, keine Einzelkugeln.
// --- Eibenhecke `Hilli` als Sichtschutz ----------------------------------
// Der Sichtschutz ist bewusst **nicht** durchgehend: im Plan wechseln sich
// 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)
.map((p) => [p.x!, p.z!] as Vec2);
// Reihen begradigen und an scharfen Knicken trennen: eine gemiterte Hecke
// um eine 90-Grad-Ecke herum verdreht sich sonst.
const rows = chainRows(eiben, 2.2).flatMap((row) => splitAtCorners(simplifyLine(row, 0.22), 32));
const rows = chainRows(eiben, 0.95).flatMap((row) => splitAtCorners(simplifyLine(row, 0.22), 32));
const eibeLaub = tex.laubEibe();
const eibeMat = new MeshStandardMaterial({
map: eibeLaub.map, normalMap: eibeLaub.normalMap ?? null,
normalScale: eibeLaub.normalScale, roughness: eibeLaub.roughness, metalness: 0,
normalScale: eibeLaub.normalScale?.clone().multiplyScalar(0.6),
roughness: eibeLaub.roughness, metalness: 0,
side: DoubleSide,
});
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) {
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 {
// Einzelstand: als Solitaerpolster
// Einzelstand: als schmales, aufrechtes Polster
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);
m.position.set(x, height(x, z) + 0.47, z);
m.position.set(x, height(x, z) + h / 2, z);
m.castShadow = true;
m.receiveShadow = true;
group.add(m);
+21 -2
View File
@@ -19,6 +19,8 @@ export interface Surface {
polygon: Vec2[];
/** Ebene a*x + b*z + c, sofern es sich um einen gebauten Belag handelt. */
plane?: [number, number, number];
/** Zugehoerige Hauptebene, sofern fest vorgegeben. */
level?: string;
note?: string;
}
@@ -74,17 +76,34 @@ export interface Wall {
export interface Prop {
id: string;
type: 'gartenhaus' | 'spielhaus' | 'flammengrill' | 'bank' | 'roboter' | 'wasserhahn' | 'schacht' | 'tor';
type: 'gartenhaus' | 'spielhaus' | 'flammengrill' | 'bank' | 'roboter' | 'wasserhahn' | 'schacht' | 'tor' | 'stufe';
x: number;
z: number;
w: number;
d: number;
h: number;
rot: number;
/** Absolute Oberkante, sofern das Bauteil nicht auf dem Gelaende aufsitzt. */
top?: number;
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 {
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);
const NAMES: Record<string, string> = {
travertin80: 'Travertinplatten 80×80×3 cm',
travertin4060: 'Travertinplatten 40×60×3 cm',
dekorkies: 'Dekorkies',
travertinstell: 'Travertinstellplatten',
keramik: 'Keramikplatten 60×60×2 cm',
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 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 = `
<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>
</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>
<dl>${[...totals.entries()]
.sort((a, b) => b[1] - a[1])
@@ -124,10 +133,13 @@ export function setupHud(opts: HudOptions): Hud {
<h3>Bepflanzung und Ausstattung</h3>
<ul>
<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>${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.slabs.length} lose Travertinplatten 40 × 60 × 3 cm im Dekorkies</li>
<li>Gartenhaus 1,8 × 1,8 m, Spielhaus, Flammengrill mit Holzscheiterwand</li>
</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 },
];
/**
* 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.
* `path` = Mittellinie, `t` = Dicke, `top`/`base` = Hoehe der Krone bzw. des Fusses
@@ -79,13 +110,13 @@ export const TREES = [
export const WALLS = [
{
id: 'travertinmauer-nord', type: 'travertin',
note: 'Travertinmauer an der Grenze zum Nachbargrundstueck Zollet, stuetzt rund 1.5 m ab',
path: [[-18.17, -6.895], [-13.82, -6.895]], t: 0.55, top: -0.30, base: -1.90,
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.33, base: -1.95,
},
{
id: 'sitzmauer-nord', type: 'travertin',
note: 'Sitzmauer 25 cm ueber der Travertinmauer (gelbe Kote "25" im Plan)',
path: [[-20.08, -6.47], [-13.82, -6.47]], t: 0.30, top: -0.03, base: -0.45,
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.08, base: -0.55,
},
{
id: 'winkelelemente-west', type: 'beton',
@@ -94,14 +125,17 @@ export const WALLS = [
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',
note: 'Travertinsitzmauer als Abschluss des Hauptsitzplatzes gegen den Rasen',
path: [[-18.64, 0.755], [-14.73, 0.755]], t: 0.41, top: 0.40, base: -0.45, seating: true,
note: 'Travertinsitzmauer: 31 cm Hoehensprung vom Hauptsitzplatz zum Rasen',
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',
note: 'Schenkel der Travertinsitzmauer laengs des Rasens',
path: [[-18.44, 0.55], [-18.44, 3.94]], t: 0.40, top: 0.40, base: -0.40, seating: true,
note: 'Schenkel der Travertinsitzmauer laengs des Rasens, gleicher Hoehensprung',
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',
@@ -121,7 +155,7 @@ export const WALLS = [
{
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,
path: [[-21.05, 2.15], [-21.05, 3.75]], t: 0.80, top: -0.18, base: -0.80,
},
{
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,
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 },
// 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.
* Positionen aus den Planbeschriftungen "Travertinstelen".
* Hoehe des Sichtschutzes. Er besteht aus aufrechten 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 = [
{ 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 },
{ 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 },
{ 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 },
export const STELE_HEIGHTS = [
{ id: 'sued', height: 1.80, test: (x, z) => z > 3.0 },
{ id: 'nord', height: 1.50, test: (x, z) => z < -5.0 },
{ id: 'west', height: 1.50, test: (x) => x < -19.4 },
];
/**
+105 -35
View File
@@ -8,7 +8,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
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,
START_VIEWS, SITE_LOCATION,
} from './authoring.mjs';
@@ -100,24 +100,45 @@ const bboxOf = (pts) => {
*/
const slabs = [];
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) {
if (s.op !== 'f' || s.circle) continue;
let type = SURFACE_BY_COLOR[s.hex];
if (!type) continue;
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)
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),
});
slabs.push({ x: round(cx), z: round(cz), w: round(b.w, 2), d: round(b.d, 2) });
continue;
}
// 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;
}
}
// 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 (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 (NOT_A_SURFACE.has(type)) continue;
@@ -130,6 +151,15 @@ for (const s of EXTRA_SURFACES) {
}
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
// ---------------------------------------------------------------------------
@@ -212,43 +242,80 @@ function distToPolygon(x, z, poly) {
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 = [];
for (const s of surfaces) {
if (!FLAT_TYPES.has(s.type) || s.area < 0.6) continue;
const inside = terrainSpots.filter((sp) => pointInPolygon(sp.x, sp.z, s.polygon));
// Nur Koten derselben Gelaendezone heranziehen - ueber eine Stuetzmauer
// hinweg gehoert die Nachbarkote nicht zu dieser Flaeche.
const [pcx, pcz] = centroid(s.polygon);
const zone = zoneOf(pcx, pcz);
const near = terrainSpots.filter(
(sp) => !inside.includes(sp) && sp.zone === zone && distToPolygon(sp.x, sp.z, s.polygon) < 1.1,
);
const pool = [...inside, ...near];
if (!pool.length) continue;
if (!FLAT_TYPES.has(s.type) || s.area < 0.4) 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;
const fixed = levelFor(s);
let plane, ys;
let plane = pool.length >= 3 ? fitPlane(pool) : null;
// Ein Belag hat hoechstens rund 5 % Gefaelle - genug fuer die Entwaesserung. Bei fast kollinearen Koten
// kippt die Ausgleichsebene weg; dann nur das Gefaelle begrenzen und die
// Ebene wieder am Schwerpunkt der Koten aufhaengen - nie am Polygonrand,
// sonst wandert sie mit dem gekappten Gefaelle davon.
if (plane) {
const slope = Math.hypot(plane.a, plane.b);
const k = slope > 0.05 ? 0.05 / slope : 1;
const a = plane.a * k, b = plane.b * k;
plane = { a, b, c: mean - (a * px + b * pz) };
if (fixed) {
plane = { a: 0, b: 0, c: fixed.level };
ys = [fixed.level];
s.level = fixed.id;
} else {
plane = { a: 0, b: 0, c: mean };
const inside = terrainSpots.filter((sp) => owner.get(sp) === s);
const [pcx, pcz] = centroid(s.polygon);
const zone = zoneOf(pcx, pcz);
const near = inside.length
? []
: terrainSpots.filter((sp) => !owner.has(sp) && sp.zone === zone && distToPolygon(sp.x, sp.z, s.polygon) < 1.2);
const pool = [...inside, ...near];
if (!pool.length) continue;
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 fit = pool.length >= 3 ? fitPlane(pool) : null;
// Ein Belag hat hoechstens rund 4 % Gefaelle. Bei fast kollinearen Koten
// kippt die Ausgleichsebene weg; dann nur das Gefaelle begrenzen und die
// Ebene am Schwerpunkt der Koten aufhaengen, nie am Polygonrand.
if (fit) {
const slope = Math.hypot(fit.a, fit.b);
const k = slope > 0.04 ? 0.04 / slope : 1;
const a = fit.a * k, b = fit.b * k;
plane = { a, b, c: mean - (a * px + b * pz) };
} else {
plane = { a: 0, b: 0, c: mean };
}
}
s.plane = [round(plane.a, 5), round(plane.b, 5), round(plane.c, 4)];
flatSurfaces.push({
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,
walls,
props: PROPS,
stelen: STELEN,
stelen,
building: BUILDING,
neighbourBuildings: NEIGHBOUR_BUILDINGS,
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(`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}`);
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.)`);
+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();