diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 3745d93e..69bd9a7f 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -24,9 +24,11 @@ Response, UploadFile, ) +from fastapi.concurrency import run_in_threadpool from fastapi.responses import StreamingResponse from geojson_pydantic import FeatureCollection from loguru import logger as log +from minio.error import S3Error from psycopg import Connection from psycopg.rows import dict_row from stream_zip import NO_COMPRESSION_64, stream_zip @@ -1913,6 +1915,185 @@ async def head_odm_assets( ) +# --------------------------------------------------------------------------- +# 3D Tiles proxy +# +# 3D Tiles consist of a root ``tileset.json`` plus a tree of individual tile +# binaries (``.b3dm``, ``.glb``, …) referenced via relative paths. Presigning +# every tile would be impractical (hundreds of files, expiry windows, mutated +# URLs in tileset.json), so we stream them through the backend instead. All +# authentication and access control happens here; S3 stays private. +# +# Layout in S3: ``projects/{project_id}/3d-tiles/...`` +# --------------------------------------------------------------------------- + +# Cache lifetime for proxied tiles. Tiles are content-addressed (they don't +# change once written for a given pipeline run) so 1 hour is conservative. +# ETag-based revalidation handles changes after expiry. +_TILE_CACHE_MAX_AGE = 3600 + +_TILE_CONTENT_TYPES: dict[str, str] = { + ".b3dm": "application/octet-stream", + ".i3dm": "application/octet-stream", + ".cmpt": "application/octet-stream", + ".pnts": "application/octet-stream", + ".glb": "model/gltf-binary", + ".gltf": "model/gltf+json", + ".json": "application/json", + ".bin": "application/octet-stream", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".ktx2": "image/ktx2", + ".webp": "image/webp", +} + + +def _tile_content_type(file_path: str) -> str: + suffix = f".{file_path.rsplit('.', 1)[-1].lower()}" if "." in file_path else "" + return _TILE_CONTENT_TYPES.get(suffix, "application/octet-stream") + + +def _validate_tile_path(file_path: str) -> str: + """Validate and normalise a tile sub-path. + + Rejects empty paths, absolute paths, parent traversal (raw or URL-encoded - + FastAPI URL-decodes path params already, so a single ``..`` check suffices), + backslashes (Windows-style), and any control characters that could be used + to inject CR/LF into S3 keys. + """ + if not file_path: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Empty tile path." + ) + if file_path.startswith("/") or "\\" in file_path: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Invalid tile path." + ) + # Split on '/' and reject any '..' segment. Substring check would also + # match legitimate filenames like ``foo..bar`` so we go segment-wise. + for segment in file_path.split("/"): + if segment in {"", ".", ".."}: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Invalid tile path." + ) + if any(ord(c) < 0x20 for c in file_path): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Invalid tile path." + ) + return file_path + + +def _build_tile_response_headers(stat) -> dict[str, str]: + """Common cache + integrity headers for tile responses (HEAD and GET).""" + headers = { + "Cache-Control": f"public, max-age={_TILE_CACHE_MAX_AGE}", + "Content-Length": str(stat.size), + } + if stat.etag: + # MinIO surfaces the raw S3 ETag (already wrapped in quotes when + # multi-part). Strip surrounding whitespace just in case. + headers["ETag"] = stat.etag.strip('"') + if stat.last_modified: + headers["Last-Modified"] = stat.last_modified.strftime( + "%a, %d %b %Y %H:%M:%S GMT" + ) + return headers + + +async def _stat_3d_tile(project_id: uuid.UUID, file_path: str): + """Stat a 3D-tile object. 404 on miss, propagates other errors as 502.""" + object_key = f"projects/{project_id}/3d-tiles/{file_path}" + try: + return await run_in_threadpool( + s3_client().stat_object, settings.S3_BUCKET_NAME, object_key + ), object_key + except S3Error as exc: + if exc.code in {"NoSuchKey", "NoSuchBucket"}: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"3D tile not found: {file_path}", + ) + log.exception(f"S3 error fetching 3D tile {object_key}: {exc}") + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail="Object store error." + ) + + +@router.head("/{project_id}/3d-tiles/{file_path:path}", tags=["3D Model"]) +async def head_3d_tile( + project_id: uuid.UUID, + file_path: str, + project: Annotated[ + project_schemas.DbProject, Depends(project_deps.get_project_by_id) + ], +): + """Check whether a 3D tile exists without streaming its content. + + Used by the frontend before initialising TilesRenderer so the placeholder + can be shown immediately when tiles haven't been generated yet. + """ + file_path = _validate_tile_path(file_path) + stat, _ = await _stat_3d_tile(project_id, file_path) + return Response( + media_type=_tile_content_type(file_path), + headers=_build_tile_response_headers(stat), + ) + + +@router.get("/{project_id}/3d-tiles/{file_path:path}", tags=["3D Model"]) +async def stream_3d_tile( + request: Request, + project_id: uuid.UUID, + file_path: str, + project: Annotated[ + project_schemas.DbProject, Depends(project_deps.get_project_by_id) + ], +): + """Stream a single 3D Tiles asset (tileset.json or a tile file) from S3. + + TilesRenderer fetches ``tileset.json`` then resolves all tile paths + relative to it. This endpoint proxies every such request through the + backend so the private S3 bucket is never exposed directly. + + The response carries ``Cache-Control: public, max-age=3600`` and the + object's S3 ETag so the browser can cache aggressively and revalidate + cheaply via ``If-None-Match`` on cache expiry. + """ + file_path = _validate_tile_path(file_path) + stat, object_key = await _stat_3d_tile(project_id, file_path) + + response_headers = _build_tile_response_headers(stat) + + # Conditional GET: if the client already has the current version, return + # 304 immediately and skip the S3 download entirely. + if_none_match = request.headers.get("if-none-match") + if if_none_match and response_headers.get("ETag"): + client_etags = {tag.strip().strip('"') for tag in if_none_match.split(",")} + if response_headers["ETag"] in client_etags or "*" in client_etags: + return Response( + status_code=HTTPStatus.NOT_MODIFIED, headers=response_headers + ) + + def generate(): + response = s3_client().get_object(settings.S3_BUCKET_NAME, object_key) + try: + while True: + chunk = response.read(65536) + if not chunk: + break + yield chunk + finally: + response.close() + response.release_conn() + + return StreamingResponse( + generate(), + media_type=_tile_content_type(file_path), + headers=response_headers, + ) + + # Endpoint not used in production but useful to keep around just for testing the # queue @router.post("/test/arq_task") diff --git a/src/frontend/package.json b/src/frontend/package.json index d9b6e9d1..122fbbc8 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -12,6 +12,7 @@ "start": "vite" }, "dependencies": { + "3d-tiles-renderer": "^0.4.24", "@cyntler/react-doc-viewer": "^1.17.0", "@geomatico/maplibre-cog-protocol": "^0.3.1", "@hotosm/gcp-editor": "workspace:*", @@ -75,6 +76,7 @@ "redux-saga": "^1.3.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", + "three": "^0.184.0", "uuid": "^9.0.1" }, "devDependencies": { @@ -86,6 +88,7 @@ "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@types/react-transition-group": "^4.4.12", + "@types/three": "^0.184.1", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vitejs/plugin-react": "^4.3.4", @@ -102,7 +105,8 @@ "prettier-plugin-tailwindcss": "^0.5.14", "tailwindcss": "^3.4.17", "typescript": "^5.9.2", - "vite": "^5.4.11" + "vite": "^5.4.11", + "vite-plugin-static-copy": "^2.3.2" }, "peerDependencies": { "@awesome.me/webawesome": "3.2.1" diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/ImageReview.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/ImageReview.tsx index e5488de7..18cd6c30 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/ImageReview.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/ImageReview.tsx @@ -1395,7 +1395,7 @@ ${safeReason && ["rejected", "unmatched", "invalid_exif", "duplicate"].includes( )} - {/* Rubber-band selection rectangle — updated via ref to avoid re-renders */} + {/* Rubber-band selection rectangle - updated via ref to avoid re-renders */}
}) => { ); return getBbox(tasksCollectiveGeojson as FeatureCollection); } - // No tasks yet — fall back to the project outline bbox + // No tasks yet - fall back to the project outline bbox return projectData?.outline?.properties?.bbox ?? null; }, [tasksData, projectData?.outline]); diff --git a/src/frontend/src/routes/appRoutes.ts b/src/frontend/src/routes/appRoutes.ts index 99ecd52f..36b3d40f 100644 --- a/src/frontend/src/routes/appRoutes.ts +++ b/src/frontend/src/routes/appRoutes.ts @@ -16,6 +16,7 @@ const UpdateUserProfile = lazy(() => import("@Views/UpdateUserProfile")); const RegulatorsApprovalPage = lazy(() => import("@Views/RegulatorsApprovalPage")); const Tutorials = lazy(() => import("@Views/Tutorial")); const ImportPage = lazy(() => import("@Views/Import")); +const View3DModel = lazy(() => import("@Views/View3DModel")); const appRoutes: IRoute[] = [ ...userRoutes, @@ -97,6 +98,12 @@ const appRoutes: IRoute[] = [ component: ImportPage, authenticated: true, }, + { + path: "/projects/:id/3d-model", + name: "3D Model Viewer", + component: View3DModel, + authenticated: false, + }, ]; export default appRoutes; diff --git a/src/frontend/src/views/IndividualProject/index.tsx b/src/frontend/src/views/IndividualProject/index.tsx index 84799808..8e132d05 100644 --- a/src/frontend/src/views/IndividualProject/index.tsx +++ b/src/frontend/src/views/IndividualProject/index.tsx @@ -237,6 +237,17 @@ const IndividualProject = () => { > GCP Editor + {projectData?.image_processing_status === "SUCCESS" && ( + + )}
+ + {projectName ? `${projectName} - 3D Model` : "3D Model Viewer"} + +
+ +
+
+ + {modelState === "loaded" && ( + + )} + + {isFetching && ( +
+ Loading project… +
+ )} + + {!isFetching && modelState === "loading" && ( +
+ Loading 3D model… +
+ )} + + {!isFetching && modelState === "unavailable" && ( +
+
+ + view_in_ar + +

+ 3D model not yet generated +

+

+ The 3D model will be available after it's been post-processed. +

+
+
+ )} + + {!isFetching && modelState === "error" && ( +
+
+ + error_outline + +

+ Failed to load 3D model +

+

+ Try refreshing the page. If the problem persists, check the project's 3D tiles + in S3. +

+
+
+ )} + + {!isFetching && modelState === "loaded" && ( +
+ Drag to pan · Scroll to zoom · Right-click drag to rotate +
+ )} +
+
+ ); +}; + +export default hasErrorBoundary(View3DModel); diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index 0ccd4b18..b3b64649 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -1,6 +1,7 @@ import react from "@vitejs/plugin-react"; import { domToCodePlugin } from "dom-to-code/vite"; import { defineConfig } from "vite"; +import { viteStaticCopy } from "vite-plugin-static-copy"; export default defineConfig({ base: "/", @@ -11,6 +12,20 @@ export default defineConfig({ mode: "react", }) : undefined, + // Self-host the DRACO and KTX2 decoders shipped with three.js so the 3D + // model viewer doesn't depend on an external CDN at runtime. + viteStaticCopy({ + targets: [ + { + src: "node_modules/three/examples/jsm/libs/draco/*", + dest: "three-libs/draco", + }, + { + src: "node_modules/three/examples/jsm/libs/basis/*", + dest: "three-libs/basis", + }, + ], + }), ], optimizeDeps: { esbuildOptions: { diff --git a/src/pnpm-lock.yaml b/src/pnpm-lock.yaml index deda1a0e..0938062f 100644 --- a/src/pnpm-lock.yaml +++ b/src/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: frontend: dependencies: + 3d-tiles-renderer: + specifier: ^0.4.24 + version: 0.4.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0) '@awesome.me/webawesome': specifier: 3.2.1 version: 3.2.1(@floating-ui/utils@0.2.11)(@types/react@19.2.14) @@ -200,6 +203,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.19) + three: + specifier: ^0.184.0 + version: 0.184.0 uuid: specifier: ^9.0.1 version: 9.0.1 @@ -228,6 +234,9 @@ importers: '@types/react-transition-group': specifier: ^4.4.12 version: 4.4.12(@types/react@19.2.14) + '@types/three': + specifier: ^0.184.1 + version: 0.184.1 '@typescript-eslint/eslint-plugin': specifier: ^5.62.0 version: 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.2.0)(typescript@5.9.3))(eslint@8.2.0)(typescript@5.9.3) @@ -279,6 +288,9 @@ importers: vite: specifier: ^5.4.11 version: 5.4.21(@types/node@22.19.15) + vite-plugin-static-copy: + specifier: ^2.3.2 + version: 2.3.2(vite@5.4.21(@types/node@22.19.15)) gcp-editor: dependencies: @@ -328,6 +340,29 @@ importers: packages: + 3d-tiles-renderer@0.4.24: + resolution: {integrity: sha512-1n21vaWoV5e+N6rTfK1nv0Bsgu9ptbY6d9ICHnT6W1llloIhUGmGzx5/JH+B7t9XggjL0aJASn0Z5sFOXXvYjw==} + peerDependencies: + '@babylonjs/core': '>=8.0.0' + '@babylonjs/loaders': '>=8.0.0' + '@react-three/fiber': ^8.17.9 || ^9.0.0 + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + three: '>=0.167.0' + peerDependenciesMeta: + '@babylonjs/core': + optional: true + '@babylonjs/loaders': + optional: true + '@react-three/fiber': + optional: true + react: + optional: true + react-dom: + optional: true + three: + optional: true + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -972,6 +1007,9 @@ packages: peerDependencies: preact: '>=10.13.0' + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@emotion/is-prop-valid@1.4.0': resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} @@ -2103,6 +2141,9 @@ packages: '@turf/truncate@5.1.5': resolution: {integrity: sha512-WjWGsRE6o1vUqULGb/O7O1eK6B4Eu6R/RBZWnF0rH0Os6WVel6tHktkeJdlKwz9WElIEO12wDIu6uKd54t7DDQ==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2179,12 +2220,18 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + '@types/stylis@4.2.7': resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==} '@types/supercluster@7.1.3': resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@types/three@0.184.1': + resolution: {integrity: sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2194,6 +2241,9 @@ packages: '@types/w3c-web-usb@1.0.13': resolution: {integrity: sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@typescript-eslint/eslint-plugin@5.62.0': resolution: {integrity: sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2390,6 +2440,7 @@ packages: '@xmldom/xmldom@0.9.8': resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yume-chan/adb-credential-web@2.1.0': resolution: {integrity: sha512-ps5XWt+xxm6Mi9eF80ePprPJ/uRFMhfR437betQE8hixrjdZpkZgMlqZU6GlnuebMi0djrbP8GWB49jJekJVYA==} @@ -3117,6 +3168,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -3169,6 +3223,10 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -3276,6 +3334,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -3537,6 +3598,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -3660,6 +3724,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3797,6 +3864,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + p-queue@8.1.1: resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} engines: {node: '>=18'} @@ -4546,6 +4617,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three@0.184.0: + resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4684,6 +4758,10 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unplugin@0.10.2: resolution: {integrity: sha512-6rk7GUa4ICYjae5PrAllvcDeuT8pA9+j5J5EkxbMFaV+SalHhxZ7X2dohMzu6C3XzsMT+6jwR/+pwPNR3uK9MA==} @@ -4739,6 +4817,12 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true + vite-plugin-static-copy@2.3.2: + resolution: {integrity: sha512-iwrrf+JupY4b9stBttRWzGHzZbeMjAHBhkrn67MNACXJVjEMRpCI10Q3AkxdBkl45IHaTfw/CNVevzQhP7yTwg==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -4880,6 +4964,12 @@ packages: snapshots: + 3d-tiles-renderer@0.4.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0): + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + three: 0.184.0 + '@alloc/quick-lru@5.2.0': {} '@asamuzakjp/css-color@5.0.1': @@ -5710,6 +5800,8 @@ snapshots: dependencies: preact: 10.29.0 + '@dimforge/rapier3d-compat@0.12.0': {} + '@emotion/is-prop-valid@1.4.0': dependencies: '@emotion/memoize': 0.9.0 @@ -6741,6 +6833,8 @@ snapshots: '@turf/helpers': 5.1.5 '@turf/meta': 5.2.0 + '@tweenjs/tween.js@23.1.3': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -6821,18 +6915,31 @@ snapshots: '@types/semver@7.7.1': {} + '@types/stats.js@0.17.4': {} + '@types/stylis@4.2.7': {} '@types/supercluster@7.1.3': dependencies: '@types/geojson': 7946.0.16 + '@types/three@0.184.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.2 + meshoptimizer: 1.1.1 + '@types/trusted-types@2.0.7': {} '@types/use-sync-external-store@0.0.6': {} '@types/w3c-web-usb@1.0.13': {} + '@types/webxr@0.5.24': {} + '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.2.0)(typescript@5.9.3))(eslint@8.2.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -8022,6 +8129,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.2: {} + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -8067,6 +8176,12 @@ snapshots: fs-constants@1.0.0: optional: true + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -8191,6 +8306,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} grid-index@1.1.0: {} @@ -8443,6 +8560,12 @@ snapshots: json5@2.2.3: {} + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -8612,6 +8735,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -8754,6 +8879,8 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-map@7.0.4: {} + p-queue@8.1.1: dependencies: eventemitter3: 5.0.4 @@ -9531,6 +9658,8 @@ snapshots: dependencies: any-promise: 1.3.0 + three@0.184.0: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -9666,6 +9795,8 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + universalify@2.0.1: {} + unplugin@0.10.2: dependencies: acorn: 8.16.0 @@ -9730,6 +9861,15 @@ snapshots: - supports-color - terser + vite-plugin-static-copy@2.3.2(vite@5.4.21(@types/node@22.19.15)): + dependencies: + chokidar: 3.6.0 + fast-glob: 3.3.3 + fs-extra: 11.3.5 + p-map: 7.0.4 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@22.19.15) + vite@5.4.21(@types/node@22.19.15): dependencies: esbuild: 0.21.5