@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import Button from '@mui/material/Button';
|
|
3
|
+
import Typography from '@mui/material/Typography';
|
|
4
|
+
import Link from '@mui/material/Link';
|
|
5
|
+
import MlThreeSplatLayer from './MlThreeSplatLayer';
|
|
6
|
+
import { useMap, TopToolbar, Sidebar } from '@mapcomponents/react-maplibre';
|
|
7
|
+
import MlThreeJsContextDecorator from '../../decorators/ThreejsContextDecorator';
|
|
8
|
+
import { ThreeObjectControls } from '../ThreeObjectControls';
|
|
9
|
+
import { useThree } from '../ThreeContext';
|
|
10
|
+
import ThreejsUtils from '../../lib/ThreejsUtils';
|
|
11
|
+
import * as THREE from 'three';
|
|
12
|
+
|
|
13
|
+
const storyoptions = {
|
|
14
|
+
title: 'MapComponents/MlThreeSplatLayer',
|
|
15
|
+
component: MlThreeSplatLayer,
|
|
16
|
+
argTypes: {
|
|
17
|
+
options: {
|
|
18
|
+
control: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
decorators: MlThreeJsContextDecorator,
|
|
24
|
+
};
|
|
25
|
+
export default storyoptions;
|
|
26
|
+
|
|
27
|
+
const Template: any = () => {
|
|
28
|
+
const { worldMatrix } = useThree();
|
|
29
|
+
const [showLayer, setShowLayer] = useState(true);
|
|
30
|
+
const [scale, setScale] = useState(100);
|
|
31
|
+
const [rotation, setRotation] = useState({ x: 270, y: 0, z: 5 });
|
|
32
|
+
const [useMapCoords, setUseMapCoords] = useState(true);
|
|
33
|
+
const [mapPosition, setMapPosition] = useState({ lng: 7.0968, lat: 50.736 });
|
|
34
|
+
const [altitude, setAltitude] = useState(30);
|
|
35
|
+
const [position, setPosition] = useState({ x: 0, y: 0, z: 100 });
|
|
36
|
+
const [enableTransformControls, setEnableTransformControls] = useState(false);
|
|
37
|
+
const [transformMode, setTransformMode] = useState<'translate' | 'rotate' | 'scale'>('translate');
|
|
38
|
+
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
39
|
+
|
|
40
|
+
const mapHook = useMap({ mapId: 'map_1' });
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!mapHook.map) return;
|
|
43
|
+
mapHook.map?.setZoom(17.5);
|
|
44
|
+
mapHook.map?.setPitch(44.5);
|
|
45
|
+
mapHook.map?.setCenter([7.096614581535903, 50.736500960686556]);
|
|
46
|
+
}, [mapHook.map]);
|
|
47
|
+
|
|
48
|
+
// Center map on position when switching coordinate modes
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!mapHook.map) return;
|
|
51
|
+
if (useMapCoords) {
|
|
52
|
+
mapHook.map.setCenter([mapPosition.lng, mapPosition.lat]);
|
|
53
|
+
}
|
|
54
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
55
|
+
}, [useMapCoords, mapHook.map]);
|
|
56
|
+
|
|
57
|
+
const handleTransformChange = (object: THREE.Object3D) => {
|
|
58
|
+
setRotation({
|
|
59
|
+
x: (object.rotation.x * 180) / Math.PI,
|
|
60
|
+
y: (object.rotation.y * 180) / Math.PI,
|
|
61
|
+
z: (object.rotation.z * 180) / Math.PI,
|
|
62
|
+
});
|
|
63
|
+
setScale(object.scale.x);
|
|
64
|
+
|
|
65
|
+
if (useMapCoords && worldMatrix) {
|
|
66
|
+
const [lng, lat, alt] = ThreejsUtils.toMapPosition(worldMatrix, object.position);
|
|
67
|
+
setMapPosition({ lng, lat });
|
|
68
|
+
setAltitude(parseFloat(alt.toFixed(2)));
|
|
69
|
+
} else {
|
|
70
|
+
setPosition({ x: object.position.x, y: object.position.y, z: object.position.z });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
{showLayer && (
|
|
77
|
+
<MlThreeSplatLayer
|
|
78
|
+
url="assets/splats/output.splat"
|
|
79
|
+
rotation={{
|
|
80
|
+
x: (rotation.x * Math.PI) / 180,
|
|
81
|
+
y: (rotation.y * Math.PI) / 180,
|
|
82
|
+
z: (rotation.z * Math.PI) / 180,
|
|
83
|
+
}}
|
|
84
|
+
scale={scale}
|
|
85
|
+
enableTransformControls={enableTransformControls}
|
|
86
|
+
transformMode={transformMode}
|
|
87
|
+
onTransformChange={handleTransformChange}
|
|
88
|
+
{...(useMapCoords
|
|
89
|
+
? {
|
|
90
|
+
mapPosition: [mapPosition.lng, mapPosition.lat],
|
|
91
|
+
altitude: altitude,
|
|
92
|
+
}
|
|
93
|
+
: {
|
|
94
|
+
position: position,
|
|
95
|
+
})}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
<TopToolbar
|
|
100
|
+
unmovableButtons={
|
|
101
|
+
<Button
|
|
102
|
+
variant={sidebarOpen ? 'contained' : 'outlined'}
|
|
103
|
+
onClick={() => setSidebarOpen(!sidebarOpen)}
|
|
104
|
+
>
|
|
105
|
+
Sidebar
|
|
106
|
+
</Button>
|
|
107
|
+
}
|
|
108
|
+
/>
|
|
109
|
+
<Sidebar open={sidebarOpen} setOpen={setSidebarOpen} name="Splat Config">
|
|
110
|
+
<ThreeObjectControls
|
|
111
|
+
showLayer={showLayer}
|
|
112
|
+
setShowLayer={setShowLayer}
|
|
113
|
+
scale={scale}
|
|
114
|
+
setScale={setScale}
|
|
115
|
+
rotation={rotation}
|
|
116
|
+
setRotation={setRotation}
|
|
117
|
+
useMapCoords={useMapCoords}
|
|
118
|
+
setUseMapCoords={setUseMapCoords}
|
|
119
|
+
mapPosition={mapPosition}
|
|
120
|
+
setMapPosition={setMapPosition}
|
|
121
|
+
altitude={altitude}
|
|
122
|
+
setAltitude={setAltitude}
|
|
123
|
+
position={position}
|
|
124
|
+
setPosition={setPosition}
|
|
125
|
+
enableTransformControls={enableTransformControls}
|
|
126
|
+
setEnableTransformControls={setEnableTransformControls}
|
|
127
|
+
transformMode={transformMode}
|
|
128
|
+
setTransformMode={setTransformMode}
|
|
129
|
+
layerName="Splat"
|
|
130
|
+
/>
|
|
131
|
+
<Typography
|
|
132
|
+
variant="body2"
|
|
133
|
+
sx={{ mt: 2, p: 1, backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 1 }}
|
|
134
|
+
>
|
|
135
|
+
The splat used is from{' '}
|
|
136
|
+
<Link
|
|
137
|
+
href="https://www.patreon.com/posts/cluster-fly-141866089"
|
|
138
|
+
target="_blank"
|
|
139
|
+
rel="noopener"
|
|
140
|
+
>
|
|
141
|
+
Cluster Fly
|
|
142
|
+
</Link>{' '}
|
|
143
|
+
by Dany Bittel published under CC.
|
|
144
|
+
</Typography>
|
|
145
|
+
</Sidebar>
|
|
146
|
+
</>
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const Default = Template.bind({});
|
|
151
|
+
Default.parameters = {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { LngLatLike } from 'maplibre-gl';
|
|
4
|
+
import { useThree } from '../ThreeContext';
|
|
5
|
+
import { SplatLoader } from '../../lib/splats/loaders/SplatLoader';
|
|
6
|
+
import { PlySplatLoader } from '../../lib/splats/loaders/PlySplatLoader';
|
|
7
|
+
import ThreejsUtils from '../../lib/ThreejsUtils';
|
|
8
|
+
import MlTransformControls from '../MlTransformControls';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renders splat 3D Models on the MapLibreMap referenced by props.mapId
|
|
12
|
+
*
|
|
13
|
+
* @component
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface MlThreeSplatLayerProps {
|
|
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 MlThreeSplatLayer = (props: MlThreeSplatLayerProps) => {
|
|
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
|
+
object.updateMatrixWorld(true);
|
|
91
|
+
|
|
92
|
+
modelRef.current = object;
|
|
93
|
+
scene.add(object);
|
|
94
|
+
setModel(object);
|
|
95
|
+
if (typeof onDoneRef.current === 'function') {
|
|
96
|
+
onDoneRef.current();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (extension === 'splat') {
|
|
101
|
+
const loader = new SplatLoader();
|
|
102
|
+
loader.load(url, (splatMesh) => {
|
|
103
|
+
onLoad(splatMesh);
|
|
104
|
+
});
|
|
105
|
+
} else if (extension === 'ply') {
|
|
106
|
+
const loader = new PlySplatLoader();
|
|
107
|
+
loader.load(url, (splatMesh) => {
|
|
108
|
+
onLoad(splatMesh);
|
|
109
|
+
});
|
|
110
|
+
} else {
|
|
111
|
+
console.warn('MlThreeSplatLayer: Unsupported file extension', extension);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
if (modelRef.current) {
|
|
116
|
+
scene.remove(modelRef.current);
|
|
117
|
+
if ('dispose' in modelRef.current && typeof modelRef.current.dispose === 'function') {
|
|
118
|
+
(modelRef.current as any).dispose();
|
|
119
|
+
}
|
|
120
|
+
modelRef.current = undefined;
|
|
121
|
+
setModel(undefined);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}, [scene, url]);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!model) return;
|
|
128
|
+
|
|
129
|
+
// Handle position: mapPosition takes precedence over position
|
|
130
|
+
if (mapPosition && worldMatrixInv) {
|
|
131
|
+
const scenePos = ThreejsUtils.toScenePosition(worldMatrixInv, mapPosition, altitude);
|
|
132
|
+
model.position.set(scenePos.x, scenePos.y, scenePos.z);
|
|
133
|
+
} else if (position) {
|
|
134
|
+
model.position.set(position.x, position.y, position.z);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (rotation) {
|
|
138
|
+
model.rotation.set(rotation.x, rotation.y, rotation.z);
|
|
139
|
+
}
|
|
140
|
+
if (scale) {
|
|
141
|
+
if (typeof scale === 'number') {
|
|
142
|
+
model.scale.set(scale, scale, scale);
|
|
143
|
+
} else {
|
|
144
|
+
model.scale.set(scale.x, scale.y, scale.z);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
model.updateMatrixWorld(true);
|
|
148
|
+
}, [model, position, mapPosition, altitude, rotation, scale, worldMatrixInv]);
|
|
149
|
+
|
|
150
|
+
if (enableTransformControls && model) {
|
|
151
|
+
return (
|
|
152
|
+
<MlTransformControls target={model} mode={transformMode} onObjectChange={onTransformChange} />
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export default MlThreeSplatLayer;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
|
|
4
|
+
import { useThree } from './ThreeContext';
|
|
5
|
+
|
|
6
|
+
export interface MlTransformControlsProps {
|
|
7
|
+
target?: THREE.Object3D;
|
|
8
|
+
mode?: 'translate' | 'rotate' | 'scale';
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
space?: 'world' | 'local';
|
|
11
|
+
size?: number;
|
|
12
|
+
onObjectChange?: (object: THREE.Object3D) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MlTransformControls = (props: MlTransformControlsProps) => {
|
|
16
|
+
const { target, mode, enabled, space, size, onObjectChange } = props;
|
|
17
|
+
const { scene, camera, renderer, map, sceneRoot } = useThree();
|
|
18
|
+
const controlsRef = useRef<TransformControls | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!scene || !camera || !renderer || !map || !sceneRoot) return;
|
|
22
|
+
|
|
23
|
+
const domElement = renderer.getRenderer().domElement;
|
|
24
|
+
const controls = new TransformControls(camera, domElement);
|
|
25
|
+
controlsRef.current = controls;
|
|
26
|
+
|
|
27
|
+
// Set initial mode
|
|
28
|
+
controls.setMode(mode || 'translate');
|
|
29
|
+
controls.setSpace(space || 'world');
|
|
30
|
+
if (size) {
|
|
31
|
+
controls.setSize(size);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Add TransformControls root object to the sceneRoot
|
|
35
|
+
// TransformControls has an internal _root that is the actual Object3D
|
|
36
|
+
sceneRoot.add((controls as any)._root);
|
|
37
|
+
|
|
38
|
+
// When transform controls are active, disable map interaction
|
|
39
|
+
const onDraggingChanged = (event: any) => {
|
|
40
|
+
if (event.value) {
|
|
41
|
+
// Disable map dragging when using transform controls
|
|
42
|
+
map.dragPan.disable();
|
|
43
|
+
map.scrollZoom.disable();
|
|
44
|
+
} else {
|
|
45
|
+
// Re-enable map dragging
|
|
46
|
+
map.dragPan.enable();
|
|
47
|
+
map.scrollZoom.enable();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
controls.addEventListener('dragging-changed', onDraggingChanged);
|
|
52
|
+
|
|
53
|
+
// Trigger callback on object change
|
|
54
|
+
if (onObjectChange) {
|
|
55
|
+
const handleObjectChange = () => {
|
|
56
|
+
if (controls.object) {
|
|
57
|
+
onObjectChange(controls.object);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
controls.addEventListener('objectChange', handleObjectChange);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
controls.removeEventListener('dragging-changed', onDraggingChanged);
|
|
65
|
+
controls.detach();
|
|
66
|
+
sceneRoot.remove((controls as any)._root);
|
|
67
|
+
controls.dispose();
|
|
68
|
+
controlsRef.current = null;
|
|
69
|
+
};
|
|
70
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
71
|
+
}, [scene, camera, renderer, map, sceneRoot]);
|
|
72
|
+
|
|
73
|
+
// Update target object
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!controlsRef.current) return;
|
|
76
|
+
|
|
77
|
+
if (target) {
|
|
78
|
+
controlsRef.current.attach(target);
|
|
79
|
+
} else {
|
|
80
|
+
controlsRef.current.detach();
|
|
81
|
+
}
|
|
82
|
+
}, [target]);
|
|
83
|
+
|
|
84
|
+
// Update mode
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!controlsRef.current) return;
|
|
87
|
+
// Directly set the mode to avoid detach/reattach cycle
|
|
88
|
+
(controlsRef.current as any).mode = mode || 'translate';
|
|
89
|
+
}, [mode]);
|
|
90
|
+
|
|
91
|
+
// Update enabled state
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!controlsRef.current) return;
|
|
94
|
+
controlsRef.current.enabled = enabled !== false;
|
|
95
|
+
}, [enabled]);
|
|
96
|
+
|
|
97
|
+
// Update space
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!controlsRef.current) return;
|
|
100
|
+
controlsRef.current.setSpace(space || 'world');
|
|
101
|
+
}, [space]);
|
|
102
|
+
|
|
103
|
+
// Update size
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!controlsRef.current || !size) return;
|
|
106
|
+
controlsRef.current.setSize(size);
|
|
107
|
+
}, [size]);
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export default MlTransformControls;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import { Scene, PerspectiveCamera, Group, Matrix4 } from 'three';
|
|
3
|
+
import { Map as MapboxMap } from 'maplibre-gl';
|
|
4
|
+
import ThreejsSceneRenderer from '../lib/ThreejsSceneRenderer';
|
|
5
|
+
|
|
6
|
+
export interface ThreeContextType {
|
|
7
|
+
scene: Scene | undefined;
|
|
8
|
+
camera: PerspectiveCamera | undefined;
|
|
9
|
+
renderer: ThreejsSceneRenderer | undefined;
|
|
10
|
+
map: MapboxMap | undefined;
|
|
11
|
+
sceneRoot: Group | undefined;
|
|
12
|
+
worldMatrix: Matrix4 | undefined;
|
|
13
|
+
worldMatrixInv: Matrix4 | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ThreeContext = createContext<ThreeContextType>({
|
|
17
|
+
scene: undefined,
|
|
18
|
+
camera: undefined,
|
|
19
|
+
renderer: undefined,
|
|
20
|
+
map: undefined,
|
|
21
|
+
sceneRoot: undefined,
|
|
22
|
+
worldMatrix: undefined,
|
|
23
|
+
worldMatrixInv: undefined,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const useThree = () => useContext(ThreeContext);
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import Button from '@mui/material/Button';
|
|
2
|
+
import ButtonGroup from '@mui/material/ButtonGroup';
|
|
3
|
+
import Slider from '@mui/material/Slider';
|
|
4
|
+
import Typography from '@mui/material/Typography';
|
|
5
|
+
import Box from '@mui/material/Box';
|
|
6
|
+
|
|
7
|
+
export interface ThreeObjectControlsProps {
|
|
8
|
+
showLayer: boolean;
|
|
9
|
+
setShowLayer: (show: boolean) => void;
|
|
10
|
+
scale: number;
|
|
11
|
+
setScale: (scale: number) => void;
|
|
12
|
+
rotation: { x: number; y: number; z: number };
|
|
13
|
+
setRotation: (rotation: { x: number; y: number; z: number }) => void;
|
|
14
|
+
useMapCoords: boolean;
|
|
15
|
+
setUseMapCoords: (use: boolean) => void;
|
|
16
|
+
mapPosition: { lng: number; lat: number };
|
|
17
|
+
setMapPosition: (position: { lng: number; lat: number }) => void;
|
|
18
|
+
altitude: number;
|
|
19
|
+
setAltitude: (altitude: number) => void;
|
|
20
|
+
position: { x: number; y: number; z: number };
|
|
21
|
+
setPosition: (position: { x: number; y: number; z: number }) => void;
|
|
22
|
+
enableTransformControls?: boolean;
|
|
23
|
+
setEnableTransformControls?: (enable: boolean) => void;
|
|
24
|
+
transformMode?: 'translate' | 'rotate' | 'scale';
|
|
25
|
+
setTransformMode?: (mode: 'translate' | 'rotate' | 'scale') => void;
|
|
26
|
+
layerName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const ThreeObjectControls = ({
|
|
30
|
+
showLayer,
|
|
31
|
+
setShowLayer,
|
|
32
|
+
scale,
|
|
33
|
+
setScale,
|
|
34
|
+
rotation,
|
|
35
|
+
setRotation,
|
|
36
|
+
useMapCoords,
|
|
37
|
+
setUseMapCoords,
|
|
38
|
+
mapPosition,
|
|
39
|
+
setMapPosition,
|
|
40
|
+
altitude,
|
|
41
|
+
setAltitude,
|
|
42
|
+
position,
|
|
43
|
+
setPosition,
|
|
44
|
+
enableTransformControls,
|
|
45
|
+
setEnableTransformControls,
|
|
46
|
+
transformMode,
|
|
47
|
+
setTransformMode,
|
|
48
|
+
layerName = 'Layer',
|
|
49
|
+
}: ThreeObjectControlsProps) => {
|
|
50
|
+
return (
|
|
51
|
+
<Box sx={{ padding: '10px' }}>
|
|
52
|
+
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', marginBottom: 2 }}>
|
|
53
|
+
<Button
|
|
54
|
+
color="primary"
|
|
55
|
+
variant={showLayer ? 'contained' : 'outlined'}
|
|
56
|
+
onClick={() => setShowLayer(!showLayer)}
|
|
57
|
+
size="small"
|
|
58
|
+
>
|
|
59
|
+
{showLayer ? 'Hide' : 'Show'} {layerName}
|
|
60
|
+
</Button>
|
|
61
|
+
<Button
|
|
62
|
+
color="secondary"
|
|
63
|
+
variant={useMapCoords ? 'contained' : 'outlined'}
|
|
64
|
+
onClick={() => setUseMapCoords(!useMapCoords)}
|
|
65
|
+
size="small"
|
|
66
|
+
>
|
|
67
|
+
{useMapCoords ? 'Map Coords' : 'Scene Coords'}
|
|
68
|
+
</Button>
|
|
69
|
+
{setEnableTransformControls && (
|
|
70
|
+
<Button
|
|
71
|
+
color="info"
|
|
72
|
+
variant={enableTransformControls ? 'contained' : 'outlined'}
|
|
73
|
+
onClick={() => setEnableTransformControls(!enableTransformControls)}
|
|
74
|
+
size="small"
|
|
75
|
+
>
|
|
76
|
+
3D Gizmo
|
|
77
|
+
</Button>
|
|
78
|
+
)}
|
|
79
|
+
</Box>
|
|
80
|
+
|
|
81
|
+
{setTransformMode && enableTransformControls && (
|
|
82
|
+
<Box sx={{ marginBottom: 2 }}>
|
|
83
|
+
<ButtonGroup variant="outlined" size="small" fullWidth aria-label="transform mode">
|
|
84
|
+
<Button
|
|
85
|
+
variant={transformMode === 'translate' ? 'contained' : 'outlined'}
|
|
86
|
+
onClick={() => setTransformMode('translate')}
|
|
87
|
+
>
|
|
88
|
+
Move
|
|
89
|
+
</Button>
|
|
90
|
+
<Button
|
|
91
|
+
variant={transformMode === 'rotate' ? 'contained' : 'outlined'}
|
|
92
|
+
onClick={() => setTransformMode('rotate')}
|
|
93
|
+
>
|
|
94
|
+
Rotate
|
|
95
|
+
</Button>
|
|
96
|
+
<Button
|
|
97
|
+
variant={transformMode === 'scale' ? 'contained' : 'outlined'}
|
|
98
|
+
onClick={() => setTransformMode('scale')}
|
|
99
|
+
>
|
|
100
|
+
Scale
|
|
101
|
+
</Button>
|
|
102
|
+
</ButtonGroup>
|
|
103
|
+
</Box>
|
|
104
|
+
)}
|
|
105
|
+
<Typography gutterBottom>Scale: {scale.toFixed(2)}</Typography>
|
|
106
|
+
<Slider
|
|
107
|
+
value={scale}
|
|
108
|
+
onChange={(e, newValue) => setScale(newValue as number)}
|
|
109
|
+
min={0.01}
|
|
110
|
+
max={150}
|
|
111
|
+
step={0.01}
|
|
112
|
+
valueLabelDisplay="auto"
|
|
113
|
+
/>
|
|
114
|
+
<Typography gutterBottom>Rotation X: {rotation.x}°</Typography>
|
|
115
|
+
<Slider
|
|
116
|
+
value={rotation.x}
|
|
117
|
+
onChange={(e, newValue) => setRotation({ ...rotation, x: newValue as number })}
|
|
118
|
+
min={0}
|
|
119
|
+
max={360}
|
|
120
|
+
valueLabelDisplay="auto"
|
|
121
|
+
/>
|
|
122
|
+
<Typography gutterBottom>Rotation Y: {rotation.y}°</Typography>
|
|
123
|
+
<Slider
|
|
124
|
+
value={rotation.y}
|
|
125
|
+
onChange={(e, newValue) => setRotation({ ...rotation, y: newValue as number })}
|
|
126
|
+
min={0}
|
|
127
|
+
max={360}
|
|
128
|
+
valueLabelDisplay="auto"
|
|
129
|
+
/>
|
|
130
|
+
<Typography gutterBottom>Rotation Z: {rotation.z}°</Typography>
|
|
131
|
+
<Slider
|
|
132
|
+
value={rotation.z}
|
|
133
|
+
onChange={(e, newValue) => setRotation({ ...rotation, z: newValue as number })}
|
|
134
|
+
min={0}
|
|
135
|
+
max={360}
|
|
136
|
+
valueLabelDisplay="auto"
|
|
137
|
+
/>
|
|
138
|
+
{useMapCoords ? (
|
|
139
|
+
<>
|
|
140
|
+
<Typography gutterBottom>Longitude: {mapPosition.lng.toFixed(6)}</Typography>
|
|
141
|
+
<Slider
|
|
142
|
+
value={mapPosition.lng}
|
|
143
|
+
onChange={(e, newValue) => setMapPosition({ ...mapPosition, lng: newValue as number })}
|
|
144
|
+
min={7.09}
|
|
145
|
+
max={7.11}
|
|
146
|
+
step={0.0001}
|
|
147
|
+
valueLabelDisplay="auto"
|
|
148
|
+
/>
|
|
149
|
+
<Typography gutterBottom>Latitude: {mapPosition.lat.toFixed(6)}</Typography>
|
|
150
|
+
<Slider
|
|
151
|
+
value={mapPosition.lat}
|
|
152
|
+
onChange={(e, newValue) => setMapPosition({ ...mapPosition, lat: newValue as number })}
|
|
153
|
+
min={50.73}
|
|
154
|
+
max={50.74}
|
|
155
|
+
step={0.0001}
|
|
156
|
+
valueLabelDisplay="auto"
|
|
157
|
+
/>
|
|
158
|
+
<Typography gutterBottom>Altitude: {altitude} m</Typography>
|
|
159
|
+
<Slider
|
|
160
|
+
value={altitude}
|
|
161
|
+
onChange={(e, newValue) => setAltitude(newValue as number)}
|
|
162
|
+
min={-100}
|
|
163
|
+
max={500}
|
|
164
|
+
valueLabelDisplay="auto"
|
|
165
|
+
/>
|
|
166
|
+
</>
|
|
167
|
+
) : (
|
|
168
|
+
<>
|
|
169
|
+
<Typography gutterBottom>Position X: {position.x}</Typography>
|
|
170
|
+
<Slider
|
|
171
|
+
value={position.x}
|
|
172
|
+
onChange={(e, newValue) => setPosition({ ...position, x: newValue as number })}
|
|
173
|
+
min={-100}
|
|
174
|
+
max={100}
|
|
175
|
+
valueLabelDisplay="auto"
|
|
176
|
+
/>
|
|
177
|
+
<Typography gutterBottom>Position Y: {position.y}</Typography>
|
|
178
|
+
<Slider
|
|
179
|
+
value={position.y}
|
|
180
|
+
onChange={(e, newValue) => setPosition({ ...position, y: newValue as number })}
|
|
181
|
+
min={-100}
|
|
182
|
+
max={100}
|
|
183
|
+
valueLabelDisplay="auto"
|
|
184
|
+
/>
|
|
185
|
+
<Typography gutterBottom>Position Z: {position.z}</Typography>
|
|
186
|
+
<Slider
|
|
187
|
+
value={position.z}
|
|
188
|
+
onChange={(e, newValue) => setPosition({ ...position, z: newValue as number })}
|
|
189
|
+
min={-500}
|
|
190
|
+
max={100}
|
|
191
|
+
valueLabelDisplay="auto"
|
|
192
|
+
/>
|
|
193
|
+
</>
|
|
194
|
+
)}
|
|
195
|
+
</Box>
|
|
196
|
+
);
|
|
197
|
+
};
|