Compare commits

...

15 Commits

Author SHA1 Message Date
Yeicor
4b6d3f6266 Automatically update version to 0.8.5 2024-03-29 11:26:06 +00:00
Yeicor
255ae72ed2 Count features again after changes to model and support for sending arbitrary lists of shapes as a single model. 2024-03-29 12:23:16 +01:00
Yeicor
77dd9fb43e Merge remote-tracking branch 'origin/master' 2024-03-28 23:32:28 +01:00
Yeicor
5dc2ae2f8d Remove debug check 2024-03-28 23:32:20 +01:00
Yeicor
58440723bd Automatically update version to 0.8.4 2024-03-28 22:29:24 +00:00
Yeicor
bfdd656316 Merge remote-tracking branch 'origin/master' 2024-03-28 23:28:43 +01:00
Yeicor
7408823c02 Debug CI 2024-03-28 23:28:36 +01:00
Yeicor
856ffbc4c5 Reduce logging 2024-03-28 23:26:00 +01:00
Yeicor
d0f8463bbf Automatically update version to 0.8.3 2024-03-28 22:01:12 +00:00
Yeicor
162d3e22a2 Fix typescript 2024-03-28 22:57:26 +01:00
Yeicor
4b06559ab8 Add a progress bar to the frontend and improve slightly batched updates logic 2024-03-28 22:52:34 +01:00
Yeicor
9afa2e5786 Add support for some gltf extensions and better multi-object updates 2024-03-28 19:12:21 +01:00
Yeicor
7196fb2f32 Automatically update version to 0.8.2 2024-03-28 11:52:24 +00:00
Yeicor
8ec60faa04 Merge remote-tracking branch 'origin/master' 2024-03-28 12:51:34 +01:00
Yeicor
13bbdd5956 Fix automatic _find_var_name 2024-03-28 12:51:26 +01:00
15 changed files with 211 additions and 94 deletions

View File

