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
+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();