diff --git a/.gitignore b/.gitignore index 804b15a0f..9cc8150e1 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ vitest.config.*.timestamp* test-output +3d_tiles storybook-static \ No newline at end of file diff --git a/apps/storybook-composition/.storybook/main.ts b/apps/storybook-composition/.storybook/main.ts index 6942d5f98..e24d57fe7 100644 --- a/apps/storybook-composition/.storybook/main.ts +++ b/apps/storybook-composition/.storybook/main.ts @@ -33,6 +33,10 @@ const config: StorybookConfig = { title: 'Ra Geospatial', url: 'http://localhost:4402', }, + 'three': { + title: 'Three', + url: 'http://localhost:4403', + }, }; } return { @@ -40,6 +44,10 @@ const config: StorybookConfig = { title: 'React MapLibreMap', url: 'https://mapcomponents.github.io/react-map-components-maplibre/react-maplibre/', }, + 'three': { + title: 'three', + url: 'https://mapcomponents.github.io/react-map-components-maplibre/three/', + }, 'deck-gl': { title: 'Deck.gl', url: 'https://mapcomponents.github.io/react-map-components-maplibre/deck-gl/', diff --git a/apps/storybook-composition/project.json b/apps/storybook-composition/project.json index 335c516ab..85273d9a5 100644 --- a/apps/storybook-composition/project.json +++ b/apps/storybook-composition/project.json @@ -11,6 +11,7 @@ "options": { "commands": [ "nx storybook deck-gl", + "nx storybook three", "nx storybook ra-geospatial", "nx storybook react-maplibre" ], diff --git a/package.json b/package.json index 47be638f5..1441b7202 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "babel-plugin-inline-react-svg": "^2.0.2", "babel-plugin-styled-components": "^2.1.4", "globals": "^16.3.0", - "react": "19.1.0", - "react-dom": "19.1.0" + "react": "^19.1.0", + "react-dom": "^19.1.0" }, "devDependencies": { "@babel/core": "^7.28.3", @@ -37,20 +37,21 @@ "@nx/vite": "21.3.10", "@nx/web": "21.3.10", "@nx/workspace": "^21.4.1", - "@storybook/addon-docs": "^9.1.3", + "@storybook/addon-docs": "^9.1.4", "@storybook/addon-links": "^9.1.4", "@storybook/addons": "^7.6.17", + "@storybook/react": "^9.1.4", "@storybook/react-vite": "9.1.1", "@storybook/testing-react": "^2.0.1", "@swc-node/register": "~1.10.10", "@swc/cli": "~0.7.8", "@swc/core": "~1.12.14", "@swc/helpers": "~0.5.17", - "@testing-library/dom": "10.4.0", - "@testing-library/react": "16.3.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", "@types/node": "^24.3.1", - "@types/react": "19.1.8", - "@types/react-dom": "19.1.6", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^4.7.0", "@vitest/ui": "^3.2.4", "ajv": "^8.17.1", @@ -62,7 +63,7 @@ "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "5.2.0", - "eslint-plugin-storybook": "9.1.1", + "eslint-plugin-storybook": "^9.1.4", "html-webpack-plugin": "^5.6.4", "jiti": "2.4.2", "jsdom": "~26.1.0", diff --git a/packages/deck-gl/package.json b/packages/deck-gl/package.json index 2a79898ba..5827a6190 100644 --- a/packages/deck-gl/package.json +++ b/packages/deck-gl/package.json @@ -11,7 +11,7 @@ "@deck.gl/aggregation-layers": "^9.1.14", "@deck.gl/core": "^9.1.14", "@deck.gl/mapbox": "^9.1.14", - "@mapcomponents/react-maplibre": "workspace:^", + "@mapcomponents/react-maplibre": "1.6.4", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", "@storybook/react": "^9.1.4", diff --git a/packages/ra-geospatial/package.json b/packages/ra-geospatial/package.json index a5ccd642b..881b20b4d 100644 --- a/packages/ra-geospatial/package.json +++ b/packages/ra-geospatial/package.json @@ -8,7 +8,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@mapcomponents/react-maplibre": "workspace:^", + "@mapcomponents/react-maplibre": "1.6.4", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", "@turf/helpers": "^7.2.0", diff --git a/packages/react-maplibre/package.json b/packages/react-maplibre/package.json index 11ef7c5e9..a0d953342 100644 --- a/packages/react-maplibre/package.json +++ b/packages/react-maplibre/package.json @@ -50,10 +50,9 @@ "react-redux": "^9.2.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", - "three": "^0.179.1", "topojson-client": "^3.1.0", "uuid": "^11.1.0", - "maplibre-gl": "5.6.0", + "maplibre-gl": "^5.7.0", "wms-capabilities": "^0.6.0" }, "peerDependencies": { @@ -77,7 +76,6 @@ "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "@types/sql.js": "^1.4.9", - "@types/three": "^0.179.0", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.42.0", "@typescript-eslint/parser": "^8.42.0", diff --git a/packages/react-maplibre/src/components/MlThreeJsLayer/MlThreeJsLayer.stories.tsx b/packages/react-maplibre/src/components/MlThreeJsLayer/MlThreeJsLayer.stories.tsx deleted file mode 100644 index 1acf90a4e..000000000 --- a/packages/react-maplibre/src/components/MlThreeJsLayer/MlThreeJsLayer.stories.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useRef, useState } from 'react'; -import noNavToolsDecorator from '../../decorators/NoNavToolsDecorator'; -import TopToolbar from '../../ui_components/TopToolbar'; -import Button from '@mui/material/Button'; -import MlThreeJsLayer from './MlThreeJsLayer'; -import { LoadingOverlayContext } from '../../ui_components/LoadingOverlayContext'; -import MlNavigationTools from '../MlNavigationTools/MlNavigationTools'; -import { useMap } from '../../index'; - -const storyoptions = { - title: 'MapComponents/MlThreeJsLayer', - component: MlThreeJsLayer, - argTypes: { - options: { - control: { - type: 'object', - }, - }, - }, - decorators: noNavToolsDecorator, -}; -export default storyoptions; - -const Template: any = () => { - const [showLayer, setShowLayer] = useState(true); - const showLayerRef = useRef(true); - const loadingOverlayContext = LoadingOverlayContext as { - setControlled?: (controlled: boolean) => void; - setLoadingDone?: (done: boolean) => void; - }; - - const mapHook = useMap(); - mapHook.map?.setZoom(14.5); - mapHook.map?.setCenter([7.1, 50.736]); - - return ( - <> - {showLayer && ( - loadingOverlayContext?.setControlled?.(true)} - onDone={() => setTimeout(() => loadingOverlayContext?.setLoadingDone?.(true), 1200)} - /> - )} - - - - - } - /> - - - ); -}; - -export const ExampleConfig = Template.bind({}); -ExampleConfig.parameters = {}; diff --git a/packages/react-maplibre/src/components/MlThreeJsLayer/MlThreeJsLayer.tsx b/packages/react-maplibre/src/components/MlThreeJsLayer/MlThreeJsLayer.tsx deleted file mode 100644 index 02f427036..000000000 --- a/packages/react-maplibre/src/components/MlThreeJsLayer/MlThreeJsLayer.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useContext, useRef, useEffect } from 'react'; -import MapContext from '../../contexts/MapContext'; -import maplibregl, { CustomLayerInterface, LngLatLike, Map } from 'maplibre-gl'; -import * as THREE from 'three'; -import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; -import MapLibreGlWrapper from '../MapLibreMap/lib/MapLibreGlWrapper'; - -/** - * Renders obj or gltf 3D Models on the MapLibreMap referenced by props.mapId - * - * @component - */ - -export interface MlThreeJsLayerProps { - mapId?: string; - init?: () => void; - onDone?: () => void; -} - -const MlThreeJsLayer = (props: MlThreeJsLayerProps) => { - const mapContext = useContext(MapContext); - - const layerName = '3d-model'; - const initializedRef = useRef(false); - const mapRef = useRef(null); - const initFuncRef = useRef(props.init); - - const cleanup = () => { - if (mapRef.current && mapRef.current.style) { - if (mapRef.current.getLayer(layerName)) { - mapRef.current.removeLayer(layerName); - } - mapRef.current = null; - } - }; - - useEffect(() => { - if (typeof initFuncRef.current === 'function') { - initFuncRef.current(); - } - - return cleanup; - }, []); - - useEffect(() => { - if (!mapContext.mapExists(props.mapId) || initializedRef.current) return; - - initializedRef.current = true; - mapRef.current = mapContext.getMap(props.mapId); - - mapRef.current?.setCenter([7.099771581806502, 50.73395746209983]); - mapRef.current?.setZoom(15); - mapRef.current?.setPitch(45); - - // parameters to ensure the model is georeferenced correctly on the map - const modelOrigin = [7.099771581806502, 50.73395746209983]; - // 50.73395746209983, 7.099771581806502 - const modelAltitude = 0; - const modelRotate = [Math.PI / 2, 90, 0]; - - const modelAsMercatorCoordinate = maplibregl.MercatorCoordinate.fromLngLat( - modelOrigin as LngLatLike, - modelAltitude - ); - - // transformation parameters to position, rotate and scale the 3D model onto the map - const modelTransform = { - translateX: modelAsMercatorCoordinate.x + 0.0000008, - translateY: modelAsMercatorCoordinate.y + 0.0000018, - translateZ: modelAsMercatorCoordinate.z, - rotateX: modelRotate[0], - rotateY: modelRotate[1], - rotateZ: modelRotate[2], - /* Since our 3D model is in real world meters, a scale transform needs to be - * applied since the CustomLayerInterface expects units in MercatorCoordinates. - */ - scale: modelAsMercatorCoordinate.meterInMercatorCoordinateUnits() + 0.00000003, - }; - - //var THREE = window.THREE; - - // configuration of the custom layer for a 3D model per the CustomLayerInterface - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const customLayer: CustomLayerInterface & { - camera: THREE.Camera | undefined; - scene: THREE.Scene | undefined; - map: Map; - renderer: THREE.WebGLRenderer; - } = { - id: '3d-model', - type: 'custom', - renderingMode: '3d', - camera: undefined, - scene: undefined, - onAdd: function (map: Map, gl: WebGL2RenderingContext) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - this.camera = new THREE.Camera(); - this.scene = new THREE.Scene(); - - // create two three.js lights to illuminate the model - const directionalLight = new THREE.DirectionalLight(0xffffff); - directionalLight.position.set(0, -70, 100).normalize(); - this.scene.add(directionalLight); - - const directionalLight2 = new THREE.DirectionalLight(0xffffff); - directionalLight2.position.set(0, 70, 100).normalize(); - this.scene.add(directionalLight2); - - // use the three.js GLTF loader to add the 3D model to the three.js scene - const loader = new GLTFLoader(); - loader.load( - 'assets/3D/godzilla_simple.glb', - //"https://docs.mapbox.com/mapbox-gl-js/assets/34M_17/34M_17.gltf", - function (gltf: { scene: THREE.Object3D }) { - self.scene?.add(gltf.scene); - if (typeof props.onDone === 'function') { - props.onDone(); - } - }.bind(this) - ); - this.map = map; - - // use the Mapbox GL JS map canvas for three.js - this.renderer = new THREE.WebGLRenderer({ - canvas: map.getCanvas(), - context: gl, - antialias: true, - }); - - this.renderer.autoClear = false; - }, - render: function (_gl, matrix) { - const rotationX = new THREE.Matrix4().makeRotationAxis( - new THREE.Vector3(1, 0, 0), - modelTransform.rotateX - ); - const rotationY = new THREE.Matrix4().makeRotationAxis( - new THREE.Vector3(0, 1, 0), - modelTransform.rotateY - ); - const rotationZ = new THREE.Matrix4().makeRotationAxis( - new THREE.Vector3(0, 0, 1), - modelTransform.rotateZ - ); - - const m = new THREE.Matrix4().fromArray( - Object.values(matrix.defaultProjectionData.mainMatrix) - ); - const l = new THREE.Matrix4() - .makeTranslation( - modelTransform.translateX, - modelTransform.translateY, - modelTransform.translateZ - ) - .scale( - new THREE.Vector3(modelTransform.scale, -modelTransform.scale, modelTransform.scale) - ) - .multiply(rotationX) - .multiply(rotationY) - .multiply(rotationZ); - - if (this.camera && this.scene) { - this.camera.projectionMatrix = m.multiply(l); - this.renderer.resetState(); - this.renderer.render(this.scene, this.camera); - this.map.triggerRepaint(); - } - }, - }; - - mapRef.current?.addLayer(customLayer); - - if (mapRef.current?.getLayer(layerName)) { - mapRef.current.setLayoutProperty(layerName, 'visibility', 'visible'); - } - }, [mapContext.mapIds, mapContext, props]); - - return <>; -}; - -export default MlThreeJsLayer; diff --git a/packages/three/.babelrc b/packages/three/.babelrc new file mode 100644 index 000000000..abff09136 --- /dev/null +++ b/packages/three/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/packages/three/.storybook/main.ts b/packages/three/.storybook/main.ts new file mode 100644 index 000000000..c1b4a4a9c --- /dev/null +++ b/packages/three/.storybook/main.ts @@ -0,0 +1,20 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.@(mdx|stories.@(js|jsx|ts|tsx))'], + addons: [], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: 'vite.config.ts', + }, + }, + }, +}; + +export default config; + +// To customize your Vite configuration you can use the viteFinal field. +// Check https://storybook.js.org/docs/react/builders/vite#configuration +// and https://nx.dev/recipes/storybook/custom-builder-configs diff --git a/packages/three/.storybook/preview.ts b/packages/three/.storybook/preview.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/three/README.md b/packages/three/README.md new file mode 100644 index 000000000..d557032b2 --- /dev/null +++ b/packages/three/README.md @@ -0,0 +1,54 @@ +# @mapcomponents/three + +This library provides React components to easily integrate [Three.js](https://threejs.org/) 3D content into [MapLibre GL JS](https://maplibre.org/) maps using [@mapcomponents/react-maplibre](https://github.com/mapcomponents/react-map-components-maplibre). + +## Installation + +Install the package and its peer dependencies: + +```bash +npm install @mapcomponents/three @mapcomponents/react-maplibre +``` + +## Getting Started + +To use `@mapcomponents/three`, you need to wrap your 3D layers with the `ThreeProvider` component. This provider initializes the Three.js scene, camera, and renderer, and registers a custom layer within the MapLibre map. + +### Basic Usage + +Here is a simple example of how to render a 3D model on a map: + +```tsx +import React from 'react'; +import { MapComponentsProvider, MapLibreMap } from '@mapcomponents/react-maplibre'; +import { ThreeProvider, MlThreeModelLayer } from '@mapcomponents/three'; + +const App = () => { + return ( + + + + + + + + ); +}; + +export default App; +``` + +## Running unit tests + +Run `nx test @mapcomponents/three` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/packages/three/cypress.config.ts b/packages/three/cypress.config.ts new file mode 100644 index 000000000..f7af2b649 --- /dev/null +++ b/packages/three/cypress.config.ts @@ -0,0 +1,13 @@ +import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + component: { + ...nxComponentTestingPreset(__filename, { + bundler: 'vite', + buildTarget: '@mapcomponents/three:build', + }), + supportFile: 'src/cypress/support/component.ts', + indexHtmlFile: 'src/cypress/support/component-index.html', + }, +}); diff --git a/packages/three/eslint.config.mjs b/packages/three/eslint.config.mjs new file mode 100644 index 000000000..e4da1d0e1 --- /dev/null +++ b/packages/three/eslint.config.mjs @@ -0,0 +1,12 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + ...nx.configs['flat/react'], + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/packages/three/package.json b/packages/three/package.json new file mode 100644 index 000000000..9ecaa41d3 --- /dev/null +++ b/packages/three/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mapcomponents/three", + "version": "0.0.1", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "import": "./index.mjs", + "require": "./index.js" + } + }, + "dependencies": { + "@mapcomponents/react-maplibre": "1.6.4", + "@mui/material": "^7.3.2", + "maplibre-gl": "^5.7.0", + "three": "^0.182.0" + }, + "devDependencies": { + "@types/three": "^0.182.0" + } +} diff --git a/packages/three/project.json b/packages/three/project.json new file mode 100644 index 000000000..62aaf89be --- /dev/null +++ b/packages/three/project.json @@ -0,0 +1,15 @@ +{ + "name": "@mapcomponents/three", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/three/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project @mapcomponents/three --web", + "targets": { + "storybook": { + "options": { + "port": 4403 + } + } + } +} diff --git a/packages/three/public/assets/3D/godzilla_simple.glb b/packages/three/public/assets/3D/godzilla_simple.glb new file mode 100644 index 000000000..f48767bb5 Binary files /dev/null and b/packages/three/public/assets/3D/godzilla_simple.glb differ diff --git a/packages/three/public/assets/splats/output.splat b/packages/three/public/assets/splats/output.splat new file mode 100644 index 000000000..fa39db445 Binary files /dev/null and b/packages/three/public/assets/splats/output.splat differ diff --git a/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.cy.tsx b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.cy.tsx new file mode 100644 index 000000000..6fe9fb523 --- /dev/null +++ b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.cy.tsx @@ -0,0 +1,63 @@ +import React, { useEffect } from 'react'; +import { MapComponentsProvider, MapLibreMap, useMap } from '@mapcomponents/react-maplibre'; +import { ThreeProvider } from '../ThreeProvider'; +import MlThreeModelLayer from './MlThreeModelLayer'; + +const MapExposer = () => { + const { map } = useMap({ mapId: 'map_1' }); + useEffect(() => { + if (map) { + (window as any)._map = map; + } + }, [map]); + return null; +}; + +const TestComponent = ({ onDone }: { onDone: () => void }) => { + return ( + + + + + + + + ); +}; + +describe('', () => { + it('renders', () => { + const onDoneSpy = cy.spy().as('onDoneSpy'); + cy.mount(); + + // Wait for map to load + cy.get('.maplibregl-canvas').should('exist'); + + // Wait for the model to load first (this confirms map and three provider are working) + cy.get('@onDoneSpy', { timeout: 15000 }).should('have.been.called'); + + // Check if map instance is available and has the custom layer + cy.window().should('have.property', '_map'); + cy.window().then((win: any) => { + const map = win._map; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(map).to.exist; + // Check for the layer added by ThreeProvider + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(map.getLayer('three-provider')).to.exist; + }); + }); +}); diff --git a/packages/react-maplibre/src/components/MlThreeJsLayer/MlThreeJsLayer.meta.json b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.meta.json similarity index 73% rename from packages/react-maplibre/src/components/MlThreeJsLayer/MlThreeJsLayer.meta.json rename to packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.meta.json index deb9ff61d..02a4d8998 100644 --- a/packages/react-maplibre/src/components/MlThreeJsLayer/MlThreeJsLayer.meta.json +++ b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.meta.json @@ -1,5 +1,5 @@ { - "name": "MlThreeJsLayer", + "name": "MlThreeModelLayer", "title": "3D Model", "description": "Layer Component, that makes it possible to show 3D Models on the map.", "i18n": { @@ -14,8 +14,8 @@ "demos": [ { "name": "Demo", - "url": "https://mapcomponents.github.io/react-map-components-maplibre/react-maplibre/iframe.html?id=mapcomponents-mlthreejslayer--example-config&viewMode=story" + "url": "https://mapcomponents.github.io/react-map-components-maplibre/three/iframe.html?id=mapcomponents-mlthreemodellayer--example-config&viewMode=story" } ], - "thumbnail": "https://mapcomponents.github.io/react-map-components-maplibre/react-maplibre/thumbnails/MlThreeJsLayer.png" + "thumbnail": "https://mapcomponents.github.io/react-map-components-maplibre/three/thumbnails/MlThreeModelLayer.png" } diff --git a/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.stories.tsx b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.stories.tsx new file mode 100644 index 000000000..c64463868 --- /dev/null +++ b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.stories.tsx @@ -0,0 +1,161 @@ +import { useRef, useState, useEffect } from 'react'; +import Button from '@mui/material/Button'; +import MlThreeModelLayer from './MlThreeModelLayer'; +import { useMap, TopToolbar, Sidebar } from '@mapcomponents/react-maplibre'; +import ThreejsContextDecorator from '../../decorators/ThreejsContextDecorator'; +import { useThree } from '../ThreeContext'; +import { ThreeObjectControls } from '../ThreeObjectControls'; +import ThreejsUtils from '../../lib/ThreejsUtils'; +import * as THREE from 'three'; + +const storyoptions = { + title: 'MapComponents/MlThreeModelLayer', + component: MlThreeModelLayer, + argTypes: { + options: { + control: { + type: 'object', + }, + }, + }, + decorators: ThreejsContextDecorator, +}; +export default storyoptions; + +const Lights = () => { + const { scene } = useThree(); + const lightsRef = useRef([]); + + useEffect(() => { + if (!scene) return; + + const directionalLight = new THREE.DirectionalLight(0xffffff); + directionalLight.position.set(0, -70, 100).normalize(); + scene.add(directionalLight); + + const directionalLight2 = new THREE.DirectionalLight(0xff2255); + directionalLight2.position.set(0, 70, 100).normalize(); + scene.add(directionalLight2); + + lightsRef.current = [directionalLight, directionalLight2]; + + return () => { + lightsRef.current.forEach((light) => scene.remove(light)); + }; + }, [scene]); + + return null; +}; + +const Template: any = () => { + const { worldMatrix } = useThree(); + const [showLayer, setShowLayer] = useState(true); + const [scale, setScale] = useState(1); + const [rotation, setRotation] = useState({ x: 90, y: 90, z: 0 }); + const [useMapCoords, setUseMapCoords] = useState(true); + const [mapPosition, setMapPosition] = useState({ lng: 7.097, lat: 50.7355 }); + const [altitude, setAltitude] = useState(0); + const [position, setPosition] = useState({ x: 0, y: 0, z: 0 }); + const [enableTransformControls, setEnableTransformControls] = useState(false); + const [transformMode, setTransformMode] = useState<'translate' | 'rotate' | 'scale'>('translate'); + const [sidebarOpen, setSidebarOpen] = useState(true); + + const mapHook = useMap({ mapId: 'map_1' }); + useEffect(() => { + if (!mapHook.map) return; + mapHook.map?.setZoom(15.5); + mapHook.map?.setPitch(44.5); + mapHook.map?.setCenter([7.097, 50.7355]); + }, [mapHook.map]); + + // Center map on position when switching coordinate modes + useEffect(() => { + if (!mapHook.map) return; + if (useMapCoords) { + mapHook.map.setCenter([mapPosition.lng, mapPosition.lat]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [useMapCoords, mapHook.map]); + + const handleTransformChange = (object: THREE.Object3D) => { + setRotation({ + x: (object.rotation.x * 180) / Math.PI, + y: (object.rotation.y * 180) / Math.PI, + z: (object.rotation.z * 180) / Math.PI, + }); + setScale(object.scale.x); + + if (useMapCoords && worldMatrix) { + const [lng, lat, alt] = ThreejsUtils.toMapPosition(worldMatrix, object.position); + setMapPosition({ lng, lat }); + setAltitude(alt); + } else { + setPosition({ x: object.position.x, y: object.position.y, z: object.position.z }); + } + }; + + return ( + <> + + {showLayer && ( + + )} + + setSidebarOpen(!sidebarOpen)} + > + Sidebar + + } + /> + + + + + ); +}; + +export const ExampleConfig = Template.bind({}); +ExampleConfig.parameters = {}; diff --git a/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.tsx b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.tsx new file mode 100644 index 000000000..954509bd9 --- /dev/null +++ b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.tsx @@ -0,0 +1,153 @@ +import { useEffect, useRef, useState } from 'react'; +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; +import { LngLatLike } from 'maplibre-gl'; +import { useThree } from '../ThreeContext'; +import ThreejsUtils from '../../lib/ThreejsUtils'; +import MlTransformControls from '../MlTransformControls'; + +/** + * Renders obj or gltf 3D Models on the MapLibreMap referenced by props.mapId + * + * @component + */ + +export interface MlThreeModelLayerProps { + mapId?: string; + url: string; + position?: { x: number; y: number; z: number }; + mapPosition?: LngLatLike; + altitude?: number; + rotation?: { x: number; y: number; z: number }; + scale?: { x: number; y: number; z: number } | number; + enableTransformControls?: boolean; + transformMode?: 'translate' | 'rotate' | 'scale'; + onTransformChange?: (object: THREE.Object3D) => void; + init?: () => void; + onDone?: () => void; +} + +const MlThreeModelLayer = (props: MlThreeModelLayerProps) => { + const { + url, + position, + mapPosition, + altitude, + rotation, + scale, + enableTransformControls, + transformMode, + onTransformChange, + init, + onDone, + } = props; + const { scene, worldMatrixInv } = useThree(); + const modelRef = useRef(undefined); + const [model, setModel] = useState(undefined); + + // Use refs for callbacks to avoid re-triggering the effect when they change + const initRef = useRef(init); + const onDoneRef = useRef(onDone); + initRef.current = init; + onDoneRef.current = onDone; + + const transformRef = useRef({ position, mapPosition, altitude, rotation, scale }); + transformRef.current = { position, mapPosition, altitude, rotation, scale }; + const worldMatrixInvRef = useRef(worldMatrixInv); + worldMatrixInvRef.current = worldMatrixInv; + + useEffect(() => { + if (!scene) return; + + if (typeof initRef.current === 'function') { + initRef.current(); + } + + const extension = url.split('.').pop()?.toLowerCase(); + + const onLoad = (object: THREE.Object3D) => { + const { position, mapPosition, altitude, rotation, scale } = transformRef.current; + const worldMatrixInv = worldMatrixInvRef.current; + + if (mapPosition && worldMatrixInv) { + const scenePos = ThreejsUtils.toScenePosition(worldMatrixInv, mapPosition, altitude); + object.position.set(scenePos.x, scenePos.y, scenePos.z); + } else if (position) { + object.position.set(position.x, position.y, position.z); + } + + if (rotation) { + object.rotation.set(rotation.x, rotation.y, rotation.z); + } + if (scale) { + if (typeof scale === 'number') { + object.scale.set(scale, scale, scale); + } else { + object.scale.set(scale.x, scale.y, scale.z); + } + } + + modelRef.current = object; + scene.add(object); + setModel(object); + if (typeof onDoneRef.current === 'function') { + onDoneRef.current(); + } + }; + + if (extension === 'glb' || extension === 'gltf') { + const loader = new GLTFLoader(); + loader.load(url, (gltf) => { + onLoad(gltf.scene); + }); + } else if (extension === 'obj') { + const loader = new OBJLoader(); + loader.load(url, (obj) => { + onLoad(obj); + }); + } else { + console.warn('MlThreeModelLayer: Unsupported file extension', extension); + } + + return () => { + if (modelRef.current) { + scene.remove(modelRef.current); + modelRef.current = undefined; + setModel(undefined); + } + }; + }, [scene, url]); + + useEffect(() => { + if (!model) return; + + // Handle position: mapPosition takes precedence over position + if (mapPosition && worldMatrixInv) { + const scenePos = ThreejsUtils.toScenePosition(worldMatrixInv, mapPosition, altitude); + model.position.set(scenePos.x, scenePos.y, scenePos.z); + } else if (position) { + model.position.set(position.x, position.y, position.z); + } + + if (rotation) { + model.rotation.set(rotation.x, rotation.y, rotation.z); + } + if (scale) { + if (typeof scale === 'number') { + model.scale.set(scale, scale, scale); + } else { + model.scale.set(scale.x, scale.y, scale.z); + } + } + }, [model, position, mapPosition, altitude, rotation, scale, worldMatrixInv]); + + if (enableTransformControls && model) { + return ( + + ); + } + return null; +}; + +export default MlThreeModelLayer; diff --git a/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.cy.tsx b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.cy.tsx new file mode 100644 index 000000000..c96cb275c --- /dev/null +++ b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.cy.tsx @@ -0,0 +1,62 @@ +import React, { useEffect } from 'react'; +import { MapComponentsProvider, MapLibreMap, useMap } from '@mapcomponents/react-maplibre'; +import { ThreeProvider } from '../ThreeProvider'; +import MlThreeSplatLayer from './MlThreeSplatLayer'; + +const MapExposer = () => { + const { map } = useMap({ mapId: 'map_1' }); + useEffect(() => { + if (map) { + (window as any)._map = map; + } + }, [map]); + return null; +}; + +const TestComponent = ({ onDone }: { onDone: () => void }) => { + return ( + + + + + + + + ); +}; + +describe('', () => { + it('renders', () => { + const onDoneSpy = cy.spy().as('onDoneSpy'); + cy.mount(); + + // Wait for map to load + cy.get('.maplibregl-canvas').should('exist'); + + // Wait for the splat to load first + cy.get('@onDoneSpy', { timeout: 15000 }).should('have.been.called'); + + // Check if map instance is available and has the custom layer + cy.window().should('have.property', '_map'); + cy.window().then((win: any) => { + const map = win._map; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(map).to.exist; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(map.getLayer('three-provider')).to.exist; + }); + }); +}); diff --git a/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.meta.json b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.meta.json new file mode 100644 index 000000000..3eed3cb2d --- /dev/null +++ b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.meta.json @@ -0,0 +1,21 @@ +{ + "name": "MlThreeSplatLayer", + "title": "3D Splat Model", + "description": "Layer Component, that makes it possible to show 3D Gaussian Splatting Models on the map.", + "i18n": { + "de": { + "title": "3D Splat Modelle", + "description": "Layer Component, das es ermöglicht 3D Gaussian Splatting Modelle auf der Karte darzustellen." + } + }, + "tags": ["Map layer", "3D", "Splat"], + "category": "layer", + "type": "component", + "demos": [ + { + "name": "Demo", + "url": "https://mapcomponents.github.io/react-map-components-maplibre/three/iframe.html?id=mapcomponents-mlthreesplatlayer--default&viewMode=story" + } + ], + "thumbnail": "https://mapcomponents.github.io/react-map-components-maplibre/three/thumbnails/MlThreeSplatLayer.png" +} diff --git a/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.stories.tsx b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.stories.tsx new file mode 100644 index 000000000..c67535e6b --- /dev/null +++ b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.stories.tsx @@ -0,0 +1,151 @@ +import { useState, useEffect } from 'react'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import Link from '@mui/material/Link'; +import MlThreeSplatLayer from './MlThreeSplatLayer'; +import { useMap, TopToolbar, Sidebar } from '@mapcomponents/react-maplibre'; +import MlThreeJsContextDecorator from '../../decorators/ThreejsContextDecorator'; +import { ThreeObjectControls } from '../ThreeObjectControls'; +import { useThree } from '../ThreeContext'; +import ThreejsUtils from '../../lib/ThreejsUtils'; +import * as THREE from 'three'; + +const storyoptions = { + title: 'MapComponents/MlThreeSplatLayer', + component: MlThreeSplatLayer, + argTypes: { + options: { + control: { + type: 'object', + }, + }, + }, + decorators: MlThreeJsContextDecorator, +}; +export default storyoptions; + +const Template: any = () => { + const { worldMatrix } = useThree(); + const [showLayer, setShowLayer] = useState(true); + const [scale, setScale] = useState(100); + const [rotation, setRotation] = useState({ x: 270, y: 0, z: 5 }); + const [useMapCoords, setUseMapCoords] = useState(true); + const [mapPosition, setMapPosition] = useState({ lng: 7.0968, lat: 50.736 }); + const [altitude, setAltitude] = useState(30); + const [position, setPosition] = useState({ x: 0, y: 0, z: 100 }); + const [enableTransformControls, setEnableTransformControls] = useState(false); + const [transformMode, setTransformMode] = useState<'translate' | 'rotate' | 'scale'>('translate'); + const [sidebarOpen, setSidebarOpen] = useState(true); + + const mapHook = useMap({ mapId: 'map_1' }); + useEffect(() => { + if (!mapHook.map) return; + mapHook.map?.setZoom(17.5); + mapHook.map?.setPitch(44.5); + mapHook.map?.setCenter([7.096614581535903, 50.736500960686556]); + }, [mapHook.map]); + + // Center map on position when switching coordinate modes + useEffect(() => { + if (!mapHook.map) return; + if (useMapCoords) { + mapHook.map.setCenter([mapPosition.lng, mapPosition.lat]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [useMapCoords, mapHook.map]); + + const handleTransformChange = (object: THREE.Object3D) => { + setRotation({ + x: (object.rotation.x * 180) / Math.PI, + y: (object.rotation.y * 180) / Math.PI, + z: (object.rotation.z * 180) / Math.PI, + }); + setScale(object.scale.x); + + if (useMapCoords && worldMatrix) { + const [lng, lat, alt] = ThreejsUtils.toMapPosition(worldMatrix, object.position); + setMapPosition({ lng, lat }); + setAltitude(parseFloat(alt.toFixed(2))); + } else { + setPosition({ x: object.position.x, y: object.position.y, z: object.position.z }); + } + }; + + return ( + <> + {showLayer && ( + + )} + + setSidebarOpen(!sidebarOpen)} + > + Sidebar + + } + /> + + + + The splat used is from{' '} + + Cluster Fly + {' '} + by Dany Bittel published under CC. + + + + ); +}; + +export const Default = Template.bind({}); +Default.parameters = {}; diff --git a/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.tsx b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.tsx new file mode 100644 index 000000000..95efe7133 --- /dev/null +++ b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState } from 'react'; +import * as THREE from 'three'; +import { LngLatLike } from 'maplibre-gl'; +import { useThree } from '../ThreeContext'; +import { SplatLoader } from '../../lib/splats/loaders/SplatLoader'; +import { PlySplatLoader } from '../../lib/splats/loaders/PlySplatLoader'; +import ThreejsUtils from '../../lib/ThreejsUtils'; +import MlTransformControls from '../MlTransformControls'; + +/** + * Renders splat 3D Models on the MapLibreMap referenced by props.mapId + * + * @component + */ + +export interface MlThreeSplatLayerProps { + mapId?: string; + url: string; + position?: { x: number; y: number; z: number }; + mapPosition?: LngLatLike; + altitude?: number; + rotation?: { x: number; y: number; z: number }; + scale?: { x: number; y: number; z: number } | number; + enableTransformControls?: boolean; + transformMode?: 'translate' | 'rotate' | 'scale'; + onTransformChange?: (object: THREE.Object3D) => void; + init?: () => void; + onDone?: () => void; +} + +const MlThreeSplatLayer = (props: MlThreeSplatLayerProps) => { + const { + url, + position, + mapPosition, + altitude, + rotation, + scale, + enableTransformControls, + transformMode, + onTransformChange, + init, + onDone, + } = props; + const { scene, worldMatrixInv } = useThree(); + const modelRef = useRef(undefined); + const [model, setModel] = useState(undefined); + + // Use refs for callbacks to avoid re-triggering the effect when they change + const initRef = useRef(init); + const onDoneRef = useRef(onDone); + initRef.current = init; + onDoneRef.current = onDone; + + const transformRef = useRef({ position, mapPosition, altitude, rotation, scale }); + transformRef.current = { position, mapPosition, altitude, rotation, scale }; + const worldMatrixInvRef = useRef(worldMatrixInv); + worldMatrixInvRef.current = worldMatrixInv; + + useEffect(() => { + if (!scene) return; + + if (typeof initRef.current === 'function') { + initRef.current(); + } + + const extension = url.split('.').pop()?.toLowerCase(); + + const onLoad = (object: THREE.Object3D) => { + const { position, mapPosition, altitude, rotation, scale } = transformRef.current; + const worldMatrixInv = worldMatrixInvRef.current; + + if (mapPosition && worldMatrixInv) { + const scenePos = ThreejsUtils.toScenePosition(worldMatrixInv, mapPosition, altitude); + object.position.set(scenePos.x, scenePos.y, scenePos.z); + } else if (position) { + object.position.set(position.x, position.y, position.z); + } + + if (rotation) { + object.rotation.set(rotation.x, rotation.y, rotation.z); + } + if (scale) { + if (typeof scale === 'number') { + object.scale.set(scale, scale, scale); + } else { + object.scale.set(scale.x, scale.y, scale.z); + } + } + object.updateMatrixWorld(true); + + modelRef.current = object; + scene.add(object); + setModel(object); + if (typeof onDoneRef.current === 'function') { + onDoneRef.current(); + } + }; + + if (extension === 'splat') { + const loader = new SplatLoader(); + loader.load(url, (splatMesh) => { + onLoad(splatMesh); + }); + } else if (extension === 'ply') { + const loader = new PlySplatLoader(); + loader.load(url, (splatMesh) => { + onLoad(splatMesh); + }); + } else { + console.warn('MlThreeSplatLayer: Unsupported file extension', extension); + } + + return () => { + if (modelRef.current) { + scene.remove(modelRef.current); + if ('dispose' in modelRef.current && typeof modelRef.current.dispose === 'function') { + (modelRef.current as any).dispose(); + } + modelRef.current = undefined; + setModel(undefined); + } + }; + }, [scene, url]); + + useEffect(() => { + if (!model) return; + + // Handle position: mapPosition takes precedence over position + if (mapPosition && worldMatrixInv) { + const scenePos = ThreejsUtils.toScenePosition(worldMatrixInv, mapPosition, altitude); + model.position.set(scenePos.x, scenePos.y, scenePos.z); + } else if (position) { + model.position.set(position.x, position.y, position.z); + } + + if (rotation) { + model.rotation.set(rotation.x, rotation.y, rotation.z); + } + if (scale) { + if (typeof scale === 'number') { + model.scale.set(scale, scale, scale); + } else { + model.scale.set(scale.x, scale.y, scale.z); + } + } + model.updateMatrixWorld(true); + }, [model, position, mapPosition, altitude, rotation, scale, worldMatrixInv]); + + if (enableTransformControls && model) { + return ( + + ); + } + return null; +}; + +export default MlThreeSplatLayer; diff --git a/packages/three/src/components/MlTransformControls.tsx b/packages/three/src/components/MlTransformControls.tsx new file mode 100644 index 000000000..52ede6c2a --- /dev/null +++ b/packages/three/src/components/MlTransformControls.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; +import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; +import { useThree } from './ThreeContext'; + +export interface MlTransformControlsProps { + target?: THREE.Object3D; + mode?: 'translate' | 'rotate' | 'scale'; + enabled?: boolean; + space?: 'world' | 'local'; + size?: number; + onObjectChange?: (object: THREE.Object3D) => void; +} + +const MlTransformControls = (props: MlTransformControlsProps) => { + const { target, mode, enabled, space, size, onObjectChange } = props; + const { scene, camera, renderer, map, sceneRoot } = useThree(); + const controlsRef = useRef(null); + + useEffect(() => { + if (!scene || !camera || !renderer || !map || !sceneRoot) return; + + const domElement = renderer.getRenderer().domElement; + const controls = new TransformControls(camera, domElement); + controlsRef.current = controls; + + // Set initial mode + controls.setMode(mode || 'translate'); + controls.setSpace(space || 'world'); + if (size) { + controls.setSize(size); + } + + // Add TransformControls root object to the sceneRoot + // TransformControls has an internal _root that is the actual Object3D + sceneRoot.add((controls as any)._root); + + // When transform controls are active, disable map interaction + const onDraggingChanged = (event: any) => { + if (event.value) { + // Disable map dragging when using transform controls + map.dragPan.disable(); + map.scrollZoom.disable(); + } else { + // Re-enable map dragging + map.dragPan.enable(); + map.scrollZoom.enable(); + } + }; + + controls.addEventListener('dragging-changed', onDraggingChanged); + + // Trigger callback on object change + if (onObjectChange) { + const handleObjectChange = () => { + if (controls.object) { + onObjectChange(controls.object); + } + }; + controls.addEventListener('objectChange', handleObjectChange); + } + + return () => { + controls.removeEventListener('dragging-changed', onDraggingChanged); + controls.detach(); + sceneRoot.remove((controls as any)._root); + controls.dispose(); + controlsRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scene, camera, renderer, map, sceneRoot]); + + // Update target object + useEffect(() => { + if (!controlsRef.current) return; + + if (target) { + controlsRef.current.attach(target); + } else { + controlsRef.current.detach(); + } + }, [target]); + + // Update mode + useEffect(() => { + if (!controlsRef.current) return; + // Directly set the mode to avoid detach/reattach cycle + (controlsRef.current as any).mode = mode || 'translate'; + }, [mode]); + + // Update enabled state + useEffect(() => { + if (!controlsRef.current) return; + controlsRef.current.enabled = enabled !== false; + }, [enabled]); + + // Update space + useEffect(() => { + if (!controlsRef.current) return; + controlsRef.current.setSpace(space || 'world'); + }, [space]); + + // Update size + useEffect(() => { + if (!controlsRef.current || !size) return; + controlsRef.current.setSize(size); + }, [size]); + + return null; +}; + +export default MlTransformControls; diff --git a/packages/three/src/components/ThreeContext.tsx b/packages/three/src/components/ThreeContext.tsx new file mode 100644 index 000000000..cc4513b22 --- /dev/null +++ b/packages/three/src/components/ThreeContext.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext } from 'react'; +import { Scene, PerspectiveCamera, Group, Matrix4 } from 'three'; +import { Map as MapboxMap } from 'maplibre-gl'; +import ThreejsSceneRenderer from '../lib/ThreejsSceneRenderer'; + +export interface ThreeContextType { + scene: Scene | undefined; + camera: PerspectiveCamera | undefined; + renderer: ThreejsSceneRenderer | undefined; + map: MapboxMap | undefined; + sceneRoot: Group | undefined; + worldMatrix: Matrix4 | undefined; + worldMatrixInv: Matrix4 | undefined; +} + +export const ThreeContext = createContext({ + scene: undefined, + camera: undefined, + renderer: undefined, + map: undefined, + sceneRoot: undefined, + worldMatrix: undefined, + worldMatrixInv: undefined, +}); + +export const useThree = () => useContext(ThreeContext); diff --git a/packages/three/src/components/ThreeObjectControls.tsx b/packages/three/src/components/ThreeObjectControls.tsx new file mode 100644 index 000000000..aa653e1c8 --- /dev/null +++ b/packages/three/src/components/ThreeObjectControls.tsx @@ -0,0 +1,197 @@ +import Button from '@mui/material/Button'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import Slider from '@mui/material/Slider'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; + +export interface ThreeObjectControlsProps { + showLayer: boolean; + setShowLayer: (show: boolean) => void; + scale: number; + setScale: (scale: number) => void; + rotation: { x: number; y: number; z: number }; + setRotation: (rotation: { x: number; y: number; z: number }) => void; + useMapCoords: boolean; + setUseMapCoords: (use: boolean) => void; + mapPosition: { lng: number; lat: number }; + setMapPosition: (position: { lng: number; lat: number }) => void; + altitude: number; + setAltitude: (altitude: number) => void; + position: { x: number; y: number; z: number }; + setPosition: (position: { x: number; y: number; z: number }) => void; + enableTransformControls?: boolean; + setEnableTransformControls?: (enable: boolean) => void; + transformMode?: 'translate' | 'rotate' | 'scale'; + setTransformMode?: (mode: 'translate' | 'rotate' | 'scale') => void; + layerName?: string; +} + +export const ThreeObjectControls = ({ + showLayer, + setShowLayer, + scale, + setScale, + rotation, + setRotation, + useMapCoords, + setUseMapCoords, + mapPosition, + setMapPosition, + altitude, + setAltitude, + position, + setPosition, + enableTransformControls, + setEnableTransformControls, + transformMode, + setTransformMode, + layerName = 'Layer', +}: ThreeObjectControlsProps) => { + return ( + + + + + {setEnableTransformControls && ( + + )} + + + {setTransformMode && enableTransformControls && ( + + + + + + + + )} + Scale: {scale.toFixed(2)} + setScale(newValue as number)} + min={0.01} + max={150} + step={0.01} + valueLabelDisplay="auto" + /> + Rotation X: {rotation.x}° + setRotation({ ...rotation, x: newValue as number })} + min={0} + max={360} + valueLabelDisplay="auto" + /> + Rotation Y: {rotation.y}° + setRotation({ ...rotation, y: newValue as number })} + min={0} + max={360} + valueLabelDisplay="auto" + /> + Rotation Z: {rotation.z}° + setRotation({ ...rotation, z: newValue as number })} + min={0} + max={360} + valueLabelDisplay="auto" + /> + {useMapCoords ? ( + <> + Longitude: {mapPosition.lng.toFixed(6)} + setMapPosition({ ...mapPosition, lng: newValue as number })} + min={7.09} + max={7.11} + step={0.0001} + valueLabelDisplay="auto" + /> + Latitude: {mapPosition.lat.toFixed(6)} + setMapPosition({ ...mapPosition, lat: newValue as number })} + min={50.73} + max={50.74} + step={0.0001} + valueLabelDisplay="auto" + /> + Altitude: {altitude} m + setAltitude(newValue as number)} + min={-100} + max={500} + valueLabelDisplay="auto" + /> + + ) : ( + <> + Position X: {position.x} + setPosition({ ...position, x: newValue as number })} + min={-100} + max={100} + valueLabelDisplay="auto" + /> + Position Y: {position.y} + setPosition({ ...position, y: newValue as number })} + min={-100} + max={100} + valueLabelDisplay="auto" + /> + Position Z: {position.z} + setPosition({ ...position, z: newValue as number })} + min={-500} + max={100} + valueLabelDisplay="auto" + /> + + )} + + ); +}; diff --git a/packages/three/src/components/ThreeProvider.tsx b/packages/three/src/components/ThreeProvider.tsx new file mode 100644 index 000000000..43647072a --- /dev/null +++ b/packages/three/src/components/ThreeProvider.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { useMap } from '@mapcomponents/react-maplibre'; +import { Scene, PerspectiveCamera, Group, Matrix4 } from 'three'; +import { LngLatLike, CustomLayerInterface } from 'maplibre-gl'; +import ThreejsSceneHelper from '../lib/ThreejsSceneHelper'; +import ThreejsSceneRenderer from '../lib/ThreejsSceneRenderer'; +import ThreejsUtils from '../lib/ThreejsUtils'; +import { ThreeContext } from './ThreeContext'; + +export interface ThreeProviderProps { + mapId?: string; + id: string; + refCenter?: LngLatLike; + envTexture?: string; + envIntensity?: number; + createLight?: boolean; + children?: React.ReactNode; + /** + * Id of an existing layer in the MapLibre instance to help specify the layer order. + * The Three.js layer will be rendered visually beneath the layer with the specified id. + */ + beforeId?: string; +} + +export const ThreeProvider: React.FC = ({ + mapId, + id, + refCenter, + envTexture, + envIntensity = 1, + createLight = true, + children, + beforeId, +}) => { + const { map } = useMap({ mapId, waitForLayer: beforeId }); + const [scene, setScene] = useState(); + const [camera, setCamera] = useState(); + const [renderer, setRenderer] = useState(); + const [sceneRoot, setSceneRoot] = useState(); + const [worldMatrix, setWorldMatrix] = useState(); + const [worldMatrixInv, setWorldMatrixInv] = useState(); + + const helperRef = useRef(new ThreejsSceneHelper()); + const worldMatrixRef = useRef(new Matrix4()); + const worldMatrixInvRef = useRef(new Matrix4()); + const rendererRef = useRef(undefined); + + useEffect(() => { + if (!map) return; + + const helper = helperRef.current; + const threeScene = helper.createScene(createLight); + const root = helper.createGroup(threeScene, 'scene-root'); + const threeCamera = helper.createCamera(root, 'camera-for-render'); + + const customLayer: CustomLayerInterface = { + id: id, + type: 'custom', + renderingMode: '3d', + onAdd: (mapInstance, gl) => { + const threeRenderer = new ThreejsSceneRenderer(mapInstance, gl as WebGL2RenderingContext); + rendererRef.current = threeRenderer; + setRenderer(threeRenderer); + + const center = refCenter || mapInstance.getCenter(); + worldMatrixRef.current = ThreejsUtils.updateWorldMatrix(mapInstance, center); + worldMatrixInvRef.current = worldMatrixRef.current.clone().invert(); + setWorldMatrix(worldMatrixRef.current); + setWorldMatrixInv(worldMatrixInvRef.current); + + if (envTexture) { + helper.createEnvTexture(envTexture, threeScene); + } + threeScene.environmentIntensity = envIntensity; + + mapInstance.triggerRepaint(); + }, + render: (gl, matrix) => { + if (!rendererRef.current || !threeScene || !threeCamera) return; + + helper.updateCameraForRender( + threeCamera, + map.map, + matrix, + worldMatrixRef.current, + worldMatrixInvRef.current + ); + + rendererRef.current.render(threeScene, threeCamera); + map.triggerRepaint(); + }, + onRemove: () => { + if (rendererRef.current) { + rendererRef.current.dispose(); + rendererRef.current = undefined; + } + setRenderer(undefined); + }, + }; + + if (!map.getLayer(id)) { + map.addLayer(customLayer, beforeId); + } + + setScene(threeScene); + setCamera(threeCamera); + setSceneRoot(root); + + return () => { + if (map.getLayer(id)) { + map.removeLayer(id); + } + // Cleanup is handled in onRemove + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map, id]); // Re-run if map or id changes. + + // Handle dynamic prop changes + useEffect(() => { + if (scene && envTexture) { + helperRef.current.createEnvTexture(envTexture, scene); + } + }, [scene, envTexture]); + + useEffect(() => { + if (scene) { + scene.environmentIntensity = envIntensity; + } + }, [scene, envIntensity]); + + // Handle refCenter change + useEffect(() => { + if (map && refCenter) { + worldMatrixRef.current = ThreejsUtils.updateWorldMatrix(map.map, refCenter); + worldMatrixInvRef.current = worldMatrixRef.current.clone().invert(); + setWorldMatrix(worldMatrixRef.current); + setWorldMatrixInv(worldMatrixInvRef.current); + map.triggerRepaint(); + } + }, [map, refCenter]); + + return ( + + {children} + + ); +}; diff --git a/packages/three/src/cypress/support/commands.ts b/packages/three/src/cypress/support/commands.ts new file mode 100644 index 000000000..6d4cbd5a6 --- /dev/null +++ b/packages/three/src/cypress/support/commands.ts @@ -0,0 +1 @@ +/// diff --git a/packages/three/src/cypress/support/component-index.html b/packages/three/src/cypress/support/component-index.html new file mode 100644 index 000000000..a9823d2e5 --- /dev/null +++ b/packages/three/src/cypress/support/component-index.html @@ -0,0 +1,13 @@ + + + + + + + @mapcomponents/three Components App + + + +
+ + diff --git a/packages/three/src/cypress/support/component.ts b/packages/three/src/cypress/support/component.ts new file mode 100644 index 000000000..4aab278c2 --- /dev/null +++ b/packages/three/src/cypress/support/component.ts @@ -0,0 +1,13 @@ +import { mount } from 'cypress/react'; +import './commands'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + mount: typeof mount; + } + } +} + +Cypress.Commands.add('mount', mount); diff --git a/packages/three/src/decorators/ThreejsContextDecorator.tsx b/packages/three/src/decorators/ThreejsContextDecorator.tsx new file mode 100644 index 000000000..126e1005e --- /dev/null +++ b/packages/three/src/decorators/ThreejsContextDecorator.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from 'react'; +import { ThreeProvider } from '../components/ThreeProvider'; +import { + MapComponentsProvider, + MapLibreMap, + MlNavigationTools, + getTheme, +} from '@mapcomponents/react-maplibre'; +import { ThemeProvider as MUIThemeProvider } from '@mui/material/styles'; +import './style.css'; + +const decorators = [ + (Story: any, context: any) => { + const theme = useMemo(() => getTheme(context?.globals?.theme), [context?.globals?.theme]); + return ( +
+ + + + + + + + + +
+ ); + }, +]; + +export default decorators; diff --git a/packages/three/src/decorators/style.css b/packages/three/src/decorators/style.css new file mode 100644 index 000000000..c03007552 --- /dev/null +++ b/packages/three/src/decorators/style.css @@ -0,0 +1,33 @@ +#root { + background-color: #000; + position: absolute; + min-height: 400px; + top: 0; + bottom: 0; + left: 0; + right: 0; +} +.docs-story { + min-height: 400px; + display: flex; + align-items: stretch; +} +.docs-story > div:first-child { + width: 100%; +} + +.App { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} +.fullscreen_map .mapContainer { + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + z-index: 100; +} diff --git a/packages/three/src/index.ts b/packages/three/src/index.ts new file mode 100644 index 000000000..05b612ac2 --- /dev/null +++ b/packages/three/src/index.ts @@ -0,0 +1,7 @@ +export * from './lib/ThreejsUtils'; +export * from './lib/ThreejsSceneHelper'; +export * from './lib/ThreejsSceneRenderer'; +export * from './components/ThreeContext'; +export * from './components/ThreeProvider'; +export { default as MlThreeModelLayer } from './components/MlThreeModelLayer/MlThreeModelLayer'; +export { default as MlThreeSplatLayer } from './components/MlThreeSplatLayer/MlThreeSplatLayer'; diff --git a/packages/three/src/lib/ThreejsSceneHelper.ts b/packages/three/src/lib/ThreejsSceneHelper.ts new file mode 100644 index 000000000..f04517535 --- /dev/null +++ b/packages/three/src/lib/ThreejsSceneHelper.ts @@ -0,0 +1,250 @@ +/** + * Derived from mapbox-3d-tiles by Jianshun Yang (MIT License) + * https://github.com/yangjs6/mapbox-3d-tiles + */ + +import { type Map as MaplibreMap } from 'maplibre-gl'; +import { + Scene, + PerspectiveCamera, + Matrix4, + Group, + EquirectangularReflectionMapping, + DirectionalLight, + AmbientLight, + Vector3, + Quaternion, + Euler, +} from 'three'; +import { HDRLoader } from 'three/examples/jsm/loaders/HDRLoader.js'; +import ThreejsUtils from './ThreejsUtils'; + +export default class ThreejsSceneHelper { + createScene(createLight = true): Scene { + const scene = new Scene(); + + if (createLight) { + const dirLight = new DirectionalLight(0xffffff, 4); + dirLight.position.set(1, 2, 3); + scene.add(dirLight); + + const ambLight = new AmbientLight(0xffffff, 0.2); + scene.add(ambLight); + } + + return scene; + } + + createGroup(parent: Scene | Group, name: string): Group { + const group = new Group(); + group.name = name; + parent.add(group); + return group; + } + + createCamera(sceneRoot: Group, name: string): PerspectiveCamera { + const camera = new PerspectiveCamera(); + camera.name = name; + + const group = new Group(); + group.name = `${name}-parent`; + group.add(camera); + + sceneRoot.add(group); + return camera; + } + + private buildPerspectiveMatrix( + fov: number, + aspect: number, + near: number, + far: number + ): Float64Array { + const f = 1.0 / Math.tan(fov / 2); + const nf = 1.0 / (near - far); + + return new Float64Array([ + f / aspect, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + (far + near) * nf, + -1, + 0, + 0, + 2 * far * near * nf, + 0, + ]); + } + + private buildOrthographicMatrix( + left: number, + right: number, + bottom: number, + top: number, + near: number, + far: number + ): Float64Array { + const lr = 1 / (left - right); + const bt = 1 / (bottom - top); + const nf = 1 / (near - far); + + return new Float64Array([ + -2 * lr, + 0, + 0, + 0, + 0, + -2 * bt, + 0, + 0, + 0, + 0, + 2 * nf, + 0, + (left + right) * lr, + (top + bottom) * bt, + (far + near) * nf, + 1, + ]); + } + + private calcProjectionMatrix(transform: any, fov: number, nearZ: number, farZ: number): Matrix4 { + const offset = transform.centerOffset; + const aspect = transform.width / transform.height; + + const perspective = this.buildPerspectiveMatrix(fov, aspect, nearZ, farZ); + perspective[8] = (-offset.x * 2) / transform.width; + perspective[9] = (offset.y * 2) / transform.height; + + if (!transform.isOrthographic) { + return new Matrix4().fromArray(perspective); + } + + const cameraToCenterDistance = (0.5 * transform.height) / Math.tan(fov / 2.0); + const halfHeight = cameraToCenterDistance * Math.tan(fov * 0.5); + const halfWidth = halfHeight * aspect; + + const ortho = this.buildOrthographicMatrix( + -halfWidth - offset.x, + halfWidth - offset.x, + -halfHeight + offset.y, + halfHeight + offset.y, + nearZ, + farZ + ); + + const transitionPitch = 15; + const t = Math.min(transform.pitch / transitionPitch, 1.0); + const eased = t * t * t * t * t; + + const blended = new Float64Array(16); + for (let i = 0; i < 16; i++) { + blended[i] = (1 - eased) * ortho[i] + eased * perspective[i]; + } + + return new Matrix4().fromArray(blended); + } + + updateCameraForRender( + camera: PerspectiveCamera, + map: MaplibreMap, + matrix: any, + worldMatrix: Matrix4, + worldMatrixInv: Matrix4 + ): void { + const transform = map.transform; + + const { fov, nearZ, farZ, aspect } = this.extractCameraParams(matrix, transform); + + camera.fov = ThreejsUtils.radToDeg(fov); + camera.aspect = aspect; + camera.near = nearZ; + camera.far = farZ; + + const cleanProjection = this.buildPerspectiveMatrix(fov, aspect, nearZ, farZ); + (camera as any)._cleanProjectionMatrix = cleanProjection; + + const mvpMatrix = this.extractMVPMatrix(matrix, worldMatrix); + const projectionMatrix = this.calcProjectionMatrix(transform, fov, nearZ, farZ); + + camera.projectionMatrix.copy(projectionMatrix); + camera.projectionMatrixInverse.copy(projectionMatrix).invert(); + + const viewMatrix = new Matrix4().multiplyMatrices(camera.projectionMatrixInverse, mvpMatrix); + + camera.matrixWorld.copy(viewMatrix).invert(); + camera.matrixWorldInverse.copy(viewMatrix); + camera.matrixAutoUpdate = false; + camera.matrixWorldAutoUpdate = false; + + this.updateCameraTransform(camera); + } + + private extractCameraParams(matrix: any, transform: any) { + const aspect = transform.width / transform.height; + + if (matrix.fov !== undefined) { + return { + fov: matrix.fov, + nearZ: matrix.nearZ, + farZ: matrix.farZ, + aspect, + }; + } + + const cameraToCenterDistance = transform.cameraToCenterDistance; + const fov = + cameraToCenterDistance && transform.height + ? 2 * Math.atan(transform.height / 2 / cameraToCenterDistance) + : 0.6435; + + return { + fov, + nearZ: transform.nearZ || 0.1, + farZ: transform.farZ || 10000, + aspect, + }; + } + + private extractMVPMatrix(matrix: any, worldMatrix: Matrix4): Matrix4 { + let baseMatrix: Matrix4; + + if (matrix.defaultProjectionData?.mainMatrix) { + baseMatrix = new Matrix4().fromArray(Object.values(matrix.defaultProjectionData.mainMatrix)); + } else if (matrix.modelViewProjectionMatrix) { + baseMatrix = new Matrix4().fromArray(matrix.modelViewProjectionMatrix); + } else { + const arr = Array.isArray(matrix) ? matrix : Array.from(matrix); + baseMatrix = new Matrix4().fromArray(arr); + } + + return new Matrix4().multiplyMatrices(baseMatrix, worldMatrix); + } + + private updateCameraTransform(camera: PerspectiveCamera): void { + const position = new Vector3(); + const quaternion = new Quaternion(); + const scale = new Vector3(); + + camera.matrixWorld.decompose(position, quaternion, scale); + camera.position.copy(position); + camera.rotation.copy(new Euler().setFromQuaternion(quaternion, 'YXZ')); + } + + createEnvTexture(envTexture: string, scene: Scene): void { + if (!envTexture?.endsWith('.hdr')) return; + + new HDRLoader().load(envTexture, (texture) => { + texture.mapping = EquirectangularReflectionMapping; + scene.environment = texture; + scene.environmentRotation.x = Math.PI / 2; + }); + } +} diff --git a/packages/three/src/lib/ThreejsSceneRenderer.ts b/packages/three/src/lib/ThreejsSceneRenderer.ts new file mode 100644 index 000000000..27c90449a --- /dev/null +++ b/packages/three/src/lib/ThreejsSceneRenderer.ts @@ -0,0 +1,73 @@ +/** + * Derived from mapbox-3d-tiles by Jianshun Yang (MIT License) + * https://github.com/yangjs6/mapbox-3d-tiles + */ + +import { type Map as MaplibreMap } from 'maplibre-gl'; +import { WebGLRenderer, Scene, Camera } from 'three'; +import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; + +export default class ThreejsSceneRenderer { + private renderer: WebGLRenderer; + private labelRenderer: CSS2DRenderer; + + /** + * Creates a ThreejsSceneRenderer instance. + * + * @param map - The MapLibre map instance + * @param gl - The WebGL2 rendering context from MapLibre + */ + constructor(map: MaplibreMap, gl: WebGL2RenderingContext) { + if (!gl || gl.isContextLost()) { + throw new Error('WebGL context is lost or invalid'); + } + + this.renderer = new WebGLRenderer({ + alpha: true, + antialias: true, + canvas: map.getCanvas(), + context: gl, + }); + this.renderer.autoClear = false; + + this.renderer.shadowMap.enabled = true; + this.labelRenderer = this.createLabelRenderer(map); + } + + private createLabelRenderer(map: MaplibreMap): CSS2DRenderer { + const container = map.getContainer(); + const labelRenderer = new CSS2DRenderer(); + + labelRenderer.setSize(container.clientWidth, container.clientHeight); + labelRenderer.domElement.style.position = 'absolute'; + labelRenderer.domElement.style.top = '0px'; + labelRenderer.domElement.style.pointerEvents = 'none'; + + map._container.appendChild(labelRenderer.domElement); + map.on('resize', () => { + const { clientWidth, clientHeight } = map.getContainer(); + labelRenderer.setSize(clientWidth, clientHeight); + }); + + return labelRenderer; + } + + getRenderer(): WebGLRenderer { + return this.renderer; + } + + render(scene: Scene, camera: Camera): void { + // Reset WebGL state to avoid conflicts with MapLibre + // but DO NOT clear the depth buffer - we want to preserve MapLibre's depth + // information so Three.js objects can be properly occluded by MapLibre 3D + // content (fill-extrusion buildings, terrain, etc.) and vice versa. + this.renderer.resetState(); + this.renderer.render(scene, camera); + this.labelRenderer.render(scene, camera); + } + + dispose(): void { + this.labelRenderer.domElement?.parentNode?.removeChild(this.labelRenderer.domElement); + this.renderer?.dispose(); + } +} diff --git a/packages/three/src/lib/ThreejsUtils.ts b/packages/three/src/lib/ThreejsUtils.ts new file mode 100644 index 000000000..5170bbe01 --- /dev/null +++ b/packages/three/src/lib/ThreejsUtils.ts @@ -0,0 +1,62 @@ +/** + * Derived from mapbox-3d-tiles by Jianshun Yang (MIT License) + * https://github.com/yangjs6/mapbox-3d-tiles + */ + +import { type Map as MaplibreMap, MercatorCoordinate, LngLatLike } from 'maplibre-gl'; +import { Vector3, Quaternion, Matrix4 } from 'three'; + +type Position = number[]; + +const DEG_TO_RAD = Math.PI / 180; +const RAD_TO_DEG = 180 / Math.PI; + +export default class ThreejsUtils { + static updateWorldMatrix(map: MaplibreMap | null, refCenter: LngLatLike | null = null): Matrix4 { + if (!map) return new Matrix4(); + + const center = refCenter ?? map.getCenter(); + const origin = MercatorCoordinate.fromLngLat(center); + const scale = origin.meterInMercatorCoordinateUnits(); + + return new Matrix4().compose( + new Vector3(origin.x, origin.y, origin.z), + new Quaternion(), + new Vector3(scale, -scale, scale) + ); + } + + static toScenePositionMercator(worldMatrixInv: Matrix4, mercator: MercatorCoordinate): Vector3 { + return new Vector3(mercator.x, mercator.y, mercator.z).applyMatrix4(worldMatrixInv); + } + + static toMapPositionMercator(worldMatrix: Matrix4, position: Vector3): MercatorCoordinate { + const transformed = position.clone().applyMatrix4(worldMatrix); + return new MercatorCoordinate(transformed.x, transformed.y, transformed.z); + } + + static toScenePosition( + worldMatrixInv: Matrix4, + position: LngLatLike, + altitude?: number + ): Vector3 { + return this.toScenePositionMercator( + worldMatrixInv, + MercatorCoordinate.fromLngLat(position, altitude) + ); + } + + static toMapPosition(worldMatrix: Matrix4, position: Vector3): Position { + const mercator = this.toMapPositionMercator(worldMatrix, position); + const lngLat = mercator.toLngLat(); + return [lngLat.lng, lngLat.lat, mercator.toAltitude()]; + } + + static degToRad(degrees: number): number { + return degrees * DEG_TO_RAD; + } + + static radToDeg(radians: number): number { + return radians * RAD_TO_DEG; + } +} diff --git a/packages/three/src/lib/splats/GaussianSplattingMesh.ts b/packages/three/src/lib/splats/GaussianSplattingMesh.ts new file mode 100644 index 000000000..5ba63edae --- /dev/null +++ b/packages/three/src/lib/splats/GaussianSplattingMesh.ts @@ -0,0 +1,848 @@ +/** + * Derived from mapbox-3d-tiles by Jianshun Yang (MIT License) + * https://github.com/yangjs6/mapbox-3d-tiles + */ + +import { + Box3, + BufferAttribute, + BufferGeometry, + Camera, + ClampToEdgeWrapping, + DataTexture, + DataUtils, + DoubleSide, + DynamicDrawUsage, + FloatType, + Group, + HalfFloatType, + InstancedBufferAttribute, + InstancedBufferGeometry, + LinearFilter, + Material, + Matrix4, + Mesh, + NearestFilter, + NormalBlending, + PixelFormat, + Quaternion, + RGBAFormat, + RGBAIntegerFormat, + RGFormat, + Scene, + ShaderMaterial, + Sphere, + UnsignedByteType, + UnsignedIntType, + UVMapping, + Vector2, + Vector3, + WebGLRenderer, +} from 'three'; +import { + Coroutine, + createYieldingScheduler, + runCoroutineAsync, + runCoroutineSync, +} from '../utils/coroutine'; +import { fragmentShaderSource, vertexShaderSource } from './GaussianSplattingShaders'; + +export type Nullable = T | null; + +const toHalfFloat = (val: number) => DataUtils.toHalfFloat(val); + +/** + * Geometry for Gaussian Splatting + */ +export class GaussianSplattingGeometry { + static build(maxSplatCount = 1): InstancedBufferGeometry { + const baseGeometry = new BufferGeometry(); + + // Triangle vertices (slightly faster than quad due to fewer shader invocations) + baseGeometry.setIndex([0, 1, 2]); + const positions = new BufferAttribute( + new Float32Array([-3.0, -2.0, 0.0, 3.0, -2.0, 0.0, 0.0, 4.0, 0.0]), + 3 + ); + baseGeometry.setAttribute('position', positions); + + const geometry = new InstancedBufferGeometry(); + geometry.setIndex(baseGeometry.getIndex()); + geometry.setAttribute('position', baseGeometry.getAttribute('position')); + + const splatIndexes = new InstancedBufferAttribute(new Float32Array(maxSplatCount), 1, false); + splatIndexes.setUsage(DynamicDrawUsage); + geometry.setAttribute('splatIndex', splatIndexes); + geometry.instanceCount = 0; + + return geometry; + } +} + +/** + * Radix sort-based depth sorter for Gaussian Splatting + */ +export class GaussianSplattingSorter { + private static readonly BATCH_SIZE = 327680; + private static workCount = 0; + + vertexCount = 0; + positions: Float32Array | null = null; + hasInit = false; + + splatIndex: Uint32Array | null = null; + depthValues: Int32Array | null = null; + tempDepths: Int32Array | null = null; + tempIndices: Uint32Array | null = null; + abortController: AbortController | null = null; + + onmessage: ((splatIndex: Uint32Array) => void) | null = null; + + terminate(): void { + this.abortController?.abort(); + this.abortController = null; + this.vertexCount = 0; + this.positions = null; + this.splatIndex = null; + this.onmessage = null; + } + + private initSortData(): void { + if (this.hasInit || this.vertexCount < 0) return; + + const count = this.vertexCount; + this.depthValues = new Int32Array(count); + this.splatIndex = new Uint32Array(count); + this.tempDepths = new Int32Array(count); + this.tempIndices = new Uint32Array(count); + this.hasInit = true; + } + + private *sortData(viewProj: number[], isAsync: boolean): Coroutine { + if (!this.hasInit) this.initSortData(); + + const { positions, vertexCount, depthValues, splatIndex, tempDepths, tempIndices } = this; + if (!positions || !depthValues || !splatIndex || !tempDepths || !tempIndices) return; + + let maxDepth = -Infinity; + let minDepth = Infinity; + + for (let i = 0; i < vertexCount; i++) { + splatIndex[i] = i; + const depth = + viewProj[2] * positions[4 * i] + + viewProj[6] * positions[4 * i + 1] + + viewProj[10] * positions[4 * i + 2]; + const depthInt = Math.floor(depth * 4096); + depthValues[i] = depthInt; + maxDepth = Math.max(maxDepth, depthInt); + minDepth = Math.min(minDepth, depthInt); + } + + if (isAsync) { + GaussianSplattingSorter.workCount += vertexCount; + if (GaussianSplattingSorter.workCount > GaussianSplattingSorter.BATCH_SIZE) { + GaussianSplattingSorter.workCount = 0; + yield; + } + } + + const depthOffset = -minDepth; + for (let i = 0; i < vertexCount; i++) { + depthValues[i] += depthOffset; + } + + const counts = new Uint32Array(256); + + for (let shift = 0; shift < 32; shift += 8) { + counts.fill(0); + + for (let i = 0; i < vertexCount; i++) { + counts[(depthValues[i] >> shift) & 0xff]++; + } + + let total = 0; + for (let i = 0; i < counts.length; i++) { + const current = counts[i]; + counts[i] = total; + total += current; + } + + for (let i = 0; i < vertexCount; i++) { + const byte = (depthValues[i] >> shift) & 0xff; + const pos = counts[byte]++; + tempDepths[pos] = depthValues[i]; + tempIndices[pos] = splatIndex[i]; + } + + depthValues.set(tempDepths); + splatIndex.set(tempIndices); + + if (isAsync) { + GaussianSplattingSorter.workCount += vertexCount; + if (GaussianSplattingSorter.workCount > GaussianSplattingSorter.BATCH_SIZE) { + GaussianSplattingSorter.workCount = 0; + yield; + } + } + } + } + + init(positions: Float32Array, vertexCount: number): void { + this.positions = positions; + this.vertexCount = vertexCount; + this.initSortData(); + } + + async sortDataAsync(viewProj: number[]): Promise { + this.abortController?.abort(); + this.abortController = new AbortController(); + const signal = this.abortController.signal; + + try { + await runCoroutineAsync(this.sortData(viewProj, true), createYieldingScheduler(), signal); + if (this.onmessage && this.splatIndex) { + this.onmessage(this.splatIndex); + } + } catch (error: unknown) { + if (error instanceof Error && error.name !== 'AbortError') { + console.error('Splat sort error:', error); + } + } finally { + this.abortController = null; + } + } +} + +/** + * Material for Gaussian Splatting + */ +export class GaussianSplattingMaterial { + static build(shDegree = 0): ShaderMaterial { + return new ShaderMaterial({ + uniforms: { + invViewport: { value: new Vector2() }, + dataTextureSize: { value: new Vector2() }, + focal: { value: new Vector2() }, + covariancesATexture: { value: null }, + covariancesBTexture: { value: null }, + centersTexture: { value: null }, + colorsTexture: { value: null }, + shTexture0: { value: null }, + shTexture1: { value: null }, + shTexture2: { value: null }, + }, + defines: { SH_DEGREE: shDegree }, + vertexShader: vertexShaderSource, + fragmentShader: fragmentShaderSource, + transparent: true, + alphaTest: 1.0, + blending: NormalBlending, + depthTest: true, + depthWrite: true, + side: DoubleSide, + }); + } + + static updateUniforms( + renderer: WebGLRenderer, + camera: Camera, + mesh: GaussianSplattingMesh + ): void { + const material = mesh.material as ShaderMaterial; + if (!material?.uniforms) return; + + const { uniforms } = material; + const renderSize = renderer.getSize(new Vector2()); + + uniforms.invViewport.value.set(1 / renderSize.x, 1 / renderSize.y); + + if (camera) { + const cleanMatrix = (camera as any)._cleanProjectionMatrix; + const elements = cleanMatrix?.elements ?? cleanMatrix ?? camera.projectionMatrix.elements; + + uniforms.focal.value.set(elements[0] * 0.5 * renderSize.x, elements[5] * 0.5 * renderSize.y); + } + + if (mesh.covariancesATexture) { + const { width, height } = mesh.covariancesATexture.image; + uniforms.dataTextureSize.value.set(width, height); + uniforms.covariancesATexture.value = mesh.covariancesATexture; + uniforms.covariancesBTexture.value = mesh.covariancesBTexture; + uniforms.centersTexture.value = mesh.centersTexture; + uniforms.colorsTexture.value = mesh.colorsTexture; + + mesh.shTextures?.forEach((tex, i) => { + uniforms[`shTexture${i}`].value = tex; + }); + } + + material.uniformsNeedUpdate = true; + } +} + +/** + * Gaussian Splatting mesh renderer + */ +export class GaussianSplattingMesh extends Mesh { + private static readonly ROW_OUTPUT_LENGTH = 3 * 4 + 3 * 4 + 4 + 4; + private static readonly SPLAT_BATCH_SIZE = 327680; + + private vertexCount = 0; + private worker: Nullable = null; + private frameIdLastUpdate = -1; + private frameIdThisUpdate = 0; + private cameraMatrix: Matrix4 | null = null; + private internalModelViewMatrix: Matrix4 | null = null; + private canPostToWorker = false; + private covariancesATextureInternal: Nullable = null; + private covariancesBTextureInternal: Nullable = null; + private centersTextureInternal: Nullable = null; + private colorsTextureInternal: Nullable = null; + private splatPositions: Nullable = null; + private splatPositions2: Nullable = null; + private splatIndex: Nullable = null; + private shTexturesInternal: Nullable = null; + private splatsDataInternal: Nullable = null; + private readonly keepInRam: boolean = false; + + private oldDirection = new Vector3(); + private useRGBACovariants = true; + private tmpCovariances = [0, 0, 0, 0, 0, 0]; + private sortIsDirty = false; + private lastSortTime = 0; + private sortThrottleMs = 200; + private shDegreeValue = 0; + + private tempQuaternion = new Quaternion(); + private tempPosition = new Vector3(); + private tempScale = new Vector3(); + private tempColor = new Uint8Array(4); + private tempMatrix = new Matrix4(); + + declare boundingBox: Box3 | null; + declare boundingSphere: Sphere | null; + readonly isGaussianSplattingMesh = true as const; + readyToDisplay = false; + override readonly type = 'GaussianSplattingMesh' as const; + + get shDegree() { + return this.shDegreeValue; + } + get splatsData() { + return this.splatsDataInternal; + } + get covariancesATexture() { + return this.covariancesATextureInternal; + } + get covariancesBTexture() { + return this.covariancesBTextureInternal; + } + get centersTexture() { + return this.centersTextureInternal; + } + get colorsTexture() { + return this.colorsTextureInternal; + } + get shTextures() { + return this.shTexturesInternal; + } + + constructor() { + super(); + this.geometry = GaussianSplattingGeometry.build(); + this.material = GaussianSplattingMaterial.build(); + this.setEnabled(false); + } + + setEnabled(enabled: boolean): void { + this.visible = enabled; + } + + postToWorker(forced = false): Promise | undefined { + const frameId = this.frameIdThisUpdate; + if ( + (forced || frameId !== this.frameIdLastUpdate) && + this.worker && + this.cameraMatrix && + this.canPostToWorker + ) { + this.internalModelViewMatrix = new Matrix4().multiplyMatrices( + this.cameraMatrix, + this.matrixWorld + ); + + const invCamera = this.cameraMatrix.clone().invert(); + const modelView = new Matrix4().multiplyMatrices(invCamera, this.matrixWorld); + const newDirection = new Vector3(0, 0, 1).transformDirection(modelView); + const dot = newDirection.dot(this.oldDirection); + + if (forced || Math.abs(dot - 1) >= 0.01) { + this.oldDirection.copy(newDirection); + this.frameIdLastUpdate = frameId; + this.canPostToWorker = false; + return this.worker.sortDataAsync(this.internalModelViewMatrix.elements); + } + } + return undefined; + } + + override onBeforeRender( + renderer: WebGLRenderer, + scene: Scene, + camera: Camera, + geometry: BufferGeometry, + material: Material, + group: Group + ): void { + this.frameIdThisUpdate = renderer.info.render.frame; + + const now = performance.now(); + if (now - this.lastSortTime > this.sortThrottleMs) { + this.lastSortTime = now; + this.sortDataAsync(camera).catch((err) => { + if (err.name !== 'AbortError') { + console.warn('Splat sorting error:', err); + } + }); + } + + GaussianSplattingMaterial.updateUniforms(renderer, camera, this); + super.onBeforeRender(renderer, scene, camera, geometry, material, group); + } + + loadDataAsync(data: ArrayBuffer): Promise { + return this.updateDataAsync(data); + } + + dispose(): void { + this.covariancesATextureInternal?.dispose(); + this.covariancesBTextureInternal?.dispose(); + this.centersTextureInternal?.dispose(); + this.colorsTextureInternal?.dispose(); + this.shTexturesInternal?.forEach((tex) => tex.dispose()); + + this.covariancesATextureInternal = null; + this.covariancesBTextureInternal = null; + this.centersTextureInternal = null; + this.colorsTextureInternal = null; + this.shTexturesInternal = null; + + this.worker?.terminate(); + this.worker = null; + } + + private copyTextures(source: GaussianSplattingMesh): void { + this.covariancesATextureInternal = source.covariancesATexture?.clone() ?? null; + this.covariancesBTextureInternal = source.covariancesBTexture?.clone() ?? null; + this.centersTextureInternal = source.centersTexture?.clone() ?? null; + this.colorsTextureInternal = source.colorsTexture?.clone() ?? null; + if (source.shTexturesInternal) { + this.shTexturesInternal = source.shTexturesInternal.map((tex) => tex.clone()); + } + } + + // @ts-expect-error - Return type differs from base class + override clone(): GaussianSplattingMesh { + const cloned = new GaussianSplattingMesh(); + cloned.geometry = this.geometry.clone(); + cloned.material = (this.material as Material).clone(); + cloned.vertexCount = this.vertexCount; + cloned.copyTextures(this); + cloned.splatPositions = this.splatPositions; + cloned.readyToDisplay = false; + cloned.instantiateWorker(); + return cloned; + } + + private makeSplatFromComponents( + sourceIndex: number, + destinationIndex: number, + position: Vector3, + scale: Vector3, + quaternion: Quaternion, + color: Uint8Array, + covA: Uint16Array, + covB: Uint16Array, + colorArray: Uint8Array, + minimum: Vector3, + maximum: Vector3 + ): void { + quaternion.w = -quaternion.w; + scale = scale.multiplyScalar(2); + + const te = this.tempMatrix.elements; + const covBSItemSize = this.useRGBACovariants ? 4 : 2; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.splatPositions![4 * sourceIndex + 0] = position.x; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.splatPositions![4 * sourceIndex + 1] = position.y; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.splatPositions![4 * sourceIndex + 2] = position.z; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.splatPositions2![4 * sourceIndex + 0] = position.x; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.splatPositions2![4 * sourceIndex + 1] = position.y; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.splatPositions2![4 * sourceIndex + 2] = position.z; + + minimum.min(position); + maximum.max(position); + + const { x, y, z, w } = quaternion; + const x2 = x + x, + y2 = y + y, + z2 = z + z; + const xx = x * x2, + xy = x * y2, + xz = x * z2; + const yy = y * y2, + yz = y * z2, + zz = z * z2; + const wx = w * x2, + wy = w * y2, + wz = w * z2; + const { x: sx, y: sy, z: sz } = scale; + + te[0] = (1 - (yy + zz)) * sx; + te[1] = (xy + wz) * sy; + te[2] = (xz - wy) * sz; + te[4] = (xy - wz) * sx; + te[5] = (1 - (xx + zz)) * sy; + te[6] = (yz + wx) * sz; + te[8] = (xz + wy) * sx; + te[9] = (yz - wx) * sy; + te[10] = (1 - (xx + yy)) * sz; + + const covariances = this.tmpCovariances; + covariances[0] = te[0] * te[0] + te[1] * te[1] + te[2] * te[2]; + covariances[1] = te[0] * te[4] + te[1] * te[5] + te[2] * te[6]; + covariances[2] = te[0] * te[8] + te[1] * te[9] + te[2] * te[10]; + covariances[3] = te[4] * te[4] + te[5] * te[5] + te[6] * te[6]; + covariances[4] = te[4] * te[8] + te[5] * te[9] + te[6] * te[10]; + covariances[5] = te[8] * te[8] + te[9] * te[9] + te[10] * te[10]; + + let factor = -10000; + for (let i = 0; i < 6; i++) { + factor = Math.max(factor, Math.abs(covariances[i])); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.splatPositions![4 * sourceIndex + 3] = factor; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.splatPositions2![4 * sourceIndex + 3] = factor; + + covA[destinationIndex * 4 + 0] = toHalfFloat(covariances[0] / factor); + covA[destinationIndex * 4 + 1] = toHalfFloat(covariances[1] / factor); + covA[destinationIndex * 4 + 2] = toHalfFloat(covariances[2] / factor); + covA[destinationIndex * 4 + 3] = toHalfFloat(covariances[3] / factor); + covB[destinationIndex * covBSItemSize + 0] = toHalfFloat(covariances[4] / factor); + covB[destinationIndex * covBSItemSize + 1] = toHalfFloat(covariances[5] / factor); + + colorArray[destinationIndex * 4 + 0] = color[0]; + colorArray[destinationIndex * 4 + 1] = color[1]; + colorArray[destinationIndex * 4 + 2] = color[2]; + colorArray[destinationIndex * 4 + 3] = color[3]; + } + + private makeSplatFromBuffer( + sourceIndex: number, + destinationIndex: number, + fBuffer: Float32Array, + uBuffer: Uint8Array, + covA: Uint16Array, + covB: Uint16Array, + colorArray: Uint8Array, + minimum: Vector3, + maximum: Vector3 + ): void { + const baseF = 8 * sourceIndex; + const baseU = 32 * sourceIndex; + + this.tempPosition.set(fBuffer[baseF], fBuffer[baseF + 1], fBuffer[baseF + 2]); + this.tempScale.set(fBuffer[baseF + 3], fBuffer[baseF + 4], fBuffer[baseF + 5]); + + this.tempQuaternion + .set( + (uBuffer[baseU + 29] - 127.5) / 127.5, + (uBuffer[baseU + 30] - 127.5) / 127.5, + (uBuffer[baseU + 31] - 127.5) / 127.5, + (uBuffer[baseU + 28] - 127.5) / 127.5 + ) + .normalize(); + + this.tempColor[0] = uBuffer[baseU + 24]; + this.tempColor[1] = uBuffer[baseU + 25]; + this.tempColor[2] = uBuffer[baseU + 26]; + this.tempColor[3] = uBuffer[baseU + 27]; + + this.makeSplatFromComponents( + sourceIndex, + destinationIndex, + this.tempPosition, + this.tempScale, + this.tempQuaternion, + this.tempColor, + covA, + covB, + colorArray, + minimum, + maximum + ); + } + + private updateTextures( + covA: Uint16Array, + covB: Uint16Array, + colorArray: Uint8Array, + sh?: Uint8Array[] + ): void { + const textureSize = this.getTextureSize(this.vertexCount); + + const createF32Texture = (data: Float32Array, w: number, h: number, format: PixelFormat) => { + const tex = new DataTexture( + data, + w, + h, + format, + FloatType, + UVMapping, + ClampToEdgeWrapping, + ClampToEdgeWrapping, + LinearFilter, + LinearFilter + ); + tex.generateMipmaps = false; + tex.needsUpdate = true; + return tex; + }; + + const createU8Texture = (data: Uint8Array, w: number, h: number, format: PixelFormat) => { + const tex = new DataTexture( + data, + w, + h, + format, + UnsignedByteType, + UVMapping, + ClampToEdgeWrapping, + ClampToEdgeWrapping, + LinearFilter, + LinearFilter + ); + tex.generateMipmaps = false; + tex.needsUpdate = true; + return tex; + }; + + const createU32Texture = (data: Uint32Array, w: number, h: number, format: PixelFormat) => { + const tex = new DataTexture( + data, + w, + h, + format, + UnsignedIntType, + UVMapping, + ClampToEdgeWrapping, + ClampToEdgeWrapping, + NearestFilter, + NearestFilter + ); + tex.generateMipmaps = false; + tex.needsUpdate = true; + return tex; + }; + + const createF16Texture = (data: Uint16Array, w: number, h: number, format: PixelFormat) => { + const tex = new DataTexture( + data, + w, + h, + format, + HalfFloatType, + UVMapping, + ClampToEdgeWrapping, + ClampToEdgeWrapping, + LinearFilter, + LinearFilter + ); + tex.generateMipmaps = false; + tex.needsUpdate = true; + return tex; + }; + + this.covariancesATextureInternal = createF16Texture( + covA, + textureSize.x, + textureSize.y, + RGBAFormat + ); + this.covariancesBTextureInternal = createF16Texture( + covB, + textureSize.x, + textureSize.y, + this.useRGBACovariants ? RGBAFormat : RGFormat + ); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.centersTextureInternal = createF32Texture( + this.splatPositions!, + textureSize.x, + textureSize.y, + RGBAFormat + ); + this.colorsTextureInternal = createU8Texture( + colorArray, + textureSize.x, + textureSize.y, + RGBAFormat + ); + + if (sh) { + this.shTexturesInternal = sh.map((shData) => { + const buffer = new Uint32Array(shData.buffer); + const shTexture = createU32Texture(buffer, textureSize.x, textureSize.y, RGBAIntegerFormat); + shTexture.wrapS = ClampToEdgeWrapping; + shTexture.wrapT = ClampToEdgeWrapping; + return shTexture; + }); + } + + this.instantiateWorker(); + } + + private updateBoundingInfo(minimum: Vector3, maximum: Vector3): void { + this.boundingBox = new Box3(minimum, maximum); + this.boundingSphere = this.boundingBox.getBoundingSphere(new Sphere()); + } + + private *updateDataCoroutine( + data: ArrayBuffer, + isAsync: boolean, + sh?: Uint8Array[] + ): Coroutine { + if (!this.covariancesATextureInternal) { + this.readyToDisplay = false; + } + + const uBuffer = new Uint8Array(data); + const fBuffer = new Float32Array(uBuffer.buffer); + + if (this.keepInRam) { + this.splatsDataInternal = data; + } + + this.shDegreeValue = sh ? sh.length : 0; + const vertexCount = uBuffer.length / GaussianSplattingMesh.ROW_OUTPUT_LENGTH; + + if (vertexCount !== this.vertexCount) { + this.vertexCount = vertexCount; + this.geometry = GaussianSplattingGeometry.build(this.vertexCount); + this.material = GaussianSplattingMaterial.build(this.shDegreeValue); + this.updateSplatIndexBuffer(this.vertexCount); + } + + const textureSize = this.getTextureSize(vertexCount); + const textureLength = textureSize.x * textureSize.y; + + this.splatPositions = new Float32Array(4 * textureLength); + this.splatPositions2 = new Float32Array(4 * vertexCount); + const covA = new Uint16Array(textureLength * 4); + const covB = new Uint16Array((this.useRGBACovariants ? 4 : 2) * textureLength); + const colorArray = new Uint8Array(textureLength * 4); + + const minimum = new Vector3(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE); + const maximum = new Vector3(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE); + + for (let i = 0; i < vertexCount; i++) { + this.makeSplatFromBuffer(i, i, fBuffer, uBuffer, covA, covB, colorArray, minimum, maximum); + if (isAsync && i % GaussianSplattingMesh.SPLAT_BATCH_SIZE === 0) { + yield; + } + } + + this.updateTextures(covA, covB, colorArray, sh); + this.updateBoundingInfo(minimum, maximum); + this.setEnabled(true); + this.postToWorker(true); + } + + async updateDataAsync(data: ArrayBuffer, sh?: Uint8Array[]): Promise { + return runCoroutineAsync(this.updateDataCoroutine(data, true, sh), createYieldingScheduler()); + } + + updateData(data: ArrayBuffer, sh?: Uint8Array[]): void { + runCoroutineSync(this.updateDataCoroutine(data, false, sh)); + } + + sortDataAsync(camera: Camera, forced = false): Promise { + if (!this.worker || !camera) { + return Promise.resolve(); + } + + this.cameraMatrix = camera.matrixWorldInverse; + return this.postToWorker(forced) ?? Promise.resolve(); + } + + private updateSplatIndexBuffer(vertexCount: number): void { + if (!this.splatIndex || vertexCount > this.splatIndex.length) { + this.splatIndex = new Float32Array(vertexCount); + for (let j = 0; j < vertexCount; j++) { + this.splatIndex[j] = j; + } + (this.geometry.attributes.splatIndex as BufferAttribute).set(this.splatIndex); + this.geometry.attributes.splatIndex.needsUpdate = true; + } + (this.geometry as InstancedBufferGeometry).instanceCount = vertexCount; + } + + private instantiateWorker(): void { + if (!this.vertexCount) return; + + this.updateSplatIndexBuffer(this.vertexCount); + this.worker?.terminate(); + this.worker = new GaussianSplattingSorter(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.worker.init(this.splatPositions2!, this.vertexCount); + this.canPostToWorker = true; + + this.worker.onmessage = (splatIndex) => { + if (this.splatIndex && splatIndex) { + for (let j = 0; j < this.vertexCount; j++) { + this.splatIndex[j] = splatIndex[j]; + } + (this.geometry.attributes.splatIndex as BufferAttribute).set(this.splatIndex); + } + + this.geometry.attributes.splatIndex.needsUpdate = true; + this.canPostToWorker = true; + this.readyToDisplay = true; + + if (this.sortIsDirty) { + this.postToWorker(true); + this.sortIsDirty = false; + } + }; + } + + private getTextureSize(length: number): Vector2 { + const maxTextureSize = 4096; + const width = maxTextureSize; + let height = 1; + + while (width * height < length) { + height *= 2; + } + + if (height > width) { + console.error( + `GaussianSplatting texture size: (${width}, ${height}), maxTextureSize: ${width}` + ); + height = width; + } + + return new Vector2(width, height); + } +} diff --git a/packages/three/src/lib/splats/GaussianSplattingShaders.ts b/packages/three/src/lib/splats/GaussianSplattingShaders.ts new file mode 100644 index 000000000..c2d85ae1d --- /dev/null +++ b/packages/three/src/lib/splats/GaussianSplattingShaders.ts @@ -0,0 +1,266 @@ +/** + * This file is part of mapbox-3d-tiles. + * Copyright (c) 2024 Jianshun Yang + * Licensed under the MIT License. + * Source: https://github.com/yangjs6/mapbox-3d-tiles + */ + +const vertexShaderSource = /* glsl */ ` +precision highp float; +#include + +attribute float splatIndex; + +uniform vec2 invViewport; +uniform vec2 dataTextureSize; +uniform vec2 focal; +uniform sampler2D covariancesATexture; +uniform sampler2D covariancesBTexture; +uniform sampler2D centersTexture; +uniform sampler2D colorsTexture; + +#if SH_DEGREE > 0 +uniform highp usampler2D shTexture0; +#endif +#if SH_DEGREE > 1 +uniform highp usampler2D shTexture1; +#endif +#if SH_DEGREE > 2 +uniform highp usampler2D shTexture2; +#endif + +varying vec4 vColor; +varying vec2 vPosition; + +vec2 getDataUV(float index, vec2 textureSize) { + float y = floor(index / textureSize.x); + float x = index - y * textureSize.x; + return vec2((x + 0.5) / textureSize.x, (y + 0.5) / textureSize.y); +} + +#if SH_DEGREE > 0 +ivec2 getDataUVint(float index, vec2 textureSize) { + float y = floor(index / textureSize.x); + float x = index - y * textureSize.x; + return ivec2(uint(x + 0.5), uint(y + 0.5)); +} +#endif + +struct Splat { + vec4 center; + vec4 color; + vec4 covA; + vec4 covB; +#if SH_DEGREE > 0 + uvec4 sh0; +#endif +#if SH_DEGREE > 1 + uvec4 sh1; +#endif +#if SH_DEGREE > 2 + uvec4 sh2; +#endif +}; + +Splat readSplat(float splatIndex) { + Splat splat; + vec2 splatUV = getDataUV(splatIndex, dataTextureSize); + splat.center = texture2D(centersTexture, splatUV); + splat.color = texture2D(colorsTexture, splatUV); + splat.covA = texture2D(covariancesATexture, splatUV) * splat.center.w; + splat.covB = texture2D(covariancesBTexture, splatUV) * splat.center.w; +#if SH_DEGREE > 0 + ivec2 splatUVint = getDataUVint(splatIndex, dataTextureSize); + splat.sh0 = texelFetch(shTexture0, splatUVint, 0); +#endif +#if SH_DEGREE > 1 + splat.sh1 = texelFetch(shTexture1, splatUVint, 0); +#endif +#if SH_DEGREE > 2 + splat.sh2 = texelFetch(shTexture2, splatUVint, 0); +#endif + return splat; +} + +vec3 computeColorFromSHDegree(vec3 dir, const vec3 sh[16]) { + const float SH_C0 = 0.28209479; + const float SH_C1 = 0.48860251; + float SH_C2[5]; + SH_C2[0] = 1.092548430; + SH_C2[1] = -1.09254843; + SH_C2[2] = 0.315391565; + SH_C2[3] = -1.09254843; + SH_C2[4] = 0.546274215; + + float SH_C3[7]; + SH_C3[0] = -0.59004358; + SH_C3[1] = 2.890611442; + SH_C3[2] = -0.45704579; + SH_C3[3] = 0.373176332; + SH_C3[4] = -0.45704579; + SH_C3[5] = 1.445305721; + SH_C3[6] = -0.59004358; + + vec3 result = sh[0]; + +#if SH_DEGREE > 0 + float x = dir.x; + float y = dir.y; + float z = dir.z; + result += -SH_C1 * y * sh[1] + SH_C1 * z * sh[2] - SH_C1 * x * sh[3]; + +#if SH_DEGREE > 1 + float xx = x * x, yy = y * y, zz = z * z; + float xy = x * y, yz = y * z, xz = x * z; + result += + SH_C2[0] * xy * sh[4] + + SH_C2[1] * yz * sh[5] + + SH_C2[2] * (2.0f * zz - xx - yy) * sh[6] + + SH_C2[3] * xz * sh[7] + + SH_C2[4] * (xx - yy) * sh[8]; + +#if SH_DEGREE > 2 + result += + SH_C3[0] * y * (3.0f * xx - yy) * sh[9] + + SH_C3[1] * xy * z * sh[10] + + SH_C3[2] * y * (4.0f * zz - xx - yy) * sh[11] + + SH_C3[3] * z * (2.0f * zz - 3.0f * xx - 3.0f * yy) * sh[12] + + SH_C3[4] * x * (4.0f * zz - xx - yy) * sh[13] + + SH_C3[5] * z * (xx - yy) * sh[14] + + SH_C3[6] * x * (xx - 3.0f * yy) * sh[15]; +#endif +#endif +#endif + + return result; +} + +vec4 decompose(uint value) { + vec4 components = vec4( + float((value) & 255u), + float((value >> uint(8)) & 255u), + float((value >> uint(16)) & 255u), + float((value >> uint(24)) & 255u) + ); + return components * vec4(2./255.) - vec4(1.); +} + +vec3 computeSH(Splat splat, vec3 color, vec3 dir) { + vec3 sh[16]; + sh[0] = color; + +#if SH_DEGREE > 0 + vec4 sh00 = decompose(splat.sh0.x); + vec4 sh01 = decompose(splat.sh0.y); + vec4 sh02 = decompose(splat.sh0.z); + sh[1] = vec3(sh00.x, sh00.y, sh00.z); + sh[2] = vec3(sh00.w, sh01.x, sh01.y); + sh[3] = vec3(sh01.z, sh01.w, sh02.x); +#endif +#if SH_DEGREE > 1 + vec4 sh03 = decompose(splat.sh0.w); + vec4 sh04 = decompose(splat.sh1.x); + vec4 sh05 = decompose(splat.sh1.y); + sh[4] = vec3(sh02.y, sh02.z, sh02.w); + sh[5] = vec3(sh03.x, sh03.y, sh03.z); + sh[6] = vec3(sh03.w, sh04.x, sh04.y); + sh[7] = vec3(sh04.z, sh04.w, sh05.x); + sh[8] = vec3(sh05.y, sh05.z, sh05.w); +#endif +#if SH_DEGREE > 2 + vec4 sh06 = decompose(splat.sh1.z); + vec4 sh07 = decompose(splat.sh1.w); + vec4 sh08 = decompose(splat.sh2.x); + vec4 sh09 = decompose(splat.sh2.y); + vec4 sh10 = decompose(splat.sh2.z); + vec4 sh11 = decompose(splat.sh2.w); + sh[9] = vec3(sh06.x, sh06.y, sh06.z); + sh[10] = vec3(sh06.w, sh07.x, sh07.y); + sh[11] = vec3(sh07.z, sh07.w, sh08.x); + sh[12] = vec3(sh08.y, sh08.z, sh08.w); + sh[13] = vec3(sh09.x, sh09.y, sh09.z); + sh[14] = vec3(sh09.w, sh10.x, sh10.y); + sh[15] = vec3(sh10.z, sh10.w, sh11.x); +#endif + + return computeColorFromSHDegree(dir, sh); +} + +vec4 gaussianSplatting(vec2 meshPos, vec3 worldPos, vec2 scale, vec3 covA, vec3 covB, mat4 worldMatrix, mat4 viewMatrix, mat4 projectionMatrix) { + mat4 modelView = viewMatrix * worldMatrix; + vec4 camspace = viewMatrix * vec4(worldPos, 1.0); + vec4 pos2d = projectionMatrix * camspace; + + float bounds = 1.2 * pos2d.w; + if (pos2d.z < -pos2d.w || pos2d.x < -bounds || pos2d.x > bounds || pos2d.y < -bounds || pos2d.y > bounds) { + return vec4(0.0, 0.0, 2.0, 1.0); + } + + mat3 Vrk = mat3( + covA.x, covA.y, covA.z, + covA.y, covB.x, covB.y, + covA.z, covB.y, covB.z + ); + + mat3 J = mat3( + focal.x / camspace.z, 0., -(focal.x * camspace.x) / (camspace.z * camspace.z), + 0., focal.y / camspace.z, -(focal.y * camspace.y) / (camspace.z * camspace.z), + 0., 0., 0. + ); + + mat3 T = transpose(mat3(modelView)) * J; + mat3 cov2d = transpose(T) * Vrk * T; + + float mid = (cov2d[0][0] + cov2d[1][1]) / 2.0; + float radius = length(vec2((cov2d[0][0] - cov2d[1][1]) / 2.0, cov2d[0][1])); + float lambda1 = mid + radius, lambda2 = mid - radius; + + if (lambda2 < 0.0) { + return vec4(0.0, 0.0, 2.0, 1.0); + } + + vec2 diagonalVector = normalize(vec2(cov2d[0][1], lambda1 - cov2d[0][0])); + vec2 majorAxis = min(sqrt(2.0 * lambda1), 1024.0) * diagonalVector; + vec2 minorAxis = min(sqrt(2.0 * lambda2), 1024.0) * vec2(diagonalVector.y, -diagonalVector.x); + + vec2 vCenter = vec2(pos2d); + return vec4( + vCenter + ((meshPos.x * majorAxis + meshPos.y * minorAxis) * invViewport * pos2d.w) * scale, + pos2d.zw + ); +} + +void main() { + Splat splat = readSplat(splatIndex); + vec3 covA = splat.covA.xyz; + vec3 covB = vec3(splat.covA.w, splat.covB.xy); + + vec4 worldPos = modelMatrix * vec4(splat.center.xyz, 1.0); + + vColor = splat.color; + vPosition = position.xy; + + gl_Position = gaussianSplatting(vPosition, worldPos.xyz, vec2(1.0, 1.0), covA, covB, modelMatrix, viewMatrix, projectionMatrix); +} +`; + +const fragmentShaderSource = /* glsl */ ` +precision highp float; +#include + +varying vec4 vColor; +varying vec2 vPosition; + +vec4 gaussianColor(vec4 inColor) { + float A = -dot(vPosition, vPosition); + if (A < -4.0) discard; + float B = exp(A) * inColor.a; + return vec4(inColor.rgb, B); +} + +void main() { + gl_FragColor = gaussianColor(vColor); +} +`; + +export { vertexShaderSource, fragmentShaderSource }; diff --git a/packages/three/src/lib/splats/loaders/PlySplatLoader.ts b/packages/three/src/lib/splats/loaders/PlySplatLoader.ts new file mode 100644 index 000000000..decda8ce8 --- /dev/null +++ b/packages/three/src/lib/splats/loaders/PlySplatLoader.ts @@ -0,0 +1,537 @@ +/** + * This file is part of mapbox-3d-tiles. + * Copyright (c) 2024 Jianshun Yang + * Licensed under the MIT License. + * Source: https://github.com/yangjs6/mapbox-3d-tiles + */ + +import { Vector3, FileLoader, Loader, Quaternion, MathUtils } from 'three'; +import { GaussianSplattingMesh } from '../GaussianSplattingMesh'; +import { createYieldingScheduler, runCoroutineAsync } from '../../utils/coroutine'; + +const unpackUnorm = (value: number, bits: number): number => { + const t = (1 << bits) - 1; + return (value & t) / t; +}; + +const unpack111011 = (value: number, result: Vector3): void => { + result.x = unpackUnorm(value >>> 21, 11); + result.y = unpackUnorm(value >>> 11, 10); + result.z = unpackUnorm(value, 11); +}; + +const unpack8888 = (value: number, result: Uint8ClampedArray): void => { + result[0] = unpackUnorm(value >>> 24, 8) * 255; + result[1] = unpackUnorm(value >>> 16, 8) * 255; + result[2] = unpackUnorm(value >>> 8, 8) * 255; + result[3] = unpackUnorm(value, 8) * 255; +}; + +const unpackRot = (value: number, result: Quaternion): void => { + const norm = 1.0 / (Math.sqrt(2) * 0.5); + const a = (unpackUnorm(value >>> 20, 10) - 0.5) * norm; + const b = (unpackUnorm(value >>> 10, 10) - 0.5) * norm; + const c = (unpackUnorm(value, 10) - 0.5) * norm; + const m = Math.sqrt(1.0 - (a * a + b * b + c * c)); + + switch (value >>> 30) { + case 0: + result.set(m, a, b, c); + break; + case 1: + result.set(a, m, b, c); + break; + case 2: + result.set(a, b, m, c); + break; + case 3: + result.set(a, b, c, m); + break; + } +}; + +interface CompressedPLYChunk { + min: Vector3; + max: Vector3; + minScale: Vector3; + maxScale: Vector3; +} + +const enum PLYType { + FLOAT, + INT, + UINT, + DOUBLE, + UCHAR, + UNDEFINED, +} + +const enum PLYValue { + MIN_X, + MIN_Y, + MIN_Z, + MAX_X, + MAX_Y, + MAX_Z, + MIN_SCALE_X, + MIN_SCALE_Y, + MIN_SCALE_Z, + MAX_SCALE_X, + MAX_SCALE_Y, + MAX_SCALE_Z, + PACKED_POSITION, + PACKED_ROTATION, + PACKED_SCALE, + PACKED_COLOR, + X, + Y, + Z, + SCALE_0, + SCALE_1, + SCALE_2, + DIFFUSE_RED, + DIFFUSE_GREEN, + DIFFUSE_BLUE, + OPACITY, + F_DC_0, + F_DC_1, + F_DC_2, + F_DC_3, + ROT_0, + ROT_1, + ROT_2, + ROT_3, + UNDEFINED, +} + +export interface PlyProperty { + value: PLYValue; + type: PLYType; + offset: number; +} + +export interface PLYHeader { + vertexCount: number; + chunkCount: number; + rowVertexLength: number; + rowChunkLength: number; + vertexProperties: PlyProperty[]; + chunkProperties: PlyProperty[]; + dataView: DataView; + buffer: ArrayBuffer; +} + +/** + * Loader for .ply Gaussian Splatting files + */ +export class PlySplatLoader extends Loader { + private static readonly ROW_OUTPUT_LENGTH = 3 * 4 + 3 * 4 + 4 + 4; + private static readonly SH_C0 = 0.28209479177387814; + private static readonly PLY_CONVERSION_BATCH_SIZE = 32768; + + override load( + url: string, + onLoad: (mesh: GaussianSplattingMesh) => void, + onProgress?: (event: ProgressEvent) => void, + onError?: (event: unknown) => void + ): void { + const loader = new FileLoader(this.manager); + loader.setPath(this.path); + loader.setResponseType('arraybuffer'); + loader.setRequestHeader(this.requestHeader); + loader.setWithCredentials(this.withCredentials); + + loader.load( + url, + (buffer) => this.parse(buffer as ArrayBuffer, onLoad, onError), + onProgress, + onError + ); + } + + parse( + plyBuffer: ArrayBuffer, + onLoad: (mesh: GaussianSplattingMesh) => void, + onError?: (error: unknown) => void + ): void { + PlySplatLoader.ConvertPLYToSplatAsync(plyBuffer) + .then((splatsData) => { + const mesh = new GaussianSplattingMesh(); + return mesh.loadDataAsync(splatsData).then(() => onLoad(mesh)); + }) + .catch((error) => { + if (onError) { + onError(error); + } else { + console.error(error); + } + }); + } + + static async ConvertPLYToSplatAsync(data: ArrayBuffer): Promise { + return runCoroutineAsync( + PlySplatLoader.ConvertPLYToSplat(data, true), + createYieldingScheduler() + ); + } + + static *ConvertPLYToSplat( + data: ArrayBuffer, + useCoroutine = false + ): Generator { + const header = PlySplatLoader.ParseHeader(data); + if (!header) return data; + + const offset = { value: 0 }; + const compressedChunks = PlySplatLoader.getCompressedChunks(header, offset); + + for (let i = 0; i < header.vertexCount; i++) { + PlySplatLoader.getSplat(header, i, compressedChunks, offset); + if (useCoroutine && i % PlySplatLoader.PLY_CONVERSION_BATCH_SIZE === 0) { + yield; + } + } + + return header.buffer; + } + + private static getCompressedChunks( + header: PLYHeader, + offset: { value: number } + ): CompressedPLYChunk[] | null { + if (!header.chunkCount) return null; + + const { dataView, chunkProperties, rowChunkLength, chunkCount } = header; + const chunks: CompressedPLYChunk[] = []; + + for (let i = 0; i < chunkCount; i++) { + const chunk: CompressedPLYChunk = { + min: new Vector3(), + max: new Vector3(), + minScale: new Vector3(), + maxScale: new Vector3(), + }; + + for (const prop of chunkProperties) { + if (prop.type !== PLYType.FLOAT) continue; + const value = dataView.getFloat32(prop.offset + offset.value, true); + + switch (prop.value) { + case PLYValue.MIN_X: + chunk.min.x = value; + break; + case PLYValue.MIN_Y: + chunk.min.y = value; + break; + case PLYValue.MIN_Z: + chunk.min.z = value; + break; + case PLYValue.MAX_X: + chunk.max.x = value; + break; + case PLYValue.MAX_Y: + chunk.max.y = value; + break; + case PLYValue.MAX_Z: + chunk.max.z = value; + break; + case PLYValue.MIN_SCALE_X: + chunk.minScale.x = value; + break; + case PLYValue.MIN_SCALE_Y: + chunk.minScale.y = value; + break; + case PLYValue.MIN_SCALE_Z: + chunk.minScale.z = value; + break; + case PLYValue.MAX_SCALE_X: + chunk.maxScale.x = value; + break; + case PLYValue.MAX_SCALE_Y: + chunk.maxScale.y = value; + break; + case PLYValue.MAX_SCALE_Z: + chunk.maxScale.z = value; + break; + } + } + + chunks.push(chunk); + offset.value += rowChunkLength; + } + + return chunks; + } + + private static getSplat( + header: PLYHeader, + index: number, + compressedChunks: CompressedPLYChunk[] | null, + offset: { value: number } + ): void { + const q = new Quaternion(); + const temp3 = new Vector3(); + + const { buffer, dataView, vertexProperties, rowVertexLength } = header; + const rowLen = PlySplatLoader.ROW_OUTPUT_LENGTH; + + const position = new Float32Array(buffer, index * rowLen, 3); + const scale = new Float32Array(buffer, index * rowLen + 12, 3); + const rgba = new Uint8ClampedArray(buffer, index * rowLen + 24, 4); + const rot = new Uint8ClampedArray(buffer, index * rowLen + 28, 4); + + const chunkIndex = index >> 8; + let r0 = 255, + r1 = 0, + r2 = 0, + r3 = 0; + + for (const prop of vertexProperties) { + let value: number; + switch (prop.type) { + case PLYType.FLOAT: + value = dataView.getFloat32(offset.value + prop.offset, true); + break; + case PLYType.INT: + value = dataView.getInt32(offset.value + prop.offset, true); + break; + case PLYType.UINT: + value = dataView.getUint32(offset.value + prop.offset, true); + break; + case PLYType.DOUBLE: + value = dataView.getFloat64(offset.value + prop.offset, true); + break; + case PLYType.UCHAR: + value = dataView.getUint8(offset.value + prop.offset); + break; + default: + continue; + } + + const chunk = compressedChunks?.[chunkIndex]; + + switch (prop.value) { + case PLYValue.PACKED_POSITION: + unpack111011(value, temp3); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + position[0] = MathUtils.lerp(chunk!.min.x, chunk!.max.x, temp3.x); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + position[1] = -MathUtils.lerp(chunk!.min.y, chunk!.max.y, temp3.y); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + position[2] = MathUtils.lerp(chunk!.min.z, chunk!.max.z, temp3.z); + break; + case PLYValue.PACKED_ROTATION: + unpackRot(value, q); + r0 = q.w; + r1 = q.z; + r2 = q.y; + r3 = q.x; + break; + case PLYValue.PACKED_SCALE: + unpack111011(value, temp3); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + scale[0] = Math.exp(MathUtils.lerp(chunk!.minScale.x, chunk!.maxScale.x, temp3.x)); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + scale[1] = Math.exp(MathUtils.lerp(chunk!.minScale.y, chunk!.maxScale.y, temp3.y)); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + scale[2] = Math.exp(MathUtils.lerp(chunk!.minScale.z, chunk!.maxScale.z, temp3.z)); + break; + case PLYValue.PACKED_COLOR: + unpack8888(value, rgba); + break; + case PLYValue.X: + position[0] = value; + break; + case PLYValue.Y: + position[1] = value; + break; + case PLYValue.Z: + position[2] = value; + break; + case PLYValue.SCALE_0: + scale[0] = Math.exp(value); + break; + case PLYValue.SCALE_1: + scale[1] = Math.exp(value); + break; + case PLYValue.SCALE_2: + scale[2] = Math.exp(value); + break; + case PLYValue.DIFFUSE_RED: + rgba[0] = value; + break; + case PLYValue.DIFFUSE_GREEN: + rgba[1] = value; + break; + case PLYValue.DIFFUSE_BLUE: + rgba[2] = value; + break; + case PLYValue.F_DC_0: + rgba[0] = (0.5 + PlySplatLoader.SH_C0 * value) * 255; + break; + case PLYValue.F_DC_1: + rgba[1] = (0.5 + PlySplatLoader.SH_C0 * value) * 255; + break; + case PLYValue.F_DC_2: + rgba[2] = (0.5 + PlySplatLoader.SH_C0 * value) * 255; + break; + case PLYValue.F_DC_3: + rgba[3] = (0.5 + PlySplatLoader.SH_C0 * value) * 255; + break; + case PLYValue.OPACITY: + rgba[3] = (1 / (1 + Math.exp(-value))) * 255; + break; + case PLYValue.ROT_0: + r0 = value; + break; + case PLYValue.ROT_1: + r1 = value; + break; + case PLYValue.ROT_2: + r2 = value; + break; + case PLYValue.ROT_3: + r3 = value; + break; + } + } + + q.set(r1, r2, r3, r0).normalize(); + rot[0] = q.w * 128 + 128; + rot[1] = q.x * 128 + 128; + rot[2] = q.y * 128 + 128; + rot[3] = q.z * 128 + 128; + offset.value += rowVertexLength; + } + + private static typeNameToEnum(name: string): PLYType { + switch (name) { + case 'float': + return PLYType.FLOAT; + case 'int': + return PLYType.INT; + case 'uint': + return PLYType.UINT; + case 'double': + return PLYType.DOUBLE; + case 'uchar': + return PLYType.UCHAR; + default: + return PLYType.UNDEFINED; + } + } + + private static valueNameToEnum(name: string): PLYValue { + const map: Record = { + min_x: PLYValue.MIN_X, + min_y: PLYValue.MIN_Y, + min_z: PLYValue.MIN_Z, + max_x: PLYValue.MAX_X, + max_y: PLYValue.MAX_Y, + max_z: PLYValue.MAX_Z, + min_scale_x: PLYValue.MIN_SCALE_X, + min_scale_y: PLYValue.MIN_SCALE_Y, + min_scale_z: PLYValue.MIN_SCALE_Z, + max_scale_x: PLYValue.MAX_SCALE_X, + max_scale_y: PLYValue.MAX_SCALE_Y, + max_scale_z: PLYValue.MAX_SCALE_Z, + packed_position: PLYValue.PACKED_POSITION, + packed_rotation: PLYValue.PACKED_ROTATION, + packed_scale: PLYValue.PACKED_SCALE, + packed_color: PLYValue.PACKED_COLOR, + x: PLYValue.X, + y: PLYValue.Y, + z: PLYValue.Z, + scale_0: PLYValue.SCALE_0, + scale_1: PLYValue.SCALE_1, + scale_2: PLYValue.SCALE_2, + diffuse_red: PLYValue.DIFFUSE_RED, + red: PLYValue.DIFFUSE_RED, + diffuse_green: PLYValue.DIFFUSE_GREEN, + green: PLYValue.DIFFUSE_GREEN, + diffuse_blue: PLYValue.DIFFUSE_BLUE, + blue: PLYValue.DIFFUSE_BLUE, + f_dc_0: PLYValue.F_DC_0, + f_dc_1: PLYValue.F_DC_1, + f_dc_2: PLYValue.F_DC_2, + f_dc_3: PLYValue.F_DC_3, + opacity: PLYValue.OPACITY, + rot_0: PLYValue.ROT_0, + rot_1: PLYValue.ROT_1, + rot_2: PLYValue.ROT_2, + rot_3: PLYValue.ROT_3, + }; + return map[name] ?? PLYValue.UNDEFINED; + } + + static ParseHeader(data: ArrayBuffer): PLYHeader | null { + const ubuf = new Uint8Array(data); + const headerText = new TextDecoder().decode(ubuf.slice(0, 1024 * 10)); + const headerEnd = 'end_header\n'; + const headerEndIndex = headerText.indexOf(headerEnd); + + if (headerEndIndex < 0) return null; + + const vertexMatch = /element vertex (\d+)\n/.exec(headerText); + if (!vertexMatch) return null; + const vertexCount = parseInt(vertexMatch[1]); + + const chunkMatch = /element chunk (\d+)\n/.exec(headerText); + const chunkCount = chunkMatch ? parseInt(chunkMatch[1]) : 0; + + const offsets: Record = { + double: 8, + int: 4, + uint: 4, + float: 4, + short: 2, + ushort: 2, + uchar: 1, + list: 0, + }; + + const enum ElementMode { + Vertex, + Chunk, + } + let mode = ElementMode.Chunk; + let rowVertexOffset = 0; + let rowChunkOffset = 0; + + const vertexProperties: PlyProperty[] = []; + const chunkProperties: PlyProperty[] = []; + + for (const line of headerText.slice(0, headerEndIndex).split('\n')) { + if (line.startsWith('property ')) { + const [, typeName, name] = line.split(' '); + const property: PlyProperty = { + value: PlySplatLoader.valueNameToEnum(name), + type: PlySplatLoader.typeNameToEnum(typeName), + offset: mode === ElementMode.Chunk ? rowChunkOffset : rowVertexOffset, + }; + + if (mode === ElementMode.Chunk) { + chunkProperties.push(property); + rowChunkOffset += offsets[typeName] ?? 0; + } else { + vertexProperties.push(property); + rowVertexOffset += offsets[typeName] ?? 0; + } + } else if (line.startsWith('element ')) { + const [, type] = line.split(' '); + mode = type === 'chunk' ? ElementMode.Chunk : ElementMode.Vertex; + } + } + + return { + vertexCount, + chunkCount, + rowVertexLength: rowVertexOffset, + rowChunkLength: rowChunkOffset, + vertexProperties, + chunkProperties, + dataView: new DataView(data, headerEndIndex + headerEnd.length), + buffer: new ArrayBuffer(PlySplatLoader.ROW_OUTPUT_LENGTH * vertexCount), + }; + } +} diff --git a/packages/three/src/lib/splats/loaders/SplatLoader.ts b/packages/three/src/lib/splats/loaders/SplatLoader.ts new file mode 100644 index 000000000..3fb78f604 --- /dev/null +++ b/packages/three/src/lib/splats/loaders/SplatLoader.ts @@ -0,0 +1,52 @@ +/** + * This file is part of mapbox-3d-tiles. + * Copyright (c) 2024 Jianshun Yang + * Licensed under the MIT License. + * Source: https://github.com/yangjs6/mapbox-3d-tiles + */ + +import { FileLoader, Loader } from 'three'; +import { GaussianSplattingMesh } from '../GaussianSplattingMesh'; + +/** + * Loader for .splat Gaussian Splatting files + */ +export class SplatLoader extends Loader { + override load( + url: string, + onLoad: (mesh: GaussianSplattingMesh) => void, + onProgress?: (event: ProgressEvent) => void, + onError?: (event: unknown) => void + ): void { + const loader = new FileLoader(this.manager); + loader.setPath(this.path); + loader.setResponseType('arraybuffer'); + loader.setRequestHeader(this.requestHeader); + loader.setWithCredentials(this.withCredentials); + + loader.load( + url, + (buffer) => this.parse(buffer as ArrayBuffer, onLoad, onError), + onProgress, + onError + ); + } + + parse( + buffer: ArrayBuffer, + onLoad: (mesh: GaussianSplattingMesh) => void, + onError?: (error: unknown) => void + ): void { + const mesh = new GaussianSplattingMesh(); + mesh + .loadDataAsync(buffer) + .then(() => onLoad(mesh)) + .catch((error) => { + if (onError) { + onError(error); + } else { + console.error(error); + } + }); + } +} diff --git a/packages/three/src/lib/utils/coroutine.ts b/packages/three/src/lib/utils/coroutine.ts new file mode 100644 index 000000000..3aa8baf82 --- /dev/null +++ b/packages/three/src/lib/utils/coroutine.ts @@ -0,0 +1,121 @@ +/** + * Derived from mapbox-3d-tiles by Jianshun Yang (MIT License) + * https://github.com/yangjs6/mapbox-3d-tiles + * + * Coroutine utilities for non-blocking async operations using generators. + * Allows long-running tasks to yield control periodically to prevent UI freezing. + */ + +export type Coroutine = Iterator & IterableIterator; +export type AsyncCoroutine = Iterator, T, void> & + IterableIterator>; +export type CoroutineStep = IteratorResult; +export type CoroutineScheduler = ( + coroutine: AsyncCoroutine, + onStep: (result: CoroutineStep) => void, + onError: (error: any) => void +) => void; + +export function inlineScheduler( + coroutine: AsyncCoroutine, + onStep: (result: CoroutineStep) => void, + onError: (error: any) => void +): void { + try { + const step = coroutine.next(); + + if (step.done || !step.value) { + onStep(step as CoroutineStep); + } else { + (step.value as Promise).then( + () => onStep({ done: step.done, value: undefined } as CoroutineStep), + onError + ); + } + } catch (error) { + onError(error); + } +} + +export function createYieldingScheduler(yieldAfterMs = 25): CoroutineScheduler { + let startTime: number | undefined; + + return (coroutine, onStep, onError) => { + const now = performance.now(); + + if (startTime === undefined || now - startTime > yieldAfterMs) { + startTime = now; + setTimeout(() => inlineScheduler(coroutine, onStep, onError), 0); + } else { + inlineScheduler(coroutine, onStep, onError); + } + }; +} + +export function runCoroutine( + coroutine: AsyncCoroutine, + scheduler: CoroutineScheduler, + onSuccess: (result: T) => void, + onError: (error: any) => void, + abortSignal?: AbortSignal +): void { + const resume = () => { + let shouldContinue: boolean | undefined; + + const onStep = (result: CoroutineStep) => { + if (result.done) { + onSuccess(result.value); + } else if (shouldContinue === undefined) { + shouldContinue = true; + } else { + resume(); + } + }; + + do { + shouldContinue = undefined; + + if (abortSignal?.aborted) { + onError(new DOMException('Aborted', 'AbortError')); + return; + } + + scheduler(coroutine, onStep, onError); + + if (shouldContinue === undefined) { + shouldContinue = false; + } + } while (shouldContinue); + }; + + resume(); +} + +export function runCoroutineSync(coroutine: Coroutine, abortSignal?: AbortSignal): T { + let result: T | undefined; + + runCoroutine( + coroutine, + inlineScheduler, + (r) => { + result = r; + }, + (e) => { + throw e; + }, + abortSignal + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return result!; +} + +export function runCoroutineAsync( + coroutine: AsyncCoroutine, + scheduler: CoroutineScheduler, + abortSignal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + runCoroutine(coroutine, scheduler, resolve, reject, abortSignal); + }); +} diff --git a/packages/three/tsconfig.json b/packages/three/tsconfig.json new file mode 100644 index 000000000..d91fb457f --- /dev/null +++ b/packages/three/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "types": ["vite/client"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.storybook.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/packages/three/tsconfig.lib.json b/packages/three/tsconfig.lib.json new file mode 100644 index 000000000..71e986c13 --- /dev/null +++ b/packages/three/tsconfig.lib.json @@ -0,0 +1,27 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "vite/client" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx", + "**/*.stories.ts", + "**/*.stories.js", + "**/*.stories.jsx", + "**/*.stories.tsx" + ], + "include": ["src/**/*", "../react-maplibre/src/**/*"] +} diff --git a/packages/three/tsconfig.storybook.json b/packages/three/tsconfig.storybook.json new file mode 100644 index 000000000..eb2f90eb6 --- /dev/null +++ b/packages/three/tsconfig.storybook.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "outDir": "" + }, + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.jsx", + "src/**/*.test.js" + ], + "include": ["src/**/*", "../react-maplibre/src/**/*"], + "files": [ + "../../node_modules/@nx/react/typings/styled-jsx.d.ts", + "../../node_modules/@nx/react/typings/cssmodule.d.ts", + "../../node_modules/@nx/react/typings/image.d.ts" + ] +} diff --git a/packages/three/vite.config.ts b/packages/three/vite.config.ts new file mode 100644 index 000000000..9bd3e672f --- /dev/null +++ b/packages/three/vite.config.ts @@ -0,0 +1,49 @@ +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import dts from 'vite-plugin-dts'; +import * as path from 'path'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/three', + plugins: [ + react(), + nxViteTsPaths(), + nxCopyAssetsPlugin(['*.md']), + dts({ + entryRoot: 'src', + tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'), + pathsToAliases: false, + }), + ], + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + // Configuration for building your library. + // See: https://vitejs.dev/guide/build.html#library-mode + build: { + outDir: '../../dist/packages/three', + emptyOutDir: true, + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: 'src/index.ts', + name: '@mapcomponents/three', + fileName: 'index', + // Change this to the formats you want to support. + // Don't forget to update your package.json as well. + formats: ['es' as const], + }, + rollupOptions: { + // External packages that should not be bundled into your library. + external: ['react', 'react-dom', 'react/jsx-runtime'], + }, + }, +})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e42ee07ee..22d81f155 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,13 +13,13 @@ importers: version: 7.28.0(@babel/core@7.28.3) '@cypress/react': specifier: ^9.0.1 - version: 9.0.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(cypress@14.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 9.0.1(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(cypress@14.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@eslint/eslintrc': specifier: ^3.3.1 version: 3.3.1 '@storybook/addon-docs': specifier: ^9.1.4 - version: 9.1.4(@types/react@19.1.8)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) + version: 9.1.4(@types/react@19.1.12)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) '@storybook/addons': specifier: ^7.6.17 version: 7.6.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -39,10 +39,10 @@ importers: specifier: ^16.3.0 version: 16.3.0 react: - specifier: 19.1.0 + specifier: ^19.1.0 version: 19.1.0 react-dom: - specifier: 19.1.0 + specifier: ^19.1.0 version: 19.1.0(react@19.1.0) devDependencies: '@babel/core': @@ -80,7 +80,7 @@ importers: version: 21.2.2(@babel/traverse@7.28.3)(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/helpers@0.5.17)(@zkochan/js-yaml@0.0.7)(esbuild@0.25.9)(eslint@9.34.0(jiti@2.4.2))(nx@21.3.10(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)(webpack@5.101.3(@swc/core@1.12.14(@swc/helpers@0.5.17))(esbuild@0.25.9)) '@nx/storybook': specifier: 21.2.2 - version: 21.2.2(@babel/traverse@7.28.3)(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17))(@zkochan/js-yaml@0.0.7)(cypress@14.5.4)(eslint@9.34.0(jiti@2.4.2))(nx@21.3.10(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17)))(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3) + version: 21.2.2(@babel/traverse@7.28.3)(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17))(@zkochan/js-yaml@0.0.7)(cypress@14.5.4)(eslint@9.34.0(jiti@2.4.2))(nx@21.3.10(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17)))(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3) '@nx/vite': specifier: 21.3.10 version: 21.3.10(@babel/traverse@7.28.3)(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17))(nx@21.3.10(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17)))(typescript@5.8.3)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4) @@ -92,13 +92,16 @@ importers: version: 21.4.1(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17)) '@storybook/addon-links': specifier: ^9.1.4 - version: 9.1.4(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) + version: 9.1.4(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) + '@storybook/react': + specifier: ^9.1.4 + version: 9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3) '@storybook/react-vite': specifier: 9.1.1 - version: 9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + version: 9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) '@storybook/testing-react': specifier: ^2.0.1 - version: 2.0.1(@storybook/client-logger@7.6.17)(@storybook/preview-api@7.6.17)(@storybook/react@9.1.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3))(@storybook/types@7.6.17)(react@19.1.0) + version: 2.0.1(@storybook/client-logger@7.6.17)(@storybook/preview-api@7.6.17)(@storybook/react@9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3))(@storybook/types@7.6.17)(react@19.1.0) '@swc-node/register': specifier: ~1.10.10 version: 1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3) @@ -112,20 +115,20 @@ importers: specifier: ~0.5.17 version: 0.5.17 '@testing-library/dom': - specifier: 10.4.0 - version: 10.4.0 + specifier: ^10.4.1 + version: 10.4.1 '@testing-library/react': - specifier: 16.3.0 - version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@types/node': specifier: ^24.3.1 version: 24.3.1 '@types/react': - specifier: 19.1.8 - version: 19.1.8 + specifier: ^19.1.12 + version: 19.1.12 '@types/react-dom': - specifier: 19.1.6 - version: 19.1.6(@types/react@19.1.8) + specifier: ^19.1.9 + version: 19.1.9(@types/react@19.1.12) '@vitejs/plugin-react': specifier: ^4.7.0 version: 4.7.0(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) @@ -160,8 +163,8 @@ importers: specifier: 5.2.0 version: 5.2.0(eslint@9.34.0(jiti@2.4.2)) eslint-plugin-storybook: - specifier: 9.1.1 - version: 9.1.1(eslint@9.34.0(jiti@2.4.2))(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3) + specifier: ^9.1.4 + version: 9.1.4(eslint@9.34.0(jiti@2.4.2))(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3) html-webpack-plugin: specifier: ^5.6.4 version: 5.6.4(@rspack/core@1.5.2(@swc/helpers@0.5.17))(webpack@5.101.3(@swc/core@1.12.14(@swc/helpers@0.5.17))(esbuild@0.25.9)) @@ -179,7 +182,7 @@ importers: version: 3.6.2 storybook: specifier: 9.1.1 - version: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + version: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) syncpack: specifier: ^13.0.4 version: 13.0.4(typescript@5.8.3) @@ -225,8 +228,8 @@ importers: specifier: ^9.1.14 version: 9.1.14(@deck.gl/core@9.1.14)(@luma.gl/core@9.1.9) '@mapcomponents/react-maplibre': - specifier: workspace:^ - version: link:../react-maplibre + specifier: 1.6.4 + version: 1.6.4(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mui/icons-material': specifier: ^7.3.2 version: 7.3.2(@mui/material@7.3.2(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.12)(react@19.1.0) @@ -253,8 +256,8 @@ importers: specifier: ^11.14.1 version: 11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0) '@mapcomponents/react-maplibre': - specifier: workspace:^ - version: link:../react-maplibre + specifier: 1.6.4 + version: 1.6.4(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mui/icons-material': specifier: ^7.3.2 version: 7.3.2(@mui/material@7.3.2(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.12)(react@19.1.0) @@ -379,8 +382,8 @@ importers: specifier: ^3.0.2 version: 3.0.2 maplibre-gl: - specifier: 5.6.0 - version: 5.6.0 + specifier: ^5.7.0 + version: 5.7.1 osm2geojson-lite: specifier: ^1.1.2 version: 1.1.2 @@ -402,9 +405,6 @@ importers: redux-thunk: specifier: ^3.1.0 version: 3.1.0(redux@5.0.1) - three: - specifier: ^0.179.1 - version: 0.179.1 topojson-client: specifier: ^3.1.0 version: 3.1.0 @@ -457,9 +457,6 @@ importers: '@types/sql.js': specifier: ^1.4.9 version: 1.4.9 - '@types/three': - specifier: ^0.179.0 - version: 0.179.0 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -560,276 +557,24 @@ importers: specifier: ^9.5.4 version: 9.5.4(typescript@5.9.2)(webpack@5.101.3(@swc/core@1.12.14(@swc/helpers@0.5.17))(esbuild@0.25.9)) - packages/react-maplibre/dist: + packages/three: dependencies: - '@dnd-kit/core': - specifier: ^6.3.1 - version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@dnd-kit/modifiers': - specifier: ^9.0.0 - version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) - '@dnd-kit/sortable': - specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) - '@dnd-kit/utilities': - specifier: ^3.2.2 - version: 3.2.2(react@19.1.0) - '@emotion/css': - specifier: ^11.13.5 - version: 11.13.5 - '@emotion/react': - specifier: ^11.14.0 - version: 11.14.0(@types/react@19.1.12)(react@19.1.0) - '@emotion/styled': - specifier: ^11.14.1 - version: 11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0) - '@mapbox/mapbox-gl-draw': - specifier: 1.4.3 - version: 1.4.3 - '@mapbox/mapbox-gl-sync-move': - specifier: ^0.3.1 - version: 0.3.1 - '@mui/icons-material': - specifier: ^7.3.2 - version: 7.3.2(@mui/material@7.3.2(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.12)(react@19.1.0) + '@mapcomponents/react-maplibre': + specifier: 1.6.4 + version: 1.6.4(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mui/material': specifier: ^7.3.2 version: 7.3.2(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@mui/system': - specifier: ^7.3.2 - version: 7.3.2(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0) - '@reduxjs/toolkit': - specifier: ^2.9.0 - version: 2.9.0(react-redux@9.2.0(@types/react@19.1.12)(react@19.1.0)(redux@5.0.1))(react@19.1.0) - '@testing-library/dom': - specifier: ^10.4.1 - version: 10.4.1 - '@testing-library/jest-dom': - specifier: ^6.8.0 - version: 6.8.0 - '@testing-library/user-event': - specifier: ^14.6.1 - version: 14.6.1(@testing-library/dom@10.4.1) - '@tmcw/togeojson': - specifier: ^7.1.2 - version: 7.1.2 - '@turf/helpers': - specifier: ^7.2.0 - version: 7.2.0 - '@turf/turf': - specifier: ^7.2.0 - version: 7.2.0 - '@types/d3': - specifier: ^7.4.3 - version: 7.4.3 - '@types/geojson': - specifier: ^7946.0.16 - version: 7946.0.16 - '@types/react-color': - specifier: ^3.0.13 - version: 3.0.13(@types/react@19.1.12) - '@types/topojson-client': - specifier: ^3.1.5 - version: 3.1.5 - '@types/topojson-specification': - specifier: ^1.0.5 - version: 1.0.5 - '@xmldom/xmldom': - specifier: ^0.9.8 - version: 0.9.8 - camelcase: - specifier: ^8.0.0 - version: 8.0.0 - csv2geojson: - specifier: ^5.1.2 - version: 5.1.2 - d3: - specifier: ^7.9.0 - version: 7.9.0 - jspdf: - specifier: ^3.0.2 - version: 3.0.2 maplibre-gl: - specifier: 5.6.0 - version: 5.6.0 - osm2geojson-lite: - specifier: ^1.1.2 - version: 1.1.2 - pako: - specifier: ^2.1.0 - version: 2.1.0 - react-color: - specifier: ^2.19.3 - version: 2.19.3(react@19.1.0) - react-moveable: - specifier: ^0.56.0 - version: 0.56.0 - react-redux: - specifier: ^9.2.0 - version: 9.2.0(@types/react@19.1.12)(react@19.1.0)(redux@5.0.1) - redux: - specifier: ^5.0.1 - version: 5.0.1 - redux-thunk: - specifier: ^3.1.0 - version: 3.1.0(redux@5.0.1) + specifier: ^5.7.0 + version: 5.7.1 three: - specifier: ^0.179.1 - version: 0.179.1 - topojson-client: - specifier: ^3.1.0 - version: 3.1.0 - uuid: - specifier: ^11.1.0 - version: 11.1.0 - wms-capabilities: - specifier: ^0.6.0 - version: 0.6.0 + specifier: ^0.182.0 + version: 0.182.0 devDependencies: - '@testing-library/react': - specifier: ^16.3.0 - version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@types/chai': - specifier: ^5.2.2 - version: 5.2.2 - '@types/elasticlunr': - specifier: ^0.9.9 - version: 0.9.9 - '@types/enzyme': - specifier: ^3.10.19 - version: 3.10.19 - '@types/expect': - specifier: ^24.3.2 - version: 24.3.2 - '@types/jest': - specifier: ^30.0.0 - version: 30.0.0 - '@types/mapbox__mapbox-gl-draw': - specifier: ^1.4.9 - version: 1.4.9 - '@types/mapbox__point-geometry': - specifier: ^0.1.4 - version: 0.1.4 - '@types/mapbox__vector-tile': - specifier: ^2.0.0 - version: 2.0.0 - '@types/mocha': - specifier: ^10.0.10 - version: 10.0.10 - '@types/pako': - specifier: ^2.0.4 - version: 2.0.4 - '@types/react': - specifier: ^19.1.12 - version: 19.1.12 - '@types/react-dom': - specifier: ^19.1.9 - version: 19.1.9(@types/react@19.1.12) - '@types/sql.js': - specifier: ^1.4.9 - version: 1.4.9 '@types/three': - specifier: ^0.179.0 - version: 0.179.0 - '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 - '@typescript-eslint/eslint-plugin': - specifier: ^8.42.0 - version: 8.42.0(@typescript-eslint/parser@8.42.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) - '@typescript-eslint/parser': - specifier: ^8.42.0 - version: 8.42.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) - avj: - specifier: ^0.0.0 - version: 0.0.0 - babel-jest: - specifier: ^30.1.2 - version: 30.1.2(@babel/core@7.28.3) - babel-loader: - specifier: ^10.0.0 - version: 10.0.0(@babel/core@7.28.3)(webpack@5.101.3(@swc/core@1.12.14(@swc/helpers@0.5.17))(esbuild@0.25.9)) - babel-plugin-inline-react-svg: - specifier: ^2.0.2 - version: 2.0.2(@babel/core@7.28.3) - babel-plugin-styled-components: - specifier: ^2.1.4 - version: 2.1.4(@babel/core@7.28.3)(styled-components@6.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) - babel-preset-react-app: - specifier: ^10.1.0 - version: 10.1.0 - chai: - specifier: ^6.0.1 - version: 6.0.1 - elasticlunr: - specifier: ^0.9.5 - version: 0.9.5 - eslint-plugin-storybook: - specifier: ^9.1.4 - version: 9.1.4(eslint@9.34.0(jiti@2.4.2))(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.9.2) - glob: - specifier: ^11.0.3 - version: 11.0.3 - jest: - specifier: 30.0.5 - version: 30.0.5(@types/node@24.3.1)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.25.9))(ts-node@10.9.2(@swc/core@1.12.14(@swc/helpers@0.5.17))(@types/node@24.3.1)(typescript@5.9.2)) - jest-circus: - specifier: 30.0.5 - version: 30.0.5(babel-plugin-macros@3.1.0) - jest-environment-jsdom: - specifier: ^30.1.2 - version: 30.1.2 - jest-enzyme: - specifier: ^7.1.2 - version: 7.1.2(enzyme@3.11.0)(jest@30.0.5(@types/node@24.3.1)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.25.9))(ts-node@10.9.2(@swc/core@1.12.14(@swc/helpers@0.5.17))(@types/node@24.3.1)(typescript@5.9.2)))(react@19.1.0) - jest-resolve: - specifier: 30.0.5 - version: 30.0.5 - jest-watch-typeahead: - specifier: 3.0.1 - version: 3.0.1(jest@30.0.5(@types/node@24.3.1)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.25.9))(ts-node@10.9.2(@swc/core@1.12.14(@swc/helpers@0.5.17))(@types/node@24.3.1)(typescript@5.9.2))) - mocha: - specifier: ^11.7.2 - version: 11.7.2 - node-fetch: - specifier: ^3.3.2 - version: 3.3.2 - path-browserify: - specifier: ^1.0.1 - version: 1.0.1 - postcss: - specifier: ^8.5.6 - version: 8.5.6 - react: - specifier: ^19.1.0 - version: 19.1.0 - react-app-polyfill: - specifier: ^3.0.0 - version: 3.0.0 - react-dev-utils: - specifier: ^12.0.1 - version: 12.0.1(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)(webpack@5.101.3(@swc/core@1.12.14(@swc/helpers@0.5.17))(esbuild@0.25.9)) - react-dom: - specifier: ^19.1.0 - version: 19.1.0(react@19.1.0) - react-draggable: - specifier: ^4.5.0 - version: 4.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react-i18next: - specifier: ^15.7.3 - version: 15.7.3(i18next@25.5.1(typescript@5.9.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2) - showdown: - specifier: ^2.1.0 - version: 2.1.0 - sql.js: - specifier: ^1.13.0 - version: 1.13.0 - ts-jest: - specifier: ^29.4.1 - version: 29.4.1(@babel/core@7.28.3)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.3))(esbuild@0.25.9)(jest-util@30.0.5)(jest@30.0.5(@types/node@24.3.1)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.25.9))(ts-node@10.9.2(@swc/core@1.12.14(@swc/helpers@0.5.17))(@types/node@24.3.1)(typescript@5.9.2)))(typescript@5.9.2) - ts-loader: - specifier: ^9.5.4 - version: 9.5.4(typescript@5.9.2)(webpack@5.101.3(@swc/core@1.12.14(@swc/helpers@0.5.17))(esbuild@0.25.9)) + specifier: ^0.182.0 + version: 0.182.0 packages: @@ -2617,6 +2362,12 @@ packages: resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} engines: {node: '>=6.0.0'} + '@mapcomponents/react-maplibre@1.6.4': + resolution: {integrity: sha512-DLIG5ZHXu92r4Pt3+JaTsgjemhZP+QOPtKShIT+lgscQ6BfNq5IpOA/PXkAreNE3AcsHkNzcmf/G2GPnYB+v0w==} + peerDependencies: + react: ^19.1.0 + react-dom: ^19.1.0 + '@maplibre/maplibre-gl-style-spec@23.3.0': resolution: {integrity: sha512-IGJtuBbaGzOUgODdBRg66p8stnwj9iDXkgbYKoYcNiiQmaez5WVRfXm4b03MCDwmZyX93csbfHFWEJJYHnn5oA==} hasBin: true @@ -3926,13 +3677,6 @@ packages: react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta storybook: ^9.1.4 - '@storybook/react-dom-shim@9.1.5': - resolution: {integrity: sha512-blSq9uzSYnfgEYPHYKgM5O14n8hbXNiXx2GiVJyDSg8QPNicbsBg+lCb1TC7/USfV26pNZr/lGNNKGkcCEN6Gw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^9.1.5 - '@storybook/react-vite@9.1.1': resolution: {integrity: sha512-9rMjAqgrcuVF/GS171fYSLuUs5QC3e0WPpIm2JOP7Z9qWctM1ApVb9UCYY7ZNl9Gc3kvjKsK5J1+A4Zw4a2+ag==} engines: {node: '>=20.0.0'} @@ -3966,18 +3710,6 @@ packages: typescript: optional: true - '@storybook/react@9.1.5': - resolution: {integrity: sha512-fBVP7Go09gzpImtaMcZ2DipLEWdWeTmz7BrACr3Z8uCyKcoH8/d1Wv0JgIiBo1UKDh5ZgYx5pLafaPNqmVAepg==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^9.1.5 - typescript: '>= 4.9.x' - peerDependenciesMeta: - typescript: - optional: true - '@storybook/router@7.6.17': resolution: {integrity: sha512-GnyC0j6Wi5hT4qRhSyT8NPtJfGmf82uZw97LQRWeyYu5gWEshUdM7aj40XlNiScd5cZDp0owO1idduVF2k2l2A==} @@ -4196,10 +3928,6 @@ packages: peerDependencies: react: ^18 || ^19 - '@testing-library/dom@10.4.0': - resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} - engines: {node: '>=18'} - '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -4883,11 +4611,6 @@ packages: peerDependencies: '@types/react': '*' - '@types/react-dom@19.1.6': - resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} - peerDependencies: - '@types/react': ^19.0.0 - '@types/react-dom@19.1.9': resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} peerDependencies: @@ -4904,9 +4627,6 @@ packages: '@types/react@19.1.12': resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} - '@types/react@19.1.8': - resolution: {integrity: sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==} - '@types/reactcss@1.2.13': resolution: {integrity: sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==} peerDependencies: @@ -4966,8 +4686,8 @@ packages: '@types/tapable@1.0.12': resolution: {integrity: sha512-bTHG8fcxEqv1M9+TD14P8ok8hjxoOCkfKc8XXLaaD05kI7ohpeI956jtDOD3XHKBQrlyPughUtzm1jtVhHpA5Q==} - '@types/three@0.179.0': - resolution: {integrity: sha512-VgbFG2Pgsm84BqdegZzr7w2aKbQxmgzIu4Dy7/75ygiD/0P68LKmp5ie08KMPNqGTQwIge8s6D1guZf1RnZE0A==} + '@types/three@0.182.0': + resolution: {integrity: sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==} '@types/topojson-client@3.1.5': resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==} @@ -7108,13 +6828,6 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-storybook@9.1.1: - resolution: {integrity: sha512-g4/i9yW6cl4TCEMzYyALNvO3d/jB6TDvSs/Pmye7dHDrra2B7dgZJGzmEWILD62brVrLVHNoXgy2dNPtx80kmw==} - engines: {node: '>=20.0.0'} - peerDependencies: - eslint: '>=8' - storybook: ^9.1.1 - eslint-plugin-storybook@9.1.4: resolution: {integrity: sha512-IiIqGFo524PDELajyDLMtceikHpDUKBF6QlH5oJECy+xV3e0DHJkcuyokwxWveb1yg7tHfTLimCKNix2ftRETg==} engines: {node: '>=20.0.0'} @@ -9533,6 +9246,7 @@ packages: osm2geojson-lite@1.1.2: resolution: {integrity: sha512-6s1uW548fdyLTJ4Cp/hQTKvdgCl/E8nUvBMEzUXnAJPHAFHoIhwMqZ3KGdph2A1g48rsCeA6gVnkPruWGiwupw==} hasBin: true + bundledDependencies: [] ospath@1.2.2: resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} @@ -11098,6 +10812,9 @@ packages: three@0.179.1: resolution: {integrity: sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw==} + three@0.182.0: + resolution: {integrity: sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==} + throttleit@1.0.1: resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} @@ -12971,14 +12688,14 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@cypress/react@9.0.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(cypress@14.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@cypress/react@9.0.1(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(cypress@14.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react-dom': 19.1.9(@types/react@19.1.12) cypress: 14.5.4 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.1.12 '@cypress/request@3.0.9': dependencies: @@ -14059,6 +13776,56 @@ snapshots: '@mapbox/whoots-js@3.1.0': {} + '@mapcomponents/react-maplibre@1.6.4(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/modifiers': 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/sortable': 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + '@emotion/css': 11.13.5 + '@emotion/react': 11.14.0(@types/react@19.1.12)(react@19.1.0) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0) + '@mapbox/mapbox-gl-draw': 1.4.3 + '@mapbox/mapbox-gl-sync-move': 0.3.1 + '@mui/icons-material': 7.3.2(@mui/material@7.3.2(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.12)(react@19.1.0) + '@mui/material': 7.3.2(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@mui/system': 7.3.2(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0))(@types/react@19.1.12)(react@19.1.0) + '@reduxjs/toolkit': 2.9.0(react-redux@9.2.0(@types/react@19.1.12)(react@19.1.0)(redux@5.0.1))(react@19.1.0) + '@testing-library/dom': 10.4.1 + '@testing-library/jest-dom': 6.8.0 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@tmcw/togeojson': 7.1.2 + '@turf/helpers': 7.2.0 + '@turf/turf': 7.2.0 + '@types/d3': 7.4.3 + '@types/geojson': 7946.0.16 + '@types/react-color': 3.0.13(@types/react@19.1.12) + '@types/topojson-client': 3.1.5 + '@types/topojson-specification': 1.0.5 + '@xmldom/xmldom': 0.9.8 + camelcase: 8.0.0 + csv2geojson: 5.1.2 + d3: 7.9.0 + jspdf: 3.0.2 + maplibre-gl: 5.6.0 + osm2geojson-lite: 1.1.2 + pako: 2.1.0 + react: 19.1.0 + react-color: 2.19.3(react@19.1.0) + react-dom: 19.1.0(react@19.1.0) + react-moveable: 0.56.0 + react-redux: 9.2.0(@types/react@19.1.12)(react@19.1.0)(redux@5.0.1) + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + three: 0.179.1 + topojson-client: 3.1.0 + uuid: 11.1.0 + wms-capabilities: 0.6.0 + transitivePeerDependencies: + - '@mui/material-pigment-css' + - '@types/react' + - supports-color + '@maplibre/maplibre-gl-style-spec@23.3.0': dependencies: '@mapbox/jsonlint-lines-primitives': 2.0.2 @@ -14095,10 +13862,10 @@ snapshots: dependencies: '@math.gl/core': 4.1.0 - '@mdx-js/react@3.1.1(@types/react@19.1.8)(react@19.1.0)': + '@mdx-js/react@3.1.1(@types/react@19.1.12)(react@19.1.0)': dependencies: '@types/mdx': 2.0.13 - '@types/react': 19.1.8 + '@types/react': 19.1.12 react: 19.1.0 '@microsoft/api-extractor-model@7.30.7(@types/node@24.3.1)': @@ -15368,7 +15135,7 @@ snapshots: - webpack - webpack-cli - '@nx/storybook@21.2.2(@babel/traverse@7.28.3)(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17))(@zkochan/js-yaml@0.0.7)(cypress@14.5.4)(eslint@9.34.0(jiti@2.4.2))(nx@21.3.10(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17)))(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3)': + '@nx/storybook@21.2.2(@babel/traverse@7.28.3)(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17))(@zkochan/js-yaml@0.0.7)(cypress@14.5.4)(eslint@9.34.0(jiti@2.4.2))(nx@21.3.10(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17)))(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3)': dependencies: '@nx/cypress': 21.2.2(@babel/traverse@7.28.3)(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17))(@zkochan/js-yaml@0.0.7)(cypress@14.5.4)(eslint@9.34.0(jiti@2.4.2))(nx@21.3.10(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17)))(typescript@5.8.3) '@nx/devkit': 21.2.2(nx@21.3.10(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17))) @@ -15376,7 +15143,7 @@ snapshots: '@nx/js': 21.2.2(@babel/traverse@7.28.3)(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17))(nx@21.3.10(@swc-node/register@1.10.10(@swc/core@1.12.14(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.12.14(@swc/helpers@0.5.17))) '@phenomnomnominal/tsquery': 5.0.1(typescript@5.8.3) semver: 7.7.2 - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) tslib: 2.8.1 transitivePeerDependencies: - '@babel/traverse' @@ -15820,23 +15587,23 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@storybook/addon-docs@9.1.4(@types/react@19.1.8)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': + '@storybook/addon-docs@9.1.4(@types/react@19.1.12)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': dependencies: - '@mdx-js/react': 3.1.1(@types/react@19.1.8)(react@19.1.0) - '@storybook/csf-plugin': 9.1.4(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) + '@mdx-js/react': 3.1.1(@types/react@19.1.12)(react@19.1.0) + '@storybook/csf-plugin': 9.1.4(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) '@storybook/icons': 1.4.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/react-dom-shim': 9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) + '@storybook/react-dom-shim': 9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-links@9.1.4(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': + '@storybook/addon-links@9.1.4(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': dependencies: '@storybook/global': 5.0.0 - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) optionalDependencies: react: 19.1.0 @@ -15849,10 +15616,10 @@ snapshots: - react - react-dom - '@storybook/builder-vite@9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))': + '@storybook/builder-vite@9.1.1(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: - '@storybook/csf-plugin': 9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + '@storybook/csf-plugin': 9.1.1(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) + storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) ts-dedent: 2.2.0 vite: 7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1) @@ -15873,14 +15640,14 @@ snapshots: dependencies: ts-dedent: 2.2.0 - '@storybook/csf-plugin@9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': + '@storybook/csf-plugin@9.1.1(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': dependencies: - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) unplugin: 1.16.1 - '@storybook/csf-plugin@9.1.4(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': + '@storybook/csf-plugin@9.1.4(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': dependencies: - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) unplugin: 1.16.1 '@storybook/csf@0.1.13': @@ -15931,17 +15698,11 @@ snapshots: ts-dedent: 2.2.0 util-deprecate: 1.0.2 - '@storybook/react-dom-shim@9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': - dependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) - - '@storybook/react-dom-shim@9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': + '@storybook/react-dom-shim@9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': dependencies: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) '@storybook/react-dom-shim@9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': dependencies: @@ -15949,25 +15710,19 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) - '@storybook/react-dom-shim@9.1.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))': - dependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) - - '@storybook/react-vite@9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))': + '@storybook/react-vite@9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.8.3)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) '@rollup/pluginutils': 5.3.0(rollup@4.50.0) - '@storybook/builder-vite': 9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) - '@storybook/react': 9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3) + '@storybook/builder-vite': 9.1.1(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + '@storybook/react': 9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3) find-up: 7.0.0 magic-string: 0.30.18 react: 19.1.0 react-docgen: 8.0.1 react-dom: 19.1.0(react@19.1.0) resolve: 1.22.10 - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) tsconfig-paths: 4.2.0 vite: 7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: @@ -15975,17 +15730,17 @@ snapshots: - supports-color - typescript - '@storybook/react@9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3)': + '@storybook/react@9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) + '@storybook/react-dom-shim': 9.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) optionalDependencies: typescript: 5.8.3 - '@storybook/react@9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.9.2)': + '@storybook/react@9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3)': dependencies: '@storybook/global': 5.0.0 '@storybook/react-dom-shim': 9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) @@ -15993,17 +15748,17 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) optionalDependencies: - typescript: 5.9.2 + typescript: 5.8.3 - '@storybook/react@9.1.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3)': + '@storybook/react@9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.9.2)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 9.1.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) + '@storybook/react-dom-shim': 9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) optionalDependencies: - typescript: 5.8.3 + typescript: 5.9.2 '@storybook/router@7.6.17': dependencies: @@ -16011,12 +15766,12 @@ snapshots: memoizerific: 1.11.3 qs: 6.14.0 - '@storybook/testing-react@2.0.1(@storybook/client-logger@7.6.17)(@storybook/preview-api@7.6.17)(@storybook/react@9.1.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3))(@storybook/types@7.6.17)(react@19.1.0)': + '@storybook/testing-react@2.0.1(@storybook/client-logger@7.6.17)(@storybook/preview-api@7.6.17)(@storybook/react@9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3))(@storybook/types@7.6.17)(react@19.1.0)': dependencies: '@storybook/client-logger': 7.6.17 '@storybook/csf': 0.1.13 '@storybook/preview-api': 7.6.17 - '@storybook/react': 9.1.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3) + '@storybook/react': 9.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3) '@storybook/types': 7.6.17 react: 19.1.0 @@ -16239,17 +15994,6 @@ snapshots: '@tanstack/query-core': 5.86.0 react: 19.1.0 - '@testing-library/dom@10.4.0': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.3 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - chalk: 4.1.2 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - pretty-format: 27.5.1 - '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -16270,16 +16014,6 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@babel/runtime': 7.28.3 - '@testing-library/dom': 10.4.0 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.28.3 @@ -16290,10 +16024,6 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': - dependencies: - '@testing-library/dom': 10.4.0 - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -17759,10 +17489,6 @@ snapshots: '@types/react': 19.1.12 '@types/reactcss': 1.2.13(@types/react@19.1.12) - '@types/react-dom@19.1.6(@types/react@19.1.8)': - dependencies: - '@types/react': 19.1.8 - '@types/react-dom@19.1.9(@types/react@19.1.12)': dependencies: '@types/react': 19.1.12 @@ -17781,10 +17507,6 @@ snapshots: dependencies: csstype: 3.1.3 - '@types/react@19.1.8': - dependencies: - csstype: 3.1.3 - '@types/reactcss@1.2.13(@types/react@19.1.12)': dependencies: '@types/react': 19.1.12 @@ -17841,7 +17563,7 @@ snapshots: '@types/tapable@1.0.12': {} - '@types/three@0.179.0': + '@types/three@0.182.0': dependencies: '@dimforge/rapier3d-compat': 0.12.0 '@tweenjs/tween.js': 23.1.3 @@ -20526,11 +20248,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@9.1.1(eslint@9.34.0(jiti@2.4.2))(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3): + eslint-plugin-storybook@9.1.4(eslint@9.34.0(jiti@2.4.2))(storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(typescript@5.8.3): dependencies: '@typescript-eslint/utils': 8.42.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.8.3) eslint: 9.34.0(jiti@2.4.2) - storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) + storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: - supports-color - typescript @@ -25330,30 +25052,6 @@ snapshots: store2@2.14.4: {} - storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)): - dependencies: - '@storybook/global': 5.0.0 - '@testing-library/jest-dom': 6.8.0 - '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)) - '@vitest/spy': 3.2.4 - better-opn: 3.0.2 - esbuild: 0.25.9 - esbuild-register: 3.6.0(esbuild@0.25.9) - recast: 0.23.11 - semver: 7.7.2 - ws: 8.18.3 - optionalDependencies: - prettier: 3.6.2 - transitivePeerDependencies: - - '@testing-library/dom' - - bufferutil - - msw - - supports-color - - utf-8-validate - - vite - storybook@9.1.1(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)): dependencies: '@storybook/global': 5.0.0 @@ -25710,6 +25408,8 @@ snapshots: three@0.179.1: {} + three@0.182.0: {} + throttleit@1.0.1: {} through@2.3.8: {} diff --git a/tsconfig.base.json b/tsconfig.base.json index 6a5ff57fb..461c3b199 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -27,7 +27,8 @@ "paths": { "@mapcomponents/deck-gl": ["packages/deck-gl/src/index.ts"], "@mapcomponents/ra-geospatial": ["packages/ra-geospatial/src/index.ts"], - "@mapcomponents/react-maplibre": ["packages/react-maplibre/src/index.ts"] + "@mapcomponents/react-maplibre": ["packages/react-maplibre/src/index.ts"], + "@mapcomponents/three": ["packages/three/src/index.ts"] } } }