@mapcomponents/three 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.babelrc +12 -0
- package/.storybook/main.ts +20 -0
- package/.storybook/preview.ts +0 -0
- package/README.md +54 -0
- package/cypress.config.ts +13 -0
- package/eslint.config.mjs +12 -0
- package/package.json +24 -0
- package/project.json +15 -0
- package/public/assets/3D/godzilla_simple.glb +0 -0
- package/public/assets/splats/output.splat +0 -0
- package/src/components/MlThreeModelLayer/MlThreeModelLayer.cy.tsx +63 -0
- package/src/components/MlThreeModelLayer/MlThreeModelLayer.meta.json +21 -0
- package/src/components/MlThreeModelLayer/MlThreeModelLayer.stories.tsx +161 -0
- package/src/components/MlThreeModelLayer/MlThreeModelLayer.tsx +153 -0
- package/src/components/MlThreeSplatLayer/MlThreeSplatLayer.cy.tsx +62 -0
- package/src/components/MlThreeSplatLayer/MlThreeSplatLayer.meta.json +21 -0
- package/src/components/MlThreeSplatLayer/MlThreeSplatLayer.stories.tsx +151 -0
- package/src/components/MlThreeSplatLayer/MlThreeSplatLayer.tsx +158 -0
- package/src/components/MlTransformControls.tsx +112 -0
- package/src/components/ThreeContext.tsx +26 -0
- package/src/components/ThreeObjectControls.tsx +197 -0
- package/src/components/ThreeProvider.tsx +149 -0
- package/src/cypress/support/commands.ts +1 -0
- package/src/cypress/support/component-index.html +13 -0
- package/src/cypress/support/component.ts +13 -0
- package/src/decorators/ThreejsContextDecorator.tsx +42 -0
- package/src/decorators/style.css +33 -0
- package/src/index.ts +7 -0
- package/src/lib/ThreejsSceneHelper.ts +250 -0
- package/src/lib/ThreejsSceneRenderer.ts +73 -0
- package/src/lib/ThreejsUtils.ts +62 -0
- package/src/lib/splats/GaussianSplattingMesh.ts +848 -0
- package/src/lib/splats/GaussianSplattingShaders.ts +266 -0
- package/src/lib/splats/loaders/PlySplatLoader.ts +537 -0
- package/src/lib/splats/loaders/SplatLoader.ts +52 -0
- package/src/lib/utils/coroutine.ts +121 -0
- package/tsconfig.json +21 -0
- package/tsconfig.lib.json +27 -0
- package/tsconfig.storybook.json +24 -0
- package/vite.config.ts +49 -0
package/.babelrc
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { StorybookConfig } from '@storybook/react-vite';
|
|
2
|
+
|
|
3
|
+
const config: StorybookConfig = {
|
|
4
|
+
stories: ['../src/**/*.@(mdx|stories.@(js|jsx|ts|tsx))'],
|
|
5
|
+
addons: [],
|
|
6
|
+
framework: {
|
|
7
|
+
name: '@storybook/react-vite',
|
|
8
|
+
options: {
|
|
9
|
+
builder: {
|
|
10
|
+
viteConfigPath: 'vite.config.ts',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default config;
|
|
17
|
+
|
|
18
|
+
// To customize your Vite configuration you can use the viteFinal field.
|
|
19
|
+
// Check https://storybook.js.org/docs/react/builders/vite#configuration
|
|
20
|
+
// and https://nx.dev/recipes/storybook/custom-builder-configs
|
|
File without changes
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @mapcomponents/three
|
|
2
|
+
|
|
3
|
+
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).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install the package and its peer dependencies:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @mapcomponents/three @mapcomponents/react-maplibre
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Getting Started
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
### Basic Usage
|
|
18
|
+
|
|
19
|
+
Here is a simple example of how to render a 3D model on a map:
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import React from 'react';
|
|
23
|
+
import { MapComponentsProvider, MapLibreMap } from '@mapcomponents/react-maplibre';
|
|
24
|
+
import { ThreeProvider, MlThreeModelLayer } from '@mapcomponents/three';
|
|
25
|
+
|
|
26
|
+
const App = () => {
|
|
27
|
+
return (
|
|
28
|
+
<MapComponentsProvider>
|
|
29
|
+
<ThreeProvider id="three-scene-1">
|
|
30
|
+
<MapLibreMap
|
|
31
|
+
options={{
|
|
32
|
+
style: 'https://demotiles.maplibre.org/style.json',
|
|
33
|
+
center: [13.404954, 52.520008],
|
|
34
|
+
zoom: 15,
|
|
35
|
+
pitch: 60
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
|
|
39
|
+
<MlThreeModelLayer
|
|
40
|
+
url="https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/master/2.0/Duck/glTF-Binary/Duck.glb"
|
|
41
|
+
mapPosition={[13.404954, 52.520008]}
|
|
42
|
+
scale={10}
|
|
43
|
+
/>
|
|
44
|
+
</ThreeProvider>
|
|
45
|
+
</MapComponentsProvider>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default App;
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Running unit tests
|
|
53
|
+
|
|
54
|
+
Run `nx test @mapcomponents/three` to execute the unit tests via [Vitest](https://vitest.dev/).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing';
|
|
2
|
+
import { defineConfig } from 'cypress';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
component: {
|
|
6
|
+
...nxComponentTestingPreset(__filename, {
|
|
7
|
+
bundler: 'vite',
|
|
8
|
+
buildTarget: '@mapcomponents/three:build',
|
|
9
|
+
}),
|
|
10
|
+
supportFile: 'src/cypress/support/component.ts',
|
|
11
|
+
indexHtmlFile: 'src/cypress/support/component-index.html',
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import nx from '@nx/eslint-plugin';
|
|
2
|
+
import baseConfig from '../../eslint.config.mjs';
|
|
3
|
+
|
|
4
|
+
export default [
|
|
5
|
+
...baseConfig,
|
|
6
|
+
...nx.configs['flat/react'],
|
|
7
|
+
{
|
|
8
|
+
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
|
9
|
+
// Override or add rules here
|
|
10
|
+
rules: {},
|
|
11
|
+
},
|
|
12
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mapcomponents/three",
|
|
3
|
+
"version": "1.7.2",
|
|
4
|
+
"main": "./index.js",
|
|
5
|
+
"types": "./index.d.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./index.mjs",
|
|
9
|
+
"require": "./index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@mui/material": "^7.3.2",
|
|
17
|
+
"maplibre-gl": "^5.7.0",
|
|
18
|
+
"three": "^0.182.0",
|
|
19
|
+
"@mapcomponents/react-maplibre": "1.7.2"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/three": "^0.182.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mapcomponents/three",
|
|
3
|
+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "packages/three/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"tags": [],
|
|
7
|
+
"// targets": "to see all targets run: nx show project @mapcomponents/three --web",
|
|
8
|
+
"targets": {
|
|
9
|
+
"storybook": {
|
|
10
|
+
"options": {
|
|
11
|
+
"port": 4403
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { MapComponentsProvider, MapLibreMap, useMap } from '@mapcomponents/react-maplibre';
|
|
3
|
+
import { ThreeProvider } from '../ThreeProvider';
|
|
4
|
+
import MlThreeModelLayer from './MlThreeModelLayer';
|
|
5
|
+
|
|
6
|
+
const MapExposer = () => {
|
|
7
|
+
const { map } = useMap({ mapId: 'map_1' });
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (map) {
|
|
10
|
+
(window as any)._map = map;
|
|
11
|
+
}
|
|
12
|
+
}, [map]);
|
|
13
|
+
return null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const TestComponent = ({ onDone }: { onDone: () => void }) => {
|
|
17
|
+
return (
|
|
18
|
+
<MapComponentsProvider>
|
|
19
|
+
<MapExposer />
|
|
20
|
+
<ThreeProvider id="three-provider" mapId="map_1">
|
|
21
|
+
<MlThreeModelLayer
|
|
22
|
+
url="assets/3D/godzilla_simple.glb"
|
|
23
|
+
mapPosition={[13.404954, 52.520008]}
|
|
24
|
+
scale={10}
|
|
25
|
+
onDone={onDone}
|
|
26
|
+
/>
|
|
27
|
+
</ThreeProvider>
|
|
28
|
+
<MapLibreMap
|
|
29
|
+
options={{
|
|
30
|
+
style: { version: 8, sources: {}, layers: [] },
|
|
31
|
+
center: [13.404954, 52.520008],
|
|
32
|
+
zoom: 15,
|
|
33
|
+
pitch: 60,
|
|
34
|
+
}}
|
|
35
|
+
mapId="map_1"
|
|
36
|
+
/>
|
|
37
|
+
</MapComponentsProvider>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('<MlThreeModelLayer />', () => {
|
|
42
|
+
it('renders', () => {
|
|
43
|
+
const onDoneSpy = cy.spy().as('onDoneSpy');
|
|
44
|
+
cy.mount(<TestComponent onDone={onDoneSpy} />);
|
|
45
|
+
|
|
46
|
+
// Wait for map to load
|
|
47
|
+
cy.get('.maplibregl-canvas').should('exist');
|
|
48
|
+
|
|
49
|
+
// Wait for the model to load first (this confirms map and three provider are working)
|
|
50
|
+
cy.get('@onDoneSpy', { timeout: 15000 }).should('have.been.called');
|
|
51
|
+
|
|
52
|
+
// Check if map instance is available and has the custom layer
|
|
53
|
+
cy.window().should('have.property', '_map');
|
|
54
|
+
cy.window().then((win: any) => {
|
|
55
|
+
const map = win._map;
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
57
|
+
expect(map).to.exist;
|
|
58
|
+
// Check for the layer added by ThreeProvider
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
60
|
+
expect(map.getLayer('three-provider')).to.exist;
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "MlThreeModelLayer",
|
|
3
|
+
"title": "3D Model",
|
|
4
|
+
"description": "Layer Component, that makes it possible to show 3D Models on the map.",
|
|
5
|
+
"i18n": {
|
|
6
|
+
"de": {
|
|
7
|
+
"title": "3D Modelle",
|
|
8
|
+
"description": "Layer Component, das es ermöglicht 3D Modelle auf der Karte darzustellen."
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"tags": ["Map layer"],
|
|
12
|
+
"category": "layer",
|
|
13
|
+
"type": "component",
|
|
14
|
+
"demos": [
|
|
15
|
+
{
|
|
16
|
+
"name": "Demo",
|
|
17
|
+
"url": "https://mapcomponents.github.io/react-map-components-maplibre/three/iframe.html?id=mapcomponents-mlthreemodellayer--example-config&viewMode=story"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"thumbnail": "https://mapcomponents.github.io/react-map-components-maplibre/three/thumbnails/MlThreeModelLayer.png"
|
|
21
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useRef, useState, useEffect } from 'react';
|
|
2
|
+
import Button from '@mui/material/Button';
|
|
3
|
+
import MlThreeModelLayer from './MlThreeModelLayer';
|
|
4
|
+
import { useMap, TopToolbar, Sidebar } from '@mapcomponents/react-maplibre';
|
|
5
|
+
import ThreejsContextDecorator from '../../decorators/ThreejsContextDecorator';
|
|
6
|
+
import { useThree } from '../ThreeContext';
|
|
7
|
+
import { ThreeObjectControls } from '../ThreeObjectControls';
|
|
8
|
+
import ThreejsUtils from '../../lib/ThreejsUtils';
|
|
9
|
+
import * as THREE from 'three';
|
|
10
|
+
|
|
11
|
+
const storyoptions = {
|
|
12
|
+
title: 'MapComponents/MlThreeModelLayer',
|
|
13
|
+
component: MlThreeModelLayer,
|
|
14
|
+
argTypes: {
|
|
15
|
+
options: {
|
|
16
|
+
control: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
decorators: ThreejsContextDecorator,
|
|
22
|
+
};
|
|
23
|
+
export default storyoptions;
|
|
24
|
+
|
|
25
|
+
const Lights = () => {
|
|
26
|
+
const { scene } = useThree();
|
|
27
|
+
const lightsRef = useRef<THREE.Light[]>([]);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!scene) return;
|
|
31
|
+
|
|
32
|
+
const directionalLight = new THREE.DirectionalLight(0xffffff);
|
|
33
|
+
directionalLight.position.set(0, -70, 100).normalize();
|
|
34
|
+
scene.add(directionalLight);
|
|
35
|
+
|
|
36
|
+
const directionalLight2 = new THREE.DirectionalLight(0xff2255);
|
|
37
|
+
directionalLight2.position.set(0, 70, 100).normalize();
|
|
38
|
+
scene.add(directionalLight2);
|
|
39
|
+
|
|
40
|
+
lightsRef.current = [directionalLight, directionalLight2];
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
lightsRef.current.forEach((light) => scene.remove(light));
|
|
44
|
+
};
|
|
45
|
+
}, [scene]);
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const Template: any = () => {
|
|
51
|
+
const { worldMatrix } = useThree();
|
|
52
|
+
const [showLayer, setShowLayer] = useState(true);
|
|
53
|
+
const [scale, setScale] = useState(1);
|
|
54
|
+
const [rotation, setRotation] = useState({ x: 90, y: 90, z: 0 });
|
|
55
|
+
const [useMapCoords, setUseMapCoords] = useState(true);
|
|
56
|
+
const [mapPosition, setMapPosition] = useState({ lng: 7.097, lat: 50.7355 });
|
|
57
|
+
const [altitude, setAltitude] = useState(0);
|
|
58
|
+
const [position, setPosition] = useState({ x: 0, y: 0, z: 0 });
|
|
59
|
+
const [enableTransformControls, setEnableTransformControls] = useState(false);
|
|
60
|
+
const [transformMode, setTransformMode] = useState<'translate' | 'rotate' | 'scale'>('translate');
|
|
61
|
+
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
62
|
+
|
|
63
|
+
const mapHook = useMap({ mapId: 'map_1' });
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!mapHook.map) return;
|
|
66
|
+
mapHook.map?.setZoom(15.5);
|
|
67
|
+
mapHook.map?.setPitch(44.5);
|
|
68
|
+
mapHook.map?.setCenter([7.097, 50.7355]);
|
|
69
|
+
}, [mapHook.map]);
|
|
70
|
+
|
|
71
|
+
// Center map on position when switching coordinate modes
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!mapHook.map) return;
|
|
74
|
+
if (useMapCoords) {
|
|
75
|
+
mapHook.map.setCenter([mapPosition.lng, mapPosition.lat]);
|
|
76
|
+
}
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [useMapCoords, mapHook.map]);
|
|
79
|
+
|
|
80
|
+
const handleTransformChange = (object: THREE.Object3D) => {
|
|
81
|
+
setRotation({
|
|
82
|
+
x: (object.rotation.x * 180) / Math.PI,
|
|
83
|
+
y: (object.rotation.y * 180) / Math.PI,
|
|
84
|
+
z: (object.rotation.z * 180) / Math.PI,
|
|
85
|
+
});
|
|
86
|
+
setScale(object.scale.x);
|
|
87
|
+
|
|
88
|
+
if (useMapCoords && worldMatrix) {
|
|
89
|
+
const [lng, lat, alt] = ThreejsUtils.toMapPosition(worldMatrix, object.position);
|
|
90
|
+
setMapPosition({ lng, lat });
|
|
91
|
+
setAltitude(alt);
|
|
92
|
+
} else {
|
|
93
|
+
setPosition({ x: object.position.x, y: object.position.y, z: object.position.z });
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<Lights />
|
|
100
|
+
{showLayer && (
|
|
101
|
+
<MlThreeModelLayer
|
|
102
|
+
url="assets/3D/godzilla_simple.glb"
|
|
103
|
+
rotation={{
|
|
104
|
+
x: (rotation.x * Math.PI) / 180,
|
|
105
|
+
y: (rotation.y * Math.PI) / 180,
|
|
106
|
+
z: (rotation.z * Math.PI) / 180,
|
|
107
|
+
}}
|
|
108
|
+
scale={scale}
|
|
109
|
+
enableTransformControls={enableTransformControls}
|
|
110
|
+
transformMode={transformMode}
|
|
111
|
+
onTransformChange={handleTransformChange}
|
|
112
|
+
{...(useMapCoords
|
|
113
|
+
? {
|
|
114
|
+
mapPosition: [mapPosition.lng, mapPosition.lat],
|
|
115
|
+
altitude: altitude,
|
|
116
|
+
}
|
|
117
|
+
: {
|
|
118
|
+
position: position,
|
|
119
|
+
})}
|
|
120
|
+
/>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
<TopToolbar
|
|
124
|
+
unmovableButtons={
|
|
125
|
+
<Button
|
|
126
|
+
variant={sidebarOpen ? 'contained' : 'outlined'}
|
|
127
|
+
onClick={() => setSidebarOpen(!sidebarOpen)}
|
|
128
|
+
>
|
|
129
|
+
Sidebar
|
|
130
|
+
</Button>
|
|
131
|
+
}
|
|
132
|
+
/>
|
|
133
|
+
<Sidebar open={sidebarOpen} setOpen={setSidebarOpen} name="3D Model Config">
|
|
134
|
+
<ThreeObjectControls
|
|
135
|
+
showLayer={showLayer}
|
|
136
|
+
setShowLayer={setShowLayer}
|
|
137
|
+
scale={scale}
|
|
138
|
+
setScale={setScale}
|
|
139
|
+
rotation={rotation}
|
|
140
|
+
setRotation={setRotation}
|
|
141
|
+
useMapCoords={useMapCoords}
|
|
142
|
+
setUseMapCoords={setUseMapCoords}
|
|
143
|
+
mapPosition={mapPosition}
|
|
144
|
+
setMapPosition={setMapPosition}
|
|
145
|
+
altitude={altitude}
|
|
146
|
+
setAltitude={setAltitude}
|
|
147
|
+
position={position}
|
|
148
|
+
setPosition={setPosition}
|
|
149
|
+
enableTransformControls={enableTransformControls}
|
|
150
|
+
setEnableTransformControls={setEnableTransformControls}
|
|
151
|
+
transformMode={transformMode}
|
|
152
|
+
setTransformMode={setTransformMode}
|
|
153
|
+
layerName="Model"
|
|
154
|
+
/>
|
|
155
|
+
</Sidebar>
|
|
156
|
+
</>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const ExampleConfig = Template.bind({});
|
|
161
|
+
ExampleConfig.parameters = {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
4
|
+
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
|
5
|
+
import { LngLatLike } from 'maplibre-gl';
|
|
6
|
+
import { useThree } from '../ThreeContext';
|
|
7
|
+
import ThreejsUtils from '../../lib/ThreejsUtils';
|
|
8
|
+
import MlTransformControls from '../MlTransformControls';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renders obj or gltf 3D Models on the MapLibreMap referenced by props.mapId
|
|
12
|
+
*
|
|
13
|
+
* @component
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface MlThreeModelLayerProps {
|
|
17
|
+
mapId?: string;
|
|
18
|
+
url: string;
|
|
19
|
+
position?: { x: number; y: number; z: number };
|
|
20
|
+
mapPosition?: LngLatLike;
|
|
21
|
+
altitude?: number;
|
|
22
|
+
rotation?: { x: number; y: number; z: number };
|
|
23
|
+
scale?: { x: number; y: number; z: number } | number;
|
|
24
|
+
enableTransformControls?: boolean;
|
|
25
|
+
transformMode?: 'translate' | 'rotate' | 'scale';
|
|
26
|
+
onTransformChange?: (object: THREE.Object3D) => void;
|
|
27
|
+
init?: () => void;
|
|
28
|
+
onDone?: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MlThreeModelLayer = (props: MlThreeModelLayerProps) => {
|
|
32
|
+
const {
|
|
33
|
+
url,
|
|
34
|
+
position,
|
|
35
|
+
mapPosition,
|
|
36
|
+
altitude,
|
|
37
|
+
rotation,
|
|
38
|
+
scale,
|
|
39
|
+
enableTransformControls,
|
|
40
|
+
transformMode,
|
|
41
|
+
onTransformChange,
|
|
42
|
+
init,
|
|
43
|
+
onDone,
|
|
44
|
+
} = props;
|
|
45
|
+
const { scene, worldMatrixInv } = useThree();
|
|
46
|
+
const modelRef = useRef<THREE.Object3D | undefined>(undefined);
|
|
47
|
+
const [model, setModel] = useState<THREE.Object3D | undefined>(undefined);
|
|
48
|
+
|
|
49
|
+
// Use refs for callbacks to avoid re-triggering the effect when they change
|
|
50
|
+
const initRef = useRef(init);
|
|
51
|
+
const onDoneRef = useRef(onDone);
|
|
52
|
+
initRef.current = init;
|
|
53
|
+
onDoneRef.current = onDone;
|
|
54
|
+
|
|
55
|
+
const transformRef = useRef({ position, mapPosition, altitude, rotation, scale });
|
|
56
|
+
transformRef.current = { position, mapPosition, altitude, rotation, scale };
|
|
57
|
+
const worldMatrixInvRef = useRef(worldMatrixInv);
|
|
58
|
+
worldMatrixInvRef.current = worldMatrixInv;
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!scene) return;
|
|
62
|
+
|
|
63
|
+
if (typeof initRef.current === 'function') {
|
|
64
|
+
initRef.current();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const extension = url.split('.').pop()?.toLowerCase();
|
|
68
|
+
|
|
69
|
+
const onLoad = (object: THREE.Object3D) => {
|
|
70
|
+
const { position, mapPosition, altitude, rotation, scale } = transformRef.current;
|
|
71
|
+
const worldMatrixInv = worldMatrixInvRef.current;
|
|
72
|
+
|
|
73
|
+
if (mapPosition && worldMatrixInv) {
|
|
74
|
+
const scenePos = ThreejsUtils.toScenePosition(worldMatrixInv, mapPosition, altitude);
|
|
75
|
+
object.position.set(scenePos.x, scenePos.y, scenePos.z);
|
|
76
|
+
} else if (position) {
|
|
77
|
+
object.position.set(position.x, position.y, position.z);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (rotation) {
|
|
81
|
+
object.rotation.set(rotation.x, rotation.y, rotation.z);
|
|
82
|
+
}
|
|
83
|
+
if (scale) {
|
|
84
|
+
if (typeof scale === 'number') {
|
|
85
|
+
object.scale.set(scale, scale, scale);
|
|
86
|
+
} else {
|
|
87
|
+
object.scale.set(scale.x, scale.y, scale.z);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
modelRef.current = object;
|
|
92
|
+
scene.add(object);
|
|
93
|
+
setModel(object);
|
|
94
|
+
if (typeof onDoneRef.current === 'function') {
|
|
95
|
+
onDoneRef.current();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (extension === 'glb' || extension === 'gltf') {
|
|
100
|
+
const loader = new GLTFLoader();
|
|
101
|
+
loader.load(url, (gltf) => {
|
|
102
|
+
onLoad(gltf.scene);
|
|
103
|
+
});
|
|
104
|
+
} else if (extension === 'obj') {
|
|
105
|
+
const loader = new OBJLoader();
|
|
106
|
+
loader.load(url, (obj) => {
|
|
107
|
+
onLoad(obj);
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
console.warn('MlThreeModelLayer: Unsupported file extension', extension);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return () => {
|
|
114
|
+
if (modelRef.current) {
|
|
115
|
+
scene.remove(modelRef.current);
|
|
116
|
+
modelRef.current = undefined;
|
|
117
|
+
setModel(undefined);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}, [scene, url]);
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!model) return;
|
|
124
|
+
|
|
125
|
+
// Handle position: mapPosition takes precedence over position
|
|
126
|
+
if (mapPosition && worldMatrixInv) {
|
|
127
|
+
const scenePos = ThreejsUtils.toScenePosition(worldMatrixInv, mapPosition, altitude);
|
|
128
|
+
model.position.set(scenePos.x, scenePos.y, scenePos.z);
|
|
129
|
+
} else if (position) {
|
|
130
|
+
model.position.set(position.x, position.y, position.z);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (rotation) {
|
|
134
|
+
model.rotation.set(rotation.x, rotation.y, rotation.z);
|
|
135
|
+
}
|
|
136
|
+
if (scale) {
|
|
137
|
+
if (typeof scale === 'number') {
|
|
138
|
+
model.scale.set(scale, scale, scale);
|
|
139
|
+
} else {
|
|
140
|
+
model.scale.set(scale.x, scale.y, scale.z);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}, [model, position, mapPosition, altitude, rotation, scale, worldMatrixInv]);
|
|
144
|
+
|
|
145
|
+
if (enableTransformControls && model) {
|
|
146
|
+
return (
|
|
147
|
+
<MlTransformControls target={model} mode={transformMode} onObjectChange={onTransformChange} />
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export default MlThreeModelLayer;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { MapComponentsProvider, MapLibreMap, useMap } from '@mapcomponents/react-maplibre';
|
|
3
|
+
import { ThreeProvider } from '../ThreeProvider';
|
|
4
|
+
import MlThreeSplatLayer from './MlThreeSplatLayer';
|
|
5
|
+
|
|
6
|
+
const MapExposer = () => {
|
|
7
|
+
const { map } = useMap({ mapId: 'map_1' });
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (map) {
|
|
10
|
+
(window as any)._map = map;
|
|
11
|
+
}
|
|
12
|
+
}, [map]);
|
|
13
|
+
return null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const TestComponent = ({ onDone }: { onDone: () => void }) => {
|
|
17
|
+
return (
|
|
18
|
+
<MapComponentsProvider>
|
|
19
|
+
<MapExposer />
|
|
20
|
+
<ThreeProvider id="three-provider" mapId="map_1">
|
|
21
|
+
<MlThreeSplatLayer
|
|
22
|
+
url="assets/splats/output.splat"
|
|
23
|
+
mapPosition={[13.404954, 52.520008]}
|
|
24
|
+
scale={1}
|
|
25
|
+
onDone={onDone}
|
|
26
|
+
/>
|
|
27
|
+
</ThreeProvider>
|
|
28
|
+
<MapLibreMap
|
|
29
|
+
options={{
|
|
30
|
+
style: { version: 8, sources: {}, layers: [] },
|
|
31
|
+
center: [13.404954, 52.520008],
|
|
32
|
+
zoom: 15,
|
|
33
|
+
pitch: 60,
|
|
34
|
+
}}
|
|
35
|
+
mapId="map_1"
|
|
36
|
+
/>
|
|
37
|
+
</MapComponentsProvider>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('<MlThreeSplatLayer />', () => {
|
|
42
|
+
it('renders', () => {
|
|
43
|
+
const onDoneSpy = cy.spy().as('onDoneSpy');
|
|
44
|
+
cy.mount(<TestComponent onDone={onDoneSpy} />);
|
|
45
|
+
|
|
46
|
+
// Wait for map to load
|
|
47
|
+
cy.get('.maplibregl-canvas').should('exist');
|
|
48
|
+
|
|
49
|
+
// Wait for the splat to load first
|
|
50
|
+
cy.get('@onDoneSpy', { timeout: 15000 }).should('have.been.called');
|
|
51
|
+
|
|
52
|
+
// Check if map instance is available and has the custom layer
|
|
53
|
+
cy.window().should('have.property', '_map');
|
|
54
|
+
cy.window().then((win: any) => {
|
|
55
|
+
const map = win._map;
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
57
|
+
expect(map).to.exist;
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
59
|
+
expect(map.getLayer('three-provider')).to.exist;
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "MlThreeSplatLayer",
|
|
3
|
+
"title": "3D Splat Model",
|
|
4
|
+
"description": "Layer Component, that makes it possible to show 3D Gaussian Splatting Models on the map.",
|
|
5
|
+
"i18n": {
|
|
6
|
+
"de": {
|
|
7
|
+
"title": "3D Splat Modelle",
|
|
8
|
+
"description": "Layer Component, das es ermöglicht 3D Gaussian Splatting Modelle auf der Karte darzustellen."
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"tags": ["Map layer", "3D", "Splat"],
|
|
12
|
+
"category": "layer",
|
|
13
|
+
"type": "component",
|
|
14
|
+
"demos": [
|
|
15
|
+
{
|
|
16
|
+
"name": "Demo",
|
|
17
|
+
"url": "https://mapcomponents.github.io/react-map-components-maplibre/three/iframe.html?id=mapcomponents-mlthreesplatlayer--default&viewMode=story"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"thumbnail": "https://mapcomponents.github.io/react-map-components-maplibre/three/thumbnails/MlThreeSplatLayer.png"
|
|
21
|
+
}
|