swisstopo: Gebaeude als Volumen + Terrain-Mesh in die 3D-Ansicht
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.
This commit is contained in:
@@ -809,6 +809,7 @@ export const de = {
|
|||||||
"ctxImport.radius": "Radius",
|
"ctxImport.radius": "Radius",
|
||||||
"ctxImport.sources": "Quellen",
|
"ctxImport.sources": "Quellen",
|
||||||
"ctxImport.src.swissBuildings": "Swisstopo-Gebäude",
|
"ctxImport.src.swissBuildings": "Swisstopo-Gebäude",
|
||||||
|
"ctxImport.src.swissTerrain": "Swisstopo-Gelände",
|
||||||
"ctxImport.src.osmBuildings": "OSM-Gebäude",
|
"ctxImport.src.osmBuildings": "OSM-Gebäude",
|
||||||
"ctxImport.src.osmRoads": "OSM-Strassen",
|
"ctxImport.src.osmRoads": "OSM-Strassen",
|
||||||
"ctxImport.src.osmWater": "OSM-Wasser",
|
"ctxImport.src.osmWater": "OSM-Wasser",
|
||||||
|
|||||||
@@ -800,6 +800,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"ctxImport.radius": "Radius",
|
"ctxImport.radius": "Radius",
|
||||||
"ctxImport.sources": "Sources",
|
"ctxImport.sources": "Sources",
|
||||||
"ctxImport.src.swissBuildings": "Swisstopo buildings",
|
"ctxImport.src.swissBuildings": "Swisstopo buildings",
|
||||||
|
"ctxImport.src.swissTerrain": "Swisstopo terrain",
|
||||||
"ctxImport.src.osmBuildings": "OSM buildings",
|
"ctxImport.src.osmBuildings": "OSM buildings",
|
||||||
"ctxImport.src.osmRoads": "OSM roads",
|
"ctxImport.src.osmRoads": "OSM roads",
|
||||||
"ctxImport.src.osmWater": "OSM water",
|
"ctxImport.src.osmWater": "OSM water",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+171
-1
@@ -7,8 +7,9 @@
|
|||||||
//
|
//
|
||||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import Delaunator from "delaunator";
|
||||||
import type { GeoOrigin } from "./lv95";
|
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. */
|
/** Fachliche Kategorie einer importierten Kontext-Linie. */
|
||||||
export type GeoCategory = "building" | "road" | "water" | "green";
|
export type GeoCategory = "building" | "road" | "water" | "green";
|
||||||
@@ -20,8 +21,17 @@ export interface GeoFeature {
|
|||||||
pts: { x: number; y: number }[];
|
pts: { x: number; y: number }[];
|
||||||
/** Geschlossener Ring (Gebäude/Wasser/Grün) vs. offene Linie (Strasse). */
|
/** Geschlossener Ring (Gebäude/Wasser/Grün) vs. offene Linie (Strasse). */
|
||||||
closed: boolean;
|
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. */
|
/** Ergebnis eines Import-Laufs: Features + verwendete Origin. */
|
||||||
export interface GeoImportResult {
|
export interface GeoImportResult {
|
||||||
origin: GeoOrigin;
|
origin: GeoOrigin;
|
||||||
@@ -86,9 +96,169 @@ export function featuresToContextObjects(
|
|||||||
contours,
|
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;
|
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
|
* Basis-URL des optionalen Geo-Proxys (openbureau-core `/geoproxy`). Wird für
|
||||||
* Overpass zwingend benutzt (CORS unzuverlässig); für geo.admin nur als
|
* Overpass zwingend benutzt (CORS unzuverlässig); für geo.admin nur als
|
||||||
|
|||||||
+27
-3
@@ -85,6 +85,28 @@ function isClosedCategory(cat: GeoCategory): boolean {
|
|||||||
return cat !== "road";
|
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<string, string> | 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
|
* OSM-Features für Zentrum + Radius (Meter) laden. `originIn` erlaubt es, exakt
|
||||||
* dieselbe Origin wie ein vorheriger swisstopo-Import zu verwenden.
|
* dieselbe Origin wie ein vorheriger swisstopo-Import zu verwenden.
|
||||||
@@ -115,23 +137,25 @@ export async function fetchOsm(
|
|||||||
const push = (
|
const push = (
|
||||||
geometry: { lat: number; lon: number }[] | undefined,
|
geometry: { lat: number; lon: number }[] | undefined,
|
||||||
cat: GeoCategory,
|
cat: GeoCategory,
|
||||||
|
height?: number,
|
||||||
) => {
|
) => {
|
||||||
if (!geometry || geometry.length < 2) return;
|
if (!geometry || geometry.length < 2) return;
|
||||||
const pts = geometry.map((g) => wgs84ToLocal(g.lat, g.lon, origin));
|
const pts = geometry.map((g) => wgs84ToLocal(g.lat, g.lon, origin));
|
||||||
const closed = isClosedCategory(cat);
|
const closed = isClosedCategory(cat);
|
||||||
if (closed && pts.length < 3) return;
|
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 ?? []) {
|
for (const el of data.elements ?? []) {
|
||||||
const cat = categorize(el.tags, sel);
|
const cat = categorize(el.tags, sel);
|
||||||
if (!cat) continue;
|
if (!cat) continue;
|
||||||
|
const h = cat === "building" ? osmBuildingHeight(el.tags) : undefined;
|
||||||
if (el.type === "way") {
|
if (el.type === "way") {
|
||||||
push(el.geometry, cat);
|
push(el.geometry, cat, h);
|
||||||
} else if (el.type === "relation") {
|
} else if (el.type === "relation") {
|
||||||
// Multipolygon: outer-Ringe als einzelne geschlossene Linien übernehmen.
|
// Multipolygon: outer-Ringe als einzelne geschlossene Linien übernehmen.
|
||||||
for (const m of el.members ?? []) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { bboxAround, makeOrigin, wgs84ToLv95 } from "./lv95";
|
|||||||
import type { GeoOrigin, LV95 } from "./lv95";
|
import type { GeoOrigin, LV95 } from "./lv95";
|
||||||
import type { GeoFeature } from "./geoContext";
|
import type { GeoFeature } from "./geoContext";
|
||||||
import { viaProxy } from "./geoContext";
|
import { viaProxy } from "./geoContext";
|
||||||
|
import type { TerrainGrid } from "../model/terrain";
|
||||||
|
|
||||||
const GEOADMIN = "https://api3.geo.admin.ch";
|
const GEOADMIN = "https://api3.geo.admin.ch";
|
||||||
|
|
||||||
@@ -134,3 +135,105 @@ export async function fetchBuildings(
|
|||||||
}
|
}
|
||||||
return { origin, features };
|
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)" } };
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,3 +98,60 @@ export function generateTerrainFromContours(contours: Contour[]): TerrainMesh {
|
|||||||
|
|
||||||
return { id, type: "terrainMesh", name, positions, indices };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,12 +11,13 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { geocode, fetchBuildings } from "../io/swissTopo";
|
import { geocode, fetchBuildings, fetchTerrain } from "../io/swissTopo";
|
||||||
import type { GeocodeCandidate } from "../io/swissTopo";
|
import type { GeocodeCandidate } from "../io/swissTopo";
|
||||||
import { fetchOsm } from "../io/osm";
|
import { fetchOsm } from "../io/osm";
|
||||||
import type { OsmSelection } from "../io/osm";
|
import type { OsmSelection } from "../io/osm";
|
||||||
import { featuresToContextObjects } from "../io/geoContext";
|
import { featuresToContextObjects } from "../io/geoContext";
|
||||||
import type { GeoFeature } from "../io/geoContext";
|
import type { GeoFeature } from "../io/geoContext";
|
||||||
|
import { terrainMeshFromGrid } from "../model/terrain";
|
||||||
import type { GeoOrigin } from "../io/lv95";
|
import type { GeoOrigin } from "../io/lv95";
|
||||||
import type { ContextObject } from "../model/types";
|
import type { ContextObject } from "../model/types";
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ export interface ContextImportDialogProps {
|
|||||||
|
|
||||||
type Sources = {
|
type Sources = {
|
||||||
swissBuildings: boolean;
|
swissBuildings: boolean;
|
||||||
|
swissTerrain: boolean;
|
||||||
osmBuildings: boolean;
|
osmBuildings: boolean;
|
||||||
osmRoads: boolean;
|
osmRoads: boolean;
|
||||||
osmWater: boolean;
|
osmWater: boolean;
|
||||||
@@ -36,6 +38,7 @@ type Sources = {
|
|||||||
|
|
||||||
const DEFAULT_SOURCES: Sources = {
|
const DEFAULT_SOURCES: Sources = {
|
||||||
swissBuildings: true,
|
swissBuildings: true,
|
||||||
|
swissTerrain: true,
|
||||||
osmBuildings: false,
|
osmBuildings: false,
|
||||||
osmRoads: true,
|
osmRoads: true,
|
||||||
osmWater: true,
|
osmWater: true,
|
||||||
@@ -91,6 +94,7 @@ export function ContextImportDialog({
|
|||||||
|
|
||||||
const anySource =
|
const anySource =
|
||||||
sources.swissBuildings ||
|
sources.swissBuildings ||
|
||||||
|
sources.swissTerrain ||
|
||||||
sources.osmBuildings ||
|
sources.osmBuildings ||
|
||||||
sources.osmRoads ||
|
sources.osmRoads ||
|
||||||
sources.osmWater ||
|
sources.osmWater ||
|
||||||
@@ -129,13 +133,25 @@ export function ContextImportDialog({
|
|||||||
all.push(...r.features);
|
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"));
|
setError(t("ctxImport.empty"));
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const objs = featuresToContextObjects(all, selected.label);
|
const objs = featuresToContextObjects(all, selected.label);
|
||||||
|
if (terrain) objs.push(terrain);
|
||||||
onImport(objs);
|
onImport(objs);
|
||||||
setStatus(
|
setStatus(
|
||||||
t("ctxImport.imported", { count: all.length, sets: objs.length }),
|
t("ctxImport.imported", { count: all.length, sets: objs.length }),
|
||||||
@@ -249,6 +265,14 @@ export function ContextImportDialog({
|
|||||||
/>
|
/>
|
||||||
{t("ctxImport.src.swissBuildings")}
|
{t("ctxImport.src.swissBuildings")}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="cim-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sources.swissTerrain}
|
||||||
|
onChange={() => toggle("swissTerrain")}
|
||||||
|
/>
|
||||||
|
{t("ctxImport.src.swissTerrain")}
|
||||||
|
</label>
|
||||||
<label className="cim-check">
|
<label className="cim-check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
Reference in New Issue
Block a user