UNPKG

@vctrl/hooks

Version:

vctrl/hooks is a React hooks package designed to simplify 3D model loading and management within React applications. It's part of the vectreal-core ecosystem and is primarily used in the vctrl/viewer React component and the official website application.

586 lines (585 loc) 16.9 kB
import { PERSISTED_BAKE_FILENAME as e, buildAssetLookupKeys as t, getSerializedAssetByteSize as n, isValidBase64 as r, normalizeAssetUri as i, normalizeCameraSettings as a, normalizeSceneInteractions as o, toSerializedAssetBytes as s } from "@vctrl/core"; import { ModelFileTypes as c, ModelLoader as l } from "@vctrl/core/model-loader"; import { useCallback as u, useMemo as d, useReducer as f, useRef as p } from "react"; //#region packages/hooks/src/use-load-model/error-helpers.ts var m = ({ code: e, message: t, recoverable: n, source: r, cause: i, context: a }) => ({ code: e, message: t, recoverable: n, source: r, cause: i, context: a }), h = (e) => e instanceof Error && e.message ? e.message : typeof e == "string" && e.trim().length > 0 ? e : "Unknown error", g = (e, t, n) => { let r = h(e); return m({ code: r.includes("missing required referenced assets") ? "missing_assets" : t, message: r, recoverable: !0, source: "local-upload", cause: e, context: n }); }, _ = (e, t) => { let n = h(e), r = "server_load_failed"; return n.includes("Server responded with 404") ? r = "not_found" : (n.includes("Server responded with 402") || n.includes("Server responded with 403")) && (r = "quota_exceeded"), m({ code: r, message: n, recoverable: r !== "not_found", source: "server-load", cause: e, context: { sceneId: t } }); }, v = {}, y = { emit(e, t) { let n = v[e]; console.debug(`Event emitted: ${e}`, t), n && n.forEach((e) => e(t)); }, on(e, t) { v[e] || (v[e] = []), v[e].push(t); }, off(e, t) { let n = v[e]; n && (v[e] = n.filter((e) => e !== t)); } }, b = (e) => { try { return i(e); } catch { return e; } }, x = (e) => { let t = /* @__PURE__ */ new Set(), n = e, r = Array.isArray(n.images) ? n.images : []; for (let e of r) typeof e.uri == "string" && !e.uri.startsWith("data:") && t.add(e.uri); let i = Array.isArray(n.buffers) ? n.buffers : []; for (let e of i) typeof e.uri == "string" && !e.uri.startsWith("data:") && t.add(e.uri); return t; }, S = async (e, n) => { let r = JSON.parse(await e.text()), i = x(r), a = {}, o = /* @__PURE__ */ new Map(), s = /* @__PURE__ */ new Map(), c = /* @__PURE__ */ new Set(), l = async (e) => { let t = s.get(e); if (t) return t; let n = new Uint8Array(await e.arrayBuffer()); return s.set(e, n), n; }; for (let e of n) { for (let n of t(e.name)) o.set(n, e); if (e.webkitRelativePath) for (let n of t(e.webkitRelativePath)) o.set(n, e); } let u = []; for (let e of i) { let t = b(e), n = t.split("/").pop() || t, r = o.get(e) || o.get(t) || o.get(n); if (!r) { u.push(e); continue; } c.add(r); let i = await l(r); a[t] = { data: Array.from(i), fileName: t, mimeType: r.type || "application/octet-stream" }; } if (u.length > 0) throw Error(`Scene payload is missing required referenced assets: ${u.slice(0, 5).join(", ")}`); for (let [e, t] of n.entries()) { if (c.has(t)) continue; let n = b(t.name), r = n.split("/").pop() || n, i = await l(t); a[`extra-${e}-${r}`] = { data: Array.from(i), fileName: r, mimeType: t.type || "application/octet-stream" }; } return { gltfJson: r, assetData: a }; }, C = async (e, t, n, r, i) => { try { let a = { model: (await n.loadToThreeJS(e)).scene, type: t, name: e.name }; if (i({ type: "set-file", payload: a }), i({ type: "set-file-loading", payload: !1 }), r && t !== c.usdz) { let t = new Uint8Array(await e.arrayBuffer()); await r.loadFromGlbBuffer(t); } return a; } catch (n) { return console.error("Error loading binary model:", n), i({ type: "set-file-loading", payload: !1 }), y.emit("load-error", g(n, "binary_load_failed", { fileName: e.name, fileType: t, fileSize: e.size })), null; } }, w = async (e, t, n, r, i, a) => { try { let { sourcePackageBytes: o, textureBytes: s } = await a(e, t), l = await n.loadGLTFWithAssetsToThreeJS(e, t), u = { model: l.scene, type: c.gltf, name: e.name, sourcePackageBytes: o, sourceTextureBytes: s }; if (i({ type: "set-file", payload: u }), i({ type: "set-file-loading", payload: !1 }), r) try { let n = await S(e, t); await r.loadFromServerSceneData(n); } catch (n) { console.warn("Failed to initialize optimizer from source GLTF payload; trying direct asset load.", n); try { if ("loadFromGLTFWithAssets" in r) { let n = new Uint8Array(await e.arrayBuffer()), i = /* @__PURE__ */ new Map(); for (let e of t) { let t = new Uint8Array(await e.arrayBuffer()); i.set(e.name, t), e.webkitRelativePath && i.set(e.webkitRelativePath, t); } await r.loadFromGLTFWithAssets(n, i); } else throw Error("loadFromGLTFWithAssets not available"); } catch (e) { console.warn("Direct asset load also failed; falling back to Three.js scene import. Texture names may be generic.", e), await r.load(l.scene); } } return u; } catch (n) { return console.error("Error loading GLTF model:", n), console.error("GLTF file:", e.name), console.error("Asset files:", t.map((e) => e.name)), i({ type: "set-file-loading", payload: !1 }), y.emit("load-error", g(n, "gltf_load_failed", { fileName: e.name, fileSize: e.size, assetCount: t.length })), null; } }; //#endregion //#region packages/hooks/src/use-load-model/optimizer-integration.ts function T(e, t, n, r) { let i = u(async (i, a) => { if (!e) { console.warn("Optimizer is not available"); return; } try { i && await i(a); let o = await e.getModel(); if (!o) { console.warn("No optimized model available after optimization"); return; } let s = o instanceof Uint8Array ? o.slice() : new Uint8Array(o).slice(), l = new File([s], n?.name || "optimized_model.glb", { type: "model/gltf-binary" }); t({ type: "set-file", payload: { model: (await r.loadToThreeJS(l)).scene, type: c.glb, name: l.name } }); } catch (e) { console.error("Optimization failed:", e); } }, [ e, r, n, t ]); if (!e) return null; let a = e.isReady, o = !!n?.model && !a; return { ...e, isReady: a, isPreparing: o, applyOptimization: i, reset: e?.reset, error: e?.error, loading: e?.loading }; } //#endregion //#region packages/hooks/src/use-load-model/state.ts var E = { file: null, isFileLoading: !1, progress: 0, supportedFileTypes: [ c.gltf, c.glb, c.usdz ] }; function D(e, t) { switch (t.type) { case "set-file": return { ...e, file: t.payload }; case "set-file-loading": return { ...e, isFileLoading: t.payload }; case "set-progress": return { ...e, progress: t.payload }; case "reset-state": return { ...E }; default: return e; } } //#endregion //#region packages/hooks/src/use-load-model/utils/read-directory.ts async function O(e) { let t = []; async function* n(e) { let t = e; for await (let [, e] of t) e.kind === "file" ? yield await e.getFile() : e.kind === "directory" && (yield* n(e)); } for await (let r of n(e)) t.push(r); return t; } //#endregion //#region packages/hooks/src/use-load-model/utils/reconstruct-files.ts function k(t) { let n = []; if (t.assetData && typeof t.assetData == "object") for (let [, r] of Object.entries(t.assetData)) { let { fileName: t, mimeType: i } = r; if (t === e) continue; let a = s(r), o = new Uint8Array(a.byteLength); o.set(a); let c = new Blob([o], { type: i }), l = new File([c], t, { type: i }); n.push(l); } let r = JSON.stringify(t.gltfJson), i = new Blob([r], { type: "model/gltf+json" }), a = t.meta?.name, o = a ? `${a}.gltf` : "scene.gltf"; return [new File([i], o, { type: "model/gltf+json" }), ...n]; } //#endregion //#region packages/hooks/src/use-load-model/utils/calculate-referenced-bytes.ts var A = (e, n) => { let r = i(e), a = r.split("/").pop() || r; for (let e of n) { let n = t(e.fileName); if (n.has(r) || n.has(a)) return e.size; } return 0; }, j = (e) => { let t = /* @__PURE__ */ new Set(), n = /* @__PURE__ */ new Set(), r = e, i = Array.isArray(r.images) ? r.images : []; for (let e of i) typeof e.uri == "string" && !e.uri.startsWith("data:") && n.add(e.uri); let a = Array.isArray(r.buffers) ? r.buffers : []; for (let e of a) typeof e.uri == "string" && !e.uri.startsWith("data:") && t.add(e.uri); return { bufferUris: t, imageUris: n }; }; async function M(e, t) { let n = await e.text(), r; try { r = JSON.parse(n); } catch { return { sourcePackageBytes: e.size, textureBytes: 0 }; } let i = t.map((e) => ({ fileName: e.name, size: e.size })), { bufferUris: a, imageUris: o } = j(r), s = Array.from(a).reduce((e, t) => e + A(t, i), 0), c = Array.from(o).reduce((e, t) => e + A(t, i), 0); return { sourcePackageBytes: e.size + s + c, textureBytes: c }; } function N(e) { let t = e.gltfJson, r = Object.values(e.assetData || {}).map((e) => ({ fileName: e.fileName, size: n(e.data) })), i = new TextEncoder().encode(JSON.stringify(t)).byteLength, { bufferUris: a, imageUris: o } = j(t), s = Array.from(a).reduce((e, t) => e + A(t, r), 0), c = Array.from(o).reduce((e, t) => e + A(t, r), 0); return { sourcePackageBytes: i + s + c, textureBytes: c }; } //#endregion //#region packages/hooks/src/use-load-model/utils/resolve-scene-payload.ts function P(e) { return typeof e == "object" && !!e && !Array.isArray(e); } function F(e) { let t = P(e.settings) ? e.settings : {}, n = a(e.camera ?? t.camera); return { ...t, bounds: e.bounds ?? t.bounds, camera: n, interactions: o(t.interactions, { camera: n }), controls: e.controls ?? t.controls, environment: e.environment ?? t.environment, shadows: e.shadows ?? t.shadows }; } function I(e) { let t = /* @__PURE__ */ new Set(), n = e, r = Array.isArray(n.images) ? n.images : []; for (let e of r) typeof e.uri == "string" && !e.uri.startsWith("data:") && t.add(e.uri); let i = Array.isArray(n.buffers) ? n.buffers : []; for (let e of i) typeof e.uri == "string" && !e.uri.startsWith("data:") && t.add(e.uri); return t; } function L(e) { for (let [t, n] of Object.entries(e)) { if (!n || typeof n != "object") throw Error(`Scene payload contains invalid asset entry for ${t}`); if (typeof n.fileName != "string" || n.fileName.trim().length === 0) throw Error(`Scene payload asset ${t} is missing a valid fileName`); if (typeof n.mimeType != "string" || n.mimeType.trim().length === 0) throw Error(`Scene payload asset ${t} is missing a valid mimeType`); if (typeof n.data == "string" && n.encoding === "base64" && !r(n.data)) throw Error(`Scene payload asset ${n.fileName} has invalid base64 data`); } } function R(e, n) { let r = I(e); if (r.size === 0) return; let a = /* @__PURE__ */ new Set(); for (let e of Object.values(n)) for (let n of t(e.fileName)) a.add(n); let o = []; for (let e of r) { let t = i(e), n = t.split("/").pop() || t; !a.has(e) && !a.has(t) && !a.has(n) && o.push(e); } if (o.length > 0) throw Error(`Scene payload is missing required referenced assets: ${o.slice(0, 5).join(", ")}`); } function z(e) { if (!P(e.gltfJson)) throw Error("Scene payload is missing a valid glTF document"); let t = e.assetData ?? {}; return L(t), R(e.gltfJson, t), { meta: e.meta, gltfJson: e.gltfJson, assetData: t, ...F(e) }; } //#endregion //#region packages/hooks/src/use-load-model/utils/fetch-manifest-assets.ts async function B(e, t = {}) { let n = Object.entries(e); if (n.length === 0) return t.onProgress?.(1), {}; let r = n.every(([, e]) => e.byteSize != null), i = r ? n.reduce((e, [, t]) => e + (t.byteSize ?? 0), 0) : 0, a = 0, o = 0, s = {}, c = new AbortController(); return await Promise.all(n.map(async ([e, l]) => { try { let u = await fetch(l.url, { headers: t.headers, credentials: "same-origin", signal: c.signal }); if (!u.ok) throw c.abort(), Error(`Failed to fetch scene asset ${l.fileName}: ${u.status}`); s[e] = { data: new Uint8Array(await u.arrayBuffer()), fileName: l.fileName, mimeType: l.mimeType }, o += 1, a += l.byteSize ?? 0, t.onProgress?.(r && i > 0 ? a / i : o / n.length); } catch (e) { throw c.abort(), e; } })), s; } //#endregion //#region packages/hooks/src/use-load-model/use-load-model.ts async function V(e, t) { try { let n = await fetch(e, { method: "GET", headers: { Accept: "application/json", ...t } }); if (!n.ok) return null; let r = await n.json(), i = r.data ?? r; return !i || typeof i != "object" || !i.gltfJson || !i.assetRefs && !i.assetData ? null : i; } catch { return null; } } async function H(e, t, n) { let r = new FormData(); r.append("action", "get-scene-settings"), r.append("sceneId", n); let i = await fetch(e, { method: "POST", headers: t, body: r }); if (!i.ok) throw Error(`Server responded with ${i.status} ${i.statusText}`); let a = await i.json(); return a.data ?? a; } function U(t) { let n = p(!1), r = p(null), [i, a] = f(D, E), o = d(() => { let e = new l(); return e.onProgress((e) => { a({ type: "set-progress", payload: e.progress }), e.progress !== 100 && y.emit("load-progress", e.progress); }), e; }, []), m = u((e) => { a({ type: "set-progress", payload: e }), y.emit("load-progress", e); }, []), h = u(() => { a({ type: "reset-state" }), y.emit("load-reset"); }, []), g = u((e, t) => e.find((e) => e.name.endsWith("." + t)), []), v = u(async (e) => { if (e.length === 0) return; let i = g(e, c.gltf), s = g(e, c.glb), l = g(e, c.usdz), u = [ i, s, l ].filter(Boolean); if (u.length > 1) { y.emit("multiple-models", u); return; } let d = e.filter((e) => e !== i && e !== s && e !== l), f = () => { n.current = !0, r.current && (y.emit("load-complete", r.current), n.current = !1); }; if (i) { let e = await w(i, d, o, t, a, M); e && (r.current = e), f(); } else if (s) { let e = await C(s, c.glb, o, t, a); e && (r.current = e), f(); } else if (l) { let e = await C(l, c.usdz, o, t, a); e && (r.current = e), f(); } else y.emit("not-loaded-files", e), a({ type: "set-file-loading", payload: !1 }); }, [ g, o, t ]), b = u(async (e) => { let t = []; y.emit("load-start"), a({ type: "reset-state" }), a({ type: "set-file-loading", payload: !0 }), m(0), n.current = !1; for (let n of e) if (n instanceof File) t.push(n); else if ("kind" in n && n.kind === "directory") { let e = await O(n); t.push(...e); } await v(t); }, [v, m]), x = u(async (n) => { let { sceneData: i, sceneId: l } = n; if (m(40), n.parseMode === "direct" && i.gltfJson) { let t = /* @__PURE__ */ new Map(); for (let n of Object.values(i.assetData ?? {})) n.fileName !== e && t.set(n.fileName, s(n)); let { sourcePackageBytes: n, textureBytes: u } = N(i); m(60); let d = { model: (await o.parseGLTFJsonToThreeJS(i.gltfJson, t)).scene, type: c.gltf, name: i.meta?.name || "scene", sourcePackageBytes: n, sourceTextureBytes: u }; return a({ type: "set-file", payload: d }), a({ type: "set-file-loading", payload: !1 }), r.current = d, m(100), { file: d, sceneId: l, ...i }; } let u = k(i), { sourcePackageBytes: d, textureBytes: f } = N(i); m(60); let p = u[0], h = u.slice(1), g = { model: (await o.loadGLTFWithAssetsToThreeJS(p, h)).scene, type: c.gltf, name: p.name, sourcePackageBytes: d, sourceTextureBytes: f }; return a({ type: "set-file", payload: g }), a({ type: "set-file-loading", payload: !1 }), r.current = g, t && await t.loadFromServerSceneData(i), m(100), { file: g, sceneId: l, ...i }; }, [ o, t, m ]), S = u(async (e) => { let { sceneId: t, serverOptions: n, parseMode: r } = e; try { y.emit("server-load-start", t), a({ type: "reset-state" }), a({ type: "set-file-loading", payload: !0 }), m(0); let e = n?.endpoint ?? `/api/scenes/${t}`, i = n?.apiKey ? { Authorization: `Bearer ${n.apiKey}`, ...n.headers } : { ...n?.headers }, o = await V(e, i); if (o?.assetRefs && !o.assetData) { m(10); let e = await B(o.assetRefs, { headers: i, onProgress: (e) => m(10 + Math.round(e * 30)) }); o = { ...o, assetData: e }; } o ||= await H(e, i, t); let s = await x({ sceneId: t, sceneData: z(o), parseMode: r }); return y.emit("server-load-complete", s), s; } catch (e) { console.error("Server scene loading failed:", e), a({ type: "set-file-loading", payload: !1 }); let n = _(e, t); throw y.emit("server-load-error", n), n; } }, [x, m]), A = T(t, a, i.file, o); return { ...i, on: y.on, off: y.off, load: b, loadFromData: x, loadFromServer: S, reset: h, optimizer: A }; } //#endregion export { B as n, k as r, U as t };