Begehbare 3D-Visualisierung des Gartens Egglihaldenstrasse 9a

Geometrie massstaeblich aus dem Gartenplan-PDF (1:100) extrahiert: Belaege,
Mauern, Pflanzenstandorte und 46 Hoehenkoten. Wohnhaus nach Gartenplan und
den Fassadenplaenen von EHLE Architekten.

- tools/: Pipeline PDF -> Szenenmodell (MuPDF, Node)
- src/: three.js-Szene mit prozeduralen Texturen, Rundgang und Uebersicht
- Docker/Traefik fuer garten.aicds.ch, Gitea-Workflow auf main

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:59:37 +02:00
co-authored by Claude Opus 5
commit 70d239fe9c
43 changed files with 5614 additions and 0 deletions
+316
View File
@@ -0,0 +1,316 @@
/**
* Handgepflegte Ergaenzungen zum Plan.
*
* Der PDF-Plan liefert Flaechen, Pflanzensymbole und Hoehenkoten. Alles, was
* im Plan nur beschriftet ist (Mauerhoehen, Bauteile, Gebaeude), steht hier -
* abgeleitet aus den Planbeschriftungen, den Architekturplaenen von EHLE
* Architekten (Haus 9a, 27.04.2021) und den Fotos des Ist-Zustands.
*
* Alle Koordinaten im Site-Frame in Metern:
* X = Hausachse ENE (Peilung 61.16 deg)
* Z = senkrecht SSE (Peilung 151.16 deg)
* Y = Hoehe relativ zum Fixpunkt 0.00 = 651.17 m ue. M. (= OK FB EG Haus 9a)
*/
/** Farbe im Plan -> Belagsart. Reihenfolge egal, Zuordnung ueber Hex. */
export const SURFACE_BY_COLOR = {
'#ffffcc': 'travertin80', // Travertinplatten 80x80x3, Hauptsitzplatz West
'#fcffe7': 'travertin4060', // Travertinplatten 40x60x3 mit Dekorkies + Keramikterrasse Zollet
'#f8ffcc': 'travertinstell',// Travertinstellplatten-Streifen
'#eee8cc': 'travertinmauer',// Mauerkronen, Sitzmauern, schwebende Stufen
'#e5ffe5': 'rasen',
'#ccffcc': 'rasen',
'#ccff99': 'schotterwiese',
'#eeeeee': 'keramik', // Keramikplatten 60x60x2 (bauseits vorhanden)
'#bbbbbb': 'verbundstein', // Anpassung Verbundsteine Nachbar
'#ddba99': 'beet',
'#c0bab4': 'spielsand',
'#ffaa00': 'tegula', // Tegula-Pflastersteine grau (im Plan orange markiert)
'#ddd299': 'travertinstell',
'#73e900': 'ligusterhecke',
'#cccccc': 'winkelelement',
'#aaaaaa': 'spielhaus',
'#b29f3c': 'spielhaus',
'#80712a': 'gartenhaus',
'#335533': 'roboter',
};
/** Pflanzensymbole: Planfarbe -> Art. Radius kommt aus dem Plankreis. */
export const PLANT_BY_COLOR = {
'#00bb00': 'eibe', // Eiben `Hilli` 80/100 cm - Heckenpflanzen und ein Solitaer
'#66ff33': 'solitaer',
'#33ff99': 'solitaer',
'#cc5599': 'solitaer', // Parrotia persica, persischer Eisenholzbaum
'#99ff66': 'strauch', // Heidelbeeren / Beerenstraeucher
'#7fff00': 'gras',
'#99ff00': 'kraut',
'#cbe900': 'kraut',
'#74ff63': 'kraut',
'#e8ff64': 'kraut',
'#59b500': 'kraut',
'#0c8e2d': 'kraut',
'#00e93c': 'kraut',
'#33ff66': 'kraut',
'#339966': 'kraut',
'#00dbdd': 'kraut',
'#ccccff': 'kraut',
'#ff66cc': 'kraut',
'#7863ff': 'kraut',
};
/**
* Solitaergehoelze und Baeume. Die Kronenradien stammen aus den Plankreisen,
* die Stammhoehen sind Erfahrungswerte fuer die im Plan gezeigten Groessen.
*/
export const TREES = [
{ name: 'Solitärgehölz Sitzplatz Süd', x: -17.5, z: 1.6, crown: 1.34, height: 4.6, species: 'parrotia' },
{ name: 'Solitärgehölz Terrasse NW', x: -19.1, z: -5.5, crown: 0.67, height: 3.2, species: 'parrotia' },
{ name: 'Solitärgehölz Flammengrill', x: -4.0, z: 3.9, crown: 0.63, height: 3.0, species: 'parrotia' },
{ name: 'Apfelbaum', x: 1.69, z: 4.05, crown: 0.9, height: 3.4, species: 'apfel' },
// Nachbargarten Zollet, im Plan mitgezeichnet
{ name: 'Solitärgehölz Zollet', x: -17.6, z: -10.2, crown: 1.34, height: 4.6, species: 'parrotia', neighbour: true },
];
/**
* Mauern, Stuetzelemente und Waende.
* `path` = Mittellinie, `t` = Dicke, `top`/`base` = Hoehe der Krone bzw. des Fusses
* (null = folgt dem Gelaende). Alle Werte aus den Planbeschriftungen und Koten.
*/
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,
},
{
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,
},
{
id: 'winkelelemente-west', type: 'beton',
note: 'Winkelelemente aus Beton entlang der Westgrenze, stuetzen den Garten ueber der Feuerwehrzufahrt',
path: [[-20.12, -6.62], [-20.66, 0.51], [-21.70, 0.52], [-21.70, 2.37]],
t: 0.22, top: null, base: null, retains: 'west',
},
{
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,
},
{
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,
},
{
id: 'betonmauer-nachbar', type: 'beton',
note: 'Bestehende Betonmauer des Suednachbarn entlang der Grenze',
path: [[-17.41, 6.07], [1.99, 6.07]], t: 0.20, top: 1.10, base: -0.60,
},
{
id: 'betonmauer-nachbar-ost', type: 'beton',
note: 'Rueckspringender Schenkel der Nachbarmauer',
path: [[2.09, 6.07], [2.09, 9.05]], t: 0.20, top: 1.10, base: -0.60,
},
{
id: 'holzscheiterwand', type: 'holzscheiter',
note: 'Holzscheiterwand 150 x 200 cm hinter dem Flammengrill',
path: [[-5.75, 4.35], [-4.25, 4.35]], t: 0.45, top: 1.75, base: 0.26,
},
{
id: 'lamellenwand', type: 'lamellen',
note: 'Lamellenwand zum Sitzen: senkrechte Holzlamellen am gedeckten Sitzplatz',
path: [[-13.26, 0.91], [-8.73, 0.91]], t: 0.34, top: 2.72, base: -0.05, openSlats: true,
},
];
/** Freistehende Bauteile und Moeblierung. rot = Drehung um Y in Grad. */
export const PROPS = [
{ id: 'gartenhaus', type: 'gartenhaus', x: 1.20, z: -3.60, w: 1.8, d: 1.8, h: 2.15, rot: 0,
note: 'Standort Gartenhaus 1,8 x 1,8 m' },
{ id: 'spielhaus', type: 'spielhaus', x: 0.0, z: 3.15, w: 1.8, d: 2.7, h: 2.30, rot: 0,
note: 'Spielhaus durch Zimmermann, Podest auf 1.47' },
{ id: 'flammengrill', type: 'flammengrill', x: -6.95, z: 2.45, w: 0.7, d: 0.7, h: 0.85, rot: 0,
note: 'Flammengrill im Dekorkiesfeld' },
{ id: 'baenkli', type: 'bank', x: -20.62, z: 1.42, w: 0.50, d: 1.50, h: 0.45, rot: 90,
note: 'Baenkli aus Travertin' },
{ id: 'maehroboter', type: 'roboter', x: -17.87, z: 3.72, w: 0.75, d: 0.55, h: 0.40, rot: 0,
note: 'Garage Maehroboter' },
{ id: 'wasserhahn', type: 'wasserhahn', x: -13.90, z: -4.60, w: 0.12, d: 0.12, h: 0.85, 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' },
{ id: 'gartentor', type: 'tor', x: -21.60, z: 3.30, w: 1.00, d: 0.06, h: 1.10, rot: 0 },
];
/**
* Flaechen, die der Plan nur als weisses Raster zeigt und die deshalb bei der
* Farbklassierung durchfallen - Masse aus den Planlinien.
*/
export const EXTRA_SURFACES = [
{
type: 'keramik',
note: 'Gedeckter Sitzplatz 14.4 m2, Keramikplatten 60x60x2 cm',
polygon: [[-13.93, -3.06], [-8.73, -3.06], [-8.73, 0.86], [-13.93, 0.86]],
},
{
type: 'travertin80',
note: 'Schwebende Stufe Travertin, obere Tritthoehe -0.17',
polygon: [[-14.74, -2.69], [-13.93, -2.69], [-13.93, 0.56], [-14.74, 0.56]],
},
{
type: 'travertin80',
note: 'Schwebende Stufe Travertin, untere Tritthoehe -0.33',
polygon: [[-15.28, -3.08], [-14.48, -3.08], [-14.48, 0.12], [-15.28, 0.12]],
},
{
type: 'travertin80',
note: 'Schwebende Stufen beim Gartentor an der Suedwestecke',
polygon: [[-21.45, 2.15], [-20.65, 2.15], [-20.66, 3.75], [-21.45, 3.75]],
},
{
type: 'tegula',
note: 'Feuerwehrzufahrt und Zugangsweg, Tegula-Pflastersteine grau',
polygon: [[-25.60, -9.00], [-22.10, -9.00], [-22.10, 6.40], [-25.60, 6.40]],
},
];
/**
* Travertinstelen: aufrechte Steinstelen als Raumteiler entlang der Beete.
* Positionen aus den Planbeschriftungen "Travertinstelen".
*/
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 },
];
/**
* Gebaeude. Das Wohnhaus 9a ist massgetreu aus dem Gartenplan uebernommen,
* die Hoehen stammen aus der Suedwest- und Nordostfassade 1:100:
* OK FB EG 651.17 -> 0.00 OK FB OG 654.07 -> +2.90
* OK Attika 657.32 -> +6.15 Nachbar Nr. 9 OK FB EG 649.47 -> -1.70
*/
export const BUILDING = {
id: 'haus-9a',
name: 'Haus 9a - Jeanine und Damian Kellenberger',
floorLevel: 0.0,
upperLevel: 2.9,
roofLevel: 6.15,
parapet: 0.35,
/**
* Aussenkante Fassade. Der Baukoerper ist im Grundriss ein Rechteck von
* 14.10 x 7.83 m; die Masse stammen aus den schwarzen Wandflaechen des
* Gartenplans (Nordostfassade z = -6.87, Suedwestfassade z = +0.96).
*/
outline: [[-13.82, -6.87], [0.28, -6.87], [0.28, 0.96], [-13.82, 0.96]],
/**
* Gedeckter Sitzplatz 14.4 m2: im Erdgeschoss zurueckspringende Nische in
* der Suedwestecke, oben vom Obergeschoss ueberdeckt.
*/
loggia: { x0: -13.82, z0: -3.06, x1: -9.06, z1: 0.96, ceiling: 2.75 },
/** Vordach ueber dem Hauseingang an der Nordostfassade (siehe Foto). */
canopy: { a: -3.20, b: 0.28, depth: 1.35, y: 2.60, thickness: 0.16 },
openings: [
// Suedwestfassade (z = 0.96 / 0.51) - grosse Fensterfronten zum Garten
{ face: 'south', a: -4.40, b: -0.20, sill: 0.0, head: 2.45, kind: 'glass' },
{ face: 'south', a: -12.90, b: -9.40, sill: 0.0, head: 2.45, kind: 'glass' },
{ face: 'south', a: -3.90, b: -0.60, sill: 3.35, head: 5.00, kind: 'glass', upper: true },
{ face: 'south', a: -12.60, b: -9.70, sill: 3.35, head: 5.00, kind: 'glass', upper: true },
// Westfassade (x = -13.82) - Fenster
{ face: 'west', a: -5.84, b: -4.24, sill: 0.85, head: 2.35, kind: 'window' },
{ face: 'west', a: -5.60, b: -4.50, sill: 3.75, head: 5.00, kind: 'window', upper: true },
// Nordostfassade (z = -6.87) - Eingang und Lamellenfenster
{ face: 'north', a: -1.90, b: -0.90, sill: 0.0, head: 2.35, kind: 'door' },
{ face: 'north', a: -5.60, b: -3.90, sill: 0.90, head: 2.35, kind: 'window' },
{ face: 'north', a: -11.60, b: -10.20, sill: 0.90, head: 2.35, kind: 'slats' },
{ face: 'north', a: -5.40, b: -4.10, sill: 3.75, head: 5.10, kind: 'window', upper: true },
{ face: 'north', a: -11.40, b: -10.40, sill: 3.75, head: 5.10, kind: 'slats', upper: true },
// Ostfassade (x = 0.28)
{ face: 'east', a: -5.60, b: -4.30, sill: 0.90, head: 2.35, kind: 'window' },
],
};
/**
* Nachbargebaeude als Kontext-Volumen. Haus 9 (Zollet) haengt am gleichen
* Baukoerper, liegt aber 1.70 m tiefer und ist gegen Nordwesten versetzt.
*/
export const NEIGHBOUR_BUILDINGS = [
{
id: 'haus-9', name: 'Haus 9 - Sharon und Alan Zollet',
outline: [[-15.60, -13.60], [-1.20, -13.60], [-1.20, -6.87], [-15.60, -6.87]],
floorLevel: -1.70, roofLevel: 4.45,
},
];
/**
* Gelaendezonen. Innerhalb einer Zone wird zwischen den Hoehenkoten
* interpoliert; ueber Zonengrenzen hinweg nicht - dort stehen die Stuetzmauern.
*/
export const TERRAIN_ZONES = [
{
id: 'garten', label: 'Garten Kellenberger',
polygon: [[-20.90, -6.60], [3.00, -6.60], [3.00, 5.95], [-20.90, 5.95]],
fallback: -0.05,
},
{
id: 'zufahrt', label: 'Feuerwehrzufahrt / Schotterwiese West',
polygon: [[-26.00, -9.00], [-20.90, -9.00], [-20.90, 6.50], [-26.00, 6.50]],
fallback: -1.20,
},
{
id: 'zollet', label: 'Nachbargarten Zollet',
polygon: [[-24.00, -14.50], [3.00, -14.50], [3.00, -6.60], [-24.00, -6.60]],
fallback: -1.70,
},
{
id: 'sued', label: 'Nachbargrundstück Süd',
polygon: [[-22.00, 5.95], [6.00, 5.95], [6.00, 12.00], [-22.00, 12.00]],
fallback: -0.20,
},
{
id: 'ost', label: 'Wiese Ost',
polygon: [[3.00, -6.60], [9.00, -6.60], [9.00, 5.95], [3.00, 5.95]],
fallback: 0.55,
},
];
/**
* Zusaetzliche Hoehenpunkte, die im Plan nicht als Kote stehen, fuer ein
* plausibles Gelaende aber noetig sind (Auslauf gegen die Grundstuecksraender).
*/
export const EXTRA_SPOTS = [
{ x: -24.5, z: -8.0, y: -2.60, zone: 'zufahrt' },
{ x: -24.5, z: 0.0, y: -1.60, zone: 'zufahrt' },
{ x: -24.5, z: 5.0, y: -0.60, zone: 'zufahrt' },
{ x: -22.6, z: 5.5, y: -0.30, zone: 'zufahrt' },
{ x: -20.0, z: -12.5, y: -1.75, zone: 'zollet' },
{ x: -8.0, z: -12.5, y: -1.70, zone: 'zollet' },
{ x: 2.0, z: -11.5, y: -1.65, zone: 'zollet' },
{ x: -18.0, z: 9.0, y: -0.40, zone: 'sued' },
{ x: -6.0, z: 9.0, y: 0.10, zone: 'sued' },
{ x: 4.0, z: 9.0, y: 0.60, zone: 'sued' },
{ x: 7.5, z: -5.0, y: 0.10, zone: 'ost' },
{ x: 7.5, z: 0.0, y: 0.45, zone: 'ost' },
{ x: 7.5, z: 5.0, y: 0.80, zone: 'ost' },
];
/** Startposition und Blickrichtung beim Betreten der Szene. yaw = Grad ab Nord. */
export const START_VIEWS = [
{ id: 'sitzplatz', label: 'Hauptsitzplatz', x: -18.6, z: -2.6, yaw: 135, note: 'Travertinplatten 80x80x3 cm, Blick über den Sitzplatz gegen den Rasen' },
{ id: 'gedeckt', label: 'Gedeckter Sitzplatz', x: -11.0, z: -1.2, yaw: 215, note: 'Keramikplatten unter dem Obergeschoss, hinter der Lamellenwand' },
{ id: 'rasen', label: 'Rasen Süd', x: -13.0, z: 2.8, yaw: 75, note: 'Blick der Südfassade entlang zum Spielbereich' },
{ id: 'grill', label: 'Flammengrill', x: -7.8, z: 2.6, yaw: 110, note: 'Dekorkiesfeld mit Flammengrill und Holzscheiterwand' },
{ id: 'spielplatz', label: 'Spielbereich', x: -3.4, z: 1.4, yaw: 150, note: 'Spielsand, Spielhaus und Apfelbaum' },
{ id: 'eingang', label: 'Hauseingang', x: 1.3, z: -1.8, yaw: 250, note: 'Keramikplatten beim Fixpunkt 0.00' },
];
/** Standort fuer den Sonnenstand: Abtwil SG. */
export const SITE_LOCATION = {
name: 'Egglihaldenstrasse 9a, 9030 Abtwil SG',
lat: 47.4206,
lon: 9.2903,
/** Peilung der Site-X-Achse gegenueber Nord, in Grad. */
northBearingOfSiteX: 61.1615,
groundMasl: 651.17,
};
+351
View File
@@ -0,0 +1,351 @@
/**
* Baut aus der Planextraktion (tools/out/plan-raw.json) und den handgepflegten
* Ergaenzungen (tools/authoring.mjs) das Szenenmodell public/data/site.json.
*
* Aufruf: node tools/build-site.mjs
*/
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,
BUILDING, NEIGHBOUR_BUILDINGS, TERRAIN_ZONES, EXTRA_SPOTS, EXTRA_SURFACES,
START_VIEWS, SITE_LOCATION,
} from './authoring.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'out', 'plan-raw.json'), 'utf8'));
/** Rasterweite des Hoehenmodells in Metern. */
const CELL = 0.25;
const GRID = { x0: -26, z0: -14.5, x1: 9, z1: 12 };
const round = (v, n = 3) => +v.toFixed(n);
// ---------------------------------------------------------------------------
// Geometrie-Helfer
// ---------------------------------------------------------------------------
function pointInPolygon(x, z, poly) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, zi] = poly[i], [xj, zj] = poly[j];
if ((zi > z) !== (zj > z) && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside;
}
return inside;
}
function polygonArea(poly) {
let a = 0;
for (let i = 0; i < poly.length; i++) {
const [x0, z0] = poly[i], [x1, z1] = poly[(i + 1) % poly.length];
a += x0 * z1 - x1 * z0;
}
return a / 2;
}
function centroid(poly) {
let x = 0, z = 0;
for (const p of poly) { x += p[0]; z += p[1]; }
return [x / poly.length, z / poly.length];
}
/** Ebene y = a*x + b*z + c durch Punkte, kleinste Quadrate. Null bei Entartung. */
function fitPlane(points) {
if (points.length < 3) return null;
let sxx = 0, sxz = 0, szz = 0, sx = 0, sz = 0, n = points.length;
let sxy = 0, szy = 0, sy = 0;
for (const p of points) {
sxx += p.x * p.x; sxz += p.x * p.z; szz += p.z * p.z;
sx += p.x; sz += p.z;
sxy += p.x * p.y; szy += p.z * p.y; sy += p.y;
}
const m = [[sxx, sxz, sx], [sxz, szz, sz], [sx, sz, n]];
const v = [sxy, szy, sy];
const det =
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) -
m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) +
m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
if (Math.abs(det) < 1e-9) return null;
const solve = (col) => {
const mm = m.map((r) => r.slice());
for (let i = 0; i < 3; i++) mm[i][col] = v[i];
return (
mm[0][0] * (mm[1][1] * mm[2][2] - mm[1][2] * mm[2][1]) -
mm[0][1] * (mm[1][0] * mm[2][2] - mm[1][2] * mm[2][0]) +
mm[0][2] * (mm[1][0] * mm[2][1] - mm[1][1] * mm[2][0])
) / det;
};
return { a: solve(0), b: solve(1), c: solve(2) };
}
// ---------------------------------------------------------------------------
// 1. Belagsflaechen
// ---------------------------------------------------------------------------
/** Bauteile, die als Mauer modelliert werden - nicht als begehbare Flaeche. */
const NOT_A_SURFACE = new Set(['travertinmauer', 'winkelelement', 'spielhaus', 'gartenhaus', 'roboter', 'ligusterhecke']);
const surfaces = [];
for (const s of raw.shapes) {
if (s.op !== 'f' || s.circle) continue;
const type = SURFACE_BY_COLOR[s.hex];
if (!type) continue;
if (s.area < 0.35) continue;
if (NOT_A_SURFACE.has(type)) continue;
const poly = polygonArea(s.pts) < 0 ? [...s.pts].reverse() : s.pts;
surfaces.push({ type, area: round(s.area, 2), polygon: poly.map((p) => [round(p[0]), round(p[1])]) });
}
for (const s of EXTRA_SURFACES) {
const poly = polygonArea(s.polygon) < 0 ? [...s.polygon].reverse() : s.polygon;
surfaces.push({ type: s.type, area: round(Math.abs(polygonArea(s.polygon)), 2), polygon: poly, note: s.note });
}
surfaces.sort((a, b) => b.area - a.area);
// ---------------------------------------------------------------------------
// 2. Hecken und Pflanzen aus den Plankreisen
// ---------------------------------------------------------------------------
const treeAt = (x, z, tol = 0.6) => TREES.find((t) => Math.hypot(t.x - x, t.z - z) < tol);
const plants = [];
for (const s of raw.shapes) {
if (s.op !== 'f' || !s.circle) continue;
const type = PLANT_BY_COLOR[s.hex];
if (!type) continue;
const { cx, cz, r } = s.circle;
if (r < 0.06) continue; // Lampensymbole und Punktraster
if (treeAt(cx, cz)) continue; // Solitaere kommen aus dem Authoring
plants.push({ type, x: round(cx), z: round(cz), r: round(r, 2) });
}
// Der Ligusterhecken-Streifen ist im Plan eine Flaeche, keine Kreise.
for (const s of raw.shapes) {
if (s.op === 'f' && s.hex === '#73e900' && s.area > 1) {
plants.push({ type: 'hecke', kind: 'liguster', height: 1.7, width: 0.75, polygon: s.pts.map((p) => [round(p[0]), round(p[1])]) });
}
}
const trees = TREES.map((t) => ({ ...t, x: round(t.x), z: round(t.z) }));
// ---------------------------------------------------------------------------
// 3. Leuchten
// ---------------------------------------------------------------------------
const lamps = [];
const addLamp = (x, z, kind) => {
if (lamps.some((l) => Math.hypot(l.x - x, l.z - z) < 0.35)) return;
lamps.push({ x: round(x), z: round(z), kind });
};
for (const s of raw.shapes) {
if (s.op !== 'f') continue;
const [cx, cz] = centroid(s.pts);
// Rote Quadrate = Wand-/Pollerleuchten an Grenze und Mauern
if (s.hex === '#ff0000' && s.area > 0.02 && s.area < 0.08) addLamp(cx, cz, 'poller');
// Kleine gelbe Marker im Kreissymbol = Bodenstrahler
else if (s.hex === '#ffff00' && s.area < 0.05) addLamp(cx, cz, 'boden');
}
// ---------------------------------------------------------------------------
// 4. Hoehenmodell
// ---------------------------------------------------------------------------
const zoneOf = (x, z) => {
for (const zone of TERRAIN_ZONES) if (pointInPolygon(x, z, zone.polygon)) return zone.id;
return null;
};
const spots = [
...raw.spots.map((s) => ({ x: s.x, z: s.z, y: s.y, zone: zoneOf(s.x, s.z) })),
...EXTRA_SPOTS,
];
// Die Kote 1.47 gehoert zum Spielhauspodest, nicht zum Gelaende.
const terrainSpots = spots.filter((s) => s.zone && s.y < 1.2);
/** Flaechen, die als Ebene ausgebildet sind - dort wird das Raster begradigt. */
const FLAT_TYPES = new Set(['travertin80', 'travertin4060', 'travertinstell', 'keramik', 'verbundstein', 'tegula', 'spielsand']);
const flatSurfaces = [];
for (const s of surfaces) {
if (!FLAT_TYPES.has(s.type) || s.area < 1.0) continue;
const inside = terrainSpots.filter((sp) => pointInPolygon(sp.x, sp.z, s.polygon));
if (!inside.length) continue;
let plane = inside.length >= 3 ? fitPlane(inside) : null;
// Fast kollineare Koten liefern wilde Ebenen. Nur uebernehmen, wenn das
// Gefaelle plausibel ist (< 10 %) und alle Koten getroffen werden.
if (plane) {
const slope = Math.hypot(plane.a, plane.b);
const maxDev = Math.max(...inside.map((p) => Math.abs(plane.a * p.x + plane.b * p.z + plane.c - p.y)));
if (slope > 0.10 || maxDev > 0.06) plane = null;
}
const ys = inside.map((p) => p.y);
const mean = ys.reduce((a, b) => a + b, 0) / ys.length;
flatSurfaces.push({
polygon: s.polygon, plane, mean, type: s.type,
min: Math.min(...ys) - 0.15, max: Math.max(...ys) + 0.15,
});
}
const nx = Math.round((GRID.x1 - GRID.x0) / CELL) + 1;
const nz = Math.round((GRID.z1 - GRID.z0) / CELL) + 1;
const height = new Float32Array(nx * nz);
/**
* Inverse Distanzwichtung innerhalb einer Zone. Der weiche Kern (1/(d^2+c))
* vermeidet Spitzen um die einzelnen Koten - ein 1/d^3-Kern erzeugt Beulen,
* die spaeter durch die Belagsflaechen durchstossen.
*/
function interpolate(x, z, zoneId) {
const pool = terrainSpots.filter((s) => s.zone === zoneId);
if (!pool.length) return TERRAIN_ZONES.find((t) => t.id === zoneId)?.fallback ?? 0;
let num = 0, den = 0;
for (const s of pool) {
const d2 = (s.x - x) ** 2 + (s.z - z) ** 2;
const w = 1 / (d2 * d2 + 0.35);
num += w * s.y; den += w;
}
return num / den;
}
for (let j = 0; j < nz; j++) {
const z = GRID.z0 + j * CELL;
for (let i = 0; i < nx; i++) {
const x = GRID.x0 + i * CELL;
const zone = zoneOf(x, z);
let y;
if (!zone) {
// Ausserhalb aller Zonen: naechstgelegene Zone weiterziehen
let best = null, bd = Infinity;
for (const t of TERRAIN_ZONES) {
const [cx, cz] = centroid(t.polygon);
const d = Math.hypot(cx - x, cz - z);
if (d < bd) { bd = d; best = t; }
}
y = best.fallback;
} else {
y = interpolate(x, z, zone);
for (const f of flatSurfaces) {
if (!pointInPolygon(x, z, f.polygon)) continue;
y = f.plane ? f.plane.a * x + f.plane.b * z + f.plane.c : f.mean;
y = Math.max(f.min, Math.min(f.max, y));
break;
}
}
height[j * nx + i] = y;
}
}
// Zwei Glaettungsrunden, damit weder die Kanten der Ebenen schneiden noch
// einzelne Koten als Beulen stehenbleiben.
let smoothed = Float32Array.from(height);
for (let pass = 0; pass < 2; pass++) {
const src = smoothed;
const dst = Float32Array.from(src);
for (let j = 1; j < nz - 1; j++) {
for (let i = 1; i < nx - 1; i++) {
const k = j * nx + i;
dst[k] = (src[k] * 4 + src[k - 1] + src[k + 1] + src[k - nx] + src[k + nx]) / 8;
}
}
smoothed = dst;
}
// ---------------------------------------------------------------------------
// 5. Mauern: fehlende Hoehen aus dem Gelaende ergaenzen
// ---------------------------------------------------------------------------
function sampleHeight(x, z) {
const fi = (x - GRID.x0) / CELL, fj = (z - GRID.z0) / CELL;
const i = Math.max(0, Math.min(nx - 2, Math.floor(fi)));
const j = Math.max(0, Math.min(nz - 2, Math.floor(fj)));
const tx = Math.max(0, Math.min(1, fi - i)), tz = Math.max(0, Math.min(1, fj - j));
const h = (a, b) => smoothed[b * nx + a];
return (
h(i, j) * (1 - tx) * (1 - tz) + h(i + 1, j) * tx * (1 - tz) +
h(i, j + 1) * (1 - tx) * tz + h(i + 1, j + 1) * tx * tz
);
}
/** Punkt und Normale auf einem Polyzug bei relativer Bogenlaenge t. */
function alongPath(path, t) {
const segs = path.slice(1).map((p, i) => ({
a: path[i], b: p, len: Math.hypot(p[0] - path[i][0], p[1] - path[i][1]),
}));
const total = segs.reduce((s, g) => s + g.len, 0);
let want = t * total;
for (const s of segs) {
if (want <= s.len || s === segs[segs.length - 1]) {
const u = s.len > 0 ? Math.min(1, want / s.len) : 0;
const dx = (s.b[0] - s.a[0]) / (s.len || 1);
const dz = (s.b[1] - s.a[1]) / (s.len || 1);
return { x: s.a[0] + (s.b[0] - s.a[0]) * u, z: s.a[1] + (s.b[1] - s.a[1]) * u, nx: -dz, nz: dx };
}
want -= s.len;
}
return { x: path[0][0], z: path[0][1], nx: 0, nz: 1 };
}
const walls = WALLS.map((w) => {
const out = { ...w };
if (w.retains) {
// Stuetzelement: Krone folgt der hoeheren Seite, Fuss der tieferen.
// Ein Meter beidseits reicht, um die beiden Gelaendeniveaus abzugreifen.
const n = 24;
const crest = [], foot = [];
for (let i = 0; i <= n; i++) {
const { x, z, nx, nz } = alongPath(w.path, i / n);
const hA = sampleHeight(x + nx * 1.0, z + nz * 1.0);
const hB = sampleHeight(x - nx * 1.0, z - nz * 1.0);
crest.push(round(Math.max(hA, hB) + 0.06, 3));
foot.push(round(Math.min(hA, hB) - 0.25, 3));
}
out.crestProfile = crest;
out.footProfile = foot;
}
return out;
});
// ---------------------------------------------------------------------------
// 6. Ausgabe
// ---------------------------------------------------------------------------
const site = {
meta: {
...raw.meta,
generated: new Date().toISOString().slice(0, 10),
location: SITE_LOCATION,
plan: 'Gartenplan 1, Garten Design AG, 14.08.2026, Massstab 1:100',
architecture: 'Haus 9a, EHLE Architekten AG, Projektplaene 27.04.2021',
},
terrain: {
grid: { x0: GRID.x0, z0: GRID.z0, cell: CELL, nx, nz },
heights: Array.from(smoothed, (v) => round(v, 3)),
zones: TERRAIN_ZONES.map((z) => ({ id: z.id, label: z.label, polygon: z.polygon })),
spots: terrainSpots.map((s) => ({ x: round(s.x), z: round(s.z), y: s.y, zone: s.zone })),
},
surfaces,
plants,
trees,
lamps,
walls,
props: PROPS,
stelen: STELEN,
building: BUILDING,
neighbourBuildings: NEIGHBOUR_BUILDINGS,
startViews: START_VIEWS,
};
fs.mkdirSync(path.join(ROOT, 'public', 'data'), { recursive: true });
const file = path.join(ROOT, 'public', 'data', 'site.json');
fs.writeFileSync(file, JSON.stringify(site));
const byType = {};
for (const s of surfaces) byType[s.type] = (byType[s.type] ?? 0) + s.area;
console.log(`site.json ${(fs.statSync(file).size / 1024).toFixed(0)} kB`);
console.log(`Belagsflaechen ${surfaces.length}:`);
for (const [t, a] of Object.entries(byType).sort((x, y) => y[1] - x[1])) {
console.log(' ' + t.padEnd(16) + a.toFixed(1).padStart(7) + ' m2');
}
console.log(`Pflanzen ${plants.length}, Baeume ${trees.length}, Leuchten ${lamps.length}, Mauern ${walls.length}, Objekte ${PROPS.length}, Stelen ${STELEN.length}`);
console.log(`Hoehenraster ${nx} x ${nz} (${CELL} m), Koten ${terrainSpots.length}`);
const hs = Array.from(smoothed);
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.)`);
+59
View File
@@ -0,0 +1,59 @@
/**
* Zeichnet die extrahierte Geometrie als SVG-Draufsicht - Kontrolle, ob die
* Transformation aus dem PDF stimmt.
*
* Aufruf: node tools/debug-svg.mjs [nur-farbe-hex]
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.join(__dirname, 'out');
const data = JSON.parse(fs.readFileSync(path.join(OUT, 'plan-raw.json'), 'utf8'));
const only = process.argv[2]?.toLowerCase();
const PAD = 1.5;
const PXM = 34; // Pixel pro Meter
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
for (const s of data.shapes) for (const [x, z] of s.pts) {
x0 = Math.min(x0, x); x1 = Math.max(x1, x); z0 = Math.min(z0, z); z1 = Math.max(z1, z);
}
x0 -= PAD; z0 -= PAD; x1 += PAD; z1 += PAD;
const W = Math.round((x1 - x0) * PXM), H = Math.round((z1 - z0) * PXM);
const X = (x) => ((x - x0) * PXM).toFixed(1);
const Z = (z) => ((z - z0) * PXM).toFixed(1);
const out = [];
out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`);
out.push(`<rect width="${W}" height="${H}" fill="#ffffff"/>`);
const fills = data.shapes.filter((s) => s.op === 'f' && s.area > 0.05);
fills.sort((a, b) => b.area - a.area);
for (const s of fills) {
if (only && s.hex.toLowerCase() !== only) continue;
const d = s.pts.map((p, i) => `${i ? 'L' : 'M'}${X(p[0])},${Z(p[1])}`).join('') + 'Z';
out.push(`<path d="${d}" fill="${s.hex}" fill-opacity="0.85" stroke="#0006" stroke-width="0.4"/>`);
}
if (!only) {
for (const s of data.shapes) {
if (s.op !== 's' || s.pts.length < 2) continue;
const d = s.pts.map((p, i) => `${i ? 'L' : 'M'}${X(p[0])},${Z(p[1])}`).join('');
out.push(`<path d="${d}" fill="none" stroke="#333" stroke-width="0.35" stroke-opacity="0.55"/>`);
}
for (const sp of data.spots) {
out.push(`<circle cx="${X(sp.x)}" cy="${Z(sp.z)}" r="2.4" fill="#d00"/>`);
out.push(`<text x="${X(sp.x) - 0}" y="${+Z(sp.z) - 4}" font-size="9" fill="#a00" text-anchor="middle">${sp.y.toFixed(2)}</text>`);
}
}
// Massstab und Achsen
out.push(`<g stroke="#06c" stroke-width="2" fill="none">
<path d="M${X(0)},${Z(0)} L${X(5)},${Z(0)}"/><path d="M${X(0)},${Z(0)} L${X(0)},${Z(5)}"/></g>`);
out.push(`<text x="${X(5.2)}" y="${Z(0)}" font-size="13" fill="#06c">+X (ENE)</text>`);
out.push(`<text x="${X(0.2)}" y="${Z(5.4)}" font-size="13" fill="#06c">+Z (SSE)</text>`);
out.push(`<text x="12" y="20" font-size="14" fill="#333">Gartenplan &#8594; Site-Frame &#183; ${(x1 - x0).toFixed(1)} &#215; ${(z1 - z0).toFixed(1)} m &#183; 1 Raster = 1 m</text>`);
out.push('</svg>');
const file = path.join(OUT, only ? `debug-${only.replace('#', '')}.svg` : 'debug-plan.svg');
fs.writeFileSync(file, out.join('\n'));
console.log('geschrieben:', file, `${W}x${H}px`);
+264
View File
@@ -0,0 +1,264 @@
/**
* Extrahiert die Vektorgeometrie aus dem Gartenplan-PDF (Garten Design AG, 1:100)
* und transformiert sie in ein georeferenziertes, hausachsen-paralleles
* Koordinatensystem in Metern.
*
* Ausgabe: tools/out/plan-raw.json
*
* Koordinatensystem (Site-Frame, entspricht three.js X/Z):
* X = Hausachse ENE, Peilung 61.16 deg
* Z = senkrecht dazu SSE, Peilung 151.16 deg
* Y = Hoehe (hier nicht enthalten, kommt aus den Hoehenkoten)
* Ursprung = Fixpunkt "0.00" beim Hauseingang, absolut 651.17 m ue. M.
*
* Aufruf: node tools/extract-plan.mjs
*/
import * as mupdf from 'mupdf';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const PDF = path.join(ROOT, 'Info Dateien', 'Gartenplan 1 Jasmin und Damian Kellenberger.pdf');
const OUT_DIR = path.join(__dirname, 'out');
/** Massstab 1:100 -> 1 PDF-Punkt entspricht 0.0352778 m in der Realitaet. */
const PT_TO_M = (25.4 / 72 / 1000) * 100;
/** Zeichnungsraster gegenueber Blattkante, aus der Winkelanalyse der Linien. */
const GRID_ANGLE_DEG = -28.8385;
/** Nur dieser Blattausschnitt enthaelt den Situationsplan (PDF-Punkte). */
const PLAN_CLIP = { x0: 600, y0: 150, x1: 1800, y1: 1060 };
const cos = Math.cos((GRID_ANGLE_DEG * Math.PI) / 180);
const sin = Math.sin((GRID_ANGLE_DEG * Math.PI) / 180);
/**
* PDF-Punkt -> Site-Meter, noch ohne Ursprungsversatz.
* Auf dem Blatt ist Norden oben, also entspricht (x, y) direkt (Ost, Sued).
* Projektion auf die beiden Rasterachsen des Plans:
* d1 = (cos, sin) bei GRID_ANGLE_DEG -> Site-X (Hausachse ENE)
* d2 = (-sin, cos) -> Site-Z (SSE)
*/
function toSite(x, y) {
return [(x * cos + y * sin) * PT_TO_M, (-x * sin + y * cos) * PT_TO_M];
}
function bboxOf(segs) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const s of segs) {
for (let i = 1; i + 1 < s.length; i += 2) {
x0 = Math.min(x0, s[i]); x1 = Math.max(x1, s[i]);
y0 = Math.min(y0, s[i + 1]); y1 = Math.max(y1, s[i + 1]);
}
}
return [x0, y0, x1, y1];
}
/** Flaeche eines geschlossenen Polygonzugs (Shoelace), in m^2. */
function areaOf(points) {
let a = 0;
for (let i = 0; i < points.length; i++) {
const [x0, z0] = points[i];
const [x1, z1] = points[(i + 1) % points.length];
a += x0 * z1 - x1 * z0;
}
return Math.abs(a) / 2;
}
/**
* Erkennt, ob ein Pfad ein Kreis ist (Pflanzensymbole sind Bezier-Kreise).
* Liefert {cx, cz, r} oder null.
*/
function asCircle(points) {
if (points.length < 8) return null;
let cx = 0, cz = 0;
for (const [x, z] of points) { cx += x; cz += z; }
cx /= points.length; cz /= points.length;
let rMin = Infinity, rMax = 0, rSum = 0;
for (const [x, z] of points) {
const r = Math.hypot(x - cx, z - cz);
rMin = Math.min(rMin, r); rMax = Math.max(rMax, r); rSum += r;
}
const r = rSum / points.length;
if (r < 0.05) return null;
// Toleranz: echte Kreise streuen unter 12 % um den Mittelwert.
if ((rMax - rMin) / r > 0.12) return null;
return { cx, cz, r };
}
function flattenSegments(segs) {
// Beziers werden linear unterteilt; 8 Stuetzstellen reichen bei Plangroessen.
const rings = [];
let cur = null, sx = 0, sy = 0, px = 0, py = 0;
const push = (x, y) => { cur.push([x, y]); px = x; py = y; };
for (const s of segs) {
switch (s[0]) {
case 'M':
if (cur && cur.length > 1) rings.push(cur);
cur = []; sx = s[1]; sy = s[2]; push(s[1], s[2]);
break;
case 'L':
if (!cur) { cur = []; sx = s[1]; sy = s[2]; }
push(s[1], s[2]);
break;
case 'C': {
if (!cur) { cur = []; sx = px; sy = py; cur.push([px, py]); }
const [x0, y0] = [px, py];
for (let i = 1; i <= 8; i++) {
const t = i / 8, u = 1 - t;
const x = u * u * u * x0 + 3 * u * u * t * s[1] + 3 * u * t * t * s[3] + t * t * t * s[5];
const y = u * u * u * y0 + 3 * u * u * t * s[2] + 3 * u * t * t * s[4] + t * t * t * s[6];
cur.push([x, y]);
}
px = s[5]; py = s[6];
break;
}
case 'Z':
if (cur && cur.length > 1) { rings.push(cur); }
cur = [[sx, sy]]; px = sx; py = sy;
break;
}
}
if (cur && cur.length > 1) rings.push(cur);
return rings;
}
/** Entfernt doppelte und kollineare Stuetzpunkte. */
function simplify(points, eps = 0.004) {
const out = [];
for (const p of points) {
const last = out[out.length - 1];
if (last && Math.hypot(p[0] - last[0], p[1] - last[1]) < eps) continue;
out.push(p);
}
while (out.length > 1 && Math.hypot(out[0][0] - out[out.length - 1][0], out[0][1] - out[out.length - 1][1]) < eps) {
out.pop();
}
const res = [];
for (let i = 0; i < out.length; i++) {
const a = out[(i - 1 + out.length) % out.length], b = out[i], c = out[(i + 1) % out.length];
const cross = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
if (Math.abs(cross) < 1e-5 && out.length > 3) continue;
res.push(b);
}
return res.length >= 3 ? res : out;
}
function main() {
const doc = mupdf.Document.openDocument(fs.readFileSync(PDF), 'application/pdf');
const page = doc.loadPage(0);
// --- 1. Vektor-Operationen mitschneiden -------------------------------
const raw = [];
const apply = (m, x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
const walk = (p, m) => {
const segs = [];
p.walk({
moveTo: (x, y) => segs.push(['M', ...apply(m, x, y)]),
lineTo: (x, y) => segs.push(['L', ...apply(m, x, y)]),
curveTo: (a, b, c, d, e, f) => segs.push(['C', ...apply(m, a, b), ...apply(m, c, d), ...apply(m, e, f)]),
closePath: () => segs.push(['Z']),
});
return segs;
};
const noop = () => {};
const device = new mupdf.Device({
fillPath: (p, eo, m, cs, color) => raw.push({ op: 'f', segs: walk(p, m), c: Array.from(color || []) }),
strokePath: (p, st, m, cs, color) => raw.push({ op: 's', segs: walk(p, m), c: Array.from(color || []), lw: st?.lineWidth ?? 0 }),
fillShade: noop, fillImage: 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(device, mupdf.Matrix.identity);
device.close();
// --- 2. Auf den Planausschnitt beschraenken und transformieren --------
const shapes = [];
for (const it of raw) {
if (!it.segs.length) continue;
const b = bboxOf(it.segs);
if (!(b[0] >= PLAN_CLIP.x0 && b[2] <= PLAN_CLIP.x1 && b[1] >= PLAN_CLIP.y0 && b[3] <= PLAN_CLIP.y1)) continue;
for (const ring of flattenSegments(it.segs)) {
const pts = simplify(ring.map(([x, y]) => toSite(x, y)));
if (pts.length < 2) continue;
const rgb = it.c.length >= 3 ? it.c.slice(0, 3).map((v) => Math.round(v * 255)) : [0, 0, 0];
shapes.push({
op: it.op,
color: rgb,
hex: '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join(''),
pts,
area: it.op === 'f' ? +areaOf(pts).toFixed(4) : 0,
circle: asCircle(pts),
lw: it.lw ? +(it.lw * PT_TO_M).toFixed(3) : 0,
});
}
}
// --- 3. Beschriftungen mit Position -----------------------------------
const st = JSON.parse(page.toStructuredText('preserve-whitespace').asJSON());
const labels = [];
for (const block of st.blocks ?? []) {
if (block.type !== 'text') continue;
for (const line of block.lines ?? []) {
const t = (line.text ?? '').trim();
if (!t) continue;
const { x, y, w, h } = line.bbox;
if (x < PLAN_CLIP.x0 || x > PLAN_CLIP.x1 || y < PLAN_CLIP.y0 || y > PLAN_CLIP.y1) continue;
const [sx, sz] = toSite(x + w / 2, y + h / 2);
labels.push({ text: t, x: +sx.toFixed(3), z: +sz.toFixed(3) });
}
}
// --- 4. Ursprung auf den Fixpunkt legen -------------------------------
const fix = labels.find((l) => l.text.includes('0.00 Fixpunkt')) ?? labels.find((l) => l.text === 'Eingang');
const ox = fix ? fix.x : 0;
const oz = fix ? fix.z : 0;
const shift = (p) => [+(p[0] - ox).toFixed(4), +(p[1] - oz).toFixed(4)];
for (const s of shapes) {
s.pts = s.pts.map(shift);
if (s.circle) {
s.circle.cx = +(s.circle.cx - ox).toFixed(4);
s.circle.cz = +(s.circle.cz - oz).toFixed(4);
s.circle.r = +s.circle.r.toFixed(4);
}
}
for (const l of labels) { l.x = +(l.x - ox).toFixed(3); l.z = +(l.z - oz).toFixed(3); }
// --- 5. Hoehenkoten aus den Beschriftungen --------------------------
const spots = labels
.filter((l) => /^-?\d\.\d{2}$/.test(l.text))
.map((l) => ({ x: l.x, z: l.z, y: parseFloat(l.text) }));
fs.mkdirSync(OUT_DIR, { recursive: true });
const out = {
meta: {
source: path.basename(PDF),
scale: '1:100',
ptToM: PT_TO_M,
gridAngleDeg: GRID_ANGLE_DEG,
northBearingOfSiteX: 61.1615,
originLabel: fix?.text ?? null,
originAbsoluteMasl: 651.17,
note: 'X = Hausachse ENE, Z = SSE, Ursprung = Fixpunkt 0.00 beim Eingang',
},
shapes,
labels,
spots,
};
fs.writeFileSync(path.join(OUT_DIR, 'plan-raw.json'), JSON.stringify(out));
const fills = shapes.filter((s) => s.op === 'f');
console.log(`Formen: ${shapes.length} (Fuellungen ${fills.length}, Linien ${shapes.length - fills.length})`);
console.log(`Kreise: ${shapes.filter((s) => s.circle).length}`);
console.log(`Labels: ${labels.length} Hoehenkoten: ${spots.length}`);
const xs = fills.flatMap((s) => s.pts.map((p) => p[0]));
const zs = fills.flatMap((s) => s.pts.map((p) => p[1]));
console.log(`Ausdehnung: X ${Math.min(...xs).toFixed(1)} .. ${Math.max(...xs).toFixed(1)} m, Z ${Math.min(...zs).toFixed(1)} .. ${Math.max(...zs).toFixed(1)} m`);
}
main();
+52
View File
@@ -0,0 +1,52 @@
/**
* Listet alle Fuellfarben des Plans mit Flaeche, Anzahl Polygone und den
* naechstgelegenen Beschriftungen - Grundlage fuer die Farb-zu-Material-Zuordnung.
*
* Aufruf: node tools/inspect-colors.mjs [minFlaeche]
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const data = JSON.parse(fs.readFileSync(path.join(__dirname, 'out', 'plan-raw.json'), 'utf8'));
const MIN = parseFloat(process.argv[2] ?? '0.15');
const centroid = (pts) => {
let x = 0, z = 0;
for (const p of pts) { x += p[0]; z += p[1]; }
return [x / pts.length, z / pts.length];
};
const groups = new Map();
for (const s of data.shapes) {
if (s.op !== 'f') continue;
const g = groups.get(s.hex) ?? { hex: s.hex, n: 0, area: 0, big: [], circles: 0 };
g.n++; g.area += s.area;
if (s.circle) g.circles++;
if (s.area >= MIN) g.big.push(s);
groups.set(s.hex, g);
}
const rows = [...groups.values()].sort((a, b) => b.area - a.area);
for (const g of rows) {
if (g.area < 0.2 && g.circles === 0) continue;
const near = new Set();
for (const s of g.big.slice(0, 6)) {
const [cx, cz] = centroid(s.pts);
const cands = data.labels
.map((l) => ({ l, d: Math.hypot(l.x - cx, l.z - cz) }))
.filter((c) => c.d < 3.5 && !/^-?\d\.\d{2}$/.test(c.l.text))
.sort((a, b) => a.d - b.d)
.slice(0, 3);
for (const c of cands) near.add(c.l.text);
}
console.log(
g.hex.padEnd(9),
('n=' + g.n).padEnd(7),
(g.area.toFixed(1) + ' m2').padStart(9),
('gross=' + g.big.length).padEnd(10),
('kreise=' + g.circles).padEnd(11),
[...near].slice(0, 7).join(' / '),
);
}
+16
View File
@@ -0,0 +1,16 @@
import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const d = JSON.parse(fs.readFileSync(path.join(__dirname,'out','plan-raw.json'),'utf8'));
console.log('--- Hoehenkoten (x, z, y) sortiert nach y ---');
for (const s of [...d.spots].sort((a,b)=>a.y-b.y))
console.log(String(s.y.toFixed(2)).padStart(6), ' x='+String(s.x.toFixed(2)).padStart(7), ' z='+String(s.z.toFixed(2)).padStart(7));
function inside(pt, poly){let c=false;for(let i=0,j=poly.length-1;i<poly.length;j=i++){const [xi,zi]=poly[i],[xj,zj]=poly[j];if((zi>pt[1])!==(zj>pt[1])&&pt[0]<(xj-xi)*(pt[1]-zi)/(zj-zi)+xi)c=!c;}return c;}
console.log('\n--- Hoehenkoten je grosse Flaeche ---');
const big = d.shapes.filter(s=>s.op==='f'&&s.area>=1.0&&s.hex!=='#000000').sort((a,b)=>b.area-a.area);
for(const s of big){
const hits = d.spots.filter(sp=>inside([sp.x,sp.z], s.pts));
const nearby = d.spots.filter(sp=>!inside([sp.x,sp.z],s.pts)).map(sp=>({sp,dd:Math.min(...s.pts.map(p=>Math.hypot(p[0]-sp.x,p[1]-sp.z)))})).filter(o=>o.dd<0.6).map(o=>o.sp);
console.log(s.hex, String(s.area.toFixed(1)).padStart(6)+'m2',
'innen=['+hits.map(h=>h.y.toFixed(2)).join(' ')+']',
'rand=['+nearby.map(h=>h.y.toFixed(2)).join(' ')+']');
}
+14
View File
@@ -0,0 +1,14 @@
import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const data = JSON.parse(fs.readFileSync(path.join(__dirname,'out','plan-raw.json'),'utf8'));
const want = process.argv[2];
const cent = (p)=>{let x=0,z=0;for(const q of p){x+=q[0];z+=q[1];}return[x/p.length,z/p.length];};
const list = data.shapes.filter(s=>s.op==='f' && s.area>=parseFloat(process.argv[3]??'0.4') && (!want||s.hex===want));
list.sort((a,b)=>b.area-a.area);
for(const s of list){
const [cx,cz]=cent(s.pts);
const near=data.labels.map(l=>({l,d:Math.hypot(l.x-cx,l.z-cz)})).filter(c=>c.d<4&&!/^-?\d\.\d{2}$/.test(c.l.text)).sort((a,b)=>a.d-b.d).slice(0,4).map(c=>c.l.text);
let x0=1e9,z0=1e9,x1=-1e9,z1=-1e9; for(const [x,z] of s.pts){x0=Math.min(x0,x);x1=Math.max(x1,x);z0=Math.min(z0,z);z1=Math.max(z1,z);}
console.log(s.hex, String(s.area.toFixed(2)).padStart(7)+'m2', 'c=('+cx.toFixed(1)+','+cz.toFixed(1)+')', 'bb '+[x0,z0,x1,z1].map(v=>v.toFixed(1)).join(' '), 'n='+s.pts.length, '|', near.join(' / '));
}
console.log('total', list.length);