auto-git:
[unlink] playwright.config.js [unlink] src/app/App.js [unlink] src/app/editor-store.js [unlink] src/app/use-editor-store.js [unlink] src/assets/audio-assets.js [unlink] src/assets/gltf-model-import.js [unlink] src/assets/image-assets.js [unlink] src/assets/model-instance-labels.js [unlink] src/assets/model-instance-rendering.js [unlink] src/assets/model-instances.js [unlink] src/assets/project-asset-storage.js [unlink] src/assets/project-assets.js [unlink] src/commands/brush-command-helpers.js [unlink] src/commands/command-history.js [unlink] src/commands/command.js [unlink] src/commands/commit-transform-session-command.js [unlink] src/commands/create-box-brush-command.js [unlink] src/commands/delete-box-brush-command.js [unlink] src/commands/delete-entity-command.js [unlink] src/commands/delete-interaction-link-command.js [unlink] src/commands/delete-model-instance-command.js [unlink] src/commands/duplicate-selection-command.js [unlink] src/commands/import-audio-asset-command.js [unlink] src/commands/import-background-image-asset-command.js [unlink] src/commands/import-model-asset-command.js [unlink] src/commands/move-box-brush-command.js [unlink] src/commands/resize-box-brush-command.js [unlink] src/commands/rotate-box-brush-command.js [unlink] src/commands/set-box-brush-face-material-command.js [unlink] src/commands/set-box-brush-face-uv-state-command.js [unlink] src/commands/set-box-brush-name-command.js [unlink] src/commands/set-box-brush-transform-command.js [unlink] src/commands/set-box-brush-volume-settings-command.js [unlink] src/commands/set-entity-name-command.js [unlink] src/commands/set-model-instance-name-command.js [unlink] src/commands/set-player-start-command.js [unlink] src/commands/set-scene-name-command.js [unlink] src/commands/set-world-settings-command.js [unlink] src/commands/upsert-entity-command.js [unlink] src/commands/upsert-interaction-link-command.js [unlink] src/commands/upsert-model-instance-command.js [unlink] src/core/ids.js [unlink] src/core/selection.js [unlink] src/core/tool-mode.js [unlink] src/core/transform-session.js [unlink] src/core/vector.js [unlink] src/core/whitebox-selection-feedback.js [unlink] src/core/whitebox-selection-mode.js [unlink] src/document/brushes.js [unlink] src/document/migrate-scene-document.js [unlink] src/document/scene-document-validation.js [unlink] src/document/scene-document.js [unlink] src/document/world-settings.js [unlink] src/entities/entity-instances.js [unlink] src/entities/entity-labels.js [unlink] src/geometry/box-brush-components.js [unlink] src/geometry/box-brush-mesh.js [unlink] src/geometry/box-brush.js [unlink] src/geometry/box-face-uvs.js [unlink] src/geometry/grid-snapping.js [unlink] src/geometry/model-instance-collider-debug-mesh.js [unlink] src/geometry/model-instance-collider-generation.js [unlink] src/interactions/interaction-links.js [unlink] src/main.js [unlink] src/materials/starter-material-library.js [unlink] src/materials/starter-material-textures.js [unlink] src/rendering/advanced-rendering.js [unlink] src/rendering/fog-material.js [unlink] src/rendering/planar-reflection.js [unlink] src/rendering/water-material.js [unlink] src/runner-web/RunnerCanvas.js [unlink] src/runtime-three/first-person-navigation-controller.js [unlink] src/runtime-three/navigation-controller.js [unlink] src/runtime-three/orbit-visitor-navigation-controller.js [unlink] src/runtime-three/player-collision.js [unlink] src/runtime-three/rapier-collision-world.js [unlink] src/runtime-three/runtime-audio-system.js [unlink] src/runtime-three/runtime-host.js [unlink] src/runtime-three/runtime-interaction-system.js [unlink] src/runtime-three/runtime-scene-build.js [unlink] src/runtime-three/runtime-scene-validation.js [unlink] src/runtime-three/underwater-fog.js [unlink] src/serialization/local-draft-storage.js [unlink] src/serialization/scene-document-json.js [unlink] src/shared-ui/HierarchicalMenu.js [unlink] src/shared-ui/Panel.js [unlink] src/shared-ui/world-background-style.js [unlink] src/viewport-three/ViewportCanvas.js [unlink] src/viewport-three/ViewportPanel.js [unlink] src/viewport-three/viewport-entity-markers.js [unlink] src/viewport-three/viewport-focus.js [unlink] src/viewport-three/viewport-host.js [unlink] src/viewport-three/viewport-layout.js [unlink] src/viewport-three/viewport-transient-state.js [unlink] src/viewport-three/viewport-view-modes.js [unlink] vite.config.js [unlink] vitest.config.js
This commit is contained in:
@@ -1,166 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { createProjectAssetStorageKey } from "./project-assets";
|
||||
function getErrorDetail(error) {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message.trim();
|
||||
}
|
||||
return "Unknown error.";
|
||||
}
|
||||
function getFileExtension(sourceName) {
|
||||
const match = /\.([^.]+)$/u.exec(sourceName.trim());
|
||||
return match === null ? "" : match[1].toLowerCase();
|
||||
}
|
||||
function inferAudioMimeType(sourceName, fallbackMimeType) {
|
||||
if (fallbackMimeType.trim().startsWith("audio/")) {
|
||||
return fallbackMimeType.trim();
|
||||
}
|
||||
switch (getFileExtension(sourceName)) {
|
||||
case "aac":
|
||||
return "audio/aac";
|
||||
case "flac":
|
||||
return "audio/flac";
|
||||
case "m4a":
|
||||
case "mp4":
|
||||
return "audio/mp4";
|
||||
case "mp3":
|
||||
return "audio/mpeg";
|
||||
case "oga":
|
||||
case "ogg":
|
||||
return "audio/ogg";
|
||||
case "wav":
|
||||
case "wave":
|
||||
return "audio/wav";
|
||||
case "webm":
|
||||
return "audio/webm";
|
||||
default:
|
||||
throw new Error(`Unsupported audio asset format for ${sourceName}. Use a browser-supported audio file.`);
|
||||
}
|
||||
}
|
||||
function getImportedFilePath(file) {
|
||||
const relativePath = typeof file.webkitRelativePath === "string" ? file.webkitRelativePath.trim() : "";
|
||||
const sourcePath = relativePath.length > 0 ? relativePath : file.name.trim();
|
||||
return sourcePath.replace(/\\/gu, "/");
|
||||
}
|
||||
function createAudioContext() {
|
||||
const AudioContextConstructor = globalThis.AudioContext ??
|
||||
globalThis.webkitAudioContext;
|
||||
if (AudioContextConstructor === undefined) {
|
||||
throw new Error("Audio context is unavailable in this browser environment.");
|
||||
}
|
||||
return new AudioContextConstructor();
|
||||
}
|
||||
async function decodeAudioBuffer(bytes) {
|
||||
const audioContext = createAudioContext();
|
||||
try {
|
||||
return await audioContext.decodeAudioData(bytes.slice(0));
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(getErrorDetail(error));
|
||||
}
|
||||
finally {
|
||||
await audioContext.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
function extractAudioAssetMetadata(buffer) {
|
||||
if (!Number.isFinite(buffer.duration) || buffer.duration <= 0) {
|
||||
throw new Error("Imported audio assets must have measurable duration.");
|
||||
}
|
||||
return {
|
||||
kind: "audio",
|
||||
durationSeconds: buffer.duration,
|
||||
channelCount: buffer.numberOfChannels,
|
||||
sampleRateHz: buffer.sampleRate,
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
function createLoadedAudioAsset(asset, buffer) {
|
||||
return {
|
||||
assetId: asset.id,
|
||||
storageKey: asset.storageKey,
|
||||
metadata: asset.metadata,
|
||||
buffer
|
||||
};
|
||||
}
|
||||
function createAudioAssetRecord(sourceName, mimeType, byteLength, metadata) {
|
||||
const assetId = createOpaqueId("asset-audio");
|
||||
return {
|
||||
id: assetId,
|
||||
kind: "audio",
|
||||
sourceName,
|
||||
mimeType,
|
||||
storageKey: createProjectAssetStorageKey(assetId),
|
||||
byteLength,
|
||||
metadata
|
||||
};
|
||||
}
|
||||
async function loadAudioAssetFromFileRecord(asset, fileRecord) {
|
||||
try {
|
||||
const buffer = await decodeAudioBuffer(fileRecord.bytes);
|
||||
return createLoadedAudioAsset(asset, buffer);
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Audio asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
}
|
||||
function getStoredAudioAssetFile(asset, storedAsset) {
|
||||
const directFile = storedAsset.files[asset.sourceName];
|
||||
if (directFile !== undefined) {
|
||||
return directFile;
|
||||
}
|
||||
const storedFiles = Object.values(storedAsset.files);
|
||||
if (storedFiles.length === 1) {
|
||||
return storedFiles[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export async function importAudioAssetFromFile(file, storage) {
|
||||
const sourceName = getImportedFilePath(file);
|
||||
const mimeType = inferAudioMimeType(sourceName, file.type);
|
||||
const bytes = await file.arrayBuffer();
|
||||
let buffer;
|
||||
try {
|
||||
buffer = await decodeAudioBuffer(bytes);
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Audio import failed for ${sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
const metadata = extractAudioAssetMetadata(buffer);
|
||||
const asset = createAudioAssetRecord(sourceName, mimeType, bytes.byteLength, metadata);
|
||||
const loadedAsset = createLoadedAudioAsset(asset, buffer);
|
||||
const packageRecord = {
|
||||
files: {
|
||||
[sourceName]: {
|
||||
bytes,
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
await storage.putAsset(asset.storageKey, packageRecord);
|
||||
return {
|
||||
asset,
|
||||
loadedAsset
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
await storage.deleteAsset(asset.storageKey).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function loadAudioAssetFromStorage(storage, asset) {
|
||||
let storedAsset;
|
||||
try {
|
||||
storedAsset = await storage.getAsset(asset.storageKey);
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Audio asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
if (storedAsset === null) {
|
||||
throw new Error(`Missing stored binary data for imported audio asset ${asset.sourceName}.`);
|
||||
}
|
||||
const storedAudioFile = getStoredAudioAssetFile(asset, storedAsset);
|
||||
if (storedAudioFile === null) {
|
||||
throw new Error(`Missing stored audio file for imported audio asset ${asset.sourceName}.`);
|
||||
}
|
||||
return loadAudioAssetFromFileRecord(asset, storedAudioFile);
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
import { Box3, Group, Mesh } from "three";
|
||||
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { createModelInstance } from "./model-instances";
|
||||
import { createProjectAssetStorageKey } from "./project-assets";
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
const DRACO_DECODER_PATH = "/draco/gltf/";
|
||||
let sharedDracoLoader = null;
|
||||
function getErrorDetail(error) {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message.trim();
|
||||
}
|
||||
return "Unknown error.";
|
||||
}
|
||||
function getFileExtension(sourceName) {
|
||||
const match = /\.([^.]+)$/u.exec(sourceName.trim());
|
||||
return match === null ? "" : match[1].toLowerCase();
|
||||
}
|
||||
function inferFileMimeType(sourceName, fallbackMimeType) {
|
||||
if (fallbackMimeType.trim().length > 0 && fallbackMimeType !== "application/octet-stream") {
|
||||
return fallbackMimeType;
|
||||
}
|
||||
switch (getFileExtension(sourceName)) {
|
||||
case "bin":
|
||||
return "application/octet-stream";
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
return "image/jpeg";
|
||||
case "gif":
|
||||
return "image/gif";
|
||||
case "webp":
|
||||
return "image/webp";
|
||||
case "avif":
|
||||
return "image/avif";
|
||||
case "ktx2":
|
||||
return "image/ktx2";
|
||||
case "wav":
|
||||
return "audio/wav";
|
||||
case "mp3":
|
||||
return "audio/mpeg";
|
||||
case "ogg":
|
||||
return "audio/ogg";
|
||||
case "glb":
|
||||
return "model/gltf-binary";
|
||||
case "gltf":
|
||||
return "model/gltf+json";
|
||||
default:
|
||||
return fallbackMimeType.trim().length > 0 ? fallbackMimeType : "application/octet-stream";
|
||||
}
|
||||
}
|
||||
function inferModelAssetFormat(sourceName, mimeType) {
|
||||
const extension = getFileExtension(sourceName);
|
||||
if (mimeType === "model/gltf-binary" || extension === "glb") {
|
||||
return "glb";
|
||||
}
|
||||
if (mimeType === "model/gltf+json" || mimeType === "application/json" || extension === "gltf") {
|
||||
return "gltf";
|
||||
}
|
||||
throw new Error(`Unsupported model asset format for ${sourceName}. Use .glb or .gltf.`);
|
||||
}
|
||||
function inferModelMimeType(format) {
|
||||
return format === "glb" ? "model/gltf-binary" : "model/gltf+json";
|
||||
}
|
||||
function stripUrlQueryAndHash(path) {
|
||||
const queryIndex = path.search(/[?#]/u);
|
||||
return queryIndex === -1 ? path : path.slice(0, queryIndex);
|
||||
}
|
||||
function normalizeRelativePath(path) {
|
||||
const normalizedPath = stripUrlQueryAndHash(path.trim()).replace(/\\/gu, "/");
|
||||
const segments = normalizedPath.split("/");
|
||||
const resolvedSegments = [];
|
||||
for (const segment of segments) {
|
||||
if (segment === "" || segment === ".") {
|
||||
continue;
|
||||
}
|
||||
if (segment === "..") {
|
||||
const previousSegment = resolvedSegments.at(-1);
|
||||
if (previousSegment !== undefined && previousSegment !== "..") {
|
||||
resolvedSegments.pop();
|
||||
}
|
||||
else {
|
||||
resolvedSegments.push("..");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
resolvedSegments.push(segment);
|
||||
}
|
||||
return resolvedSegments.join("/");
|
||||
}
|
||||
function getPathDirectory(path) {
|
||||
const normalizedPath = normalizeRelativePath(path);
|
||||
const lastSlashIndex = normalizedPath.lastIndexOf("/");
|
||||
return lastSlashIndex === -1 ? "" : normalizedPath.slice(0, lastSlashIndex);
|
||||
}
|
||||
function getRelativePath(fromDirectory, targetPath) {
|
||||
const normalizedFromSegments = normalizeRelativePath(fromDirectory).split("/").filter((segment) => segment.length > 0);
|
||||
const normalizedTargetSegments = normalizeRelativePath(targetPath).split("/").filter((segment) => segment.length > 0);
|
||||
while (normalizedFromSegments.length > 0 &&
|
||||
normalizedTargetSegments.length > 0 &&
|
||||
normalizedFromSegments[0] === normalizedTargetSegments[0]) {
|
||||
normalizedFromSegments.shift();
|
||||
normalizedTargetSegments.shift();
|
||||
}
|
||||
return [...new Array(normalizedFromSegments.length).fill(".."), ...normalizedTargetSegments].join("/");
|
||||
}
|
||||
function getImportedFilePath(file) {
|
||||
const relativePath = typeof file.webkitRelativePath === "string" ? file.webkitRelativePath.trim() : "";
|
||||
return normalizeRelativePath(relativePath.length > 0 ? relativePath : file.name.trim());
|
||||
}
|
||||
function createBoundingBoxFromObject(object) {
|
||||
const box = new Box3().setFromObject(object);
|
||||
if (box.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
const min = {
|
||||
x: box.min.x,
|
||||
y: box.min.y,
|
||||
z: box.min.z
|
||||
};
|
||||
const max = {
|
||||
x: box.max.x,
|
||||
y: box.max.y,
|
||||
z: box.max.z
|
||||
};
|
||||
return {
|
||||
min,
|
||||
max,
|
||||
size: {
|
||||
x: max.x - min.x,
|
||||
y: max.y - min.y,
|
||||
z: max.z - min.z
|
||||
}
|
||||
};
|
||||
}
|
||||
function collectMaterialNames(scene) {
|
||||
const names = new Set();
|
||||
scene.traverse((object) => {
|
||||
const maybeMesh = object;
|
||||
if (maybeMesh.isMesh !== true) {
|
||||
return;
|
||||
}
|
||||
const materials = Array.isArray(maybeMesh.material) ? maybeMesh.material : [maybeMesh.material];
|
||||
for (const material of materials) {
|
||||
if (material.name.trim().length > 0) {
|
||||
names.add(material.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
return [...names].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
function collectTextureNames(parserJson) {
|
||||
const textures = parserJson.textures ?? [];
|
||||
const names = new Set();
|
||||
for (const texture of textures) {
|
||||
if (texture.name !== undefined && texture.name.trim().length > 0) {
|
||||
names.add(texture.name);
|
||||
}
|
||||
}
|
||||
return [...names].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
function collectAnimationNames(gltf) {
|
||||
return gltf.animations
|
||||
.map((animation, index) => (animation.name.trim().length > 0 ? animation.name : `Animation ${index + 1}`))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
function countNodes(scene) {
|
||||
let count = 0;
|
||||
scene.traverse(() => {
|
||||
count += 1;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
export function extractModelAssetMetadata(gltf, format) {
|
||||
gltf.scene.updateMatrixWorld(true);
|
||||
const boundingBox = createBoundingBoxFromObject(gltf.scene);
|
||||
let actualMeshCount = 0;
|
||||
gltf.scene.traverse((object) => {
|
||||
if (object.isMesh === true) {
|
||||
actualMeshCount += 1;
|
||||
}
|
||||
});
|
||||
const parserJson = gltf.parser.json;
|
||||
const materialNames = collectMaterialNames(gltf.scene);
|
||||
const textureNames = collectTextureNames(parserJson);
|
||||
const animationNames = collectAnimationNames(gltf);
|
||||
const warnings = [];
|
||||
if (boundingBox === null) {
|
||||
warnings.push("The imported model does not contain measurable geometry.");
|
||||
}
|
||||
if (actualMeshCount === 0) {
|
||||
warnings.push("The imported model does not contain any meshes.");
|
||||
}
|
||||
if (materialNames.length === 0 && (parserJson.materials?.length ?? 0) > 0) {
|
||||
for (const material of parserJson.materials ?? []) {
|
||||
if (material.name !== undefined && material.name.trim().length > 0) {
|
||||
materialNames.push(material.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "model",
|
||||
format,
|
||||
sceneName: gltf.scene.name.trim().length > 0 ? gltf.scene.name : null,
|
||||
nodeCount: countNodes(gltf.scene),
|
||||
meshCount: actualMeshCount,
|
||||
materialNames: [...new Set(materialNames)].sort((left, right) => left.localeCompare(right)),
|
||||
textureNames,
|
||||
animationNames,
|
||||
boundingBox,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
function createLoadedModelAsset(asset, template, animations) {
|
||||
return {
|
||||
assetId: asset.id,
|
||||
storageKey: asset.storageKey,
|
||||
metadata: asset.metadata,
|
||||
template,
|
||||
animations
|
||||
};
|
||||
}
|
||||
function createModelAssetRecord(sourceName, mimeType, byteLength, metadata) {
|
||||
const assetId = createOpaqueId("asset-model");
|
||||
return {
|
||||
id: assetId,
|
||||
kind: "model",
|
||||
sourceName,
|
||||
mimeType,
|
||||
storageKey: createProjectAssetStorageKey(assetId),
|
||||
byteLength,
|
||||
metadata
|
||||
};
|
||||
}
|
||||
async function loadGltfFromArrayBuffer(arrayBuffer) {
|
||||
const loader = createConfiguredGltfLoader();
|
||||
return loader.parseAsync(arrayBuffer, "");
|
||||
}
|
||||
function createConfiguredGltfLoader() {
|
||||
const loader = new GLTFLoader();
|
||||
loader.setDRACOLoader(getSharedDracoLoader());
|
||||
return loader;
|
||||
}
|
||||
function getSharedDracoLoader() {
|
||||
if (sharedDracoLoader === null) {
|
||||
sharedDracoLoader = new DRACOLoader();
|
||||
sharedDracoLoader.setDecoderPath(DRACO_DECODER_PATH);
|
||||
}
|
||||
return sharedDracoLoader;
|
||||
}
|
||||
function createDataUrlForStoredFile(file) {
|
||||
const byteArray = new Uint8Array(file.bytes);
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let index = 0; index < byteArray.length; index += chunkSize) {
|
||||
binary += String.fromCharCode(...byteArray.subarray(index, index + chunkSize));
|
||||
}
|
||||
const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(file.bytes).toString("base64");
|
||||
return `data:${file.mimeType};base64,${base64}`;
|
||||
}
|
||||
function createTransientResourceUrl(file) {
|
||||
if (typeof URL.createObjectURL === "function" && typeof Blob !== "undefined") {
|
||||
const objectUrl = URL.createObjectURL(new Blob([file.bytes], { type: file.mimeType }));
|
||||
return {
|
||||
url: objectUrl,
|
||||
revoke: () => {
|
||||
if (typeof URL.revokeObjectURL === "function") {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: createDataUrlForStoredFile(file),
|
||||
revoke: () => undefined
|
||||
};
|
||||
}
|
||||
function rewriteGltfResourceUris(gltfJson, files) {
|
||||
const dataUrlsByPath = new Map();
|
||||
const revokeUrls = [];
|
||||
const missingUris = new Set();
|
||||
const resolveUri = (uri) => {
|
||||
if (uri.startsWith("data:") || uri.startsWith("blob:")) {
|
||||
return uri;
|
||||
}
|
||||
const normalizedUri = normalizeRelativePath(uri);
|
||||
const storedFile = files[normalizedUri];
|
||||
if (storedFile === undefined) {
|
||||
return null;
|
||||
}
|
||||
const cachedDataUrl = dataUrlsByPath.get(normalizedUri);
|
||||
if (cachedDataUrl !== undefined) {
|
||||
return cachedDataUrl;
|
||||
}
|
||||
const transientResourceUrl = createTransientResourceUrl(storedFile);
|
||||
dataUrlsByPath.set(normalizedUri, transientResourceUrl.url);
|
||||
revokeUrls.push(transientResourceUrl.revoke);
|
||||
return transientResourceUrl.url;
|
||||
};
|
||||
const rewriteUri = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
const resolvedUri = resolveUri(stripUrlQueryAndHash(value));
|
||||
if (resolvedUri === null) {
|
||||
missingUris.add(normalizeRelativePath(value));
|
||||
return value;
|
||||
}
|
||||
return resolvedUri;
|
||||
};
|
||||
const buffers = Array.isArray(gltfJson.buffers) ? gltfJson.buffers : [];
|
||||
for (const buffer of buffers) {
|
||||
if (typeof buffer.uri === "string") {
|
||||
buffer.uri = rewriteUri(buffer.uri);
|
||||
}
|
||||
}
|
||||
const images = Array.isArray(gltfJson.images) ? gltfJson.images : [];
|
||||
for (const image of images) {
|
||||
if (typeof image.uri === "string") {
|
||||
image.uri = rewriteUri(image.uri);
|
||||
}
|
||||
}
|
||||
return {
|
||||
missingUris: [...missingUris],
|
||||
revokeUrls
|
||||
};
|
||||
}
|
||||
function cloneTemplateScene(scene) {
|
||||
// Use SkeletonUtils.clone so that SkinnedMesh.skeleton.bones are remapped
|
||||
// to the cloned hierarchy. A plain scene.clone(true) leaves the bones array
|
||||
// pointing at the original loader's nodes, which are gone after parsing,
|
||||
// making every skinned mesh invisible at runtime.
|
||||
return cloneSkeleton(scene);
|
||||
}
|
||||
function cloneMaterial(material) {
|
||||
return material.clone();
|
||||
}
|
||||
function cloneMeshResources(object) {
|
||||
const maybeMesh = object;
|
||||
if (maybeMesh.isMesh !== true) {
|
||||
return;
|
||||
}
|
||||
maybeMesh.geometry = maybeMesh.geometry.clone();
|
||||
maybeMesh.material = Array.isArray(maybeMesh.material)
|
||||
? maybeMesh.material.map((material) => cloneMaterial(material))
|
||||
: cloneMaterial(maybeMesh.material);
|
||||
}
|
||||
function disposeTexture(texture, seenTextures) {
|
||||
if (seenTextures.has(texture)) {
|
||||
return;
|
||||
}
|
||||
seenTextures.add(texture);
|
||||
texture.dispose();
|
||||
}
|
||||
function disposeMaterialResources(material, disposeTextures, seenTextures) {
|
||||
if (disposeTextures) {
|
||||
for (const value of Object.values(material)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
if (entry !== null && typeof entry === "object" && "isTexture" in entry) {
|
||||
disposeTexture(entry, seenTextures);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "object" && "isTexture" in value) {
|
||||
disposeTexture(value, seenTextures);
|
||||
}
|
||||
}
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
function disposeMeshResources(object, disposeTextures, seenTextures) {
|
||||
const maybeMesh = object;
|
||||
if (maybeMesh.isMesh !== true) {
|
||||
return;
|
||||
}
|
||||
maybeMesh.geometry.dispose();
|
||||
if (Array.isArray(maybeMesh.material)) {
|
||||
for (const material of maybeMesh.material) {
|
||||
disposeMaterialResources(material, disposeTextures, seenTextures);
|
||||
}
|
||||
}
|
||||
else {
|
||||
disposeMaterialResources(maybeMesh.material, disposeTextures, seenTextures);
|
||||
}
|
||||
}
|
||||
export function instantiateModelTemplate(template) {
|
||||
const clone = cloneSkeleton(template);
|
||||
clone.traverse(cloneMeshResources);
|
||||
return clone;
|
||||
}
|
||||
export function disposeModelTemplate(template) {
|
||||
const seenTextures = new Set();
|
||||
template.traverse((object) => {
|
||||
disposeMeshResources(object, true, seenTextures);
|
||||
});
|
||||
}
|
||||
export function disposeModelInstance(instance) {
|
||||
const seenTextures = new Set();
|
||||
instance.traverse((object) => {
|
||||
disposeMeshResources(object, false, seenTextures);
|
||||
});
|
||||
}
|
||||
async function loadModelFileSet(files) {
|
||||
if (files.length === 0) {
|
||||
throw new Error("Select a .glb or .gltf file to import.");
|
||||
}
|
||||
const modelFiles = files.filter((file) => {
|
||||
try {
|
||||
inferModelAssetFormat(file.name, file.type);
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (modelFiles.length === 0) {
|
||||
throw new Error("Select a .glb or .gltf file to import.");
|
||||
}
|
||||
if (modelFiles.length > 1) {
|
||||
throw new Error("Select exactly one .glb or .gltf file and any matching sidecar resources.");
|
||||
}
|
||||
const rootFile = modelFiles[0];
|
||||
const rootSourcePath = getImportedFilePath(rootFile);
|
||||
const rootDirectory = getPathDirectory(rootSourcePath);
|
||||
const importedFiles = await Promise.all(files.map(async (file) => ({
|
||||
file,
|
||||
bytes: await file.arrayBuffer()
|
||||
})));
|
||||
const fileEntries = [];
|
||||
const packageFiles = {};
|
||||
for (const { file, bytes } of importedFiles) {
|
||||
const sourcePath = file === rootFile ? normalizeRelativePath(rootFile.name.trim()) : getRelativePath(rootDirectory, getImportedFilePath(file));
|
||||
const mimeType = inferFileMimeType(file.name, file.type);
|
||||
if (packageFiles[sourcePath] !== undefined) {
|
||||
throw new Error(`Duplicate imported file path ${sourcePath}.`);
|
||||
}
|
||||
const entry = {
|
||||
bytes,
|
||||
mimeType,
|
||||
path: sourcePath
|
||||
};
|
||||
fileEntries.push(entry);
|
||||
packageFiles[sourcePath] = {
|
||||
bytes,
|
||||
mimeType
|
||||
};
|
||||
}
|
||||
const rootEntry = fileEntries.find((entry) => entry.path === normalizeRelativePath(rootFile.name.trim()));
|
||||
if (rootEntry === undefined) {
|
||||
throw new Error(`Unable to locate the root model file ${rootFile.name}.`);
|
||||
}
|
||||
// Keep the root file's canonical storage path equal to its source name so reloads can find it directly.
|
||||
const packageRecord = {
|
||||
files: packageFiles
|
||||
};
|
||||
return {
|
||||
fileEntries,
|
||||
packageRecord,
|
||||
rootFile: rootEntry,
|
||||
totalByteLength: fileEntries.reduce((total, entry) => total + entry.bytes.byteLength, 0)
|
||||
};
|
||||
}
|
||||
async function loadGltfFromImportedModelFileSet(fileSet) {
|
||||
const rootFormat = inferModelAssetFormat(fileSet.rootFile.path, fileSet.rootFile.mimeType);
|
||||
if (rootFormat === "glb") {
|
||||
return loadGltfFromArrayBuffer(fileSet.rootFile.bytes);
|
||||
}
|
||||
const text = new TextDecoder().decode(fileSet.rootFile.bytes);
|
||||
const gltfJson = JSON.parse(text);
|
||||
const { missingUris, revokeUrls } = rewriteGltfResourceUris(gltfJson, fileSet.packageRecord.files);
|
||||
if (missingUris.length > 0) {
|
||||
for (const revokeUrl of revokeUrls) {
|
||||
revokeUrl();
|
||||
}
|
||||
throw new Error(`Missing external model resource(s): ${missingUris.join(", ")}.`);
|
||||
}
|
||||
const loader = createConfiguredGltfLoader();
|
||||
try {
|
||||
return await loader.parseAsync(JSON.stringify(gltfJson), "");
|
||||
}
|
||||
finally {
|
||||
for (const revokeUrl of revokeUrls) {
|
||||
revokeUrl();
|
||||
}
|
||||
}
|
||||
}
|
||||
function createModelAssetRecordFromFileSet(sourceName, mimeType, byteLength, metadata) {
|
||||
return createModelAssetRecord(sourceName, mimeType, byteLength, metadata);
|
||||
}
|
||||
export async function importModelAssetFromFiles(files, storage) {
|
||||
let fileSet;
|
||||
try {
|
||||
fileSet = await loadModelFileSet(files);
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Model import failed: ${getErrorDetail(error)}`);
|
||||
}
|
||||
const sourceName = fileSet.rootFile.path;
|
||||
const format = inferModelAssetFormat(sourceName, fileSet.rootFile.mimeType);
|
||||
const mimeType = inferModelMimeType(format);
|
||||
let gltf;
|
||||
try {
|
||||
gltf = await loadGltfFromImportedModelFileSet(fileSet);
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Model import failed for ${sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
const metadata = extractModelAssetMetadata(gltf, format);
|
||||
const asset = createModelAssetRecordFromFileSet(sourceName, mimeType, fileSet.totalByteLength, metadata);
|
||||
try {
|
||||
await storage.putAsset(asset.storageKey, fileSet.packageRecord);
|
||||
const modelInstance = createModelInstance({
|
||||
assetId: asset.id,
|
||||
name: undefined
|
||||
});
|
||||
return {
|
||||
asset,
|
||||
modelInstance,
|
||||
loadedAsset: createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations)
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
await storage.deleteAsset(asset.storageKey).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function importModelAssetFromFile(file, storage) {
|
||||
return importModelAssetFromFiles([file], storage);
|
||||
}
|
||||
function getStoredModelAssetFile(asset, storedAsset) {
|
||||
const directFile = storedAsset.files[asset.sourceName];
|
||||
if (directFile !== undefined) {
|
||||
return directFile;
|
||||
}
|
||||
const storedFiles = Object.values(storedAsset.files);
|
||||
if (storedFiles.length === 1) {
|
||||
return storedFiles[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export async function loadModelAssetFromStorage(storage, asset) {
|
||||
let storedAsset;
|
||||
try {
|
||||
storedAsset = await storage.getAsset(asset.storageKey);
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
if (storedAsset === null) {
|
||||
throw new Error(`Missing stored binary data for imported model asset ${asset.sourceName}.`);
|
||||
}
|
||||
const storedModelFile = getStoredModelAssetFile(asset, storedAsset);
|
||||
if (storedModelFile === null) {
|
||||
throw new Error(`Missing stored root file for imported model asset ${asset.sourceName}.`);
|
||||
}
|
||||
if (asset.metadata.format === "glb") {
|
||||
try {
|
||||
const gltf = await loadGltfFromArrayBuffer(storedModelFile.bytes);
|
||||
return createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations);
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
}
|
||||
const fileEntries = storedAsset.files;
|
||||
const rootFileBytes = storedModelFile.bytes;
|
||||
const gltfJson = JSON.parse(new TextDecoder().decode(rootFileBytes));
|
||||
const { missingUris, revokeUrls } = rewriteGltfResourceUris(gltfJson, fileEntries);
|
||||
if (missingUris.length > 0) {
|
||||
for (const revokeUrl of revokeUrls) {
|
||||
revokeUrl();
|
||||
}
|
||||
throw new Error(`Missing stored external model resource(s): ${missingUris.join(", ")}.`);
|
||||
}
|
||||
const loader = createConfiguredGltfLoader();
|
||||
try {
|
||||
const gltf = await loader.parseAsync(JSON.stringify(gltfJson), "");
|
||||
return createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations);
|
||||
}
|
||||
finally {
|
||||
for (const revokeUrl of revokeUrls) {
|
||||
revokeUrl();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
import { DataTexture, EquirectangularReflectionMapping, LinearSRGBColorSpace, SRGBColorSpace, Texture } from "three";
|
||||
import { EXRLoader } from "three/examples/jsm/loaders/EXRLoader.js";
|
||||
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { createProjectAssetStorageKey } from "./project-assets";
|
||||
import {} from "./project-asset-storage";
|
||||
function getErrorDetail(error) {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message.trim();
|
||||
}
|
||||
return "Unknown error.";
|
||||
}
|
||||
function getFileExtension(sourceName) {
|
||||
const match = /\.([^.]+)$/u.exec(sourceName.trim());
|
||||
return match === null ? "" : match[1].toLowerCase();
|
||||
}
|
||||
function inferImageMimeType(sourceName, fallbackMimeType) {
|
||||
if (fallbackMimeType.trim().startsWith("image/")) {
|
||||
return fallbackMimeType.trim();
|
||||
}
|
||||
switch (getFileExtension(sourceName)) {
|
||||
case "avif":
|
||||
return "image/avif";
|
||||
case "exr":
|
||||
return "image/x-exr";
|
||||
case "gif":
|
||||
return "image/gif";
|
||||
case "hdr":
|
||||
return "image/vnd.radiance";
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
return "image/jpeg";
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "svg":
|
||||
return "image/svg+xml";
|
||||
case "webp":
|
||||
return "image/webp";
|
||||
default:
|
||||
throw new Error(`Unsupported image asset format for ${sourceName}. Use a browser-supported image file or .exr/.hdr.`);
|
||||
}
|
||||
}
|
||||
function isHdrFormat(sourceName) {
|
||||
const ext = getFileExtension(sourceName);
|
||||
return ext === "exr" || ext === "hdr";
|
||||
}
|
||||
function getImportedFilePath(file) {
|
||||
const relativePath = typeof file.webkitRelativePath === "string" ? file.webkitRelativePath.trim() : "";
|
||||
const sourcePath = relativePath.length > 0 ? relativePath : file.name.trim();
|
||||
return sourcePath.replace(/\\/gu, "/");
|
||||
}
|
||||
function createDataUrlForStoredFile(file) {
|
||||
const byteArray = new Uint8Array(file.bytes);
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let index = 0; index < byteArray.length; index += chunkSize) {
|
||||
binary += String.fromCharCode(...byteArray.subarray(index, index + chunkSize));
|
||||
}
|
||||
const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(file.bytes).toString("base64");
|
||||
return `data:${file.mimeType};base64,${base64}`;
|
||||
}
|
||||
function createTransientResourceUrl(file) {
|
||||
if (typeof URL.createObjectURL === "function" && typeof Blob !== "undefined") {
|
||||
const objectUrl = URL.createObjectURL(new Blob([file.bytes], { type: file.mimeType }));
|
||||
return {
|
||||
url: objectUrl,
|
||||
revoke: () => {
|
||||
if (typeof URL.revokeObjectURL === "function") {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: createDataUrlForStoredFile(file),
|
||||
revoke: () => undefined
|
||||
};
|
||||
}
|
||||
function loadImageElement(sourceUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.addEventListener("load", () => {
|
||||
resolve(image);
|
||||
});
|
||||
image.addEventListener("error", () => {
|
||||
reject(new Error(`Image could not be loaded from ${sourceUrl}.`));
|
||||
});
|
||||
image.src = sourceUrl;
|
||||
});
|
||||
}
|
||||
function detectImageHasAlpha(image) {
|
||||
const canvas = document.createElement("canvas");
|
||||
const sampleWidth = Math.max(1, Math.min(64, image.naturalWidth || image.width));
|
||||
const sampleHeight = Math.max(1, Math.min(64, image.naturalHeight || image.height));
|
||||
const context = canvas.getContext("2d", {
|
||||
willReadFrequently: true
|
||||
});
|
||||
if (context === null) {
|
||||
return false;
|
||||
}
|
||||
canvas.width = sampleWidth;
|
||||
canvas.height = sampleHeight;
|
||||
context.drawImage(image, 0, 0, sampleWidth, sampleHeight);
|
||||
try {
|
||||
const pixels = context.getImageData(0, 0, sampleWidth, sampleHeight).data;
|
||||
for (let index = 3; index < pixels.length; index += 4) {
|
||||
if (pixels[index] !== 255) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function extractImageAssetMetadata(image) {
|
||||
const width = image.naturalWidth || image.width;
|
||||
const height = image.naturalHeight || image.height;
|
||||
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
|
||||
throw new Error("Imported image assets must have measurable dimensions.");
|
||||
}
|
||||
const warnings = [];
|
||||
const aspectRatio = width / height;
|
||||
if (Math.abs(aspectRatio - 2) > 0.15) {
|
||||
warnings.push("Background images work best as a 2:1 equirectangular panorama.");
|
||||
}
|
||||
return {
|
||||
kind: "image",
|
||||
width,
|
||||
height,
|
||||
hasAlpha: detectImageHasAlpha(image),
|
||||
warnings
|
||||
};
|
||||
}
|
||||
function extractHdrTextureMetadata(texture) {
|
||||
const width = texture.image.width;
|
||||
const height = texture.image.height;
|
||||
const warnings = [];
|
||||
if (Math.abs(width / height - 2) > 0.15) {
|
||||
warnings.push("Background images work best as a 2:1 equirectangular panorama.");
|
||||
}
|
||||
return { kind: "image", width, height, hasAlpha: false, warnings };
|
||||
}
|
||||
function createImageTexture(image) {
|
||||
const texture = new Texture(image);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.mapping = EquirectangularReflectionMapping;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
function configureHdrTexture(texture) {
|
||||
// HDR/EXR data is linear — do not apply sRGB color space
|
||||
texture.colorSpace = LinearSRGBColorSpace;
|
||||
texture.mapping = EquirectangularReflectionMapping;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
function loadHdrTexture(url, sourceName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (getFileExtension(sourceName) === "exr") {
|
||||
new EXRLoader().load(url, resolve, undefined, () => {
|
||||
reject(new Error(`EXR file could not be loaded: ${sourceName}.`));
|
||||
});
|
||||
}
|
||||
else {
|
||||
new RGBELoader().load(url, resolve, undefined, () => {
|
||||
reject(new Error(`HDR file could not be loaded: ${sourceName}.`));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
function createLoadedImageAsset(asset, image, sourceUrl, revokeSourceUrl) {
|
||||
return {
|
||||
assetId: asset.id,
|
||||
storageKey: asset.storageKey,
|
||||
metadata: asset.metadata,
|
||||
texture: createImageTexture(image),
|
||||
sourceUrl,
|
||||
revokeSourceUrl
|
||||
};
|
||||
}
|
||||
function createLoadedHdrImageAsset(asset, texture, sourceUrl, revokeSourceUrl) {
|
||||
return {
|
||||
assetId: asset.id,
|
||||
storageKey: asset.storageKey,
|
||||
metadata: asset.metadata,
|
||||
texture: configureHdrTexture(texture),
|
||||
sourceUrl,
|
||||
revokeSourceUrl
|
||||
};
|
||||
}
|
||||
function createImageAssetRecord(sourceName, mimeType, byteLength, metadata) {
|
||||
const assetId = createOpaqueId("asset-image");
|
||||
return {
|
||||
id: assetId,
|
||||
kind: "image",
|
||||
sourceName,
|
||||
mimeType,
|
||||
storageKey: createProjectAssetStorageKey(assetId),
|
||||
byteLength,
|
||||
metadata
|
||||
};
|
||||
}
|
||||
function getStoredImageAssetFile(asset, storedAsset) {
|
||||
const directFile = storedAsset.files[asset.sourceName];
|
||||
if (directFile !== undefined) {
|
||||
return directFile;
|
||||
}
|
||||
const storedFiles = Object.values(storedAsset.files);
|
||||
if (storedFiles.length === 1) {
|
||||
return storedFiles[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function loadImageAssetFromFileRecord(asset, fileRecord) {
|
||||
const transientResourceUrl = createTransientResourceUrl(fileRecord);
|
||||
if (isHdrFormat(asset.sourceName)) {
|
||||
try {
|
||||
const texture = await loadHdrTexture(transientResourceUrl.url, asset.sourceName);
|
||||
return createLoadedHdrImageAsset(asset, texture, transientResourceUrl.url, transientResourceUrl.revoke);
|
||||
}
|
||||
catch (error) {
|
||||
transientResourceUrl.revoke();
|
||||
throw new Error(`Image asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const image = await loadImageElement(transientResourceUrl.url);
|
||||
return createLoadedImageAsset(asset, image, transientResourceUrl.url, transientResourceUrl.revoke);
|
||||
}
|
||||
catch (error) {
|
||||
transientResourceUrl.revoke();
|
||||
throw new Error(`Image asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
}
|
||||
export async function importBackgroundImageAssetFromFile(file, storage) {
|
||||
const sourceName = getImportedFilePath(file);
|
||||
const mimeType = inferImageMimeType(sourceName, file.type);
|
||||
const bytes = await file.arrayBuffer();
|
||||
const fileRecord = { bytes, mimeType };
|
||||
let asset;
|
||||
let loadedAsset;
|
||||
if (isHdrFormat(sourceName)) {
|
||||
const transientResourceUrl = createTransientResourceUrl(fileRecord);
|
||||
let texture;
|
||||
try {
|
||||
texture = await loadHdrTexture(transientResourceUrl.url, sourceName);
|
||||
}
|
||||
catch (error) {
|
||||
transientResourceUrl.revoke();
|
||||
throw new Error(`Image import failed for ${sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
const metadata = extractHdrTextureMetadata(texture);
|
||||
asset = createImageAssetRecord(sourceName, mimeType, bytes.byteLength, metadata);
|
||||
loadedAsset = createLoadedHdrImageAsset(asset, texture, transientResourceUrl.url, transientResourceUrl.revoke);
|
||||
}
|
||||
else {
|
||||
const transientResourceUrl = createTransientResourceUrl(fileRecord);
|
||||
let image;
|
||||
try {
|
||||
image = await loadImageElement(transientResourceUrl.url);
|
||||
}
|
||||
catch (error) {
|
||||
transientResourceUrl.revoke();
|
||||
throw new Error(`Image import failed for ${sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
const metadata = extractImageAssetMetadata(image);
|
||||
asset = createImageAssetRecord(sourceName, mimeType, bytes.byteLength, metadata);
|
||||
loadedAsset = createLoadedImageAsset(asset, image, transientResourceUrl.url, transientResourceUrl.revoke);
|
||||
}
|
||||
const packageRecord = {
|
||||
files: { [sourceName]: fileRecord }
|
||||
};
|
||||
try {
|
||||
await storage.putAsset(asset.storageKey, packageRecord);
|
||||
return { asset, loadedAsset };
|
||||
}
|
||||
catch (error) {
|
||||
disposeLoadedImageAsset(loadedAsset);
|
||||
await storage.deleteAsset(asset.storageKey).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function loadImageAssetFromStorage(storage, asset) {
|
||||
let storedAsset;
|
||||
try {
|
||||
storedAsset = await storage.getAsset(asset.storageKey);
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Image asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
if (storedAsset === null) {
|
||||
throw new Error(`Missing stored binary data for imported image asset ${asset.sourceName}.`);
|
||||
}
|
||||
const storedImageFile = getStoredImageAssetFile(asset, storedAsset);
|
||||
if (storedImageFile === null) {
|
||||
throw new Error(`Missing stored image file for imported image asset ${asset.sourceName}.`);
|
||||
}
|
||||
return loadImageAssetFromFileRecord(asset, storedImageFile);
|
||||
}
|
||||
export function disposeLoadedImageAsset(asset) {
|
||||
asset.texture.dispose();
|
||||
asset.revokeSourceUrl();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { getModelInstances } from "./model-instances";
|
||||
function getModelInstanceBaseLabel(modelInstance, assets) {
|
||||
if (modelInstance.name !== undefined) {
|
||||
return modelInstance.name;
|
||||
}
|
||||
const asset = assets[modelInstance.assetId];
|
||||
if (asset === undefined) {
|
||||
return "Model Instance";
|
||||
}
|
||||
return asset.sourceName;
|
||||
}
|
||||
export function getModelInstanceDisplayLabel(modelInstance, assets) {
|
||||
return getModelInstanceBaseLabel(modelInstance, assets);
|
||||
}
|
||||
export function getModelInstanceDisplayLabelById(modelInstanceId, modelInstances, assets) {
|
||||
const modelInstance = modelInstances[modelInstanceId];
|
||||
if (modelInstance === undefined) {
|
||||
return "Model Instance";
|
||||
}
|
||||
return getModelInstanceDisplayLabel(modelInstance, assets);
|
||||
}
|
||||
export function getSortedModelInstanceDisplayLabels(modelInstances, assets) {
|
||||
return getModelInstances(modelInstances).map((modelInstance) => ({
|
||||
modelInstance,
|
||||
label: getModelInstanceDisplayLabel(modelInstance, assets)
|
||||
}));
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from "three";
|
||||
import { instantiateModelTemplate } from "./gltf-model-import";
|
||||
const MODEL_PLACEHOLDER_COLOR = 0x89b6ff;
|
||||
const MODEL_SELECTION_COLOR = 0xf7d2aa;
|
||||
const MODEL_PREVIEW_SHELL_OPACITY = 0.5;
|
||||
function getLocalModelBounds(asset) {
|
||||
if (asset?.kind === "model" && asset.metadata.boundingBox !== null) {
|
||||
const boundingBox = asset.metadata.boundingBox;
|
||||
return {
|
||||
center: {
|
||||
x: (boundingBox.min.x + boundingBox.max.x) * 0.5,
|
||||
y: (boundingBox.min.y + boundingBox.max.y) * 0.5,
|
||||
z: (boundingBox.min.z + boundingBox.max.z) * 0.5
|
||||
},
|
||||
size: {
|
||||
x: Math.max(0.1, Math.abs(boundingBox.max.x - boundingBox.min.x)),
|
||||
y: Math.max(0.1, Math.abs(boundingBox.max.y - boundingBox.min.y)),
|
||||
z: Math.max(0.1, Math.abs(boundingBox.max.z - boundingBox.min.z))
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
center: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
},
|
||||
size: {
|
||||
x: 1,
|
||||
y: 1,
|
||||
z: 1
|
||||
}
|
||||
};
|
||||
}
|
||||
function createWireframeBox(size, color, opacity) {
|
||||
return new Mesh(new BoxGeometry(size.x, size.y, size.z), new MeshBasicMaterial({
|
||||
color,
|
||||
wireframe: true,
|
||||
transparent: true,
|
||||
opacity,
|
||||
depthWrite: false
|
||||
}));
|
||||
}
|
||||
function createWireframeMaterial(material) {
|
||||
const source = material;
|
||||
const opacity = typeof source.opacity === "number" ? source.opacity : 1;
|
||||
return new MeshBasicMaterial({
|
||||
color: source.color?.getHex() ?? MODEL_PLACEHOLDER_COLOR,
|
||||
wireframe: true,
|
||||
transparent: source.transparent === true || opacity < 1,
|
||||
opacity,
|
||||
depthWrite: false
|
||||
});
|
||||
}
|
||||
function applyWireframeMaterialPresentation(group) {
|
||||
group.traverse((object) => {
|
||||
const maybeMesh = object;
|
||||
if (maybeMesh.isMesh !== true) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(maybeMesh.material)) {
|
||||
const originalMaterials = maybeMesh.material;
|
||||
maybeMesh.material = originalMaterials.map((material) => createWireframeMaterial(material));
|
||||
for (const material of originalMaterials) {
|
||||
material.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const originalMaterial = maybeMesh.material;
|
||||
maybeMesh.material = createWireframeMaterial(originalMaterial);
|
||||
originalMaterial.dispose();
|
||||
});
|
||||
}
|
||||
function disposeTexture(texture, seenTextures) {
|
||||
if (seenTextures.has(texture)) {
|
||||
return;
|
||||
}
|
||||
seenTextures.add(texture);
|
||||
texture.dispose();
|
||||
}
|
||||
function disposeMaterialResources(material, disposeTextures, seenTextures) {
|
||||
if (disposeTextures) {
|
||||
for (const value of Object.values(material)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
if (entry !== null && typeof entry === "object" && "isTexture" in entry) {
|
||||
disposeTexture(entry, seenTextures);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "object" && "isTexture" in value) {
|
||||
disposeTexture(value, seenTextures);
|
||||
}
|
||||
}
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
function disposeMeshResources(object, disposeTextures, seenTextures) {
|
||||
const maybeMesh = object;
|
||||
if (maybeMesh.isMesh !== true) {
|
||||
return;
|
||||
}
|
||||
maybeMesh.geometry.dispose();
|
||||
if (Array.isArray(maybeMesh.material)) {
|
||||
for (const material of maybeMesh.material) {
|
||||
disposeMaterialResources(material, disposeTextures, seenTextures);
|
||||
}
|
||||
}
|
||||
else {
|
||||
disposeMaterialResources(maybeMesh.material, disposeTextures, seenTextures);
|
||||
}
|
||||
}
|
||||
export function createModelInstanceRenderGroup(modelInstance, asset, loadedAsset, selected = false, previewShellColor, renderMode = "normal") {
|
||||
const bounds = getLocalModelBounds(asset);
|
||||
const group = new Group();
|
||||
group.position.set(modelInstance.position.x, modelInstance.position.y, modelInstance.position.z);
|
||||
group.rotation.set((modelInstance.rotationDegrees.x * Math.PI) / 180, (modelInstance.rotationDegrees.y * Math.PI) / 180, (modelInstance.rotationDegrees.z * Math.PI) / 180);
|
||||
group.scale.set(modelInstance.scale.x, modelInstance.scale.y, modelInstance.scale.z);
|
||||
group.userData.modelInstanceId = modelInstance.id;
|
||||
group.userData.assetId = modelInstance.assetId;
|
||||
if (loadedAsset !== undefined) {
|
||||
const instantiatedModel = instantiateModelTemplate(loadedAsset.template);
|
||||
if (renderMode === "wireframe") {
|
||||
applyWireframeMaterialPresentation(instantiatedModel);
|
||||
}
|
||||
group.add(instantiatedModel);
|
||||
}
|
||||
else {
|
||||
const placeholder = createWireframeBox(bounds.size, previewShellColor ?? MODEL_PLACEHOLDER_COLOR, previewShellColor === undefined ? 0.28 : MODEL_PREVIEW_SHELL_OPACITY);
|
||||
placeholder.position.set(bounds.center.x, bounds.center.y, bounds.center.z);
|
||||
placeholder.userData.shadowIgnored = true;
|
||||
group.add(placeholder);
|
||||
}
|
||||
if (loadedAsset !== undefined && previewShellColor !== undefined) {
|
||||
const previewShell = createWireframeBox(bounds.size, previewShellColor, MODEL_PREVIEW_SHELL_OPACITY);
|
||||
previewShell.position.set(bounds.center.x, bounds.center.y, bounds.center.z);
|
||||
previewShell.userData.shadowIgnored = true;
|
||||
group.add(previewShell);
|
||||
}
|
||||
if (selected) {
|
||||
const selectionShell = createWireframeBox(bounds.size, MODEL_SELECTION_COLOR, 0.8);
|
||||
selectionShell.position.set(bounds.center.x, bounds.center.y, bounds.center.z);
|
||||
selectionShell.userData.shadowIgnored = true;
|
||||
group.add(selectionShell);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
export function disposeModelInstance(instance) {
|
||||
const seenTextures = new Set();
|
||||
instance.traverse((object) => {
|
||||
disposeMeshResources(object, false, seenTextures);
|
||||
});
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
export const MODEL_INSTANCE_COLLISION_MODES = ["none", "terrain", "static", "dynamic", "simple"];
|
||||
export const DEFAULT_MODEL_INSTANCE_POSITION = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
export const DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
export const DEFAULT_MODEL_INSTANCE_SCALE = {
|
||||
x: 1,
|
||||
y: 1,
|
||||
z: 1
|
||||
};
|
||||
export const DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS = {
|
||||
mode: "none",
|
||||
visible: false
|
||||
};
|
||||
function cloneVec3(vector) {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
function areVec3Equal(left, right) {
|
||||
return left.x === right.x && left.y === right.y && left.z === right.z;
|
||||
}
|
||||
export function isModelInstanceCollisionMode(value) {
|
||||
return MODEL_INSTANCE_COLLISION_MODES.includes(value);
|
||||
}
|
||||
export function createModelInstanceCollisionSettings(overrides = {}) {
|
||||
const mode = overrides.mode ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS.mode;
|
||||
if (!isModelInstanceCollisionMode(mode)) {
|
||||
throw new Error("Model instance collision mode must be a supported value.");
|
||||
}
|
||||
const visible = overrides.visible ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS.visible;
|
||||
if (typeof visible !== "boolean") {
|
||||
throw new Error("Model instance collision visibility must be a boolean.");
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
visible
|
||||
};
|
||||
}
|
||||
export function cloneModelInstanceCollisionSettings(settings) {
|
||||
return createModelInstanceCollisionSettings(settings);
|
||||
}
|
||||
export function areModelInstanceCollisionSettingsEqual(left, right) {
|
||||
return left.mode === right.mode && left.visible === right.visible;
|
||||
}
|
||||
export function normalizeModelInstanceName(name) {
|
||||
if (name === undefined || name === null) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
return trimmedName.length === 0 ? undefined : trimmedName;
|
||||
}
|
||||
function assertFiniteVec3(vector, label) {
|
||||
if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) {
|
||||
throw new Error(`${label} must be finite on every axis.`);
|
||||
}
|
||||
}
|
||||
function assertPositiveFiniteVec3(vector, label) {
|
||||
assertFiniteVec3(vector, label);
|
||||
if (vector.x <= 0 || vector.y <= 0 || vector.z <= 0) {
|
||||
throw new Error(`${label} must remain positive on every axis.`);
|
||||
}
|
||||
}
|
||||
export function createModelInstance(overrides) {
|
||||
const position = cloneVec3(overrides.position ?? DEFAULT_MODEL_INSTANCE_POSITION);
|
||||
const rotationDegrees = cloneVec3(overrides.rotationDegrees ?? DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES);
|
||||
const scale = cloneVec3(overrides.scale ?? DEFAULT_MODEL_INSTANCE_SCALE);
|
||||
const collision = cloneModelInstanceCollisionSettings(overrides.collision ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS);
|
||||
if (overrides.assetId.trim().length === 0) {
|
||||
throw new Error("Model instance assetId must be a non-empty string.");
|
||||
}
|
||||
assertFiniteVec3(position, "Model instance position");
|
||||
assertFiniteVec3(rotationDegrees, "Model instance rotation");
|
||||
assertPositiveFiniteVec3(scale, "Model instance scale");
|
||||
return {
|
||||
id: overrides.id ?? createOpaqueId("model-instance"),
|
||||
kind: "modelInstance",
|
||||
assetId: overrides.assetId,
|
||||
name: normalizeModelInstanceName(overrides.name),
|
||||
position,
|
||||
rotationDegrees,
|
||||
scale,
|
||||
collision,
|
||||
animationClipName: overrides.animationClipName,
|
||||
animationAutoplay: overrides.animationAutoplay
|
||||
};
|
||||
}
|
||||
export function createModelInstancePlacementPosition(asset, anchor) {
|
||||
const boundingBox = asset?.metadata.boundingBox;
|
||||
if (anchor !== null) {
|
||||
const floorOffset = boundingBox === null || boundingBox === undefined ? 0 : -boundingBox.min.y;
|
||||
return {
|
||||
x: anchor.x,
|
||||
y: anchor.y + floorOffset,
|
||||
z: anchor.z
|
||||
};
|
||||
}
|
||||
return {
|
||||
x: DEFAULT_MODEL_INSTANCE_POSITION.x,
|
||||
y: boundingBox === null || boundingBox === undefined ? DEFAULT_MODEL_INSTANCE_POSITION.y : Math.max(DEFAULT_MODEL_INSTANCE_POSITION.y, -boundingBox.min.y),
|
||||
z: DEFAULT_MODEL_INSTANCE_POSITION.z
|
||||
};
|
||||
}
|
||||
export function cloneModelInstance(instance) {
|
||||
return createModelInstance(instance);
|
||||
}
|
||||
export function areModelInstancesEqual(left, right) {
|
||||
return (left.id === right.id &&
|
||||
left.kind === right.kind &&
|
||||
left.assetId === right.assetId &&
|
||||
left.name === right.name &&
|
||||
areVec3Equal(left.position, right.position) &&
|
||||
areVec3Equal(left.rotationDegrees, right.rotationDegrees) &&
|
||||
areVec3Equal(left.scale, right.scale) &&
|
||||
areModelInstanceCollisionSettingsEqual(left.collision, right.collision) &&
|
||||
left.animationClipName === right.animationClipName &&
|
||||
left.animationAutoplay === right.animationAutoplay);
|
||||
}
|
||||
export function compareModelInstances(left, right) {
|
||||
if (left.assetId !== right.assetId) {
|
||||
return left.assetId.localeCompare(right.assetId);
|
||||
}
|
||||
const leftName = left.name ?? "";
|
||||
const rightName = right.name ?? "";
|
||||
if (leftName !== rightName) {
|
||||
return leftName.localeCompare(rightName);
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
export function getModelInstances(modelInstances) {
|
||||
return Object.values(modelInstances).sort(compareModelInstances);
|
||||
}
|
||||
export function getModelInstanceKindLabel() {
|
||||
return "Model Instance";
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
const PROJECT_ASSET_DATABASE_NAME = "webeditor3d-project-assets";
|
||||
const PROJECT_ASSET_DATABASE_VERSION = 1;
|
||||
const PROJECT_ASSET_OBJECT_STORE_NAME = "assets";
|
||||
function cloneArrayBuffer(bytes) {
|
||||
return bytes.slice(0);
|
||||
}
|
||||
function cloneFileRecord(file) {
|
||||
return {
|
||||
bytes: cloneArrayBuffer(file.bytes),
|
||||
mimeType: file.mimeType
|
||||
};
|
||||
}
|
||||
export function cloneProjectAssetStorageRecord(record) {
|
||||
const files = {};
|
||||
for (const [path, file] of Object.entries(record.files)) {
|
||||
files[path] = cloneFileRecord(file);
|
||||
}
|
||||
return {
|
||||
files
|
||||
};
|
||||
}
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object";
|
||||
}
|
||||
function isLegacyProjectAssetStorageRecord(value) {
|
||||
return (isObject(value) &&
|
||||
value.bytes instanceof ArrayBuffer &&
|
||||
typeof value.mimeType === "string");
|
||||
}
|
||||
function isProjectAssetStoragePackageRecord(value) {
|
||||
if (!isObject(value) || !isObject(value.files)) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value.files).every((entry) => {
|
||||
return (isObject(entry) &&
|
||||
entry.bytes instanceof ArrayBuffer &&
|
||||
typeof entry.mimeType === "string");
|
||||
});
|
||||
}
|
||||
function normalizeStoredAssetRecord(storageKey, value) {
|
||||
if (isProjectAssetStoragePackageRecord(value)) {
|
||||
return cloneProjectAssetStorageRecord(value);
|
||||
}
|
||||
if (isLegacyProjectAssetStorageRecord(value)) {
|
||||
return {
|
||||
files: {
|
||||
[storageKey]: cloneFileRecord(value)
|
||||
}
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function getErrorDetail(error) {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message.trim();
|
||||
}
|
||||
return "Unknown error.";
|
||||
}
|
||||
function formatDiagnostic(prefix, error) {
|
||||
return `${prefix} ${getErrorDetail(error)}`;
|
||||
}
|
||||
function promisifyRequest(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.addEventListener("success", () => {
|
||||
resolve(request.result);
|
||||
});
|
||||
request.addEventListener("error", () => {
|
||||
reject(request.error ?? new Error("IndexedDB request failed."));
|
||||
});
|
||||
});
|
||||
}
|
||||
function openIndexedDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(PROJECT_ASSET_DATABASE_NAME, PROJECT_ASSET_DATABASE_VERSION);
|
||||
request.addEventListener("upgradeneeded", () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(PROJECT_ASSET_OBJECT_STORE_NAME)) {
|
||||
database.createObjectStore(PROJECT_ASSET_OBJECT_STORE_NAME);
|
||||
}
|
||||
});
|
||||
request.addEventListener("success", () => {
|
||||
resolve(request.result);
|
||||
});
|
||||
request.addEventListener("error", () => {
|
||||
reject(request.error ?? new Error("IndexedDB open failed."));
|
||||
});
|
||||
});
|
||||
}
|
||||
class IndexedDbProjectAssetStorage {
|
||||
databasePromise;
|
||||
constructor(databasePromise) {
|
||||
this.databasePromise = databasePromise;
|
||||
}
|
||||
async withStore(mode, callback) {
|
||||
const database = await this.databasePromise;
|
||||
const transaction = database.transaction(PROJECT_ASSET_OBJECT_STORE_NAME, mode);
|
||||
const store = transaction.objectStore(PROJECT_ASSET_OBJECT_STORE_NAME);
|
||||
const result = await promisifyRequest(callback(store));
|
||||
await new Promise((resolve, reject) => {
|
||||
transaction.addEventListener("complete", () => resolve());
|
||||
transaction.addEventListener("error", () => reject(transaction.error ?? new Error("IndexedDB transaction failed.")));
|
||||
transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("IndexedDB transaction aborted.")));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
async getAsset(storageKey) {
|
||||
const database = await this.databasePromise;
|
||||
const transaction = database.transaction(PROJECT_ASSET_OBJECT_STORE_NAME, "readonly");
|
||||
const store = transaction.objectStore(PROJECT_ASSET_OBJECT_STORE_NAME);
|
||||
const result = await promisifyRequest(store.get(storageKey));
|
||||
return normalizeStoredAssetRecord(storageKey, result);
|
||||
}
|
||||
async putAsset(storageKey, asset) {
|
||||
await this.withStore("readwrite", (store) => store.put(cloneProjectAssetStorageRecord(asset), storageKey));
|
||||
}
|
||||
async deleteAsset(storageKey) {
|
||||
await this.withStore("readwrite", (store) => store.delete(storageKey));
|
||||
}
|
||||
}
|
||||
class InMemoryProjectAssetStorage {
|
||||
values = new Map();
|
||||
constructor(initialValues = {}) {
|
||||
for (const [storageKey, asset] of Object.entries(initialValues)) {
|
||||
this.values.set(storageKey, cloneStoredAsset(asset));
|
||||
}
|
||||
}
|
||||
async getAsset(storageKey) {
|
||||
const asset = this.values.get(storageKey);
|
||||
if (asset === undefined) {
|
||||
return null;
|
||||
}
|
||||
return normalizeStoredAssetRecord(storageKey, asset);
|
||||
}
|
||||
async putAsset(storageKey, asset) {
|
||||
this.values.set(storageKey, cloneProjectAssetStorageRecord(asset));
|
||||
}
|
||||
async deleteAsset(storageKey) {
|
||||
this.values.delete(storageKey);
|
||||
}
|
||||
}
|
||||
function cloneStoredAsset(asset) {
|
||||
if (isLegacyProjectAssetStorageRecord(asset)) {
|
||||
return cloneFileRecord(asset);
|
||||
}
|
||||
return cloneProjectAssetStorageRecord(asset);
|
||||
}
|
||||
export function createInMemoryProjectAssetStorage(initialValues = {}) {
|
||||
return new InMemoryProjectAssetStorage(initialValues);
|
||||
}
|
||||
export async function getBrowserProjectAssetStorageAccess() {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
storage: null,
|
||||
diagnostic: null
|
||||
};
|
||||
}
|
||||
if (typeof indexedDB === "undefined") {
|
||||
return {
|
||||
storage: null,
|
||||
diagnostic: "IndexedDB is unavailable in this browser environment."
|
||||
};
|
||||
}
|
||||
try {
|
||||
const databasePromise = openIndexedDb();
|
||||
await databasePromise;
|
||||
return {
|
||||
storage: new IndexedDbProjectAssetStorage(databasePromise),
|
||||
diagnostic: null
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
storage: null,
|
||||
diagnostic: formatDiagnostic("Project asset storage could not be opened.", error)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
export const PROJECT_ASSET_KINDS = ["model", "image", "audio"];
|
||||
export function createProjectAssetStorageKey(assetId) {
|
||||
return `project-asset:${assetId}`;
|
||||
}
|
||||
export function isProjectAssetKind(value) {
|
||||
return value === "model" || value === "image" || value === "audio";
|
||||
}
|
||||
function cloneVec3(vector) {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
function cloneBoundingBox(boundingBox) {
|
||||
if (boundingBox === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
min: cloneVec3(boundingBox.min),
|
||||
max: cloneVec3(boundingBox.max),
|
||||
size: cloneVec3(boundingBox.size)
|
||||
};
|
||||
}
|
||||
function cloneModelAssetMetadata(metadata) {
|
||||
return {
|
||||
kind: "model",
|
||||
format: metadata.format,
|
||||
sceneName: metadata.sceneName,
|
||||
nodeCount: metadata.nodeCount,
|
||||
meshCount: metadata.meshCount,
|
||||
materialNames: [...metadata.materialNames],
|
||||
textureNames: [...metadata.textureNames],
|
||||
animationNames: [...metadata.animationNames],
|
||||
boundingBox: cloneBoundingBox(metadata.boundingBox),
|
||||
warnings: [...metadata.warnings]
|
||||
};
|
||||
}
|
||||
function cloneImageAssetMetadata(metadata) {
|
||||
return {
|
||||
kind: "image",
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
hasAlpha: metadata.hasAlpha,
|
||||
warnings: [...metadata.warnings]
|
||||
};
|
||||
}
|
||||
function cloneAudioAssetMetadata(metadata) {
|
||||
return {
|
||||
kind: "audio",
|
||||
durationSeconds: metadata.durationSeconds,
|
||||
channelCount: metadata.channelCount,
|
||||
sampleRateHz: metadata.sampleRateHz,
|
||||
warnings: [...metadata.warnings]
|
||||
};
|
||||
}
|
||||
export function cloneProjectAssetRecord(asset) {
|
||||
switch (asset.kind) {
|
||||
case "model":
|
||||
return {
|
||||
id: asset.id,
|
||||
kind: "model",
|
||||
sourceName: asset.sourceName,
|
||||
mimeType: asset.mimeType,
|
||||
storageKey: asset.storageKey,
|
||||
byteLength: asset.byteLength,
|
||||
metadata: cloneModelAssetMetadata(asset.metadata)
|
||||
};
|
||||
case "image":
|
||||
return {
|
||||
id: asset.id,
|
||||
kind: "image",
|
||||
sourceName: asset.sourceName,
|
||||
mimeType: asset.mimeType,
|
||||
storageKey: asset.storageKey,
|
||||
byteLength: asset.byteLength,
|
||||
metadata: cloneImageAssetMetadata(asset.metadata)
|
||||
};
|
||||
case "audio":
|
||||
return {
|
||||
id: asset.id,
|
||||
kind: "audio",
|
||||
sourceName: asset.sourceName,
|
||||
mimeType: asset.mimeType,
|
||||
storageKey: asset.storageKey,
|
||||
byteLength: asset.byteLength,
|
||||
metadata: cloneAudioAssetMetadata(asset.metadata)
|
||||
};
|
||||
}
|
||||
}
|
||||
export function getProjectAssetKindLabel(kind) {
|
||||
switch (kind) {
|
||||
case "model":
|
||||
return "Model";
|
||||
case "image":
|
||||
return "Image";
|
||||
case "audio":
|
||||
return "Audio";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user