mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
Use Blob URLs for external glTF resources
This commit is contained in:
parent
763273a90f
commit
0d9a38d244
4 changed files with 764 additions and 627 deletions
|
|
@ -14,6 +14,7 @@
|
|||
- Performance: serialize Live Preview and reading-mode model loads through an inline preview queue and defer `3dgrid` model preparation until the grid enters the viewport, reducing startup and multi-model I/O spikes.
|
||||
- Performance: defer Live Preview runtime imports for preview backends, annotation managers, conversion preparation, and load feedback until a visible embed actually starts loading.
|
||||
- Performance: defer reading-mode annotation runtime setup until after inline model canvases are visible, reducing first-frame delay for annotated previews.
|
||||
- Performance: load external `.gltf` buffers and textures through temporary Blob URLs instead of base64-rewriting the JSON, reducing large-model memory spikes and parse overhead.
|
||||
- Performance: register Live Preview embeds through a lightweight lazy widget so workspace startup no longer imports the full embed runtime before a model embed approaches the viewport.
|
||||
- Performance: defer Live Preview editor-extension registration until the workspace layout is ready, keeping CodeMirror model-embed setup off the plugin startup critical path.
|
||||
- Performance: schedule Live Preview extension setup after the initial layout settles and lazy-load heading-pin observer runtime only when heading-linked annotations exist.
|
||||
|
|
|
|||
1196
main.js
1196
main.js
File diff suppressed because one or more lines are too long
|
|
@ -8,10 +8,24 @@ vi.mock("obsidian", () => ({
|
|||
|
||||
let loadThreePLY: typeof import("./loaders").loadThreePLY;
|
||||
let loadThreeSTL: typeof import("./loaders").loadThreeSTL;
|
||||
let loadThreeGLTF: typeof import("./loaders").loadThreeGLTF;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal("activeWindow", {});
|
||||
vi.stubGlobal("ProgressEvent", class ProgressEvent extends Event {
|
||||
readonly lengthComputable: boolean;
|
||||
readonly loaded: number;
|
||||
readonly total: number;
|
||||
|
||||
constructor(type: string, eventInitDict: ProgressEventInit = {}) {
|
||||
super(type, eventInitDict);
|
||||
this.lengthComputable = eventInitDict.lengthComputable ?? false;
|
||||
this.loaded = eventInitDict.loaded ?? 0;
|
||||
this.total = eventInitDict.total ?? 0;
|
||||
}
|
||||
});
|
||||
const loaders = await import("./loaders");
|
||||
loadThreeGLTF = loaders.loadThreeGLTF;
|
||||
loadThreePLY = loaders.loadThreePLY;
|
||||
loadThreeSTL = loaders.loadThreeSTL;
|
||||
});
|
||||
|
|
@ -45,7 +59,67 @@ function createColoredBinaryStl(): ArrayBuffer {
|
|||
return buffer;
|
||||
}
|
||||
|
||||
function createExternalBufferGltf(): { gltf: ArrayBuffer; bin: ArrayBuffer } {
|
||||
const positions = new Float32Array([
|
||||
0, 0, 0,
|
||||
1, 0, 0,
|
||||
0, 1, 0,
|
||||
]);
|
||||
const gltf = {
|
||||
asset: { version: "2.0" },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0, name: "external-buffer-triangle" }],
|
||||
meshes: [{
|
||||
primitives: [{
|
||||
attributes: { POSITION: 0 },
|
||||
mode: 4,
|
||||
}],
|
||||
}],
|
||||
buffers: [{ uri: "Geometry%20Data.BIN", byteLength: positions.byteLength }],
|
||||
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: positions.byteLength, target: 34962 }],
|
||||
accessors: [{
|
||||
bufferView: 0,
|
||||
componentType: 5126,
|
||||
count: 3,
|
||||
type: "VEC3",
|
||||
min: [0, 0, 0],
|
||||
max: [1, 1, 0],
|
||||
}],
|
||||
};
|
||||
return {
|
||||
gltf: encodeAscii(JSON.stringify(gltf)),
|
||||
bin: positions.buffer.slice(positions.byteOffset, positions.byteOffset + positions.byteLength) as ArrayBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Three loaders", () => {
|
||||
it("loads GLTF external buffers through Blob URLs without rewriting the JSON", async () => {
|
||||
const fixture = createExternalBufferGltf();
|
||||
const createObjectURL = URL.createObjectURL.bind(URL);
|
||||
const revokeObjectURL = URL.revokeObjectURL.bind(URL);
|
||||
const createSpy = vi.spyOn(URL, "createObjectURL").mockImplementation((blob) => createObjectURL(blob));
|
||||
const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation((url) => revokeObjectURL(url));
|
||||
const readFile = vi.fn(async (path: string) => {
|
||||
if (path === "fixtures/Geometry Data.BIN") {
|
||||
return fixture.bin;
|
||||
}
|
||||
throw new Error(`Unexpected path: ${path}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await loadThreeGLTF(fixture.gltf, "gltf", readFile, "fixtures/model.gltf");
|
||||
|
||||
expect(readFile).toHaveBeenCalledWith("fixtures/Geometry Data.BIN");
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
expect(revokeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.scene.getObjectByName("external-buffer-triangle")).toBeTruthy();
|
||||
} finally {
|
||||
createSpy.mockRestore();
|
||||
revokeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("enables vertex colors for colored STL", async () => {
|
||||
const object = await loadThreeSTL(createColoredBinaryStl());
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
|
|||
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { BufferGeometry, Material, Mesh, MeshStandardMaterial, PointsMaterial, Points } from "three";
|
||||
import { BufferGeometry, LoadingManager, Material, Mesh, MeshStandardMaterial, PointsMaterial, Points } from "three";
|
||||
import { getPortableBasename, getPortableDirname, getPortableStem, joinPortablePath } from "../../utils/resolve-path";
|
||||
import { arrayBufferToBase64 } from "../../utils/base64";
|
||||
import {
|
||||
|
|
@ -30,11 +30,88 @@ interface GltfJson {
|
|||
images?: GltfExternalResource[];
|
||||
}
|
||||
|
||||
interface GltfBlobResourceResolver {
|
||||
manager: LoadingManager;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function guessMime(path: string): string {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "png";
|
||||
return IMAGE_MIME[ext] ?? `image/${ext}`;
|
||||
}
|
||||
|
||||
function stripUriSuffix(uri: string): string {
|
||||
return uri.split(/[?#]/, 1)[0] ?? uri;
|
||||
}
|
||||
|
||||
function normalizeResourceLookupKey(uri: string): string {
|
||||
return stripUriSuffix(uri).replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function addResourceLookupKey(
|
||||
lookup: Map<string, string>,
|
||||
key: string | undefined,
|
||||
objectUrl: string,
|
||||
): void {
|
||||
if (!key) return;
|
||||
lookup.set(normalizeResourceLookupKey(key), objectUrl);
|
||||
}
|
||||
|
||||
function addGltfResourceLookupKeys(
|
||||
lookup: Map<string, string>,
|
||||
modelDir: string,
|
||||
uri: string,
|
||||
resolvedPath: string,
|
||||
objectUrl: string,
|
||||
): void {
|
||||
const rawUri = stripUriSuffix(uri);
|
||||
addResourceLookupKey(lookup, rawUri, objectUrl);
|
||||
addResourceLookupKey(lookup, joinPortablePath("", rawUri), objectUrl);
|
||||
addResourceLookupKey(lookup, resolvedPath, objectUrl);
|
||||
if (modelDir) {
|
||||
addResourceLookupKey(lookup, `${modelDir}/${rawUri}`, objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async function createGltfBlobResourceResolver(
|
||||
readFile: (path: string) => Promise<ArrayBuffer>,
|
||||
modelDir: string,
|
||||
gltfJson: GltfJson,
|
||||
): Promise<GltfBlobResourceResolver> {
|
||||
const lookup = new Map<string, string>();
|
||||
const objectUrls: string[] = [];
|
||||
const manager = new LoadingManager();
|
||||
|
||||
const register = async (uri: string | undefined, mimeType?: string): Promise<void> => {
|
||||
if (!uri || uri.startsWith("data:")) {
|
||||
return;
|
||||
}
|
||||
const resource = await readRelativeResource(readFile, modelDir, uri);
|
||||
const objectUrl = URL.createObjectURL(new Blob([resource.data], { type: mimeType ?? guessMime(resource.path) }));
|
||||
objectUrls.push(objectUrl);
|
||||
addGltfResourceLookupKeys(lookup, modelDir, uri, resource.path, objectUrl);
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
...(gltfJson.buffers ?? []).map((buffer) => register(buffer.uri, "application/octet-stream")),
|
||||
...(gltfJson.images ?? []).map((image) => register(image.uri)),
|
||||
]);
|
||||
|
||||
manager.setURLModifier((url) => {
|
||||
const key = normalizeResourceLookupKey(url);
|
||||
return lookup.get(key) ?? lookup.get(joinPortablePath("", key)) ?? url;
|
||||
});
|
||||
|
||||
return {
|
||||
manager,
|
||||
dispose: () => {
|
||||
for (const objectUrl of objectUrls) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function firstMtlPath(value: string): string {
|
||||
const trimmed = value.replace(/\s+#.*$/, "").trim();
|
||||
if (trimmed.startsWith("\"")) {
|
||||
|
|
@ -95,44 +172,29 @@ export async function loadThreeGLTF(
|
|||
readFile?: (path: string) => Promise<ArrayBuffer>,
|
||||
modelPath?: string,
|
||||
): Promise<{ scene: Object3D; animations: AnimationClip[]; warnings: string[] }> {
|
||||
const loader = new GLTFLoader();
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (ext === "gltf" && readFile && modelPath) {
|
||||
// GLTF JSON may reference external .bin and textures.
|
||||
// Use a custom loader manager that resolves from the vault.
|
||||
// GLTF JSON may reference external .bin and textures. Keep the original JSON
|
||||
// intact and resolve vault resources through temporary Blob URLs to avoid
|
||||
// base64-expanding large buffers/textures during model load.
|
||||
const gltfText = new TextDecoder().decode(new Uint8Array(data));
|
||||
const gltfJson = JSON.parse(gltfText) as GltfJson;
|
||||
const modelDir = getPortableDirname(modelPath);
|
||||
|
||||
// Pre-load all external buffers and images as data URLs
|
||||
if (gltfJson.buffers) {
|
||||
for (const buf of gltfJson.buffers) {
|
||||
if (buf.uri && !buf.uri.startsWith("data:")) {
|
||||
const resource = await readRelativeResource(readFile, modelDir, buf.uri);
|
||||
buf.uri = `data:application/octet-stream;base64,${arrayBufferToBase64(resource.data)}`;
|
||||
}
|
||||
}
|
||||
const resolver = await createGltfBlobResourceResolver(readFile, modelDir, gltfJson);
|
||||
const loader = new GLTFLoader(resolver.manager);
|
||||
try {
|
||||
const gltf = await loader.parseAsync(data, modelDir ? `${modelDir}/` : "");
|
||||
const root = gltf.scene || gltf.scenes?.[0];
|
||||
if (!root) throw new Error("GLTF did not contain a scene");
|
||||
return { scene: root, animations: gltf.animations, warnings };
|
||||
} finally {
|
||||
resolver.dispose();
|
||||
}
|
||||
|
||||
if (gltfJson.images) {
|
||||
for (const img of gltfJson.images) {
|
||||
if (img.uri && !img.uri.startsWith("data:")) {
|
||||
const resource = await readRelativeResource(readFile, modelDir, img.uri);
|
||||
img.uri = `data:${guessMime(resource.path)};base64,${arrayBufferToBase64(resource.data)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedText = JSON.stringify(gltfJson);
|
||||
const resolvedBuffer = new TextEncoder().encode(resolvedText);
|
||||
const gltf = await loader.parseAsync(resolvedBuffer.buffer, modelDir ? `${modelDir}/` : "");
|
||||
const root = gltf.scene || gltf.scenes?.[0];
|
||||
if (!root) throw new Error("GLTF did not contain a scene");
|
||||
return { scene: root, animations: gltf.animations, warnings };
|
||||
}
|
||||
|
||||
// .glb binary path
|
||||
const loader = new GLTFLoader();
|
||||
const gltf = await loader.parseAsync(data, "");
|
||||
const root = gltf.scene || gltf.scenes?.[0];
|
||||
if (!root) throw new Error("GLB did not contain a scene");
|
||||
|
|
|
|||
Loading…
Reference in a new issue