From e3204b4900fdd126eed83a197b20d42f84139cd6 Mon Sep 17 00:00:00 2001 From: Andrej Fast Date: Wed, 17 Jun 2026 14:14:36 +0200 Subject: [PATCH 1/6] feat(lib): geotiff support --- package-lock.json | 74 ++ package.json | 1 + src/lib/layers/equirectLayer.ts | 316 +++++++- src/lib/layers/glsl/flatInverse.frag.glsl | 23 +- .../layers/glsl/gpuProjectedMask.frag.glsl | 28 +- src/lib/layers/gridExport.ts | 704 +++++++++++++++++- src/lib/layers/gridExportMetadata.ts | 116 +++ src/lib/layers/textureLayerFormats.ts | 77 ++ src/store/store.ts | 2 +- src/ui/grids/Regular.vue | 52 +- src/ui/grids/composables/useGridOverlays.ts | 28 +- .../grids/composables/useSharedGridLogic.ts | 6 +- src/ui/overlays/controls/LayerPanel.vue | 12 +- tests/unit/lib/layers/gridExport.test.ts | 210 ++++++ .../lib/layers/textureLayerFormats.test.ts | 72 ++ 15 files changed, 1634 insertions(+), 87 deletions(-) create mode 100644 src/lib/layers/gridExportMetadata.ts create mode 100644 src/lib/layers/textureLayerFormats.ts create mode 100644 tests/unit/lib/layers/gridExport.test.ts create mode 100644 tests/unit/lib/layers/textureLayerFormats.test.ts diff --git a/package-lock.json b/package-lock.json index 59063406..ea37d302 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "dayjs": "^1.11.21", "fflate": "^0.8.3", "geokdbush": "2.1.0", + "geotiff": "^3.0.5", "humanize-duration": "^3.33.2", "icechunk-js": "^0.4.0", "kdbush": "4.1.0", @@ -1275,6 +1276,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "license": "MIT" + }, "node_modules/@pkgr/core": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", @@ -5120,6 +5127,37 @@ "tinyqueue": "^3.0.0" } }, + "node_modules/geotiff": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", + "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", + "license": "MIT", + "dependencies": { + "@petamoriken/float16": "^3.9.3", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.5.0", + "xml-utils": "^1.10.2", + "zstddec": "^0.2.0" + }, + "engines": { + "node": ">=10.19" + } + }, + "node_modules/geotiff/node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -6347,6 +6385,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", + "license": "Apache-2.0" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7298,6 +7342,12 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7310,6 +7360,12 @@ "node": ">=6" } }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -9532,6 +9588,12 @@ "typescript": ">=5.0.0" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -9737,6 +9799,12 @@ "node": ">=12" } }, + "node_modules/xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", + "license": "CC0-1.0" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -9846,6 +9914,12 @@ "@zarrita/storage": "^0.2.0", "numcodecs": "^0.3.2" } + }, + "node_modules/zstddec": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", + "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", + "license": "MIT AND BSD-3-Clause" } } } diff --git a/package.json b/package.json index 88ae906e..d01108b5 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dayjs": "^1.11.21", "fflate": "^0.8.3", "geokdbush": "2.1.0", + "geotiff": "^3.0.5", "humanize-duration": "^3.33.2", "icechunk-js": "^0.4.0", "kdbush": "4.1.0", diff --git a/src/lib/layers/equirectLayer.ts b/src/lib/layers/equirectLayer.ts index 26902ba3..4d2f1798 100644 --- a/src/lib/layers/equirectLayer.ts +++ b/src/lib/layers/equirectLayer.ts @@ -4,6 +4,7 @@ // Used by the built-in land/sea mask and by user-uploaded texture layers. import * as d3 from "d3-geo"; +import { fromBlob, type TypedArrayWithDimensions } from "geotiff"; import * as THREE from "three"; import flatInverseFragmentShader from "./glsl/flatInverse.frag.glsl"; @@ -11,6 +12,7 @@ import flatInverseVertexShader from "./glsl/flatInverse.vert.glsl"; import gpuProjectedMaskFragmentShader from "./glsl/gpuProjectedMask.frag.glsl"; import gpuProjectedMaskVertexShader from "./glsl/gpuProjectedMask.vert.glsl"; import { ResourceCache } from "./ResourceCache.ts"; +import { isGeoTiffLayerSource } from "./textureLayerFormats.ts"; import { getProjectionTypeFromMode } from "@/lib/projection/projectionShaders.ts"; import type { ProjectionHelper } from "@/lib/projection/projectionUtils.ts"; @@ -25,10 +27,60 @@ export const LAND_SEA_MASK_MODES = { export type TLandSeaMaskMode = (typeof LAND_SEA_MASK_MODES)[keyof typeof LAND_SEA_MASK_MODES]; +export type TGeoBounds = { + west: number; + south: number; + east: number; + north: number; +}; + +export type TImageLayerTexture = { + texture: THREE.Texture; + bounds: TGeoBounds; +}; + +export const TextureLayerSampling = { + SMOOTH: "smooth", + PIXELATED: "pixelated", +} as const; + +export type TTextureLayerSampling = + (typeof TextureLayerSampling)[keyof typeof TextureLayerSampling]; + // All equirect layers share the coastline radius so there is no parallax // drift when the camera orbits; layering is handled purely via renderOrder. const GLOBE_LAYER_RADIUS = 1.003; const GRID_RESOLUTION = { latSegments: 180, lonSegments: 360 }; +export const GLOBAL_TEXTURE_BOUNDS: TGeoBounds = { + west: -180, + south: -90, + east: 180, + north: 90, +}; +const GeoTiffModelType = { + GEOGRAPHIC: 2, +} as const; +type TGeoTiffModelType = + (typeof GeoTiffModelType)[keyof typeof GeoTiffModelType]; +const GeoTiffAngularUnit = { + DEGREE: 9102, +} as const; +type TGeoTiffAngularUnit = + (typeof GeoTiffAngularUnit)[keyof typeof GeoTiffAngularUnit]; +const COORDINATE_EPSILON = 1e-6; + +type TGeoTiffImage = { + getBitsPerSample(sampleIndex?: number): number; + getBoundingBox(): number[]; + getGeoKeys(): Partial> | null; + getHeight(): number; + getSamplesPerPixel(): number; + getWidth(): number; + readRGB(options: { + enableAlpha: boolean; + interleave: true; + }): Promise; +}; // ============================================================================= // Canvas helpers @@ -43,13 +95,39 @@ export function createLayerCanvas(width = 8192, height = 4096) { return { canvas, ctx, width, height }; } -export function configureEquirectangularTexture(texture: THREE.Texture) { - texture.wrapS = THREE.RepeatWrapping; +export function getLongitudeSpan(bounds: TGeoBounds): number { + const span = bounds.east - bounds.west; + return span > 0 ? span : span + 360; +} + +function isGlobalTextureBounds(bounds: TGeoBounds) { + return ( + Math.abs(getLongitudeSpan(bounds) - 360) < COORDINATE_EPSILON && + bounds.south <= -90 + COORDINATE_EPSILON && + bounds.north >= 90 - COORDINATE_EPSILON + ); +} + +export function configureEquirectangularTexture( + texture: THREE.Texture, + bounds: TGeoBounds = GLOBAL_TEXTURE_BOUNDS, + sampling: TTextureLayerSampling = TextureLayerSampling.SMOOTH +) { + texture.colorSpace = THREE.SRGBColorSpace; + texture.wrapS = isGlobalTextureBounds(bounds) + ? THREE.RepeatWrapping + : THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; - texture.anisotropy = 16; + texture.anisotropy = sampling === TextureLayerSampling.PIXELATED ? 1 : 16; texture.generateMipmaps = false; - texture.minFilter = THREE.LinearFilter; - texture.magFilter = THREE.LinearFilter; + texture.minFilter = + sampling === TextureLayerSampling.PIXELATED + ? THREE.NearestFilter + : THREE.LinearFilter; + texture.magFilter = + sampling === TextureLayerSampling.PIXELATED + ? THREE.NearestFilter + : THREE.LinearFilter; texture.needsUpdate = true; return texture; } @@ -78,6 +156,29 @@ function getEquirectangularPathHeight(width: number): number { return width / 2; } +function createRegionalEquirectangularPath( + ctx: CanvasRenderingContext2D, + width: number, + bounds: TGeoBounds +): { path: d3.GeoPath; pathHeight: number } { + const lonSpan = getLongitudeSpan(bounds); + const latSpan = bounds.north - bounds.south; + const scale = width / THREE.MathUtils.degToRad(lonSpan); + const pathHeight = THREE.MathUtils.degToRad(latSpan) * scale; + const projection = d3 + .geoEquirectangular() + .translate([ + -THREE.MathUtils.degToRad(bounds.west) * scale, + THREE.MathUtils.degToRad(bounds.north) * scale, + ]) + .scale(scale) + .clipExtent([ + [0, 0], + [width, pathHeight], + ]); + return { path: d3.geoPath(projection, ctx), pathHeight }; +} + /** * Cut the current canvas content to land (`"land"`) or sea (`"sea"`) using * the natural-earth land polygons. `"off"`/`"globe"` leave it untouched. @@ -86,14 +187,23 @@ export async function applyLandSeaCutout( ctx: CanvasRenderingContext2D, width: number, height: number, - mode: TLandSeaMaskMode + mode: TLandSeaMaskMode, + bounds: TGeoBounds = GLOBAL_TEXTURE_BOUNDS ): Promise { if (mode !== LAND_SEA_MASK_MODES.LAND && mode !== LAND_SEA_MASK_MODES.SEA) { return; } const land = await ResourceCache.loadLandGeoJSON(); - const pathHeight = getEquirectangularPathHeight(width); - const path = createEquirectangularPath(ctx, width, pathHeight); + const { path, pathHeight } = isGlobalTextureBounds(bounds) + ? { + path: createEquirectangularPath( + ctx, + width, + getEquirectangularPathHeight(width) + ), + pathHeight: getEquirectangularPathHeight(width), + } + : createRegionalEquirectangularPath(ctx, width, bounds); ctx.save(); ctx.scale(1, height / pathHeight); ctx.beginPath(); @@ -104,14 +214,176 @@ export async function applyLandSeaCutout( ctx.restore(); } +function getGeoKeyNumber( + image: TGeoTiffImage, + key: string +): number | undefined { + const value = image.getGeoKeys()?.[key]; + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : undefined; +} + +function isGeographicGeoTiff(image: TGeoTiffImage): boolean { + const modelType = getGeoKeyNumber(image, "GTModelTypeGeoKey"); + if (modelType !== undefined) { + return ( + modelType === (GeoTiffModelType.GEOGRAPHIC satisfies TGeoTiffModelType) + ); + } + return ( + getGeoKeyNumber(image, "GeographicTypeGeoKey") !== undefined && + getGeoKeyNumber(image, "ProjectedCSTypeGeoKey") === undefined + ); +} + +function hasDegreeAngularUnits(image: TGeoTiffImage): boolean { + const angularUnit = getGeoKeyNumber(image, "GeogAngularUnitsGeoKey"); + return ( + angularUnit === undefined || + angularUnit === (GeoTiffAngularUnit.DEGREE satisfies TGeoTiffAngularUnit) + ); +} + +function clampLatitude(latitude: number) { + if (latitude < -90 && latitude >= -90 - COORDINATE_EPSILON) { + return -90; + } + if (latitude > 90 && latitude <= 90 + COORDINATE_EPSILON) { + return 90; + } + return latitude; +} + +export function normalizeGeoTiffBounds(boundingBox: number[]): TGeoBounds { + const [west, southValue, east, northValue] = boundingBox; + const south = clampLatitude(Math.min(southValue, northValue)); + const north = clampLatitude(Math.max(southValue, northValue)); + const bounds = { west, south, east, north }; + const lonSpan = getLongitudeSpan(bounds); + if ( + ![bounds.west, bounds.south, bounds.east, bounds.north].every( + Number.isFinite + ) || + south < -90 || + north > 90 || + north <= south || + lonSpan <= 0 || + lonSpan > 360 + COORDINATE_EPSILON + ) { + throw new Error( + "GeoTIFF layers must use longitude/latitude bounds in degrees." + ); + } + return bounds; +} + +function getGeoTiffBounds(image: TGeoTiffImage): TGeoBounds { + if (!isGeographicGeoTiff(image) || !hasDegreeAngularUnits(image)) { + throw new Error( + "Only longitude/latitude GeoTIFF layers are supported. Reproject the GeoTIFF to EPSG:4326 before uploading." + ); + } + return normalizeGeoTiffBounds(image.getBoundingBox()); +} + +function getColorChannelMax( + image: TGeoTiffImage, + data: TypedArrayWithDimensions, + channel: number +) { + if (data instanceof Uint8Array || data instanceof Uint8ClampedArray) { + return 255; + } + if (data instanceof Float32Array || data instanceof Float64Array) { + return 1; + } + const sampleIndex = Math.min(channel, image.getSamplesPerPixel() - 1); + return 2 ** image.getBitsPerSample(sampleIndex) - 1; +} + +function toColorByte(value: number, max: number) { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.min(255, Math.round((value / max) * 255))); +} + +function putRgbRasterOnCanvas( + image: TGeoTiffImage, + ctx: CanvasRenderingContext2D, + raster: TypedArrayWithDimensions +) { + const pixelCount = raster.width * raster.height; + const channelCount = raster.length / pixelCount; + if (channelCount < 3) { + throw new Error("GeoTIFF layer could not be decoded as RGB."); + } + const channelMax = [ + getColorChannelMax(image, raster, 0), + getColorChannelMax(image, raster, 1), + getColorChannelMax(image, raster, 2), + getColorChannelMax(image, raster, 3), + ]; + const imageData = ctx.createImageData(raster.width, raster.height); + for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex++) { + const sourceIndex = pixelIndex * channelCount; + const targetIndex = pixelIndex * 4; + imageData.data[targetIndex] = toColorByte( + raster[sourceIndex], + channelMax[0] + ); + imageData.data[targetIndex + 1] = toColorByte( + raster[sourceIndex + 1], + channelMax[1] + ); + imageData.data[targetIndex + 2] = toColorByte( + raster[sourceIndex + 2], + channelMax[2] + ); + imageData.data[targetIndex + 3] = + channelCount >= 4 + ? toColorByte(raster[sourceIndex + 3], channelMax[3]) + : 255; + } + ctx.putImageData(imageData, 0, 0); +} + +async function createGeoTiffLayerTexture( + blob: Blob, + maskMode: TLandSeaMaskMode +): Promise { + const tiff = await fromBlob(blob); + const image = (await tiff.getImage()) as TGeoTiffImage; + const bounds = getGeoTiffBounds(image); + const raster = await image.readRGB({ interleave: true, enableAlpha: true }); + const { canvas, ctx, width, height } = createLayerCanvas( + image.getWidth(), + image.getHeight() + ); + putRgbRasterOnCanvas(image, ctx, raster); + await applyLandSeaCutout(ctx, width, height, maskMode, bounds); + return { + texture: configureEquirectangularTexture( + new THREE.CanvasTexture(canvas), + bounds, + TextureLayerSampling.PIXELATED + ), + bounds, + }; +} + /** - * Build an equirectangular THREE texture from an image blob (JPG/PNG), - * optionally cut out to land or sea. PNG alpha is preserved. + * Build a layer texture from an image blob, optionally cut out to land or sea. + * PNG alpha is preserved. GeoTIFFs keep their native raster size and bounds. */ export async function createImageLayerTexture( blob: Blob, - maskMode: TLandSeaMaskMode -): Promise { + maskMode: TLandSeaMaskMode, + name?: string +): Promise { + if (isGeoTiffLayerSource(blob, name)) { + return createGeoTiffLayerTexture(blob, maskMode); + } const image = await createImageBitmap(blob); const { canvas, ctx, width, height } = createLayerCanvas( image.width, @@ -120,7 +392,10 @@ export async function createImageLayerTexture( ctx.drawImage(image, 0, 0, width, height); image.close(); await applyLandSeaCutout(ctx, width, height, maskMode); - return configureEquirectangularTexture(new THREE.CanvasTexture(canvas)); + return { + texture: configureEquirectangularTexture(new THREE.CanvasTexture(canvas)), + bounds: GLOBAL_TEXTURE_BOUNDS, + }; } // ============================================================================= @@ -195,6 +470,15 @@ function createGlobeGeometry(): THREE.BufferGeometry { return geometry; } +function createTextureBoundsVector(bounds: TGeoBounds) { + return new THREE.Vector4( + bounds.west, + bounds.south, + bounds.east, + bounds.north + ); +} + /** * Create a mesh rendering an equirectangular texture under the current * projection. Globe: GPU-projected sphere. Flat: inverse-projection quad. @@ -204,16 +488,19 @@ function createGlobeGeometry(): THREE.BufferGeometry { export function createEquirectLayerMesh( texture: THREE.Texture, projectionHelper: ProjectionHelper, - name: string + name: string, + bounds: TGeoBounds = GLOBAL_TEXTURE_BOUNDS ): THREE.Mesh { let geometry: THREE.BufferGeometry; let material: THREE.ShaderMaterial; + const textureBounds = createTextureBoundsVector(bounds); if (projectionHelper.isFlat) { geometry = createFlatQuadGeometry(); material = new THREE.ShaderMaterial({ uniforms: { maskTexture: { value: texture }, + textureBounds: { value: textureBounds }, opacity: { value: 1.0 }, projectionType: { value: getProjectionTypeFromMode(projectionHelper.type), @@ -232,6 +519,7 @@ export function createEquirectLayerMesh( material = new THREE.ShaderMaterial({ uniforms: { maskTexture: { value: texture }, + textureBounds: { value: textureBounds }, projectionRadius: { value: GLOBE_LAYER_RADIUS }, opacity: { value: 1.0 }, }, diff --git a/src/lib/layers/glsl/flatInverse.frag.glsl b/src/lib/layers/glsl/flatInverse.frag.glsl index 89b42627..bec68885 100644 --- a/src/lib/layers/glsl/flatInverse.frag.glsl +++ b/src/lib/layers/glsl/flatInverse.frag.glsl @@ -148,6 +148,7 @@ vec3 inverseProjectLatLon(float x, float y, int projType) { } uniform sampler2D maskTexture; +uniform vec4 textureBounds; uniform float opacity; uniform int projectionType; uniform float centerLon; @@ -155,6 +156,21 @@ uniform float centerLat; varying vec2 vProjectedCoord; +float longitudeToTextureU(float lon) { + float west = textureBounds.x; + float east = textureBounds.z; + float span = east - west; + float sampleLon = lon; + if (span <= 0.0) { + span += 360.0; + if (sampleLon < west) sampleLon += 360.0; + } else { + if (sampleLon < west) sampleLon += 360.0; + if (sampleLon > east) sampleLon -= 360.0; + } + return (sampleLon - west) / span; +} + void main() { vec3 result = inverseProjectLatLon(vProjectedCoord.x, vProjectedCoord.y, projectionType); if (result.z < 0.0) discard; @@ -164,11 +180,12 @@ void main() { // Normalize longitude to [-180, 180] float lon = mod(geo.y + 180.0, 360.0) - 180.0; - // Convert to equirectangular texture UV - float u = (lon + 180.0) / 360.0; - float v = (geo.x + 90.0) / 180.0; + float u = longitudeToTextureU(lon); + float v = (geo.x - textureBounds.y) / (textureBounds.w - textureBounds.y); + if (u < 0.0 || u > 1.0 || v < 0.0 || v > 1.0) discard; vec4 texColor = texture2D(maskTexture, vec2(u, v)); if (texColor.a < 0.01) discard; gl_FragColor = vec4(texColor.rgb, texColor.a * opacity); + #include } diff --git a/src/lib/layers/glsl/gpuProjectedMask.frag.glsl b/src/lib/layers/glsl/gpuProjectedMask.frag.glsl index fb78e3ac..8a88aa2b 100644 --- a/src/lib/layers/glsl/gpuProjectedMask.frag.glsl +++ b/src/lib/layers/glsl/gpuProjectedMask.frag.glsl @@ -1,19 +1,39 @@ #include "../../projection/glsl/projectionConstants.glsl" uniform sampler2D maskTexture; +uniform vec4 textureBounds; uniform float opacity; varying vec3 vSpherePosition; +float longitudeToTextureU(float lon) { + float west = textureBounds.x; + float east = textureBounds.z; + float span = east - west; + float sampleLon = lon; + if (span <= 0.0) { + span += 360.0; + if (sampleLon < west) sampleLon += 360.0; + } else { + if (sampleLon < west) sampleLon += 360.0; + if (sampleLon > east) sampleLon -= 360.0; + } + return (sampleLon - west) / span; +} + void main() { vec3 spherePosition = normalize(vSpherePosition); - float lon = atan(spherePosition.y, spherePosition.x); - float lat = asin(clamp(spherePosition.z, -1.0, 1.0)); - float u = (lon + PI) / (2.0 * PI); - float v = (lat + PI * 0.5) / PI; + float lon = degrees(atan(spherePosition.y, spherePosition.x)); + float lat = degrees(asin(clamp(spherePosition.z, -1.0, 1.0))); + float u = longitudeToTextureU(lon); + float v = (lat - textureBounds.y) / (textureBounds.w - textureBounds.y); + if (u < 0.0 || u > 1.0 || v < 0.0 || v > 1.0) { + discard; + } vec4 texColor = texture2D(maskTexture, vec2(u, v)); if (texColor.a < 0.01) { discard; } gl_FragColor = vec4(texColor.rgb, texColor.a * opacity); + #include } diff --git a/src/lib/layers/gridExport.ts b/src/lib/layers/gridExport.ts index 2d701fe9..b07bd7d9 100644 --- a/src/lib/layers/gridExport.ts +++ b/src/lib/layers/gridExport.ts @@ -1,17 +1,51 @@ -// Export the currently rendered data grid as an equirectangular PNG texture. +// Export the currently rendered data grid as an equirectangular GeoTIFF layer. // The grid is rendered offscreen with projection forced to equirectangular at // center (0, 0), independent of the projection active in the viewer. +import { zlib } from "fflate"; +import { writeArrayBuffer, type GeotiffWriterMetadata } from "geotiff"; import * as THREE from "three"; +import { + GLOBAL_TEXTURE_BOUNDS, + getLongitudeSpan, + type TGeoBounds, +} from "@/lib/layers/equirectLayer.ts"; +import { + GridTextureExportMode, + GridTextureExportUserDataKey, + TextureExportVCoordinate, + type TGridTextureExportMetadata, + type TRegularLatLonTextureExportMetadata, +} from "@/lib/layers/gridExportMetadata.ts"; +import { + createTriangleWrapProjectionGeometry, + setupProjectionGeometryWrap, +} from "@/lib/projection/projectionEdgeQuality.ts"; import { getProjectionTypeFromMode } from "@/lib/projection/projectionShaders.ts"; import { PROJECTION_TYPES } from "@/lib/projection/projectionUtils.ts"; +import textureColormapFragmentShader from "@/lib/shaders/glsl/textureColormap.frag.glsl"; -const EXPORT_WIDTH = 8192; -const EXPORT_HEIGHT = 4096; +const FALLBACK_EXPORT_PIXELS_PER_DEGREE = 8192 / 360; +const MIN_EXPORT_SIZE = 1; +const GEOMETRY_EXPORT_SIZE = { width: 8192, height: 4096 } as const; +const MIN_GLOBAL_LONGITUDE_COVERAGE_DEGREES = 359; type TUniformValue = { value: unknown }; type TGridObject = THREE.Mesh | THREE.Points; +type TExportSize = { width: number; height: number }; +type TAlphaCrop = { x: number; y: number; width: number; height: number }; +type TRegularTextureExport = { + material: THREE.ShaderMaterial; + texture: THREE.Texture; + size: TExportSize; + metadata: TRegularLatLonTextureExportMetadata; +}; +type TTextureFilterState = { + minFilter: THREE.TextureFilter; + magFilter: THREE.MagnificationTextureFilter; + generateMipmaps: boolean; +}; type TRendererState = { clearColor: THREE.Color; clearAlpha: number; @@ -22,6 +56,47 @@ type TRendererState = { }; const EXPORT_WRAP_INSTANCE_COUNT = 3; +const GeoTiffPhotometricInterpretation = { + RGB: 2, +} as const; +type TGeoTiffPhotometricInterpretation = + (typeof GeoTiffPhotometricInterpretation)[keyof typeof GeoTiffPhotometricInterpretation]; +const GeoTiffExtraSample = { + UNASSOCIATED_ALPHA: 2, +} as const; +type TGeoTiffExtraSample = + (typeof GeoTiffExtraSample)[keyof typeof GeoTiffExtraSample]; +const GeoTiffRasterType = { + AREA: 1, +} as const; +type TGeoTiffRasterType = + (typeof GeoTiffRasterType)[keyof typeof GeoTiffRasterType]; +const GeoTiffModelType = { + GEOGRAPHIC: 2, +} as const; +type TGeoTiffModelType = + (typeof GeoTiffModelType)[keyof typeof GeoTiffModelType]; +const GeoTiffCompression = { + DEFLATE: 8, +} as const; +type TGeoTiffCompression = + (typeof GeoTiffCompression)[keyof typeof GeoTiffCompression]; +const TIFF_DEFLATE_LEVEL = 6; +const TRIANGLE_WRAP_ATTRIBUTE_NAMES = [ + "triangleLatLon0", + "triangleLatLon1", + "triangleLatLon2", +] as const; +const DIRECT_TEXTURE_EXPORT_VERTEX_SHADER = ` +varying vec2 vUv; +varying vec2 vProjectedXY; + +void main() { + vUv = uv; + vProjectedXY = vec2(0.0); + gl_Position = vec4(position.xy, 0.0, 1.0); +} +`; function isGridDataMaterial( material: THREE.Material | THREE.Material[] @@ -54,6 +129,281 @@ function collectGridObjects(scene: THREE.Scene) { return gridObjects; } +function normalizeLongitude360(lon: number) { + return ((lon % 360) + 360) % 360; +} + +function normalizeLongitude180(lon: number) { + const normalized = ((lon + 180) % 360) + (lon + 180 < 0 ? 360 : 0); + return normalized - 180; +} + +function getLongitudeBounds( + longitudes: number[] +): Pick { + if (longitudes.length === 0) { + throw new Error("No longitudes available for GeoTIFF export."); + } + const normalizedLongitudes = [ + ...new Set(longitudes.map(normalizeLongitude360)), + ].sort((a, b) => a - b); + + if (normalizedLongitudes.length === 1) { + const lon = normalizeLongitude180(normalizedLongitudes[0]); + return { west: lon - 0.5, east: lon + 0.5 }; + } + + let largestGap = Number.NEGATIVE_INFINITY; + let largestGapIndex = 0; + for (let index = 0; index < normalizedLongitudes.length; index++) { + const lon = normalizedLongitudes[index]; + const next = + index === normalizedLongitudes.length - 1 + ? normalizedLongitudes[0] + 360 + : normalizedLongitudes[index + 1]; + const gap = next - lon; + if (gap > largestGap) { + largestGap = gap; + largestGapIndex = index; + } + } + const coveredSpan = 360 - largestGap; + if (coveredSpan >= MIN_GLOBAL_LONGITUDE_COVERAGE_DEGREES) { + return { + west: GLOBAL_TEXTURE_BOUNDS.west, + east: GLOBAL_TEXTURE_BOUNDS.east, + }; + } + + const west = + normalizedLongitudes[(largestGapIndex + 1) % normalizedLongitudes.length]; + const east = normalizedLongitudes[largestGapIndex]; + return { + west: normalizeLongitude180(west), + east: normalizeLongitude180(east), + }; +} + +export function getGeoBoundsFromLatLonValues( + values: readonly number[] +): TGeoBounds { + const longitudes: number[] = []; + let south = Number.POSITIVE_INFINITY; + let north = Number.NEGATIVE_INFINITY; + for (let index = 0; index < values.length; index += 2) { + const lat = values[index]; + const lon = values[index + 1]; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + continue; + } + south = Math.min(south, lat); + north = Math.max(north, lat); + longitudes.push(lon); + } + if (!Number.isFinite(south) || !Number.isFinite(north)) { + throw new Error( + "No latitude/longitude geometry available for GeoTIFF export." + ); + } + if (south === north) { + south -= 0.5; + north += 0.5; + } + const { west, east } = getLongitudeBounds(longitudes); + return { west, south, east, north }; +} + +function getGeoBounds(objects: TGridObject[]): TGeoBounds { + const values: number[] = []; + for (const object of objects) { + const latLon = object.geometry.getAttribute("latLon"); + if (!latLon) { + continue; + } + for (let index = 0; index < latLon.count; index++) { + values.push(latLon.getX(index), latLon.getY(index)); + } + } + return getGeoBoundsFromLatLonValues(values); +} + +function getTextureImageSize(texture: THREE.Texture): TExportSize | undefined { + const image = texture.image as + | { width?: number; height?: number } + | undefined; + const width = image?.width; + const height = image?.height; + if ( + typeof width === "number" && + typeof height === "number" && + width > 0 && + height > 0 + ) { + return { width, height }; + } + return undefined; +} + +function getUniqueDataTextureSizes(objects: TGridObject[]) { + const sizes = new Map(); + for (const object of objects) { + const material = object.material as THREE.ShaderMaterial; + const texture = material.uniforms.data?.value; + if (!(texture instanceof THREE.Texture)) { + continue; + } + const size = getTextureImageSize(texture); + if (size) { + sizes.set(texture.uuid, size); + } + } + return [...sizes.values()]; +} + +function isRegularLatLonTextureExportMetadata( + metadata: unknown +): metadata is TRegularLatLonTextureExportMetadata { + return ( + typeof metadata === "object" && + metadata !== null && + (metadata as TGridTextureExportMetadata).mode === + GridTextureExportMode.REGULAR_LAT_LON + ); +} + +function getTextureExportMetadata( + object: TGridObject +): TRegularLatLonTextureExportMetadata | undefined { + const metadata = + object.userData[GridTextureExportUserDataKey.METADATA] ?? + object.geometry.userData[GridTextureExportUserDataKey.METADATA]; + return isRegularLatLonTextureExportMetadata(metadata) ? metadata : undefined; +} + +function sameGeoBounds(a: TGeoBounds, b: TGeoBounds) { + return ( + a.west === b.west && + a.south === b.south && + a.east === b.east && + a.north === b.north + ); +} + +function getRegularTextureExport( + objects: TGridObject[] +): TRegularTextureExport | undefined { + let result: TRegularTextureExport | undefined; + for (const object of objects) { + if (!(object instanceof THREE.Mesh)) { + return undefined; + } + const metadata = getTextureExportMetadata(object); + const material = object.material as THREE.ShaderMaterial; + const texture = material.uniforms.data?.value; + if (!metadata || !(texture instanceof THREE.Texture)) { + return undefined; + } + const size = getTextureImageSize(texture); + if (!size) { + return undefined; + } + if (!result) { + result = { material, texture, size, metadata }; + continue; + } + if ( + result.texture !== texture || + result.size.width !== size.width || + result.size.height !== size.height || + result.metadata.topV !== metadata.topV || + !sameGeoBounds(result.metadata.bounds, metadata.bounds) + ) { + return undefined; + } + } + return result; +} + +function getAttributeSampleCount(objects: TGridObject[]) { + return objects.reduce((count, object) => { + const dataValues = object.geometry.getAttribute("data_value"); + const latLon = object.geometry.getAttribute("latLon"); + return count + (dataValues?.count ?? latLon?.count ?? 0); + }, 0); +} + +function getSizeFromPixelCount( + pixelCount: number, + bounds: TGeoBounds +): TExportSize { + const lonSpan = Math.max(getLongitudeSpan(bounds), 1); + const latSpan = Math.max(bounds.north - bounds.south, 1); + const aspect = lonSpan / latSpan; + const width = Math.max( + MIN_EXPORT_SIZE, + Math.round(Math.sqrt(pixelCount * aspect)) + ); + const height = Math.max(MIN_EXPORT_SIZE, Math.round(width / aspect)); + return { width, height }; +} + +function getFallbackExportSize(bounds: TGeoBounds): TExportSize { + return { + width: Math.max( + MIN_EXPORT_SIZE, + Math.round(getLongitudeSpan(bounds) * FALLBACK_EXPORT_PIXELS_PER_DEGREE) + ), + height: Math.max( + MIN_EXPORT_SIZE, + Math.round( + (bounds.north - bounds.south) * FALLBACK_EXPORT_PIXELS_PER_DEGREE + ) + ), + }; +} + +export function getGeoTiffExportSize( + bounds: TGeoBounds, + textureSizes: TExportSize[], + sampleCount: number +): TExportSize { + if (textureSizes.length === 1) { + return textureSizes[0]; + } + if (sampleCount > 0) { + return GEOMETRY_EXPORT_SIZE; + } + const texturePixelCount = textureSizes.reduce( + (total, size) => total + size.width * size.height, + 0 + ); + if (texturePixelCount > 0) { + return getSizeFromPixelCount(texturePixelCount, bounds); + } + return getFallbackExportSize(bounds); +} + +function clampExportSize(size: TExportSize, maxSize: number): TExportSize { + const scale = Math.min(1, maxSize / size.width, maxSize / size.height); + return { + width: Math.max(MIN_EXPORT_SIZE, Math.floor(size.width * scale)), + height: Math.max(MIN_EXPORT_SIZE, Math.floor(size.height * scale)), + }; +} + +function getExportSize( + objects: TGridObject[], + bounds: TGeoBounds, + renderer: THREE.WebGLRenderer +) { + const size = getGeoTiffExportSize( + bounds, + getUniqueDataTextureSizes(objects), + getAttributeSampleCount(objects) + ); + return clampExportSize(size, renderer.capabilities.maxTextureSize); +} + function configureProjectionUniforms(material: THREE.ShaderMaterial) { const uniforms = material.uniforms as Record; if (uniforms.projectionType) { @@ -70,6 +420,9 @@ function configureProjectionUniforms(material: THREE.ShaderMaterial) { if (uniforms.edgeQuality) { uniforms.edgeQuality.value = 1; } + if (uniforms.useTriangleWrapCull) { + uniforms.useTriangleWrapCull.value = 1; + } if (uniforms.projectionRadius) { uniforms.projectionRadius.value = 1; } @@ -77,14 +430,41 @@ function configureProjectionUniforms(material: THREE.ShaderMaterial) { material.depthWrite = false; } -function configureExportGeometry(geometry: THREE.BufferGeometry) { - const instancedGeometry = geometry as THREE.InstancedBufferGeometry; +function hasTriangleWrapAttributes(geometry: THREE.BufferGeometry) { + return TRIANGLE_WRAP_ATTRIBUTE_NAMES.every((name) => + Boolean(geometry.getAttribute(name)) + ); +} + +export function createGeoTiffExportGeometry( + source: THREE.BufferGeometry, + useTriangleWrapCull: boolean +) { + const geometry = source.clone(); if ( - instancedGeometry.isInstancedBufferGeometry && - geometry.getAttribute("wrapDirection") + useTriangleWrapCull && + geometry.getAttribute("latLon") && + !hasTriangleWrapAttributes(geometry) ) { - instancedGeometry.instanceCount = EXPORT_WRAP_INSTANCE_COUNT; + geometry.deleteAttribute("wrapDirection"); + const wrappedGeometry = createTriangleWrapProjectionGeometry( + geometry as THREE.InstancedBufferGeometry + ); + geometry.dispose(); + return wrappedGeometry; + } + return geometry; +} + +function configureExportGeometry(geometry: THREE.BufferGeometry) { + const instancedGeometry = geometry as THREE.InstancedBufferGeometry; + if (!instancedGeometry.isInstancedBufferGeometry) { + return; } + if (!geometry.getAttribute("wrapDirection")) { + setupProjectionGeometryWrap(instancedGeometry); + } + instancedGeometry.instanceCount = EXPORT_WRAP_INSTANCE_COUNT; } function cloneGridMaterial(material: THREE.ShaderMaterial) { @@ -105,7 +485,10 @@ function cloneGridMaterial(material: THREE.ShaderMaterial) { } function cloneGridObject(object: TGridObject): TGridObject { - const geometry = object.geometry.clone(); + const geometry = createGeoTiffExportGeometry( + object.geometry, + object instanceof THREE.Mesh + ); const material = cloneGridMaterial(object.material as THREE.ShaderMaterial); configureExportGeometry(geometry); configureProjectionUniforms(material); @@ -178,79 +561,318 @@ function restoreRendererState( function readTargetPixels( renderer: THREE.WebGLRenderer, - target: THREE.WebGLRenderTarget + target: THREE.WebGLRenderTarget, + size: TExportSize ): Uint8Array { - const pixels = new Uint8Array(EXPORT_WIDTH * EXPORT_HEIGHT * 4); + const pixels = new Uint8Array(size.width * size.height * 4); renderer.readRenderTargetPixels( target, 0, 0, - EXPORT_WIDTH, - EXPORT_HEIGHT, + size.width, + size.height, pixels ); return pixels; } -function encodePixelsToBlob(pixels: Uint8Array): Promise { - // Flip vertically (GL reads bottom-up) into a canvas. - const canvas = document.createElement("canvas"); - canvas.width = EXPORT_WIDTH; - canvas.height = EXPORT_HEIGHT; - const ctx = canvas.getContext("2d")!; - const imageData = ctx.createImageData(EXPORT_WIDTH, EXPORT_HEIGHT); - const rowBytes = EXPORT_WIDTH * 4; - for (let y = 0; y < EXPORT_HEIGHT; y++) { - const src = (EXPORT_HEIGHT - 1 - y) * rowBytes; - imageData.data.set(pixels.subarray(src, src + rowBytes), y * rowBytes); +function flipPixelsTopDown(pixels: Uint8Array, size: TExportSize) { + const flipped = new Uint8Array(pixels.length); + const rowBytes = size.width * 4; + for (let y = 0; y < size.height; y++) { + const src = (size.height - 1 - y) * rowBytes; + flipped.set(pixels.subarray(src, src + rowBytes), y * rowBytes); } - ctx.putImageData(imageData, 0, 0); + return flipped; +} - return new Promise((resolve, reject) => { - canvas.toBlob( - (blob) => - blob ? resolve(blob) : reject(new Error("PNG encoding failed")), - "image/png" +export function getAlphaCrop( + pixels: Uint8Array, + size: TExportSize +): TAlphaCrop | undefined { + let minX = size.width; + let minY = size.height; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < size.height; y++) { + for (let x = 0; x < size.width; x++) { + const alpha = pixels[(y * size.width + x) * 4 + 3]; + if (alpha === 0) { + continue; + } + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + if (maxX < minX || maxY < minY) { + return undefined; + } + return { + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1, + }; +} + +function cropPixels( + pixels: Uint8Array, + size: TExportSize, + crop: TAlphaCrop +): Uint8Array { + const cropped = new Uint8Array(crop.width * crop.height * 4); + const targetRowBytes = crop.width * 4; + for (let y = 0; y < crop.height; y++) { + const sourceStart = ((crop.y + y) * size.width + crop.x) * 4; + cropped.set( + pixels.subarray(sourceStart, sourceStart + targetRowBytes), + y * targetRowBytes ); + } + return cropped; +} + +export function getCroppedGeoBounds( + bounds: TGeoBounds, + size: TExportSize, + crop: TAlphaCrop +): TGeoBounds { + const lonSpan = getLongitudeSpan(bounds); + const latSpan = bounds.north - bounds.south; + return { + west: bounds.west + (crop.x / size.width) * lonSpan, + east: bounds.west + ((crop.x + crop.width) / size.width) * lonSpan, + north: bounds.north - (crop.y / size.height) * latSpan, + south: bounds.north - ((crop.y + crop.height) / size.height) * latSpan, + }; +} + +function makeGeoTiffMetadata( + size: TExportSize, + bounds: TGeoBounds, + byteCount: number +): GeotiffWriterMetadata { + return { + width: size.width, + height: size.height, + BitsPerSample: [8, 8, 8, 8], + SampleFormat: [1, 1, 1, 1], + SamplesPerPixel: 4, + ExtraSamples: + GeoTiffExtraSample.UNASSOCIATED_ALPHA satisfies TGeoTiffExtraSample, + PhotometricInterpretation: + GeoTiffPhotometricInterpretation.RGB satisfies TGeoTiffPhotometricInterpretation, + Compression: GeoTiffCompression.DEFLATE satisfies TGeoTiffCompression, + RowsPerStrip: size.height, + StripByteCounts: [byteCount], + ModelPixelScale: [ + getLongitudeSpan(bounds) / size.width, + (bounds.north - bounds.south) / size.height, + 0, + ], + ModelTiepoint: [0, 0, 0, bounds.west, bounds.north, 0], + GeographicTypeGeoKey: 4326, + GeogCitationGeoKey: "WGS 84", + GTModelTypeGeoKey: GeoTiffModelType.GEOGRAPHIC satisfies TGeoTiffModelType, + GTRasterTypeGeoKey: GeoTiffRasterType.AREA satisfies TGeoTiffRasterType, + }; +} + +function compressTiffStrip(pixels: Uint8Array): Promise { + return new Promise((resolve, reject) => { + zlib(pixels, { level: TIFF_DEFLATE_LEVEL }, (error, data) => { + if (error) { + reject(error); + return; + } + resolve(data); + }); }); } +export async function encodePixelsToGeoTiffBlob( + pixels: Uint8Array, + size: TExportSize, + bounds: TGeoBounds, + cropAlpha = true +): Promise { + let tiffPixels = pixels; + let tiffSize = size; + let tiffBounds = bounds; + if (cropAlpha) { + const crop = getAlphaCrop(pixels, size); + if (!crop) { + throw new Error("Grid export produced no visible pixels."); + } + tiffPixels = cropPixels(pixels, size, crop); + tiffSize = { width: crop.width, height: crop.height }; + tiffBounds = getCroppedGeoBounds(bounds, size, crop); + } + const compressedPixels = await compressTiffStrip(tiffPixels); + const arrayBuffer = writeArrayBuffer( + compressedPixels, + makeGeoTiffMetadata(tiffSize, tiffBounds, compressedPixels.byteLength) + ); + return new Blob([arrayBuffer], { type: "image/tiff" }); +} + +export function createExportRenderTarget(size: TExportSize) { + return new THREE.WebGLRenderTarget(size.width, size.height); +} + +function createDirectTextureExportGeometry( + topV: TRegularLatLonTextureExportMetadata["topV"] +) { + const bottomV = + topV === TextureExportVCoordinate.TOP + ? TextureExportVCoordinate.BOTTOM + : TextureExportVCoordinate.TOP; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.Float32BufferAttribute( + [-1, -1, 0, 1, -1, 0, -1, 1, 0, 1, 1, 0], + 3 + ) + ); + geometry.setAttribute( + "uv", + new THREE.Float32BufferAttribute( + [0, bottomV, 1, bottomV, 0, topV, 1, topV], + 2 + ) + ); + geometry.setIndex([0, 1, 2, 1, 3, 2]); + return geometry; +} + +function createDirectTextureExportMaterial(source: THREE.ShaderMaterial) { + const material = cloneGridMaterial(source); + material.vertexShader = DIRECT_TEXTURE_EXPORT_VERTEX_SHADER; + material.fragmentShader = textureColormapFragmentShader; + material.depthTest = false; + material.depthWrite = false; + if (material.uniforms.edgeQuality) { + material.uniforms.edgeQuality.value = 0; + } + return material; +} + +function setTextureNearestSampling( + texture: THREE.Texture +): TTextureFilterState { + const state = { + minFilter: texture.minFilter, + magFilter: texture.magFilter, + generateMipmaps: texture.generateMipmaps, + }; + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + texture.generateMipmaps = false; + texture.needsUpdate = true; + return state; +} + +function restoreTextureSampling( + texture: THREE.Texture, + state: TTextureFilterState +) { + texture.minFilter = state.minFilter; + texture.magFilter = state.magFilter; + texture.generateMipmaps = state.generateMipmaps; + texture.needsUpdate = true; +} + +async function exportRegularTextureAsGeoTiff( + renderer: THREE.WebGLRenderer, + config: TRegularTextureExport +) { + const target = createExportRenderTarget(config.size); + const scene = new THREE.Scene(); + const camera = new THREE.Camera(); + const geometry = createDirectTextureExportGeometry(config.metadata.topV); + const material = createDirectTextureExportMaterial(config.material); + const mesh = new THREE.Mesh(geometry, material); + scene.add(mesh); + const savedRendererState = saveRendererState(renderer); + const savedTextureState = setTextureNearestSampling(config.texture); + + let pixels: Uint8Array; + try { + renderer.setRenderTarget(target); + renderer.setViewport(0, 0, config.size.width, config.size.height); + renderer.setScissorTest(false); + renderer.setClearColor(0x000000, 0); + renderer.clear(); + renderer.render(scene, camera); + pixels = flipPixelsTopDown( + readTargetPixels(renderer, target, config.size), + config.size + ); + } finally { + restoreTextureSampling(config.texture, savedTextureState); + restoreRendererState(renderer, savedRendererState); + geometry.dispose(); + material.dispose(); + target.dispose(); + } + + return encodePixelsToGeoTiffBlob( + pixels, + config.size, + config.metadata.bounds, + false + ); +} + /** - * Render the visible data-grid objects as an equirectangular PNG (transparent - * background) at center (0, 0) and return the image blob. + * Render the visible data-grid objects as an equirectangular GeoTIFF + * (transparent background) at center (0, 0) and return the image blob. */ -export async function exportGridAsEquirectTexture( +export async function exportGridAsGeoTiffTexture( renderer: THREE.WebGLRenderer, scene: THREE.Scene ): Promise { + const gridObjects = collectGridObjects(scene); + if (gridObjects.length === 0) { + throw new Error("No grid layer is visible for GeoTIFF export."); + } + const regularTextureExport = getRegularTextureExport(gridObjects); + if (regularTextureExport) { + return exportRegularTextureAsGeoTiff(renderer, regularTextureExport); + } + const bounds = getGeoBounds(gridObjects); + const size = getExportSize(gridObjects, bounds, renderer); const camera = new THREE.OrthographicCamera( - -Math.PI, - Math.PI, - Math.PI / 2, - -Math.PI / 2, + THREE.MathUtils.degToRad(bounds.west), + THREE.MathUtils.degToRad(bounds.west + getLongitudeSpan(bounds)), + THREE.MathUtils.degToRad(bounds.north), + THREE.MathUtils.degToRad(bounds.south), 0.1, 10 ); camera.position.set(0, 0, 5); - const target = new THREE.WebGLRenderTarget(EXPORT_WIDTH, EXPORT_HEIGHT); + const target = createExportRenderTarget(size); const { exportScene, clones } = createExportScene(scene); const savedRendererState = saveRendererState(renderer); let pixels: Uint8Array; try { renderer.setRenderTarget(target); - renderer.setViewport(0, 0, EXPORT_WIDTH, EXPORT_HEIGHT); + renderer.setViewport(0, 0, size.width, size.height); renderer.setScissorTest(false); renderer.setClearColor(0x000000, 0); renderer.clear(); renderer.render(exportScene, camera); - pixels = readTargetPixels(renderer, target); + pixels = flipPixelsTopDown(readTargetPixels(renderer, target, size), size); } finally { restoreRendererState(renderer, savedRendererState); disposeExportObjects(clones); target.dispose(); } - return encodePixelsToBlob(pixels); + return encodePixelsToGeoTiffBlob(pixels, size, bounds); } diff --git a/src/lib/layers/gridExportMetadata.ts b/src/lib/layers/gridExportMetadata.ts new file mode 100644 index 00000000..bd3097f5 --- /dev/null +++ b/src/lib/layers/gridExportMetadata.ts @@ -0,0 +1,116 @@ +import { type TGeoBounds } from "./equirectLayer.ts"; + +export const GridTextureExportMode = { + REGULAR_LAT_LON: "regular-lat-lon", +} as const; + +export type TGridTextureExportMode = + (typeof GridTextureExportMode)[keyof typeof GridTextureExportMode]; + +export const GridTextureExportUserDataKey = { + METADATA: "gridTextureExport", +} as const; + +export type TGridTextureExportUserDataKey = + (typeof GridTextureExportUserDataKey)[keyof typeof GridTextureExportUserDataKey]; + +export const TextureExportVCoordinate = { + BOTTOM: 0, + TOP: 1, +} as const; + +export type TTextureExportVCoordinate = + (typeof TextureExportVCoordinate)[keyof typeof TextureExportVCoordinate]; + +export type TRegularLatLonTextureExportMetadata = { + mode: typeof GridTextureExportMode.REGULAR_LAT_LON; + bounds: TGeoBounds; + topV: TTextureExportVCoordinate; +}; + +export type TGridTextureExportMetadata = TRegularLatLonTextureExportMetadata; + +const DEFAULT_SINGLE_COORD_SPAN = 1; + +function getSingleCoordinateBounds(value: number) { + const halfSpan = DEFAULT_SINGLE_COORD_SPAN / 2; + return { min: value - halfSpan, max: value + halfSpan }; +} + +function getOrderedLongitudeBounds( + longitudes: Float32Array +): Pick | undefined { + if (longitudes.length === 0) { + return undefined; + } + if (longitudes.length === 1) { + const bounds = getSingleCoordinateBounds(longitudes[0]); + return { west: bounds.min, east: bounds.max }; + } + + const first = longitudes[0]; + const last = longitudes[longitudes.length - 1]; + const firstStep = longitudes[1] - first; + const lastStep = last - longitudes[longitudes.length - 2]; + if (firstStep <= 0 || lastStep <= 0 || last <= first) { + return undefined; + } + + return { + west: first - firstStep / 2, + east: last + lastStep / 2, + }; +} + +function getLatitudeBounds( + latitudes: Float32Array +): Pick | undefined { + if (latitudes.length === 0) { + return undefined; + } + if (latitudes.length === 1) { + const bounds = getSingleCoordinateBounds(latitudes[0]); + return { + south: Math.max(-90, bounds.min), + north: Math.min(90, bounds.max), + }; + } + + const first = latitudes[0]; + const last = latitudes[latitudes.length - 1]; + const firstStep = latitudes[1] - first; + const lastStep = last - latitudes[latitudes.length - 2]; + if (firstStep === 0 || lastStep === 0 || first === last) { + return undefined; + } + + if (first < last) { + return { + south: Math.max(-90, first - Math.abs(firstStep) / 2), + north: Math.min(90, last + Math.abs(lastStep) / 2), + }; + } + + return { + south: Math.max(-90, last - Math.abs(lastStep) / 2), + north: Math.min(90, first + Math.abs(firstStep) / 2), + }; +} + +export function getRegularLatLonGridBounds( + latitudes: Float32Array, + longitudes: Float32Array +): TGeoBounds | undefined { + const longitudeBounds = getOrderedLongitudeBounds(longitudes); + const latitudeBounds = getLatitudeBounds(latitudes); + if (!longitudeBounds || !latitudeBounds) { + return undefined; + } + if (latitudeBounds.north <= latitudeBounds.south) { + return undefined; + } + return { + ...longitudeBounds, + ...latitudeBounds, + }; +} diff --git a/src/lib/layers/textureLayerFormats.ts b/src/lib/layers/textureLayerFormats.ts new file mode 100644 index 00000000..0c49efe4 --- /dev/null +++ b/src/lib/layers/textureLayerFormats.ts @@ -0,0 +1,77 @@ +export const TEXTURE_LAYER_MIME_TYPES = { + JPEG: "image/jpeg", + PNG: "image/png", + TIFF_SHORT: "image/tif", + TIFF: "image/tiff", + TIFF_LEGACY: "image/x-tiff", + TIFF_APPLICATION: "application/tiff", + GEOTIFF: "image/geotiff", + GEOTIFF_APPLICATION: "application/geotiff", + GEOTIFF_APPLICATION_LEGACY: "application/x-geotiff", +} as const; + +export type TTextureLayerMimeType = + (typeof TEXTURE_LAYER_MIME_TYPES)[keyof typeof TEXTURE_LAYER_MIME_TYPES]; + +export const TEXTURE_LAYER_FILE_EXTENSIONS = { + GEOTIFF: ".geotiff", + TIF: ".tif", + TIFF: ".tiff", +} as const; + +export type TTextureLayerFileExtension = + (typeof TEXTURE_LAYER_FILE_EXTENSIONS)[keyof typeof TEXTURE_LAYER_FILE_EXTENSIONS]; + +const SUPPORTED_TEXTURE_LAYER_MIME_TYPES = Object.values( + TEXTURE_LAYER_MIME_TYPES +) as TTextureLayerMimeType[]; + +const GEOTIFF_TEXTURE_LAYER_MIME_TYPES: TTextureLayerMimeType[] = [ + TEXTURE_LAYER_MIME_TYPES.TIFF, + TEXTURE_LAYER_MIME_TYPES.TIFF_SHORT, + TEXTURE_LAYER_MIME_TYPES.TIFF_LEGACY, + TEXTURE_LAYER_MIME_TYPES.TIFF_APPLICATION, + TEXTURE_LAYER_MIME_TYPES.GEOTIFF, + TEXTURE_LAYER_MIME_TYPES.GEOTIFF_APPLICATION, + TEXTURE_LAYER_MIME_TYPES.GEOTIFF_APPLICATION_LEGACY, +]; + +const GEOTIFF_TEXTURE_LAYER_FILE_EXTENSIONS = Object.values( + TEXTURE_LAYER_FILE_EXTENSIONS +) as TTextureLayerFileExtension[]; + +export const TEXTURE_LAYER_UPLOAD_ACCEPT = [ + ...SUPPORTED_TEXTURE_LAYER_MIME_TYPES, + ...GEOTIFF_TEXTURE_LAYER_FILE_EXTENSIONS, +].join(","); + +function hasExtension( + name: string | undefined, + extensions: readonly TTextureLayerFileExtension[] +) { + const lowerName = name?.toLowerCase(); + return Boolean( + lowerName && extensions.some((ext) => lowerName.endsWith(ext)) + ); +} + +function hasMimeType( + type: string, + mimeTypes: readonly TTextureLayerMimeType[] +) { + return mimeTypes.includes(type as TTextureLayerMimeType); +} + +export function isGeoTiffLayerSource(blob: Blob, name?: string) { + return ( + hasMimeType(blob.type, GEOTIFF_TEXTURE_LAYER_MIME_TYPES) || + hasExtension(name, GEOTIFF_TEXTURE_LAYER_FILE_EXTENSIONS) + ); +} + +export function isSupportedTextureLayerFile(file: File) { + return ( + hasMimeType(file.type, SUPPORTED_TEXTURE_LAYER_MIME_TYPES) || + hasExtension(file.name, GEOTIFF_TEXTURE_LAYER_FILE_EXTENSIONS) + ); +} diff --git a/src/store/store.ts b/src/store/store.ts index 88c9ae5b..d399fbcc 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -147,7 +147,7 @@ export const useGlobeControlStore = defineStore("globeControl", { catalogData: undefined as TCatalog | undefined, // layer panel stack, ordered top → bottom; order determines render order layerStack: builtinLayerStack() as TLayerEntry[], - // incremented to request an equirectangular export of the current grid + // incremented to request a GeoTIFF image-layer export of the current grid gridExportRequest: 0 as number, // will get incremented each time a new dataset OR a new variable in the // same dataset is loaded; used to trigger reactivity in child components diff --git a/src/ui/grids/Regular.vue b/src/ui/grids/Regular.vue index a89a266a..0d4dc70c 100644 --- a/src/ui/grids/Regular.vue +++ b/src/ui/grids/Regular.vue @@ -27,6 +27,12 @@ import { decodeVariableDataAndGetBounds, } from "@/lib/data/variableDecoding.ts"; import { ZarrDataManager } from "@/lib/data/ZarrDataManager.ts"; +import { + GridTextureExportMode, + GridTextureExportUserDataKey, + getRegularLatLonGridBounds, + TextureExportVCoordinate, +} from "@/lib/layers/gridExportMetadata.ts"; import { createWrappedProjectionMesh, setupProjectionGeometryWrap, @@ -84,6 +90,7 @@ const { setHoverLookupFromIndex, clearHoverLookup } = const longitudes = ref(new Float32Array()); const latitudes = ref(new Float32Array()); +const isProjectedGrid = ref(false); const BATCH_SIZE = 60; const MAX_GEO_RESOLUTION = 512; @@ -154,6 +161,7 @@ async function getDims() { const isProjectedXY = isProjectedXName(lastDim) && isProjectedYName(secondLastDim); + isProjectedGrid.value = isProjectedXY; const latOnlyCheck = !isProjectedXY && @@ -352,6 +360,37 @@ function subsampleCoords( return { coords, origIndices }; } +function getRegularTextureExportMetadata(isRotated: boolean | undefined) { + if (isRotated || isProjectedGrid.value) { + return undefined; + } + + const bounds = getRegularLatLonGridBounds(latitudes.value, longitudes.value); + if (!bounds) { + return undefined; + } + + return { + mode: GridTextureExportMode.REGULAR_LAT_LON, + bounds, + topV: + latitudes.value[0] > latitudes.value[latitudes.value.length - 1] + ? TextureExportVCoordinate.BOTTOM + : TextureExportVCoordinate.TOP, + }; +} + +async function getRegularGridPole(isRotated: boolean | undefined) { + if (!isRotated) { + return {}; + } + const rotatedNorthPole = await getRotatedNorthPole(); + return { + poleLat: rotatedNorthPole.lat, + poleLon: rotatedNorthPole.lon, + }; +} + async function getRegularGridParameters() { const isRotated = props.isRotated; let longitudeValues = normalizeLongitudes(longitudes.value); @@ -367,6 +406,7 @@ async function getRegularGridParameters() { const isGlobal = isLongitudeGlobal(longitudes.value); const textureLonCount = longitudeValues.length; const originalLatCount = latitudeValues.length; + const textureExportMetadata = getRegularTextureExportMetadata(isRotated); const { coords: geoLatitudes, origIndices: latOrigIndices } = subsampleCoords( latitudeValues, @@ -387,12 +427,7 @@ async function getRegularGridParameters() { lonOrigIndices[lonOrigIndicesBase.length] = textureLonCount; } - let poleLat: number | undefined, poleLon: number | undefined; - if (isRotated) { - const rotatedNorthPole = await getRotatedNorthPole(); - poleLat = rotatedNorthPole.lat; - poleLon = rotatedNorthPole.lon; - } + const { poleLat, poleLon } = await getRegularGridPole(isRotated); return { geoLatitudes, @@ -407,6 +442,7 @@ async function getRegularGridParameters() { poleLon, geoLatCount: geoLatitudes.length, geoLonCount: geoLongitudes.length, + textureExportMetadata, }; } @@ -492,6 +528,10 @@ function createBatchGeometry( const indices = generateGridIndices(batchLatCount, gridParams.geoLonCount); geometry.setIndex(new THREE.BufferAttribute(indices, 1)); + if (gridParams.textureExportMetadata) { + geometry.userData[GridTextureExportUserDataKey.METADATA] = + gridParams.textureExportMetadata; + } return geometry; } diff --git a/src/ui/grids/composables/useGridOverlays.ts b/src/ui/grids/composables/useGridOverlays.ts index 9ec7965a..e893ebbd 100644 --- a/src/ui/grids/composables/useGridOverlays.ts +++ b/src/ui/grids/composables/useGridOverlays.ts @@ -8,6 +8,7 @@ import { createImageLayerTexture, disposeLayerMesh, updateEquirectLayerProjection, + type TImageLayerTexture, } from "@/lib/layers/equirectLayer.ts"; import { geojson2gpuLineSegmentsGeometry } from "@/lib/layers/geojson.ts"; import { @@ -96,7 +97,7 @@ export function useGridOverlays(options: UseGridOverlaysOptions) { const textureLayerMeshes = new Map(); const textureCache = new Map< string, - { maskMode: TLandSeaMaskMode; texture: THREE.Texture } + { maskMode: TLandSeaMaskMode; layerTexture: TImageLayerTexture } >(); let textureLayersUpdating = false; let textureLayersDirty = false; @@ -382,17 +383,21 @@ export function useGridOverlays(options: UseGridOverlaysOptions) { async function getLayerTexture(entry: TLayerEntry) { const cached = textureCache.get(entry.id); if (cached && cached.maskMode === entry.maskMode) { - return cached.texture; + return cached.layerTexture; } - cached?.texture.dispose(); + cached?.layerTexture.texture.dispose(); textureCache.delete(entry.id); const stored = await getTexture(entry.id); if (!stored) { return undefined; } - const texture = await createImageLayerTexture(stored.blob, entry.maskMode); - textureCache.set(entry.id, { maskMode: entry.maskMode, texture }); - return texture; + const layerTexture = await createImageLayerTexture( + stored.blob, + entry.maskMode, + stored.name + ); + textureCache.set(entry.id, { maskMode: entry.maskMode, layerTexture }); + return layerTexture; } // Removes the mesh but keeps the cached texture (disposed separately). @@ -445,7 +450,7 @@ export function useGridOverlays(options: UseGridOverlaysOptions) { for (const id of [...textureLayerMeshes.keys()]) { if (!wanted.has(id)) { removeTextureLayerMesh(id); - textureCache.get(id)?.texture.dispose(); + textureCache.get(id)?.layerTexture.texture.dispose(); textureCache.delete(id); } } @@ -459,14 +464,15 @@ export function useGridOverlays(options: UseGridOverlaysOptions) { } let mesh = textureLayerMeshes.get(entry.id); if (!mesh && entry.visible) { - const texture = await getLayerTexture(entry); - if (!texture) { + const layerTexture = await getLayerTexture(entry); + if (!layerTexture) { continue; } mesh = createEquirectLayerMesh( - texture, + layerTexture.texture, projectionHelper.value, - `textureLayer:${entry.id}` + `textureLayer:${entry.id}`, + layerTexture.bounds ); textureLayerMeshes.set(entry.id, mesh); scene.add(mesh); diff --git a/src/ui/grids/composables/useSharedGridLogic.ts b/src/ui/grids/composables/useSharedGridLogic.ts index 3e2d234b..e0f40017 100644 --- a/src/ui/grids/composables/useSharedGridLogic.ts +++ b/src/ui/grids/composables/useSharedGridLogic.ts @@ -12,7 +12,7 @@ import { fetchDataVariable, getVariableDatasource, } from "@/lib/data/variableData.ts"; -import { exportGridAsEquirectTexture } from "@/lib/layers/gridExport.ts"; +import { exportGridAsGeoTiffTexture } from "@/lib/layers/gridExport.ts"; import { saveTexture } from "@/lib/layers/textureStore.ts"; import { ProjectionHelper } from "@/lib/projection/projectionUtils.ts"; import { availableColormaps } from "@/lib/shaders/colormapShaders.ts"; @@ -158,9 +158,9 @@ export function useSharedGridLogic() { return; } try { - const blob = await exportGridAsEquirectTexture(renderer, scene); + const blob = await exportGridAsGeoTiffTexture(renderer, scene); const stored = await saveTexture( - `Image: ${store.varnameDisplay}`, + `Image: ${store.varnameDisplay}.tif`, blob ); store.addTextureLayer(stored.id, stored.name); diff --git a/src/ui/overlays/controls/LayerPanel.vue b/src/ui/overlays/controls/LayerPanel.vue index 2972a530..545b89b0 100644 --- a/src/ui/overlays/controls/LayerPanel.vue +++ b/src/ui/overlays/controls/LayerPanel.vue @@ -6,6 +6,10 @@ import { LAND_SEA_MASK_MODES, type TLandSeaMaskMode, } from "@/lib/layers/landSeaMask.ts"; +import { + isSupportedTextureLayerFile, + TEXTURE_LAYER_UPLOAD_ACCEPT, +} from "@/lib/layers/textureLayerFormats.ts"; import { deleteTexture, getTexture, @@ -141,7 +145,7 @@ async function onFileSelected(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ""; - if (!file || !["image/png", "image/jpeg"].includes(file.type)) { + if (!file || !isSupportedTextureLayerFile(file)) { return; } try { @@ -429,7 +433,7 @@ function getLayerName(layer: TLayerEntry) { { + const bounds = getGeoBoundsFromLatLonValues([ + 38, -123, 42, -123, 42, -119, 38, -119, + ]); + + expect(bounds).toEqual({ + west: -123, + south: 38, + east: -119, + north: 42, + }); +}); + +it("keeps antimeridian-crossing bounds compact", () => { + const bounds = getGeoBoundsFromLatLonValues([ + -10, 170, 10, 170, 10, -170, -10, -170, + ]); + + expect(bounds).toEqual({ + west: 170, + south: -10, + east: -170, + north: 10, + }); +}); + +it("computes longitude bounds for many distinct grid points", () => { + const pointCount = 150_000; + const values: number[] = []; + for (let index = 0; index < pointCount; index++) { + values.push(index % 2, -180 + (index * 360) / pointCount); + } + + expect(getGeoBoundsFromLatLonValues(values)).toEqual({ + west: -180, + south: 0, + east: 180, + north: 1, + }); +}); + +it("treats near-global mesh coverage as global GeoTIFF bounds", () => { + const values: number[] = []; + const step = 0.703125; + const pointCount = Math.floor(359.296875 / step) + 1; + for (let index = 0; index < pointCount; index++) { + const lon = 45 + index * step; + values.push(index % 2 === 0 ? -90 : 90, lon > 180 ? lon - 360 : lon); + } + + expect(getGeoBoundsFromLatLonValues(values)).toEqual({ + west: -180, + south: -90, + east: 180, + north: 90, + }); +}); + +it("uses a single data texture size as the GeoTIFF export size", () => { + expect( + getGeoTiffExportSize( + { west: -123, south: 38, east: -119, north: 42 }, + [{ width: 1052, height: 784 }], + 0 + ) + ).toEqual({ width: 1052, height: 784 }); +}); + +it("computes source-ordered cell-edge bounds for regular lat/lon textures", () => { + expect( + getRegularLatLonGridBounds( + new Float32Array([-88.75, -87.5, -86.25]), + new Float32Array([0, 2.5, 5]) + ) + ).toEqual({ + west: -1.25, + east: 6.25, + south: -89.375, + north: -85.625, + }); +}); + +it("uses a high export size for geometry-valued grids", () => { + expect( + getGeoTiffExportSize( + { west: -10, south: -5, east: 10, north: 5 }, + [], + 20_000 + ) + ).toEqual({ width: 8192, height: 4096 }); +}); + +it("crops transparent export pixels and updates bounds", () => { + const size = { width: 4, height: 3 }; + const pixels = new Uint8Array(size.width * size.height * 4); + pixels[(1 * size.width + 1) * 4 + 3] = 255; + pixels[(1 * size.width + 2) * 4 + 3] = 255; + + const crop = getAlphaCrop(pixels, size); + + expect(crop).toEqual({ x: 1, y: 1, width: 2, height: 1 }); + expect( + getCroppedGeoBounds( + { west: -20, south: -15, east: 20, north: 15 }, + size, + crop! + ) + ).toEqual({ + west: -10, + south: -5, + east: 10, + north: 5, + }); +}); + +it("exports raw render target pixels without color-space conversion", () => { + const target = createExportRenderTarget({ width: 4, height: 4 }); + + expect(target.texture.colorSpace).toBe(THREE.NoColorSpace); +}); + +it("adds triangle wrap attributes for mesh GeoTIFF export", () => { + const geometry = new THREE.InstancedBufferGeometry(); + geometry.setAttribute( + "position", + new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0], 3) + ); + geometry.setAttribute( + "latLon", + new THREE.Float32BufferAttribute([0, 177.5, 0, -180, 1, 177.5, 1, -180], 2) + ); + geometry.setAttribute( + "wrapDirection", + new THREE.InstancedBufferAttribute(new Float32Array([0, 1, -1]), 1) + ); + geometry.setIndex([0, 1, 2, 1, 3, 2]); + + const exportGeometry = createGeoTiffExportGeometry(geometry, true); + + expect(exportGeometry.index).toBeNull(); + expect(exportGeometry.getAttribute("triangleLatLon0")).toBeDefined(); + expect(exportGeometry.getAttribute("triangleLatLon1")).toBeDefined(); + expect(exportGeometry.getAttribute("triangleLatLon2")).toBeDefined(); + expect(exportGeometry.getAttribute("wrapDirection")).toBeUndefined(); +}); + +it("can encode a regular texture GeoTIFF without alpha cropping", async () => { + const size = { width: 4, height: 2 }; + const pixels = new Uint8Array(size.width * size.height * 4); + pixels[(0 * size.width + 1) * 4] = 20; + pixels[(0 * size.width + 1) * 4 + 3] = 255; + + const blob = await encodePixelsToGeoTiffBlob( + pixels, + size, + { + west: -1.25, + south: -90, + east: 8.75, + north: -85, + }, + false + ); + + const tiff = await fromArrayBuffer(await blob.arrayBuffer()); + const image = await tiff.getImage(); + expect(image.getWidth()).toBe(4); + expect(image.getHeight()).toBe(2); + const raster = await image.readRGB({ interleave: true, enableAlpha: true }); + expect(Array.from(raster)).toEqual(Array.from(pixels)); +}); + +it("encodes exported pixels as a compressed GeoTIFF", async () => { + const size = { width: 64, height: 64 }; + const pixels = new Uint8Array(size.width * size.height * 4); + for (let index = 0; index < size.width * size.height; index++) { + pixels[index * 4] = 20; + pixels[index * 4 + 1] = 40; + pixels[index * 4 + 2] = 60; + pixels[index * 4 + 3] = 255; + } + + const blob = await encodePixelsToGeoTiffBlob(pixels, size, { + west: -10, + south: -5, + east: 10, + north: 5, + }); + + expect(blob.size).toBeLessThan(pixels.byteLength); + const tiff = await fromArrayBuffer(await blob.arrayBuffer()); + const image = await tiff.getImage(); + const raster = await image.readRGB({ interleave: true, enableAlpha: true }); + expect(Array.from(raster)).toEqual(Array.from(pixels)); +}); diff --git a/tests/unit/lib/layers/textureLayerFormats.test.ts b/tests/unit/lib/layers/textureLayerFormats.test.ts new file mode 100644 index 00000000..c95b8c3a --- /dev/null +++ b/tests/unit/lib/layers/textureLayerFormats.test.ts @@ -0,0 +1,72 @@ +import * as THREE from "three"; +import { expect, it } from "vitest"; + +import { + configureEquirectangularTexture, + getLongitudeSpan, + normalizeGeoTiffBounds, + TextureLayerSampling, +} from "@/lib/layers/equirectLayer.ts"; +import { + isGeoTiffLayerSource, + isSupportedTextureLayerFile, +} from "@/lib/layers/textureLayerFormats.ts"; + +it("accepts image files and GeoTIFF files as texture layers", () => { + expect( + isSupportedTextureLayerFile( + new File([], "layer.png", { type: "image/png" }) + ) + ).toBe(true); + expect( + isSupportedTextureLayerFile(new File([], "regional.tif", { type: "" })) + ).toBe(true); + expect( + isSupportedTextureLayerFile(new File([], "regional.geotiff", { type: "" })) + ).toBe(true); + expect( + isSupportedTextureLayerFile(new File([], "metadata.json", { type: "" })) + ).toBe(false); +}); + +it("detects GeoTIFF sources from MIME type or extension", () => { + expect(isGeoTiffLayerSource(new Blob([], { type: "image/tiff" }))).toBe(true); + expect(isGeoTiffLayerSource(new Blob(), "regional.tiff")).toBe(true); + expect(isGeoTiffLayerSource(new Blob([], { type: "image/png" }))).toBe(false); +}); + +it("normalizes longitude-latitude GeoTIFF bounds", () => { + expect(normalizeGeoTiffBounds([-123, 38, -119, 42])).toEqual({ + west: -123, + south: 38, + east: -119, + north: 42, + }); + expect( + getLongitudeSpan({ west: 170, south: -10, east: -170, north: 10 }) + ).toBe(20); +}); + +it("rejects non-geographic GeoTIFF bounds", () => { + expect(() => + normalizeGeoTiffBounds([500000, 4500000, 510000, 4510000]) + ).toThrow("longitude/latitude"); +}); + +it("marks image layer textures as display RGB", () => { + const texture = configureEquirectangularTexture(new THREE.Texture()); + + expect(texture.colorSpace).toBe(THREE.SRGBColorSpace); +}); + +it("can configure GeoTIFF textures with pixel-preserving sampling", () => { + const texture = configureEquirectangularTexture( + new THREE.Texture(), + { west: -10, south: -5, east: 10, north: 5 }, + TextureLayerSampling.PIXELATED + ); + + expect(texture.minFilter).toBe(THREE.NearestFilter); + expect(texture.magFilter).toBe(THREE.NearestFilter); + expect(texture.anisotropy).toBe(1); +}); From 9e21fcfdf8e55c444af2920514463929d2909e75 Mon Sep 17 00:00:00 2001 From: Andrej Fast Date: Thu, 18 Jun 2026 10:51:53 +0200 Subject: [PATCH 2/6] chore(ui): added loading spinner for the export --- src/store/store.ts | 1 + src/ui/overlays/controls/LayerPanel.vue | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/store/store.ts b/src/store/store.ts index d399fbcc..55cd5f11 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -149,6 +149,7 @@ export const useGlobeControlStore = defineStore("globeControl", { layerStack: builtinLayerStack() as TLayerEntry[], // incremented to request a GeoTIFF image-layer export of the current grid gridExportRequest: 0 as number, + gridExportLoading: false, // will get incremented each time a new dataset OR a new variable in the // same dataset is loaded; used to trigger reactivity in child components // that need to reload data when the variable changes diff --git a/src/ui/overlays/controls/LayerPanel.vue b/src/ui/overlays/controls/LayerPanel.vue index 545b89b0..98cc7166 100644 --- a/src/ui/overlays/controls/LayerPanel.vue +++ b/src/ui/overlays/controls/LayerPanel.vue @@ -441,7 +441,9 @@ function getLayerName(layer: TLayerEntry) {