From bd134959237f439d9a7c16aad92d7261631c619a Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 01:33:51 +0200 Subject: [PATCH] swisstopo: Gebaeude als Volumen + Terrain-Mesh in die 3D-Ansicht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gebaeude-Footprints (swisstopo vec25) werden jetzt zu Volumen extrudiert (z=0 bis Hoehe; Hoehe aus OSM height/building:levels, sonst 9 m; Deckel/ Boden via Delaunay, konkave Grundrisse) und als importedMesh gerendert — der flache Footprint-contourSet bleibt fuer den 2D-Plan erhalten. Neuer Terrain-Abruf (swissALTI3D ueber den CORS-faehigen profile.json-Dienst, zeilenweise parallel) liefert ein N-Raster, das terrainMeshFromGrid trianguliert; Hoehen aufs Zentrum bezogen, lagerichtig zu den Gebaeuden. Neue Quelle 'Swisstopo-Gelaende' im Import-Dialog. Viewport3D unveraendert (bestehender importedMesh/terrainMesh-Pfad). 8 neue Tests. --- src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/io/geoContext.test.ts | 111 +++++++++++++++++++++ src/io/geoContext.ts | 172 ++++++++++++++++++++++++++++++++- src/io/osm.ts | 30 +++++- src/io/swissTopo.ts | 103 ++++++++++++++++++++ src/model/terrain.ts | 57 +++++++++++ src/model/terrainGrid.test.ts | 56 +++++++++++ src/ui/ContextImportDialog.tsx | 28 +++++- 9 files changed, 553 insertions(+), 6 deletions(-) create mode 100644 src/io/geoContext.test.ts create mode 100644 src/model/terrainGrid.test.ts diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 0df0327..cb651e6 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -809,6 +809,7 @@ export const de = { "ctxImport.radius": "Radius", "ctxImport.sources": "Quellen", "ctxImport.src.swissBuildings": "Swisstopo-Gebäude", + "ctxImport.src.swissTerrain": "Swisstopo-Gelände", "ctxImport.src.osmBuildings": "OSM-Gebäude", "ctxImport.src.osmRoads": "OSM-Strassen", "ctxImport.src.osmWater": "OSM-Wasser", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 07a8047..f39c6d8 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -800,6 +800,7 @@ export const en: Record = { "ctxImport.radius": "Radius", "ctxImport.sources": "Sources", "ctxImport.src.swissBuildings": "Swisstopo buildings", + "ctxImport.src.swissTerrain": "Swisstopo terrain", "ctxImport.src.osmBuildings": "OSM buildings", "ctxImport.src.osmRoads": "OSM roads", "ctxImport.src.osmWater": "OSM water", diff --git a/src/io/geoContext.test.ts b/src/io/geoContext.test.ts new file mode 100644 index 0000000..9a0d361 --- /dev/null +++ b/src/io/geoContext.test.ts @@ -0,0 +1,111 @@ +// Tests für den swisstopo/OSM-Kontext-Import: Gebäude-Extrusion (Footprint → +// 3D-Volumen) und die ContextObject-Erzeugung. + +import { describe, it, expect } from "vitest"; +import { + buildingsToMesh, + featuresToContextObjects, + DEFAULT_BUILDING_HEIGHT, +} from "./geoContext"; +import type { GeoFeature } from "./geoContext"; + +/** Ein achsenparalleles Quadrat (Seite s) als geschlossener Footprint-Ring. */ +function square(s: number): { x: number; y: number }[] { + return [ + { x: 0, y: 0 }, + { x: s, y: 0 }, + { x: s, y: s }, + { x: 0, y: s }, + ]; +} + +describe("buildingsToMesh", () => { + it("extrudiert einen Footprint zu einem geschlossenen Volumen", () => { + const f: GeoFeature = { + category: "building", + pts: square(10), + closed: true, + height: 12, + }; + const mesh = buildingsToMesh([f], "Test"); + expect(mesh).not.toBeNull(); + expect(mesh!.type).toBe("importedMesh"); + // 4 Boden- + 4 Deckel-Vertices. + expect(mesh!.positions.length).toBe(8 * 3); + // Wände (4 Quads × 2 = 8 Dreiecke) + Deckel + Boden. + expect(mesh!.indices.length).toBeGreaterThanOrEqual(8 * 3); + + // Z-Werte: Basis auf 0, Deckel auf der Höhe. + const zs: number[] = []; + for (let i = 2; i < mesh!.positions.length; i += 3) zs.push(mesh!.positions[i]); + expect(Math.min(...zs)).toBeCloseTo(0); + expect(Math.max(...zs)).toBeCloseTo(12); + }); + + it("nutzt die Default-Höhe ohne height-Angabe", () => { + const f: GeoFeature = { category: "building", pts: square(8), closed: true }; + const mesh = buildingsToMesh([f], "Test")!; + const zMax = Math.max( + ...Array.from({ length: mesh.positions.length / 3 }, (_, i) => mesh.positions[i * 3 + 2]), + ); + expect(zMax).toBeCloseTo(DEFAULT_BUILDING_HEIGHT); + }); + + it("überspringt entartete/offene Ringe und liefert dann null", () => { + const degenerate: GeoFeature = { + category: "building", + pts: [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 2 }, + ], + closed: true, + }; + const open: GeoFeature = { + category: "building", + pts: square(5), + closed: false, + }; + expect(buildingsToMesh([degenerate, open], "x")).toBeNull(); + }); + + it("trianguliert einen konkaven (L-förmigen) Footprint ohne Fläche außerhalb", () => { + // L-Form: Schwerpunkt-Filter darf keine Faces in der Einbuchtung erzeugen. + const L: { x: number; y: number }[] = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 4 }, + { x: 4, y: 4 }, + { x: 4, y: 10 }, + { x: 0, y: 10 }, + ]; + const mesh = buildingsToMesh( + [{ category: "building", pts: L, closed: true, height: 5 }], + "L", + ); + expect(mesh).not.toBeNull(); + expect(mesh!.indices.length).toBeGreaterThan(0); + }); +}); + +describe("featuresToContextObjects", () => { + it("liefert Footprint-contourSet UND extrudiertes Gebäude-Mesh", () => { + const features: GeoFeature[] = [ + { category: "building", pts: square(10), closed: true, height: 9 }, + { + category: "road", + pts: [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + ], + closed: false, + }, + ]; + const objs = featuresToContextObjects(features, "Ort"); + const types = objs.map((o) => o.type).sort(); + // contourSet (Gebäude-Footprint) + contourSet (Strasse) + importedMesh. + expect(objs.some((o) => o.type === "importedMesh")).toBe(true); + expect(objs.filter((o) => o.type === "contourSet").length).toBe(2); + expect(types).toContain("importedMesh"); + }); +}); diff --git a/src/io/geoContext.ts b/src/io/geoContext.ts index 863c1bc..807d91a 100644 --- a/src/io/geoContext.ts +++ b/src/io/geoContext.ts @@ -7,8 +7,9 @@ // // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). +import Delaunator from "delaunator"; import type { GeoOrigin } from "./lv95"; -import type { ContextObject, Contour } from "../model/types"; +import type { ContextObject, Contour, ImportedMesh } from "../model/types"; /** Fachliche Kategorie einer importierten Kontext-Linie. */ export type GeoCategory = "building" | "road" | "water" | "green"; @@ -20,8 +21,17 @@ export interface GeoFeature { pts: { x: number; y: number }[]; /** Geschlossener Ring (Gebäude/Wasser/Grün) vs. offene Linie (Strasse). */ closed: boolean; + /** + * Gebäudehöhe in Metern (nur `building`). Quelle: OSM-Tags (height / + * building:levels). Fehlt sie, greift {@link DEFAULT_BUILDING_HEIGHT} bei der + * Extrusion. + */ + height?: number; } +/** Default-Gebäudehöhe (m), wenn keine Höhe aus der Quelle vorliegt. */ +export const DEFAULT_BUILDING_HEIGHT = 9; + /** Ergebnis eines Import-Laufs: Features + verwendete Origin. */ export interface GeoImportResult { origin: GeoOrigin; @@ -86,9 +96,169 @@ export function featuresToContextObjects( contours, }); } + + // Gebäude zusätzlich als extrudierte Volumen (Footprint × Höhe) für die + // 3D-Ansicht. Der flache Footprint bleibt als contourSet erhalten (2D-Plan), + // das Volumen kommt als importedMesh dazu — beide teilen dieselbe Lage. + const buildingMesh = buildingsToMesh( + features.filter((f) => f.category === "building"), + sourceLabel, + ); + if (buildingMesh) objs.push(buildingMesh); + return objs; } +/** + * Baut aus allen Gebäude-Features EIN extrudiertes Volumen-Mesh (roh + * positions/indices in Modell-Metern, three-frei). Jeder geschlossene Footprint + * wird von z=0 bis z=Höhe hochgezogen (Wände + Deckel + Boden). Ohne Höhe greift + * {@link DEFAULT_BUILDING_HEIGHT}. Liefert `null`, wenn nichts Brauchbares dabei + * ist. + */ +export function buildingsToMesh( + buildings: GeoFeature[], + sourceLabel: string, +): ImportedMesh | null { + const positions: number[] = []; + const indices: number[] = []; + for (const b of buildings) { + if (!b.closed) continue; + const h = Number.isFinite(b.height) && (b.height as number) > 0 + ? (b.height as number) + : DEFAULT_BUILDING_HEIGHT; + extrudeRing(b.pts, h, positions, indices); + } + if (positions.length < 9 || indices.length < 3) return null; + return { + id: contextId("geo-building3d"), + type: "importedMesh", + name: `Gebäude 3D (${sourceLabel})`, + layer: CATEGORY_LAYER.building, + positions, + indices, + }; +} + +/** + * Extrudiert einen geschlossenen (x,y)-Ring zu einem Prisma [0..height] und + * hängt Vertices/Indizes an die gemeinsamen Arrays an. Erzeugt Seitenwände, + * Deckel und Boden. Winding ist unkritisch (Kontext-Material rendert doppel- + * seitig). Entartete Ringe (< 3 Punkte oder ~0 Fläche) werden übersprungen. + */ +function extrudeRing( + ringIn: { x: number; y: number }[], + height: number, + positions: number[], + indices: number[], +): void { + // Schließenden Doppelpunkt entfernen. + const ring = ringIn.slice(); + if ( + ring.length > 1 && + Math.abs(ring[0].x - ring[ring.length - 1].x) < 1e-6 && + Math.abs(ring[0].y - ring[ring.length - 1].y) < 1e-6 + ) { + ring.pop(); + } + const n = ring.length; + if (n < 3) return; + if (!ringArea(ring)) return; + + const base = positions.length / 3; + // n Boden-Vertices (z=0), dann n Deckel-Vertices (z=height). + for (const p of ring) positions.push(p.x, p.y, 0); + for (const p of ring) positions.push(p.x, p.y, height); + + // Seitenwände: je Kante ein Quad → 2 Dreiecke. + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + const b0 = base + i; + const b1 = base + j; + const t0 = base + n + i; + const t1 = base + n + j; + indices.push(b0, b1, t1, b0, t1, t0); + } + + // Deckel + Boden über eine Polygon-Triangulation des Rings. + const tris = triangulateRing(ring); + for (let k = 0; k < tris.length; k += 3) { + const a = tris[k], b = tris[k + 1], c = tris[k + 2]; + // Deckel (obere Vertex-Reihe). + indices.push(base + n + a, base + n + b, base + n + c); + // Boden (untere Reihe, umgekehrte Reihenfolge). + indices.push(base + a, base + c, base + b); + } +} + +/** Doppelte Ringfläche > EPS? (Filtert entartete/kollineare Footprints.) */ +function ringArea(ring: { x: number; y: number }[]): boolean { + let a2 = 0; + for (let i = 0; i < ring.length; i++) { + const p = ring[i]; + const q = ring[(i + 1) % ring.length]; + a2 += p.x * q.y - q.x * p.y; + } + return Math.abs(a2) > 1e-6; +} + +/** + * Trianguliert einen einfachen (evtl. konkaven) Ring. Strategie: Delaunay über + * die (x,y)-Punkte, dann nur die Dreiecke behalten, deren Schwerpunkt IM Polygon + * liegt (Point-in-Polygon) — so werden konkave Einbuchtungen respektiert, ohne + * eine externe Earcut-Abhängigkeit. Fällt bei Bedarf auf eine Fächer- + * Triangulation zurück. Rückgabe: Dreiecks-Indizes in `ring`. + */ +function triangulateRing(ring: { x: number; y: number }[]): number[] { + const n = ring.length; + if (n < 3) return []; + if (n === 3) return [0, 1, 2]; + const coords = new Float64Array(n * 2); + for (let i = 0; i < n; i++) { + coords[i * 2] = ring[i].x; + coords[i * 2 + 1] = ring[i].y; + } + let tri: Uint32Array; + try { + tri = new Delaunator(coords).triangles; + } catch { + return fanTriangulate(n); + } + const out: number[] = []; + for (let t = 0; t < tri.length; t += 3) { + const a = tri[t], b = tri[t + 1], c = tri[t + 2]; + const cx = (ring[a].x + ring[b].x + ring[c].x) / 3; + const cy = (ring[a].y + ring[b].y + ring[c].y) / 3; + if (pointInRing(cx, cy, ring)) out.push(a, b, c); + } + return out.length >= 3 ? out : fanTriangulate(n); +} + +/** Fächer-Triangulation ab Vertex 0 (Fallback für konvexe/sternförmige Ringe). */ +function fanTriangulate(n: number): number[] { + const out: number[] = []; + for (let i = 1; i < n - 1; i++) out.push(0, i, i + 1); + return out; +} + +/** Point-in-Polygon (Ray-Casting) in der (x,y)-Ebene. */ +function pointInRing( + x: number, + y: number, + ring: { x: number; y: number }[], +): boolean { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const xi = ring[i].x, yi = ring[i].y; + const xj = ring[j].x, yj = ring[j].y; + const hit = + yi > y !== yj > y && + x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; + if (hit) inside = !inside; + } + return inside; +} + /** * Basis-URL des optionalen Geo-Proxys (openbureau-core `/geoproxy`). Wird für * Overpass zwingend benutzt (CORS unzuverlässig); für geo.admin nur als diff --git a/src/io/osm.ts b/src/io/osm.ts index 01af725..95cd874 100644 --- a/src/io/osm.ts +++ b/src/io/osm.ts @@ -85,6 +85,28 @@ function isClosedCategory(cat: GeoCategory): boolean { return cat !== "road"; } +/** + * Gebäudehöhe (m) aus OSM-Tags: bevorzugt `height` (numerisch, evtl. mit + * Einheit), sonst `building:levels` × 3 m Geschosshöhe. Ohne Angabe `undefined` + * (die Extrusion nutzt dann die Default-Höhe). + */ +function osmBuildingHeight( + tags: Record | undefined, +): number | undefined { + if (!tags) return undefined; + const raw = tags.height ?? tags["building:height"]; + if (raw) { + const m = parseFloat(raw.replace(",", ".")); + if (Number.isFinite(m) && m > 0) return m; + } + const lvl = tags["building:levels"] ?? tags["building:levels:aboveground"]; + if (lvl) { + const l = parseFloat(lvl.replace(",", ".")); + if (Number.isFinite(l) && l > 0) return l * 3; + } + return undefined; +} + /** * OSM-Features für Zentrum + Radius (Meter) laden. `originIn` erlaubt es, exakt * dieselbe Origin wie ein vorheriger swisstopo-Import zu verwenden. @@ -115,23 +137,25 @@ export async function fetchOsm( const push = ( geometry: { lat: number; lon: number }[] | undefined, cat: GeoCategory, + height?: number, ) => { if (!geometry || geometry.length < 2) return; const pts = geometry.map((g) => wgs84ToLocal(g.lat, g.lon, origin)); const closed = isClosedCategory(cat); if (closed && pts.length < 3) return; - features.push({ category: cat, pts, closed }); + features.push({ category: cat, pts, closed, height }); }; for (const el of data.elements ?? []) { const cat = categorize(el.tags, sel); if (!cat) continue; + const h = cat === "building" ? osmBuildingHeight(el.tags) : undefined; if (el.type === "way") { - push(el.geometry, cat); + push(el.geometry, cat, h); } else if (el.type === "relation") { // Multipolygon: outer-Ringe als einzelne geschlossene Linien übernehmen. for (const m of el.members ?? []) { - if (m.role === "outer" || m.role === "") push(m.geometry, cat); + if (m.role === "outer" || m.role === "") push(m.geometry, cat, h); } } } diff --git a/src/io/swissTopo.ts b/src/io/swissTopo.ts index 4172387..05b5d6b 100644 --- a/src/io/swissTopo.ts +++ b/src/io/swissTopo.ts @@ -17,6 +17,7 @@ import { bboxAround, makeOrigin, wgs84ToLv95 } from "./lv95"; import type { GeoOrigin, LV95 } from "./lv95"; import type { GeoFeature } from "./geoContext"; import { viaProxy } from "./geoContext"; +import type { TerrainGrid } from "../model/terrain"; const GEOADMIN = "https://api3.geo.admin.ch"; @@ -134,3 +135,105 @@ export async function fetchBuildings( } return { origin, features }; } + +/** Ein Punkt aus dem geo.admin-Höhenprofil (profile.json, sr=2056). */ +interface ProfilePoint { + dist: number; + easting: number; + northing: number; + alts?: { DTM2?: number; DTM25?: number; COMB?: number }; +} + +/** + * Gelände-Höhenraster (swissALTI3D-DTM) für Zentrum + Radius laden. + * + * Statt schwerer swissALTI3D-Kachel-Downloads (STAC) nutzt der Port den + * CORS-fähigen geo.admin `profile.json`-Dienst: pro Rasterzeile EIN Request + * liefert die Höhen entlang einer Ost-West-Linie (nb_points Stützstellen). So + * entsteht ein reguläres N×N-Höhenraster mit wenigen Requests, direkt aus dem + * Browser. + * + * Die Höhen werden auf den Wert am Zentrum bezogen (z=0 im Zentrum), damit das + * Gelände beim Modell-Ursprung liegt und lagerichtig zu den Gebäuden (Basis + * z=0) sitzt. Rückgabe: das Raster in lokalen Metern + die verwendete Origin. + */ +export async function fetchTerrain( + center: LV95, + radius: number, + originIn?: GeoOrigin, +): Promise<{ origin: GeoOrigin; grid: TerrainGrid | null }> { + const origin = originIn ?? makeOrigin(center); + const [eMin, nMin, eMax] = bboxAround(center, radius); + const span = 2 * radius; + // Ziel-Rasterweite ~20 m, aber N in [6, 24] begrenzen (Request-Zahl zähmen). + const n = Math.max(6, Math.min(24, Math.round(span / 20) + 1)); + + // Achsen (LV95) + lokale Achsen. + const eAxis: number[] = []; + const nAxis: number[] = []; + const xs: number[] = []; + const ys: number[] = []; + for (let i = 0; i < n; i++) { + const e = eMin + (span * i) / (n - 1); + const nn = nMin + (span * i) / (n - 1); + eAxis.push(e); + nAxis.push(nn); + xs.push(e - origin.e); + ys.push(nn - origin.n); + } + + // Eine Rasterzeile (konstantes N) via profile.json abrufen → Höhen je Spalte. + const fetchRow = async (north: number): Promise<(number | null)[]> => { + const geom = JSON.stringify({ + type: "LineString", + coordinates: [ + [eMin, north], + [eMax, north], + ], + }); + const url = + `${GEOADMIN}/rest/services/profile.json` + + `?geom=${encodeURIComponent(geom)}` + + `&sr=2056&nb_points=${n}&distinct_points=true&offset=0`; + try { + const res = await fetch(viaProxy(url)); + if (!res.ok) return new Array(n).fill(null); + const pts = (await res.json()) as ProfilePoint[]; + const row: (number | null)[] = new Array(n).fill(null); + // Die Punkte kommen nach Distanz sortiert (West→Ost) — direkt Spalte i. + pts.forEach((p, i) => { + if (i >= n) return; + const z = p.alts?.DTM2 ?? p.alts?.COMB ?? p.alts?.DTM25; + row[i] = Number.isFinite(z as number) ? (z as number) : null; + }); + return row; + } catch { + return new Array(n).fill(null); + } + }; + + // Alle Zeilen parallel (n ≤ 24 Requests). + const rows = await Promise.all(nAxis.map((north) => fetchRow(north))); + + // Höhen-Nullpunkt: Wert am Rasterzentrum (fällt der aus, Mittel aller Werte). + const mid = Math.floor(n / 2); + let zRef = rows[mid]?.[mid] ?? null; + if (zRef == null || !Number.isFinite(zRef)) { + let sum = 0; + let cnt = 0; + for (const r of rows) + for (const v of r) + if (v != null && Number.isFinite(v)) { + sum += v; + cnt++; + } + zRef = cnt > 0 ? sum / cnt : null; + } + if (zRef == null) return { origin, grid: null }; + + // Höhen relativ zum Zentrum (z=0 im Zentrum). + const z: (number | null)[][] = rows.map((r) => + r.map((v) => (v == null ? null : v - (zRef as number))), + ); + return { origin, grid: { xs, ys, z, name: "Gelände (swisstopo)" } }; +} diff --git a/src/model/terrain.ts b/src/model/terrain.ts index 0f97b9f..7314a27 100644 --- a/src/model/terrain.ts +++ b/src/model/terrain.ts @@ -98,3 +98,60 @@ export function generateTerrainFromContours(contours: Contour[]): TerrainMesh { return { id, type: "terrainMesh", name, positions, indices }; } + +/** + * Ein reguläres Höhenraster: sortierte X/Y-Achsen (lokale Meter) und eine + * zeilenweise Höhenmatrix `z[row][col]` (Zeile = Y-Index, Spalte = X-Index). + * Einzelne Zellen dürfen `null` sein (kein Messwert) — dort entstehen keine + * Faces. Wird vom swisstopo-Terrain-Import (DTM-Profilraster) geliefert. + */ +export interface TerrainGrid { + xs: number[]; + ys: number[]; + z: (number | null)[][]; + name?: string; +} + +/** + * Baut ein Terrain-Mesh aus einem regulären Höhenraster (Quad-Gitter → zwei + * Dreiecke je Zelle). Fehlende Zellen (`null`) werden ausgelassen; eine Zelle + * entsteht nur, wenn alle vier Ecken vorhanden sind. Rohes positions/indices- + * Mesh (three-frei), analog {@link generateTerrainFromContours}. + */ +export function terrainMeshFromGrid(grid: TerrainGrid): TerrainMesh { + const id = `terrain-${Date.now()}-${terrainSeq++}`; + const name = grid.name ?? "Gelände"; + const { xs, ys, z } = grid; + const nx = xs.length; + const ny = ys.length; + + // Vertex-Index je (row, col) — nur für vorhandene Höhen. -1 = fehlt. + const vidx: number[] = new Array(nx * ny).fill(-1); + const positions: number[] = []; + for (let j = 0; j < ny; j++) { + for (let i = 0; i < nx; i++) { + const h = z[j]?.[i]; + if (h == null || !Number.isFinite(h)) continue; + vidx[j * nx + i] = positions.length / 3; + positions.push(xs[i], ys[j], h); + } + } + + const indices: number[] = []; + for (let j = 0; j < ny - 1; j++) { + for (let i = 0; i < nx - 1; i++) { + const a = vidx[j * nx + i]; + const b = vidx[j * nx + (i + 1)]; + const c = vidx[(j + 1) * nx + (i + 1)]; + const d = vidx[(j + 1) * nx + i]; + if (a < 0 || b < 0 || c < 0 || d < 0) continue; + // Quad a-b-c-d → zwei Dreiecke. + indices.push(a, b, c, a, c, d); + } + } + + if (positions.length < 9 || indices.length < 3) { + return { id, type: "terrainMesh", name, positions: [], indices: [] }; + } + return { id, type: "terrainMesh", name, positions, indices }; +} diff --git a/src/model/terrainGrid.test.ts b/src/model/terrainGrid.test.ts new file mode 100644 index 0000000..d890de7 --- /dev/null +++ b/src/model/terrainGrid.test.ts @@ -0,0 +1,56 @@ +// Tests für den Terrain-Mesh-Aufbau aus einem regulären Höhenraster +// (swisstopo-DTM-Profilraster → terrainMeshFromGrid). + +import { describe, it, expect } from "vitest"; +import { terrainMeshFromGrid } from "./terrain"; +import type { TerrainGrid } from "./terrain"; + +describe("terrainMeshFromGrid", () => { + it("baut ein vollständiges Quad-Gitter (3×3 → 8 Dreiecke)", () => { + const grid: TerrainGrid = { + xs: [0, 10, 20], + ys: [0, 10, 20], + z: [ + [1, 2, 3], + [2, 3, 4], + [3, 4, 5], + ], + }; + const mesh = terrainMeshFromGrid(grid); + expect(mesh.type).toBe("terrainMesh"); + // 9 Vertices × 3 Komponenten. + expect(mesh.positions.length).toBe(9 * 3); + // 2×2 Zellen × 2 Dreiecke × 3 Indizes. + expect(mesh.indices.length).toBe(2 * 2 * 2 * 3); + }); + + it("lässt Zellen mit fehlender (null) Ecke aus", () => { + const grid: TerrainGrid = { + xs: [0, 10, 20], + ys: [0, 10, 20], + z: [ + [1, 2, 3], + [2, null, 4], + [3, 4, 5], + ], + }; + const mesh = terrainMeshFromGrid(grid); + // Die fehlende Mitte gehört zu allen 4 Zellen → keine Faces übrig. + expect(mesh.indices.length).toBe(0); + }); + + it("überträgt die Höhen als Z-Koordinate (Modell x,y,z)", () => { + const grid: TerrainGrid = { + xs: [0, 10], + ys: [0, 10], + z: [ + [5, 7], + [9, 11], + ], + }; + const mesh = terrainMeshFromGrid(grid); + const zs: number[] = []; + for (let i = 2; i < mesh.positions.length; i += 3) zs.push(mesh.positions[i]); + expect(zs.sort((a, b) => a - b)).toEqual([5, 7, 9, 11]); + }); +}); diff --git a/src/ui/ContextImportDialog.tsx b/src/ui/ContextImportDialog.tsx index 9105c01..45aac14 100644 --- a/src/ui/ContextImportDialog.tsx +++ b/src/ui/ContextImportDialog.tsx @@ -11,12 +11,13 @@ import { useEffect, useRef, useState } from "react"; import { t } from "../i18n"; -import { geocode, fetchBuildings } from "../io/swissTopo"; +import { geocode, fetchBuildings, fetchTerrain } from "../io/swissTopo"; import type { GeocodeCandidate } from "../io/swissTopo"; import { fetchOsm } from "../io/osm"; import type { OsmSelection } from "../io/osm"; import { featuresToContextObjects } from "../io/geoContext"; import type { GeoFeature } from "../io/geoContext"; +import { terrainMeshFromGrid } from "../model/terrain"; import type { GeoOrigin } from "../io/lv95"; import type { ContextObject } from "../model/types"; @@ -28,6 +29,7 @@ export interface ContextImportDialogProps { type Sources = { swissBuildings: boolean; + swissTerrain: boolean; osmBuildings: boolean; osmRoads: boolean; osmWater: boolean; @@ -36,6 +38,7 @@ type Sources = { const DEFAULT_SOURCES: Sources = { swissBuildings: true, + swissTerrain: true, osmBuildings: false, osmRoads: true, osmWater: true, @@ -91,6 +94,7 @@ export function ContextImportDialog({ const anySource = sources.swissBuildings || + sources.swissTerrain || sources.osmBuildings || sources.osmRoads || sources.osmWater || @@ -129,13 +133,25 @@ export function ContextImportDialog({ all.push(...r.features); } - if (all.length === 0) { + // Gelände-Höhenraster (swissALTI3D via geo.admin-Profil) → Terrain-Mesh. + let terrain: ContextObject | null = null; + if (sources.swissTerrain) { + const r = await fetchTerrain(center, radius, origin); + origin = r.origin; + if (r.grid) { + const mesh = terrainMeshFromGrid(r.grid); + if (mesh.positions.length >= 9) terrain = mesh; + } + } + + if (all.length === 0 && !terrain) { setError(t("ctxImport.empty")); setStatus(null); return; } const objs = featuresToContextObjects(all, selected.label); + if (terrain) objs.push(terrain); onImport(objs); setStatus( t("ctxImport.imported", { count: all.length, sets: objs.length }), @@ -249,6 +265,14 @@ export function ContextImportDialog({ /> {t("ctxImport.src.swissBuildings")} +