Nordgrenze zum Nachbarn, Torbogen, einfacher Rundgang
Deploy App / deploy (push) Successful in 27s

Nordgrenze
- Sitzmauer und Travertinmauer waren vertauscht. Der Plan zeichnet aussen,
  auf der Terrasse der Nachbarn, die "Sitzmauer" (0.55 tief, rund 40 cm
  hoch: Terrasse -1.77, Krone -1.35) und direkt dahinter die
  "Travertinmauer", die jetzt bis auf das Niveau der Nachbarn hinunter-
  reicht - sie sehen Naturstein statt Beton.
- Terrasse der Nachbarn fest auf -1.77.
- Schotterwiese (Flaechen 4 und 5) eben: quer kein Hang mehr, laengs folgt
  sie der Feuerwehrzufahrt. Unter der Ligusterhecke fassen Winkelelemente
  den Garten der Nachbarn, so wie der Plan sie dort beschriftet. Wiesen mit
  fester Ebene werden jetzt plan gerendert statt dem Raster zu folgen.
- Eingang fuer die Nachbarn: Travertinstufe in der Luecke zwischen Hecke
  und Mauer ("Stufe" im Plan), darueber ein Torbogen aus Cortenstahl mit
  Kletterrose. Kein Tor.

Rundgang
- Klick auf den Boden laeuft dorthin, Ziehen dreht den Blick. Kein
  Mausfang mehr.
- Weiches Anfahren und Bremsen, gedaempftes Nachfuehren bei Stufen statt
  Fall-und-Aufprall, Rutschen an Hindernissen.
- Travertinmauern mit fester Krone sind begehbar; man steigt ueber die
  Sitzmauer vom Sitzplatz auf den Rasen, statt springen zu muessen.
- Gartentor oeffnet sich beim Naehertreten und schliesst hinter einem, E
  uebersteuert. Schwelle unter dem Tor schliesst die Luecke zum Podest.
- Blickrichtung blieb beim Wechsel Uebersicht/Rundgang nicht erhalten
  (Vorzeichenfehler in yawDegrees).
