diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 172ad0d..9eb561b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -7,5 +7,6 @@ module.exports = { plugins: ['react-refresh'], rules: { 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + '@typescript-eslint/no-explicit-any': 'off', }, }; diff --git a/fixtures/glxf/TableScene.json b/fixtures/glxf/TableScene.json new file mode 100644 index 0000000..16f0734 --- /dev/null +++ b/fixtures/glxf/TableScene.json @@ -0,0 +1,59 @@ +{ + "asset": { + "version": "2.0", + "experience": true + }, + "assets": [ + { + "uri": "RedTable.glb" + }, + { + "uri": "IridescentDishWithOlives.glb" + }, + { + "uri": "StainedGlassLamp.glb" + } + ], + "nodes": [ + { + "name": "root", + "children": [1, 2, 3, 4] + }, + { + "name": "Table", + "asset": 0, + "translation": [0, 0, 0] + }, + { + "name": "IridescentDishWithOlives", + "asset": 1, + "translation": [0, 0, -0.2] + }, + { + "name": "Lamp", + "asset": 2, + "translation": [0.2, 0, 0.4] + }, + { + "camera": 0, + "rotation": [-0.12909476333357384, -0.5350785203344225, -0.08315080323337401, 0.8307294764712628], + "translation": [-1.1538678407669067, 0.3662871718406677, 0.4870622456073761] + } + ], + "scene": 0, + "scenes": [ + { + "nodes": [0] + } + ], + "cameras": [ + { + "perspective": { + "yfov": 1, + "zfar": 763, + "znear": 0.07 + }, + "type": "perspective" + } + ] +} diff --git a/package.json b/package.json index 51178e6..e1a5554 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "prepare": "npm run build", - "preview": "vite preview" + "preview": "vite preview", + "generate-src": "node scripts/generate-src.js" }, "devDependencies": { "@types/node": "^20.10.4", diff --git a/scripts/generate-src.js b/scripts/generate-src.js new file mode 100644 index 0000000..cc9b9f4 --- /dev/null +++ b/scripts/generate-src.js @@ -0,0 +1,78 @@ +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Convert a glTF-style JSON scene to an array of SrcObj objects + * @param {Object} json - The glTF JSON scene + * @param {string} baseUrl - Base URL for assets + * @returns {Array} Array of SrcObj objects + */ +function glxfToSrc(json, baseUrl = '') { + const srcObjects = []; + + // Get the root scene + const rootScene = json.scenes[json.scene]; + if (!rootScene) return srcObjects; + + // Function to process nodes recursively + function processNode(nodeIndex) { + const node = json.nodes[nodeIndex]; + if (!node) return; + + // If this node has an asset, create a SrcObj + if (node.asset !== undefined && json.assets[node.asset]) { + const asset = json.assets[node.asset]; + const srcObj = { + url: baseUrl + asset.uri, + label: node.name, + }; + + // Add position if translation exists + if (node.translation) { + srcObj.position = [node.translation[0], node.translation[1], node.translation[2]]; + } + + // Add rotation if it exists (convert from quaternion to Euler if needed) + if (node.rotation) { + // Note: The JSON has quaternion rotation [x, y, z, w] + // You might need to convert this to Euler angles depending on your needs + // For now, we'll store it as-is and let Three.js handle it + srcObj.rotation = [node.rotation[0], node.rotation[1], node.rotation[2]]; + } + + // Add scale if it exists + if (node.scale) { + srcObj.scale = [node.scale[0], node.scale[1], node.scale[2]]; + } + + srcObjects.push(srcObj); + } + + // Process children recursively + if (node.children) { + node.children.forEach((childIndex) => processNode(childIndex)); + } + } + + // Process all root nodes + rootScene.nodes.forEach((nodeIndex) => processNode(nodeIndex)); + + return srcObjects; +} + +// Read the TableScene.json file +const sceneJsonPath = resolve(__dirname, '../fixtures/glxf/TableScene.json'); +const sceneJson = JSON.parse(readFileSync(sceneJsonPath, 'utf8')); + +// Base URL for the assets (adjust as needed) +const baseUrl = 'https://glxf-demo-assets.vercel.app/'; + +// Convert the scene to SrcObj array +const srcArray = glxfToSrc(sceneJson, baseUrl); + +// Output the result +console.log(JSON.stringify(srcArray, null, 2)); diff --git a/src/App.tsx b/src/App.tsx index fa2797a..d6a14e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,7 @@ import { useControls } from 'leva'; import { normalizeSrc, ViewerRef, SrcObj, Viewer, ControlPanel } from '../index'; import { Toaster } from '@/components/ui/sonner'; import { toast } from 'sonner'; -// @ts-ignore +// @ts-expect-error import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify'; import useStore from './Store'; import { PresetsType } from '@react-three/drei/helpers/environment-assets'; @@ -13,12 +13,7 @@ function App() { const viewerRef = useRef(null); const loadedUrlsRef = useRef([]); - const { - cameraMode, - environmentMap, - setAmbientLightIntensity, - setEnvironmentMap - } = useStore(); + const { cameraMode, environmentMap, setAmbientLightIntensity, setEnvironmentMap } = useStore(); // Configurable app data, includes list of models and scene/UI presets const config = { @@ -75,20 +70,36 @@ function App() { scale: [1, 1, 1], }, ] as SrcObj[], - 'Stanford Bunny': - 'https://raw.githubusercontent.com/JulieWinchester/aleph-assets/main/bunny.glb', - // 'Frog (Draco) URL': 'https://aleph-gltf-models.netlify.app/Frog.glb', + 'glxf demo': [ + { + url: 'https://glxf-demo-assets.vercel.app/RedTable.glb', + label: 'Table', + position: [0, 0, 0], + }, + { + url: 'https://glxf-demo-assets.vercel.app/IridescentDishWithOlives.glb', + label: 'IridescentDishWithOlives', + position: [0, 0, -0.2], + }, + { + url: 'https://glxf-demo-assets.vercel.app/StainedGlassLamp.glb', + label: 'Lamp', + position: [0.2, 0, 0.4], + }, + ] as SrcObj[], + 'Stanford Bunny': 'https://raw.githubusercontent.com/JulieWinchester/aleph-assets/main/bunny.glb', + // 'Frog (Draco) URL': 'https://aleph-gltf-models.netlify.app/Frog.glb', }, scene: { ambientLightIntensity: 0, environmentMap: 'apartment', rotation: [0, 0, 0], // Default rotation in radians - } + }, }; // https://github.com/KhronosGroup/glTF-Sample-Assets/blob/main/Models/Models-showcase.md // https://github.com/google/model-viewer/tree/master/packages/modelviewer.dev/assets - const [{ src }, _setLevaControls] = useControls(() => ({ + const [{ src }] = useControls(() => ({ src: { options: config.srcs, }, diff --git a/src/components/viewer.tsx b/src/components/viewer.tsx index ce7a852..ab6e945 100644 --- a/src/components/viewer.tsx +++ b/src/components/viewer.tsx @@ -1,7 +1,7 @@ import '@/viewer.css'; import '../index.css'; import React, { RefObject, Suspense, forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; -import { Canvas, useThree } from '@react-three/fiber'; +import { Canvas, useThree, ThreeEvent } from '@react-three/fiber'; import { GLTF } from '@/components/gltf'; import { CameraControls, @@ -37,7 +37,7 @@ import { getBoundingSphere, normalizeSrc } from '@/lib/utils'; function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { const boundsRef = useRef(null); - const boundsLineRef = useRef(null); + const boundsLineRef = useRef(); // React.MutableRefObject | Falsey const boundsSphereRef = useRef(null); const rotationControlsRef = useRef(null); @@ -76,9 +76,7 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { srcs, } = useStore(); - const rotationMatrixRef = useRef( - new Matrix4().makeRotationFromEuler(rotationEuler) - ); + const rotationMatrixRef = useRef(new Matrix4().makeRotationFromEuler(rotationEuler)); const triggerCameraUpdateEvent = useEventTrigger(CAMERA_UPDATE); @@ -90,18 +88,18 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { }, [src]); // rotationXDegrees, rotationYDegrees, rotationZDegrees changed - useEffect(() => { + useEffect(() => { setRotationFromArray([ - rotationXDegrees * (Math.PI / 180), - rotationYDegrees * (Math.PI / 180), - rotationZDegrees * (Math.PI / 180) - ]) + rotationXDegrees * (Math.PI / 180), + rotationYDegrees * (Math.PI / 180), + rotationZDegrees * (Math.PI / 180), + ]); }, [rotationEuler, rotationXDegrees, rotationYDegrees, rotationZDegrees]); // When loaded, set initial rotation // todo: this looks good, but wrap up all the rotation setting in functions useEffect(() => { - if (!loading && rotationPreset) setRotationFromArray(rotationPreset, true); + if (!loading && rotationPreset) setRotationFromArray(rotationPreset, true); }, [loading]); // when loaded or camera type changed, zoom to object(s) instantaneously @@ -115,14 +113,14 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { [loading, cameraMode] ); - const handleRecenterEvent = (e: any) => { + const handleRecenterEvent = (e: CustomEvent) => { recenter(e.detail); }; useEventListener(RECENTER, handleRecenterEvent); - const handleCameraEnabledEvent = (e: any) => { - (cameraRefs.controls.current as any).enabled = e.detail; + const handleCameraEnabledEvent = (e: CustomEvent) => { + cameraRefs.controls.current!.enabled = e.detail; }; useEventListener(CAMERA_CONTROLS_ENABLED, handleCameraEnabledEvent); @@ -157,8 +155,8 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { if (orthographicEnabled) { const cameraObjectDistance = cameraRefs.controls.current?.distance; if (cameraObjectDistance) { - camera.near = cameraObjectDistance - (radius * 100); - camera.far = cameraObjectDistance + (radius * 100); + camera.near = cameraObjectDistance - radius * 100; + camera.far = cameraObjectDistance + radius * 100; camera.updateProjectionMatrix(); } @@ -167,11 +165,11 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { const width = camera.right - camera.left; const height = camera.top - camera.bottom; const diameter = radius * 2; - const zoom = Math.min( width / diameter, height / diameter ); + const zoom = Math.min(width / diameter, height / diameter); // Don't set maximum zoom for multiple objects - cameraRefs.controls.current.maxZoom = (srcs.length === 1) ? (zoom*4) : Infinity; - cameraRefs.controls.current.minZoom = zoom/4; + cameraRefs.controls.current.maxZoom = srcs.length === 1 ? zoom * 4 : Infinity; + cameraRefs.controls.current.minZoom = zoom / 4; } } } else { @@ -181,7 +179,7 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { if (cameraRefs.controls.current) { // Don't set minimum distance for multiple objects - cameraRefs.controls.current.minDistance = (srcs.length === 1) ? radius : Number.EPSILON; + cameraRefs.controls.current.minDistance = srcs.length === 1 ? radius : Number.EPSILON; cameraRefs.controls.current.maxDistance = radius * 5; } } @@ -196,7 +194,7 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { if (setRotationDegrees) { setRotationXDegrees(rotationEuler.x * (180 / Math.PI)); setRotationYDegrees(rotationEuler.y * (180 / Math.PI)); - setRotationZDegrees(rotationEuler.z * (180 / Math.PI)); + setRotationZDegrees(rotationEuler.z * (180 / Math.PI)); } } @@ -206,7 +204,7 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { setRotationEuler(rotationEuler.setFromRotationMatrix(matrix, 'XYZ')); setRotationXDegrees(rotationEuler.x * (180 / Math.PI)); setRotationYDegrees(rotationEuler.y * (180 / Math.PI)); - setRotationZDegrees(rotationEuler.z * (180 / Math.PI)); + setRotationZDegrees(rotationEuler.z * (180 / Math.PI)); } function getGridProperties(): [size?: number | undefined, divisions?: number | undefined] { @@ -218,23 +216,23 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { for (const breakPoint of breakPoints) { if (boundsSphereRef.current.radius! < breakPoint) { - cellWidth = breakPoint/10.0; + cellWidth = breakPoint / 10.0; break; - } + } } - - return [cellWidth * 100.0, 100] + + return [cellWidth * 100.0, 100]; } else { return [100, 100]; } } function Bounds({ lineVisible, children }: { lineVisible?: boolean; children: React.ReactNode }) { - // @ts-ignore + // @ts-expect-error todo: get type correct for boundsLineRef useHelper(boundsLineRef, BoxHelper, 'white'); // zoom to object on double click in scene mode - const handleDoubleClickEvent = (e: any) => { + const handleDoubleClickEvent = (e: ThreeEvent) => { if (mode === 'scene') { e.stopPropagation(); if (e.delta <= 2) { @@ -256,6 +254,7 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { setSelectedAnnotation(null); } }}> + {/* @ts-expect-error todo: get type correct for boundsLineRef */} {lineVisible ? {children} : children} ); @@ -292,8 +291,8 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { ); } - function onCameraChange(e: any) { - if (e.type !== 'update') { + function onCameraChange(e?: { type: 'update' }) { + if (!e || e.type !== 'update') { return; } @@ -316,39 +315,50 @@ function Scene({ envPreset, onLoad, src, rotationPreset }: ViewerProps) { return ( <> - {orthographicEnabled ? : } + {orthographicEnabled ? ( + + ) : ( + + )} }> - setRotationFromMatrix4(local)} - scale={300} - > - - {srcs.map((src, index) => { return ( - - );})} - - + {(() => { + const children = ( + + {srcs.map((src, index) => { + return ; + })} + + ); + + return sceneControlsEnabled && mode == 'scene' ? ( + setRotationFromMatrix4(local)} + scale={300}> + {children} + + ) : ( + children + ); + })()} {Tools[mode]} - { (gridEnabled && mode == 'scene') && } - { (axesEnabled && mode == 'scene') && + {gridEnabled && mode == 'scene' && } + {axesEnabled && mode == 'scene' && ( - } + )} ); } diff --git a/src/lib/hooks/use-event.ts b/src/lib/hooks/use-event.ts index 0fd39a8..729d666 100644 --- a/src/lib/hooks/use-event.ts +++ b/src/lib/hooks/use-event.ts @@ -1,12 +1,12 @@ import { useEffect, useCallback } from 'react'; -function useEventListener(eventName: string, handler: (e?: any) => void) { +function useEventListener(eventName: string, handler: (e: CustomEvent) => void) { useEffect(() => { - window.addEventListener(eventName, handler); + window.addEventListener(eventName, handler as EventListener); // Remove event listener on cleanup return () => { - window.removeEventListener(eventName, handler); + window.removeEventListener(eventName, handler as EventListener); }; }, [eventName, handler]); // Re-run if eventName or handler changes } @@ -14,7 +14,7 @@ function useEventListener(eventName: string, handler: (e?: any) => void) { function useEventTrigger(eventName: string) { // Event trigger function const triggerEvent = useCallback( - (detail?: any) => { + (detail?: unknown) => { const event = new CustomEvent(eventName, { detail }); window.dispatchEvent(event); }, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index fb33a61..bf0a7e7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -52,16 +52,16 @@ export const copyText = (text: string) => { }; export const downloadJsonFile = (json: string) => { - const fileName = "aleph_annotations.json"; - const data = new Blob([json], { type: "text/json" }); + const fileName = 'aleph_annotations.json'; + const data = new Blob([json], { type: 'text/json' }); const jsonURL = window.URL.createObjectURL(data); - const link = document.createElement("a"); + const link = document.createElement('a'); document.body.appendChild(link); link.href = jsonURL; - link.setAttribute("download", fileName); + link.setAttribute('download', fileName); link.click(); document.body.removeChild(link); -} +}; export const parseAnnotations = (value: any) => { value.forEach((anno: any) => {