diff --git a/core/frontend/src/components/vehiclesetup/OrientationPicker.vue b/core/frontend/src/components/vehiclesetup/OrientationPicker.vue index 73724226be..7389ecd4e1 100644 --- a/core/frontend/src/components/vehiclesetup/OrientationPicker.vue +++ b/core/frontend/src/components/vehiclesetup/OrientationPicker.vue @@ -44,9 +44,18 @@ import { get_board_model } from '@/components/vehiclesetup/viewers/modelHelper' import mavlink2rest from '@/libs/MAVLink2Rest' import autopilot_data from '@/store/autopilot' import Parameter, { printParam } from '@/types/autopilot/parameter' +import dracoDecoderPath from '@/utils/draco' -// Import DRACO decoder files using Vite's glob import -const dracoFiles = import.meta.glob('/node_modules/three/examples/jsm/libs/draco/*', { eager: true, as: 'url' }) +function makeGLTFLoader(): GLTFLoader { + const dracoLoader = new DRACOLoader() + const decoderPath = dracoDecoderPath() + if (decoderPath !== undefined) { + dracoLoader.setDecoderPath(decoderPath) + } + const loader = new GLTFLoader() + loader.setDRACOLoader(dracoLoader) + return loader +} class Rotation { name: string @@ -331,15 +340,7 @@ export default { this.vehicle_obj = undefined } - const dracoLoader = new DRACOLoader() - // Get the base path from the imported DRACO files - const dracoWasmFile = Object.keys(dracoFiles).find((key) => key.includes('draco_decoder.wasm')) - if (dracoWasmFile) { - const basePath = dracoFiles[dracoWasmFile].replace(/[^/]*$/, '') - dracoLoader.setDecoderPath(basePath) - } - const loader = new GLTFLoader() - loader.setDRACOLoader(dracoLoader) + const loader = makeGLTFLoader() loader.load( this.vehicle_model, (gltf: GLTF) => { @@ -374,15 +375,7 @@ export default { async add_board_model() { if (this.scene) { try { - const dracoLoader = new DRACOLoader() - // Get the base path from the imported DRACO files - const dracoWasmFile = Object.keys(dracoFiles).find((key) => key.includes('draco_decoder.wasm')) - if (dracoWasmFile) { - const basePath = dracoFiles[dracoWasmFile].replace(/[^/]*$/, '') - dracoLoader.setDecoderPath(basePath) - } - const loader = new GLTFLoader() - loader.setDRACOLoader(dracoLoader) + const loader = makeGLTFLoader() const board_model = await get_board_model(this.componentModel) diff --git a/core/frontend/src/utils/draco.ts b/core/frontend/src/utils/draco.ts new file mode 100644 index 0000000000..5cb1df5c8f --- /dev/null +++ b/core/frontend/src/utils/draco.ts @@ -0,0 +1,20 @@ +// three ships the same Draco decoder that GLTFLoader and would otherwise +// fetch from a Google CDN, so we bundle it to keep 3D models loadable without internet access. +const decoderFiles = import.meta.glob( + '/node_modules/three/examples/jsm/libs/draco/{draco_decoder.js,draco_decoder.wasm,draco_wasm_wrapper.js}', + { eager: true, as: 'url' }, +) + +/** + * URL of the directory serving the bundled Draco decoder, as expected by DRACOLoader's + * decoder path. Returns undefined if the decoder was not bundled, in which case loaders + * keep their default CDN location. + */ +export default function dracoDecoderPath(): string | undefined { + const wasmFile = Object.keys(decoderFiles).find((key) => key.endsWith('draco_decoder.wasm')) + if (wasmFile === undefined) { + console.warn('Bundled Draco decoder not found, 3D models will only load with internet access.') + return undefined + } + return decoderFiles[wasmFile].replace(/[^/]*$/, '') +} diff --git a/core/frontend/src/utils/model_viewer_support.ts b/core/frontend/src/utils/model_viewer_support.ts index e4c3745edf..8b3ba49b64 100644 --- a/core/frontend/src/utils/model_viewer_support.ts +++ b/core/frontend/src/utils/model_viewer_support.ts @@ -1,3 +1,7 @@ +import type { ModelViewerGlobalConfig } from '@google/model-viewer/lib/features/loading' + +import dracoDecoderPath from '@/utils/draco' + let cachedSupport: boolean | undefined let loadPromise: Promise | undefined @@ -24,15 +28,29 @@ export function canUseModelViewer(): boolean { return cachedSupport } +// model-viewer reads this global when its first element is created, so it has to be set +// before the library is imported. +function configureDecoderLocation(): void { + const decoderPath = dracoDecoderPath() + if (decoderPath === undefined) { + return + } + const scope = globalThis as unknown as { ModelViewerElement?: ModelViewerGlobalConfig } + scope.ModelViewerElement = { ...scope.ModelViewerElement, dracoDecoderLocation: decoderPath } +} + export function ensureModelViewer(): Promise { if (!canUseModelViewer()) { return Promise.resolve(false) } - loadPromise ??= import('@google/model-viewer/dist/model-viewer') - .then(() => true) - .catch((error) => { - console.warn('Failed to load model-viewer, proceeding without 3D viewer.', error) - return false - }) + if (loadPromise === undefined) { + configureDecoderLocation() + loadPromise = import('@google/model-viewer/dist/model-viewer') + .then(() => true) + .catch((error) => { + console.warn('Failed to load model-viewer, proceeding without 3D viewer.', error) + return false + }) + } return loadPromise } diff --git a/core/frontend/vite.config.js b/core/frontend/vite.config.js index 2fc103124d..f0fccac403 100644 --- a/core/frontend/vite.config.js +++ b/core/frontend/vite.config.js @@ -161,10 +161,14 @@ export default defineConfig(({ command, mode }) => { // List of file patterns to excluded from hashing const noHashPatterns = [ /ArduPilot-Parameter-Repository.*\.json$/, - /three\/examples\/jsm\/libs\/draco\// ]; if (assetInfo.name) { + // DRACOLoader is configured with a directory and looks up the decoder files by + // name inside it, so they must be unhashed and kept together. + if (/three\/examples\/jsm\/libs\/draco\//.test(assetInfo.name)) { + return `assets/draco/[name][extname]`; + } if (noHashPatterns.some(pattern => pattern.test(assetInfo.name))) { return `assets/[name][extname]`; }