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,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "garten-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.claude
|
||||||
|
tools/out
|
||||||
|
Info Dateien
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
.claude-launch-static.mjs
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Deploy App
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Deploy to Server
|
||||||
|
run: |
|
||||||
|
APP_DIR="/opt/aicds/apps/${GITHUB_REPOSITORY#*/}"
|
||||||
|
mkdir -p $APP_DIR
|
||||||
|
cp -a . $APP_DIR/
|
||||||
|
cd $APP_DIR
|
||||||
|
docker compose down || true
|
||||||
|
docker compose up -d --build
|
||||||
|
docker image prune -f
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
tools/out/
|
||||||
|
.claude-launch-static.mjs
|
||||||
|
*.log
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# --- Build ------------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY tsconfig.json vite.config.ts index.html ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY public ./public
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Laufzeit ---------------------------------------------------------------
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
||||||
|
CMD wget -qO- http://localhost/ >/dev/null || exit 1
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 391 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 581 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 869 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 887 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 761 KiB |
@@ -0,0 +1,165 @@
|
|||||||
|
# Gartenvisualisierung Egglihaldenstrasse 9a
|
||||||
|
|
||||||
|
Begehbare 3D-Visualisierung des geplanten Gartens von Jasmin und Damian
|
||||||
|
Kellenberger, Egglihaldenstrasse 9a, 9030 Abtwil SG.
|
||||||
|
|
||||||
|
Live: **https://garten.aicds.ch**
|
||||||
|
|
||||||
|
Die gesamte Geometrie ist **massstäblich aus dem Gartenplan** abgeleitet – nicht
|
||||||
|
nachmodelliert. Ein Node-Werkzeug liest die Vektordaten direkt aus dem Plan-PDF,
|
||||||
|
rechnet sie in Meter um und erzeugt daraus das Szenenmodell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grundlagen
|
||||||
|
|
||||||
|
| Quelle | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `Info Dateien/Gartenplan 1 …pdf` | Garten Design AG, 14.08.2026, Massstab 1:100, A1 |
|
||||||
|
| `Info Dateien/20210427 - Projektpläne Haus 9a.pdf` | EHLE Architekten AG, Grundrisse, Fassaden, Umgebung |
|
||||||
|
| `Info Dateien/WhatsApp Image …jpeg` | Fotos des Ist-Zustands (Fassade, Beläge, Nachbarschaft) |
|
||||||
|
|
||||||
|
Die DWG wird nicht benötigt – das PDF enthält dieselbe Vektorgeometrie.
|
||||||
|
|
||||||
|
### Koordinatensystem
|
||||||
|
|
||||||
|
Alles rechnet im **Site-Frame**, in Metern:
|
||||||
|
|
||||||
|
- **X** – Hausachse ENE, Peilung 61.1615° gegen Nord
|
||||||
|
- **Z** – senkrecht dazu, SSE, Peilung 151.1615°
|
||||||
|
- **Y** – Höhe über dem Fixpunkt `0.00` = **651.17 m ü. M.** (OK Fertigboden EG)
|
||||||
|
|
||||||
|
Der Nordpfeil des Plans zeigt exakt zur Blattoberkante, das Zeichnungsraster
|
||||||
|
liegt um −28.8385° gedreht dazu. Der Massstab ist über das Plattenraster
|
||||||
|
verifiziert: die Keramikplatten messen sich auf 0.600 m genau.
|
||||||
|
|
||||||
|
### Höhen
|
||||||
|
|
||||||
|
| Bezug | relativ | absolut |
|
||||||
|
|---|---|---|
|
||||||
|
| Feuerwehrzufahrt, Nordwestecke | −2.08 | 648.09 m |
|
||||||
|
| Nachbargarten Zollet (Haus 9, OK FB EG) | −1.70 | 649.47 m |
|
||||||
|
| Hauptsitzplatz Travertin | −0.30 … −0.02 | 650.87 … 651.15 m |
|
||||||
|
| Fixpunkt Hauseingang | 0.00 | 651.17 m |
|
||||||
|
| Ostseite beim Apfelbaum | +0.63 | 651.80 m |
|
||||||
|
| OK Fertigboden Obergeschoss | +2.90 | 654.07 m |
|
||||||
|
| OK Attika Haus 9a | +6.15 | 657.32 m |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenpipeline
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run data # extract-plan + build-site
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **`tools/extract-plan.mjs`** – öffnet das Plan-PDF mit MuPDF (WASM) und
|
||||||
|
protokolliert alle Zeichenoperationen mit. Ergebnis: rund 5400 Polygone,
|
||||||
|
234 Pflanzenkreise, 229 Beschriftungen und 46 Höhenkoten, transformiert in
|
||||||
|
den Site-Frame. → `tools/out/plan-raw.json`
|
||||||
|
|
||||||
|
2. **`tools/authoring.mjs`** – handgepflegte Ergänzungen: Zuordnung der
|
||||||
|
Planfarben zu Belagsarten, Mauerachsen und -höhen, Gebäudekubatur,
|
||||||
|
Geländezonen, Standorte. Alles kommentiert und auf Plan oder
|
||||||
|
Architekturpläne zurückgeführt.
|
||||||
|
|
||||||
|
3. **`tools/build-site.mjs`** – klassiert die Flächen, leitet Pflanzen und
|
||||||
|
Leuchten ab, interpoliert das Geländeraster (0.25 m) aus den Höhenkoten je
|
||||||
|
Zone und schreibt `public/data/site.json` (rund 110 kB).
|
||||||
|
|
||||||
|
Zur Kontrolle: `node tools/debug-svg.mjs` zeichnet die extrahierte Geometrie als
|
||||||
|
SVG-Draufsicht, `node tools/inspect-colors.mjs` listet die Planfarben mit den
|
||||||
|
nächstgelegenen Beschriftungen.
|
||||||
|
|
||||||
|
### Warum Zonen statt eines einzigen Geländes
|
||||||
|
|
||||||
|
Das Grundstück fällt über rund 3.5 m ab, die Sprünge liegen aber nicht im
|
||||||
|
Gelände, sondern in den Stützbauten: Travertinmauer zum Nachbargrundstück,
|
||||||
|
Winkelelemente aus Beton entlang der Feuerwehrzufahrt, Betonmauer im Süden.
|
||||||
|
Deshalb wird je Zone getrennt interpoliert – über die Zonengrenze hinweg nie.
|
||||||
|
Die Mauern erhalten ihr Kronen- und Fussprofil aus dem Gelände beider Seiten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anwendung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
npm run build # dist/, rund 640 kB
|
||||||
|
```
|
||||||
|
|
||||||
|
Stack: Vite + TypeScript + three.js. **Keine externen Assets** – sämtliche
|
||||||
|
Texturen (Travertin, Keramik, Tegula, Rasen, Kies, Beton, Holzschalung,
|
||||||
|
Scheiterwand) werden zur Laufzeit prozedural auf ein Canvas gezeichnet. Die UVs
|
||||||
|
sind weltbezogen, damit die Plattenraster über Flächengrenzen hinweg durchlaufen
|
||||||
|
– so wie auf dem Plan.
|
||||||
|
|
||||||
|
### Bedienung
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Rundgang | Klicken, dann `W` `A` `S` `D`, `Shift` zum Rennen, `Esc` gibt die Maus frei |
|
||||||
|
| Handy | links Joystick, rechts wischen zum Umsehen |
|
||||||
|
| Übersicht | ziehen zum Drehen, Mausrad zum Zoomen |
|
||||||
|
| Tageszeit | Regler oben rechts, Sonnenstand astronomisch für Abtwil SG gerechnet |
|
||||||
|
|
||||||
|
Der Rundgang folgt dem Gelände, steigt Stufen bis 45 cm und wird von Mauern,
|
||||||
|
Gebäuden, Hecken und Objekten aufgehalten. Ab Dämmerung schalten die 17 Leuchten
|
||||||
|
des Plans automatisch ein.
|
||||||
|
|
||||||
|
### Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
site.ts Datenmodell und Höhen-Sampler
|
||||||
|
scene/textures.ts prozedurale Beläge und Materialien
|
||||||
|
scene/geometry.ts Polygone auf das Gelände legen, Mauern extrudieren
|
||||||
|
scene/terrain.ts Geländeraster und Umland
|
||||||
|
scene/surfaces.ts Beläge
|
||||||
|
scene/structures.ts Mauern, Stelen, Objekte, Leuchten
|
||||||
|
scene/vegetation.ts Gehölze, Hecken, Gräser, Stauden
|
||||||
|
scene/building.ts Wohnhaus 9a und Nachbargebäude
|
||||||
|
scene/environment.ts Himmel, Sonnenstand, Licht
|
||||||
|
controls/walker.ts Begehen mit Kollision und Geländeführung
|
||||||
|
ui/hud.ts Bedienoberfläche
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Push auf `main` löst den Gitea-Workflow aus; die App landet unter
|
||||||
|
`/opt/aicds/apps/Gartenvisualisierung/` und wird per `docker compose` gebaut.
|
||||||
|
Traefik-Router `garten`, Host `garten.aicds.ch`, Netzwerk `agent-net`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose config # Prüfung vor dem Push
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Image enthält nur `dist/` hinter nginx – die Planunterlagen und Fotos aus
|
||||||
|
`Info Dateien/` werden über `.dockerignore` ausgeschlossen und gelangen nie in
|
||||||
|
den Webcontainer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was bewusst interpretiert ist
|
||||||
|
|
||||||
|
Der Plan ist ein Situationsplan; einiges steht nur als Beschriftung darin. Diese
|
||||||
|
Annahmen stecken in `tools/authoring.mjs` und sind dort einzeln kommentiert:
|
||||||
|
|
||||||
|
- **Mauerhöhen** aus den Höhenkoten beidseits abgeleitet; die gelbe Kote `25`
|
||||||
|
bei der Sitzmauer als 25 cm Aufkantung gelesen, `35` bei den Heidelbeeren
|
||||||
|
analog.
|
||||||
|
- **Lamellenwand zum Sitzen** als senkrechte Holzlamellen im Planrhythmus
|
||||||
|
(10 cm breit, 35 cm tief, rund 90 cm Achsabstand), passend zum Foto des
|
||||||
|
Ist-Zustands.
|
||||||
|
- **Wohnhaus** in Kubatur und Öffnungen nach Gartenplan und Fassadenplänen,
|
||||||
|
Materialisierung nach den Fotos: vertikale Holzschalung graubraun, heller
|
||||||
|
Betonsockel, Flachdach mit extensiver Begrünung.
|
||||||
|
- **Nachbarhaus Zollet** als einfaches Kontextvolumen, 1.70 m tiefer liegend.
|
||||||
|
- **Solitärgehölze** mit den Kronenradien des Plans; Wuchshöhen als
|
||||||
|
Erfahrungswerte.
|
||||||
|
- **Gartenhaus, Spielhaus, Flammengrill, Bänkli** in Lage und Grundfläche nach
|
||||||
|
Plan, in der Ausformulierung frei.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- agent-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.garten.rule=Host(`garten.aicds.ch`)"
|
||||||
|
- "traefik.http.routers.garten.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.garten.tls.certresolver=myresolver"
|
||||||
|
- "traefik.http.services.garten.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
agent-net:
|
||||||
|
external: true
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de-CH">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#2c3a2a" />
|
||||||
|
<meta name="description" content="Begehbare 3D-Visualisierung des geplanten Gartens an der Egglihaldenstrasse 9a in Abtwil, massstäblich aus dem Gartenplan der Garten Design AG." />
|
||||||
|
<title>Garten Egglihaldenstrasse 9a · 3D-Visualisierung</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='26'%3E%F0%9F%8C%B3%3C/text%3E%3C/svg%3E" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="view"></canvas>
|
||||||
|
|
||||||
|
<div id="loader">
|
||||||
|
<div class="loader-inner">
|
||||||
|
<div class="loader-title">Garten Egglihaldenstrasse 9a</div>
|
||||||
|
<div class="loader-bar"><span id="loader-fill"></span></div>
|
||||||
|
<div class="loader-note" id="loader-note">Gartenplan wird aufgebaut …</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hud" hidden>
|
||||||
|
<header id="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<strong>Garten Egglihaldenstrasse 9a</strong>
|
||||||
|
<span>Abtwil SG · Gartenplan 1, Garten Design AG</span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button id="btn-mode" type="button" title="Zwischen Rundgang und Übersicht wechseln">Übersicht</button>
|
||||||
|
<button id="btn-info" type="button" title="Informationen zum Plan">Info</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav id="views" aria-label="Standorte"></nav>
|
||||||
|
|
||||||
|
<div id="timebar">
|
||||||
|
<label for="time">Tageszeit <output id="time-out">14:00</output></label>
|
||||||
|
<input id="time" type="range" min="4" max="23" step="0.25" value="14" />
|
||||||
|
<label for="season">Datum <output id="season-out">21. Juni</output></label>
|
||||||
|
<input id="season" type="range" min="1" max="365" step="1" value="172" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="crosshair"></div>
|
||||||
|
|
||||||
|
<div id="hint">
|
||||||
|
<span class="desktop">Klicken zum Umsehen · <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> gehen · <kbd>Shift</kbd> schneller · <kbd>Esc</kbd> Maus freigeben</span>
|
||||||
|
<span class="touch">Links ziehen zum Gehen · rechts wischen zum Umsehen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="joystick" hidden><div id="joystick-knob"></div></div>
|
||||||
|
|
||||||
|
<aside id="info" hidden>
|
||||||
|
<button id="info-close" type="button" aria-label="Schliessen">×</button>
|
||||||
|
<h2>Über diese Visualisierung</h2>
|
||||||
|
<div id="info-body"></div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<p style="padding:2rem;font:16px system-ui">Für die 3D-Visualisierung wird JavaScript benötigt.</p>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# Statische Assets tragen einen Hash im Namen und duerfen lange liegenbleiben.
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Das Szenenmodell wird bei jedem Plan-Update neu gebaut.
|
||||||
|
location /data/ {
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1254
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "gartenvisualisierung",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Begehbare 3D-Visualisierung des Gartens Kellenberger, Egglihaldenstrasse 9a, 9030 Abtwil",
|
||||||
|
"scripts": {
|
||||||
|
"extract": "node tools/extract-plan.mjs",
|
||||||
|
"build:site": "node tools/build-site.mjs",
|
||||||
|
"data": "npm run extract && npm run build:site",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --port 4173"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/three": "^0.172.0",
|
||||||
|
"mupdf": "^1.28.1",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.172.0"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"esbuild@0.25.12": true
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Begehen des Gartens: Maus/Tastatur am Desktop, Joystick und Wischen am Handy.
|
||||||
|
* Der Spieler folgt dem Gelaende, kann Stufen bis 45 cm ersteigen und wird
|
||||||
|
* von Mauern, Gebaeuden und Objekten aufgehalten.
|
||||||
|
*/
|
||||||
|
import { Euler, PerspectiveCamera, Vector3 } from 'three';
|
||||||
|
|
||||||
|
/** Achsenausgerichtetes oder gedrehtes Hindernis in der Draufsicht. */
|
||||||
|
export interface Obstacle {
|
||||||
|
cx: number;
|
||||||
|
cz: number;
|
||||||
|
hw: number;
|
||||||
|
hd: number;
|
||||||
|
/** Drehung um Y in Radiant. */
|
||||||
|
rot: number;
|
||||||
|
/** Hindernisse unterhalb dieser Hoehe kann man uebersteigen. */
|
||||||
|
climbable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalkerOptions {
|
||||||
|
height: (x: number, z: number) => number;
|
||||||
|
obstacles: Obstacle[];
|
||||||
|
bounds: { x0: number; z0: number; x1: number; z1: number };
|
||||||
|
eyeHeight?: number;
|
||||||
|
radius?: number;
|
||||||
|
maxStep?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEED_WALK = 1.65;
|
||||||
|
const SPEED_RUN = 3.6;
|
||||||
|
|
||||||
|
export class Walker {
|
||||||
|
readonly camera: PerspectiveCamera;
|
||||||
|
private readonly opts: Required<WalkerOptions>;
|
||||||
|
private readonly euler = new Euler(0, 0, 0, 'YXZ');
|
||||||
|
private readonly keys = new Set<string>();
|
||||||
|
private readonly pos = new Vector3();
|
||||||
|
private velocityY = 0;
|
||||||
|
private grounded = true;
|
||||||
|
private joystick = { x: 0, y: 0, active: false };
|
||||||
|
private lookDelta = { x: 0, y: 0 };
|
||||||
|
private pointerLocked = false;
|
||||||
|
private enabled = true;
|
||||||
|
|
||||||
|
/** Wird gesetzt, wenn der Nutzer die Maus gefangen hat oder wieder frei ist. */
|
||||||
|
onPointerLockChange?: (locked: boolean) => void;
|
||||||
|
|
||||||
|
constructor(camera: PerspectiveCamera, options: WalkerOptions) {
|
||||||
|
this.camera = camera;
|
||||||
|
this.opts = {
|
||||||
|
eyeHeight: 1.68,
|
||||||
|
radius: 0.32,
|
||||||
|
maxStep: 0.45,
|
||||||
|
...options,
|
||||||
|
} as Required<WalkerOptions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
get position(): Vector3 { return this.pos; }
|
||||||
|
get yawDegrees(): number { return (this.euler.y * 180) / Math.PI; }
|
||||||
|
|
||||||
|
setEnabled(on: boolean) { this.enabled = on; }
|
||||||
|
|
||||||
|
teleport(x: number, z: number, yawDeg: number) {
|
||||||
|
this.pos.set(x, this.opts.height(x, z) + this.opts.eyeHeight, z);
|
||||||
|
this.euler.set(0, (-yawDeg * Math.PI) / 180, 0);
|
||||||
|
this.velocityY = 0;
|
||||||
|
this.applyCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Eingaben -------------------------------------------------------------
|
||||||
|
|
||||||
|
attach(dom: HTMLElement) {
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.code === 'Tab') return;
|
||||||
|
this.keys.add(e.code);
|
||||||
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
|
||||||
|
});
|
||||||
|
window.addEventListener('keyup', (e) => this.keys.delete(e.code));
|
||||||
|
window.addEventListener('blur', () => this.keys.clear());
|
||||||
|
|
||||||
|
dom.addEventListener('click', () => {
|
||||||
|
if (!this.enabled || this.pointerLocked || isTouch()) return;
|
||||||
|
// In eingebetteten Vorschauen ist Pointer Lock gesperrt - kein Grund abzustuerzen.
|
||||||
|
Promise.resolve(dom.requestPointerLock()).catch(() => {});
|
||||||
|
});
|
||||||
|
document.addEventListener('pointerlockchange', () => {
|
||||||
|
this.pointerLocked = document.pointerLockElement === dom;
|
||||||
|
this.onPointerLockChange?.(this.pointerLocked);
|
||||||
|
});
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!this.pointerLocked || !this.enabled) return;
|
||||||
|
this.lookDelta.x -= e.movementX * 0.0022;
|
||||||
|
this.lookDelta.y -= e.movementY * 0.0022;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wischen zum Umsehen (rechte Bildschirmhaelfte bleibt fuer den Blick frei)
|
||||||
|
let touchId: number | null = null;
|
||||||
|
let last = { x: 0, y: 0 };
|
||||||
|
dom.addEventListener('touchstart', (e) => {
|
||||||
|
for (const t of Array.from(e.changedTouches)) {
|
||||||
|
if (touchId === null && t.clientX > window.innerWidth * 0.35) {
|
||||||
|
touchId = t.identifier;
|
||||||
|
last = { x: t.clientX, y: t.clientY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
dom.addEventListener('touchmove', (e) => {
|
||||||
|
for (const t of Array.from(e.changedTouches)) {
|
||||||
|
if (t.identifier !== touchId) continue;
|
||||||
|
this.lookDelta.x -= (t.clientX - last.x) * 0.005;
|
||||||
|
this.lookDelta.y -= (t.clientY - last.y) * 0.005;
|
||||||
|
last = { x: t.clientX, y: t.clientY };
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
const end = (e: TouchEvent) => {
|
||||||
|
for (const t of Array.from(e.changedTouches)) if (t.identifier === touchId) touchId = null;
|
||||||
|
};
|
||||||
|
dom.addEventListener('touchend', end, { passive: true });
|
||||||
|
dom.addEventListener('touchcancel', end, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
setJoystick(x: number, y: number, active: boolean) {
|
||||||
|
this.joystick = { x, y, active };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Bewegung -------------------------------------------------------------
|
||||||
|
|
||||||
|
update(dt: number) {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
this.euler.y += this.lookDelta.x;
|
||||||
|
this.euler.x += this.lookDelta.y;
|
||||||
|
this.euler.x = Math.max(-1.4, Math.min(1.4, this.euler.x));
|
||||||
|
this.lookDelta.x = 0;
|
||||||
|
this.lookDelta.y = 0;
|
||||||
|
|
||||||
|
let forward = 0, strafe = 0;
|
||||||
|
if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) forward += 1;
|
||||||
|
if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) forward -= 1;
|
||||||
|
if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) strafe += 1;
|
||||||
|
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) strafe -= 1;
|
||||||
|
if (this.joystick.active) {
|
||||||
|
forward += -this.joystick.y;
|
||||||
|
strafe += this.joystick.x;
|
||||||
|
}
|
||||||
|
|
||||||
|
const len = Math.hypot(forward, strafe);
|
||||||
|
if (len > 1) { forward /= len; strafe /= len; }
|
||||||
|
|
||||||
|
const running = this.keys.has('ShiftLeft') || this.keys.has('ShiftRight');
|
||||||
|
const speed = (running ? SPEED_RUN : SPEED_WALK) * dt;
|
||||||
|
const sin = Math.sin(this.euler.y), cos = Math.cos(this.euler.y);
|
||||||
|
const dx = (-sin * forward + cos * strafe) * speed;
|
||||||
|
const dz = (-cos * forward - sin * strafe) * speed;
|
||||||
|
|
||||||
|
this.move(dx, dz);
|
||||||
|
|
||||||
|
// Vertikal: dem Gelaende folgen, Absaetze weich abfangen
|
||||||
|
const ground = this.opts.height(this.pos.x, this.pos.z) + this.opts.eyeHeight;
|
||||||
|
if (this.pos.y > ground + 0.02) {
|
||||||
|
this.velocityY -= 9.81 * dt;
|
||||||
|
this.pos.y += this.velocityY * dt;
|
||||||
|
if (this.pos.y <= ground) { this.pos.y = ground; this.velocityY = 0; this.grounded = true; }
|
||||||
|
else this.grounded = false;
|
||||||
|
} else {
|
||||||
|
// sanftes Nachziehen beim Aufsteigen
|
||||||
|
this.pos.y += (ground - this.pos.y) * Math.min(1, dt * 12);
|
||||||
|
this.velocityY = 0;
|
||||||
|
this.grounded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bewegt den Spieler und loest Kollisionen achsenweise auf. */
|
||||||
|
private move(dx: number, dz: number) {
|
||||||
|
const tryAxis = (nx: number, nz: number): boolean => {
|
||||||
|
const y0 = this.opts.height(this.pos.x, this.pos.z);
|
||||||
|
const y1 = this.opts.height(nx, nz);
|
||||||
|
if (this.grounded && y1 - y0 > this.opts.maxStep) return false;
|
||||||
|
if (this.blocked(nx, nz)) return false;
|
||||||
|
const b = this.opts.bounds;
|
||||||
|
if (nx < b.x0 || nx > b.x1 || nz < b.z0 || nz > b.z1) return false;
|
||||||
|
this.pos.x = nx;
|
||||||
|
this.pos.z = nz;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if (dx !== 0) tryAxis(this.pos.x + dx, this.pos.z);
|
||||||
|
if (dz !== 0) tryAxis(this.pos.x, this.pos.z + dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
private blocked(x: number, z: number): boolean {
|
||||||
|
const r = this.opts.radius;
|
||||||
|
for (const o of this.opts.obstacles) {
|
||||||
|
const dx = x - o.cx, dz = z - o.cz;
|
||||||
|
const c = Math.cos(-o.rot), s = Math.sin(-o.rot);
|
||||||
|
const lx = dx * c - dz * s;
|
||||||
|
const lz = dx * s + dz * c;
|
||||||
|
if (Math.abs(lx) < o.hw + r && Math.abs(lz) < o.hd + r) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCamera() {
|
||||||
|
this.camera.position.copy(this.pos);
|
||||||
|
this.camera.quaternion.setFromEuler(this.euler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTouch(): boolean {
|
||||||
|
return window.matchMedia('(pointer: coarse)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut ein Hindernis aus einer Strecke (Mauer) mit gegebener Dicke. */
|
||||||
|
export function obstacleFromSegment(a: [number, number], b: [number, number], thickness: number): Obstacle {
|
||||||
|
const dx = b[0] - a[0], dz = b[1] - a[1];
|
||||||
|
const len = Math.hypot(dx, dz);
|
||||||
|
return {
|
||||||
|
cx: (a[0] + b[0]) / 2,
|
||||||
|
cz: (a[1] + b[1]) / 2,
|
||||||
|
hw: len / 2,
|
||||||
|
hd: thickness / 2,
|
||||||
|
rot: Math.atan2(dz, dx),
|
||||||
|
};
|
||||||
|
}
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Begehbare 3D-Visualisierung des Gartens Egglihaldenstrasse 9a, Abtwil SG.
|
||||||
|
*
|
||||||
|
* Die gesamte Geometrie ist massstaeblich aus dem Gartenplan 1 der
|
||||||
|
* Garten Design AG (1:100) abgeleitet; das Wohnhaus zusaetzlich aus den
|
||||||
|
* Projektplaenen von EHLE Architekten. Siehe tools/ fuer die Datenpipeline.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
ACESFilmicToneMapping, Group, PCFSoftShadowMap, PerspectiveCamera, Scene,
|
||||||
|
SRGBColorSpace, Vector3, WebGLRenderer,
|
||||||
|
} from 'three';
|
||||||
|
import './style.css';
|
||||||
|
import { loadSite, makeHeightSampler, type SiteData } from './site';
|
||||||
|
import { buildTerrain } from './scene/terrain';
|
||||||
|
import { buildSurfaces } from './scene/surfaces';
|
||||||
|
import { buildWalls, buildStelen, buildProps, buildLamps } from './scene/structures';
|
||||||
|
import { buildVegetation } from './scene/vegetation';
|
||||||
|
import { buildBuilding, buildNeighbourBuildings } from './scene/building';
|
||||||
|
import { buildEnvironment } from './scene/environment';
|
||||||
|
import { Walker, isTouch, obstacleFromSegment, type Obstacle } from './controls/walker';
|
||||||
|
import { setupHud } from './ui/hud';
|
||||||
|
|
||||||
|
const CENTER: [number, number] = [-9.5, -1.0];
|
||||||
|
const BOUNDS = { x0: -24.0, z0: -12.5, x1: 7.5, z1: 8.5 };
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const canvas = document.getElementById('view') as HTMLCanvasElement;
|
||||||
|
const fill = document.getElementById('loader-fill') as HTMLSpanElement;
|
||||||
|
const note = document.getElementById('loader-note') as HTMLDivElement;
|
||||||
|
const progress = (pct: number, text: string) => {
|
||||||
|
fill.style.width = `${pct}%`;
|
||||||
|
note.textContent = text;
|
||||||
|
return new Promise<void>((r) => requestAnimationFrame(() => r()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = new WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
|
renderer.outputColorSpace = SRGBColorSpace;
|
||||||
|
renderer.toneMapping = ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 0.94;
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = PCFSoftShadowMap;
|
||||||
|
|
||||||
|
const scene = new Scene();
|
||||||
|
const camera = new PerspectiveCamera(62, 1, 0.08, 1400);
|
||||||
|
|
||||||
|
await progress(8, 'Gartenplan wird geladen …');
|
||||||
|
const site: SiteData = await loadSite();
|
||||||
|
const height = makeHeightSampler(site.terrain);
|
||||||
|
|
||||||
|
await progress(22, 'Gelände wird modelliert …');
|
||||||
|
scene.add(buildTerrain(site.terrain));
|
||||||
|
|
||||||
|
await progress(38, 'Beläge werden verlegt …');
|
||||||
|
scene.add(buildSurfaces(site.surfaces, height));
|
||||||
|
|
||||||
|
await progress(52, 'Mauern und Bauteile …');
|
||||||
|
scene.add(buildWalls(site.walls, height));
|
||||||
|
scene.add(buildStelen(site.stelen, height));
|
||||||
|
scene.add(buildProps(site.props, height));
|
||||||
|
|
||||||
|
await progress(66, 'Haus und Nachbarschaft …');
|
||||||
|
scene.add(buildBuilding(site.building));
|
||||||
|
scene.add(buildNeighbourBuildings(site.neighbourBuildings));
|
||||||
|
|
||||||
|
await progress(80, 'Bepflanzung wird gesetzt …');
|
||||||
|
scene.add(buildVegetation(site.plants, site.trees, height));
|
||||||
|
|
||||||
|
await progress(90, 'Licht und Himmel …');
|
||||||
|
const lamps = buildLamps(site.lamps, height);
|
||||||
|
scene.add(lamps.group);
|
||||||
|
const env = buildEnvironment(scene, {
|
||||||
|
lat: site.meta.location.lat,
|
||||||
|
lon: site.meta.location.lon,
|
||||||
|
northBearingOfSiteX: site.meta.location.northBearingOfSiteX,
|
||||||
|
center: CENTER,
|
||||||
|
radius: 26,
|
||||||
|
});
|
||||||
|
scene.add(env.group);
|
||||||
|
|
||||||
|
// -- Hindernisse fuer den Rundgang --------------------------------------
|
||||||
|
const obstacles: Obstacle[] = [];
|
||||||
|
const b = site.building;
|
||||||
|
const [bx0, bz0] = b.outline[0];
|
||||||
|
const bx1 = b.outline[1][0];
|
||||||
|
const bz1 = b.outline[2][1];
|
||||||
|
// Baukoerper ohne die begehbare Loggia: zwei Rechtecke
|
||||||
|
obstacles.push({
|
||||||
|
cx: (bx0 + bx1) / 2, cz: (bz0 + b.loggia.z0) / 2,
|
||||||
|
hw: (bx1 - bx0) / 2, hd: (b.loggia.z0 - bz0) / 2, rot: 0,
|
||||||
|
});
|
||||||
|
obstacles.push({
|
||||||
|
cx: (b.loggia.x1 + bx1) / 2, cz: (b.loggia.z0 + bz1) / 2,
|
||||||
|
hw: (bx1 - b.loggia.x1) / 2, hd: (bz1 - b.loggia.z0) / 2, rot: 0,
|
||||||
|
});
|
||||||
|
for (const nb of site.neighbourBuildings) {
|
||||||
|
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
|
||||||
|
for (const [x, z] of nb.outline) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); z0 = Math.min(z0, z); z1 = Math.max(z1, z); }
|
||||||
|
obstacles.push({ cx: (x0 + x1) / 2, cz: (z0 + z1) / 2, hw: (x1 - x0) / 2, hd: (z1 - z0) / 2, rot: 0 });
|
||||||
|
}
|
||||||
|
for (const w of site.walls) {
|
||||||
|
for (let i = 1; i < w.path.length; i++) {
|
||||||
|
obstacles.push(obstacleFromSegment(w.path[i - 1], w.path[i], w.t + 0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of site.props) {
|
||||||
|
if (p.type === 'schacht' || p.type === 'wasserhahn') continue;
|
||||||
|
obstacles.push({ cx: p.x, cz: p.z, hw: p.w / 2, hd: p.d / 2, rot: (p.rot * Math.PI) / 180 });
|
||||||
|
}
|
||||||
|
for (const pl of site.plants) {
|
||||||
|
if (pl.type !== 'hecke' || !pl.polygon) continue;
|
||||||
|
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
|
||||||
|
for (const [x, z] of pl.polygon) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); z0 = Math.min(z0, z); z1 = Math.max(z1, z); }
|
||||||
|
obstacles.push({ cx: (x0 + x1) / 2, cz: (z0 + z1) / 2, hw: (x1 - x0) / 2, hd: (z1 - z0) / 2, rot: 0 });
|
||||||
|
}
|
||||||
|
for (const t of site.trees) {
|
||||||
|
obstacles.push({ cx: t.x, cz: t.z, hw: 0.22, hd: 0.22, rot: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const walker = new Walker(camera, { height, obstacles, bounds: BOUNDS });
|
||||||
|
walker.attach(canvas);
|
||||||
|
const start = site.startViews[0];
|
||||||
|
walker.teleport(start.x, start.z, start.yaw);
|
||||||
|
|
||||||
|
// -- Uebersichtsmodus ----------------------------------------------------
|
||||||
|
const orbit = {
|
||||||
|
active: false,
|
||||||
|
yaw: -0.7,
|
||||||
|
pitch: 0.72,
|
||||||
|
distance: 42,
|
||||||
|
target: new Vector3(CENTER[0], 0.5, CENTER[1]),
|
||||||
|
apply() {
|
||||||
|
const cp = Math.cos(this.pitch), sp = Math.sin(this.pitch);
|
||||||
|
camera.position.set(
|
||||||
|
this.target.x + this.distance * cp * Math.sin(this.yaw),
|
||||||
|
this.target.y + this.distance * sp,
|
||||||
|
this.target.z + this.distance * cp * Math.cos(this.yaw),
|
||||||
|
);
|
||||||
|
camera.lookAt(this.target);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
let lastPointer = { x: 0, y: 0 };
|
||||||
|
canvas.addEventListener('pointerdown', (e) => {
|
||||||
|
if (!orbit.active) return;
|
||||||
|
dragging = true;
|
||||||
|
lastPointer = { x: e.clientX, y: e.clientY };
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
canvas.addEventListener('pointermove', (e) => {
|
||||||
|
if (!orbit.active || !dragging) return;
|
||||||
|
orbit.yaw -= (e.clientX - lastPointer.x) * 0.006;
|
||||||
|
orbit.pitch = Math.max(0.12, Math.min(1.45, orbit.pitch + (e.clientY - lastPointer.y) * 0.004));
|
||||||
|
lastPointer = { x: e.clientX, y: e.clientY };
|
||||||
|
orbit.apply();
|
||||||
|
});
|
||||||
|
canvas.addEventListener('pointerup', () => { dragging = false; });
|
||||||
|
canvas.addEventListener('wheel', (e) => {
|
||||||
|
if (!orbit.active) return;
|
||||||
|
e.preventDefault();
|
||||||
|
orbit.distance = Math.max(14, Math.min(110, orbit.distance * (1 + Math.sign(e.deltaY) * 0.12)));
|
||||||
|
orbit.apply();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// -- Bedienoberflaeche ---------------------------------------------------
|
||||||
|
const hud = setupHud({
|
||||||
|
site,
|
||||||
|
onView: (v) => {
|
||||||
|
if (orbit.active) hud.setMode('walk');
|
||||||
|
walker.teleport(v.x, v.z, v.yaw);
|
||||||
|
},
|
||||||
|
onMode: (mode) => {
|
||||||
|
orbit.active = mode === 'overview';
|
||||||
|
walker.setEnabled(!orbit.active);
|
||||||
|
if (orbit.active) {
|
||||||
|
if (document.pointerLockElement) document.exitPointerLock();
|
||||||
|
orbit.apply();
|
||||||
|
} else {
|
||||||
|
walker.teleport(walker.position.x, walker.position.z, walker.yawDegrees);
|
||||||
|
}
|
||||||
|
canvas.style.cursor = orbit.active ? 'grab' : 'default';
|
||||||
|
},
|
||||||
|
onJoystick: (x, y, active) => walker.setJoystick(x, y, active),
|
||||||
|
});
|
||||||
|
walker.onPointerLockChange = (locked) => hud.setPointerLocked(locked);
|
||||||
|
|
||||||
|
// -- Renderschleife ------------------------------------------------------
|
||||||
|
function resize() {
|
||||||
|
const w = canvas.clientWidth || window.innerWidth;
|
||||||
|
const h = canvas.clientHeight || window.innerHeight;
|
||||||
|
renderer.setSize(w, h, false);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
let last = performance.now();
|
||||||
|
let lastSun = { hours: -1, day: -1 };
|
||||||
|
|
||||||
|
function frame(now: number) {
|
||||||
|
const dt = Math.min(0.05, (now - last) / 1000);
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
const t = hud.time();
|
||||||
|
if (t.hours !== lastSun.hours || t.day !== lastSun.day) {
|
||||||
|
const sun = env.update(t.day, t.hours);
|
||||||
|
lamps.setOn(env.isDark());
|
||||||
|
hud.setSunReadout(sun);
|
||||||
|
lastSun = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orbit.active) {
|
||||||
|
orbit.apply();
|
||||||
|
} else {
|
||||||
|
walker.update(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
await progress(100, 'Bereit.');
|
||||||
|
document.getElementById('loader')!.classList.add('done');
|
||||||
|
document.getElementById('hud')!.hidden = false;
|
||||||
|
if (isTouch()) document.getElementById('joystick')!.hidden = false;
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
// Zugriff fuer Entwicklung und automatisierte Bildkontrolle
|
||||||
|
(window as unknown as { garten: unknown }).garten = {
|
||||||
|
scene, camera, site, env, walker, orbit, hud, renderer, Group,
|
||||||
|
setOverview(on: boolean) { hud.setMode(on ? 'overview' : 'walk'); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
const note = document.getElementById('loader-note');
|
||||||
|
if (note) note.textContent = `Fehler beim Laden: ${(err as Error).message}`;
|
||||||
|
});
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Wohnhaus 9a und Nachbargebaeude.
|
||||||
|
*
|
||||||
|
* Grundriss und Oeffnungen stammen aus dem Gartenplan, die Hoehen aus den
|
||||||
|
* Fassadenplaenen 1:100 von EHLE Architekten (OK FB EG 651.17 = 0.00,
|
||||||
|
* OK FB OG +2.90, OK Attika +6.15). Materialisierung nach den Fotos:
|
||||||
|
* vertikale, offene Holzschalung graubraun auf hellem Betonsockel,
|
||||||
|
* dunkelbronzene Fensterrahmen, Flachdach mit extensiver Begruenung.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
BufferAttribute, BufferGeometry, Group, Mesh, MeshPhysicalMaterial,
|
||||||
|
MeshStandardMaterial, PlaneGeometry,
|
||||||
|
} from 'three';
|
||||||
|
import type { Building, NeighbourBuilding, Opening, Vec2 } from '../site';
|
||||||
|
import { boxGeometry, mergeGeometries } from './geometry';
|
||||||
|
import * as tex from './textures';
|
||||||
|
|
||||||
|
/** Rechteck in Fassadenkoordinaten (u entlang der Wand, y in der Hoehe). */
|
||||||
|
interface Rect { u0: number; u1: number; y0: number; y1: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zieht Aussparungen von einem Wandfeld ab und liefert die verbleibenden
|
||||||
|
* Rechtecke. Erst in u-Richtung aufteilen, dann je Streifen in y-Richtung.
|
||||||
|
*/
|
||||||
|
function subtract(panel: Rect, voids: Rect[]): Rect[] {
|
||||||
|
const relevant = voids.filter((v) => v.u1 > panel.u0 && v.u0 < panel.u1 && v.y1 > panel.y0 && v.y0 < panel.y1);
|
||||||
|
if (!relevant.length) return [panel];
|
||||||
|
|
||||||
|
const cuts = new Set<number>([panel.u0, panel.u1]);
|
||||||
|
for (const v of relevant) {
|
||||||
|
if (v.u0 > panel.u0 && v.u0 < panel.u1) cuts.add(v.u0);
|
||||||
|
if (v.u1 > panel.u0 && v.u1 < panel.u1) cuts.add(v.u1);
|
||||||
|
}
|
||||||
|
const us = [...cuts].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const out: Rect[] = [];
|
||||||
|
for (let i = 0; i < us.length - 1; i++) {
|
||||||
|
const u0 = us[i], u1 = us[i + 1];
|
||||||
|
if (u1 - u0 < 1e-4) continue;
|
||||||
|
const mid = (u0 + u1) / 2;
|
||||||
|
const bands = relevant
|
||||||
|
.filter((v) => v.u0 <= mid && v.u1 >= mid)
|
||||||
|
.map((v) => [Math.max(panel.y0, v.y0), Math.min(panel.y1, v.y1)] as [number, number])
|
||||||
|
.sort((a, b) => a[0] - b[0]);
|
||||||
|
let y = panel.y0;
|
||||||
|
for (const [b0, b1] of bands) {
|
||||||
|
if (b0 > y) out.push({ u0, u1, y0: y, y1: b0 });
|
||||||
|
y = Math.max(y, b1);
|
||||||
|
}
|
||||||
|
if (y < panel.y1) out.push({ u0, u1, y0: y, y1: panel.y1 });
|
||||||
|
}
|
||||||
|
return out.filter((r) => r.u1 - r.u0 > 1e-4 && r.y1 - r.y0 > 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erzeugt die Quads einer Fassadenebene aus Rechtecken in (u, y). */
|
||||||
|
function faceGeometry(
|
||||||
|
rects: Rect[],
|
||||||
|
toWorld: (u: number, y: number) => [number, number, number],
|
||||||
|
flip: boolean,
|
||||||
|
tile: number,
|
||||||
|
): BufferGeometry {
|
||||||
|
const pos: number[] = [];
|
||||||
|
const uv: number[] = [];
|
||||||
|
for (const r of rects) {
|
||||||
|
const a = toWorld(r.u0, r.y0), b = toWorld(r.u1, r.y0);
|
||||||
|
const c = toWorld(r.u1, r.y1), d = toWorld(r.u0, r.y1);
|
||||||
|
const quad = flip ? [a, d, c, a, c, b] : [a, b, c, a, c, d];
|
||||||
|
const uvs = flip
|
||||||
|
? [[r.u0, r.y0], [r.u0, r.y1], [r.u1, r.y1], [r.u0, r.y0], [r.u1, r.y1], [r.u1, r.y0]]
|
||||||
|
: [[r.u0, r.y0], [r.u1, r.y0], [r.u1, r.y1], [r.u0, r.y0], [r.u1, r.y1], [r.u0, r.y1]];
|
||||||
|
for (const p of quad) pos.push(p[0], p[1], p[2]);
|
||||||
|
for (const [u, y] of uvs) uv.push(u / tile, y / tile);
|
||||||
|
}
|
||||||
|
const g = new BufferGeometry();
|
||||||
|
g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
|
||||||
|
g.setAttribute('uv', new BufferAttribute(new Float32Array(uv), 2));
|
||||||
|
g.computeVertexNormals();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function glassMaterial() {
|
||||||
|
return new MeshPhysicalMaterial({
|
||||||
|
color: 0x1d2a2e, roughness: 0.06, metalness: 0.1,
|
||||||
|
transmission: 0, opacity: 0.72, transparent: true,
|
||||||
|
reflectivity: 0.85, envMapIntensity: 1.2, side: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRAME = () => new MeshStandardMaterial({ color: 0x4a3f34, roughness: 0.45, metalness: 0.35 });
|
||||||
|
|
||||||
|
export function buildBuilding(b: Building): Group {
|
||||||
|
const group = new Group();
|
||||||
|
group.name = b.name;
|
||||||
|
|
||||||
|
const holz = tex.fassadeHolz();
|
||||||
|
const holzMat = new MeshStandardMaterial({ map: holz.map, roughness: holz.roughness, metalness: 0 });
|
||||||
|
const sockelTex = tex.sockel();
|
||||||
|
const sockelMat = new MeshStandardMaterial({ map: sockelTex.map, roughness: sockelTex.roughness });
|
||||||
|
const glasMat = glassMaterial();
|
||||||
|
const frameMat = FRAME();
|
||||||
|
|
||||||
|
const [x0, z0] = b.outline[0];
|
||||||
|
const [x1] = b.outline[1];
|
||||||
|
const z1 = b.outline[2][1];
|
||||||
|
const yTop = b.roofLevel;
|
||||||
|
const SOCKEL = 0.35; // heller Betonsockel unter der Holzschalung
|
||||||
|
|
||||||
|
/** Definition der vier Fassaden: u laeuft jeweils in Site-X bzw. Site-Z. */
|
||||||
|
const faces = {
|
||||||
|
north: { u0: x0, u1: x1, toWorld: (u: number, y: number) => [u, y, z0] as [number, number, number], flip: true },
|
||||||
|
south: { u0: x0, u1: x1, toWorld: (u: number, y: number) => [u, y, z1] as [number, number, number], flip: false },
|
||||||
|
west: { u0: z0, u1: z1, toWorld: (u: number, y: number) => [x0, y, u] as [number, number, number], flip: false },
|
||||||
|
east: { u0: z0, u1: z1, toWorld: (u: number, y: number) => [x1, y, u] as [number, number, number], flip: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aussparungen je Fassade: Fenster, Tueren und der offene Loggia-Bereich
|
||||||
|
const voidsFor = (face: Opening['face']): Rect[] => {
|
||||||
|
const list: Rect[] = b.openings
|
||||||
|
.filter((o) => o.face === face)
|
||||||
|
.map((o) => ({ u0: Math.min(o.a, o.b), u1: Math.max(o.a, o.b), y0: o.sill, y1: o.head }));
|
||||||
|
if (face === 'south') list.push({ u0: b.loggia.x0, u1: b.loggia.x1, y0: b.floorLevel - 0.4, y1: b.loggia.ceiling });
|
||||||
|
if (face === 'west') list.push({ u0: b.loggia.z0, u1: b.loggia.z1, y0: b.floorLevel - 0.4, y1: b.loggia.ceiling });
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
|
||||||
|
const holzParts: BufferGeometry[] = [];
|
||||||
|
const sockelParts: BufferGeometry[] = [];
|
||||||
|
for (const key of ['north', 'south', 'west', 'east'] as const) {
|
||||||
|
const f = faces[key];
|
||||||
|
const voids = voidsFor(key);
|
||||||
|
const sockelRects = subtract({ u0: f.u0, u1: f.u1, y0: b.floorLevel - 0.55, y1: b.floorLevel + SOCKEL }, voids);
|
||||||
|
const holzRects = subtract({ u0: f.u0, u1: f.u1, y0: b.floorLevel + SOCKEL, y1: yTop }, voids);
|
||||||
|
sockelParts.push(faceGeometry(sockelRects, f.toWorld, f.flip, sockelTex.tile));
|
||||||
|
holzParts.push(faceGeometry(holzRects, f.toWorld, f.flip, holz.tile));
|
||||||
|
}
|
||||||
|
const wallMesh = new Mesh(mergeGeometries(holzParts), holzMat);
|
||||||
|
wallMesh.name = 'Fassade';
|
||||||
|
wallMesh.castShadow = true;
|
||||||
|
wallMesh.receiveShadow = true;
|
||||||
|
group.add(wallMesh);
|
||||||
|
|
||||||
|
const sockelMesh = new Mesh(mergeGeometries(sockelParts), sockelMat);
|
||||||
|
sockelMesh.name = 'Sockel';
|
||||||
|
sockelMesh.castShadow = true;
|
||||||
|
sockelMesh.receiveShadow = true;
|
||||||
|
group.add(sockelMesh);
|
||||||
|
|
||||||
|
// --- Loggia: Innenwaende, Decke und Boden -------------------------------
|
||||||
|
const lg = b.loggia;
|
||||||
|
const loggia = new Group();
|
||||||
|
loggia.name = 'Gedeckter Sitzplatz';
|
||||||
|
const inner = new Mesh(
|
||||||
|
boxGeometry(lg.x1, (lg.z0 + lg.z1) / 2, 0.16, lg.z1 - lg.z0, b.floorLevel - 0.4, lg.ceiling, 0, holz.tile),
|
||||||
|
holzMat,
|
||||||
|
);
|
||||||
|
loggia.add(inner);
|
||||||
|
const back = new Mesh(
|
||||||
|
boxGeometry((lg.x0 + lg.x1) / 2, lg.z0, lg.x1 - lg.x0, 0.16, b.floorLevel - 0.4, lg.ceiling, 0, holz.tile),
|
||||||
|
holzMat,
|
||||||
|
);
|
||||||
|
loggia.add(back);
|
||||||
|
const ceil = new PlaneGeometry(lg.x1 - lg.x0, lg.z1 - lg.z0);
|
||||||
|
ceil.rotateX(Math.PI / 2);
|
||||||
|
const ceilMesh = new Mesh(ceil, new MeshStandardMaterial({ color: 0xd8d4cc, roughness: 0.85 }));
|
||||||
|
ceilMesh.position.set((lg.x0 + lg.x1) / 2, lg.ceiling, (lg.z0 + lg.z1) / 2);
|
||||||
|
loggia.add(ceilMesh);
|
||||||
|
loggia.children.forEach((c) => { (c as Mesh).castShadow = true; (c as Mesh).receiveShadow = true; });
|
||||||
|
group.add(loggia);
|
||||||
|
|
||||||
|
// --- Verglasungen und Rahmen -------------------------------------------
|
||||||
|
const glassGroup = new Group();
|
||||||
|
glassGroup.name = 'Verglasungen';
|
||||||
|
for (const o of b.openings) {
|
||||||
|
const f = faces[o.face];
|
||||||
|
const u0 = Math.min(o.a, o.b), u1 = Math.max(o.a, o.b);
|
||||||
|
const inset = 0.10;
|
||||||
|
const mid = (p: [number, number, number]): [number, number, number] => {
|
||||||
|
const n: [number, number, number] = [...p];
|
||||||
|
if (o.face === 'north') n[2] += inset;
|
||||||
|
if (o.face === 'south') n[2] -= inset;
|
||||||
|
if (o.face === 'west') n[0] += inset;
|
||||||
|
if (o.face === 'east') n[0] -= inset;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const rect: Rect = { u0, u1, y0: o.sill, y1: o.head };
|
||||||
|
const paneMat = o.kind === 'door'
|
||||||
|
? new MeshStandardMaterial({ color: 0x6b4a30, roughness: 0.55 })
|
||||||
|
: o.kind === 'slats'
|
||||||
|
? new MeshStandardMaterial({ color: 0x24211d, roughness: 0.7 })
|
||||||
|
: glasMat;
|
||||||
|
const pane = new Mesh(faceGeometry([rect], (u, y) => mid(f.toWorld(u, y)), f.flip, 1), paneMat);
|
||||||
|
pane.name = `Öffnung ${o.face} ${u0.toFixed(1)}`;
|
||||||
|
glassGroup.add(pane);
|
||||||
|
|
||||||
|
// Laibungsrahmen
|
||||||
|
const border = 0.07;
|
||||||
|
const frameRects: Rect[] = [
|
||||||
|
{ u0, u1, y0: o.sill - border, y1: o.sill },
|
||||||
|
{ u0, u1, y0: o.head, y1: o.head + border },
|
||||||
|
{ u0: u0 - border, u1: u0, y0: o.sill - border, y1: o.head + border },
|
||||||
|
{ u0: u1, u1: u1 + border, y0: o.sill - border, y1: o.head + border },
|
||||||
|
];
|
||||||
|
const frame = new Mesh(faceGeometry(frameRects, (u, y) => mid(f.toWorld(u, y)), f.flip, 1), frameMat);
|
||||||
|
glassGroup.add(frame);
|
||||||
|
}
|
||||||
|
group.add(glassGroup);
|
||||||
|
|
||||||
|
// --- Flachdach mit Attika und Begruenung --------------------------------
|
||||||
|
const roof = new Group();
|
||||||
|
roof.name = 'Dach';
|
||||||
|
const gruen = tex.gruendach();
|
||||||
|
const roofPlane = new PlaneGeometry(x1 - x0 - 0.3, z1 - z0 - 0.3);
|
||||||
|
roofPlane.rotateX(-Math.PI / 2);
|
||||||
|
const ru = roofPlane.getAttribute('uv') as BufferAttribute;
|
||||||
|
for (let i = 0; i < ru.count; i++) ru.setXY(i, ru.getX(i) * ((x1 - x0) / gruen.tile), ru.getY(i) * ((z1 - z0) / gruen.tile));
|
||||||
|
const roofMesh = new Mesh(roofPlane, new MeshStandardMaterial({ map: gruen.map, roughness: 1 }));
|
||||||
|
roofMesh.position.set((x0 + x1) / 2, yTop - 0.05, (z0 + z1) / 2);
|
||||||
|
roofMesh.receiveShadow = true;
|
||||||
|
roof.add(roofMesh);
|
||||||
|
|
||||||
|
const attikaMat = new MeshStandardMaterial({ color: 0x8b8377, roughness: 0.7, metalness: 0.15 });
|
||||||
|
const attikaPath: Vec2[] = [[x0, z0], [x1, z0], [x1, z1], [x0, z1]];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const a = attikaPath[i], c = attikaPath[(i + 1) % 4];
|
||||||
|
const cx = (a[0] + c[0]) / 2, cz = (a[1] + c[1]) / 2;
|
||||||
|
const w = Math.abs(c[0] - a[0]) || 0.16;
|
||||||
|
const d = Math.abs(c[1] - a[1]) || 0.16;
|
||||||
|
const m = new Mesh(boxGeometry(cx, cz, w + 0.16, d + 0.16, yTop, yTop + b.parapet, 0, 2), attikaMat);
|
||||||
|
m.castShadow = true;
|
||||||
|
roof.add(m);
|
||||||
|
}
|
||||||
|
group.add(roof);
|
||||||
|
|
||||||
|
// --- Vordach ueber dem Eingang -----------------------------------------
|
||||||
|
const cp = b.canopy;
|
||||||
|
const canopy = new Mesh(
|
||||||
|
boxGeometry((cp.a + cp.b) / 2, z0 - cp.depth / 2, cp.b - cp.a, cp.depth, cp.y, cp.y + cp.thickness, 0, 2),
|
||||||
|
new MeshStandardMaterial({ color: 0x5a5148, roughness: 0.6 }),
|
||||||
|
);
|
||||||
|
canopy.name = 'Vordach Eingang';
|
||||||
|
canopy.castShadow = true;
|
||||||
|
group.add(canopy);
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNeighbourBuildings(list: NeighbourBuilding[]): Group {
|
||||||
|
const group = new Group();
|
||||||
|
group.name = 'Nachbargebäude';
|
||||||
|
const holz = tex.fassadeHolz();
|
||||||
|
const mat = new MeshStandardMaterial({ map: holz.map, roughness: holz.roughness });
|
||||||
|
const attika = new MeshStandardMaterial({ color: 0x8b8377, roughness: 0.7 });
|
||||||
|
|
||||||
|
for (const b of list) {
|
||||||
|
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
|
||||||
|
for (const [x, z] of b.outline) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); z0 = Math.min(z0, z); z1 = Math.max(z1, z); }
|
||||||
|
const body = new Mesh(
|
||||||
|
boxGeometry((x0 + x1) / 2, (z0 + z1) / 2, x1 - x0, z1 - z0, b.floorLevel - 0.8, b.roofLevel, 0, holz.tile),
|
||||||
|
mat,
|
||||||
|
);
|
||||||
|
body.name = b.name;
|
||||||
|
body.castShadow = true;
|
||||||
|
body.receiveShadow = true;
|
||||||
|
group.add(body);
|
||||||
|
const cap = new Mesh(
|
||||||
|
boxGeometry((x0 + x1) / 2, (z0 + z1) / 2, x1 - x0 + 0.2, z1 - z0 + 0.2, b.roofLevel, b.roofLevel + 0.3, 0, 2),
|
||||||
|
attika,
|
||||||
|
);
|
||||||
|
cap.castShadow = true;
|
||||||
|
group.add(cap);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* Himmel, Sonnenstand und Beleuchtung.
|
||||||
|
*
|
||||||
|
* Der Sonnenstand wird fuer den tatsaechlichen Standort gerechnet
|
||||||
|
* (Abtwil SG, 47.42 N / 9.29 O) und in den Site-Frame gedreht, dessen
|
||||||
|
* X-Achse mit 61.16 Grad gegen Nord verlaeuft.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
AmbientLight, BackSide, Color, DirectionalLight, Fog, Group, HemisphereLight,
|
||||||
|
Mesh, ShaderMaterial, SphereGeometry, Scene, Vector3,
|
||||||
|
} from 'three';
|
||||||
|
|
||||||
|
export interface SunState {
|
||||||
|
/** Hoehenwinkel in Grad, negativ wenn die Sonne unter dem Horizont steht. */
|
||||||
|
altitude: number;
|
||||||
|
/** Azimut in Grad, von Nord im Uhrzeigersinn. */
|
||||||
|
azimuth: number;
|
||||||
|
/** Richtung zur Sonne im Site-Frame. */
|
||||||
|
direction: Vector3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sonnenstand nach dem vereinfachten NOAA-Verfahren.
|
||||||
|
* `date` als Tag im Jahr (1..365), `hours` als lokale Uhrzeit in Stunden.
|
||||||
|
*/
|
||||||
|
export function sunPosition(
|
||||||
|
dayOfYear: number, hours: number, lat: number, lon: number, utcOffset: number, northBearingOfSiteX: number,
|
||||||
|
): SunState {
|
||||||
|
const gamma = ((2 * Math.PI) / 365) * (dayOfYear - 1 + (hours - 12) / 24);
|
||||||
|
const eqTime = 229.18 * (
|
||||||
|
0.000075 + 0.001868 * Math.cos(gamma) - 0.032077 * Math.sin(gamma) -
|
||||||
|
0.014615 * Math.cos(2 * gamma) - 0.040849 * Math.sin(2 * gamma)
|
||||||
|
);
|
||||||
|
const decl =
|
||||||
|
0.006918 - 0.399912 * Math.cos(gamma) + 0.070257 * Math.sin(gamma) -
|
||||||
|
0.006758 * Math.cos(2 * gamma) + 0.000907 * Math.sin(2 * gamma) -
|
||||||
|
0.002697 * Math.cos(3 * gamma) + 0.00148 * Math.sin(3 * gamma);
|
||||||
|
|
||||||
|
const timeOffset = eqTime + 4 * lon - 60 * utcOffset;
|
||||||
|
const trueSolarTime = (hours * 60 + timeOffset + 1440) % 1440;
|
||||||
|
const hourAngle = (trueSolarTime / 4 - 180) * RAD;
|
||||||
|
const latR = lat * RAD;
|
||||||
|
|
||||||
|
const cosZenith =
|
||||||
|
Math.sin(latR) * Math.sin(decl) + Math.cos(latR) * Math.cos(decl) * Math.cos(hourAngle);
|
||||||
|
const zenith = Math.acos(Math.max(-1, Math.min(1, cosZenith)));
|
||||||
|
const altitude = 90 - zenith / RAD;
|
||||||
|
|
||||||
|
let azimuth = Math.atan2(
|
||||||
|
-Math.sin(hourAngle),
|
||||||
|
Math.tan(decl) * Math.cos(latR) - Math.sin(latR) * Math.cos(hourAngle),
|
||||||
|
) / RAD;
|
||||||
|
azimuth = (azimuth + 360) % 360;
|
||||||
|
|
||||||
|
const altR = altitude * RAD;
|
||||||
|
const bx = (azimuth - northBearingOfSiteX) * RAD;
|
||||||
|
const bz = (azimuth - (northBearingOfSiteX + 90)) * RAD;
|
||||||
|
const direction = new Vector3(
|
||||||
|
Math.cos(altR) * Math.cos(bx),
|
||||||
|
Math.sin(altR),
|
||||||
|
Math.cos(altR) * Math.cos(bz),
|
||||||
|
).normalize();
|
||||||
|
|
||||||
|
return { altitude, azimuth, direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKY_VERT = /* glsl */ `
|
||||||
|
varying vec3 vWorld;
|
||||||
|
void main() {
|
||||||
|
vWorld = (modelMatrix * vec4(position, 1.0)).xyz;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SKY_FRAG = /* glsl */ `
|
||||||
|
uniform vec3 uZenith;
|
||||||
|
uniform vec3 uHorizon;
|
||||||
|
uniform vec3 uGround;
|
||||||
|
uniform vec3 uSun;
|
||||||
|
uniform float uSunIntensity;
|
||||||
|
varying vec3 vWorld;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 dir = normalize(vWorld);
|
||||||
|
float h = dir.y;
|
||||||
|
vec3 sky = mix(uHorizon, uZenith, pow(clamp(h, 0.0, 1.0), 0.55));
|
||||||
|
sky = mix(uGround, sky, smoothstep(-0.10, 0.02, h));
|
||||||
|
float d = max(dot(dir, normalize(uSun)), 0.0);
|
||||||
|
sky += uSunIntensity * (pow(d, 900.0) * 6.0 + pow(d, 12.0) * 0.30 + pow(d, 3.0) * 0.06);
|
||||||
|
gl_FragColor = vec4(sky, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface Environment {
|
||||||
|
group: Group;
|
||||||
|
sun: DirectionalLight;
|
||||||
|
/** Setzt Tageszeit und Jahreszeit; liefert den berechneten Sonnenstand. */
|
||||||
|
update(dayOfYear: number, hours: number): SunState;
|
||||||
|
/** true, sobald die Gartenbeleuchtung eingeschaltet werden soll. */
|
||||||
|
isDark(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEnvironment(scene: Scene, opts: {
|
||||||
|
lat: number; lon: number; northBearingOfSiteX: number; center: [number, number]; radius: number;
|
||||||
|
}): Environment {
|
||||||
|
const group = new Group();
|
||||||
|
group.name = 'Umgebung';
|
||||||
|
|
||||||
|
const skyMat = new ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
uZenith: { value: new Color(0x2f6fc4) },
|
||||||
|
uHorizon: { value: new Color(0xbcd6ea) },
|
||||||
|
uGround: { value: new Color(0x6f7a63) },
|
||||||
|
uSun: { value: new Vector3(0, 1, 0) },
|
||||||
|
uSunIntensity: { value: 1 },
|
||||||
|
},
|
||||||
|
vertexShader: SKY_VERT,
|
||||||
|
fragmentShader: SKY_FRAG,
|
||||||
|
side: BackSide,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const sky = new Mesh(new SphereGeometry(900, 32, 20), skyMat);
|
||||||
|
sky.name = 'Himmel';
|
||||||
|
group.add(sky);
|
||||||
|
|
||||||
|
const sun = new DirectionalLight(0xfff3e0, 2.6);
|
||||||
|
sun.castShadow = true;
|
||||||
|
sun.shadow.mapSize.set(2048, 2048);
|
||||||
|
const r = opts.radius;
|
||||||
|
const cam = sun.shadow.camera;
|
||||||
|
cam.left = -r; cam.right = r; cam.top = r; cam.bottom = -r;
|
||||||
|
cam.near = 1; cam.far = r * 6;
|
||||||
|
sun.shadow.bias = -0.0006;
|
||||||
|
sun.shadow.normalBias = 0.03;
|
||||||
|
sun.target.position.set(opts.center[0], 0, opts.center[1]);
|
||||||
|
group.add(sun, sun.target);
|
||||||
|
|
||||||
|
const hemi = new HemisphereLight(0xcfe3f5, 0x6b6a52, 1.0);
|
||||||
|
group.add(hemi);
|
||||||
|
const ambient = new AmbientLight(0xffffff, 0.30);
|
||||||
|
group.add(ambient);
|
||||||
|
|
||||||
|
scene.fog = new Fog(0xc4d6e6, 90, 620);
|
||||||
|
|
||||||
|
let dark = false;
|
||||||
|
|
||||||
|
function update(dayOfYear: number, hours: number): SunState {
|
||||||
|
// Sommerzeit grob: Ende Maerz bis Ende Oktober
|
||||||
|
const utcOffset = dayOfYear > 86 && dayOfYear < 303 ? 2 : 1;
|
||||||
|
const s = sunPosition(dayOfYear, hours, opts.lat, opts.lon, utcOffset, opts.northBearingOfSiteX);
|
||||||
|
|
||||||
|
const dist = r * 3;
|
||||||
|
sun.position.set(
|
||||||
|
opts.center[0] + s.direction.x * dist,
|
||||||
|
Math.max(2, s.direction.y * dist),
|
||||||
|
opts.center[1] + s.direction.z * dist,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tageslichtkurve: 0 bei Sonnenuntergang, 1 im Zenit
|
||||||
|
const day = Math.max(0, Math.min(1, (s.altitude + 4) / 34));
|
||||||
|
const dusk = Math.max(0, Math.min(1, (s.altitude + 8) / 14));
|
||||||
|
dark = s.altitude < 2;
|
||||||
|
|
||||||
|
const warm = 1 - dusk;
|
||||||
|
sun.intensity = 3.1 * day;
|
||||||
|
sun.color.setRGB(1, 0.95 - warm * 0.24, 0.86 - warm * 0.44);
|
||||||
|
sun.visible = s.altitude > -3;
|
||||||
|
|
||||||
|
hemi.intensity = 0.28 + 0.95 * dusk;
|
||||||
|
ambient.intensity = 0.10 + 0.30 * dusk;
|
||||||
|
|
||||||
|
const zen = new Color().setHSL(0.60, 0.62 - 0.2 * warm, 0.12 + 0.42 * dusk);
|
||||||
|
const hor = new Color().setHSL(0.58 - 0.10 * warm, 0.45 + 0.25 * warm, 0.18 + 0.66 * dusk);
|
||||||
|
skyMat.uniforms.uZenith.value.copy(zen);
|
||||||
|
skyMat.uniforms.uHorizon.value.copy(hor);
|
||||||
|
skyMat.uniforms.uGround.value.setHSL(0.25, 0.16, 0.06 + 0.26 * dusk);
|
||||||
|
skyMat.uniforms.uSun.value.copy(s.direction);
|
||||||
|
skyMat.uniforms.uSunIntensity.value = Math.max(0, dusk);
|
||||||
|
|
||||||
|
(scene.fog as Fog).color.copy(hor);
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { group, sun, update, isDark: () => dark };
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Geometrie-Bausteine: Polygone auf das Gelaende legen, Mauern extrudieren.
|
||||||
|
*/
|
||||||
|
import { BufferGeometry, BufferAttribute, ShapeUtils, Vector2 } from 'three';
|
||||||
|
import type { Vec2 } from '../site';
|
||||||
|
|
||||||
|
type Tri = [Vec2, Vec2, Vec2];
|
||||||
|
|
||||||
|
/** Zerlegt ein einfaches Polygon in Dreiecke (Ohrenschneiden aus three.js). */
|
||||||
|
export function triangulate(polygon: Vec2[]): Tri[] {
|
||||||
|
const contour = polygon.map(([x, z]) => new Vector2(x, z));
|
||||||
|
const faces = ShapeUtils.triangulateShape(contour, []);
|
||||||
|
return faces.map(([a, b, c]) => [polygon[a], polygon[b], polygon[c]] as Tri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verfeinert Dreiecke, bis keine Kante laenger als `maxEdge` ist. Damit folgen
|
||||||
|
* grosse Flaechen dem Gelaende, statt es zu ueberspannen.
|
||||||
|
*/
|
||||||
|
export function refine(tris: Tri[], maxEdge: number, maxDepth = 10): Tri[] {
|
||||||
|
let current = tris;
|
||||||
|
for (let depth = 0; depth < maxDepth; depth++) {
|
||||||
|
const next: Tri[] = [];
|
||||||
|
let split = false;
|
||||||
|
for (const t of current) {
|
||||||
|
const lens = [0, 1, 2].map((i) => {
|
||||||
|
const a = t[i], b = t[(i + 1) % 3];
|
||||||
|
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||||
|
});
|
||||||
|
const longest = lens.indexOf(Math.max(...lens));
|
||||||
|
if (lens[longest] <= maxEdge) { next.push(t); continue; }
|
||||||
|
split = true;
|
||||||
|
const a = t[longest], b = t[(longest + 1) % 3], c = t[(longest + 2) % 3];
|
||||||
|
const m: Vec2 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||||
|
next.push([a, m, c], [m, b, c]);
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
if (!split) break;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut ein Flaechenmesh, das dem Gelaende folgt.
|
||||||
|
* UVs sind weltbezogen, damit Plattenraster ueber Flaechengrenzen durchlaufen.
|
||||||
|
*/
|
||||||
|
export function surfaceGeometry(
|
||||||
|
polygon: Vec2[],
|
||||||
|
height: (x: number, z: number) => number,
|
||||||
|
opts: { offset?: number; tile?: number; maxEdge?: number } = {},
|
||||||
|
): BufferGeometry {
|
||||||
|
const { offset = 0.01, tile = 1, maxEdge = 0.3 } = opts;
|
||||||
|
const tris = refine(triangulate(polygon), maxEdge);
|
||||||
|
const pos = new Float32Array(tris.length * 9);
|
||||||
|
const uv = new Float32Array(tris.length * 6);
|
||||||
|
let p = 0, u = 0;
|
||||||
|
for (const t of tris) {
|
||||||
|
for (const [x, z] of t) {
|
||||||
|
pos[p++] = x;
|
||||||
|
pos[p++] = height(x, z) + offset;
|
||||||
|
pos[p++] = z;
|
||||||
|
uv[u++] = x / tile;
|
||||||
|
uv[u++] = -z / tile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const g = new BufferGeometry();
|
||||||
|
g.setAttribute('position', new BufferAttribute(pos, 3));
|
||||||
|
g.setAttribute('uv', new BufferAttribute(uv, 2));
|
||||||
|
g.computeVertexNormals();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fasst mehrere Polygone zu einem Mesh zusammen. */
|
||||||
|
export function surfaceGeometryMulti(
|
||||||
|
polygons: Vec2[][],
|
||||||
|
height: (x: number, z: number) => number,
|
||||||
|
opts: { offset?: number; tile?: number; maxEdge?: number } = {},
|
||||||
|
): BufferGeometry {
|
||||||
|
const parts = polygons.map((p) => surfaceGeometry(p, height, opts));
|
||||||
|
return mergeGeometries(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einfaches Zusammenfuehren gleichartiger Geometrien (position/uv/normal). */
|
||||||
|
export function mergeGeometries(parts: BufferGeometry[]): BufferGeometry {
|
||||||
|
const total = parts.reduce((n, g) => n + g.getAttribute('position').count, 0);
|
||||||
|
const pos = new Float32Array(total * 3);
|
||||||
|
const nor = new Float32Array(total * 3);
|
||||||
|
const uv = new Float32Array(total * 2);
|
||||||
|
let o = 0;
|
||||||
|
for (const g of parts) {
|
||||||
|
const gp = g.getAttribute('position') as BufferAttribute;
|
||||||
|
const gn = g.getAttribute('normal') as BufferAttribute;
|
||||||
|
const gu = g.getAttribute('uv') as BufferAttribute;
|
||||||
|
pos.set(gp.array as Float32Array, o * 3);
|
||||||
|
nor.set(gn.array as Float32Array, o * 3);
|
||||||
|
uv.set(gu.array as Float32Array, o * 2);
|
||||||
|
o += gp.count;
|
||||||
|
g.dispose();
|
||||||
|
}
|
||||||
|
const g = new BufferGeometry();
|
||||||
|
g.setAttribute('position', new BufferAttribute(pos, 3));
|
||||||
|
g.setAttribute('normal', new BufferAttribute(nor, 3));
|
||||||
|
g.setAttribute('uv', new BufferAttribute(uv, 2));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Quad { a: [number, number, number]; b: [number, number, number]; c: [number, number, number]; d: [number, number, number] }
|
||||||
|
|
||||||
|
/** Haengt ein Viereck (gegen den Uhrzeigersinn) an die Puffer an. */
|
||||||
|
function pushQuad(pos: number[], uv: number[], q: Quad, uScale: number, vScale: number, uOff = 0) {
|
||||||
|
const [ax, ay, az] = q.a, [bx, by, bz] = q.b, [cx, cy, cz] = q.c, [dx, dy, dz] = q.d;
|
||||||
|
const du = Math.hypot(bx - ax, bz - az) * uScale;
|
||||||
|
const v0 = ay * vScale, v1 = cy * vScale, v2 = dy * vScale, v3 = by * vScale;
|
||||||
|
pos.push(ax, ay, az, bx, by, bz, cx, cy, cz);
|
||||||
|
uv.push(uOff, v0, uOff + du, v3, uOff + du, v1);
|
||||||
|
pos.push(ax, ay, az, cx, cy, cz, dx, dy, dz);
|
||||||
|
uv.push(uOff, v0, uOff + du, v1, uOff, v2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrudiert eine Mauer entlang einer Mittellinie.
|
||||||
|
* `top`/`base` liefern fuer einen Parameter t (0..1) die Kronen- bzw. Fusshoehe.
|
||||||
|
*/
|
||||||
|
export function wallGeometry(
|
||||||
|
path: Vec2[],
|
||||||
|
thickness: number,
|
||||||
|
top: (t: number, x: number, z: number) => number,
|
||||||
|
base: (t: number, x: number, z: number) => number,
|
||||||
|
opts: { segments?: number; tile?: number } = {},
|
||||||
|
): BufferGeometry {
|
||||||
|
const { segments = 24, tile = 1.6 } = opts;
|
||||||
|
const pos: number[] = [];
|
||||||
|
const uv: number[] = [];
|
||||||
|
const h = thickness / 2;
|
||||||
|
const s = 1 / tile;
|
||||||
|
|
||||||
|
// Laenge und Richtungen entlang des Pfades
|
||||||
|
const pts: { x: number; z: number; nx: number; nz: number; t: number }[] = [];
|
||||||
|
const totalLen = path.slice(1).reduce((L, p, i) => L + Math.hypot(p[0] - path[i][0], p[1] - path[i][1]), 0);
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const t = i / segments;
|
||||||
|
// Punkt auf dem Polyzug bei Bogenlaenge t * totalLen
|
||||||
|
let want = t * totalLen;
|
||||||
|
let x = path[0][0], z = path[0][1], dx = 1, dz = 0;
|
||||||
|
for (let k = 1; k < path.length; k++) {
|
||||||
|
const ax = path[k - 1][0], az = path[k - 1][1];
|
||||||
|
const bx = path[k][0], bz = path[k][1];
|
||||||
|
const L = Math.hypot(bx - ax, bz - az);
|
||||||
|
if (want <= L || k === path.length - 1) {
|
||||||
|
const u = L > 0 ? Math.min(1, want / L) : 0;
|
||||||
|
x = ax + (bx - ax) * u; z = az + (bz - az) * u;
|
||||||
|
dx = (bx - ax) / (L || 1); dz = (bz - az) / (L || 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
want -= L;
|
||||||
|
}
|
||||||
|
pts.push({ x, z, nx: -dz, nz: dx, t });
|
||||||
|
}
|
||||||
|
|
||||||
|
let uRun = 0;
|
||||||
|
for (let i = 0; i < segments; i++) {
|
||||||
|
const p0 = pts[i], p1 = pts[i + 1];
|
||||||
|
const t0 = top(p0.t, p0.x, p0.z), t1 = top(p1.t, p1.x, p1.z);
|
||||||
|
const b0 = base(p0.t, p0.x, p0.z), b1 = base(p1.t, p1.x, p1.z);
|
||||||
|
const segLen = Math.hypot(p1.x - p0.x, p1.z - p0.z);
|
||||||
|
|
||||||
|
const l0: Vec2 = [p0.x + p0.nx * h, p0.z + p0.nz * h];
|
||||||
|
const r0: Vec2 = [p0.x - p0.nx * h, p0.z - p0.nz * h];
|
||||||
|
const l1: Vec2 = [p1.x + p1.nx * h, p1.z + p1.nz * h];
|
||||||
|
const r1: Vec2 = [p1.x - p1.nx * h, p1.z - p1.nz * h];
|
||||||
|
|
||||||
|
// linke Flanke
|
||||||
|
pushQuad(pos, uv, {
|
||||||
|
a: [l0[0], b0, l0[1]], b: [l1[0], b1, l1[1]],
|
||||||
|
c: [l1[0], t1, l1[1]], d: [l0[0], t0, l0[1]],
|
||||||
|
}, s, s, uRun * s);
|
||||||
|
// rechte Flanke
|
||||||
|
pushQuad(pos, uv, {
|
||||||
|
a: [r1[0], b1, r1[1]], b: [r0[0], b0, r0[1]],
|
||||||
|
c: [r0[0], t0, r0[1]], d: [r1[0], t1, r1[1]],
|
||||||
|
}, s, s, uRun * s);
|
||||||
|
// Krone
|
||||||
|
pos.push(l0[0], t0, l0[1], r0[0], t0, r0[1], r1[0], t1, r1[1]);
|
||||||
|
uv.push(uRun * s, 0, uRun * s, thickness * s, (uRun + segLen) * s, thickness * s);
|
||||||
|
pos.push(l0[0], t0, l0[1], r1[0], t1, r1[1], l1[0], t1, l1[1]);
|
||||||
|
uv.push(uRun * s, 0, (uRun + segLen) * s, thickness * s, (uRun + segLen) * s, 0);
|
||||||
|
uRun += segLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stirnseiten
|
||||||
|
for (const [p, sign] of [[pts[0], -1], [pts[pts.length - 1], 1]] as const) {
|
||||||
|
const tp = top(p.t, p.x, p.z), bs = base(p.t, p.x, p.z);
|
||||||
|
const l: Vec2 = [p.x + p.nx * h, p.z + p.nz * h];
|
||||||
|
const r: Vec2 = [p.x - p.nx * h, p.z - p.nz * h];
|
||||||
|
const A = sign < 0 ? l : r, B = sign < 0 ? r : l;
|
||||||
|
pushQuad(pos, uv, {
|
||||||
|
a: [A[0], bs, A[1]], b: [B[0], bs, B[1]],
|
||||||
|
c: [B[0], tp, B[1]], d: [A[0], tp, A[1]],
|
||||||
|
}, s, s, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = new BufferGeometry();
|
||||||
|
g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
|
||||||
|
g.setAttribute('uv', new BufferAttribute(new Float32Array(uv), 2));
|
||||||
|
g.computeVertexNormals();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quaderfoermiges Volumen mit weltbezogenen UVs, fuer Bauteile und Moebel. */
|
||||||
|
export function boxGeometry(
|
||||||
|
cx: number, cz: number, w: number, d: number, y0: number, y1: number, rotDeg = 0, tile = 1,
|
||||||
|
): BufferGeometry {
|
||||||
|
const pos: number[] = [];
|
||||||
|
const uv: number[] = [];
|
||||||
|
const a = (rotDeg * Math.PI) / 180;
|
||||||
|
const ca = Math.cos(a), sa = Math.sin(a);
|
||||||
|
const P = (u: number, v: number, y: number): [number, number, number] =>
|
||||||
|
[cx + u * ca - v * sa, y, cz + u * sa + v * ca];
|
||||||
|
const hw = w / 2, hd = d / 2;
|
||||||
|
const s = 1 / tile;
|
||||||
|
const faces: [Vec2, Vec2, number][] = [
|
||||||
|
[[-hw, -hd], [hw, -hd], 1], [[hw, -hd], [hw, hd], 1],
|
||||||
|
[[hw, hd], [-hw, hd], 1], [[-hw, hd], [-hw, -hd], 1],
|
||||||
|
];
|
||||||
|
let uRun = 0;
|
||||||
|
for (const [p0, p1] of faces) {
|
||||||
|
const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
|
||||||
|
const A = P(p0[0], p0[1], y0), B = P(p1[0], p1[1], y0);
|
||||||
|
const C = P(p1[0], p1[1], y1), D = P(p0[0], p0[1], y1);
|
||||||
|
pos.push(...A, ...B, ...C, ...A, ...C, ...D);
|
||||||
|
uv.push(uRun * s, y0 * s, (uRun + len) * s, y0 * s, (uRun + len) * s, y1 * s);
|
||||||
|
uv.push(uRun * s, y0 * s, (uRun + len) * s, y1 * s, uRun * s, y1 * s);
|
||||||
|
uRun += len;
|
||||||
|
}
|
||||||
|
// Deckel
|
||||||
|
const t0 = P(-hw, -hd, y1), t1 = P(hw, -hd, y1), t2 = P(hw, hd, y1), t3 = P(-hw, hd, y1);
|
||||||
|
pos.push(...t0, ...t1, ...t2, ...t0, ...t2, ...t3);
|
||||||
|
uv.push(0, 0, w * s, 0, w * s, d * s, 0, 0, w * s, d * s, 0, d * s);
|
||||||
|
const g = new BufferGeometry();
|
||||||
|
g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
|
||||||
|
g.setAttribute('uv', new BufferAttribute(new Float32Array(uv), 2));
|
||||||
|
g.computeVertexNormals();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* Bauteile: Stuetz- und Sitzmauern, Travertinstelen, Gartenhaus, Spielhaus,
|
||||||
|
* Flammengrill, Baenkli, Leuchten.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
BoxGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial, PointLight,
|
||||||
|
type MeshStandardMaterialParameters,
|
||||||
|
} from 'three';
|
||||||
|
import type { Lamp, Prop, Stele, Wall } from '../site';
|
||||||
|
import { boxGeometry, wallGeometry } from './geometry';
|
||||||
|
import * as tex from './textures';
|
||||||
|
|
||||||
|
function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {}) {
|
||||||
|
return new MeshStandardMaterial({
|
||||||
|
map: belag.map, roughness: belag.roughness, metalness: 0, ...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const WALL_LOOK: Record<Wall['type'], () => tex.Belag> = {
|
||||||
|
travertin: tex.travertinMauer,
|
||||||
|
beton: tex.beton,
|
||||||
|
holzscheiter: tex.holzscheiter,
|
||||||
|
lamellen: tex.fassadeHolz,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wertet ein Hoehenprofil (Stuetzelemente) an der Stelle t aus. */
|
||||||
|
function profileAt(profile: number[] | undefined, t: number, fallback: number): number {
|
||||||
|
if (!profile || !profile.length) return fallback;
|
||||||
|
const f = t * (profile.length - 1);
|
||||||
|
const i = Math.max(0, Math.min(profile.length - 2, Math.floor(f)));
|
||||||
|
const u = f - i;
|
||||||
|
return profile[i] * (1 - u) + profile[i + 1] * u;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWalls(walls: Wall[], height: (x: number, z: number) => number): Group {
|
||||||
|
const group = new Group();
|
||||||
|
group.name = 'Mauern und Wände';
|
||||||
|
|
||||||
|
for (const w of walls) {
|
||||||
|
const belag = WALL_LOOK[w.type]();
|
||||||
|
const top = (t: number, x: number, z: number) =>
|
||||||
|
w.crestProfile ? profileAt(w.crestProfile, t, 0) : (w.top ?? height(x, z) + 0.4);
|
||||||
|
const base = (t: number, x: number, z: number) =>
|
||||||
|
w.footProfile ? profileAt(w.footProfile, t, -1) : (w.base ?? height(x, z) - 0.5);
|
||||||
|
|
||||||
|
if (w.openSlats) {
|
||||||
|
// Lamellenwand: senkrechte Bretter im Plan-Rhythmus (10 cm breit,
|
||||||
|
// 35 cm tief, rund 90 cm Achsabstand), dazu das schmale Randband.
|
||||||
|
const slats = new Group();
|
||||||
|
const [a, b] = [w.path[0], w.path[w.path.length - 1]];
|
||||||
|
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||||
|
const n = Math.max(2, Math.round(len / 0.9));
|
||||||
|
const mat = material(belag, { roughness: 0.85 });
|
||||||
|
const dirX = (b[0] - a[0]) / len, dirZ = (b[1] - a[1]) / len;
|
||||||
|
const angle = (Math.atan2(dirZ, dirX) * 180) / Math.PI;
|
||||||
|
for (let i = 0; i <= n; i++) {
|
||||||
|
const t = i / n;
|
||||||
|
const x = a[0] + (b[0] - a[0]) * t;
|
||||||
|
const z = a[1] + (b[1] - a[1]) * t;
|
||||||
|
const g = boxGeometry(x, z, 0.10, w.t, base(t, x, z), top(t, x, z), angle, 1.2);
|
||||||
|
const m = new Mesh(g, mat);
|
||||||
|
m.castShadow = true;
|
||||||
|
m.receiveShadow = true;
|
||||||
|
slats.add(m);
|
||||||
|
}
|
||||||
|
// durchlaufendes Randband an der Fassadenkante
|
||||||
|
const band = new Mesh(
|
||||||
|
wallGeometry(w.path, 0.10, top, base, { segments: 6, tile: belag.tile }),
|
||||||
|
mat,
|
||||||
|
);
|
||||||
|
band.castShadow = true;
|
||||||
|
band.receiveShadow = true;
|
||||||
|
slats.add(band);
|
||||||
|
slats.name = w.id;
|
||||||
|
slats.userData.note = w.note;
|
||||||
|
group.add(slats);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = wallGeometry(w.path, w.t, top, base, {
|
||||||
|
segments: w.crestProfile ? w.crestProfile.length - 1 : 16,
|
||||||
|
tile: belag.tile,
|
||||||
|
});
|
||||||
|
const mesh = new Mesh(geo, material(belag));
|
||||||
|
mesh.name = w.id;
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
mesh.userData.note = w.note;
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStelen(stelen: Stele[], height: (x: number, z: number) => number): Group {
|
||||||
|
const group = new Group();
|
||||||
|
group.name = 'Travertinstelen';
|
||||||
|
const mat = material(tex.travertinMauer(), { roughness: 0.68 });
|
||||||
|
for (const s of stelen) {
|
||||||
|
const y0 = height(s.x, s.z) - 0.3;
|
||||||
|
const geo = boxGeometry(s.x, s.z, 0.22, 0.22, y0, y0 + 0.3 + s.h, 0, 1.6);
|
||||||
|
const m = new Mesh(geo, mat);
|
||||||
|
m.castShadow = true;
|
||||||
|
m.receiveShadow = true;
|
||||||
|
group.add(m);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Freistehende Objekte
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function gartenhaus(p: Prop, y: number): Group {
|
||||||
|
const g = new Group();
|
||||||
|
const holz = material(tex.fassadeHolz(), { roughness: 0.8 });
|
||||||
|
const body = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, y, y + p.h, p.rot, 1.2), holz);
|
||||||
|
body.castShadow = true; body.receiveShadow = true;
|
||||||
|
g.add(body);
|
||||||
|
const roof = new Mesh(
|
||||||
|
boxGeometry(p.x, p.z, p.w + 0.16, p.d + 0.16, y + p.h, y + p.h + 0.09, p.rot, 2),
|
||||||
|
material(tex.beton(), { color: 0x6a6a66, roughness: 0.9 }),
|
||||||
|
);
|
||||||
|
roof.castShadow = true;
|
||||||
|
g.add(roof);
|
||||||
|
// Tuer
|
||||||
|
const door = new Mesh(
|
||||||
|
new BoxGeometry(0.78, 1.95, 0.04),
|
||||||
|
new MeshStandardMaterial({ color: 0x3b3934, roughness: 0.6 }),
|
||||||
|
);
|
||||||
|
door.position.set(p.x, y + 0.98, p.z - p.d / 2 - 0.02);
|
||||||
|
g.add(door);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spielhaus durch Zimmermann: Podest, Bretterwaende, Gelaender, Pultdach. */
|
||||||
|
function spielhaus(p: Prop, y: number): Group {
|
||||||
|
const g = new Group();
|
||||||
|
const holz = material(tex.fassadeHolz(), { color: 0xd8b98c, roughness: 0.85 });
|
||||||
|
const dunkel = material(tex.fassadeHolz(), { color: 0xa8895f, roughness: 0.88 });
|
||||||
|
const podestTop = y + 0.55;
|
||||||
|
|
||||||
|
const podest = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, y - 0.1, podestTop, p.rot, 1.2), dunkel);
|
||||||
|
podest.castShadow = true; podest.receiveShadow = true;
|
||||||
|
g.add(podest);
|
||||||
|
|
||||||
|
const hw = p.w / 2, hd = p.d / 2;
|
||||||
|
// Rueckwand und zwei Seitenwaende, Vorderseite offen
|
||||||
|
const back = new Mesh(boxGeometry(p.x, p.z + hd - 0.05, p.w, 0.09, podestTop, y + p.h, p.rot, 1.2), holz);
|
||||||
|
const left = new Mesh(boxGeometry(p.x - hw + 0.05, p.z, 0.09, p.d, podestTop, y + p.h, p.rot, 1.2), holz);
|
||||||
|
const right = new Mesh(boxGeometry(p.x + hw - 0.05, p.z - hd / 3, 0.09, p.d / 3, podestTop, y + p.h, p.rot, 1.2), holz);
|
||||||
|
for (const m of [back, left, right]) { m.castShadow = true; m.receiveShadow = true; g.add(m); }
|
||||||
|
|
||||||
|
// Bruestung an der offenen Seite
|
||||||
|
const rail = new Mesh(boxGeometry(p.x, p.z - hd + 0.05, p.w, 0.07, podestTop + 0.32, podestTop + 0.45, p.rot, 1.2), holz);
|
||||||
|
rail.castShadow = true;
|
||||||
|
g.add(rail);
|
||||||
|
|
||||||
|
const roof = new Mesh(
|
||||||
|
boxGeometry(p.x, p.z, p.w + 0.26, p.d + 0.26, y + p.h, y + p.h + 0.09, p.rot, 2),
|
||||||
|
material(tex.fassadeHolz(), { color: 0x7d6549, roughness: 0.9 }),
|
||||||
|
);
|
||||||
|
roof.castShadow = true;
|
||||||
|
g.add(roof);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flammengrill(p: Prop, y: number): Group {
|
||||||
|
const g = new Group();
|
||||||
|
const steel = new MeshStandardMaterial({ color: 0x3a3b3e, roughness: 0.5, metalness: 0.45 });
|
||||||
|
const body = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, y + 0.06, y + p.h, p.rot, 1), steel);
|
||||||
|
body.castShadow = true; body.receiveShadow = true;
|
||||||
|
g.add(body);
|
||||||
|
const plate = new Mesh(
|
||||||
|
boxGeometry(p.x, p.z, p.w + 0.1, p.d + 0.1, y + p.h, y + p.h + 0.03, p.rot, 1),
|
||||||
|
new MeshStandardMaterial({ color: 0x3a3a3c, roughness: 0.35, metalness: 0.7 }),
|
||||||
|
);
|
||||||
|
plate.castShadow = true;
|
||||||
|
g.add(plate);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bank(p: Prop, y: number): Group {
|
||||||
|
const g = new Group();
|
||||||
|
const stein = material(tex.travertinMauer(), { roughness: 0.65 });
|
||||||
|
const seat = new Mesh(boxGeometry(p.x, p.z, p.w, p.d, y + p.h - 0.09, y + p.h, p.rot, 1.6), stein);
|
||||||
|
seat.castShadow = true; seat.receiveShadow = true;
|
||||||
|
g.add(seat);
|
||||||
|
for (const s of [-1, 1]) {
|
||||||
|
const m = new Mesh(
|
||||||
|
boxGeometry(p.x, p.z + (s * (p.d / 2 - 0.22)), p.w - 0.08, 0.14, y, y + p.h - 0.09, p.rot, 1.6),
|
||||||
|
stein,
|
||||||
|
);
|
||||||
|
m.castShadow = true;
|
||||||
|
g.add(m);
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleBox(p: Prop, y: number, color: number, rough = 0.7): Mesh {
|
||||||
|
const m = new Mesh(
|
||||||
|
boxGeometry(p.x, p.z, p.w, p.d, y, y + p.h, p.rot, 1),
|
||||||
|
new MeshStandardMaterial({ color, roughness: rough }),
|
||||||
|
);
|
||||||
|
m.castShadow = true;
|
||||||
|
m.receiveShadow = true;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gartentor(p: Prop, y: number): Group {
|
||||||
|
const g = new Group();
|
||||||
|
const mat = new MeshStandardMaterial({ color: 0x4a4a4c, roughness: 0.5, metalness: 0.4 });
|
||||||
|
const frame = new Mesh(boxGeometry(p.x, p.z, p.w, 0.05, y, y + p.h, p.rot, 1), mat);
|
||||||
|
g.add(frame);
|
||||||
|
for (let i = 1; i < 6; i++) {
|
||||||
|
const bar = new Mesh(
|
||||||
|
boxGeometry(p.x - p.w / 2 + (i * p.w) / 6, p.z, 0.03, 0.03, y, y + p.h, p.rot, 1),
|
||||||
|
mat,
|
||||||
|
);
|
||||||
|
g.add(bar);
|
||||||
|
}
|
||||||
|
g.children.forEach((c) => { (c as Mesh).castShadow = true; });
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProps(props: Prop[], height: (x: number, z: number) => number): Group {
|
||||||
|
const group = new Group();
|
||||||
|
group.name = 'Objekte';
|
||||||
|
for (const p of props) {
|
||||||
|
const y = height(p.x, p.z);
|
||||||
|
let obj: Group | Mesh;
|
||||||
|
switch (p.type) {
|
||||||
|
case 'gartenhaus': obj = gartenhaus(p, y); break;
|
||||||
|
case 'spielhaus': obj = spielhaus(p, y); break;
|
||||||
|
case 'flammengrill': obj = flammengrill(p, y); break;
|
||||||
|
case 'bank': obj = bank(p, y); break;
|
||||||
|
case 'tor': obj = gartentor(p, y); break;
|
||||||
|
case 'roboter': obj = simpleBox(p, y, 0x33413a, 0.6); break;
|
||||||
|
case 'wasserhahn': obj = simpleBox(p, y, 0x8a8d90, 0.4); break;
|
||||||
|
case 'schacht': obj = simpleBox(p, y - 0.01, 0x77746e, 0.9); break;
|
||||||
|
default: continue;
|
||||||
|
}
|
||||||
|
obj.name = p.id;
|
||||||
|
obj.userData.note = p.note;
|
||||||
|
group.add(obj);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Beleuchtung im Garten
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface GardenLights {
|
||||||
|
group: Group;
|
||||||
|
/** Schaltet die Leuchten je nach Tageszeit. */
|
||||||
|
setOn(on: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLamps(lamps: Lamp[], height: (x: number, z: number) => number): GardenLights {
|
||||||
|
const group = new Group();
|
||||||
|
group.name = 'Beleuchtung';
|
||||||
|
const bodyMat = new MeshStandardMaterial({ color: 0x33332f, roughness: 0.55, metalness: 0.3 });
|
||||||
|
const glowMat = new MeshStandardMaterial({
|
||||||
|
color: 0xffd9a0, emissive: 0xffc26e, emissiveIntensity: 0, roughness: 0.4,
|
||||||
|
});
|
||||||
|
const pointLights: PointLight[] = [];
|
||||||
|
|
||||||
|
for (const l of lamps) {
|
||||||
|
const y = height(l.x, l.z);
|
||||||
|
const g = new Group();
|
||||||
|
if (l.kind === 'poller') {
|
||||||
|
const post = new Mesh(new BoxGeometry(0.09, 0.62, 0.09), bodyMat);
|
||||||
|
post.position.set(l.x, y + 0.31, l.z);
|
||||||
|
post.castShadow = true;
|
||||||
|
g.add(post);
|
||||||
|
const head = new Mesh(new BoxGeometry(0.11, 0.06, 0.11), glowMat);
|
||||||
|
head.position.set(l.x, y + 0.6, l.z);
|
||||||
|
g.add(head);
|
||||||
|
} else {
|
||||||
|
const ring = new Mesh(new CylinderGeometry(0.07, 0.07, 0.03, 16), bodyMat);
|
||||||
|
ring.position.set(l.x, y + 0.015, l.z);
|
||||||
|
g.add(ring);
|
||||||
|
const lens = new Mesh(new CylinderGeometry(0.05, 0.05, 0.035, 16), glowMat);
|
||||||
|
lens.position.set(l.x, y + 0.025, l.z);
|
||||||
|
g.add(lens);
|
||||||
|
}
|
||||||
|
const pl = new PointLight(0xffc98a, 0, 6, 2);
|
||||||
|
pl.position.set(l.x, y + (l.kind === 'poller' ? 0.6 : 0.1), l.z);
|
||||||
|
pointLights.push(pl);
|
||||||
|
g.add(pl);
|
||||||
|
group.add(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
setOn(on: boolean) {
|
||||||
|
glowMat.emissiveIntensity = on ? 1.6 : 0;
|
||||||
|
for (const pl of pointLights) pl.intensity = on ? 2.2 : 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Belagsflaechen aus dem Gartenplan: Travertin, Keramik, Tegula, Rasen,
|
||||||
|
* Schotterwiese, Beete, Spielsand.
|
||||||
|
*/
|
||||||
|
import { Group, Mesh, MeshStandardMaterial, DoubleSide } from 'three';
|
||||||
|
import type { Surface, SurfaceType } from '../site';
|
||||||
|
import { surfaceGeometryMulti } from './geometry';
|
||||||
|
import * as tex from './textures';
|
||||||
|
|
||||||
|
const BELAG: Record<SurfaceType, { belag: () => tex.Belag; offset: number; label: string }> = {
|
||||||
|
travertin80: { belag: tex.travertin80, offset: 0.042, label: 'Travertinplatten 80×80×3 cm' },
|
||||||
|
travertin4060: { belag: tex.travertin4060, offset: 0.042, label: 'Travertinplatten 40×60×3 cm mit Dekorkies' },
|
||||||
|
travertinstell: { belag: tex.travertinstell, offset: 0.038, label: 'Travertinstellplatten' },
|
||||||
|
keramik: { belag: tex.keramik, offset: 0.032, label: 'Keramikplatten 60×60×2 cm' },
|
||||||
|
verbundstein: { belag: tex.verbundstein, offset: 0.030, label: 'Verbundsteine (Anpassung Nachbar)' },
|
||||||
|
tegula: { belag: tex.tegula, offset: 0.034, label: 'Tegula-Pflastersteine grau' },
|
||||||
|
rasen: { belag: tex.rasen, offset: 0.022, label: 'Rasen' },
|
||||||
|
schotterwiese: { belag: tex.schotterwiese, offset: 0.020, label: 'Schotterwiese' },
|
||||||
|
beet: { belag: tex.beet, offset: 0.018, label: 'Pflanzbeet' },
|
||||||
|
spielsand: { belag: tex.spielsand, offset: 0.016, label: 'Spielsand' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildSurfaces(
|
||||||
|
surfaces: Surface[],
|
||||||
|
height: (x: number, z: number) => number,
|
||||||
|
): Group {
|
||||||
|
const group = new Group();
|
||||||
|
group.name = 'Beläge';
|
||||||
|
|
||||||
|
const byType = new Map<SurfaceType, Surface[]>();
|
||||||
|
for (const s of surfaces) {
|
||||||
|
const list = byType.get(s.type) ?? [];
|
||||||
|
list.push(s);
|
||||||
|
byType.set(s.type, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [type, list] of byType) {
|
||||||
|
const def = BELAG[type];
|
||||||
|
if (!def) continue;
|
||||||
|
const belag = def.belag();
|
||||||
|
const geo = surfaceGeometryMulti(
|
||||||
|
list.map((s) => s.polygon),
|
||||||
|
height,
|
||||||
|
{ offset: def.offset, tile: belag.tile, maxEdge: 0.28 },
|
||||||
|
);
|
||||||
|
const mesh = new Mesh(geo, new MeshStandardMaterial({
|
||||||
|
map: belag.map,
|
||||||
|
roughness: belag.roughness,
|
||||||
|
metalness: 0,
|
||||||
|
side: DoubleSide,
|
||||||
|
polygonOffset: true,
|
||||||
|
polygonOffsetFactor: -1,
|
||||||
|
polygonOffsetUnits: -1,
|
||||||
|
}));
|
||||||
|
mesh.name = def.label;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
mesh.userData.surfaceType = type;
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Gelaende: Basismesh aus dem Hoehenraster, plus eine weite, flache Umgebung,
|
||||||
|
* damit der Garten nicht im Nichts steht.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
BufferAttribute, BufferGeometry, DoubleSide, Mesh, MeshStandardMaterial, Group,
|
||||||
|
} from 'three';
|
||||||
|
import type { Terrain } from '../site';
|
||||||
|
import { rasen, schotterwiese } from './textures';
|
||||||
|
|
||||||
|
export function buildTerrain(terrain: Terrain): Group {
|
||||||
|
const group = new Group();
|
||||||
|
group.name = 'Gelände';
|
||||||
|
const { x0, z0, cell, nx, nz } = terrain.grid;
|
||||||
|
const h = terrain.heights;
|
||||||
|
|
||||||
|
// --- Hauptgelaende -------------------------------------------------------
|
||||||
|
const geo = new BufferGeometry();
|
||||||
|
const verts = new Float32Array(nx * nz * 3);
|
||||||
|
const uvs = new Float32Array(nx * nz * 2);
|
||||||
|
const belag = rasen();
|
||||||
|
for (let j = 0; j < nz; j++) {
|
||||||
|
for (let i = 0; i < nx; i++) {
|
||||||
|
const k = j * nx + i;
|
||||||
|
const x = x0 + i * cell, z = z0 + j * cell;
|
||||||
|
verts[k * 3] = x;
|
||||||
|
verts[k * 3 + 1] = h[k];
|
||||||
|
verts[k * 3 + 2] = z;
|
||||||
|
uvs[k * 2] = x / belag.tile;
|
||||||
|
uvs[k * 2 + 1] = -z / belag.tile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const idx: number[] = [];
|
||||||
|
for (let j = 0; j < nz - 1; j++) {
|
||||||
|
for (let i = 0; i < nx - 1; i++) {
|
||||||
|
const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1;
|
||||||
|
idx.push(a, c, b, b, c, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
geo.setAttribute('position', new BufferAttribute(verts, 3));
|
||||||
|
geo.setAttribute('uv', new BufferAttribute(uvs, 2));
|
||||||
|
geo.setIndex(idx);
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
|
||||||
|
const ground = new Mesh(geo, new MeshStandardMaterial({
|
||||||
|
map: belag.map, roughness: belag.roughness, metalness: 0,
|
||||||
|
}));
|
||||||
|
ground.name = 'Terrain';
|
||||||
|
ground.receiveShadow = true;
|
||||||
|
group.add(ground);
|
||||||
|
|
||||||
|
// --- Umland ---------------------------------------------------------------
|
||||||
|
// Das Grundstueck liegt an einem Hang ueber dem Tal. Das Umland wird als
|
||||||
|
// Kranz direkt an die Randpunkte des Rasters gehaengt - so entsteht keine
|
||||||
|
// sichtbare Kante - und faellt gegen aussen ab, vor allem nach Suedwesten.
|
||||||
|
const wiese = schotterwiese();
|
||||||
|
const cx = x0 + ((nx - 1) * cell) / 2;
|
||||||
|
const cz = z0 + ((nz - 1) * cell) / 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Randpunkte des Rasters im Umlauf, bewusst grob abgetastet: bei jedem
|
||||||
|
* Rasterpunkt entstuenden extrem schlanke Dreiecke, deren Normalen
|
||||||
|
* unbrauchbar werden und sich als dunkle Streifen abzeichnen.
|
||||||
|
*/
|
||||||
|
const STEP = 10;
|
||||||
|
const border: { x: number; z: number; y: number }[] = [];
|
||||||
|
const at = (i: number, j: number) => ({
|
||||||
|
x: x0 + i * cell, z: z0 + j * cell, y: h[j * nx + i] - 0.05,
|
||||||
|
});
|
||||||
|
for (let i = 0; i < nx - 1; i += STEP) border.push(at(i, 0));
|
||||||
|
for (let j = 0; j < nz - 1; j += STEP) border.push(at(nx - 1, j));
|
||||||
|
for (let i = nx - 1; i > 0; i -= STEP) border.push(at(i, nz - 1));
|
||||||
|
for (let j = nz - 1; j > 0; j -= STEP) border.push(at(0, j));
|
||||||
|
|
||||||
|
// Der Kranz entsteht durch reine Skalierung des Randpolygons um den
|
||||||
|
// Mittelpunkt. Das kann sich nicht selbst durchdringen, egal wie weit
|
||||||
|
// hinausskaliert wird, und schliesst luecklos an das Raster an.
|
||||||
|
const RINGS = 26;
|
||||||
|
const SCALE_MAX = 18;
|
||||||
|
const ringPos: number[] = [];
|
||||||
|
const ringUv: number[] = [];
|
||||||
|
const ringIdx: number[] = [];
|
||||||
|
const n = border.length;
|
||||||
|
for (let k = 0; k <= RINGS; k++) {
|
||||||
|
const t = (k / RINGS) ** 2.4;
|
||||||
|
const s = 1 + (SCALE_MAX - 1) * t;
|
||||||
|
for (const p of border) {
|
||||||
|
const dx = p.x - cx, dz = p.z - cz;
|
||||||
|
const x = cx + dx * s;
|
||||||
|
const z = cz + dz * s;
|
||||||
|
// Nach Suedwesten faellt das Gelaende deutlich staerker ab (Blick ins Tal).
|
||||||
|
const sw = (1 - Math.cos(Math.atan2(dz, dx) - 2.4)) / 2;
|
||||||
|
ringPos.push(x, p.y - t * (6 + 26 * sw * sw), z);
|
||||||
|
ringUv.push(x / (wiese.tile * 5), -z / (wiese.tile * 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let k = 0; k < RINGS; k++) {
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = k * n + i, b = k * n + ((i + 1) % n);
|
||||||
|
const c = (k + 1) * n + i, d = (k + 1) * n + ((i + 1) % n);
|
||||||
|
ringIdx.push(a, c, b, b, c, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const far = new BufferGeometry();
|
||||||
|
far.setAttribute('position', new BufferAttribute(new Float32Array(ringPos), 3));
|
||||||
|
far.setAttribute('uv', new BufferAttribute(new Float32Array(ringUv), 2));
|
||||||
|
far.setIndex(ringIdx);
|
||||||
|
far.computeVertexNormals();
|
||||||
|
const surroundings = new Mesh(far, new MeshStandardMaterial({
|
||||||
|
map: wiese.map, roughness: 1, metalness: 0, color: 0x86935f, side: DoubleSide,
|
||||||
|
}));
|
||||||
|
surroundings.name = 'Umland';
|
||||||
|
group.add(surroundings);
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* Prozedurale Texturen. Die Anwendung soll ohne externe Assets auskommen,
|
||||||
|
* deshalb werden alle Belaege zur Laufzeit auf ein Canvas gezeichnet.
|
||||||
|
*
|
||||||
|
* Konvention: Jede Textur bildet eine quadratische Weltflaeche von `tile`
|
||||||
|
* Metern ab. Die Meshes bekommen weltbezogene UVs (x/tile, z/tile), damit
|
||||||
|
* Plattenraster ueber Flaechengrenzen hinweg durchlaufen - so wie im Plan.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
|
||||||
|
} from 'three';
|
||||||
|
|
||||||
|
const SIZE = 512;
|
||||||
|
|
||||||
|
/** Deterministischer Zufall, damit jeder Aufbau gleich aussieht. */
|
||||||
|
function mulberry32(seed: number) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvas(): [HTMLCanvasElement, CanvasRenderingContext2D] {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = SIZE;
|
||||||
|
c.height = SIZE;
|
||||||
|
const ctx = c.getContext('2d', { willReadFrequently: true })!;
|
||||||
|
return [c, ctx];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kachelbares Wertrauschen, wird fuer Koernung und Farbschwankung genutzt. */
|
||||||
|
function noiseTile(ctx: CanvasRenderingContext2D, seed: number, cells: number, alpha: number, dark = true) {
|
||||||
|
const rnd = mulberry32(seed);
|
||||||
|
const g: number[] = [];
|
||||||
|
for (let i = 0; i < cells * cells; i++) g.push(rnd());
|
||||||
|
const at = (i: number, j: number) => g[((j + cells) % cells) * cells + ((i + cells) % cells)];
|
||||||
|
const img = ctx.getImageData(0, 0, SIZE, SIZE);
|
||||||
|
const step = SIZE / cells;
|
||||||
|
for (let y = 0; y < SIZE; y++) {
|
||||||
|
const fy = y / step, j = Math.floor(fy), ty = fy - j;
|
||||||
|
for (let x = 0; x < SIZE; x++) {
|
||||||
|
const fx = x / step, i = Math.floor(fx), tx = fx - i;
|
||||||
|
const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty);
|
||||||
|
const v =
|
||||||
|
at(i, j) * (1 - sx) * (1 - sy) + at(i + 1, j) * sx * (1 - sy) +
|
||||||
|
at(i, j + 1) * (1 - sx) * sy + at(i + 1, j + 1) * sx * sy;
|
||||||
|
const k = (y * SIZE + x) * 4;
|
||||||
|
const d = (dark ? -1 : 1) * (v - 0.5) * 2 * alpha * 255;
|
||||||
|
img.data[k] = Math.max(0, Math.min(255, img.data[k] + d));
|
||||||
|
img.data[k + 1] = Math.max(0, Math.min(255, img.data[k + 1] + d));
|
||||||
|
img.data[k + 2] = Math.max(0, Math.min(255, img.data[k + 2] + d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.putImageData(img, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feine Koernung als Ueberlagerung, ohne die Kachelbarkeit zu stoeren. */
|
||||||
|
function grain(ctx: CanvasRenderingContext2D, seed: number, amount: number, dotSize = 1) {
|
||||||
|
const rnd = mulberry32(seed);
|
||||||
|
const n = Math.floor((SIZE * SIZE) / (24 * dotSize));
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const v = Math.floor(rnd() * 255);
|
||||||
|
ctx.fillStyle = `rgba(${v},${v},${v},${amount})`;
|
||||||
|
ctx.fillRect(rnd() * SIZE, rnd() * SIZE, dotSize, dotSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function texture(c: HTMLCanvasElement): CanvasTexture {
|
||||||
|
const t = new CanvasTexture(c);
|
||||||
|
t.wrapS = t.wrapT = RepeatWrapping;
|
||||||
|
t.colorSpace = SRGBColorSpace;
|
||||||
|
t.anisotropy = 8;
|
||||||
|
t.minFilter = LinearMipmapLinearFilter;
|
||||||
|
t.generateMipmaps = true;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichnet ein Plattenraster mit Fuge. slabW/slabH in Metern. */
|
||||||
|
function slabs(
|
||||||
|
ctx: CanvasRenderingContext2D, tile: number, slabW: number, slabH: number,
|
||||||
|
base: string, jointColor: string, jointM: number, seed: number, variation: number,
|
||||||
|
) {
|
||||||
|
const px = SIZE / tile;
|
||||||
|
ctx.fillStyle = jointColor;
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
const rnd = mulberry32(seed);
|
||||||
|
const cols = Math.round(tile / slabW);
|
||||||
|
const rows = Math.round(tile / slabH);
|
||||||
|
const j = Math.max(1, jointM * px);
|
||||||
|
const [r, g, b] = base.match(/\d+/g)!.map(Number);
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const d = (rnd() - 0.5) * 2 * variation;
|
||||||
|
ctx.fillStyle = `rgb(${Math.round(r + d)},${Math.round(g + d)},${Math.round(b + d)})`;
|
||||||
|
ctx.fillRect(col * slabW * px + j / 2, row * slabH * px + j / 2, slabW * px - j, slabH * px - j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Belag {
|
||||||
|
map: CanvasTexture;
|
||||||
|
tile: number;
|
||||||
|
roughness: number;
|
||||||
|
color?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, Belag>();
|
||||||
|
|
||||||
|
function make(key: string, tile: number, roughness: number, draw: (ctx: CanvasRenderingContext2D) => void): Belag {
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit) return hit;
|
||||||
|
const [c, ctx] = canvas();
|
||||||
|
draw(ctx);
|
||||||
|
const belag: Belag = { map: texture(c), tile, roughness };
|
||||||
|
cache.set(key, belag);
|
||||||
|
return belag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Belaege
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Travertinplatten 80 x 80 x 3 cm, warmes Creme mit feiner Poren-Struktur. */
|
||||||
|
export const travertin80 = () => make('travertin80', 3.2, 0.72, (ctx) => {
|
||||||
|
slabs(ctx, 3.2, 0.8, 0.8, 'rgb(209,195,168)', 'rgb(170,157,134)', 0.008, 11, 7);
|
||||||
|
noiseTile(ctx, 21, 32, 0.05);
|
||||||
|
grain(ctx, 31, 0.05, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Travertinplatten 40 x 60 x 3 cm, im Plan mit Dekorkies in den Fugen. */
|
||||||
|
export const travertin4060 = () => make('travertin4060', 2.4, 0.75, (ctx) => {
|
||||||
|
slabs(ctx, 2.4, 0.4, 0.6, 'rgb(212,199,172)', 'rgb(158,150,133)', 0.02, 12, 8);
|
||||||
|
noiseTile(ctx, 22, 32, 0.05);
|
||||||
|
grain(ctx, 32, 0.06, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Travertinstellplatten als schmaler Randstreifen. */
|
||||||
|
export const travertinstell = () => make('travertinstell', 2.4, 0.75, (ctx) => {
|
||||||
|
slabs(ctx, 2.4, 0.4, 0.4, 'rgb(206,192,166)', 'rgb(164,154,135)', 0.012, 13, 8);
|
||||||
|
noiseTile(ctx, 23, 32, 0.05);
|
||||||
|
grain(ctx, 33, 0.05, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Keramikplatten 60 x 60 x 2 cm, grau - bauseits vorhanden (siehe Fotos). */
|
||||||
|
export const keramik = () => make('keramik', 2.4, 0.55, (ctx) => {
|
||||||
|
slabs(ctx, 2.4, 0.6, 0.6, 'rgb(160,158,154)', 'rgb(128,127,124)', 0.006, 14, 5);
|
||||||
|
noiseTile(ctx, 24, 48, 0.035);
|
||||||
|
grain(ctx, 34, 0.04, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Tegula-Pflastersteine grau, kleinformatig und leicht unregelmaessig. */
|
||||||
|
export const tegula = () => make('tegula', 1.2, 0.85, (ctx) => {
|
||||||
|
const px = SIZE / 1.2;
|
||||||
|
ctx.fillStyle = 'rgb(118,116,114)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
const rnd = mulberry32(15);
|
||||||
|
const sw = 0.15, sh = 0.1;
|
||||||
|
const cols = Math.round(1.2 / sw), rows = Math.round(1.2 / sh);
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
const off = (row % 2) * 0.5;
|
||||||
|
for (let col = -1; col <= cols; col++) {
|
||||||
|
const v = 140 + rnd() * 34;
|
||||||
|
ctx.fillStyle = `rgb(${v | 0},${(v - 2) | 0},${(v - 5) | 0})`;
|
||||||
|
const x = (col + off) * sw * px + 1.2;
|
||||||
|
const y = row * sh * px + 1.2;
|
||||||
|
ctx.fillRect(x, y, sw * px - 2.4, sh * px - 2.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
noiseTile(ctx, 25, 24, 0.06);
|
||||||
|
grain(ctx, 35, 0.09, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Verbundsteine des Nachbarn, etwas dunkler als Tegula. */
|
||||||
|
export const verbundstein = () => make('verbundstein', 1.2, 0.88, (ctx) => {
|
||||||
|
const px = SIZE / 1.2;
|
||||||
|
ctx.fillStyle = 'rgb(110,108,106)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
const rnd = mulberry32(16);
|
||||||
|
for (let row = 0; row < 8; row++) {
|
||||||
|
for (let col = 0; col < 8; col++) {
|
||||||
|
const v = 126 + rnd() * 30;
|
||||||
|
ctx.fillStyle = `rgb(${v | 0},${v | 0},${(v - 4) | 0})`;
|
||||||
|
ctx.fillRect(col * 0.15 * px + 1.5, row * 0.15 * px + 1.5, 0.15 * px - 3, 0.15 * px - 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grain(ctx, 36, 0.08, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Rasen: gemaehte Flaeche mit leichten Bahnen. */
|
||||||
|
export const rasen = () => make('rasen', 2.0, 0.95, (ctx) => {
|
||||||
|
ctx.fillStyle = 'rgb(96,133,62)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
noiseTile(ctx, 41, 16, 0.09);
|
||||||
|
noiseTile(ctx, 42, 64, 0.07);
|
||||||
|
const rnd = mulberry32(43);
|
||||||
|
for (let i = 0; i < 5200; i++) {
|
||||||
|
const x = rnd() * SIZE, y = rnd() * SIZE;
|
||||||
|
const v = rnd();
|
||||||
|
ctx.strokeStyle = `rgba(${(78 + v * 62) | 0},${(118 + v * 66) | 0},${(44 + v * 40) | 0},0.55)`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
ctx.lineTo(x + (rnd() - 0.5) * 3, y - 2 - rnd() * 4);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Schotterwiese: magerer Kiesrasen, heller und offener als der Rasen. */
|
||||||
|
export const schotterwiese = () => make('schotterwiese', 2.0, 0.98, (ctx) => {
|
||||||
|
ctx.fillStyle = 'rgb(139,146,104)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
noiseTile(ctx, 44, 20, 0.1);
|
||||||
|
const rnd = mulberry32(45);
|
||||||
|
for (let i = 0; i < 2600; i++) {
|
||||||
|
const v = 150 + rnd() * 70;
|
||||||
|
ctx.fillStyle = `rgba(${v | 0},${(v - 4) | 0},${(v - 18) | 0},0.8)`;
|
||||||
|
const r = 1 + rnd() * 2.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(rnd() * SIZE, rnd() * SIZE, r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 1800; i++) {
|
||||||
|
const x = rnd() * SIZE, y = rnd() * SIZE;
|
||||||
|
ctx.strokeStyle = `rgba(112,138,64,0.5)`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
ctx.lineTo(x + (rnd() - 0.5) * 3, y - 3 - rnd() * 3);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Dekorkies in den Pflanzflaechen und um den Flammengrill. */
|
||||||
|
export const dekorkies = () => make('dekorkies', 1.0, 0.99, (ctx) => {
|
||||||
|
ctx.fillStyle = 'rgb(150,144,132)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
const rnd = mulberry32(46);
|
||||||
|
for (let i = 0; i < 6500; i++) {
|
||||||
|
const v = 128 + rnd() * 90;
|
||||||
|
ctx.fillStyle = `rgb(${v | 0},${(v - 5) | 0},${(v - 16) | 0})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 2 + rnd() * 4, 2 + rnd() * 3, rnd() * 3.14, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
noiseTile(ctx, 47, 24, 0.07);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Pflanzbeet: dunkler Humus mit Rindenanteil. */
|
||||||
|
export const beet = () => make('beet', 1.5, 0.99, (ctx) => {
|
||||||
|
ctx.fillStyle = 'rgb(74,58,44)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
noiseTile(ctx, 48, 20, 0.12);
|
||||||
|
const rnd = mulberry32(49);
|
||||||
|
for (let i = 0; i < 2200; i++) {
|
||||||
|
const v = 60 + rnd() * 60;
|
||||||
|
ctx.fillStyle = `rgb(${v | 0},${(v - 12) | 0},${(v - 24) | 0})`;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(rnd() * SIZE, rnd() * SIZE);
|
||||||
|
ctx.rotate(rnd() * 3.14);
|
||||||
|
ctx.fillRect(0, 0, 3 + rnd() * 9, 2 + rnd() * 3);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Spielsand. */
|
||||||
|
export const spielsand = () => make('spielsand', 1.5, 1.0, (ctx) => {
|
||||||
|
ctx.fillStyle = 'rgb(211,192,155)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
noiseTile(ctx, 50, 28, 0.05);
|
||||||
|
grain(ctx, 51, 0.16, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bauteile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Travertin-Mauerwerk in Schichten, wie auf den Referenzfotos des Plans. */
|
||||||
|
export const travertinMauer = () => make('travertinMauer', 1.6, 0.7, (ctx) => {
|
||||||
|
const px = SIZE / 1.6;
|
||||||
|
ctx.fillStyle = 'rgb(168,152,126)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
const rnd = mulberry32(61);
|
||||||
|
const h = 0.2;
|
||||||
|
for (let row = 0; row < Math.round(1.6 / h); row++) {
|
||||||
|
let x = -((row % 2) * 0.3);
|
||||||
|
while (x < 1.6) {
|
||||||
|
const w = 0.4 + rnd() * 0.4;
|
||||||
|
const v = 202 + rnd() * 26;
|
||||||
|
ctx.fillStyle = `rgb(${v | 0},${(v - 17) | 0},${(v - 44) | 0})`;
|
||||||
|
ctx.fillRect(x * px + 1, row * h * px + 1, w * px - 2, h * px - 2);
|
||||||
|
x += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
noiseTile(ctx, 62, 40, 0.045);
|
||||||
|
grain(ctx, 63, 0.05, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sichtbeton der Winkelelemente und der Nachbarmauer. */
|
||||||
|
export const beton = () => make('beton', 2.0, 0.8, (ctx) => {
|
||||||
|
ctx.fillStyle = 'rgb(176,174,169)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
noiseTile(ctx, 64, 12, 0.05);
|
||||||
|
noiseTile(ctx, 65, 48, 0.025);
|
||||||
|
grain(ctx, 66, 0.04, 1);
|
||||||
|
// Schalungsstoesse
|
||||||
|
ctx.strokeStyle = 'rgba(120,118,114,0.35)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, SIZE / 2); ctx.lineTo(SIZE, SIZE / 2);
|
||||||
|
ctx.moveTo(SIZE / 2, 0); ctx.lineTo(SIZE / 2, SIZE);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = 'rgba(120,118,114,0.4)';
|
||||||
|
for (const [x, y] of [[SIZE * 0.25, SIZE * 0.25], [SIZE * 0.75, SIZE * 0.25], [SIZE * 0.25, SIZE * 0.75], [SIZE * 0.75, SIZE * 0.75]]) {
|
||||||
|
ctx.beginPath(); ctx.arc(x, y, 3.5, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Gestapelte Holzscheiter der Scheiterwand hinter dem Flammengrill. */
|
||||||
|
export const holzscheiter = () => make('holzscheiter', 1.0, 0.9, (ctx) => {
|
||||||
|
ctx.fillStyle = 'rgb(48,38,30)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
const rnd = mulberry32(67);
|
||||||
|
for (let i = 0; i < 260; i++) {
|
||||||
|
const r = 12 + rnd() * 16;
|
||||||
|
const x = rnd() * SIZE, y = rnd() * SIZE;
|
||||||
|
const v = 128 + rnd() * 74;
|
||||||
|
ctx.fillStyle = `rgb(${v | 0},${(v - 26) | 0},${(v - 56) | 0})`;
|
||||||
|
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(60,44,32,0.75)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = 'rgba(90,66,46,0.5)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let k = 1; k <= 2; k++) {
|
||||||
|
ctx.beginPath(); ctx.arc(x, y, (r * k) / 3, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Vertikale Holzschalung der Fassade - graubraun vergraut, wie auf den Fotos. */
|
||||||
|
export const fassadeHolz = () => make('fassadeHolz', 1.2, 0.78, (ctx) => {
|
||||||
|
const px = SIZE / 1.2;
|
||||||
|
ctx.fillStyle = 'rgb(124,114,100)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
const rnd = mulberry32(71);
|
||||||
|
const w = 0.06;
|
||||||
|
for (let col = 0; col < Math.round(1.2 / w); col++) {
|
||||||
|
const v = 152 + rnd() * 32;
|
||||||
|
ctx.fillStyle = `rgb(${v | 0},${(v - 8) | 0},${(v - 20) | 0})`;
|
||||||
|
ctx.fillRect(col * w * px + 1.5, 0, w * px - 3, SIZE);
|
||||||
|
ctx.strokeStyle = 'rgba(84,76,66,0.45)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const y = rnd() * SIZE;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(col * w * px + 2, y);
|
||||||
|
ctx.lineTo(col * w * px + w * px - 3, y + (rnd() - 0.5) * 5);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
noiseTile(ctx, 72, 32, 0.03);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Heller Betonsockel unter der Holzschalung. */
|
||||||
|
export const sockel = () => make('sockel', 2.0, 0.72, (ctx) => {
|
||||||
|
ctx.fillStyle = 'rgb(214,211,204)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
noiseTile(ctx, 73, 16, 0.035);
|
||||||
|
grain(ctx, 74, 0.03, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Extensive Dachbegruenung der Flachdaecher. */
|
||||||
|
export const gruendach = () => make('gruendach', 2.0, 1.0, (ctx) => {
|
||||||
|
ctx.fillStyle = 'rgb(108,116,72)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
noiseTile(ctx, 75, 18, 0.13);
|
||||||
|
const rnd = mulberry32(76);
|
||||||
|
for (let i = 0; i < 2800; i++) {
|
||||||
|
const v = rnd();
|
||||||
|
ctx.fillStyle = `rgba(${(96 + v * 80) | 0},${(112 + v * 60) | 0},${(58 + v * 40) | 0},0.75)`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(rnd() * SIZE, rnd() * SIZE, 1.5 + rnd() * 3.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Dunkler Spritzschutz-Kiesstreifen entlang der Fassade (siehe Fotos). */
|
||||||
|
export const spritzschutz = () => make('spritzschutz', 1.0, 0.98, (ctx) => {
|
||||||
|
ctx.fillStyle = 'rgb(62,62,66)';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
const rnd = mulberry32(77);
|
||||||
|
for (let i = 0; i < 4200; i++) {
|
||||||
|
const v = 52 + rnd() * 78;
|
||||||
|
ctx.fillStyle = `rgb(${v | 0},${v | 0},${(v + 6) | 0})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 2 + rnd() * 4, 2 + rnd() * 3, rnd() * 3.14, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Bepflanzung: Solitaergehoelze, Eibenhecke `Hilli`, Beerenstraeucher,
|
||||||
|
* Graeser, Kraeuter und die Ligusterhecke des Nachbargartens.
|
||||||
|
*
|
||||||
|
* Der Stil ist bewusst stilisiert: klare Volumen mit leichter Unregelmaessigkeit
|
||||||
|
* statt Blattgeometrie - das bleibt auch auf dem Handy fluessig.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
BoxGeometry, BufferAttribute, Color, CylinderGeometry, Group, IcosahedronGeometry,
|
||||||
|
InstancedMesh, Matrix4, Mesh, MeshStandardMaterial, Object3D, Quaternion, Vector3,
|
||||||
|
type BufferGeometry,
|
||||||
|
} from 'three';
|
||||||
|
import type { Plant, Tree } from '../site';
|
||||||
|
import { boxGeometry } from './geometry';
|
||||||
|
|
||||||
|
function rng(seed: number) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x9e3779b9) >>> 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 16), 0x21f0aaad);
|
||||||
|
t = Math.imul(t ^ (t >>> 15), 0x735a2d97);
|
||||||
|
return ((t ^ (t >>> 15)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kugel mit verrauschter Oberflaeche - Grundform aller Kronen und Polster.
|
||||||
|
* `stretchY` staucht oder streckt die Form bereits in der Geometrie. Das ist
|
||||||
|
* wichtig: eine ungleichmaessige Instanz-Skalierung verzerrt die Normalen und
|
||||||
|
* erzeugt harte, glaenzende Kanten.
|
||||||
|
*/
|
||||||
|
function blob(radius: number, detail: number, roughness: number, seed: number, stretchY = 1): BufferGeometry {
|
||||||
|
const g = new IcosahedronGeometry(radius, detail);
|
||||||
|
const pos = g.getAttribute('position') as BufferAttribute;
|
||||||
|
const r = rng(seed);
|
||||||
|
const v = new Vector3();
|
||||||
|
for (let i = 0; i < pos.count; i++) {
|
||||||
|
v.fromBufferAttribute(pos, i);
|
||||||
|
const f = 1 + (r() - 0.5) * 2 * roughness;
|
||||||
|
v.multiplyScalar(f);
|
||||||
|
pos.setXYZ(i, v.x, v.y * stretchY, v.z);
|
||||||
|
}
|
||||||
|
g.computeVertexNormals();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAUB = {
|
||||||
|
parrotia: 0x76944f,
|
||||||
|
apfel: 0x7e9c52,
|
||||||
|
eibe: 0x466b3a,
|
||||||
|
strauch: 0x6b8f45,
|
||||||
|
gras: 0xa3b45c,
|
||||||
|
kraut: 0x8ba455,
|
||||||
|
liguster: 0x557a37,
|
||||||
|
};
|
||||||
|
|
||||||
|
function foliage(color: number, rough = 0.92, flat = false) {
|
||||||
|
return new MeshStandardMaterial({ color, roughness: rough, metalness: 0, flatShading: flat });
|
||||||
|
}
|
||||||
|
|
||||||
|
const RINDE = new MeshStandardMaterial({ color: 0x7d6d59, roughness: 0.9 });
|
||||||
|
|
||||||
|
/** Solitaergehoelz oder Baum: mehrstaemmig, mit gestapelten Kronenblasen. */
|
||||||
|
function buildTree(t: Tree, y: number): Group {
|
||||||
|
const g = new Group();
|
||||||
|
g.name = t.name;
|
||||||
|
const r = rng(Math.round((t.x + 100) * 37 + (t.z + 100) * 91));
|
||||||
|
const crownR = Math.max(0.9, t.crown);
|
||||||
|
const trunkH = t.height * 0.42;
|
||||||
|
|
||||||
|
const stems = t.species === 'parrotia' ? 3 : 1;
|
||||||
|
for (let s = 0; s < stems; s++) {
|
||||||
|
const lean = stems > 1 ? 0.13 : 0;
|
||||||
|
const ox = (r() - 0.5) * 2 * lean * trunkH;
|
||||||
|
const oz = (r() - 0.5) * 2 * lean * trunkH;
|
||||||
|
const geo = new CylinderGeometry(0.05 + r() * 0.015, 0.075 + r() * 0.02, trunkH, 9, 1);
|
||||||
|
const m = new Mesh(geo, RINDE);
|
||||||
|
m.position.set(t.x + ox / 2, y + trunkH / 2, t.z + oz / 2);
|
||||||
|
m.rotation.z = ox / trunkH;
|
||||||
|
m.rotation.x = -oz / trunkH;
|
||||||
|
m.castShadow = true;
|
||||||
|
g.add(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Krone aus mehreren, leicht unterschiedlich getoenten Blasen
|
||||||
|
const blobs = 5 + Math.round(r() * 2);
|
||||||
|
for (let i = 0; i < blobs; i++) {
|
||||||
|
const rr = crownR * (0.6 + r() * 0.35);
|
||||||
|
const geo = blob(rr, 2, 0.07, i * 17 + 5);
|
||||||
|
const shade = 1 - r() * 0.16;
|
||||||
|
const mat = foliage(
|
||||||
|
new Color(LAUB[t.species]).multiplyScalar(shade).getHex(),
|
||||||
|
0.94,
|
||||||
|
);
|
||||||
|
const m = new Mesh(geo, mat);
|
||||||
|
const a = (i / blobs) * Math.PI * 2 + r();
|
||||||
|
const rad = crownR * 0.3 * (i === 0 ? 0 : 1);
|
||||||
|
m.position.set(
|
||||||
|
t.x + Math.cos(a) * rad,
|
||||||
|
y + trunkH + crownR * (0.4 + r() * 0.45),
|
||||||
|
t.z + Math.sin(a) * rad,
|
||||||
|
);
|
||||||
|
m.scale.y = 0.86;
|
||||||
|
m.castShadow = true;
|
||||||
|
m.receiveShadow = true;
|
||||||
|
g.add(m);
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut eine InstancedMesh aus Transformationen. */
|
||||||
|
function instances(
|
||||||
|
geo: BufferGeometry, mat: MeshStandardMaterial,
|
||||||
|
items: { x: number; y: number; z: number; s: number; sy?: number; rot?: number }[],
|
||||||
|
name: string,
|
||||||
|
): InstancedMesh | null {
|
||||||
|
if (!items.length) return null;
|
||||||
|
const mesh = new InstancedMesh(geo, mat, items.length);
|
||||||
|
const m = new Matrix4();
|
||||||
|
const q = new Quaternion();
|
||||||
|
const pos = new Vector3();
|
||||||
|
const scale = new Vector3();
|
||||||
|
const dummy = new Object3D();
|
||||||
|
items.forEach((it, i) => {
|
||||||
|
pos.set(it.x, it.y, it.z);
|
||||||
|
dummy.rotation.set(0, it.rot ?? 0, 0);
|
||||||
|
q.setFromEuler(dummy.rotation);
|
||||||
|
scale.set(it.s, it.sy ?? it.s, it.s);
|
||||||
|
m.compose(pos, q, scale);
|
||||||
|
mesh.setMatrixAt(i, m);
|
||||||
|
});
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
mesh.name = name;
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVegetation(
|
||||||
|
plants: Plant[],
|
||||||
|
trees: Tree[],
|
||||||
|
height: (x: number, z: number) => number,
|
||||||
|
): Group {
|
||||||
|
const group = new Group();
|
||||||
|
group.name = 'Bepflanzung';
|
||||||
|
|
||||||
|
for (const t of trees) group.add(buildTree(t, height(t.x, t.z)));
|
||||||
|
|
||||||
|
const r = rng(4242);
|
||||||
|
|
||||||
|
// --- Eiben `Hilli` 80/100 cm: aufrechte, dichte Polster ------------------
|
||||||
|
// Plan: Pflanzhoehe 80/100 cm, Breite rund 45 cm. Die Streckung steckt in
|
||||||
|
// der Geometrie, die Instanzen werden nur gleichmaessig skaliert.
|
||||||
|
const eiben = plants.filter((p) => p.type === 'eibe' && p.x !== undefined);
|
||||||
|
const eibeGeo = blob(1, 2, 0.028, 3, 1.9);
|
||||||
|
const eibeMesh = instances(eibeGeo, foliage(LAUB.eibe, 0.96), eiben.map((p) => {
|
||||||
|
const s = Math.max(0.22, (p.r ?? 0.2) * 1.1) * (0.92 + r() * 0.18);
|
||||||
|
return { x: p.x!, y: height(p.x!, p.z!) + s * 1.9, z: p.z!, s, rot: r() * 6.28 };
|
||||||
|
}), 'Eiben `Hilli`');
|
||||||
|
if (eibeMesh) group.add(eibeMesh);
|
||||||
|
|
||||||
|
// --- Beerenstraeucher und Heidelbeeren ----------------------------------
|
||||||
|
const straeucher = plants.filter((p) => p.type === 'strauch' && p.x !== undefined);
|
||||||
|
const strauchMesh = instances(blob(1, 2, 0.06, 7, 0.8), foliage(LAUB.strauch), straeucher.map((p) => {
|
||||||
|
const s = (p.r ?? 0.35) * 1.1;
|
||||||
|
return { x: p.x!, y: height(p.x!, p.z!) + s * 0.75, z: p.z!, s, rot: r() * 6.28 };
|
||||||
|
}), 'Beerensträucher');
|
||||||
|
if (strauchMesh) group.add(strauchMesh);
|
||||||
|
|
||||||
|
// --- Graeser: Buendel schmaler, leicht geneigter Halme -------------------
|
||||||
|
const graeser = plants.filter((p) => p.type === 'gras' && p.x !== undefined);
|
||||||
|
if (graeser.length) {
|
||||||
|
// Ein Halm als Einheitsgeometrie mit Fusspunkt im Ursprung; Neigung und
|
||||||
|
// Position kommen aus der Instanzmatrix, nicht aus einer Mesh-Rotation.
|
||||||
|
const blade = new BoxGeometry(0.022, 1, 0.022);
|
||||||
|
blade.translate(0, 0.5, 0);
|
||||||
|
const mesh = new InstancedMesh(blade, foliage(LAUB.gras, 0.98), graeser.length * 9);
|
||||||
|
const dummy = new Object3D();
|
||||||
|
let k = 0;
|
||||||
|
for (const p of graeser) {
|
||||||
|
const y = height(p.x!, p.z!);
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const a = r() * Math.PI * 2;
|
||||||
|
const rad = (p.r ?? 0.12) * r() * 0.9;
|
||||||
|
dummy.position.set(p.x! + Math.cos(a) * rad, y, p.z! + Math.sin(a) * rad);
|
||||||
|
dummy.rotation.set((r() - 0.5) * 0.45, r() * Math.PI, (r() - 0.5) * 0.45);
|
||||||
|
dummy.scale.set(1, 0.42 + r() * 0.4, 1);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
mesh.setMatrixAt(k++, dummy.matrix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mesh.count = k;
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.name = 'Gräser';
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kraeuter und Stauden -----------------------------------------------
|
||||||
|
const kraeuter = plants.filter((p) => p.type === 'kraut' && p.x !== undefined);
|
||||||
|
const krautMesh = instances(blob(1, 2, 0.10, 11, 0.7), foliage(LAUB.kraut, 0.98), kraeuter.map((p) => {
|
||||||
|
const rr = Math.max(0.10, (p.r ?? 0.14) * 0.85);
|
||||||
|
return { x: p.x!, y: height(p.x!, p.z!) + rr * 0.5, z: p.z!, s: rr, rot: r() * 6.28 };
|
||||||
|
}), 'Kräuter');
|
||||||
|
if (krautMesh) group.add(krautMesh);
|
||||||
|
|
||||||
|
// --- Ligusterhecke, Hoehe 170 cm, Breite 75 cm --------------------------
|
||||||
|
for (const p of plants) {
|
||||||
|
if (p.type !== 'hecke' || !p.polygon) continue;
|
||||||
|
const poly = p.polygon;
|
||||||
|
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
|
||||||
|
for (const [x, z] of poly) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); z0 = Math.min(z0, z); z1 = Math.max(z1, z); }
|
||||||
|
const cx = (x0 + x1) / 2, cz = (z0 + z1) / 2;
|
||||||
|
const y = height(cx, cz);
|
||||||
|
const h = p.height ?? 1.7;
|
||||||
|
const geo = boxGeometry(cx, cz, x1 - x0, z1 - z0, y, y + h, 0, 0.8);
|
||||||
|
const m = new Mesh(geo, foliage(LAUB.liguster, 0.97));
|
||||||
|
m.castShadow = true;
|
||||||
|
m.receiveShadow = true;
|
||||||
|
m.name = 'Ligusterhecke';
|
||||||
|
group.add(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Datenmodell der Szene. Entspricht public/data/site.json, das aus dem
|
||||||
|
* Gartenplan-PDF erzeugt wird (siehe tools/build-site.mjs).
|
||||||
|
*
|
||||||
|
* Koordinaten durchwegs in Metern im Site-Frame:
|
||||||
|
* x = Hausachse ENE (Peilung 61.16 Grad), z = senkrecht SSE, y = Hoehe
|
||||||
|
* y = 0 entspricht dem Fixpunkt 651.17 m ue. M. (OK Fertigboden EG Haus 9a)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Vec2 = [number, number];
|
||||||
|
|
||||||
|
export type SurfaceType =
|
||||||
|
| 'travertin80' | 'travertin4060' | 'travertinstell' | 'keramik'
|
||||||
|
| 'verbundstein' | 'tegula' | 'rasen' | 'schotterwiese' | 'beet' | 'spielsand';
|
||||||
|
|
||||||
|
export interface Surface {
|
||||||
|
type: SurfaceType;
|
||||||
|
area: number;
|
||||||
|
polygon: Vec2[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plant {
|
||||||
|
type: 'eibe' | 'strauch' | 'gras' | 'kraut' | 'hecke';
|
||||||
|
x?: number;
|
||||||
|
z?: number;
|
||||||
|
r?: number;
|
||||||
|
kind?: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
polygon?: Vec2[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tree {
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
crown: number;
|
||||||
|
height: number;
|
||||||
|
species: 'parrotia' | 'apfel';
|
||||||
|
neighbour?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lamp {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
kind: 'poller' | 'boden';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Wall {
|
||||||
|
id: string;
|
||||||
|
type: 'travertin' | 'beton' | 'holzscheiter' | 'lamellen';
|
||||||
|
note?: string;
|
||||||
|
path: Vec2[];
|
||||||
|
t: number;
|
||||||
|
top: number | null;
|
||||||
|
base: number | null;
|
||||||
|
seating?: boolean;
|
||||||
|
openSlats?: boolean;
|
||||||
|
retains?: string;
|
||||||
|
crestProfile?: number[];
|
||||||
|
footProfile?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Prop {
|
||||||
|
id: string;
|
||||||
|
type: 'gartenhaus' | 'spielhaus' | 'flammengrill' | 'bank' | 'roboter' | 'wasserhahn' | 'schacht' | 'tor';
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
w: number;
|
||||||
|
d: number;
|
||||||
|
h: number;
|
||||||
|
rot: number;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stele { x: number; z: number; h: number }
|
||||||
|
|
||||||
|
export interface Opening {
|
||||||
|
face: 'north' | 'south' | 'east' | 'west';
|
||||||
|
a: number;
|
||||||
|
b: number;
|
||||||
|
sill: number;
|
||||||
|
head: number;
|
||||||
|
kind: 'glass' | 'window' | 'door' | 'slats';
|
||||||
|
upper?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Building {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
floorLevel: number;
|
||||||
|
upperLevel: number;
|
||||||
|
roofLevel: number;
|
||||||
|
parapet: number;
|
||||||
|
outline: Vec2[];
|
||||||
|
loggia: { x0: number; z0: number; x1: number; z1: number; ceiling: number };
|
||||||
|
canopy: { a: number; b: number; depth: number; y: number; thickness: number };
|
||||||
|
openings: Opening[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NeighbourBuilding {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
outline: Vec2[];
|
||||||
|
floorLevel: number;
|
||||||
|
roofLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartView {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
yaw: number;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Terrain {
|
||||||
|
grid: { x0: number; z0: number; cell: number; nx: number; nz: number };
|
||||||
|
heights: number[];
|
||||||
|
zones: { id: string; label: string; polygon: Vec2[] }[];
|
||||||
|
spots: { x: number; z: number; y: number; zone: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteData {
|
||||||
|
meta: {
|
||||||
|
plan: string;
|
||||||
|
architecture: string;
|
||||||
|
generated: string;
|
||||||
|
originAbsoluteMasl: number;
|
||||||
|
location: {
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
northBearingOfSiteX: number;
|
||||||
|
groundMasl: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
terrain: Terrain;
|
||||||
|
surfaces: Surface[];
|
||||||
|
plants: Plant[];
|
||||||
|
trees: Tree[];
|
||||||
|
lamps: Lamp[];
|
||||||
|
walls: Wall[];
|
||||||
|
props: Prop[];
|
||||||
|
stelen: Stele[];
|
||||||
|
building: Building;
|
||||||
|
neighbourBuildings: NeighbourBuilding[];
|
||||||
|
startViews: StartView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSite(url = './data/site.json'): Promise<SiteData> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`site.json konnte nicht geladen werden (${res.status})`);
|
||||||
|
return (await res.json()) as SiteData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bilineare Hoeheninterpolation im Raster. Ausserhalb des Rasters wird der
|
||||||
|
* Randwert gehalten, damit die Szene nirgends ins Leere faellt.
|
||||||
|
*/
|
||||||
|
export function makeHeightSampler(terrain: Terrain): (x: number, z: number) => number {
|
||||||
|
const { x0, z0, cell, nx, nz } = terrain.grid;
|
||||||
|
const h = Float32Array.from(terrain.heights);
|
||||||
|
return (x: number, z: number) => {
|
||||||
|
const fi = (x - x0) / cell;
|
||||||
|
const fj = (z - 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));
|
||||||
|
const tz = Math.max(0, Math.min(1, fj - j));
|
||||||
|
const h00 = h[j * nx + i], h10 = h[j * nx + i + 1];
|
||||||
|
const h01 = h[(j + 1) * nx + i], h11 = h[(j + 1) * nx + i + 1];
|
||||||
|
return (
|
||||||
|
h00 * (1 - tx) * (1 - tz) + h10 * tx * (1 - tz) +
|
||||||
|
h01 * (1 - tx) * tz + h11 * tx * tz
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
:root {
|
||||||
|
--ink: #1d241c;
|
||||||
|
--paper: rgba(250, 249, 245, 0.92);
|
||||||
|
--line: rgba(29, 36, 28, 0.14);
|
||||||
|
--accent: #6f8f3f;
|
||||||
|
--shadow: 0 6px 24px rgba(20, 28, 18, 0.22);
|
||||||
|
--radius: 12px;
|
||||||
|
font-family: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #10150f;
|
||||||
|
color: var(--ink);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#view {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Ladeanzeige ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
#loader {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(170deg, #3d5238, #1b2418 70%);
|
||||||
|
color: #f2f4ec;
|
||||||
|
z-index: 50;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loader.done { opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
.loader-inner { width: min(320px, 76vw); text-align: center; }
|
||||||
|
.loader-title { font-size: 1.05rem; font-weight: 600; letter-spacing: 0.01em; margin-bottom: 1rem; }
|
||||||
|
.loader-bar { height: 3px; background: rgba(255, 255, 255, 0.18); border-radius: 3px; overflow: hidden; }
|
||||||
|
.loader-bar span { display: block; height: 100%; width: 0; background: #b9d08a; transition: width 0.3s ease; }
|
||||||
|
.loader-note { margin-top: 0.7rem; font-size: 0.8rem; opacity: 0.72; }
|
||||||
|
|
||||||
|
/* -- Bedienoberflaeche ---------------------------------------------------- */
|
||||||
|
|
||||||
|
#hud { position: fixed; inset: 0; pointer-events: none; z-index: 10; }
|
||||||
|
#hud button, #hud input, #hud a { pointer-events: auto; }
|
||||||
|
|
||||||
|
#topbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.7rem clamp(0.7rem, 2vw, 1.3rem);
|
||||||
|
padding-top: max(0.7rem, env(safe-area-inset-top));
|
||||||
|
background: linear-gradient(to bottom, rgba(12, 18, 10, 0.62), rgba(12, 18, 10, 0));
|
||||||
|
color: #f4f6ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { display: flex; flex-direction: column; line-height: 1.32; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); }
|
||||||
|
.brand strong { font-size: 0.95rem; font-weight: 600; }
|
||||||
|
.brand span { font-size: 0.72rem; opacity: 0.8; }
|
||||||
|
|
||||||
|
.topbar-actions { display: flex; gap: 0.5rem; }
|
||||||
|
|
||||||
|
#hud button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.42rem 0.85rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(14, 20, 12, 0.45);
|
||||||
|
color: #f4f6ee;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
#hud button:hover { background: rgba(30, 42, 24, 0.7); border-color: rgba(255, 255, 255, 0.5); }
|
||||||
|
#hud button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
|
||||||
|
#views {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: max(0.8rem, env(safe-area-inset-bottom));
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0 0.6rem;
|
||||||
|
max-width: 96vw;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
#views::-webkit-scrollbar { display: none; }
|
||||||
|
#views button { white-space: nowrap; }
|
||||||
|
|
||||||
|
#timebar {
|
||||||
|
position: absolute;
|
||||||
|
right: clamp(0.7rem, 2vw, 1.3rem);
|
||||||
|
top: 4.2rem;
|
||||||
|
width: min(230px, 46vw);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(14, 20, 12, 0.42);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: #eef1e6;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
#timebar label { display: flex; justify-content: space-between; opacity: 0.85; }
|
||||||
|
#timebar output { font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||||
|
#timebar input { width: 100%; accent-color: var(--accent); margin: 0 0 0.45rem; }
|
||||||
|
|
||||||
|
#crosshair {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%; top: 50%;
|
||||||
|
width: 5px; height: 5px;
|
||||||
|
margin: -2.5px 0 0 -2.5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
#crosshair.on { opacity: 1; }
|
||||||
|
|
||||||
|
#hint {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 3.4rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(14, 20, 12, 0.5);
|
||||||
|
color: #eef1e6;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
#hint.fade { opacity: 0; }
|
||||||
|
#hint kbd {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.02em 0.34em;
|
||||||
|
margin: 0 0.06em;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
#hint .touch { display: none; }
|
||||||
|
|
||||||
|
#joystick {
|
||||||
|
position: absolute;
|
||||||
|
left: 1.3rem;
|
||||||
|
bottom: max(4.6rem, calc(env(safe-area-inset-bottom) + 4.4rem));
|
||||||
|
width: 116px;
|
||||||
|
height: 116px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
background: rgba(14, 20, 12, 0.3);
|
||||||
|
pointer-events: auto;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
#joystick-knob {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%; top: 50%;
|
||||||
|
width: 46px; height: 46px;
|
||||||
|
margin: -23px 0 0 -23px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(240, 244, 232, 0.7);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#info {
|
||||||
|
position: absolute;
|
||||||
|
right: clamp(0.7rem, 2vw, 1.3rem);
|
||||||
|
top: 4.2rem;
|
||||||
|
width: min(390px, 88vw);
|
||||||
|
max-height: min(72vh, 640px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.1rem 1.25rem 1.3rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
pointer-events: auto;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
#info h2 { margin: 0 0 0.6rem; font-size: 1rem; }
|
||||||
|
#info h3 { margin: 1.1rem 0 0.3rem; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.06em; color: #5d6a55; }
|
||||||
|
#info p { margin: 0 0 0.5rem; }
|
||||||
|
#info dl { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem 0.8rem; margin: 0; }
|
||||||
|
#info dt { color: #5d6a55; }
|
||||||
|
#info dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||||
|
#info ul { margin: 0.2rem 0 0.6rem; padding-left: 1.1rem; }
|
||||||
|
#info li { margin-bottom: 0.15rem; }
|
||||||
|
#info-close {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.55rem; top: 0.5rem;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
padding: 0 !important;
|
||||||
|
font-size: 1.1rem !important;
|
||||||
|
line-height: 1;
|
||||||
|
border-color: var(--line) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--ink) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
#hint .desktop { display: none; }
|
||||||
|
#hint .touch { display: inline; }
|
||||||
|
#timebar { top: auto; bottom: max(9.5rem, calc(env(safe-area-inset-bottom) + 9rem)); width: min(200px, 44vw); }
|
||||||
|
#hint { bottom: 8.2rem; font-size: 0.68rem; }
|
||||||
|
#views { bottom: max(0.6rem, env(safe-area-inset-bottom)); justify-content: flex-start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.brand span { display: none; }
|
||||||
|
}
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Bedienoberflaeche: Standorte, Tageszeit, Modusumschaltung, Joystick, Infobox.
|
||||||
|
*/
|
||||||
|
import type { SiteData, StartView } from '../site';
|
||||||
|
import type { SunState } from '../scene/environment';
|
||||||
|
import { isTouch } from '../controls/walker';
|
||||||
|
|
||||||
|
export interface HudOptions {
|
||||||
|
site: SiteData;
|
||||||
|
onView(view: StartView): void;
|
||||||
|
onMode(mode: 'walk' | 'overview'): void;
|
||||||
|
onJoystick(x: number, y: number, active: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hud {
|
||||||
|
time(): { hours: number; day: number };
|
||||||
|
setMode(mode: 'walk' | 'overview'): void;
|
||||||
|
setPointerLocked(locked: boolean): void;
|
||||||
|
setSunReadout(sun: SunState): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||||
|
|
||||||
|
function dayLabel(dayOfYear: number): string {
|
||||||
|
const d = new Date(2026, 0, 1);
|
||||||
|
d.setDate(dayOfYear);
|
||||||
|
return `${d.getDate()}. ${MONTHS[d.getMonth()]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeLabel(hours: number): string {
|
||||||
|
const h = Math.floor(hours);
|
||||||
|
const m = Math.round((hours - h) * 60);
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupHud(opts: HudOptions): Hud {
|
||||||
|
const { site } = opts;
|
||||||
|
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||||
|
|
||||||
|
const timeInput = $<HTMLInputElement>('time');
|
||||||
|
const timeOut = $<HTMLOutputElement>('time-out');
|
||||||
|
const seasonInput = $<HTMLInputElement>('season');
|
||||||
|
const seasonOut = $<HTMLOutputElement>('season-out');
|
||||||
|
const btnMode = $<HTMLButtonElement>('btn-mode');
|
||||||
|
const btnInfo = $<HTMLButtonElement>('btn-info');
|
||||||
|
const info = $<HTMLElement>('info');
|
||||||
|
const crosshair = $<HTMLElement>('crosshair');
|
||||||
|
const hint = $<HTMLElement>('hint');
|
||||||
|
|
||||||
|
// -- Standorte -----------------------------------------------------------
|
||||||
|
const nav = $<HTMLElement>('views');
|
||||||
|
for (const v of site.startViews) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = v.label;
|
||||||
|
if (v.note) btn.title = v.note;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
for (const other of nav.querySelectorAll('button')) other.removeAttribute('aria-pressed');
|
||||||
|
btn.setAttribute('aria-pressed', 'true');
|
||||||
|
opts.onView(v);
|
||||||
|
});
|
||||||
|
nav.append(btn);
|
||||||
|
}
|
||||||
|
nav.querySelector('button')?.setAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
// -- Tageszeit -----------------------------------------------------------
|
||||||
|
const syncTime = () => {
|
||||||
|
timeOut.value = timeLabel(+timeInput.value);
|
||||||
|
seasonOut.value = dayLabel(+seasonInput.value);
|
||||||
|
};
|
||||||
|
timeInput.addEventListener('input', syncTime);
|
||||||
|
seasonInput.addEventListener('input', syncTime);
|
||||||
|
syncTime();
|
||||||
|
|
||||||
|
// -- Modus ---------------------------------------------------------------
|
||||||
|
let mode: 'walk' | 'overview' = 'walk';
|
||||||
|
const setMode = (next: 'walk' | 'overview') => {
|
||||||
|
mode = next;
|
||||||
|
btnMode.textContent = mode === 'walk' ? 'Übersicht' : 'Rundgang';
|
||||||
|
btnMode.setAttribute('aria-pressed', String(mode === 'overview'));
|
||||||
|
hint.style.display = mode === 'overview' ? 'none' : '';
|
||||||
|
opts.onMode(mode);
|
||||||
|
};
|
||||||
|
btnMode.addEventListener('click', () => setMode(mode === 'walk' ? 'overview' : 'walk'));
|
||||||
|
|
||||||
|
// -- Infobox -------------------------------------------------------------
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
for (const s of site.surfaces) totals.set(s.type, (totals.get(s.type) ?? 0) + s.area);
|
||||||
|
const NAMES: Record<string, string> = {
|
||||||
|
travertin80: 'Travertinplatten 80×80×3 cm',
|
||||||
|
travertin4060: 'Travertinplatten 40×60×3 cm',
|
||||||
|
travertinstell: 'Travertinstellplatten',
|
||||||
|
keramik: 'Keramikplatten 60×60×2 cm',
|
||||||
|
verbundstein: 'Verbundsteine (Nachbar)',
|
||||||
|
tegula: 'Tegula-Pflastersteine grau',
|
||||||
|
rasen: 'Rasen',
|
||||||
|
schotterwiese: 'Schotterwiese',
|
||||||
|
beet: 'Pflanzbeete',
|
||||||
|
spielsand: 'Spielsand',
|
||||||
|
};
|
||||||
|
const heights = site.terrain.spots.filter((s) => s.zone === 'garten').map((s) => s.y);
|
||||||
|
const eibenCount = site.plants.filter((p) => p.type === 'eibe').length;
|
||||||
|
|
||||||
|
$<HTMLElement>('info-body').innerHTML = `
|
||||||
|
<p>Alle Flächen, Mauern und Pflanzstandorte sind massstäblich aus dem Gartenplan
|
||||||
|
übernommen. Die Vektorgeometrie wurde direkt aus dem Plan-PDF (1:100) extrahiert,
|
||||||
|
das Gelände aus den ${site.terrain.spots.length} Höhenkoten interpoliert.</p>
|
||||||
|
|
||||||
|
<h3>Grundlagen</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Gartenplan</dt><dd>${site.meta.plan}</dd>
|
||||||
|
<dt>Architektur</dt><dd>${site.meta.architecture}</dd>
|
||||||
|
<dt>Standort</dt><dd>${site.meta.location.name}</dd>
|
||||||
|
<dt>Fixpunkt 0.00</dt><dd>${site.meta.location.groundMasl.toFixed(2)} m ü. M. (OK FB EG)</dd>
|
||||||
|
<dt>Höhen im Garten</dt><dd>${Math.min(...heights).toFixed(2)} bis ${Math.max(...heights).toFixed(2)} m</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3>Beläge</h3>
|
||||||
|
<dl>${[...totals.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([k, v]) => `<dt>${NAMES[k] ?? k}</dt><dd>${v.toFixed(1)} m²</dd>`)
|
||||||
|
.join('')}</dl>
|
||||||
|
|
||||||
|
<h3>Bepflanzung und Ausstattung</h3>
|
||||||
|
<ul>
|
||||||
|
<li>${site.trees.length} Solitärgehölze und Bäume, inkl. Apfelbaum</li>
|
||||||
|
<li>${eibenCount} Eiben „Hilli“ 80/100 cm als Heckenpflanzung</li>
|
||||||
|
<li>Ligusterhecke 170 cm hoch, 75 cm breit</li>
|
||||||
|
<li>${site.walls.length} Mauern und Wände, ${site.stelen.length} Travertinstelen</li>
|
||||||
|
<li>${site.lamps.length} Leuchten – sie schalten sich abends automatisch ein</li>
|
||||||
|
<li>Gartenhaus 1,8 × 1,8 m, Spielhaus, Flammengrill mit Holzscheiterwand</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Bedienung</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Rundgang: Klicken, dann <kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd>, <kbd>Shift</kbd> zum Rennen</li>
|
||||||
|
<li>Am Handy: links der Joystick, rechts wischen zum Umsehen</li>
|
||||||
|
<li>Übersicht: ziehen zum Drehen, Mausrad zum Zoomen</li>
|
||||||
|
<li>Die Sonne steht für Abtwil SG astronomisch korrekt</li>
|
||||||
|
</ul>
|
||||||
|
<p id="sun-readout"></p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const toggleInfo = () => {
|
||||||
|
info.hidden = !info.hidden;
|
||||||
|
btnInfo.setAttribute('aria-pressed', String(!info.hidden));
|
||||||
|
};
|
||||||
|
btnInfo.addEventListener('click', toggleInfo);
|
||||||
|
$<HTMLButtonElement>('info-close').addEventListener('click', toggleInfo);
|
||||||
|
|
||||||
|
// -- Joystick ------------------------------------------------------------
|
||||||
|
if (isTouch()) {
|
||||||
|
const pad = $<HTMLElement>('joystick');
|
||||||
|
const knob = $<HTMLElement>('joystick-knob');
|
||||||
|
let id: number | null = null;
|
||||||
|
const radius = 36;
|
||||||
|
const reset = () => {
|
||||||
|
knob.style.transform = '';
|
||||||
|
opts.onJoystick(0, 0, false);
|
||||||
|
id = null;
|
||||||
|
};
|
||||||
|
pad.addEventListener('touchstart', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
id = e.changedTouches[0].identifier;
|
||||||
|
}, { passive: false });
|
||||||
|
pad.addEventListener('touchmove', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
for (const t of Array.from(e.changedTouches)) {
|
||||||
|
if (t.identifier !== id) continue;
|
||||||
|
const r = pad.getBoundingClientRect();
|
||||||
|
let dx = t.clientX - (r.left + r.width / 2);
|
||||||
|
let dy = t.clientY - (r.top + r.height / 2);
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len > radius) { dx = (dx / len) * radius; dy = (dy / len) * radius; }
|
||||||
|
knob.style.transform = `translate(${dx}px, ${dy}px)`;
|
||||||
|
opts.onJoystick(dx / radius, dy / radius, true);
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
pad.addEventListener('touchend', reset, { passive: true });
|
||||||
|
pad.addEventListener('touchcancel', reset, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => hint.classList.add('fade'), 9000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: () => ({
|
||||||
|
hours: Math.round(+timeInput.value * 4) / 4,
|
||||||
|
day: +seasonInput.value,
|
||||||
|
}),
|
||||||
|
setMode,
|
||||||
|
setPointerLocked(locked) {
|
||||||
|
crosshair.classList.toggle('on', locked);
|
||||||
|
if (locked) hint.classList.add('fade');
|
||||||
|
},
|
||||||
|
setSunReadout(sun) {
|
||||||
|
const el = document.getElementById('sun-readout');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = sun.altitude > 0
|
||||||
|
? `Sonnenstand: ${sun.altitude.toFixed(0)}° über dem Horizont, Azimut ${sun.azimuth.toFixed(0)}°.`
|
||||||
|
: 'Die Sonne steht unter dem Horizont – die Gartenbeleuchtung ist eingeschaltet.';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
target: 'es2022',
|
||||||
|
chunkSizeWarningLimit: 1200,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: { three: ['three'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { host: true, port: 5173 },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user