@@ -2225,13 +2225,13 @@ THE SOFTWARE.
The following npm package may be included in this product:
- three@0.162.0
- three@0.160.1
This package contains the following license and notice below:
The MIT License
Copyright © 2010-2024 three.js authors
Copyright © 2010-2023 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -33,6 +33,10 @@ const setDisableTap = (val: boolean) => disableTap.value = val;
provide('disableTap', {disableTap, setDisableTap});
async function onModelUpdateRequest(event: NetworkUpdateEvent) {
// Trigger progress bar as soon as possible (also triggered earlier for each raw notification)
if (viewer.value && event.models.length > 0) {
viewer.value.onProgress(0.10);
}
// Load/unload a new batch of models to optimize rendering time
console.log("Received model update request", event.models);
let shutdownRequestIndex = event.models.findIndex((model) => model.isRemove == null);
@@ -70,6 +74,8 @@ async function onModelRemoveRequest(name: string) {
// Set up the load model event listener
let networkMgr = new NetworkManager();
networkMgr.addEventListener('update-early',
(e) => viewer.value?.onProgress((e as CustomEvent<Array<any>>).detail.length * 0.01));
networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent));
// Start loading all configured models ASAP
for (let model of settings.preload) {

View File

@@ -21,7 +21,32 @@ export async function mergePartial(url: string, name: string, document: Document
networkFinished();
// Load the new document
let newDoc = await io.readBinary(new Uint8Array(buffer));
let newDoc = null;
let alreadyTried: { [name: string]: boolean } = {}
while (newDoc == null) { // Retry adding extensions as required until the document is loaded
try { // Try to load fast if no extensions are used
newDoc = await io.readBinary(new Uint8Array(buffer));
} catch (e) { // Fallback to wait for download and register big extensions
if (e instanceof Error && e.message.toLowerCase().includes("khr_draco_mesh_compression")) {
if (alreadyTried["draco"]) throw e; else alreadyTried["draco"] = true;
// WARNING: Draco decompression on web is really slow for non-trivial models! (it should work?)
let {KHRDracoMeshCompression} = await import("@gltf-transform/extensions")
let dracoDecoderWeb = await import("three/examples/jsm/libs/draco/draco_decoder.js");
let dracoEncoderWeb = await import("three/examples/jsm/libs/draco/draco_encoder.js");
io.registerExtensions([KHRDracoMeshCompression])
.registerDependencies({
'draco3d.decoder': await dracoDecoderWeb.default({}),
'draco3d.encoder': await dracoEncoderWeb.default({})
});
} else if (e instanceof Error && e.message.toLowerCase().includes("ext_texture_webp")) {
if (alreadyTried["webp"]) throw e; else alreadyTried["webp"] = true;
let {EXTTextureWebP} = await import("@gltf-transform/extensions")
io.registerExtensions([EXTTextureWebP]);
} else { // TODO: Add more extensions as required
throw e;
}
}
}
// Remove any previous model with the same name
await document.transform(dropByName(name));

View File

@@ -93,29 +93,48 @@ export class NetworkManager extends EventTarget {
private foundModel(name: string, hash: string | null, url: string, isRemove: boolean | null, disconnect: () => void = () => {
}) {
let prevHash = this.knownObjectHashes[name];
// console.debug("Found model", name, "with hash", hash, "and previous hash", prevHash);
if (!hash || hash !== prevHash || isRemove) {
// Update known hashes
if (isRemove == false) {
this.knownObjectHashes[name] = hash;
} else if (isRemove == true) {
if (!(name in this.knownObjectHashes)) return; // Nothing to remove...
delete this.knownObjectHashes[name];
// Also update buffered updates if the model is removed
// console.debug("Found model", name, "with hash", hash, "at", url, "isRemove", isRemove);
// We only care about the latest update per model name
this.bufferedUpdates = this.bufferedUpdates.filter(m => m.name !== name);
}
let newModel = new NetworkUpdateEventModel(name, url, hash, isRemove);
this.bufferedUpdates.push(newModel);
// Add the new model to the list of updates and dispatch the early update
let upd = new NetworkUpdateEventModel(name, url, hash, isRemove);
this.bufferedUpdates.push(upd);
this.dispatchEvent(new CustomEvent("update-early", {detail: this.bufferedUpdates}));
// Optimization: try to batch updates automatically for faster rendering
if (this.batchTimeout !== null) clearTimeout(this.batchTimeout);
this.batchTimeout = setTimeout(() => {
// Update known hashes for minimal updates
for (let model of this.bufferedUpdates) {
if (model.isRemove == false && model.hash && model.hash === this.knownObjectHashes[model.name]) {
// Delete this useless update
let foundFirst = false;
this.bufferedUpdates = this.bufferedUpdates.filter(m => {
if (m === model) {
if (!foundFirst) { // Remove only first full match
foundFirst = true;
return false;
}
}
return true;
})
} else {
// Keep this update and update the last known hash
if (model.isRemove == true) {
if (model.name in this.knownObjectHashes) delete this.knownObjectHashes[model.name];
} else if (model.isRemove == false) {
this.knownObjectHashes[model.name] = model.hash;
}
}
}
// Dispatch the event to actually update the models
this.dispatchEvent(new NetworkUpdateEvent(this.bufferedUpdates, disconnect));
this.bufferedUpdates = [];
}, batchTimeout);
}
}
}
async function* readLinesStreamings(reader: ReadableStreamDefaultReader<Uint8Array>) {

View File

@@ -13,12 +13,13 @@ export class SceneMgr {
let loadStart = performance.now();
let loadNetworkEnd: number;
try {
// Start merging into the current document, replacing or adding as needed
document = await mergePartial(url, name, document, () => loadNetworkEnd = performance.now());
console.log("Model", name, "loaded in", performance.now() - loadNetworkEnd!, "ms after",
loadNetworkEnd! - loadStart, "ms of transferring data (maybe building the object on the server)");
} finally {
if (updateHelpers) {
// Reload the helpers to fit the new model
await this.reloadHelpers(sceneUrl, document, reloadScene);
@@ -31,6 +32,7 @@ export class SceneMgr {
document = await this.showCurrentDoc(sceneUrl, document);
console.log("Scene displayed in", performance.now() - displayStart, "ms");
}
}
return document;
}
@@ -97,7 +99,7 @@ export class SceneMgr {
// Serialize the document into a GLB and update the viewerSrc
let buffer = await toBuffer(document);
let blob = new Blob([buffer], {type: 'model/gltf-binary'});
console.debug("Showing current doc", document, "as", Array.from(buffer));
console.debug("Showing current doc", document, "with", buffer.length, "total bytes");
sceneUrl.value = URL.createObjectURL(blob);
return document;

View File

@@ -43,10 +43,11 @@ const emit = defineEmits<{ remove: [] }>()
let modelName = props.meshes[0].getExtras()[extrasNameKey] // + " blah blah blah blah blag blah blah blah"
// Reactive properties
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
const opacity = defineModel<number>("opacity", {default: 1});
const wireframe = ref(false);
// Count the number of faces, edges and vertices
let faceCount = ref(-1);
let edgeCount = ref(-1);
let vertexCount = ref(-1);
// Clipping planes are handled in y-up space (swapped on interface, Z inverted later)
const clipPlaneX = ref(1);
const clipPlaneSwappedX = ref(false);
@@ -56,24 +57,10 @@ const clipPlaneZ = ref(1);
const clipPlaneSwappedZ = ref(false);
const edgeWidth = ref(settings.edgeWidth);
// Count the number of faces, edges and vertices
let faceCount = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES))
.map(p => (p.getExtras()?.face_triangles_end as any)?.length ?? 1)
.reduce((a, b) => a + b, 0)
let edgeCount = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]))
.map(p => (p.getExtras()?.edge_points_end as any)?.length ?? 0)
.reduce((a, b) => a + b, 0)
let vertexCount = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS))
.map(p => (p.getAttribute("POSITION")?.getCount() ?? 0))
.reduce((a, b) => a + b, 0)
// Set initial defaults for the enabled features
if (faceCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
if (edgeCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1)
if (vertexCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2)
// Misc properties
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
const opacity = defineModel<number>("opacity", {default: 1});
const wireframe = ref(false);
// Listeners for changes in the properties (or viewer reloads)
function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
@@ -81,9 +68,6 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
// Iterate all primitives of the mesh and set their visibility based on the enabled features
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
// of not actually removing the primitives from the scene graph
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh'
@@ -107,10 +91,6 @@ function onOpacityChange(newOpacity: number) {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
// Iterate all primitives of the mesh and set their opacity based on the enabled features
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
// of not actually removing the primitives from the scene graph
// console.log('Opacity may have changed', newOpacity)
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
if (child.material && child.material.opacity !== newOpacity) {
@@ -129,10 +109,6 @@ function onWireframeChange(newWireframe: boolean) {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
// Iterate all primitives of the mesh and set their wireframe based on the enabled features
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
// of not actually removing the primitives from the scene graph
// console.log('Wireframe may have changed', newWireframe)
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
if (child.material && child.material.wireframe !== newWireframe) {
@@ -268,9 +244,8 @@ function onModelLoad() {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
// Iterate all primitives of the mesh and set their visibility based on the enabled features
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
// of not actually removing the primitives from the scene graph
// Add darkened back faces for all face objects to improve cutting planes
let childrenToAdd: Array<MObject3D> = [];
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
@@ -303,6 +278,28 @@ function onModelLoad() {
});
childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child));
// Count the number of faces, edges and vertices
faceCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES))
.map(p => (p.getExtras()?.face_triangles_end as any)?.length ?? 1)
.reduce((a, b) => a + b, 0)
edgeCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]))
.map(p => (p.getExtras()?.edge_points_end as any)?.length ?? 0)
.reduce((a, b) => a + b, 0)
vertexCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS))
.map(p => (p.getAttribute("POSITION")?.getCount() ?? 0))
.reduce((a, b) => a + b, 0)
// Set the enabled features to all provided features
if (faceCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
else if (!enabledFeatures.value.includes(0)) enabledFeatures.value.push(0)
if (edgeCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1)
else if (!enabledFeatures.value.includes(1)) enabledFeatures.value.push(1)
if (vertexCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2)
else if (!enabledFeatures.value.includes(2)) enabledFeatures.value.push(2)
// Furthermore...
// Enabled features may have been reset after a reload
onEnabledFeaturesChange(enabledFeatures.value)

View File

@@ -36,9 +36,7 @@ function onRemove(mesh: Mesh) {
}
function findModel(name: string) {
console.log('Find model', name);
if (!expandedNames.value.includes(name)) expandedNames.value.push(name);
console.log('Expanded', expandedNames.value);
}
defineExpose({findModel})

2
frontend/shims.d.ts vendored
View File

@@ -1,3 +1,5 @@
// Avoids typescript error when importing some files
declare module '@jamescoyle/vue-icon'
declare module 'three-orientation-gizmo/src/OrientationGizmo'
declare module 'three/examples/jsm/libs/draco/draco_decoder.js'
declare module 'three/examples/jsm/libs/draco/draco_encoder.js'

View File

@@ -8,8 +8,8 @@ import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelSc
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
import type {Renderer} from "@google/model-viewer/lib/three-components/Renderer";
import type {Vector3} from "three";
import { computeBoundsTree, disposeBoundsTree, acceleratedRaycast } from 'three-mesh-bvh';
import {BufferGeometry, Mesh} from "three";
import {acceleratedRaycast, computeBoundsTree, disposeBoundsTree} from 'three-mesh-bvh';
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
//@ts-ignore
@@ -41,8 +41,31 @@ onMounted(() => {
emit('load')
});
elem.value.addEventListener('camera-change', onCameraChange);
elem.value.addEventListener('progress', (ev) => onProgress((ev as any).detail.totalProgress));
});
// Handles loading the events for <model-viewer>'s slotted progress bar
const progressBar = ref<HTMLElement | null>(null);
const updateBar = ref<HTMLElement | null>(null);
let onProgressHideTimeout: number | null = null;
const onProgress = (totalProgress: number) => {
if (!progressBar.value || !updateBar.value) return;
// Update the progress bar and ensure it's visible
progressBar.value.style.display = 'block';
progressBar.value.style.opacity = '1'; // Fade in
updateBar.value.style.width = `${totalProgress * 100}%`;
// Auto-hide smoothly when no progress is made for a while
if (onProgressHideTimeout) clearTimeout(onProgressHideTimeout);
onProgressHideTimeout = setTimeout(() => {
if (!progressBar.value) return;
progressBar.value.style.opacity = '0'; // Fade out
setTimeout(() => {
if (!progressBar.value) return;
progressBar.value.style.display = 'none'; // Actually hide
}, 300); // 0.3s fade out
}, 1000);
};
class Line3DData {
startHotspot: HTMLElement = document.body
endHotspot: HTMLElement = document.body
@@ -137,7 +160,7 @@ function entries(lines: { [id: number]: Line3DData }): [string, Line3DData][] {
return Object.entries(lines);
}
defineExpose({elem, onElemReady, scene, renderer, addLine3D, removeLine3D});
defineExpose({elem, onElemReady, scene, renderer, addLine3D, removeLine3D, onProgress});
let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!;
watch(disableTap, (value) => {
@@ -155,7 +178,8 @@ watch(disableTap, (value) => {
:shadow-intensity="settings.shadowIntensity" interaction-prompt="none" :autoplay="settings.autoplay"
:ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :skybox-image="settings.background"
:environment-image="settings.background">
<slot></slot> <!-- Controls, annotations, etc. -->
<slot></slot>
<!-- Display some information during initial load -->
<div class="annotation initial-load-banner">
Trying to load models from...
<v-list v-for="src in settings.preload" :key="src">
@@ -163,6 +187,11 @@ watch(disableTap, (value) => {
</v-list>
<!-- Too much idle CPU usage: <loading></loading> -->
</div>
<!-- Customize the progress bar -->
<div class="progress-bar" slot="progress-bar" ref="progressBar">
<div class="update-bar" ref="updateBar"/>
</div>
</model-viewer>
<!-- The SVG overlay for fake 3D lines attached to the model -->
@@ -223,4 +252,30 @@ watch(disableTap, (value) => {
.initial-load-banner .v-list-item {
overflow: hidden;
}
.progress-bar {
display: block;
pointer-events: none;
width: 100%;
height: 10%;
max-height: 2%;
position: absolute;
left: 50%;
top: 0;
transform: translate3d(-50%, 0%, 0);
border-radius: 25px;
box-shadow: 0 3px 10px 3px rgba(0, 0, 0, 0.5), 0 0 5px 1px rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.9);
background-color: rgba(0, 0, 0, 0.5);
transition: opacity 0.3s;
}
.update-bar {
background-color: rgba(255, 255, 255, 0.9);
width: 0;
height: 100%;
border-radius: 25px;
float: left;
transition: width 0.3s;
}
</style>

View File

@@ -1,6 +1,6 @@
{
"name": "yet-another-cad-viewer",
"version": "0.8.1",
"version": "0.8.5",
"description": "",
"license": "MIT",
"private": true,
@@ -15,7 +15,8 @@
"update-licenses": "generate-license-file --input package.json --output assets/licenses.txt --overwrite"
},
"dependencies": {
"@gltf-transform/core": "^3.10.0",
"@gltf-transform/core": "^3.10.1",
"@gltf-transform/extensions": "^3.10.1",
"@gltf-transform/functions": "^3.10.1",
"@google/model-viewer": "^3.4.0",
"@jamescoyle/vue-icon": "^0.1.2",

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "yacv-server"
version = "0.8.1"
version = "0.8.5"
description = "Yet Another CAD Viewer (server)"
authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"]
license = "MIT"

View File

@@ -28,7 +28,7 @@ export default defineConfig({
build: {
assetsDir: '.',
cssCodeSplit: false, // Small enough to inline
chunkSizeWarningLimit: 550, // Three.js is huge
chunkSizeWarningLimit: 550, // Three.js is big. Draco is even bigger but not likely to be used.
},
define: {
__APP_NAME__: JSON.stringify(name),

View File

@@ -2,10 +2,11 @@
Utilities to work with CAD objects
"""
import hashlib
from typing import Optional, Union, List, Tuple
from typing import Optional, Union, Tuple
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS_Shape
from build123d import Compound, Shape
from yacv_server.gltf import GLTFMgr
@@ -13,7 +14,7 @@ CADCoreLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and
CADLike = Union[CADCoreLike, any] # build123d and cadquery types
def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]:
def get_shape(obj: CADLike, error: bool = True, in_iter: bool = False) -> Optional[CADCoreLike]:
""" Get the shape of a CAD-like object """
# Try to grab a shape if a different type of object was passed
@@ -40,22 +41,33 @@ def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]:
if isinstance(obj, TopoDS_Shape):
return obj
# Handle iterables like Build123d ShapeList by extracting all sub-shapes and making a compound
if not in_iter:
try:
obj_iter = iter(obj)
# print(obj, ' -> ', obj_iter)
shapes_raw = [get_shape(sub_obj, error=False, in_iter=True) for sub_obj in obj_iter]
shapes_bd = [Shape(shape) for shape in shapes_raw if shape is not None]
return get_shape(Compound(shapes_bd), error)
except TypeError:
pass
if error:
raise ValueError(f'Cannot show object of type {type(obj)} (submit issue?)')
else:
return None
def grab_all_cad() -> List[Tuple[str, CADCoreLike]]:
def grab_all_cad() -> set[Tuple[str, CADCoreLike]]:
""" Grab all shapes by inspecting the stack """
import inspect
stack = inspect.stack()
shapes = []
shapes = set()
for frame in stack:
for key, value in frame.frame.f_locals.items():
shape = get_shape(value, error=False)
if shape:
shapes.append((key, shape))
if shape and shape not in shapes:
shapes.add((key, shape))
return shapes

View File

@@ -194,7 +194,7 @@ class YACV:
def show_cad_all(self, **kwargs):
"""Publishes all CAD objects in the current scope to the server"""
all_cad = grab_all_cad()
all_cad = list(grab_all_cad()) # List for reproducible iteration order
self.show(*[cad for _, cad in all_cad], names=[name for name, _ in all_cad], **kwargs)
def remove(self, name: str):
@@ -325,7 +325,7 @@ _find_var_name_count = 0
def _find_var_name(obj: any, avoid_levels: int = 2) -> str:
"""A hacky way to get a stable name for an object that may change over time"""
global _find_var_name_count
obj_shape = get_shape(obj)
obj_shape = get_shape(obj, error=False) or obj
for frame in inspect.stack()[avoid_levels:]:
for key, value in frame.frame.f_locals.items():
if get_shape(value, error=False) is obj_shape:

View File

@@ -390,7 +390,7 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz#9c907b21e30a52db959ba4f80bb01a0cc403d5cc"
integrity sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==
"@gltf-transform/core@^3.10.0", "@gltf-transform/core@^3.10.1":
"@gltf-transform/core@^3.10.1":
version "3.10.1"
resolved "https://registry.yarnpkg.com/@gltf-transform/core/-/core-3.10.1.tgz#d99c060b499482ed2c3304466405bf4c10939831"
integrity sha512-50OYemknGNxjBmiOM6iJp04JAu0bl9jvXJfN/gFt9QdJO02cPDcoXlTfSPJG6TVWDcfl0xPlsx1vybcbPVGFcQ==