diff --git a/.storybook/stories/FromFile.stories.tsx b/.storybook/stories/FromFile.stories.tsx index 4e0356f..c4e24ef 100644 --- a/.storybook/stories/FromFile.stories.tsx +++ b/.storybook/stories/FromFile.stories.tsx @@ -1,58 +1,65 @@ -import { CSSProperties, FC, useCallback, useState } from "react"; -import { StlViewer, StlViewerProps } from "../../src"; -import { ComponentMeta } from "@storybook/react"; +import {CSSProperties, FC, useCallback, useState} from "react"; +import {StlViewer, StlViewerProps} from "../../src"; +import {ComponentMeta} from "@storybook/react"; import React from "react"; const style: CSSProperties = { - position: "absolute", - top: '0vh', - left: '0vw', - width: '100vw', - height: '100vh', - display: "flex", - alignItems: "center", - justifyContent: "center" -} + position: "absolute", + top: "0vh", + left: "0vw", + width: "100vw", + height: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", +}; function FromUrl(props: Omit) { - const [file, setFile] = useState() - - const preventDefault = useCallback((e: React.DragEvent) => { - e.preventDefault() - }, []) - - const onDrop = useCallback((e: React.DragEvent) => { - e.preventDefault() - console.log("file dropped") - setFile(e.dataTransfer.files[0]) - }, []) - - const extraProps = {onDragOver: preventDefault, onDragEnter: preventDefault, onDrop} - - return ( - <> - {file && } - {!file &&
-

drop here

