Deploy App / deploy (push) Successful in 28s
Rueckmeldung Punkt fuer Punkt: Weg rund ums Haus - Der Ostweg ist jetzt durchgehend gepflastert von der Verbundstein- Anpassung (x 2.86) bis zur Grenze (x 4.45) - vorher 0.83 m breit mit Rasenstreifen daneben. Der Weg an der Suedgrenze reicht von der Grenze bis an die Nachbarmauer (z 4.49 bis 5.97). - Winkelelemente Ost auf die Grenzlinie x 4.45; die Linie bei z 4.96 im Sueden war keine eigene Stuetzwand, das ist die Nachbarmauer selbst. Hoehen und Gefaelle - LEVELS koennen jetzt auch eine geneigte Ebene vorgeben. Die Wege sind Rampen: Ostweg rund 12%, Suedweg rund 3%, Feuerwehrzufahrt 13.6% aus den Plankoten -2.08 und -1.08. Der Architekturplan nennt an denselben Stellen "Steigung 9.9%" und "Steigung 13.0%". - Ausserhalb der Zonen wird die Interpolation der naechsten Zone fortgesetzt statt ihres konstanten Ersatzwerts - die Feuerwehrzufahrt stand vorher als schwebende Platte ueber einer Gelaendekante. - Gelaende oestlich der Grenze korrigiert: dort lag ein Huegel ueber dem Weg, obwohl der Plan -0.72 und -1.57 angibt. Obere Ebene - Der Rasen ist eben auf -0.02. Die Koten 0.09 bis 0.26 liegen schon im angehobenen Kiesbett des Sichtschutzes, nicht mehr im Rasen; ohne feste Vorgabe zog die Interpolation den Rasen huegelig hoch. Stufen - Beim gedeckten Sitzplatz stimmen jetzt beide schwebenden Platten: der Plan zeichnet zwei versetzte Rechtecke mit den Koten -0.02 und -0.17, bisher lagen sie auf -0.17 und -0.31 - die untere verschwand im Belag. - Das Gartentor steht auf dem oberen Niveau (-0.13), von dort zwei Stufen hinunter auf den Hauptsitzplatz. - Westtreppe ueber den ganzen Treppenblock (5 Stufen statt 4), die Gelaenderpfosten reichen bis ins Gelaende statt in der Luft zu enden. Rundgang - Treppen und schwebende Stufen sind begehbar: ihre Trittflaechen gehen in die Hoehenabfrage ein, die Stufen sind kein Hindernis mehr. - Springen mit der Leertaste, rund 55 cm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
/**
|
|
* 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 { buildStelen, buildProps, buildLamps, buildLinear, treppeTritte } from './scene/structures';
|
|
import { buildWalls } from './scene/walls';
|
|
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, site.slabs, 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));
|
|
scene.add(buildLinear(site.linear, 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);
|
|
|
|
// -- Begehbare Bauteile --------------------------------------------------
|
|
// Stufen und Treppen stecken nicht im Hoehenraster, sondern sind eigene
|
|
// Koerper. Damit man sie wirklich begehen kann, wird ihre Trittflaeche in
|
|
// die Hoehenabfrage des Rundgangs eingerechnet - sonst blieben sie blosse
|
|
// Hindernisse, um die man herumlaufen muss.
|
|
interface Tritt { cx: number; cz: number; hw: number; hd: number; rot: number; top: number }
|
|
const tritte: Tritt[] = [];
|
|
for (const p of site.props) {
|
|
if (p.type !== 'stufe' || p.top === undefined) continue;
|
|
tritte.push({
|
|
cx: p.x, cz: p.z, hw: p.w / 2, hd: p.d / 2,
|
|
rot: (p.rot * Math.PI) / 180, top: p.top,
|
|
});
|
|
}
|
|
for (const f of site.linear) {
|
|
if (f.kind === 'treppe') tritte.push(...treppeTritte(f));
|
|
}
|
|
const onTritt = (t: Tritt, x: number, z: number) => {
|
|
const dx = x - t.cx, dz = z - t.cz;
|
|
const c = Math.cos(-t.rot), s = Math.sin(-t.rot);
|
|
return Math.abs(dx * c - dz * s) <= t.hw && Math.abs(dx * s + dz * c) <= t.hd;
|
|
};
|
|
/** Hoehe unter den Fuessen: Gelaende oder die hoechste Trittflaeche darauf. */
|
|
const walkHeight = (x: number, z: number) => {
|
|
let y = height(x, z);
|
|
for (const t of tritte) if (onTritt(t, x, z) && t.top > y) y = t.top;
|
|
return y;
|
|
};
|
|
|
|
// -- 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));
|
|
}
|
|
}
|
|
// Gelaender begrenzen den Rundgang, Treppen und Rinnen bleiben begehbar.
|
|
for (const f of site.linear) {
|
|
if (f.kind !== 'gelaender') continue;
|
|
for (let i = 1; i < f.path.length; i++) {
|
|
obstacles.push(obstacleFromSegment(f.path[i - 1], f.path[i], 0.14));
|
|
}
|
|
}
|
|
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 });
|
|
}
|
|
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 });
|
|
}
|
|
// Der Sichtschutz ist mannshoch und damit undurchdringlich: aufrechte
|
|
// Travertinstelen und die Eibengruppen dazwischen.
|
|
for (const s of site.stelen) {
|
|
const hw = s.rot === 0 ? s.len / 2 : s.t / 2;
|
|
const hd = s.rot === 0 ? s.t / 2 : s.len / 2;
|
|
obstacles.push({ cx: s.x, cz: s.z, hw, hd, rot: 0 });
|
|
}
|
|
for (const p of site.plants) {
|
|
if (p.type !== 'eibe' || p.x === undefined || p.z === undefined) continue;
|
|
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 });
|
|
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}`;
|
|
});
|