Begehbare 3D-Visualisierung des Gartens Egglihaldenstrasse 9a

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:59:37 +02:00
co-authored by Claude Opus 5
commit 70d239fe9c
43 changed files with 5614 additions and 0 deletions
+203
View File
@@ -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 &bdquo;Hilli&ldquo; 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 &ndash; 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.';
},
};
}