swissBUILDINGS3D-Import: auf den gesuchten Radius zuschneiden statt die ganze STAC-Kachel zu liefern
Jede STAC-Kachel (DXF) liefert ALLE Gebäude der Kachel als EIN gemeinsames Mesh (parseDxf sammelt alle 3DFACE/Polyface-Entities eines Files in denselben Puffer) — ohne Zuschnitt landete bei einem Import IMMER die komplette Kachel im Modell, teils mehrere Kilometer über den gesuchten Suchradius hinaus. Live gegen echte STAC-Daten verifiziert: zwei 1.4 km auseinanderliegende Adressen (gleiche Kachel) lieferten vorher IDENTISCHE, unclipped Geometrie (62532 Dreiecke); danach jeweils nur die ~400 Dreiecke im eigenen Suchradius. clipMeshToBbox schneidet + kompaktiert das Mesh (nicht referenzierte Ecken entfernt, Indizes neu gemappt) — sonst sähe jede Verbraucher-Logik, die roh über `positions` statt `indices` iteriert (Bounding-Box, Kamera-Fit), weiter die volle ungeschnittene Ausdehnung.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
// `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { shiftMeshToOrigin } from "./swissBuildings3d";
|
||||
import { shiftMeshToOrigin, clipMeshToBbox } from "./swissBuildings3d";
|
||||
import type { ImportedMesh } from "../model/types";
|
||||
|
||||
describe("shiftMeshToOrigin", () => {
|
||||
@@ -36,3 +36,46 @@ describe("shiftMeshToOrigin", () => {
|
||||
expect(mesh.positions).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipMeshToBbox", () => {
|
||||
// Zwei Dreiecke: #0 ganz innerhalb der Box, #1 ganz ausserhalb (weit weg,
|
||||
// simuliert ein Nachbargebäude aus derselben STAC-Kachel ausserhalb des
|
||||
// gesuchten Radius).
|
||||
const mesh: ImportedMesh = {
|
||||
id: "m1",
|
||||
type: "importedMesh",
|
||||
name: "roh",
|
||||
positions: [
|
||||
0, 0, 0, 1, 0, 0, 0, 1, 0, // Dreieck 0: innerhalb [-5,5]
|
||||
1000, 1000, 0, 1001, 1000, 0, 1000, 1001, 0, // Dreieck 1: weit ausserhalb
|
||||
],
|
||||
indices: [0, 1, 2, 3, 4, 5],
|
||||
};
|
||||
|
||||
it("behält nur Dreiecke mit mindestens einem Eckpunkt in der Box", () => {
|
||||
const clipped = clipMeshToBbox(mesh, [-5, -5, 5, 5]);
|
||||
expect(clipped.indices).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("leere Box -> leere Indizes (kein Absturz)", () => {
|
||||
const clipped = clipMeshToBbox(mesh, [500, 500, 500, 500]);
|
||||
expect(clipped.indices).toEqual([]);
|
||||
});
|
||||
|
||||
it("Box umfasst alles -> alle Dreiecke bleiben", () => {
|
||||
const clipped = clipMeshToBbox(mesh, [-2000, -2000, 2000, 2000]);
|
||||
expect(clipped.indices).toEqual(mesh.indices);
|
||||
});
|
||||
|
||||
it("ein Dreieck, das die Box-Kante überragt, bleibt VOLLSTÄNDIG erhalten (kein Mittendurch-Schnitt)", () => {
|
||||
const straddling: ImportedMesh = {
|
||||
id: "m2",
|
||||
type: "importedMesh",
|
||||
name: "roh",
|
||||
positions: [4, 0, 0, 10, 0, 0, 4, 10, 0], // ein Eckpunkt (4,0) in [-5,5], zwei ausserhalb
|
||||
indices: [0, 1, 2],
|
||||
};
|
||||
const clipped = clipMeshToBbox(straddling, [-5, -5, 5, 5]);
|
||||
expect(clipped.indices).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,55 @@ export function shiftMeshToOrigin(
|
||||
return { ...mesh, positions, name: `swissBUILDINGS3D ${sourceId}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schneidet ein Mesh auf eine (bereits origin-verschobene, lokale) Bounding-Box
|
||||
* zurecht: ein Dreieck bleibt, sobald MINDESTENS EIN Eckpunkt innerhalb der Box
|
||||
* liegt (grosszügig — schneidet Gebäude am Rand nicht mittendurch ab). Nötig,
|
||||
* weil eine einzelne swissBUILDINGS3D-DXF-Kachel (STAC-Tile) alle Gebäude der
|
||||
* GANZEN Kachel als EIN gemeinsames Mesh liefert (s. `dxfParser.ts::parseDxf`
|
||||
* — alle 3DFACE/Polyface-Entities eines Files landen im selben Positions-/
|
||||
* Indexpuffer) — ohne diesen Zuschnitt liefert ein einzelner Import ALLES in
|
||||
* der Kachel, auch weit ausserhalb des gesuchten Radius (bis zu mehreren km,
|
||||
* je nach Kachelgrösse). `positions` wird dabei KOMPAKTIERT (nicht referenzierte
|
||||
* Ecken entfernt, Indizes neu gemappt) — sonst sähe jeder Verbraucher, der roh
|
||||
* über `positions` statt über `indices` iteriert (z. B. eine Bounding-Box/
|
||||
* Kamera-Fit-Berechnung), weiterhin die volle, ungeschnittene Ausdehnung. Rein
|
||||
* (kein Netzwerk), exportiert für Unit-Tests.
|
||||
*/
|
||||
export function clipMeshToBbox(
|
||||
mesh: ImportedMesh,
|
||||
bboxLocal: [number, number, number, number],
|
||||
): ImportedMesh {
|
||||
const [xMin, yMin, xMax, yMax] = bboxLocal;
|
||||
const pos = mesh.positions;
|
||||
const inBox = (idx: number): boolean => {
|
||||
const x = pos[idx * 3];
|
||||
const y = pos[idx * 3 + 1];
|
||||
return x >= xMin && x <= xMax && y >= yMin && y <= yMax;
|
||||
};
|
||||
const keptTris: [number, number, number][] = [];
|
||||
for (let i = 0; i + 2 < mesh.indices.length; i += 3) {
|
||||
const a = mesh.indices[i];
|
||||
const b = mesh.indices[i + 1];
|
||||
const c = mesh.indices[i + 2];
|
||||
if (inBox(a) || inBox(b) || inBox(c)) keptTris.push([a, b, c]);
|
||||
}
|
||||
const remap = new Map<number, number>();
|
||||
const positions: number[] = [];
|
||||
const indices: number[] = [];
|
||||
const mapVertex = (old: number): number => {
|
||||
let ni = remap.get(old);
|
||||
if (ni === undefined) {
|
||||
ni = positions.length / 3;
|
||||
positions.push(pos[old * 3], pos[old * 3 + 1], pos[old * 3 + 2]);
|
||||
remap.set(old, ni);
|
||||
}
|
||||
return ni;
|
||||
};
|
||||
for (const [a, b, c] of keptTris) indices.push(mapVertex(a), mapVertex(b), mapVertex(c));
|
||||
return { ...mesh, positions, indices };
|
||||
}
|
||||
|
||||
export interface FetchBuildings3dResult {
|
||||
origin: GeoOrigin;
|
||||
meshes: ImportedMesh[];
|
||||
@@ -88,7 +137,17 @@ export async function fetchBuildings3d(
|
||||
version: BuildingsVersion = "v2",
|
||||
): Promise<FetchBuildings3dResult> {
|
||||
const origin = originIn ?? makeOrigin(center);
|
||||
const bboxWgs = lv95BboxToWgs84(bboxAround(center, radius));
|
||||
const bboxLv95 = bboxAround(center, radius);
|
||||
const bboxWgs = lv95BboxToWgs84(bboxLv95);
|
||||
// Lokale (origin-verschobene) Zuschnitt-Box — s. clipMeshToBbox: eine STAC-
|
||||
// Kachel liefert ALLE Gebäude der Kachel als EIN Mesh, nicht nur die im
|
||||
// gesuchten Radius (kann mehrere km ausserhalb des Suchpunkts umfassen).
|
||||
const clipBoxLocal: [number, number, number, number] = [
|
||||
bboxLv95[0] - origin.e,
|
||||
bboxLv95[1] - origin.n,
|
||||
bboxLv95[2] - origin.e,
|
||||
bboxLv95[3] - origin.n,
|
||||
];
|
||||
|
||||
const tryCollection = async (
|
||||
collectionId: string,
|
||||
@@ -108,7 +167,10 @@ export async function fetchBuildings3d(
|
||||
continue;
|
||||
}
|
||||
const parsed = parseDxf(text);
|
||||
for (const m of parsed.meshes) meshes.push(shiftMeshToOrigin(m, origin, item.id));
|
||||
for (const m of parsed.meshes) {
|
||||
const clipped = clipMeshToBbox(shiftMeshToOrigin(m, origin, item.id), clipBoxLocal);
|
||||
if (clipped.indices.length > 0) meshes.push(clipped);
|
||||
}
|
||||
}
|
||||
return { meshes, skipped };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user