-
} - - - ); + const [file, setFile] = useState(); + + const preventDefault = useCallback((e: React.DragEvent) => { + e.preventDefault(); + }, []); + + const onDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + console.log("file dropped"); + setFile(e.dataTransfer.files[0]); + }, []); + + const extraProps = { + onDragOver: preventDefault, + onDragEnter: preventDefault, + onDrop, + }; + + return ( + <> + {file && ( + + )} + {!file && ( +
+

drop here

+
+ )} + + ); } -export const Primary = FromUrl.bind({}) +export const Primary = FromUrl.bind({}); export default { - component: StlViewer, - title: "StlViewer from dropped file", -} as ComponentMeta> + component: StlViewer, + title: "StlViewer from dropped file", +} as ComponentMeta>; diff --git a/.storybook/stories/FromUrl.stories.tsx b/.storybook/stories/FromUrl.stories.tsx index 8d9c372..883919a 100644 --- a/.storybook/stories/FromUrl.stories.tsx +++ b/.storybook/stories/FromUrl.stories.tsx @@ -1,104 +1,124 @@ -import React, { FC, useRef, useState } from "react"; -import { StlViewer, StlViewerProps } from "../../src"; -import { ComponentMeta } from "@storybook/react"; -import { ModelRef } from "../../src/StlViewer/SceneSetup"; +import React, {FC, useRef, useState} from "react"; +import {ComponentMeta} from "@storybook/react"; +import {StlViewer, StlViewerProps, ModelRef, CameraRef} from "../../src"; function download(filename: string, blob: Blob) { - const element = document.createElement('a'); - element.setAttribute('download', filename); - element.style.display = 'none'; - element.href = URL.createObjectURL(blob) - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); + const element = document.createElement("a"); + element.setAttribute("download", filename); + element.style.display = "none"; + element.href = URL.createObjectURL(blob); + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); } -const url = "https://storage.googleapis.com/ucloud-v3/61575ca49d8a1777fa431395.stl" -const url2 = "https://storage.googleapis.com/ucloud-v3/2272dfa00d58a59dae26a399.stl" +const url = + "https://storage.googleapis.com/ucloud-v3/61575ca49d8a1777fa431395.stl"; +const url2 = + "https://storage.googleapis.com/ucloud-v3/2272dfa00d58a59dae26a399.stl"; function FromUrl(props: Omit) { - const ref = useRef() - const [rotationX, setRotationX] = useState(0) - const [rotationY, setRotationY] = useState(0) - const [rotationZ, setRotationZ] = useState(0) + const ref = useRef(); + const cameraRef = useRef(); + const [rotationX, setRotationX] = useState(0); + const [rotationY, setRotationY] = useState(0); + const [rotationZ, setRotationZ] = useState(0); - - return ( - <> - geometry - }} - floorProps={{ - gridWidth: 300 - }} - onFinishLoading={console.log} - {...props} - /> - - {[ - ["rotate x", setRotationX] as const, - ["rotate y", setRotationY] as const, - ["rotate z", setRotationZ] as const - ].map(([text, setRotation], index) => ( - - ))} - - - ); + return ( + <> + geometry, + }} + floorProps={{ + gridWidth: 300, + }} + onFinishLoading={console.log} + {...props} + /> + + + {[ + ["rotate x", setRotationX] as const, + ["rotate y", setRotationY] as const, + ["rotate z", setRotationZ] as const, + ].map(([text, setRotation], index) => ( + + ))} + + ); } -export const Primary = FromUrl.bind({}) +export const Primary = FromUrl.bind({}); export default { - component: StlViewer, - title: "StlViewer from url", -} as ComponentMeta> + component: StlViewer, + title: "StlViewer from url", +} as ComponentMeta>; diff --git a/README.md b/README.md index 82be40f..1566b38 100644 --- a/README.md +++ b/README.md @@ -54,24 +54,39 @@ You can see working the examples from `.storybook/stories` [here](https://gabote ## Props -| Prop | Type | Required | Notes | -|-------------------------|:------------------------------:|:--------:|:----------------------------------------------------------------------------------------------------------------:| -| `url` | `string` | `true` | url of the Stl file | -| `modelProps` | `ModelProps` | `false` | 3d model properties, see below | -| `floorProps` | `FloorProps` | `false` | floor properties, see below | -| `shadows` | `boolean` | `false` | render shadows projected by the model on the ground | -| `showAxes` | `boolean` | `false` | show x y z axis | -| `orbitControls` | `boolean` | `false` | enable camera orbit controls | -| `cameraInitialPosition` | `CameraInitialPosition` | `false` | set the initial position of the camera in geographic coordinates. The origin of coordinates is the object itself | -| `extraHeaders` | `Record` | `false` | custom headers for making the http query | -| `onFinishLoading` | `(ev: ModelDimensions) => any` | `false` | callback triggered when Stl is fully loaded | -| `onError` | `(err: Error) => any` | `false` | callback triggered when an error occurred while loading Stl | -| `canvasId` | `string` | `false` | id of the canvas element used for rendering the model | +| Prop | Type | Required | Notes | +|-------------------|:------------------------------:|:--------:|:-----------------------------------------------------------:| +| `url` | `string` | `true` | url of the Stl file | +| `cameraProps` | `CameraProps` | `false` | camera properties, see below | +| `modelProps` | `ModelProps` | `false` | 3d model properties, see below | +| `floorProps` | `FloorProps` | `false` | floor properties, see below | +| `shadows` | `boolean` | `false` | render shadows projected by the model on the ground | +| `showAxes` | `boolean` | `false` | show x y z axis | +| `orbitControls` | `boolean` | `false` | enable camera orbit controls | +| `extraHeaders` | `Record` | `false` | custom headers for making the http query | +| `onFinishLoading` | `(ev: ModelDimensions) => any` | `false` | callback triggered when Stl is fully loaded | +| `onError` | `(err: Error) => any` | `false` | callback triggered when an error occurred while loading Stl | +| `canvasId` | `string` | `false` | id of the canvas element used for rendering the model | The component also accepts ```
``` props ## Interfaces +### CameraProps + +| Prop | Type | Required | Notes | +|-------------------|:----------------------:|:--------:|:--------------------------------------------------------------------------------------------------------:| +| `ref` | `{current: CameraRef}` | `false` | reference of the camera for accessing it's properties | +| `initialPosition` | `CameraPosition` | `false` | set the position of the camera in geographic coordinates. The origin of coordinates is the object itself | + +### CameraRef + +| Prop | Type | Required | Notes | +|---------------------|:-----------------------------------:|:--------:|:----------------------------------------------------------------------------------:| +| `setCameraPosition` | `(position: CameraPosition) => any` | `true` | imperatively sets the camera position based on the provided geographic coordinates | + +setCameraPosition: + ### ModelProps | Prop | Type | Required | Notes | @@ -102,7 +117,7 @@ The component also accepts ```
``` props | `height` | `number` | height of the 3d object | | `length` | `number` | length of the 3d object | -### CameraInitialPosition +### CameraPosition | Prop | Type | Required | Notes | |-------------|:--------:|:--------:|:------------------------------------------------------------------------------------------------------------------------------------------------:| diff --git a/src/StlViewer/SceneElements/Camera.tsx b/src/StlViewer/SceneElements/Camera.tsx index d66eaa7..12ffd72 100644 --- a/src/StlViewer/SceneElements/Camera.tsx +++ b/src/StlViewer/SceneElements/Camera.tsx @@ -2,14 +2,17 @@ import React, { useEffect } from 'react' import { PerspectiveCameraProps, useThree } from '@react-three/fiber' import { Vector3 } from 'three' -export interface CameraInitialPosition { +export interface CameraPosition { latitude: number longitude: number distance: number } +/** @deprecated use {@link CameraPosition} instead */ +export type CameraInitialPosition = CameraPosition + export interface CameraProps extends Partial { - initialPosition: CameraInitialPosition + initialPosition: CameraPosition center: [number, number, number] } @@ -19,7 +22,7 @@ function clamp (min: number, value: number, max: number): number { const EPS = 0.01 -function polarToCartesian (polar: CameraInitialPosition): [number, number, number] { +export function polarToCartesian (polar: CameraPosition): [number, number, number] { const latitude = clamp(-Math.PI / 2 + EPS, polar.latitude, Math.PI / 2 - EPS) const longitude = clamp(-Math.PI + EPS, polar.longitude, Math.PI - EPS) return [ diff --git a/src/StlViewer/SceneSetup.tsx b/src/StlViewer/SceneSetup.tsx index 38cbdc1..8c1973d 100644 --- a/src/StlViewer/SceneSetup.tsx +++ b/src/StlViewer/SceneSetup.tsx @@ -1,12 +1,12 @@ import React, { CSSProperties, useEffect, useMemo, useState } from 'react' -import { useFrame, useLoader } from '@react-three/fiber' +import { useFrame, useLoader, useThree } from '@react-three/fiber' import { STLLoader } from 'three-stdlib/loaders/STLLoader' import { Box3, BufferGeometry, Color, Group, Mesh } from 'three' import { STLExporter } from './exporters/STLExporter' import Model3D, { ModelDimensions } from './SceneElements/Model3D' import Floor from './SceneElements/Floor' import Lights from './SceneElements/Lights' -import Camera, { CameraInitialPosition } from './SceneElements/Camera' +import Camera, { CameraPosition, polarToCartesian } from './SceneElements/Camera' import OrbitControls from './SceneElements/OrbitControls' const INITIAL_LATITUDE = Math.PI / 8 @@ -21,6 +21,15 @@ export interface FloorProps { gridLength?: number } +export interface CameraRef { + setCameraPosition: (position: CameraPosition) => any +} + +export interface CameraProps { + ref?: { current?: null | CameraRef } + initialPosition?: CameraPosition +} + export interface ModelRef { model: Mesh save: () => Blob @@ -40,12 +49,14 @@ export interface ModelProps { export interface SceneSetupProps { url: string - cameraInitialPosition?: Partial + /** @deprecated use cameraProps.initialPosition instead */ + cameraInitialPosition?: Partial extraHeaders?: Record shadows?: boolean showAxes?: boolean orbitControls?: boolean onFinishLoading?: (ev: ModelDimensions) => any + cameraProps?: CameraProps modelProps?: ModelProps floorProps?: FloorProps } @@ -59,12 +70,20 @@ const SceneSetup: React.FC = ( orbitControls = false, onFinishLoading = () => {}, cameraInitialPosition: { - latitude = INITIAL_LATITUDE, - longitude = INITIAL_LONGITUDE, - distance: distanceFactor + latitude: deprecatedLatitude, + longitude: deprecatedLongitude, + distance: deprecatedDistanceFactor + } = {}, + cameraProps: { + ref: cameraRef, + initialPosition: { + latitude = INITIAL_LATITUDE, + longitude = INITIAL_LONGITUDE, + distance: distanceFactor = undefined + } = {} } = {}, modelProps: { - ref, + ref: modelRef, scale = 1, positionX, positionY, @@ -80,6 +99,7 @@ const SceneSetup: React.FC = ( } = {} } ) => { + const { camera } = useThree() const [mesh, setMesh] = useState() const [meshDims, setMeshDims] = useState({ @@ -89,7 +109,7 @@ const SceneSetup: React.FC = ( boundingRadius: 0 }) - const [cameraInitialPosition, setCameraInitialPosition] = useState() + const [cameraInitialPosition, setCameraInitialPosition] = useState() const [modelCenter, setModelCenter] = useState<[number, number, number]>([0, 0, 0]) const [sceneReady, setSceneReady] = useState(false) @@ -108,37 +128,52 @@ const SceneSetup: React.FC = ( [geometry, geometryProcessor] ) + function calculateCameraDistance (boundingRadius: number, factor?: number): number { + const maxGridDimension = Math.max(gridWidth ?? 0, gridLength ?? 0) + if (maxGridDimension > 0) { + return maxGridDimension * (factor ?? 1) + } else { + return boundingRadius * (factor ?? CAMERA_POSITION_DISTANCE_FACTOR) + } + } + function onLoaded (dims: ModelDimensions, mesh: Mesh): void { setMesh(mesh) const { width, length, height, boundingRadius } = dims setMeshDims(dims) setModelCenter([positionX ?? width/2, positionY ?? length/2, height/2]) - const maxGridDimension = Math.max(gridWidth ?? 0, gridLength ?? 0) - let distance - if (maxGridDimension > 0) { - distance = maxGridDimension * (distanceFactor ?? 1) - } else { - distance = boundingRadius * (distanceFactor ?? CAMERA_POSITION_DISTANCE_FACTOR) - } setCameraInitialPosition({ - latitude, - longitude, - distance + latitude: deprecatedLatitude ?? latitude, + longitude: deprecatedLongitude ?? longitude, + distance: calculateCameraDistance(boundingRadius, deprecatedDistanceFactor ?? distanceFactor) }) onFinishLoading(dims) setTimeout(() => setSceneReady(true), 100) // let the three.js render loop place things } useEffect(() => { - if ((ref == null) || (mesh == null)) return - ref.current = { + if (cameraRef == null) return + cameraRef.current = { + setCameraPosition: ({ latitude, longitude, distance: factor }) => { + const distance = calculateCameraDistance(meshDims.boundingRadius, factor) + const [x, y, z] = polarToCartesian({ latitude, longitude, distance }) + const [cx, cy, cz] = modelCenter + camera.position.set(x + cx, y + cy, z + cz) + camera.lookAt(cx, cy, cz) + } + } + }, [camera, cameraRef, modelCenter, meshDims]) + + useEffect(() => { + if ((modelRef == null) || (mesh == null)) return + modelRef.current = { save: () => new Blob( [new STLExporter().parse(mesh, { binary: true })], { type: 'application/octet-stream' } ), model: mesh } - }, [mesh, ref]) + }, [mesh, modelRef]) useFrame(({ scene }) => { const mesh = scene.getObjectByName('mesh') as Mesh diff --git a/src/StlViewer/StlViewer.tsx b/src/StlViewer/StlViewer.tsx index e9c63d8..c4d6ae3 100644 --- a/src/StlViewer/StlViewer.tsx +++ b/src/StlViewer/StlViewer.tsx @@ -14,6 +14,7 @@ export interface StlViewerProps extends DivProps, SceneSetupProps { const StlViewer: React.FC = ( { url, + cameraProps, modelProps, floorProps, children, @@ -30,6 +31,7 @@ const StlViewer: React.FC = ( ) => { const sceneProps: SceneSetupProps = { url, + cameraProps, modelProps, floorProps, extraHeaders, diff --git a/src/StlViewer/index.ts b/src/StlViewer/index.ts index 4dc9f21..6ed5134 100644 --- a/src/StlViewer/index.ts +++ b/src/StlViewer/index.ts @@ -1,5 +1,5 @@ export { ModelDimensions } from './SceneElements/Model3D' -export { CameraInitialPosition } from './SceneElements/Camera' +export { CameraInitialPosition, CameraPosition } from './SceneElements/Camera' export { default as StlViewer } from './StlViewer' export type { StlViewerProps } from './StlViewer' -export type { ModelProps, ModelRef, FloorProps } from './SceneSetup' +export type { ModelProps, ModelRef, FloorProps, CameraProps, CameraRef } from './SceneSetup'