diff --git a/packages/three/package.json b/packages/three/package.json index 37ae6c4b..dc255e71 100644 --- a/packages/three/package.json +++ b/packages/three/package.json @@ -1,12 +1,14 @@ { "name": "@mapcomponents/three", "version": "1.7.2", - "main": "./index.js", - "types": "./index.d.ts", + "license": "MIT", + "main": "dist/index.cjs.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", "exports": { ".": { - "import": "./index.mjs", - "require": "./index.js" + "import": "dist/index.mjs", + "require": "dist/index.cjs.js" } }, "publishConfig": { diff --git a/packages/three/src/components/MlTransformControls.tsx b/packages/three/src/components/MlThreeGizmo.tsx similarity index 93% rename from packages/three/src/components/MlTransformControls.tsx rename to packages/three/src/components/MlThreeGizmo.tsx index 52ede6c2..214eded0 100644 --- a/packages/three/src/components/MlTransformControls.tsx +++ b/packages/three/src/components/MlThreeGizmo.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react'; import * as THREE from 'three'; import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; -import { useThree } from './ThreeContext'; +import { useThree } from '../contexts/ThreeContext'; -export interface MlTransformControlsProps { +export interface MlThreeGizmoProps { target?: THREE.Object3D; mode?: 'translate' | 'rotate' | 'scale'; enabled?: boolean; @@ -12,7 +12,7 @@ export interface MlTransformControlsProps { onObjectChange?: (object: THREE.Object3D) => void; } -const MlTransformControls = (props: MlTransformControlsProps) => { +const MlThreeGizmo = (props: MlThreeGizmoProps) => { const { target, mode, enabled, space, size, onObjectChange } = props; const { scene, camera, renderer, map, sceneRoot } = useThree(); const controlsRef = useRef(null); @@ -109,4 +109,4 @@ const MlTransformControls = (props: MlTransformControlsProps) => { return null; }; -export default MlTransformControls; +export default MlThreeGizmo; diff --git a/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.cy.tsx b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.cy.tsx index 6fe9fb52..52e5ab58 100644 --- a/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.cy.tsx +++ b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.cy.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import { MapComponentsProvider, MapLibreMap, useMap } from '@mapcomponents/react-maplibre'; -import { ThreeProvider } from '../ThreeProvider'; +import { ThreeProvider } from '../../contexts/ThreeProvider'; import MlThreeModelLayer from './MlThreeModelLayer'; const MapExposer = () => { @@ -20,8 +20,7 @@ const TestComponent = ({ onDone }: { onDone: () => void }) => { diff --git a/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.stories.tsx b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.stories.tsx index c6446386..82280eb1 100644 --- a/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.stories.tsx +++ b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.stories.tsx @@ -3,9 +3,8 @@ 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 { useThree } from '../../contexts/ThreeContext'; +import { MlThreeObjectControls } from '../MlThreeObjectControls'; import * as THREE from 'three'; const storyoptions = { @@ -48,17 +47,14 @@ const Lights = () => { }; 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 [sidebarOpen, setSidebarOpen] = useState(true); 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(() => { @@ -68,55 +64,22 @@ const Template: any = () => { 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 && ( )} @@ -131,26 +94,22 @@ const Template: any = () => { } /> - diff --git a/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.tsx b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.tsx index 954509bd..5d84095c 100644 --- a/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.tsx +++ b/packages/three/src/components/MlThreeModelLayer/MlThreeModelLayer.tsx @@ -1,152 +1,49 @@ -import { useEffect, useRef, useState } from 'react'; -import * as THREE from 'three'; +import { useMemo } from 'react'; 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'; +import { useThreeModel, UseThreeModelProps, ModelLoader } from '../../hooks/useThreeModel'; /** - * Renders obj or gltf 3D Models on the MapLibreMap referenced by props.mapId + * Renders obj or gltf 3D Models on the MapLibreMap * * @component */ -export interface MlThreeModelLayerProps { +export type MlThreeModelLayerProps = Omit & { 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 { + const { url, position, transform, init, onDone, customLoaders } = props; + + const loaders = useMemo>( + () => ({ + gltf: (url, onLoad) => { + const loader = new GLTFLoader(); + loader.load(url, (gltf) => onLoad(gltf.scene)); + }, + glb: (url, onLoad) => { + const loader = new GLTFLoader(); + loader.load(url, (gltf) => onLoad(gltf.scene)); + }, + obj: (url, onLoad) => { + const loader = new OBJLoader(); + loader.load(url, (obj) => onLoad(obj)); + }, + }), + [] + ); + + useThreeModel({ url, position, - mapPosition, - altitude, - rotation, - scale, - enableTransformControls, - transformMode, - onTransformChange, + transform, 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]); + loaders, + customLoaders, + }); - if (enableTransformControls && model) { - return ( - - ); - } return null; }; diff --git a/packages/three/src/components/MlThreeObjectControls.tsx b/packages/three/src/components/MlThreeObjectControls.tsx new file mode 100644 index 00000000..6327a960 --- /dev/null +++ b/packages/three/src/components/MlThreeObjectControls.tsx @@ -0,0 +1,289 @@ +import { useRef, useLayoutEffect, useState } from 'react'; +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'; +import * as THREE from 'three'; +import { LngLatLike } from 'maplibre-gl'; +import MlThreeGizmo from './MlThreeGizmo'; +import { useThree } from '../contexts/ThreeContext'; +import ThreejsUtils from '../lib/ThreejsUtils'; + +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; + mapPosition: { lng: number; lat: number }; + setMapPosition: (position: { lng: number; lat: 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 MlThreeObjectControls = ({ + showLayer, + setShowLayer, + scale, + setScale, + rotation, + setRotation, + mapPosition, + setMapPosition, + position, + setPosition, + enableTransformControls, + setEnableTransformControls, + transformMode, + setTransformMode, + layerName = 'Layer', +}: ThreeObjectControlsProps) => { + const { scene, worldMatrixInv } = useThree(); + const dummyMeshRef = useRef(undefined); + const [dummyMeshReady, setDummyMeshReady] = useState(false); + + // Create and manage dummy mesh for transform controls + useLayoutEffect(() => { + if (!scene || !worldMatrixInv || !enableTransformControls) { + // Clean up dummy mesh when controls are disabled + if (dummyMeshRef.current) { + scene?.remove(dummyMeshRef.current); + dummyMeshRef.current.geometry.dispose(); + (dummyMeshRef.current.material as THREE.Material).dispose(); + dummyMeshRef.current = undefined; + setDummyMeshReady(false); + } + return; + } + + // Create invisible dummy mesh at the model position + const geometry = new THREE.BoxGeometry(1, 1, 1); + const material = new THREE.MeshBasicMaterial({ visible: false }); + const dummyMesh = new THREE.Mesh(geometry, material); + + // Position the dummy mesh + const scenePos = ThreejsUtils.toScenePosition( + worldMatrixInv, + [mapPosition.lng, mapPosition.lat] as LngLatLike, + 0 + ); + dummyMesh.position.set( + scenePos.x + position.x, + scenePos.y + position.y, + scenePos.z + position.z + ); + dummyMesh.rotation.set( + (rotation.x * Math.PI) / 180, + (rotation.y * Math.PI) / 180, + (rotation.z * Math.PI) / 180 + ); + dummyMesh.scale.set(scale, scale, scale); + + scene.add(dummyMesh); + dummyMeshRef.current = dummyMesh; + setDummyMeshReady(true); + + return () => { + if (dummyMeshRef.current) { + scene.remove(dummyMeshRef.current); + dummyMeshRef.current.geometry.dispose(); + (dummyMeshRef.current.material as THREE.Material).dispose(); + dummyMeshRef.current = undefined; + setDummyMeshReady(false); + } + }; + }, [scene, worldMatrixInv, enableTransformControls]); + + // Update dummy mesh position when props change (but only when controls are enabled) + useLayoutEffect(() => { + if (!dummyMeshRef.current || !worldMatrixInv) return; + + const scenePos = ThreejsUtils.toScenePosition( + worldMatrixInv, + [mapPosition.lng, mapPosition.lat] as LngLatLike, + 0 + ); + dummyMeshRef.current.position.set( + scenePos.x + position.x, + scenePos.y + position.y, + scenePos.z + position.z + ); + dummyMeshRef.current.rotation.set( + (rotation.x * Math.PI) / 180, + (rotation.y * Math.PI) / 180, + (rotation.z * Math.PI) / 180 + ); + dummyMeshRef.current.scale.set(scale, scale, scale); + dummyMeshRef.current.updateMatrixWorld(true); + }, [position, rotation, scale, mapPosition, worldMatrixInv]); + + const handleObjectChange = (object: THREE.Object3D) => { + if (!worldMatrixInv) return; + + // Get the base scene position from map coordinates + const scenePos = ThreejsUtils.toScenePosition( + worldMatrixInv, + [mapPosition.lng, mapPosition.lat] as LngLatLike, + 0 + ); + + // Calculate the offset position (object position - base scene position) + setPosition({ + x: object.position.x - scenePos.x, + y: object.position.y - scenePos.y, + z: object.position.z - scenePos.z, + }); + + // Update rotation (convert from radians to degrees) + setRotation({ + x: (object.rotation.x * 180) / Math.PI, + y: (object.rotation.y * 180) / Math.PI, + z: (object.rotation.z * 180) / Math.PI, + }); + + // Update scale (assuming uniform scale) + setScale(object.scale.x); + }; + + return ( + <> + {dummyMeshReady && dummyMeshRef.current && enableTransformControls && ( + + )} + + + + {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" + /> + 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" + /> + 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/MlThreeSplatLayer/MlThreeSplatLayer.cy.tsx b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.cy.tsx index c96cb275..8b1b02c7 100644 --- a/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.cy.tsx +++ b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.cy.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import { MapComponentsProvider, MapLibreMap, useMap } from '@mapcomponents/react-maplibre'; -import { ThreeProvider } from '../ThreeProvider'; +import { ThreeProvider } from '../../contexts/ThreeProvider'; import MlThreeSplatLayer from './MlThreeSplatLayer'; const MapExposer = () => { @@ -20,8 +20,7 @@ const TestComponent = ({ onDone }: { onDone: () => void }) => { diff --git a/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.stories.tsx b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.stories.tsx index c67535e6..b845a73b 100644 --- a/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.stories.tsx +++ b/packages/three/src/components/MlThreeSplatLayer/MlThreeSplatLayer.stories.tsx @@ -5,10 +5,7 @@ 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'; +import { MlThreeObjectControls } from '../MlThreeObjectControls'; const storyoptions = { title: 'MapComponents/MlThreeSplatLayer', @@ -25,17 +22,14 @@ const storyoptions = { 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 [position, setPosition] = useState({ x: 0, y: 0, z: 30 }); + const [sidebarOpen, setSidebarOpen] = useState(true); 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(() => { @@ -45,57 +39,23 @@ const Template: any = () => { 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 && ( )} - { } /> - & { 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 { + const { url, position, transform, init, onDone, customLoaders } = props; + + const loaders = useMemo>( + () => ({ + splat: (url, onLoad) => { + const loader = new SplatLoader(); + loader.load(url, (splatMesh) => onLoad(splatMesh)); + }, + ply: (url, onLoad) => { + const loader = new PlySplatLoader(); + loader.load(url, (splatMesh) => onLoad(splatMesh)); + }, + }), + [] + ); + + useThreeModel({ url, position, - mapPosition, - altitude, - rotation, - scale, - enableTransformControls, - transformMode, - onTransformChange, + transform, 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]); + loaders, + customLoaders, + }); - if (enableTransformControls && model) { - return ( - - ); - } return null; }; diff --git a/packages/three/src/components/ThreeObjectControls.tsx b/packages/three/src/components/ThreeObjectControls.tsx deleted file mode 100644 index aa653e1c..00000000 --- a/packages/three/src/components/ThreeObjectControls.tsx +++ /dev/null @@ -1,197 +0,0 @@ -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/ThreeContext.tsx b/packages/three/src/contexts/ThreeContext.tsx similarity index 100% rename from packages/three/src/components/ThreeContext.tsx rename to packages/three/src/contexts/ThreeContext.tsx diff --git a/packages/three/src/components/ThreeProvider.tsx b/packages/three/src/contexts/ThreeProvider.tsx similarity index 100% rename from packages/three/src/components/ThreeProvider.tsx rename to packages/three/src/contexts/ThreeProvider.tsx diff --git a/packages/three/src/decorators/ThreejsContextDecorator.tsx b/packages/three/src/decorators/ThreejsContextDecorator.tsx index 126e1005..5a1b998a 100644 --- a/packages/three/src/decorators/ThreejsContextDecorator.tsx +++ b/packages/three/src/decorators/ThreejsContextDecorator.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { ThreeProvider } from '../components/ThreeProvider'; +import { ThreeProvider } from '../contexts/ThreeProvider'; import { MapComponentsProvider, MapLibreMap, diff --git a/packages/three/src/hooks/useThreeModel.tsx b/packages/three/src/hooks/useThreeModel.tsx new file mode 100644 index 00000000..be1e6656 --- /dev/null +++ b/packages/three/src/hooks/useThreeModel.tsx @@ -0,0 +1,179 @@ +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import * as THREE from 'three'; +import { LngLatLike } from 'maplibre-gl'; +import { useThree } from '../contexts/ThreeContext'; +import ThreejsUtils from '../lib/ThreejsUtils'; + +export interface ThreeModelTransform { + rotation?: { x: number; y: number; z: number }; + scale?: { x: number; y: number; z: number } | number; + position?: { x: number; y: number; z: number }; +} + +export type ModelLoader = (url: string, onSuccess: (object: THREE.Object3D) => void) => void; + +export interface UseThreeModelProps { + url: string; + position: LngLatLike; + transform?: ThreeModelTransform; + init?: () => void; + onDone?: () => void; + loaders: Record; + customLoaders?: Record; +} + +/** + * Recursively dispose of Three.js object resources to prevent memory leaks. + */ +const disposeObject = (obj: THREE.Object3D): void => { + if ((obj as any).geometry) { + (obj as any).geometry.dispose(); + } + + if ((obj as any).material) { + const material = (obj as any).material; + if (Array.isArray(material)) { + material.forEach((m) => m.dispose()); + } else { + material.dispose(); + } + } + + if ('dispose' in obj && typeof (obj as any).dispose === 'function') { + (obj as any).dispose(); + } +}; + +/** + * Hook to manage loading, transforming, and rendering a 3D model in the MapLibre/Three.js context. + */ +export const useThreeModel = (props: UseThreeModelProps) => { + const { url, position, transform, init, onDone, loaders, customLoaders } = props; + const { scene, worldMatrixInv } = useThree(); + const [model, setModel] = useState(undefined); + const modelRef = useRef(undefined); + + const initRef = useRef(init); + const onDoneRef = useRef(onDone); + initRef.current = init; + onDoneRef.current = onDone; + + const transformRef = useRef({ position, transform }); + transformRef.current = { position, transform }; + const worldMatrixInvRef = useRef(worldMatrixInv); + worldMatrixInvRef.current = worldMatrixInv; + + const allLoaders = useMemo(() => ({ ...loaders, ...customLoaders }), [loaders, customLoaders]); + + const updateModelTransform = useCallback( + (object: THREE.Object3D, currentWorldMatrixInv: THREE.Matrix4 | undefined) => { + const { position: currentPosition, transform: currentTransform } = transformRef.current; + + if (currentPosition && currentWorldMatrixInv) { + const scenePos = ThreejsUtils.toScenePosition(currentWorldMatrixInv, currentPosition, 0); + object.position.set(scenePos.x, scenePos.y, scenePos.z); + + if (currentTransform?.position) { + object.position.x += currentTransform.position.x; + object.position.y += currentTransform.position.y; + object.position.z += currentTransform.position.z; + } + } + + if (currentTransform?.rotation) { + object.rotation.set( + currentTransform.rotation.x, + currentTransform.rotation.y, + currentTransform.rotation.z + ); + } + + if (currentTransform?.scale) { + if (typeof currentTransform.scale === 'number') { + object.scale.set(currentTransform.scale, currentTransform.scale, currentTransform.scale); + } else { + object.scale.set( + currentTransform.scale.x, + currentTransform.scale.y, + currentTransform.scale.z + ); + } + } + + object.updateMatrixWorld(true); + }, + [] + ); + + const cleanup = useCallback(() => { + if (modelRef.current && scene) { + scene.remove(modelRef.current); + modelRef.current.traverse(disposeObject); + disposeObject(modelRef.current); + modelRef.current = undefined; + setModel(undefined); + } + }, [scene]); + + useEffect(() => { + if (!scene) return; + + if (typeof initRef.current === 'function') { + initRef.current(); + } + + let extension = ''; + try { + const urlObj = new URL(url, window.location.origin); + extension = urlObj.pathname.split('.').pop()?.toLowerCase() || ''; + } catch (e) { + extension = url.split('.').pop()?.toLowerCase() || ''; + } + + const loader = allLoaders[extension]; + if (!loader) { + console.warn( + `useThreeModel: No loader found for file extension "${extension}". Supported extensions: ${Object.keys(allLoaders).join(', ')}` + ); + return; + } + + let isCanceled = false; + + const handleLoad = (object: THREE.Object3D) => { + if (isCanceled) { + object.traverse(disposeObject); + disposeObject(object); + return; + } + + if (modelRef.current) { + cleanup(); + } + + modelRef.current = object; + updateModelTransform(object, worldMatrixInvRef.current); + scene.add(object); + setModel(object); + + if (typeof onDoneRef.current === 'function') { + onDoneRef.current(); + } + }; + + loader(url, handleLoad); + + return () => { + isCanceled = true; + cleanup(); + }; + }, [url, scene, allLoaders, cleanup, updateModelTransform]); + + useEffect(() => { + if (model) { + updateModelTransform(model, worldMatrixInv); + } + }, [model, position, transform, worldMatrixInv, updateModelTransform]); + + return model; +}; diff --git a/packages/three/src/index.ts b/packages/three/src/index.ts index 05b612ac..9da29fec 100644 --- a/packages/three/src/index.ts +++ b/packages/three/src/index.ts @@ -1,7 +1,9 @@ export * from './lib/ThreejsUtils'; export * from './lib/ThreejsSceneHelper'; export * from './lib/ThreejsSceneRenderer'; -export * from './components/ThreeContext'; -export * from './components/ThreeProvider'; +export * from './contexts/ThreeContext'; +export * from './contexts/ThreeProvider'; export { default as MlThreeModelLayer } from './components/MlThreeModelLayer/MlThreeModelLayer'; export { default as MlThreeSplatLayer } from './components/MlThreeSplatLayer/MlThreeSplatLayer'; +export { default as MlThreeGizmo } from './components/MlThreeGizmo'; +export { MlThreeObjectControls } from './components/MlThreeObjectControls'; diff --git a/packages/three/vite.config.ts b/packages/three/vite.config.ts index 9bd3e672..b9da46bd 100644 --- a/packages/three/vite.config.ts +++ b/packages/three/vite.config.ts @@ -26,7 +26,7 @@ export default defineConfig(() => ({ // Configuration for building your library. // See: https://vitejs.dev/guide/build.html#library-mode build: { - outDir: '../../dist/packages/three', + outDir: 'dist', emptyOutDir: true, reportCompressedSize: true, commonjsOptions: { @@ -39,7 +39,7 @@ export default defineConfig(() => ({ 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], + formats: ['es' as const, 'cjs' as const], }, rollupOptions: { // External packages that should not be bundled into your library.