@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
JavaScript
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 };