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
+225
View File
@@ -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),
};
}