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:
2026-07-20 10:51:37 +02:00
parent 0ab0361e3d
commit 68a0459d0e
90 changed files with 7730 additions and 1849 deletions
+141 -1
View File
@@ -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
View File
@@ -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 };
+73
View File
@@ -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]);
});
});
+216
View File
@@ -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.10.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 };
}
}