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:
@@ -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,
|
||||
};
|
||||
@@ -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.)`);
|
||||
@@ -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 → Site-Frame · ${(x1 - x0).toFixed(1)} × ${(z1 - z0).toFixed(1)} m · 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`);
|
||||
@@ -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();
|
||||
@@ -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(' / '),
|
||||
);
|
||||
}
|
||||
@@ -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(' ')+']');
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user