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:
2026-04-11 15:48:39 +02:00
parent 277706e950
commit 9b7706bf5b
97 changed files with 0 additions and 21624 deletions

View File

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

View File

@@ -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();
}
}
}

View File

@@ -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();
}

View File

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

View File

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

View File

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

View File

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

View File

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