- mergeGeometries loest indizierte Geometrien vorher auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 09:50:44 +02:00
co-authored by Claude Opus 5
parent 866bedf64d
commit 1ac99b1660
11 changed files with 469 additions and 112 deletions
+19 -6
View File
@@ -207,13 +207,26 @@ offen** („Aussensitzplatz“ in der Südwestfassade) – gegen den Garten steh
eine geschlossene Wand. Ohne sie schaut man von aussen durch die Fassade in den
Sitzplatz hinein.
### Treppen begehen und springen
### Rundgang
Stufen und Treppen stecken nicht im Höhenraster, sondern sind eigene Körper.
Damit man sie begehen kann, rechnet `main.ts` ihre Trittflächen in die
Höhenabfrage des Rundgangs ein (`treppeTritte` in `src/scene/structures.ts`);
die schwebenden Stufen sind entsprechend **kein** Hindernis mehr. Dazu kommt
der Sprung mit der Leertaste – rund 55 cm, genug für jede Mauerkrone.
Bewusst einfach: **auf den Boden klicken** (am Handy tippen), dann läuft man
dorthin; **ziehen** zum Umsehen. Tastatur geht weiterhin (`W A S D`, `Shift`,
`Leertaste` springt). Es gibt keinen Mausfang mehr – der Zeiger bleibt frei
für die Bedienelemente.
Damit es nicht ruppig wirkt:
- Der Spieler beschleunigt und bremst weich und rutscht an Hindernissen entlang.
- Höhenwechsel bis 60 cm werden gedämpft nachgeführt, hinauf wie hinunter. Nur
bei grösseren Abstürzen oder im Sprung wirkt die Schwerkraft. Tiefer als
1.20 m läuft man nicht einfach von einer Mauer hinunter.
- Stufen, Treppen und **Travertinmauern mit fester Krone** sind begehbar: ihre
Oberkante geht in die Höhenabfrage ein (`walkHeight` in `main.ts`,
`treppeTritte` in `structures.ts`). Ob man hinaufkommt, entscheidet nur die
Stufenhöhe (45 cm). Holz-, Lamellen- und Betonwände bleiben Hindernisse.
- Das **Gartentor** öffnet sich, wenn man darauf zugeht, und schliesst sich
hinter einem wieder; `E` übersteuert das von Hand. Im Durchgang selbst
schliesst es nie.
### Warum Zonen statt eines einzigen Geländes
+2 -2
View File
@@ -43,8 +43,8 @@
<div id="crosshair"></div>
<div id="hint">
<span class="desktop">Klicken zum Umsehen &middot; <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> gehen &middot; <kbd>Shift</kbd> schneller &middot; <kbd>Leertaste</kbd> springen &middot; <kbd>Esc</kbd> Maus freigeben</span>
<span class="touch">Links ziehen zum Gehen &middot; rechts wischen zum Umsehen</span>
<span class="desktop">Auf den Boden klicken: dorthin gehen &middot; Ziehen: umsehen &middot; <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> gehen &middot; <kbd>Leertaste</kbd> springen</span>
<span class="touch">Auf den Boden tippen: dorthin gehen &middot; rechts wischen: umsehen</span>
</div>
<div id="joystick" hidden><div id="joystick-knob"></div></div>
File diff suppressed because one or more lines are too long
+165 -56
View File
@@ -1,7 +1,16 @@
/**
* Begehen des Gartens: Maus/Tastatur am Desktop, Joystick und Wischen am Handy.
* Der Spieler folgt dem Gelaende, kann Stufen bis 45 cm ersteigen, mit der
* Leertaste springen und wird von Mauern, Gebaeuden und Objekten aufgehalten.
* Begehen des Gartens.
*
* Bewusst einfach gehalten:
* - Umsehen: mit der Maus ziehen (am Handy rechts wischen). Kein Mausfang,
* der Mauszeiger bleibt frei fuer die Bedienelemente.
* - Gehen: W A S D oder Pfeiltasten, am Handy der Joystick - oder einfach
* auf den Boden klicken bzw. tippen, dann laeuft man dorthin.
* - Leertaste springt.
*
* Damit es sich nicht ruppig anfuehlt, beschleunigt und bremst der Spieler
* weich, folgt Stufen und Rampen gedaempft statt sprunghaft und rutscht an
* Hindernissen entlang, statt haengen zu bleiben.
*/
import { Euler, PerspectiveCamera, Vector3 } from 'three';
@@ -13,115 +22,163 @@ export interface Obstacle {
hd: number;
/** Drehung um Y in Radiant. */
rot: number;
/** Hindernisse unterhalb dieser Hoehe kann man uebersteigen. */
climbable?: boolean;
/** Voruebergehend durchlaessig, etwa ein offenes Tor. */
disabled?: boolean;
}
export interface WalkerOptions {
height: (x: number, z: number) => number;
obstacles: Obstacle[];
bounds: { x0: number; z0: number; x1: number; z1: number };
/** Liefert den Bodenpunkt unter einer Bildschirmposition, fuer Klick-zum-Gehen. */
pick?: (clientX: number, clientY: number) => { x: number; z: number } | null;
eyeHeight?: number;
radius?: number;
maxStep?: number;
}
const SPEED_WALK = 1.65;
const SPEED_WALK = 1.7;
const SPEED_RUN = 3.6;
/** Wie schnell die Geschwindigkeit dem Wunsch folgt (1/s). */
const ACCEL = 9;
/** Absprunggeschwindigkeit: reicht fuer rund 55 cm Sprunghoehe. */
const JUMP = 3.3;
const GRAVITY = 9.81;
/** Tiefer als das laeuft man nicht einfach hinunter, etwa von einer Stuetzmauer. */
const MAX_DROP = 1.2;
/** Ab so viel Hoehenunterschied faellt man, darunter wird weich nachgefuehrt. */
const FALL_FROM = 0.6;
export class Walker {
readonly camera: PerspectiveCamera;
private readonly opts: Required<WalkerOptions>;
private readonly opts: Required<Omit<WalkerOptions, 'pick'>> & Pick<WalkerOptions, 'pick'>;
private readonly euler = new Euler(0, 0, 0, 'YXZ');
private readonly keys = new Set<string>();
private readonly pos = new Vector3();
private vx = 0;
private vz = 0;
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;
/** Ziel fuer Klick-zum-Gehen. */
private target: { x: number; z: number } | null = null;
private stuck = 0;
/** Wird gesetzt, wenn der Nutzer die Maus gefangen hat oder wieder frei ist. */
onPointerLockChange?: (locked: boolean) => void;
/** Wird bei jedem neuen Gehziel aufgerufen (null = kein Ziel mehr). */
onTarget?: (t: { x: number; z: number } | null) => void;
constructor(camera: PerspectiveCamera, options: WalkerOptions) {
this.camera = camera;
this.opts = {
eyeHeight: 1.68,
radius: 0.32,
radius: 0.28,
maxStep: 0.45,
...options,
} as Required<WalkerOptions>;
} as Walker['opts'];
}
get position(): Vector3 { return this.pos; }
get yawDegrees(): number { return (this.euler.y * 180) / Math.PI; }
get yawDegrees(): number { return (-this.euler.y * 180) / Math.PI; }
setEnabled(on: boolean) { this.enabled = on; }
setEnabled(on: boolean) {
this.enabled = on;
if (!on) this.setTarget(null);
}
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.vx = this.vz = this.velocityY = 0;
this.grounded = true;
this.setTarget(null);
this.applyCamera();
}
private setTarget(t: { x: number; z: number } | null) {
this.target = t;
this.stuck = 0;
this.onTarget?.(t);
}
// -- Eingaben -------------------------------------------------------------
attach(dom: HTMLElement) {
window.addEventListener('keydown', (e) => {
if (e.code === 'Tab') return;
if (e.code === 'Tab' || e.target instanceof HTMLInputElement) 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(() => {});
// Maus: ziehen = umsehen, kurzer Klick = dorthin gehen.
let down: { x: number; y: number; id: number } | null = null;
let dragged = false;
dom.addEventListener('pointerdown', (e) => {
if (!this.enabled || e.pointerType !== 'mouse' || e.button !== 0) return;
down = { x: e.clientX, y: e.clientY, id: e.pointerId };
dragged = false;
dom.setPointerCapture(e.pointerId);
});
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;
dom.addEventListener('pointermove', (e) => {
if (!down || e.pointerId !== down.id) return;
if (!dragged && Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) dragged = true;
if (dragged) {
this.lookDelta.x += e.movementX * 0.0042;
this.lookDelta.y += e.movementY * 0.0042;
dom.style.cursor = 'grabbing';
}
});
const up = (e: PointerEvent) => {
if (!down || e.pointerId !== down.id) return;
if (!dragged) this.walkTo(e.clientX, e.clientY);
down = null;
dom.style.cursor = '';
};
dom.addEventListener('pointerup', up);
dom.addEventListener('pointercancel', () => { down = null; dom.style.cursor = ''; });
// Wischen zum Umsehen (rechte Bildschirmhaelfte bleibt fuer den Blick frei)
// Touch: rechts wischen = umsehen, tippen = dorthin gehen
let touchId: number | null = null;
let last = { x: 0, y: 0 };
let start = { x: 0, y: 0, t: 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 };
start = { x: t.clientX, y: t.clientY, t: performance.now() };
}
}
}, { 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;
this.lookDelta.x += (t.clientX - last.x) * 0.006;
this.lookDelta.y += (t.clientY - last.y) * 0.006;
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;
for (const t of Array.from(e.changedTouches)) {
if (t.identifier !== touchId) continue;
touchId = null;
const moved = Math.hypot(t.clientX - start.x, t.clientY - start.y);
if (moved < 10 && performance.now() - start.t < 350) this.walkTo(t.clientX, t.clientY);
}
};
dom.addEventListener('touchend', end, { passive: true });
dom.addEventListener('touchcancel', end, { passive: true });
}
private walkTo(clientX: number, clientY: number) {
if (!this.enabled || !this.opts.pick) return;
const hit = this.opts.pick(clientX, clientY);
if (hit) this.setTarget(hit);
}
setJoystick(x: number, y: number, active: boolean) {
this.joystick = { x, y, active };
}
@@ -131,9 +188,11 @@ export class Walker {
update(dt: number) {
if (!this.enabled) return;
// Blick: man zieht die Szene - wie in der Uebersicht und bei Street View.
// Nach rechts ziehen dreht den Blick nach links, nach unten ziehen hebt ihn.
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.euler.x = Math.max(-1.3, Math.min(1.3, this.euler.x));
this.lookDelta.x = 0;
this.lookDelta.y = 0;
@@ -146,17 +205,49 @@ export class Walker {
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 speed = running ? SPEED_RUN : SPEED_WALK;
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;
let wx = (-sin * forward + cos * strafe) * speed;
let wz = (-cos * forward - sin * strafe) * speed;
this.move(dx, dz);
// Klick-zum-Gehen: nur, solange nicht von Hand gesteuert wird
if (len > 0.05) {
if (this.target) this.setTarget(null);
} else if (this.target) {
const tx = this.target.x - this.pos.x, tz = this.target.z - this.pos.z;
const dist = Math.hypot(tx, tz);
if (dist < 0.25) {
this.setTarget(null);
} else {
// kurz vor dem Ziel sanft auslaufen
const v = SPEED_WALK * Math.min(1, dist / 0.8);
wx = (tx / dist) * v;
wz = (tz / dist) * v;
// Blick dreht langsam in die Gehrichtung
const want = Math.atan2(-tx, -tz);
let d = want - this.euler.y;
d = Math.atan2(Math.sin(d), Math.cos(d));
this.euler.y += d * Math.min(1, dt * 3);
}
}
// Weich beschleunigen und bremsen
const k = Math.min(1, dt * ACCEL);
this.vx += (wx - this.vx) * k;
this.vz += (wz - this.vz) * k;
if (Math.abs(this.vx) < 1e-4 && Math.abs(this.vz) < 1e-4) { this.vx = 0; this.vz = 0; }
const before = { x: this.pos.x, z: this.pos.z };
this.move(this.vx * dt, this.vz * dt);
if (this.target) {
const moved = Math.hypot(this.pos.x - before.x, this.pos.z - before.z);
this.stuck = moved < 0.15 * SPEED_WALK * dt ? this.stuck + dt : 0;
if (this.stuck > 0.5) this.setTarget(null);
}
// Springen: nur vom Boden weg, danach uebernimmt die Schwerkraft.
if (this.keys.has('Space') && this.grounded) {
@@ -164,48 +255,66 @@ export class Walker {
this.grounded = false;
}
// Vertikal: dem Gelaende folgen, Absaetze weich abfangen
// Vertikal: kleine Hoehenwechsel gedaempft nachfuehren - hinauf wie
// hinunter. Nur bei grossen Abstuerzen oder im Sprung wirkt die Schwerkraft.
const ground = this.opts.height(this.pos.x, this.pos.z) + this.opts.eyeHeight;
if (!this.grounded || this.pos.y > ground + 0.02) {
if (this.grounded && this.pos.y - ground > FALL_FROM) this.grounded = false;
if (!this.grounded) {
this.velocityY -= GRAVITY * dt;
this.pos.y += this.velocityY * dt;
if (this.velocityY <= 0 && 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.pos.y += (ground - this.pos.y) * Math.min(1, dt * 11);
this.velocityY = 0;
this.grounded = true;
}
this.applyCamera();
}
/** Bewegt den Spieler und loest Kollisionen achsenweise auf. */
/**
* Bewegt den Spieler. Gelingt der ganze Schritt nicht, wird entlang der
* Achsen gerutscht - so gleitet man an Mauern entlang statt stehenzubleiben.
*/
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;
if (dx === 0 && dz === 0) return;
if (this.canStand(this.pos.x + dx, this.pos.z + dz)) {
this.pos.x += dx;
this.pos.z += dz;
return;
}
if (dx !== 0 && this.canStand(this.pos.x + dx, this.pos.z)) {
this.pos.x += dx;
this.vz *= 0.5;
} else if (dz !== 0 && this.canStand(this.pos.x, this.pos.z + dz)) {
this.pos.z += dz;
this.vx *= 0.5;
} else {
this.vx *= 0.3;
this.vz *= 0.3;
}
}
private canStand(nx: number, nz: number): boolean {
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;
if (this.blocked(nx, nz)) return false;
if (this.grounded) {
const y0 = this.opts.height(this.pos.x, this.pos.z);
const y1 = this.opts.height(nx, nz);
if (y1 - y0 > this.opts.maxStep) return false;
if (y0 - y1 > MAX_DROP) return false;
}
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) {
if (o.disabled) continue;
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;
+88 -11
View File
@@ -6,8 +6,9 @@
* Projektplaenen von EHLE Architekten. Siehe tools/ fuer die Datenpipeline.
*/
import {
ACESFilmicToneMapping, Group, PCFSoftShadowMap, PerspectiveCamera, Scene,
SRGBColorSpace, Vector3, WebGLRenderer,
ACESFilmicToneMapping, Group, Mesh, MeshBasicMaterial, PCFSoftShadowMap, PerspectiveCamera,
Raycaster, RingGeometry, Scene, SRGBColorSpace, Vector2, Vector3, WebGLRenderer,
type Object3D,
} from 'three';
import './style.css';
import { loadSite, makeHeightSampler, type SiteData } from './site';
@@ -50,16 +51,21 @@ async function main() {
const height = makeHeightSampler(site.terrain);
await progress(22, 'Gelände wird modelliert …');
scene.add(buildTerrain(site.terrain));
const terrainGroup = buildTerrain(site.terrain);
scene.add(terrainGroup);
await progress(38, 'Beläge werden verlegt …');
scene.add(buildSurfaces(site.surfaces, site.slabs, height));
const surfaceGroup = buildSurfaces(site.surfaces, site.slabs, height);
scene.add(surfaceGroup);
await progress(52, 'Mauern und Bauteile …');
scene.add(buildWalls(site.walls, height));
const wallGroup = buildWalls(site.walls, height);
scene.add(wallGroup);
scene.add(buildStelen(site.stelen, height));
scene.add(buildProps(site.props, height));
scene.add(buildLinear(site.linear, height));
const propGroup = buildProps(site.props, height);
scene.add(propGroup);
const linearGroup = buildLinear(site.linear, height);
scene.add(linearGroup);
await progress(66, 'Haus und Nachbarschaft …');
scene.add(buildBuilding(site.building));
@@ -129,11 +135,24 @@ async function main() {
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 });
}
// Travertinmauern mit fester Krone sind Sitz- und Stuetzmauern: man kann
// auf sie hinaufsteigen, sofern die Stufe klein genug ist - die Hoehe regelt
// der Rundgang selbst. Holz-, Lamellen- und Betonwaende bleiben Hindernisse.
for (const w of site.walls) {
const begehbar = w.type === 'travertin' && w.top !== null;
for (let i = 1; i < w.path.length; i++) {
const [ax, az] = w.path[i - 1], [bx, bz] = w.path[i];
if (begehbar) {
tritte.push({
cx: (ax + bx) / 2, cz: (az + bz) / 2,
hw: Math.hypot(bx - ax, bz - az) / 2, hd: w.t / 2,
rot: Math.atan2(bz - az, bx - ax), top: w.top!,
});
} else {
obstacles.push(obstacleFromSegment(w.path[i - 1], w.path[i], w.t + 0.1));
}
}
}
// Gelaender begrenzen den Rundgang, Treppen und Rinnen bleiben begehbar.
for (const f of site.linear) {
if (f.kind !== 'gelaender') continue;
@@ -141,10 +160,15 @@ async function main() {
obstacles.push(obstacleFromSegment(f.path[i - 1], f.path[i], 0.14));
}
}
// Tore merken wir uns: ihr Hindernis faellt weg, solange sie offen stehen.
const tore: { x: number; z: number; obstacle: Obstacle; obj: Object3D; winkel: number; hand: boolean | null }[] = [];
for (const p of site.props) {
// Stufen sind begehbar, kein Hindernis - sie stehen in `tritte`.
if (p.type === 'schacht' || p.type === 'wasserhahn' || p.type === 'stufe') continue;
obstacles.push({ cx: p.x, cz: p.z, hw: p.w / 2, hd: p.d / 2, rot: (p.rot * Math.PI) / 180 });
if (['schacht', 'wasserhahn', 'stufe', 'torbogen'].includes(p.type)) continue;
const o: Obstacle = { cx: p.x, cz: p.z, hw: p.w / 2, hd: p.d / 2, rot: (p.rot * Math.PI) / 180 };
obstacles.push(o);
const obj = p.type === 'tor' ? propGroup.getObjectByName(p.id) : undefined;
if (obj?.userData.fluegel) tore.push({ x: p.x, z: p.z, obstacle: o, obj, winkel: obj.userData.zu, hand: null });
}
for (const pl of site.plants) {
if (pl.type !== 'hecke' || !pl.polygon) continue;
@@ -167,8 +191,47 @@ async function main() {
obstacles.push({ cx: p.x, cz: p.z, hw: 0.26, hd: 0.26, rot: 0 });
}
const walker = new Walker(camera, { height: walkHeight, obstacles, bounds: BOUNDS });
// Klick-zum-Gehen: Bodenpunkt unter dem Mauszeiger. Getroffen werden nur
// Gelaende, Belaege, Mauern, Treppen und Objekte - nicht Laub oder Himmel.
const ray = new Raycaster();
ray.far = 70;
const ndc = new Vector2();
const pickTargets = [terrainGroup, surfaceGroup, wallGroup, linearGroup, propGroup];
const pick = (clientX: number, clientY: number) => {
const r = canvas.getBoundingClientRect();
ndc.set(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ndc, camera);
const hit = ray.intersectObjects(pickTargets, true)[0];
return hit ? { x: hit.point.x, z: hit.point.z } : null;
};
const walker = new Walker(camera, { height: walkHeight, obstacles, bounds: BOUNDS, pick });
walker.attach(canvas);
// Zielmarke fuer Klick-zum-Gehen: ein heller Ring am Boden
const marke = new Mesh(
new RingGeometry(0.16, 0.22, 32),
new MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85, depthWrite: false }),
);
marke.rotation.x = -Math.PI / 2;
marke.visible = false;
marke.renderOrder = 5;
scene.add(marke);
walker.onTarget = (ziel) => {
marke.visible = !!ziel;
if (ziel) marke.position.set(ziel.x, walkHeight(ziel.x, ziel.z) + 0.03, ziel.z);
};
// Tor von Hand: E oeffnet oder schliesst das naechste Tor in Reichweite
window.addEventListener('keydown', (e) => {
if (e.code !== 'KeyE') return;
for (const tor of tore) {
const d = Math.hypot(walker.position.x - tor.x, walker.position.z - tor.z);
if (d > 3) continue;
const offen = tor.winkel - tor.obj.userData.zu > 0.8;
tor.hand = !offen;
}
});
const start = site.startViews[0];
walker.teleport(start.x, start.z, start.yaw);
@@ -233,7 +296,6 @@ async function main() {
},
onJoystick: (x, y, active) => walker.setJoystick(x, y, active),
});
walker.onPointerLockChange = (locked) => hud.setPointerLocked(locked);
// Die App oeffnet in der Uebersicht; der Rundgang ist einen Klick entfernt.
hud.setMode('overview');
@@ -269,6 +331,21 @@ async function main() {
walker.update(dt);
}
// Tore oeffnen sich, wenn man darauf zugeht, und schliessen sich hinter
// einem wieder. Mit E laesst sich das von Hand uebersteuern; im Durchgang
// selbst schliesst ein Tor nie.
for (const tor of tore) {
const d = Math.hypot(walker.position.x - tor.x, walker.position.z - tor.z);
if (tor.hand !== null && d > 4) tor.hand = null;
let auf = tor.hand ?? (!orbit.active && d < 2.2);
if (d < 0.9) auf = true;
const ziel = auf ? tor.obj.userData.offen : tor.obj.userData.zu;
const schritt = 2.4 * dt;
tor.winkel += Math.max(-schritt, Math.min(schritt, ziel - tor.winkel));
tor.obj.userData.fluegel.rotation.y = tor.winkel;
tor.obstacle.disabled = tor.winkel - tor.obj.userData.zu > 0.6;
}
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
+4 -1
View File
@@ -81,7 +81,10 @@ export function surfaceGeometryMulti(
}
/** Einfaches Zusammenfuehren gleichartiger Geometrien (position/uv/normal). */
export function mergeGeometries(parts: BufferGeometry[]): BufferGeometry {
export function mergeGeometries(input: BufferGeometry[]): BufferGeometry {
// Indizierte Geometrien (Rohre, Kugeln) erst aufloesen - hier werden nur
// die Vertex-Arrays aneinandergehaengt, ein Index ginge verloren.
const parts = input.map((g) => (g.index ? g.toNonIndexed() : g));
const total = parts.reduce((n, g) => n + g.getAttribute('position').count, 0);
const pos = new Float32Array(total * 3);
const nor = new Float32Array(total * 3);
+125 -19
View File
@@ -1,11 +1,12 @@
/**
* Freistehende Bauteile: Travertinstelen, Gartenhaus, Spielhaus,
* Flammengrill, Baenkli, Maehroboter-Garage, Gartentor und die Leuchten.
* Flammengrill, Baenkli, Maehroboter-Garage, Gartentor, Torbogen und die Leuchten.
* Die Mauern stehen in walls.ts.
*/
import {
BoxGeometry, BufferGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial,
PointLight, SpotLight, type MeshStandardMaterialParameters,
BoxGeometry, BufferGeometry, CatmullRomCurve3, CylinderGeometry, Group, IcosahedronGeometry,
Mesh, MeshStandardMaterial, PointLight, SpotLight, TubeGeometry, Vector3,
type MeshStandardMaterialParameters,
} from 'three';
import type { Lamp, Linear, Prop, Stele } from '../site';
import { boxGeometry, mergeGeometries } from './geometry';
@@ -346,35 +347,139 @@ function schachtdeckel(p: Prop, y: number): Group {
* Flachstahl und senkrechte Staebe im Abstand von rund 11 cm - das Tor bleibt
* durchsichtig, es ist kein geschlossenes Blatt.
*/
/**
* Gartentor mit drehbarem Fluegel. Die Pfosten stehen fest, der Fluegel haengt
* in einer eigenen Gruppe am Scharnier - so kann der Rundgang ihn oeffnen und
* schliessen. Er schlaegt nach aussen auf das Podest auf, weil innen gleich
* die beiden Stufen liegen.
*/
function gartentor(p: Prop, y: number): Group {
const g = new Group();
const mat = new MeshStandardMaterial({ color: 0x43464a, roughness: 0.45, metalness: 0.55 });
const a = (p.rot * Math.PI) / 180;
// Laengsrichtung des Fluegels in der Ebene, quer dazu die Blattdicke.
const ux = Math.cos(a), uz = Math.sin(a);
const at = (t: number): [number, number] => [p.x + ux * t, p.z + uz * t];
const parts: BufferGeometry[] = [];
const half = p.w / 2;
const top = y + p.h;
// Pfosten links und rechts, etwas hoeher als der Fluegel
for (const t of [-half - 0.05, half + 0.05]) {
const [x, z] = at(t);
parts.push(boxGeometry(x, z, 0.08, 0.08, y - 0.35, top + 0.06, p.rot, 1));
const pfosten: BufferGeometry[] = [];
for (const s of [-half - 0.05, half + 0.05]) {
pfosten.push(boxGeometry(p.x + ux * s, p.z + uz * s, 0.08, 0.08, y - 0.35, top + 0.06, p.rot, 1));
}
// Rahmen: unterer und oberer Riegel
for (const [y0, y1] of [[y + 0.08, y + 0.14], [top - 0.08, top]] as const) {
parts.push(boxGeometry(p.x, p.z, p.w, 0.035, y0, y1, p.rot, 1));
const posts = new Mesh(mergeGeometries(pfosten), mat);
posts.castShadow = true;
g.add(posts);
// Fluegel in Scharnier-Koordinaten: er laeuft vom Scharnier aus in lokal -X.
const teile: BufferGeometry[] = [];
for (const [y0, y1] of [[0.08, 0.14], [p.h - 0.08, p.h]] as const) {
teile.push(boxGeometry(-half, 0, p.w - 0.02, 0.035, y0, y1, 0, 1));
}
// Senkrechte Staebe
for (const x of [-0.03, -p.w + 0.03]) teile.push(boxGeometry(x, 0, 0.04, 0.04, 0.08, p.h, 0, 1));
const n = Math.max(4, Math.round(p.w / 0.11));
for (let i = 0; i <= n; i++) {
const [x, z] = at(-half + (i * p.w) / n);
parts.push(boxGeometry(x, z, 0.016, 0.016, y + 0.08, top, p.rot, 1));
for (let i = 1; i < n; i++) {
teile.push(boxGeometry(-(i * p.w) / n, 0, 0.016, 0.016, 0.08, p.h, 0, 1));
}
const m = new Mesh(mergeGeometries(parts), mat);
m.castShadow = true;
g.add(m);
const leaf = new Mesh(mergeGeometries(teile), mat);
leaf.castShadow = true;
// Scharnier am +u-Ende. Lokal -X zeigt im geschlossenen Zustand nach -u:
// (-cos th, sin th) = (-ux, -uz) => th = atan2(-uz, ux).
const pivot = new Group();
pivot.name = 'Torfluegel';
pivot.position.set(p.x + ux * half, y, p.z + uz * half);
const zu = Math.atan2(-uz, ux);
pivot.rotation.y = zu;
pivot.add(leaf);
g.add(pivot);
g.userData.fluegel = pivot;
g.userData.zu = zu;
g.userData.offen = zu + 1.65;
return g;
}
/**
* Torbogen fuer den Eingang der Nachbarn: zwei Baender aus Cortenstahl, die
* als Pfosten aufsteigen und oben einen Halbkreis schlagen, dazwischen
* Querstaebe. An einer Seite rankt eine Kletterrose hoch. Kein Tor - der
* Durchgang bleibt offen.
*/
function torbogen(p: Prop, y: number): Group {
const g = new Group();
const corten = new MeshStandardMaterial({ color: 0x8b4a27, roughness: 0.78, metalness: 0.25 });
const a = (p.rot * Math.PI) / 180;
// u: quer zum Durchgang (Spannweite), v: laengs des Durchgangs (Tiefe)
const ux = Math.cos(a), uz = Math.sin(a);
const vx = -Math.sin(a), vz = Math.cos(a);
const r = p.w / 2;
const P = (u: number, yy: number, v: number) =>
new Vector3(p.x + ux * u + vx * v, yy, p.z + uz * u + vz * v);
const baender: BufferGeometry[] = [];
for (const v of [-p.d / 2, p.d / 2]) {
const pts: Vector3[] = [P(-r, y - 0.2, v), P(-r, y + p.h * 0.5, v), P(-r, y + p.h, v)];
for (let i = 1; i < 16; i++) {
const w = Math.PI - (i / 16) * Math.PI;
pts.push(P(Math.cos(w) * r, y + p.h + Math.sin(w) * r, v));
}
pts.push(P(r, y + p.h, v), P(r, y + p.h * 0.5, v), P(r, y - 0.2, v));
const kurve = new CatmullRomCurve3(pts, false, 'centripetal');
baender.push(new TubeGeometry(kurve, 96, 0.026, 6, false));
}
// Querstaebe laengs des Bogens
for (let i = 0; i <= 8; i++) {
const w = Math.PI - (i / 8) * Math.PI;
const u = Math.cos(w) * r, yy = y + p.h + Math.sin(w) * r;
const kurve = new CatmullRomCurve3([P(u, yy, -p.d / 2), P(u, yy, p.d / 2)]);
baender.push(new TubeGeometry(kurve, 2, 0.012, 5, false));
}
for (const u of [-r, r]) {
for (const yy of [y + 0.45, y + 0.95, y + 1.45]) {
const kurve = new CatmullRomCurve3([P(u, yy, -p.d / 2), P(u, yy, p.d / 2)]);
baender.push(new TubeGeometry(kurve, 2, 0.012, 5, false));
}
}
const bogen = new Mesh(mergeGeometries(baender), corten);
bogen.castShadow = true;
g.add(bogen);
// Kletterrose an einer Seite: viele kleine, unregelmaessige Laubbueschel den
// Pfosten hinauf und ein Stueck ueber den Bogen, dazwischen einzelne Blueten.
const laub = new MeshStandardMaterial({ color: 0x35592a, roughness: 0.92, flatShading: true });
const bluete = new MeshStandardMaterial({ color: 0xd47a8e, roughness: 0.7 });
const laubGeo = new IcosahedronGeometry(1, 0);
let seed = 7;
const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
const ranke: [number, number][] = [];
for (let i = 0; i < 14; i++) ranke.push([-r, y + 0.05 + (i / 13) * p.h]);
for (let i = 1; i <= 7; i++) {
const w = Math.PI - (i / 16) * Math.PI;
ranke.push([Math.cos(w) * r, y + p.h + Math.sin(w) * r]);
}
const blaetter: BufferGeometry[] = [];
const blueten: BufferGeometry[] = [];
for (const [u, yy] of ranke) {
const n = 3 + Math.floor(rnd() * 3);
for (let k = 0; k < n; k++) {
const geo = laubGeo.clone();
geo.scale(0.05 + rnd() * 0.05, 0.04 + rnd() * 0.04, 0.05 + rnd() * 0.05);
geo.rotateY(rnd() * 6.28);
geo.rotateX((rnd() - 0.5) * 0.8);
const q = P(u + (rnd() - 0.5) * 0.16, yy + (rnd() - 0.5) * 0.14, (rnd() - 0.5) * p.d * 1.1);
geo.translate(q.x, q.y, q.z);
blaetter.push(geo);
if (rnd() < 0.22) {
const bg = laubGeo.clone();
bg.scale(0.028, 0.024, 0.028);
bg.translate(q.x + (rnd() - 0.5) * 0.06, q.y + 0.04, q.z + (rnd() - 0.5) * 0.06);
blueten.push(bg);
}
}
}
const rose = new Mesh(mergeGeometries(blaetter), laub);
rose.castShadow = true;
g.add(rose);
if (blueten.length) g.add(new Mesh(mergeGeometries(blueten), bluete));
return g;
}
@@ -390,6 +495,7 @@ export function buildProps(props: Prop[], height: (x: number, z: number) => numb
case 'flammengrill': obj = flammengrill(p, y); break;
case 'bank': obj = bank(p, y); break;
case 'tor': obj = gartentor(p, y); break;
case 'torbogen': obj = torbogen(p, y); break;
case 'stufe': obj = schwebendeStufe(p, y); break;
case 'roboter': obj = roboterGarage(p, y); break;
case 'wasserhahn': obj = wasserhahn(p, y); break;
+1 -1
View File
@@ -77,7 +77,7 @@ export interface Wall {
export interface Prop {
id: string;
type: 'gartenhaus' | 'spielhaus' | 'flammengrill' | 'bank' | 'roboter' | 'wasserhahn' | 'schacht' | 'tor' | 'stufe';
type: 'gartenhaus' | 'spielhaus' | 'flammengrill' | 'bank' | 'roboter' | 'wasserhahn' | 'schacht' | 'tor' | 'torbogen' | 'stufe';
x: number;
z: number;
w: number;
+6 -4
View File
@@ -151,11 +151,13 @@ export function setupHud(opts: HudOptions): Hud {
<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>Rundgang: auf den Boden klicken, dann l&auml;uft man dorthin. Mit der Maus ziehen
zum Umsehen.</li>
<li>Oder mit <kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd> gehen, <kbd>Shift</kbd> zum Rennen,
<kbd>Leertaste</kbd> zum Springen</li>
<li>Treppen und die schwebenden Stufen sind begehbar &ndash; hinauf geht es zu Fuss
oder mit einem Sprung</li>
<li>Am Handy: links der Joystick, rechts wischen zum Umsehen</li>
<li>Treppen, Stufen und Sitzmauern sind begehbar. Das Gartentor &ouml;ffnet sich,
wenn man darauf zugeht; <kbd>E</kbd> &ouml;ffnet oder schliesst es von Hand.</li>
<li>Am Handy: auf den Boden tippen, 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>
+50 -6
View File
@@ -84,6 +84,21 @@ export const TREES = [
* bleibt die Ausgleichsebene aus den Koten stehen.
*/
export const LEVELS = [
{
// Terrasse der Nachbarn: Kote -1.77 auf den Keramikplatten. Die -1.35
// daneben ist die Krone ihrer Sitzmauer.
id: 'zollet-terrasse', level: -1.77, flatten: true, types: ['keramik'],
note: 'Keramikterrasse der Nachbarn',
polygon: [[-20.12, -12.10], [-13.90, -12.10], [-13.90, -6.60], [-20.12, -6.60]],
},
{
// Die Schotterwiese zwischen Grenze und Winkelelementen ist eben - quer
// dazu kein Hang, laengs folgt sie der Feuerwehrzufahrt.
id: 'schotterwiese', plane: [0, 0.1364, -1.096], flatten: true, types: ['schotterwiese'],
note: 'Schotterwiese, eben neben der Feuerwehrzufahrt',
polygon: [[-22.12, -13.60], [-19.99, -13.60], [-19.99, -7.67], [-20.52, -7.49], [-20.52, -6.70],
[-20.02, -6.67], [-20.56, 0.45], [-21.74, 0.45], [-21.74, 0.35], [-22.12, 0.35]],
},
{
// Vor dem Gartentor liegt ein Podest auf -0.14 (Plan: dreimal -0.14 bis
// zur Suedwestecke). Es ist der westliche Arm des Wegs um das Haus und
@@ -159,14 +174,35 @@ export const LEVELS = [
*/
export const WALLS = [
{
id: 'travertinmauer-nord', type: 'travertin',
note: 'Travertinmauer an der Grenze zum Nachbargrundstueck Zollet; Krone auf dem Niveau der unteren Ebene, stuetzt rund 1.5 m ab',
path: [[-18.17, -6.895], [-13.82, -6.895]], t: 0.55, top: -0.33, base: -1.95,
// Der Plan zeichnet an der Nordgrenze zwei Baender: aussen, auf der
// Terrasse der Nachbarn, die "Sitzmauer" (0.55 tief), direkt dahinter die
// "Travertinmauer". Die Sitzmauer ist eine Bank fuer die Nachbarn - rund
// 40 cm hoch (Terrasse -1.77, Krone -1.35). Die Travertinmauer dahinter
// reicht bis auf ihr Niveau hinunter, damit sie eine Natursteinwand sehen
// und keinen Betonblock.
id: 'sitzmauer-nachbar', type: 'travertin',
note: 'Sitzmauer auf der Terrasse der Nachbarn, rund 40 cm hoch',
path: [[-18.17, -6.895], [-13.82, -6.895]], t: 0.55, top: -1.35, base: -1.95,
},
{
id: 'sitzmauer-nord', type: 'travertin',
note: 'Sitzmauer 25 cm ueber der unteren Ebene (gelbe Kote "25" im Plan)',
path: [[-20.08, -6.47], [-13.82, -6.47]], t: 0.30, top: -0.08, base: -0.55,
id: 'travertinmauer-nord', type: 'travertin',
note: 'Travertinmauer an der Nordgrenze, von der Terrasse der Nachbarn bis knapp ueber das Kiesbett des Gartens',
path: [[-20.08, -6.47], [-13.82, -6.47]], t: 0.30, top: -0.08, base: -2.15,
},
{
// Der Plan beschriftet auch entlang der Ligusterhecke "Winkelelemente":
// sie fassen den Garten der Nachbarn ueber der ebenen Schotterwiese.
id: 'winkelelemente-liguster', type: 'beton',
note: 'Winkelelemente unter der Ligusterhecke, stuetzen den Garten der Nachbarn',
path: [[-20.10, -13.60], [-20.10, -7.60]],
t: 0.22, top: null, base: null, retains: 'zollet',
},
{
// Die "Stufe" des Plans in der Luecke zwischen Ligusterhecke und Mauer:
// hier betreten die Nachbarn ihren Garten von der Schotterwiese her.
id: 'stufe-eingang-nachbar', type: 'travertin',
note: 'Travertinstufe unter dem Torbogen zum Garten der Nachbarn',
path: [[-20.31, -7.47], [-20.31, -6.67]], t: 0.42, top: -1.77, base: -2.20,
},
{
id: 'winkelelemente-west', type: 'beton',
@@ -234,6 +270,14 @@ export const WALLS = [
/** Freistehende Bauteile und Moeblierung. rot = Drehung um Y in Grad. */
export const PROPS = [
// Schwelle unter dem Gartentor: schliesst die Luecke zwischen der oberen
// Torstufe und dem Podest, damit man eben durch das Tor geht.
{ id: 'schwelle-gartentor', type: 'stufe', x: -21.60, z: 3.06, w: 0.34, d: 1.10, h: 0.08, rot: 0, top: -0.13,
note: 'Travertinschwelle unter dem Gartentor' },
// Kleiner Eingang fuer die Nachbarn: ein Torbogen aus Cortenstahl ueber der
// Travertinstufe, ohne Tor. Durchgang in Site-X, der Bogen spannt laengs Z.
{ id: 'torbogen-nachbar', type: 'torbogen', x: -20.31, z: -7.07, w: 0.84, d: 0.42, h: 1.95, rot: 90,
base: -1.77, note: 'Torbogen zum Garten der Nachbarn' },
// Der Plan reserviert 1.8 x 1.8 m fuer das Gartenhaus. Gebaut wird dort ein
// schmaler Gartenschrank: 1.80 m breit, nur 0.50 m tief, rund 2.00 m hoch,
// mit dem Ruecken zum Beerenbeet und den Tueren zum Hauszugang hin.
+6 -3
View File
@@ -298,9 +298,12 @@ function levelFor(s) {
const flatSurfaces = [];
for (const s of surfaces) {
if (!FLAT_TYPES.has(s.type) || s.area < 0.4) continue;
// Auch eine Wiese ist eben, wenn eine feste Ebene sie ausdruecklich nennt -
// sonst folgt ihr Netz dem Raster und zackt an den Raendern auf.
const fixed = levelFor(s);
const genannt = fixed?.types?.includes(s.type) && fixed.flatten;
if ((!FLAT_TYPES.has(s.type) && !genannt) || s.area < 0.4) continue;
let plane, ys;
if (fixed) {
@@ -395,7 +398,7 @@ for (let j = 0; j < nz; j++) {
// aber zwischen den Zonen, und dort stand ein Grashuegel darueber.
for (const lv of LEVELS) {
if (!lv.flatten || !pointInPolygon(x, z, lv.polygon)) continue;
y = lv.level;
y = lv.plane ? lv.plane[0] * x + lv.plane[1] * z + lv.plane[2] : lv.level;
locked[j * nx + i] = 1;
break;
}