diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 9a1caf4e..7d36e1ab 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -13,6 +13,7 @@ import { useKeyboard } from '@/hooks/use-keyboard' import { ZoneSystem } from '../systems/zone/zone-system' import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' +import { ReferencePanel } from '../ui/panels/reference-panel' import { SidebarProvider } from '../ui/primitives/sidebar' import { AppSidebar } from '../ui/sidebar/app-sidebar' import { CustomCameraControls } from './custom-camera-controls' @@ -31,6 +32,7 @@ export default function Editor() { + diff --git a/apps/editor/components/ui/panels/reference-panel.tsx b/apps/editor/components/ui/panels/reference-panel.tsx new file mode 100644 index 00000000..6ff1f00e --- /dev/null +++ b/apps/editor/components/ui/panels/reference-panel.tsx @@ -0,0 +1,176 @@ +'use client' + +import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core' +import { Box, Image, X } from 'lucide-react' +import { useCallback } from 'react' +import useEditor from '@/store/use-editor' + +type ReferenceNode = ScanNode | GuideNode + +export function ReferencePanel() { + const selectedReferenceId = useEditor((s) => s.selectedReferenceId) + const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) + const nodes = useScene((s) => s.nodes) + const updateNode = useScene((s) => s.updateNode) + + const node = selectedReferenceId + ? (nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined) + : undefined + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!selectedReferenceId) return + updateNode(selectedReferenceId as AnyNode['id'], updates) + }, + [selectedReferenceId, updateNode], + ) + + const handleClose = useCallback(() => { + setSelectedReferenceId(null) + }, [setSelectedReferenceId]) + + if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null + + const isScan = node.type === 'scan' + + return ( +
+ {/* Header */} +
+
+ {isScan ? ( + + ) : ( + + )} +

+ {node.name || (isScan ? '3D Scan' : 'Guide Image')} +

+
+ +
+ + {/* Content */} +
+
+ {/* Position */} +
+ +
+ {([0, 1, 2] as const).map((i) => ( +
+ + { + const value = Number.parseFloat(e.target.value) + if (!Number.isNaN(value)) { + const pos = [...node.position] as [number, number, number] + pos[i] = value + handleUpdate({ position: pos }) + } + }} + step="0.1" + type="number" + value={Math.round(node.position[i] * 100) / 100} + /> +
+ ))} +
+
+ + {/* Rotation Y */} +
+ +
+ { + const degrees = Number.parseFloat(e.target.value) + if (!Number.isNaN(degrees)) { + const radians = (degrees * Math.PI) / 180 + handleUpdate({ + rotation: [node.rotation[0], radians, node.rotation[2]], + }) + } + }} + step="1" + type="number" + value={Math.round((node.rotation[1] * 180) / Math.PI)} + /> + ° + + +
+
+ + {/* Scale */} +
+ + { + const value = Number.parseFloat(e.target.value) + if (!Number.isNaN(value) && value > 0) { + handleUpdate({ scale: value }) + } + }} + step="0.1" + type="number" + value={Math.round(node.scale * 100) / 100} + /> +
+ + {/* Opacity */} +
+
+ + {node.opacity}% +
+ handleUpdate({ opacity: Number.parseInt(e.target.value, 10) })} + step="1" + type="range" + value={node.opacity} + /> +
+
+
+
+ ) +} diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx index 673fedf8..3e7bf9d5 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx @@ -10,12 +10,15 @@ import { ChevronDown, Hexagon, Layers, + MoreHorizontal, Plus, Trash2, } from "lucide-react"; +import { useState } from "react"; import { cn } from "@/lib/utils"; import useEditor from "@/store/use-editor"; import { TreeNode } from "./tree-node"; +import { ReferencesDialog } from "./references-dialog"; import { Popover, PopoverContent, @@ -156,6 +159,8 @@ function LevelsSection() { const selectedLevelId = useViewer((state) => state.selection.levelId); const setSelection = useViewer((state) => state.setSelection); + const [referencesLevelId, setReferencesLevelId] = useState(null); + const building = selectedBuildingId ? (nodes[selectedBuildingId] as BuildingNode) : null; @@ -194,19 +199,46 @@ function LevelsSection() { {/* Level buttons */}
{levels.map((level) => ( - + + + + + + + + + +
))} {levels.length === 0 && (
@@ -214,6 +246,17 @@ function LevelsSection() {
)} + + {/* References dialog */} + {referencesLevelId && ( + { + if (!open) setReferencesLevelId(null); + }} + /> + )} ); } diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx new file mode 100644 index 00000000..245fb06d --- /dev/null +++ b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx @@ -0,0 +1,186 @@ +import { + type AnyNodeId, + type GuideNode, + GuideNode as GuideNodeSchema, + type LevelNode, + type ScanNode, + ScanNode as ScanNodeSchema, + saveAsset, + useScene, +} from '@pascal-app/core' +import { Box, Image, Pencil, Plus, Trash2 } from 'lucide-react' +import { useCallback, useRef } from 'react' +import useEditor from '@/store/use-editor' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/primitives/dialog' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/primitives/popover' + +interface ReferencesDialogProps { + levelId: string + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDialogProps) { + const nodes = useScene((s) => s.nodes) + const createNode = useScene((s) => s.createNode) + const deleteNode = useScene((s) => s.deleteNode) + const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) + + const scanInputRef = useRef(null) + const guideInputRef = useRef(null) + + const level = nodes[levelId as AnyNodeId] as LevelNode | undefined + if (!level) return null + + // Find all scan and guide children of this level + const references = Object.values(nodes).filter( + (node): node is ScanNode | GuideNode => + (node.type === 'scan' || node.type === 'guide') && node.parentId === levelId, + ) + + const handleAddScan = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const url = await saveAsset(file) + const node = ScanNodeSchema.parse({ + url, + name: file.name, + parentId: levelId, + }) + createNode(node, levelId as AnyNodeId) + e.target.value = '' + }, + [levelId, createNode], + ) + + const handleAddGuide = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const url = await saveAsset(file) + const node = GuideNodeSchema.parse({ + url, + name: file.name, + parentId: levelId, + }) + createNode(node, levelId as AnyNodeId) + e.target.value = '' + }, + [levelId, createNode], + ) + + const handleEdit = useCallback( + (nodeId: string) => { + setSelectedReferenceId(nodeId) + onOpenChange(false) + }, + [setSelectedReferenceId, onOpenChange], + ) + + const handleDelete = useCallback( + (nodeId: string) => { + deleteNode(nodeId as AnyNodeId) + }, + [deleteNode], + ) + + return ( + + + + References — {level.name || `Level ${level.level}`} + + +
+ {references.length === 0 && ( +

+ No references yet. Add a 3D scan or guide image. +

+ )} + + {references.map((ref) => ( +
+ {ref.type === 'scan' ? ( + + ) : ( + + )} + + {ref.name || (ref.type === 'scan' ? '3D Scan' : 'Guide Image')} + + + +
+ ))} +
+ +
+ + + + + + + + + + + + +
+
+
+ ) +} diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 0d80d636..05a0384f 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -58,6 +58,8 @@ type EditorState = { setSelectedItem: (item: Asset) => void movingNode: ItemNode | null setMovingNode: (node: ItemNode | null) => void + selectedReferenceId: string | null + setSelectedReferenceId: (id: string | null) => void } const useEditor = create()((set, get) => ({ @@ -173,6 +175,8 @@ const useEditor = create()((set, get) => ({ setSelectedItem: (item) => set({ selectedItem: item }), movingNode: null, setMovingNode: (node) => set({ movingNode: node }), + selectedReferenceId: null, + setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), })) export default useEditor diff --git a/bun.lock b/bun.lock index 3cfc7316..360c0829 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ "version": "0.0.1", "dependencies": { "dedent": "^1.7.1", + "idb-keyval": "^6.2.2", "mitt": "^3.0.1", "nanoid": "^5.1.6", "zod": "^4.3.5", @@ -695,6 +696,8 @@ "hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="], + "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], diff --git a/packages/core/package.json b/packages/core/package.json index 56559158..4a2b8a37 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "dedent": "^1.7.1", + "idb-keyval": "^6.2.2", "mitt": "^3.0.1", "nanoid": "^5.1.6", "zod": "^4.3.5", diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 202e6144..e831c91c 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -15,6 +15,8 @@ export const sceneRegistry = { item: new Set(), slab: new Set(), zone: new Set(), + scan: new Set(), + guide: new Set(), }, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ac9a5504..b15f97a8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -34,3 +34,5 @@ export { SlabSystem } from './systems/slab/slab-system' export { WallSystem } from './systems/wall/wall-system' export { isObject } from './utils/types' +// Asset storage +export { saveAsset, loadAssetUrl } from './lib/asset-storage' diff --git a/packages/core/src/lib/asset-storage.ts b/packages/core/src/lib/asset-storage.ts new file mode 100644 index 00000000..72f577a3 --- /dev/null +++ b/packages/core/src/lib/asset-storage.ts @@ -0,0 +1,55 @@ +import { get, set } from 'idb-keyval' + +export const ASSET_PREFIX = 'asset_data:' + +// Cache for active object URLs to prevent leaks and flickering +const urlCache = new Map() + +/** + * Save a file to IndexedDB and return a custom protocol URL + */ +export async function saveAsset(file: File): Promise { + const id = crypto.randomUUID() + await set(`${ASSET_PREFIX}${id}`, file) + return `asset://${id}` +} + +/** + * Load a file from IndexedDB and return an object URL + * If the URL is not a custom protocol URL, return it as is + */ +export async function loadAssetUrl(url: string): Promise { + if (!url) return null + + // If it's already a blob or http URL, return as is + if (url.startsWith('blob:') || url.startsWith('http')) { + return url + } + + // Handle our custom asset protocol + if (url.startsWith('asset://')) { + const id = url.replace('asset://', '') + + // Check cache first + if (urlCache.has(id)) { + return urlCache.get(id)! + } + + try { + const file = await get(`${ASSET_PREFIX}${id}`) + if (!file) { + console.warn(`Asset not found: ${id}`) + return null + } + const objectUrl = URL.createObjectURL(file) + urlCache.set(id, objectUrl) + return objectUrl + } catch (error) { + console.error('Failed to load asset:', error) + return null + } + } + + // Legacy data URLs are returned as is + return url +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index c82c05a9..1e13f807 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -13,6 +13,8 @@ export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' export { ZoneNode } from './nodes/zone' +export { ScanNode } from './nodes/scan' +export { GuideNode } from './nodes/guide' export type { AnyNodeId, AnyNodeType } from './types' // Union types export { AnyNode } from './types' diff --git a/packages/core/src/schema/nodes/guide.ts b/packages/core/src/schema/nodes/guide.ts new file mode 100644 index 00000000..c331f656 --- /dev/null +++ b/packages/core/src/schema/nodes/guide.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const GuideNode = BaseNode.extend({ + id: objectId('guide'), + type: nodeType('guide'), + url: z.string(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + scale: z.number().default(1), + opacity: z.number().min(0).max(100).default(50), +}) + +export type GuideNode = z.infer diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index c613fede..d30ff196 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -2,6 +2,8 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { CeilingNode } from './ceiling' +import { GuideNode } from './guide' +import { ScanNode } from './scan' import { SlabNode } from './slab' import { WallNode } from './wall' import { ZoneNode } from './zone' @@ -9,7 +11,7 @@ import { ZoneNode } from './zone' export const LevelNode = BaseNode.extend({ id: objectId('level'), type: nodeType('level'), - children: z.array(z.union([WallNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id])).default([]), + children: z.array(z.union([WallNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id, ScanNode.shape.id, GuideNode.shape.id])).default([]), // Specific props level: z.number().default(0), }).describe( diff --git a/packages/core/src/schema/nodes/scan.ts b/packages/core/src/schema/nodes/scan.ts new file mode 100644 index 00000000..2e2eaf1d --- /dev/null +++ b/packages/core/src/schema/nodes/scan.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const ScanNode = BaseNode.extend({ + id: objectId('scan'), + type: nodeType('scan'), + url: z.string(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + scale: z.number().default(1), + opacity: z.number().min(0).max(100).default(100), +}) + +export type ScanNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 5f99dcfe..963d82ff 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -1,8 +1,10 @@ import z from 'zod' import { BuildingNode } from './nodes/building' import { CeilingNode } from './nodes/ceiling' +import { GuideNode } from './nodes/guide' import { ItemNode } from './nodes/item' import { LevelNode } from './nodes/level' +import { ScanNode } from './nodes/scan' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' import { WallNode } from './nodes/wall' @@ -17,6 +19,8 @@ export const AnyNode = z.discriminatedUnion('type', [ ZoneNode, SlabNode, CeilingNode, + ScanNode, + GuideNode, ]) export type AnyNode = z.infer diff --git a/packages/viewer/src/components/renderers/guide/guide-renderer.tsx b/packages/viewer/src/components/renderers/guide/guide-renderer.tsx new file mode 100644 index 00000000..8b3a5038 --- /dev/null +++ b/packages/viewer/src/components/renderers/guide/guide-renderer.tsx @@ -0,0 +1,61 @@ +import { type GuideNode, useRegistry } from '@pascal-app/core' +import { Suspense, useMemo, useRef } from 'react' +import { DoubleSide, type Group, type Texture, TextureLoader } from 'three' +import { float, texture } from 'three/tsl' +import { MeshBasicNodeMaterial } from 'three/webgpu' +import { useLoader } from '@react-three/fiber' +import { useAssetUrl } from '../../../hooks/use-asset-url' + +export const GuideRenderer = ({ node }: { node: GuideNode }) => { + const ref = useRef(null!) + useRegistry(node.id, 'guide', ref) + + const resolvedUrl = useAssetUrl(node.url) + + return ( + + {resolvedUrl && ( + + + + )} + + ) +} + +const GuidePlane = ({ url, scale, opacity }: { url: string; scale: number; opacity: number }) => { + const tex = useLoader(TextureLoader, url) as Texture + + const { width, height, material } = useMemo(() => { + const img = tex.image as HTMLImageElement | ImageBitmap + const w = img.width || 1 + const h = img.height || 1 + const aspect = w / h + + // Default: 10 meters wide, height from aspect ratio + const planeWidth = 10 * scale + const planeHeight = (10 / aspect) * scale + + const normalizedOpacity = opacity / 100 + + const mat = new MeshBasicNodeMaterial({ + transparent: true, + colorNode: texture(tex), + opacityNode: float(normalizedOpacity), + side: DoubleSide, + depthWrite: false, + }) + + return { width: planeWidth, height: planeHeight, material: mat } + }, [tex, scale, opacity]) + + return ( + + + + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 8c4234ac..7d375430 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -3,8 +3,10 @@ import { type AnyNode, useScene } from '@pascal-app/core' import { BuildingRenderer } from './building/building-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer' +import { GuideRenderer } from './guide/guide-renderer' import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' +import { ScanRenderer } from './scan/scan-renderer' import { SlabRenderer } from './slab/slab-renderer' import { WallRenderer } from './wall/wall-renderer' import { ZoneRenderer } from './zone/zone-renderer' @@ -23,6 +25,8 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'slab' && } {node.type === 'wall' && } {node.type === 'zone' && } + {node.type === 'scan' && } + {node.type === 'guide' && } ) } diff --git a/packages/viewer/src/components/renderers/scan/scan-renderer.tsx b/packages/viewer/src/components/renderers/scan/scan-renderer.tsx new file mode 100644 index 00000000..246477d6 --- /dev/null +++ b/packages/viewer/src/components/renderers/scan/scan-renderer.tsx @@ -0,0 +1,64 @@ +import { type ScanNode, useRegistry } from '@pascal-app/core' +import { Clone } from '@react-three/drei/core/Clone' +import { Suspense, useMemo, useRef } from 'react' +import type { Group, Material, Mesh } from 'three' +import { useAssetUrl } from '../../../hooks/use-asset-url' +import { useGLTFKTX2 } from '../../../hooks/use-gltf-ktx2' + +export const ScanRenderer = ({ node }: { node: ScanNode }) => { + const ref = useRef(null!) + useRegistry(node.id, 'scan', ref) + + const resolvedUrl = useAssetUrl(node.url) + + return ( + + {resolvedUrl && ( + + + + )} + + ) +} + +const ScanModel = ({ url, opacity }: { url: string; opacity: number }) => { + const { scene } = useGLTFKTX2(url) + + useMemo(() => { + const normalizedOpacity = opacity / 100 + const isTransparent = normalizedOpacity < 1 + + const updateMaterial = (material: Material) => { + if (isTransparent) { + material.transparent = true + material.opacity = normalizedOpacity + } else { + material.transparent = false + material.opacity = 1 + } + material.needsUpdate = true + } + + scene.traverse((child) => { + if ((child as Mesh).isMesh) { + const mesh = child as Mesh + + if (Array.isArray(mesh.material)) { + mesh.material.forEach((material) => { + updateMaterial(material) + }) + } else { + updateMaterial(mesh.material) + } + } + }) + }, [scene, opacity]) + + return +} diff --git a/packages/viewer/src/hooks/use-asset-url.ts b/packages/viewer/src/hooks/use-asset-url.ts new file mode 100644 index 00000000..57bf04d3 --- /dev/null +++ b/packages/viewer/src/hooks/use-asset-url.ts @@ -0,0 +1,23 @@ +import { loadAssetUrl } from '@pascal-app/core' +import { useEffect, useState } from 'react' + +/** + * Resolves an asset:// URL to a blob URL for use with Three.js loaders. + * Returns null while loading or if resolution fails. + */ +export function useAssetUrl(url: string): string | null { + const [resolved, setResolved] = useState(null) + + useEffect(() => { + let cancelled = false + setResolved(null) + loadAssetUrl(url).then((result) => { + if (!cancelled) setResolved(result) + }) + return () => { + cancelled = true + } + }, [url]) + + return resolved +} diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx new file mode 100644 index 00000000..7b4bc4de --- /dev/null +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -0,0 +1,19 @@ +import { useGLTF } from "@react-three/drei" +import { useThree } from "@react-three/fiber" +import { KTX2Loader } from "three/examples/jsm/Addons.js" +import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js" + +const ktx2LoaderInstance = new KTX2Loader() +ktx2LoaderInstance.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/basis/') + +const useGLTFKTX2 = (path: string) => { + const gl = useThree((state) => state.gl) + + return useGLTF(path, true, true, (loader) => { + ktx2LoaderInstance.detectSupport(gl) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + loader.setKTX2Loader(ktx2LoaderInstance as any) + loader.setMeshoptDecoder(MeshoptDecoder) + }) +} +export { useGLTFKTX2 } \ No newline at end of file