/** * 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((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}`; });