Schnittebenen 2D↔3D Phase 1+2, native Fenster, Hell/Dunkel-Theme, SWISSIMAGE-Import
- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht, unsichtbare Ebenen blenden ihre Führungslinie aus - Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/ Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste (AppMenuBar) neben der Wortmarke - Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert, flackerfrei vor erstem Render gesetzt - SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene - UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access- Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/ Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt - Neues Dachziegel-Material (RoofingTiles013A)
This commit is contained in:
@@ -5,7 +5,13 @@
|
||||
// `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { shiftMeshToOrigin, clipMeshToBbox } from "./swissBuildings3d";
|
||||
import {
|
||||
shiftMeshToOrigin,
|
||||
clipMeshToBbox,
|
||||
splitMeshComponents,
|
||||
stripOverhangSoffits,
|
||||
BUILDING_LAYER,
|
||||
} from "./swissBuildings3d";
|
||||
import type { ImportedMesh } from "../model/types";
|
||||
|
||||
describe("shiftMeshToOrigin", () => {
|
||||
@@ -79,3 +85,137 @@ describe("clipMeshToBbox", () => {
|
||||
expect(clipped.indices).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitMeshComponents", () => {
|
||||
// Zwei räumlich getrennte Dreiecke (kein gemeinsamer Vertex) = zwei Gebäude.
|
||||
const twoBuildings: ImportedMesh = {
|
||||
id: "m1",
|
||||
type: "importedMesh",
|
||||
name: "Kachel",
|
||||
positions: [
|
||||
0, 0, 0, 1, 0, 0, 0, 1, 0, // Gebäude A
|
||||
100, 100, 0, 101, 100, 0, 100, 101, 0, // Gebäude B (weit weg)
|
||||
],
|
||||
indices: [0, 1, 2, 3, 4, 5],
|
||||
};
|
||||
|
||||
it("trennt zwei unverbundene Dreiecke in zwei Meshes mit eigener Id + Ebene", () => {
|
||||
const parts = splitMeshComponents(twoBuildings);
|
||||
expect(parts.length).toBe(2);
|
||||
expect(parts[0].id).not.toBe(parts[1].id);
|
||||
for (const p of parts) {
|
||||
expect(p.layer).toBe(BUILDING_LAYER);
|
||||
expect(p.indices.length).toBe(3); // je ein Dreieck
|
||||
expect(p.positions.length).toBe(9); // kompaktiert: nur die 3 eigenen Vertices
|
||||
// Indizes sind neu gemappt (0..2), zeigen nicht mehr auf die alten Globalindizes.
|
||||
expect(Math.max(...p.indices)).toBeLessThan(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("verschweisst geteilte Eckpunkte: zwei Dreiecke mit gemeinsamer Kante bleiben EIN Gebäude", () => {
|
||||
// Zwei Dreiecke teilen die Kante (1,0)-(0,1), aber über DUPLIZIERTE Vertices
|
||||
// (getrennte Indizes, gleiche Koordinaten) — das Welding muss sie verbinden.
|
||||
const shared: ImportedMesh = {
|
||||
id: "m2",
|
||||
type: "importedMesh",
|
||||
name: "Haus",
|
||||
positions: [
|
||||
0, 0, 0, 1, 0, 0, 0, 1, 0, // Dreieck 1: (0,0),(1,0),(0,1)
|
||||
1, 0, 0, 0, 1, 0, 1, 1, 0, // Dreieck 2: (1,0),(0,1),(1,1) — dupl. Ecken
|
||||
],
|
||||
indices: [0, 1, 2, 3, 4, 5],
|
||||
};
|
||||
const parts = splitMeshComponents(shared);
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0].indices.length).toBe(6);
|
||||
});
|
||||
|
||||
it("ein einzelnes zusammenhängendes Mesh bleibt ein Mesh (bekommt aber die Ebene)", () => {
|
||||
const one: ImportedMesh = {
|
||||
id: "m3",
|
||||
type: "importedMesh",
|
||||
name: "Solo",
|
||||
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
||||
indices: [0, 1, 2],
|
||||
};
|
||||
const parts = splitMeshComponents(one);
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0].layer).toBe(BUILDING_LAYER);
|
||||
});
|
||||
|
||||
it("leeres/entartetes Mesh -> leeres Ergebnis (kein Crash)", () => {
|
||||
const empty: ImportedMesh = { id: "e", type: "importedMesh", name: "x", positions: [], indices: [] };
|
||||
expect(splitMeshComponents(empty)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripOverhangSoffits", () => {
|
||||
// Sockel (z=0) + Soffit/Wandkopf-Ring (z=2.5, EINE Dreieck-Fläche zeigt nach
|
||||
// unten = Überstand-Untersicht) + Dach (z=3, nach oben) + zwei Wanddreiecke
|
||||
// (senkrecht, nz≈0). Simuliert einen minimalen swissBUILDINGS3D-Baukörper mit
|
||||
// einer sichtbaren Dachüberstand-Untersicht (Nutzer-Report: "man sieht die
|
||||
// Wände von unten").
|
||||
it("entfernt eine nach unten zeigende Überstand-Untersicht oberhalb der Grundmauer", () => {
|
||||
const mesh: ImportedMesh = {
|
||||
id: "m1",
|
||||
type: "importedMesh",
|
||||
name: "Haus",
|
||||
positions: [
|
||||
0, 0, 0, // 0: Sockel
|
||||
1, 0, 0, // 1: Sockel
|
||||
0, 1, 0, // 2: Sockel
|
||||
0, 0, 2.5, // 3: Wandkopf
|
||||
1, 0, 2.5, // 4: Wandkopf
|
||||
0, 1, 2.5, // 5: Wandkopf (nur vom Soffit referenziert)
|
||||
0, 0, 3, // 6: Dach
|
||||
1, 0, 3, // 7: Dach
|
||||
0, 1, 3, // 8: Dach
|
||||
],
|
||||
indices: [
|
||||
0, 1, 2, // Sockel-Deckel (bleibt immer)
|
||||
3, 5, 4, // Soffit (Normale zeigt nach unten, nz=-1) -> entfernt
|
||||
6, 7, 8, // Dach (Normale zeigt nach oben, nz=+1) -> bleibt
|
||||
0, 1, 4, // Wand (senkrecht, nz≈0) -> bleibt
|
||||
0, 4, 3, // Wand (senkrecht, nz≈0) -> bleibt
|
||||
],
|
||||
};
|
||||
const out = stripOverhangSoffits(mesh);
|
||||
// 5 Dreiecke -> 4 (Soffit weg) = 12 Indizes.
|
||||
expect(out.indices.length).toBe(12);
|
||||
// Vertex 5 wird nur vom Soffit referenziert -> nach Kompaktierung weg
|
||||
// (8 statt 9 Vertices).
|
||||
expect(out.positions.length).toBe(24);
|
||||
});
|
||||
|
||||
it("lässt ein Mesh ohne Soffiten unverändert", () => {
|
||||
const mesh: ImportedMesh = {
|
||||
id: "m1",
|
||||
type: "importedMesh",
|
||||
name: "Dreieck",
|
||||
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
||||
indices: [0, 1, 2],
|
||||
};
|
||||
const out = stripOverhangSoffits(mesh);
|
||||
expect(out.indices).toEqual([0, 1, 2]);
|
||||
expect(out.positions).toEqual(mesh.positions);
|
||||
});
|
||||
|
||||
it("behält den Boden-/Sockel-Deckel unabhängig von seiner Windung", () => {
|
||||
// Ein Dreieck exakt auf der Grundmauer-Höhe mit nach-unten-Windung -> die
|
||||
// Boden-Ausnahme greift VOR der Normalen-Prüfung, bleibt also erhalten.
|
||||
const mesh: ImportedMesh = {
|
||||
id: "m1",
|
||||
type: "importedMesh",
|
||||
name: "Sockel",
|
||||
positions: [0, 0, 0, 0, 1, 0, 1, 0, 0], // a,c,b-Reihenfolge -> nz=-1
|
||||
indices: [0, 1, 2],
|
||||
};
|
||||
const out = stripOverhangSoffits(mesh);
|
||||
expect(out.indices.length).toBe(3);
|
||||
});
|
||||
|
||||
it("leeres/entartetes Mesh -> unverändert (kein Crash)", () => {
|
||||
const empty: ImportedMesh = { id: "e", type: "importedMesh", name: "x", positions: [], indices: [] };
|
||||
expect(stripOverhangSoffits(empty)).toBe(empty);
|
||||
});
|
||||
});
|
||||
|
||||
+191
-1
@@ -105,6 +105,189 @@ export function clipMeshToBbox(
|
||||
return { ...mesh, positions, indices };
|
||||
}
|
||||
|
||||
/** Sichtbarer Ebenen-/Gruppierungsname der importierten Gebäude (SitePanel). */
|
||||
export const BUILDING_LAYER = "Gebäude";
|
||||
|
||||
/**
|
||||
* Zerlegt ein Mesh in seine räumlich ZUSAMMENHÄNGENDEN Komponenten (Union-Find
|
||||
* über verschweisste Vertices) — je Komponente ein eigenes {@link ImportedMesh}.
|
||||
* So wird eine swissBUILDINGS3D-Kachel (ein Mesh mit ALLEN Gebäuden, s.
|
||||
* `dxfParser.ts::parseDxf`) in EINZELNE, getrennt anwähl-/löschbare Gebäude
|
||||
* aufgeteilt (Nutzer-Wunsch). Vertices werden per quantisierter Position
|
||||
* (`weldMm`) verschweisst, damit Dreiecke, die sich einen Eckpunkt teilen, aber
|
||||
* getrennte Indizes tragen (typisch für 3DFACE-DXF), als verbunden gelten. Jede
|
||||
* Komponente bekommt eine eigene Id + fortlaufenden Namen + `layer`. Rein (kein
|
||||
* Netzwerk), exportiert für Unit-Tests.
|
||||
*/
|
||||
export function splitMeshComponents(mesh: ImportedMesh, weldMm = 1): ImportedMesh[] {
|
||||
const pos = mesh.positions;
|
||||
const vcount = Math.floor(pos.length / 3);
|
||||
const idx = mesh.indices;
|
||||
if (vcount < 3 || idx.length < 3) return idx.length >= 3 ? [mesh] : [];
|
||||
|
||||
// 1) Weld: gleiche (quantisierte) Position -> gemeinsamer kanonischer Index.
|
||||
const q = weldMm / 1000; // Meter
|
||||
const weldMap = new Map<string, number>();
|
||||
const canonical = new Int32Array(vcount);
|
||||
for (let i = 0; i < vcount; i++) {
|
||||
const key = `${Math.round(pos[i * 3] / q)},${Math.round(pos[i * 3 + 1] / q)},${Math.round(pos[i * 3 + 2] / q)}`;
|
||||
let c = weldMap.get(key);
|
||||
if (c === undefined) {
|
||||
c = i;
|
||||
weldMap.set(key, i);
|
||||
}
|
||||
canonical[i] = c;
|
||||
}
|
||||
|
||||
// 2) Union-Find über die kanonischen Vertices via Dreieckskanten.
|
||||
const parent = new Int32Array(vcount);
|
||||
for (let i = 0; i < vcount; i++) parent[i] = i;
|
||||
const find = (x: number): number => {
|
||||
let r = x;
|
||||
while (parent[r] !== r) r = parent[r];
|
||||
while (parent[x] !== r) {
|
||||
const next = parent[x];
|
||||
parent[x] = r;
|
||||
x = next;
|
||||
}
|
||||
return r;
|
||||
};
|
||||
const union = (a: number, b: number) => {
|
||||
const ra = find(a);
|
||||
const rb = find(b);
|
||||
if (ra !== rb) parent[ra] = rb;
|
||||
};
|
||||
for (let i = 0; i + 2 < idx.length; i += 3) {
|
||||
const a = idx[i], b = idx[i + 1], c = idx[i + 2];
|
||||
if (a >= vcount || b >= vcount || c >= vcount) continue;
|
||||
union(canonical[a], canonical[b]);
|
||||
union(canonical[b], canonical[c]);
|
||||
}
|
||||
|
||||
// 3) Dreiecke nach Komponenten-Wurzel gruppieren.
|
||||
const groups = new Map<number, number[]>();
|
||||
for (let i = 0; i + 2 < idx.length; i += 3) {
|
||||
const a = idx[i], b = idx[i + 1], c = idx[i + 2];
|
||||
if (a >= vcount || b >= vcount || c >= vcount) continue;
|
||||
const root = find(canonical[a]);
|
||||
let g = groups.get(root);
|
||||
if (!g) {
|
||||
g = [];
|
||||
groups.set(root, g);
|
||||
}
|
||||
g.push(a, b, c);
|
||||
}
|
||||
if (groups.size <= 1) return [{ ...mesh, layer: mesh.layer ?? BUILDING_LAYER }];
|
||||
|
||||
// 4) Je Komponente ein eigenes Mesh (Positions kompaktiert, Indizes neu gemappt).
|
||||
const out: ImportedMesh[] = [];
|
||||
let n = 0;
|
||||
for (const tri of groups.values()) {
|
||||
const remap = new Map<number, number>();
|
||||
const positions: number[] = [];
|
||||
const indices: number[] = [];
|
||||
for (const old of tri) {
|
||||
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);
|
||||
}
|
||||
indices.push(ni);
|
||||
}
|
||||
out.push({
|
||||
...mesh,
|
||||
id: `${mesh.id}-b${n}`,
|
||||
name: `${mesh.name} #${n + 1}`,
|
||||
layer: mesh.layer ?? BUILDING_LAYER,
|
||||
positions,
|
||||
indices,
|
||||
});
|
||||
n++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ab welcher Höhe (Meter) über der Grundmauer ein Dreieck NICHT mehr als
|
||||
* Boden-/Sockel-Deckel gilt (siehe {@link stripOverhangSoffits}). */
|
||||
const BASE_CAP_TOL = 0.05;
|
||||
/** Normalen-Höhenkomponente (z, Modell-Hoch-Achse): darunter gilt ein Dreieck
|
||||
* als „zeigt deutlich nach unten" (Soffit-Kandidat). cos(70°) ≈ 0.34 — nur
|
||||
* weitgehend WAAGRECHTE Unterseiten, keine steilen Dachflächen. */
|
||||
const SOFFIT_DOWN_THRESHOLD = -0.35;
|
||||
|
||||
/**
|
||||
* Entfernt Dachüberstand-Untersichten (Soffiten) aus einem swissBUILDINGS3D-
|
||||
* Mesh: Dreiecke, deren Fläche deutlich nach UNTEN zeigt UND nicht auf
|
||||
* Grundmauer-Höhe liegt (kein Boden-/Sockel-Deckel). Solche Flächen sind reine
|
||||
* LOD2-Detailtreue (die Untersicht eines Dachüberstands) — im Konturen-
|
||||
* Anzeigemodus fallen sie als zusätzliche „man sieht die Wand von unten"-
|
||||
* Silhouettenkanten auf (Nutzer-Report), für den gewünschten Gipsmodell-/
|
||||
* Massenmodell-Look unerwünscht.
|
||||
*
|
||||
* ANNAHME: die DXF-Quelle liefert pro Gebäude ein KONSISTENT (aussen = positive
|
||||
* Hoch-Komponente) gewundenes Solid (Standard bei swisstopo-LOD2 — die
|
||||
* doppelseitige Darstellung in render3d ist eine reine GPU-Vorsichtsmassnahme
|
||||
* gegen Einzelfälle, keine Aussage über zufällige Windung). Bei inkonsistenter
|
||||
* Windung könnte dies vereinzelt eine echte Dachfläche statt einer Soffite
|
||||
* entfernen — visuell in Tauri zu verifizieren.
|
||||
*
|
||||
* `positions` wird KOMPAKTIERT (analog `clipMeshToBbox`). Rein (kein Netzwerk),
|
||||
* exportiert für Unit-Tests.
|
||||
*/
|
||||
export function stripOverhangSoffits(mesh: ImportedMesh): ImportedMesh {
|
||||
const pos = mesh.positions;
|
||||
const idx = mesh.indices;
|
||||
if (idx.length < 3 || pos.length < 9) return mesh;
|
||||
|
||||
let minZ = Infinity;
|
||||
for (let i = 2; i < pos.length; i += 3) {
|
||||
if (pos[i] < minZ) minZ = pos[i];
|
||||
}
|
||||
|
||||
const keptTris: [number, number, number][] = [];
|
||||
for (let i = 0; i + 2 < idx.length; i += 3) {
|
||||
const a = idx[i];
|
||||
const b = idx[i + 1];
|
||||
const c = idx[i + 2];
|
||||
const ax = pos[a * 3], ay = pos[a * 3 + 1], az = pos[a * 3 + 2];
|
||||
const bx = pos[b * 3], by = pos[b * 3 + 1], bz = pos[b * 3 + 2];
|
||||
const cx = pos[c * 3], cy = pos[c * 3 + 1], cz = pos[c * 3 + 2];
|
||||
// Boden-/Sockel-Deckel (alle drei Ecken nahe der Grundmauer-Höhe) bleibt
|
||||
// in jedem Fall erhalten — kein Soffit-Kandidat.
|
||||
const isBaseCap =
|
||||
az <= minZ + BASE_CAP_TOL && bz <= minZ + BASE_CAP_TOL && cz <= minZ + BASE_CAP_TOL;
|
||||
if (isBaseCap) {
|
||||
keptTris.push([a, b, c]);
|
||||
continue;
|
||||
}
|
||||
// Flächennormale (Modell x,y,z=Höhe): z-Komponente = "nach oben/unten".
|
||||
const ux = bx - ax, uy = by - ay, uz = bz - az;
|
||||
const vx = cx - ax, vy = cy - ay, vz = cz - az;
|
||||
const nx = uy * vz - uz * vy;
|
||||
const ny = uz * vx - ux * vz;
|
||||
const nz = ux * vy - uy * vx;
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||||
if (len > 1e-9 && nz / len < SOFFIT_DOWN_THRESHOLD) continue; // Soffit: weg
|
||||
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[];
|
||||
@@ -169,7 +352,14 @@ export async function fetchBuildings3d(
|
||||
const parsed = parseDxf(text);
|
||||
for (const m of parsed.meshes) {
|
||||
const clipped = clipMeshToBbox(shiftMeshToOrigin(m, origin, item.id), clipBoxLocal);
|
||||
if (clipped.indices.length > 0) meshes.push(clipped);
|
||||
if (clipped.indices.length < 3) continue;
|
||||
// In EINZELNE Gebäude aufteilen (eine Kachel = ein Mesh mit allen
|
||||
// Häusern) — so ist jedes Haus getrennt anwähl-/löschbar. Je Gebäude
|
||||
// die Dachüberstand-Untersichten (Soffiten) entfernen (Gipsmodell-Look,
|
||||
// s. stripOverhangSoffits-Moduldoc).
|
||||
for (const b of splitMeshComponents(clipped)) {
|
||||
meshes.push(stripOverhangSoffits(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { meshes, skipped };
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Reine (netzwerkfreie) Bausteine des SWISSIMAGE-Imports: WMS-URL-Bau +
|
||||
// Pixelgrössen-Deckelung. Der Fetch selbst bleibt ungetestet (kein Fetch-
|
||||
// Mocking im Repo, analog swissTopo/swissAlti3d).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildSwissImageUrl,
|
||||
imagePixelSize,
|
||||
sampleColorsFromImageData,
|
||||
} from "./swissImage";
|
||||
|
||||
describe("buildSwissImageUrl", () => {
|
||||
it("baut eine WMS-1.1.1-GetMap-URL mit EPSG:2056 und E,N-bbox-Reihenfolge", () => {
|
||||
const url = buildSwissImageUrl([2600000, 1200000, 2600400, 1200400], 2000, 2000);
|
||||
expect(url).toContain("SERVICE=WMS");
|
||||
expect(url).toContain("VERSION=1.1.1");
|
||||
expect(url).toContain("REQUEST=GetMap");
|
||||
expect(url).toContain("LAYERS=ch.swisstopo.swissimage");
|
||||
expect(url).toContain("SRS=EPSG%3A2056");
|
||||
// bbox in E,N: eMin,nMin,eMax,nMax.
|
||||
expect(url).toContain("BBOX=2600000%2C1200000%2C2600400%2C1200400");
|
||||
expect(url).toContain("WIDTH=2000");
|
||||
expect(url).toContain("HEIGHT=2000");
|
||||
expect(url).toContain("FORMAT=image%2Fjpeg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("imagePixelSize", () => {
|
||||
it("skaliert mit der Spannweite (~0.2 m/px)", () => {
|
||||
expect(imagePixelSize(400)).toBe(2000); // 400 m / 0.2
|
||||
});
|
||||
it("deckelt bei der Maximalgrösse", () => {
|
||||
expect(imagePixelSize(100000)).toBe(2500);
|
||||
});
|
||||
it("hat eine sinnvolle Untergrenze", () => {
|
||||
expect(imagePixelSize(1)).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampleColorsFromImageData (Luftbild-Drape)", () => {
|
||||
// 2×2-Bild: oben-links rot, oben-rechts grün, unten-links blau, unten-rechts weiss.
|
||||
// Reihenfolge im RGBA-Puffer ist zeilenweise von OBEN (Zeile 0 = Nord/+Y).
|
||||
const W = 2;
|
||||
const H = 2;
|
||||
const px = new Uint8ClampedArray([
|
||||
255, 0, 0, 255, /* (0,0) rot */ 0, 255, 0, 255, /* (1,0) grün */
|
||||
0, 0, 255, 255, /* (0,1) blau */ 255, 255, 255, 255, /* (1,1) weiss */
|
||||
]);
|
||||
const min = { x: 0, y: 0 };
|
||||
const max = { x: 10, y: 10 };
|
||||
|
||||
it("mappt die Modell-Ecken auf die richtigen Bildpixel (Nord = Bild-oben)", () => {
|
||||
// NW (x=0,y=10) -> Bild oben-links = rot; NE (10,10) -> oben-rechts = grün;
|
||||
// SW (0,0) -> unten-links = blau; SE (10,0) -> unten-rechts = weiss.
|
||||
const positions = [
|
||||
0, 10, 0, // NW
|
||||
10, 10, 0, // NE
|
||||
0, 0, 0, // SW
|
||||
10, 0, 0, // SE
|
||||
];
|
||||
const c = sampleColorsFromImageData(px, W, H, positions, min, max);
|
||||
expect(c.slice(0, 3)).toEqual([1, 0, 0]); // rot
|
||||
expect(c.slice(3, 6)).toEqual([0, 1, 0]); // grün
|
||||
expect(c.slice(6, 9)).toEqual([0, 0, 1]); // blau
|
||||
expect(c.slice(9, 12)).toEqual([1, 1, 1]); // weiss
|
||||
});
|
||||
|
||||
it("klemmt Vertices ausserhalb der Bildbox auf den Randpixel", () => {
|
||||
const c = sampleColorsFromImageData(px, W, H, [-100, 100, 0], min, max);
|
||||
// x<min, y>max -> u=0, v=1 -> Bild oben-links = rot.
|
||||
expect(c).toEqual([1, 0, 0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
// SWISSIMAGE-Luftbild (swisstopo WMS) für eine LV95-bbox → georeferenziertes
|
||||
// Rasterbild (Data-URL + Modell-Meter-Bounding-Box). Wird als Hintergrund-
|
||||
// Ebene in den Grundriss gelegt (Kontext-Schicht, `ContextImage`).
|
||||
//
|
||||
// Bezug via WMS 1.1.1 GetMap (EPSG:2056, eindeutige E,N-Achsreihenfolge) auf
|
||||
// `ch.swisstopo.swissimage` — dieselbe offene geo.admin-Infrastruktur wie die
|
||||
// übrigen Importe. Das Bild wird als Blob geladen und in eine Data-URL gewandelt,
|
||||
// damit es (wie die Bild-Schraffuren) ohne externen Host im Projekt/Export lebt.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
|
||||
|
||||
import { bboxAround, makeOrigin } from "./lv95";
|
||||
import type { GeoOrigin, LV95 } from "./lv95";
|
||||
import { viaProxy } from "./geoContext";
|
||||
|
||||
const WMS = "https://wms.geo.admin.ch/";
|
||||
const LAYER = "ch.swisstopo.swissimage";
|
||||
/** Ziel-Bodenauflösung (Meter/Pixel) — SWISSIMAGE liegt bei ~0.1–0.25 m/px. */
|
||||
const TARGET_MPP = 0.2;
|
||||
/** Max. Kantenlänge des angeforderten Bildes (Pixel) — WMS-Server-Limit + Speicher. */
|
||||
const MAX_PX = 2500;
|
||||
|
||||
/** Ergebnis eines SWISSIMAGE-Abrufs. */
|
||||
export interface FetchSwissImageResult {
|
||||
origin: GeoOrigin;
|
||||
/** Data-URL (JPEG) oder null (kein Bild / Fehler). */
|
||||
dataUrl: string | null;
|
||||
/** Modell-Meter-Bounding-Box, die das Bild ausfüllt (aus `origin` abgeleitet). */
|
||||
min: { x: number; y: number };
|
||||
max: { x: number; y: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die WMS-1.1.1-GetMap-URL für eine LV95-bbox (Reihenfolge E,N) — rein,
|
||||
* für Unit-Tests exportiert.
|
||||
*/
|
||||
export function buildSwissImageUrl(
|
||||
bboxLv95: [number, number, number, number],
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
): string {
|
||||
const [eMin, nMin, eMax, nMax] = bboxLv95;
|
||||
const params = new URLSearchParams({
|
||||
SERVICE: "WMS",
|
||||
VERSION: "1.1.1",
|
||||
REQUEST: "GetMap",
|
||||
LAYERS: LAYER,
|
||||
STYLES: "",
|
||||
SRS: "EPSG:2056",
|
||||
BBOX: `${eMin},${nMin},${eMax},${nMax}`,
|
||||
WIDTH: String(widthPx),
|
||||
HEIGHT: String(heightPx),
|
||||
FORMAT: "image/jpeg",
|
||||
});
|
||||
return `${WMS}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** Pixelmaße für eine bbox-Spannweite (Meter) bei {@link TARGET_MPP}, gedeckelt. */
|
||||
export function imagePixelSize(spanMeters: number): number {
|
||||
const px = Math.round(spanMeters / TARGET_MPP);
|
||||
return Math.max(64, Math.min(MAX_PX, px));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes → base64 (chunked `btoa`) — umgeht `FileReader` (nicht in jeder
|
||||
* Umgebung vorhanden) und die Argument-Längengrenze von `String.fromCharCode`.
|
||||
*/
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** ArrayBuffer → Data-URL des Bildes. */
|
||||
function bufferToDataUrl(buf: ArrayBuffer, mime: string): string {
|
||||
return `data:${mime};base64,${bytesToBase64(new Uint8Array(buf))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sampelt für jeden Mesh-Vertex die Bildfarbe an seiner Lage — die reine
|
||||
* (testbare) Kernrechnung des Luftbild-„Drapierens" aufs Gelände-TIN. `positions`
|
||||
* ist das flache Modell-Array `[x,y,z, …]` (z = Höhe, ignoriert); `min`/`max` die
|
||||
* Modell-Meter-Bounding-Box, die das Bild ausfüllt (Bild-Oben = +Y/Nord). Liefert
|
||||
* eine flache Pro-Vertex-Albedo `[r,g,b, …]` (0..1). Vertices ausserhalb der Box
|
||||
* klemmen auf den Randpixel.
|
||||
*/
|
||||
export function sampleColorsFromImageData(
|
||||
data: Uint8ClampedArray | Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
positions: readonly number[],
|
||||
min: { x: number; y: number },
|
||||
max: { x: number; y: number },
|
||||
): number[] {
|
||||
const spanX = max.x - min.x || 1;
|
||||
const spanY = max.y - min.y || 1;
|
||||
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i + 2 < positions.length; i += 3) {
|
||||
const u = clamp01((positions[i] - min.x) / spanX);
|
||||
// Bildzeile 0 (oben) entspricht Nord (+Y) -> v=1; daher (1 - v) spiegeln.
|
||||
const v = clamp01((positions[i + 1] - min.y) / spanY);
|
||||
const px = Math.min(width - 1, Math.max(0, Math.round(u * (width - 1))));
|
||||
const py = Math.min(height - 1, Math.max(0, Math.round((1 - v) * (height - 1))));
|
||||
const idx = (py * width + px) * 4;
|
||||
out.push(data[idx] / 255, data[idx + 1] / 255, data[idx + 2] / 255);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dekodiert eine Bild-Data-URL und sampelt daraus die Pro-Vertex-Albedo für ein
|
||||
* Gelände-TIN (siehe {@link sampleColorsFromImageData}). Nutzt `createImageBitmap`
|
||||
* + `OffscreenCanvas` (im App-/WebView-Kontext vorhanden; in Node/Tests fehlen sie
|
||||
* — dann `null`, kein Wurf). So bleibt der Render-Pfad synchron: gesampelt wird
|
||||
* EINMAL beim Import, das Ergebnis lebt auf `TerrainMesh.vertexColors`.
|
||||
*/
|
||||
export async function sampleTerrainColors(
|
||||
dataUrl: string,
|
||||
positions: readonly number[],
|
||||
min: { x: number; y: number },
|
||||
max: { x: number; y: number },
|
||||
): Promise<number[] | null> {
|
||||
if (
|
||||
typeof createImageBitmap !== "function" ||
|
||||
typeof OffscreenCanvas !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
const bmp = await createImageBitmap(blob);
|
||||
const canvas = new OffscreenCanvas(bmp.width, bmp.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
|
||||
return sampleColorsFromImageData(img.data, bmp.width, bmp.height, positions, min, max);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Dekodiertes Bild: dichte RGBA8-Bytes + Maße (zeilenweise von oben). */
|
||||
export interface DecodedImage {
|
||||
data: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dekodiert eine Bild-Data-URL zu dichten RGBA8-Bytes (für den WASM-Textur-Upload
|
||||
* beim 3D-Luftbild-Drapieren, `WebModelRenderer.set_aerial_texture`). Nutzt
|
||||
* `createImageBitmap` + `OffscreenCanvas` (App-/WebView-Kontext; in Node/Tests
|
||||
* fehlen sie → `null`, kein Wurf).
|
||||
*/
|
||||
export async function decodeImageToRgba(dataUrl: string): Promise<DecodedImage | null> {
|
||||
if (
|
||||
typeof createImageBitmap !== "function" ||
|
||||
typeof OffscreenCanvas !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
const bmp = await createImageBitmap(blob);
|
||||
const canvas = new OffscreenCanvas(bmp.width, bmp.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
|
||||
return {
|
||||
data: new Uint8Array(img.data.buffer.slice(0)),
|
||||
width: bmp.width,
|
||||
height: bmp.height,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt ein SWISSIMAGE-Luftbild für eine LV95-bbox (Zentrum + Radius) und liefert
|
||||
* es als Data-URL samt Modell-Meter-Bounding-Box. `originIn` wiederverwenden
|
||||
* (wie die übrigen Importe), damit das Bild lagerichtig zu Gebäuden/Terrain
|
||||
* liegt. Wirft nicht — bei Netz-/Serverfehler ist `dataUrl` null.
|
||||
*/
|
||||
export async function fetchSwissImage(
|
||||
center: LV95,
|
||||
radius: number,
|
||||
originIn?: GeoOrigin,
|
||||
): Promise<FetchSwissImageResult> {
|
||||
const origin = originIn ?? makeOrigin(center);
|
||||
const bboxLv95 = bboxAround(center, radius);
|
||||
const px = imagePixelSize(2 * radius);
|
||||
const min = { x: bboxLv95[0] - origin.e, y: bboxLv95[1] - origin.n };
|
||||
const max = { x: bboxLv95[2] - origin.e, y: bboxLv95[3] - origin.n };
|
||||
|
||||
try {
|
||||
const res = await fetch(viaProxy(buildSwissImageUrl(bboxLv95, px, px)));
|
||||
if (!res.ok) return { origin, dataUrl: null, min, max };
|
||||
// WMS liefert bei Fehlern manchmal eine XML-Fehlermeldung statt eines Bildes.
|
||||
const mime = res.headers.get("content-type") ?? "";
|
||||
if (!mime.startsWith("image/")) return { origin, dataUrl: null, min, max };
|
||||
const buf = await res.arrayBuffer();
|
||||
if (buf.byteLength === 0) return { origin, dataUrl: null, min, max };
|
||||
return { origin, dataUrl: bufferToDataUrl(buf, mime.split(";")[0]), min, max };
|
||||
} catch {
|
||||
return { origin, dataUrl: null, min, max };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user