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:
2026-07-04 01:33:51 +02:00
parent 683242ba78
commit bd13495923
9 changed files with 553 additions and 6 deletions
+111
View File
@@ -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");
});
});