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,29 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testMatch: ["**/*.e2e.ts"],
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 5_000
|
||||
},
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4173",
|
||||
trace: "on-first-retry"
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
launchOptions: {
|
||||
args: ["--enable-webgl", "--use-gl=angle", "--use-angle=swiftshader-webgl"]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run dev -- --host 127.0.0.1 --port 4173",
|
||||
url: "http://127.0.0.1:4173",
|
||||
reuseExistingServer: !process.env.CI
|
||||
}
|
||||
});
|
||||
3615
src/app/App.js
3615
src/app/App.js
File diff suppressed because one or more lines are too long
@@ -1,391 +0,0 @@
|
||||
import { CommandHistory } from "../commands/command-history";
|
||||
import { areEditorSelectionsEqual, normalizeSelectionForWhiteboxSelectionMode } from "../core/selection";
|
||||
import {} from "../core/whitebox-selection-mode";
|
||||
import { areTransformSessionsEqual, cloneTransformSession, createInactiveTransformSession } from "../core/transform-session";
|
||||
import { createEmptySceneDocument } from "../document/scene-document";
|
||||
import { DEFAULT_SCENE_DRAFT_STORAGE_KEY, loadSceneDocumentDraft, saveSceneDocumentDraft } from "../serialization/local-draft-storage";
|
||||
import { parseSceneDocumentJson, serializeSceneDocument } from "../serialization/scene-document-json";
|
||||
import { areViewportToolPreviewsEqual, cloneViewportToolPreview, createDefaultViewportTransientState, isViewportToolPreviewCompatible } from "../viewport-three/viewport-transient-state";
|
||||
import { areViewportPanelCameraStatesEqual, cloneViewportLayoutState, cloneViewportPanelCameraState, createDefaultViewportLayoutState } from "../viewport-three/viewport-layout";
|
||||
export class EditorStore {
|
||||
document;
|
||||
selection = { kind: "none" };
|
||||
whiteboxSelectionMode = "object";
|
||||
toolMode = "select";
|
||||
viewportLayoutMode;
|
||||
activeViewportPanelId;
|
||||
viewportPanels;
|
||||
viewportQuadSplit;
|
||||
viewportTransientState = createDefaultViewportTransientState();
|
||||
previousEditingToolMode = "select";
|
||||
history = new CommandHistory();
|
||||
listeners = new Set();
|
||||
storage;
|
||||
storageKey;
|
||||
lastCommandLabel = null;
|
||||
snapshot;
|
||||
commandContext = {
|
||||
getDocument: () => this.document,
|
||||
setDocument: (document) => {
|
||||
this.document = document;
|
||||
},
|
||||
getSelection: () => this.selection,
|
||||
setSelection: (selection) => {
|
||||
this.selection = selection;
|
||||
},
|
||||
getToolMode: () => this.toolMode,
|
||||
setToolMode: (toolMode) => {
|
||||
this.toolMode = toolMode;
|
||||
}
|
||||
};
|
||||
constructor(options = {}) {
|
||||
const initialViewportLayoutState = cloneViewportLayoutState(options.initialViewportLayoutState ?? createDefaultViewportLayoutState());
|
||||
this.document = options.initialDocument ?? createEmptySceneDocument();
|
||||
this.viewportLayoutMode = initialViewportLayoutState.layoutMode;
|
||||
this.activeViewportPanelId = initialViewportLayoutState.activePanelId;
|
||||
this.viewportPanels = initialViewportLayoutState.panels;
|
||||
this.viewportQuadSplit = initialViewportLayoutState.viewportQuadSplit;
|
||||
this.storage = options.storage ?? null;
|
||||
this.storageKey = options.storageKey ?? DEFAULT_SCENE_DRAFT_STORAGE_KEY;
|
||||
this.snapshot = this.createSnapshot();
|
||||
}
|
||||
subscribe = (listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
getState = () => this.snapshot;
|
||||
setToolMode(toolMode) {
|
||||
if (this.toolMode === toolMode) {
|
||||
return;
|
||||
}
|
||||
if (toolMode !== "play") {
|
||||
this.previousEditingToolMode = toolMode;
|
||||
}
|
||||
this.toolMode = toolMode;
|
||||
if (!isViewportToolPreviewCompatible(toolMode, this.viewportTransientState.toolPreview)) {
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
toolPreview: createDefaultViewportTransientState().toolPreview
|
||||
};
|
||||
}
|
||||
if (toolMode !== "select" && this.viewportTransientState.transformSession.kind !== "none") {
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
transformSession: createInactiveTransformSession()
|
||||
};
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
setViewportLayoutMode(viewportLayoutMode) {
|
||||
if (this.viewportLayoutMode === viewportLayoutMode) {
|
||||
return;
|
||||
}
|
||||
this.viewportLayoutMode = viewportLayoutMode;
|
||||
this.emit();
|
||||
}
|
||||
setActiveViewportPanel(panelId) {
|
||||
if (this.activeViewportPanelId === panelId) {
|
||||
return;
|
||||
}
|
||||
this.activeViewportPanelId = panelId;
|
||||
this.emit();
|
||||
}
|
||||
setViewportPanelViewMode(panelId, viewMode) {
|
||||
if (this.viewportPanels[panelId].viewMode === viewMode) {
|
||||
return;
|
||||
}
|
||||
this.viewportPanels = {
|
||||
...this.viewportPanels,
|
||||
[panelId]: {
|
||||
...this.viewportPanels[panelId],
|
||||
viewMode
|
||||
}
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
setViewportPanelDisplayMode(panelId, displayMode) {
|
||||
if (this.viewportPanels[panelId].displayMode === displayMode) {
|
||||
return;
|
||||
}
|
||||
this.viewportPanels = {
|
||||
...this.viewportPanels,
|
||||
[panelId]: {
|
||||
...this.viewportPanels[panelId],
|
||||
displayMode
|
||||
}
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
setViewportPanelCameraState(panelId, cameraState) {
|
||||
if (areViewportPanelCameraStatesEqual(this.viewportPanels[panelId].cameraState, cameraState)) {
|
||||
return;
|
||||
}
|
||||
this.viewportPanels = {
|
||||
...this.viewportPanels,
|
||||
[panelId]: {
|
||||
...this.viewportPanels[panelId],
|
||||
cameraState: cloneViewportPanelCameraState(cameraState)
|
||||
}
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
setViewportQuadSplit(viewportQuadSplit) {
|
||||
if (this.viewportQuadSplit.x === viewportQuadSplit.x && this.viewportQuadSplit.y === viewportQuadSplit.y) {
|
||||
return;
|
||||
}
|
||||
this.viewportQuadSplit = {
|
||||
x: viewportQuadSplit.x,
|
||||
y: viewportQuadSplit.y
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
setViewportToolPreview(toolPreview) {
|
||||
const nextToolPreview = cloneViewportToolPreview(toolPreview);
|
||||
if (areViewportToolPreviewsEqual(this.viewportTransientState.toolPreview, nextToolPreview)) {
|
||||
return;
|
||||
}
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
toolPreview: nextToolPreview
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
clearViewportToolPreview(sourcePanelId) {
|
||||
const currentToolPreview = this.viewportTransientState.toolPreview;
|
||||
if (currentToolPreview.kind === "none") {
|
||||
return;
|
||||
}
|
||||
if (sourcePanelId !== undefined && currentToolPreview.sourcePanelId !== sourcePanelId) {
|
||||
return;
|
||||
}
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
toolPreview: createDefaultViewportTransientState().toolPreview
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
setTransformSession(transformSession) {
|
||||
const nextTransformSession = cloneTransformSession(transformSession);
|
||||
if (areTransformSessionsEqual(this.viewportTransientState.transformSession, nextTransformSession)) {
|
||||
return;
|
||||
}
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
transformSession: nextTransformSession
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
clearTransformSession() {
|
||||
if (this.viewportTransientState.transformSession.kind === "none") {
|
||||
return;
|
||||
}
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
transformSession: createInactiveTransformSession()
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
setTransformAxisConstraint(axisConstraint) {
|
||||
if (this.viewportTransientState.transformSession.kind !== "active") {
|
||||
return;
|
||||
}
|
||||
if (this.viewportTransientState.transformSession.axisConstraint === axisConstraint) {
|
||||
return;
|
||||
}
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
transformSession: {
|
||||
...cloneTransformSession(this.viewportTransientState.transformSession),
|
||||
axisConstraint
|
||||
}
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
setViewportViewMode(viewportViewMode) {
|
||||
this.setViewportPanelViewMode(this.activeViewportPanelId, viewportViewMode);
|
||||
}
|
||||
enterPlayMode() {
|
||||
if (this.toolMode === "play") {
|
||||
return;
|
||||
}
|
||||
this.previousEditingToolMode = this.toolMode;
|
||||
this.toolMode = "play";
|
||||
if (this.viewportTransientState.toolPreview.kind !== "none") {
|
||||
this.viewportTransientState = createDefaultViewportTransientState();
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
exitPlayMode() {
|
||||
if (this.toolMode !== "play") {
|
||||
return;
|
||||
}
|
||||
this.toolMode = this.previousEditingToolMode;
|
||||
this.emit();
|
||||
}
|
||||
setSelection(selection) {
|
||||
if (this.viewportTransientState.transformSession.kind === "active" && !areEditorSelectionsEqual(this.selection, selection)) {
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
transformSession: createInactiveTransformSession()
|
||||
};
|
||||
}
|
||||
this.selection = selection;
|
||||
this.emit();
|
||||
}
|
||||
setWhiteboxSelectionMode(mode) {
|
||||
if (this.whiteboxSelectionMode === mode) {
|
||||
return;
|
||||
}
|
||||
if (this.viewportTransientState.transformSession.kind !== "none") {
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
transformSession: createInactiveTransformSession()
|
||||
};
|
||||
}
|
||||
this.whiteboxSelectionMode = mode;
|
||||
this.selection = normalizeSelectionForWhiteboxSelectionMode(this.selection, mode);
|
||||
this.emit();
|
||||
}
|
||||
executeCommand(command) {
|
||||
if (this.viewportTransientState.transformSession.kind !== "none") {
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
transformSession: createInactiveTransformSession()
|
||||
};
|
||||
}
|
||||
this.history.execute(command, this.commandContext);
|
||||
this.lastCommandLabel = command.label;
|
||||
this.emit();
|
||||
}
|
||||
undo() {
|
||||
let clearedTransformSession = false;
|
||||
if (this.viewportTransientState.transformSession.kind !== "none") {
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
transformSession: createInactiveTransformSession()
|
||||
};
|
||||
clearedTransformSession = true;
|
||||
}
|
||||
const command = this.history.undo(this.commandContext);
|
||||
if (command === null) {
|
||||
if (clearedTransformSession) {
|
||||
this.emit();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.lastCommandLabel = `Undid ${command.label}`;
|
||||
this.emit();
|
||||
return true;
|
||||
}
|
||||
redo() {
|
||||
let clearedTransformSession = false;
|
||||
if (this.viewportTransientState.transformSession.kind !== "none") {
|
||||
this.viewportTransientState = {
|
||||
...this.viewportTransientState,
|
||||
transformSession: createInactiveTransformSession()
|
||||
};
|
||||
clearedTransformSession = true;
|
||||
}
|
||||
const command = this.history.redo(this.commandContext);
|
||||
if (command === null) {
|
||||
if (clearedTransformSession) {
|
||||
this.emit();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.lastCommandLabel = `Redid ${command.label}`;
|
||||
this.emit();
|
||||
return true;
|
||||
}
|
||||
replaceDocument(document, resetHistory = true) {
|
||||
this.document = document;
|
||||
this.selection = { kind: "none" };
|
||||
this.whiteboxSelectionMode = "object";
|
||||
this.toolMode = "select";
|
||||
this.previousEditingToolMode = "select";
|
||||
this.viewportTransientState = createDefaultViewportTransientState();
|
||||
if (resetHistory) {
|
||||
this.history.clear();
|
||||
this.lastCommandLabel = null;
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
saveDraft() {
|
||||
if (this.storage === null) {
|
||||
return {
|
||||
status: "error",
|
||||
message: "Browser local storage is unavailable."
|
||||
};
|
||||
}
|
||||
return saveSceneDocumentDraft(this.storage, this.document, this.createViewportLayoutState(), this.storageKey);
|
||||
}
|
||||
loadDraft() {
|
||||
if (this.storage === null) {
|
||||
return {
|
||||
status: "error",
|
||||
message: "Browser local storage is unavailable."
|
||||
};
|
||||
}
|
||||
const draftResult = loadSceneDocumentDraft(this.storage, this.storageKey);
|
||||
if (draftResult.status !== "loaded") {
|
||||
return draftResult;
|
||||
}
|
||||
this.replaceDocument(draftResult.document);
|
||||
if (draftResult.viewportLayoutState !== null) {
|
||||
this.applyViewportLayoutState(draftResult.viewportLayoutState);
|
||||
this.emit();
|
||||
}
|
||||
return draftResult;
|
||||
}
|
||||
exportDocumentJson() {
|
||||
return serializeSceneDocument(this.document);
|
||||
}
|
||||
importDocumentJson(source) {
|
||||
const document = parseSceneDocumentJson(source);
|
||||
this.replaceDocument(document);
|
||||
return document;
|
||||
}
|
||||
emit() {
|
||||
this.snapshot = this.createSnapshot();
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
createViewportLayoutState() {
|
||||
return cloneViewportLayoutState({
|
||||
layoutMode: this.viewportLayoutMode,
|
||||
activePanelId: this.activeViewportPanelId,
|
||||
panels: this.viewportPanels,
|
||||
viewportQuadSplit: this.viewportQuadSplit
|
||||
});
|
||||
}
|
||||
applyViewportLayoutState(viewportLayoutState) {
|
||||
const nextViewportLayoutState = cloneViewportLayoutState(viewportLayoutState);
|
||||
this.viewportLayoutMode = nextViewportLayoutState.layoutMode;
|
||||
this.activeViewportPanelId = nextViewportLayoutState.activePanelId;
|
||||
this.viewportPanels = nextViewportLayoutState.panels;
|
||||
this.viewportQuadSplit = nextViewportLayoutState.viewportQuadSplit;
|
||||
}
|
||||
createSnapshot() {
|
||||
return {
|
||||
document: this.document,
|
||||
selection: this.selection,
|
||||
whiteboxSelectionMode: this.whiteboxSelectionMode,
|
||||
toolMode: this.toolMode,
|
||||
viewportLayoutMode: this.viewportLayoutMode,
|
||||
activeViewportPanelId: this.activeViewportPanelId,
|
||||
viewportPanels: this.viewportPanels,
|
||||
viewportQuadSplit: this.viewportQuadSplit,
|
||||
viewportTransientState: this.viewportTransientState,
|
||||
canUndo: this.history.canUndo(),
|
||||
canRedo: this.history.canRedo(),
|
||||
lastCommandLabel: this.lastCommandLabel,
|
||||
storageAvailable: this.storage !== null
|
||||
};
|
||||
}
|
||||
}
|
||||
export function createEditorStore(options) {
|
||||
return new EditorStore(options);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
export function useEditorStoreState(store) {
|
||||
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { cloneEditorSelection } from "../core/selection";
|
||||
import { cloneFaceUvState } from "../document/brushes";
|
||||
export function getBoxBrushOrThrow(document, brushId) {
|
||||
const brush = document.brushes[brushId];
|
||||
if (brush === undefined) {
|
||||
throw new Error(`Box brush ${brushId} does not exist.`);
|
||||
}
|
||||
if (brush.kind !== "box") {
|
||||
throw new Error(`Brush ${brushId} is not a supported box brush.`);
|
||||
}
|
||||
return brush;
|
||||
}
|
||||
export function setSingleBrushSelection(brushId) {
|
||||
return {
|
||||
kind: "brushes",
|
||||
ids: [brushId]
|
||||
};
|
||||
}
|
||||
export function setSingleBrushFaceSelection(brushId, faceId) {
|
||||
return {
|
||||
kind: "brushFace",
|
||||
brushId,
|
||||
faceId
|
||||
};
|
||||
}
|
||||
export function setSingleBrushEdgeSelection(brushId, edgeId) {
|
||||
return {
|
||||
kind: "brushEdge",
|
||||
brushId,
|
||||
edgeId
|
||||
};
|
||||
}
|
||||
export function setSingleBrushVertexSelection(brushId, vertexId) {
|
||||
return {
|
||||
kind: "brushVertex",
|
||||
brushId,
|
||||
vertexId
|
||||
};
|
||||
}
|
||||
export function cloneSelectionForCommand(selection) {
|
||||
return cloneEditorSelection(selection);
|
||||
}
|
||||
export function replaceBrush(document, brush) {
|
||||
return {
|
||||
...document,
|
||||
brushes: {
|
||||
...document.brushes,
|
||||
[brush.id]: brush
|
||||
}
|
||||
};
|
||||
}
|
||||
export function removeBrush(document, brushId) {
|
||||
const remainingBrushes = {
|
||||
...document.brushes
|
||||
};
|
||||
delete remainingBrushes[brushId];
|
||||
return {
|
||||
...document,
|
||||
brushes: remainingBrushes
|
||||
};
|
||||
}
|
||||
export function getBoxBrushFaceOrThrow(document, brushId, faceId) {
|
||||
const brush = getBoxBrushOrThrow(document, brushId);
|
||||
const face = brush.faces[faceId];
|
||||
if (face === undefined) {
|
||||
throw new Error(`Box brush ${brushId} does not contain face ${faceId}.`);
|
||||
}
|
||||
return face;
|
||||
}
|
||||
export function replaceBoxBrushFace(document, brushId, faceId, face) {
|
||||
const brush = getBoxBrushOrThrow(document, brushId);
|
||||
return replaceBrush(document, {
|
||||
...brush,
|
||||
faces: {
|
||||
...brush.faces,
|
||||
[faceId]: {
|
||||
materialId: face.materialId,
|
||||
uv: cloneFaceUvState(face.uv)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export class CommandHistory {
|
||||
undoStack = [];
|
||||
redoStack = [];
|
||||
execute(command, context) {
|
||||
command.execute(context);
|
||||
this.undoStack.push(command);
|
||||
this.redoStack.length = 0;
|
||||
}
|
||||
undo(context) {
|
||||
const command = this.undoStack.pop();
|
||||
if (command === undefined) {
|
||||
return null;
|
||||
}
|
||||
command.undo(context);
|
||||
this.redoStack.push(command);
|
||||
return command;
|
||||
}
|
||||
redo(context) {
|
||||
const command = this.redoStack.pop();
|
||||
if (command === undefined) {
|
||||
return null;
|
||||
}
|
||||
command.execute(context);
|
||||
this.undoStack.push(command);
|
||||
return command;
|
||||
}
|
||||
clear() {
|
||||
this.undoStack.length = 0;
|
||||
this.redoStack.length = 0;
|
||||
}
|
||||
canUndo() {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
canRedo() {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {};
|
||||
@@ -1,200 +0,0 @@
|
||||
import { createMoveBoxBrushCommand } from "./move-box-brush-command";
|
||||
import { createResizeBoxBrushCommand } from "./resize-box-brush-command";
|
||||
import { createRotateBoxBrushCommand } from "./rotate-box-brush-command";
|
||||
import { createSetBoxBrushTransformCommand } from "./set-box-brush-transform-command";
|
||||
import { createUpsertEntityCommand } from "./upsert-entity-command";
|
||||
import { createUpsertModelInstanceCommand } from "./upsert-model-instance-command";
|
||||
import { createModelInstance } from "../assets/model-instances";
|
||||
import { createInteractableEntity, createPlayerStartEntity, createPointLightEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity } from "../entities/entity-instances";
|
||||
import { getTransformOperationLabel } from "../core/transform-session";
|
||||
function createTransformCommandLabel(session) {
|
||||
return `${getTransformOperationLabel(session.operation)} ${session.target.kind === "brush"
|
||||
? "whitebox box"
|
||||
: session.target.kind === "brushFace"
|
||||
? "whitebox face"
|
||||
: session.target.kind === "brushEdge"
|
||||
? "whitebox edge"
|
||||
: session.target.kind === "brushVertex"
|
||||
? "whitebox vertex"
|
||||
: session.target.kind === "entity"
|
||||
? session.target.entityKind === "playerStart"
|
||||
? "player start"
|
||||
: session.target.entityKind === "pointLight"
|
||||
? "point light"
|
||||
: session.target.entityKind === "spotLight"
|
||||
? "spot light"
|
||||
: session.target.entityKind === "soundEmitter"
|
||||
? "sound emitter"
|
||||
: session.target.entityKind === "triggerVolume"
|
||||
? "trigger volume"
|
||||
: session.target.entityKind === "teleportTarget"
|
||||
? "teleport target"
|
||||
: "interactable"
|
||||
: "model instance"}`;
|
||||
}
|
||||
export function createCommitTransformSessionCommand(document, session) {
|
||||
switch (session.target.kind) {
|
||||
case "brush":
|
||||
if (session.preview.kind !== "brush") {
|
||||
throw new Error("Brush transform preview is invalid.");
|
||||
}
|
||||
switch (session.operation) {
|
||||
case "translate":
|
||||
return createMoveBoxBrushCommand({
|
||||
brushId: session.target.brushId,
|
||||
center: session.preview.center,
|
||||
snapToGrid: false,
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
case "rotate":
|
||||
return createRotateBoxBrushCommand({
|
||||
brushId: session.target.brushId,
|
||||
rotationDegrees: session.preview.rotationDegrees,
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
case "scale":
|
||||
return createResizeBoxBrushCommand({
|
||||
brushId: session.target.brushId,
|
||||
size: session.preview.size,
|
||||
snapToGrid: false,
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
}
|
||||
case "brushFace":
|
||||
if (session.preview.kind !== "brush") {
|
||||
throw new Error("Whitebox face transform preview is invalid.");
|
||||
}
|
||||
return createSetBoxBrushTransformCommand({
|
||||
selection: {
|
||||
kind: "brushFace",
|
||||
brushId: session.target.brushId,
|
||||
faceId: session.target.faceId
|
||||
},
|
||||
center: session.preview.center,
|
||||
rotationDegrees: session.preview.rotationDegrees,
|
||||
size: session.preview.size,
|
||||
geometry: session.preview.geometry,
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
case "brushEdge":
|
||||
if (session.preview.kind !== "brush") {
|
||||
throw new Error("Whitebox edge transform preview is invalid.");
|
||||
}
|
||||
return createSetBoxBrushTransformCommand({
|
||||
selection: {
|
||||
kind: "brushEdge",
|
||||
brushId: session.target.brushId,
|
||||
edgeId: session.target.edgeId
|
||||
},
|
||||
center: session.preview.center,
|
||||
rotationDegrees: session.preview.rotationDegrees,
|
||||
size: session.preview.size,
|
||||
geometry: session.preview.geometry,
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
case "brushVertex":
|
||||
if (session.preview.kind !== "brush") {
|
||||
throw new Error("Whitebox vertex transform preview is invalid.");
|
||||
}
|
||||
return createSetBoxBrushTransformCommand({
|
||||
selection: {
|
||||
kind: "brushVertex",
|
||||
brushId: session.target.brushId,
|
||||
vertexId: session.target.vertexId
|
||||
},
|
||||
center: session.preview.center,
|
||||
rotationDegrees: session.preview.rotationDegrees,
|
||||
size: session.preview.size,
|
||||
geometry: session.preview.geometry,
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
case "modelInstance": {
|
||||
if (session.preview.kind !== "modelInstance") {
|
||||
throw new Error("Model instance transform preview is invalid.");
|
||||
}
|
||||
const modelInstance = document.modelInstances[session.target.modelInstanceId];
|
||||
if (modelInstance === undefined) {
|
||||
throw new Error(`Model instance ${session.target.modelInstanceId} does not exist.`);
|
||||
}
|
||||
return createUpsertModelInstanceCommand({
|
||||
modelInstance: createModelInstance({
|
||||
...modelInstance,
|
||||
position: session.preview.position,
|
||||
rotationDegrees: session.preview.rotationDegrees,
|
||||
scale: session.preview.scale
|
||||
}),
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
}
|
||||
case "entity": {
|
||||
if (session.preview.kind !== "entity") {
|
||||
throw new Error("Entity transform preview is invalid.");
|
||||
}
|
||||
const entity = document.entities[session.target.entityId];
|
||||
if (entity === undefined) {
|
||||
throw new Error(`Entity ${session.target.entityId} does not exist.`);
|
||||
}
|
||||
switch (entity.kind) {
|
||||
case "pointLight":
|
||||
return createUpsertEntityCommand({
|
||||
entity: createPointLightEntity({
|
||||
...entity,
|
||||
position: session.preview.position
|
||||
}),
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
case "spotLight":
|
||||
return createUpsertEntityCommand({
|
||||
entity: createSpotLightEntity({
|
||||
...entity,
|
||||
position: session.preview.position,
|
||||
direction: session.preview.rotation.kind === "direction" ? session.preview.rotation.direction : entity.direction
|
||||
}),
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
case "playerStart":
|
||||
return createUpsertEntityCommand({
|
||||
entity: createPlayerStartEntity({
|
||||
...entity,
|
||||
position: session.preview.position,
|
||||
yawDegrees: session.preview.rotation.kind === "yaw" ? session.preview.rotation.yawDegrees : entity.yawDegrees
|
||||
}),
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
case "soundEmitter":
|
||||
return createUpsertEntityCommand({
|
||||
entity: createSoundEmitterEntity({
|
||||
...entity,
|
||||
position: session.preview.position
|
||||
}),
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
case "triggerVolume":
|
||||
return createUpsertEntityCommand({
|
||||
entity: createTriggerVolumeEntity({
|
||||
...entity,
|
||||
position: session.preview.position
|
||||
}),
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
case "teleportTarget":
|
||||
return createUpsertEntityCommand({
|
||||
entity: createTeleportTargetEntity({
|
||||
...entity,
|
||||
position: session.preview.position,
|
||||
yawDegrees: session.preview.rotation.kind === "yaw" ? session.preview.rotation.yawDegrees : entity.yawDegrees
|
||||
}),
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
case "interactable":
|
||||
return createUpsertEntityCommand({
|
||||
entity: createInteractableEntity({
|
||||
...entity,
|
||||
position: session.preview.position
|
||||
}),
|
||||
label: createTransformCommandLabel(session)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createBoxBrush, DEFAULT_BOX_BRUSH_CENTER, DEFAULT_BOX_BRUSH_SIZE } from "../document/brushes";
|
||||
import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid, snapVec3ToGrid } from "../geometry/grid-snapping";
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneSelectionForCommand, removeBrush, setSingleBrushSelection } from "./brush-command-helpers";
|
||||
export function createCreateBoxBrushCommand(options = {}) {
|
||||
const snapToGrid = options.snapToGrid ?? true;
|
||||
const brush = createBoxBrush({
|
||||
center: snapToGrid === false
|
||||
? options.center ?? DEFAULT_BOX_BRUSH_CENTER
|
||||
: snapVec3ToGrid(options.center ?? DEFAULT_BOX_BRUSH_CENTER, options.gridSize ?? DEFAULT_GRID_SIZE),
|
||||
size: snapToGrid === false
|
||||
? options.size ?? DEFAULT_BOX_BRUSH_SIZE
|
||||
: snapPositiveSizeToGrid(options.size ?? DEFAULT_BOX_BRUSH_SIZE, options.gridSize ?? DEFAULT_GRID_SIZE)
|
||||
});
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: "Create box brush",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
if (currentDocument.brushes[brush.id] !== undefined) {
|
||||
throw new Error(`Box brush ${brush.id} already exists.`);
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneSelectionForCommand(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
brushes: {
|
||||
...currentDocument.brushes,
|
||||
[brush.id]: brush
|
||||
}
|
||||
});
|
||||
context.setSelection(setSingleBrushSelection(brush.id));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
context.setDocument(removeBrush(context.getDocument(), brush.id));
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneBoxBrush } from "../document/brushes";
|
||||
import { cloneSelectionForCommand, removeBrush } from "./brush-command-helpers";
|
||||
function selectionIncludesBrush(selection, brushId) {
|
||||
return ((selection.kind === "brushes" && selection.ids.includes(brushId)) ||
|
||||
((selection.kind === "brushFace" || selection.kind === "brushEdge" || selection.kind === "brushVertex") &&
|
||||
selection.brushId === brushId));
|
||||
}
|
||||
export function createDeleteBoxBrushCommand(brushId) {
|
||||
let previousBrush = null;
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: "Delete box brush",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const currentBrush = currentDocument.brushes[brushId];
|
||||
if (currentBrush === undefined) {
|
||||
throw new Error(`Box brush ${brushId} does not exist.`);
|
||||
}
|
||||
if (previousBrush === null) {
|
||||
previousBrush = cloneBoxBrush(currentBrush);
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneSelectionForCommand(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
context.setDocument(removeBrush(currentDocument, brushId));
|
||||
if (selectionIncludesBrush(context.getSelection(), brushId)) {
|
||||
context.setSelection({
|
||||
kind: "none"
|
||||
});
|
||||
}
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
if (previousBrush === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
brushes: {
|
||||
...currentDocument.brushes,
|
||||
[previousBrush.id]: cloneBoxBrush(previousBrush)
|
||||
}
|
||||
});
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneEditorSelection } from "../core/selection";
|
||||
import { cloneEntityInstance } from "../entities/entity-instances";
|
||||
function selectionIncludesEntity(selection, entityId) {
|
||||
return selection.kind === "entities" && selection.ids.includes(entityId);
|
||||
}
|
||||
export function createDeleteEntityCommand(entityId) {
|
||||
let previousEntity = null;
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: "Delete entity",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const currentEntity = currentDocument.entities[entityId];
|
||||
if (currentEntity === undefined) {
|
||||
throw new Error(`Entity ${entityId} does not exist.`);
|
||||
}
|
||||
if (previousEntity === null) {
|
||||
previousEntity = cloneEntityInstance(currentEntity);
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneEditorSelection(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
const nextEntities = {
|
||||
...currentDocument.entities
|
||||
};
|
||||
delete nextEntities[entityId];
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
entities: nextEntities
|
||||
});
|
||||
if (selectionIncludesEntity(context.getSelection(), entityId)) {
|
||||
context.setSelection({
|
||||
kind: "none"
|
||||
});
|
||||
}
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
if (previousEntity === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
entities: {
|
||||
...currentDocument.entities,
|
||||
[previousEntity.id]: cloneEntityInstance(previousEntity)
|
||||
}
|
||||
});
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneInteractionLink } from "../interactions/interaction-links";
|
||||
export function createDeleteInteractionLinkCommand(linkId) {
|
||||
let previousLink = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: "Delete interaction link",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const currentLink = currentDocument.interactionLinks[linkId];
|
||||
if (currentLink === undefined) {
|
||||
throw new Error(`Interaction link ${linkId} does not exist.`);
|
||||
}
|
||||
if (previousLink === null) {
|
||||
previousLink = cloneInteractionLink(currentLink);
|
||||
}
|
||||
const nextInteractionLinks = {
|
||||
...currentDocument.interactionLinks
|
||||
};
|
||||
delete nextInteractionLinks[linkId];
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
interactionLinks: nextInteractionLinks
|
||||
});
|
||||
},
|
||||
undo(context) {
|
||||
if (previousLink === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
interactionLinks: {
|
||||
...currentDocument.interactionLinks,
|
||||
[previousLink.id]: cloneInteractionLink(previousLink)
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneEditorSelection } from "../core/selection";
|
||||
import { cloneModelInstance } from "../assets/model-instances";
|
||||
function selectionIncludesModelInstance(selection, modelInstanceId) {
|
||||
return selection.kind === "modelInstances" && selection.ids.includes(modelInstanceId);
|
||||
}
|
||||
export function createDeleteModelInstanceCommand(modelInstanceId) {
|
||||
let previousModelInstance = null;
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: "Delete model instance",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const currentModelInstance = currentDocument.modelInstances[modelInstanceId];
|
||||
if (currentModelInstance === undefined) {
|
||||
throw new Error(`Model instance ${modelInstanceId} does not exist.`);
|
||||
}
|
||||
if (previousModelInstance === null) {
|
||||
previousModelInstance = cloneModelInstance(currentModelInstance);
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneEditorSelection(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
const nextModelInstances = {
|
||||
...currentDocument.modelInstances
|
||||
};
|
||||
delete nextModelInstances[modelInstanceId];
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
modelInstances: nextModelInstances
|
||||
});
|
||||
if (selectionIncludesModelInstance(context.getSelection(), modelInstanceId)) {
|
||||
context.setSelection({
|
||||
kind: "none"
|
||||
});
|
||||
}
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
if (previousModelInstance === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
modelInstances: {
|
||||
...currentDocument.modelInstances,
|
||||
[previousModelInstance.id]: cloneModelInstance(previousModelInstance)
|
||||
}
|
||||
});
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import { cloneModelInstance } from "../assets/model-instances";
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneEditorSelection } from "../core/selection";
|
||||
import { cloneBoxBrush } from "../document/brushes";
|
||||
import { cloneEntityInstance } from "../entities/entity-instances";
|
||||
|
||||
function duplicateBrush(brush) {
|
||||
const duplicatedBrush = cloneBoxBrush(brush);
|
||||
duplicatedBrush.id = createOpaqueId("brush");
|
||||
return duplicatedBrush;
|
||||
}
|
||||
|
||||
function duplicateEntity(entity) {
|
||||
const duplicatedEntity = cloneEntityInstance(entity);
|
||||
duplicatedEntity.id = createOpaqueId(`entity-${duplicatedEntity.kind}`);
|
||||
return duplicatedEntity;
|
||||
}
|
||||
|
||||
function duplicateModelInstance(modelInstance) {
|
||||
const duplicatedModelInstance = cloneModelInstance(modelInstance);
|
||||
duplicatedModelInstance.id = createOpaqueId("model-instance");
|
||||
return duplicatedModelInstance;
|
||||
}
|
||||
|
||||
function resolveDuplicatableBrushIds(selection) {
|
||||
switch (selection.kind) {
|
||||
case "brushes":
|
||||
return selection.ids;
|
||||
case "brushFace":
|
||||
case "brushEdge":
|
||||
case "brushVertex":
|
||||
return [selection.brushId];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createDuplicateSelectionResult(currentDocument, selection) {
|
||||
const duplicatableBrushIds = resolveDuplicatableBrushIds(selection);
|
||||
|
||||
if (duplicatableBrushIds !== null) {
|
||||
if (duplicatableBrushIds.length === 0) {
|
||||
throw new Error("Select at least one whitebox solid to duplicate.");
|
||||
}
|
||||
|
||||
const duplicatedBrushes = duplicatableBrushIds.map((brushId) => {
|
||||
const sourceBrush = currentDocument.brushes[brushId];
|
||||
|
||||
if (sourceBrush === undefined) {
|
||||
throw new Error(`Box brush ${brushId} does not exist.`);
|
||||
}
|
||||
|
||||
if (sourceBrush.kind !== "box") {
|
||||
throw new Error(`Brush ${brushId} is not a supported box brush.`);
|
||||
}
|
||||
|
||||
return duplicateBrush(sourceBrush);
|
||||
});
|
||||
|
||||
return {
|
||||
selection: {
|
||||
kind: "brushes",
|
||||
ids: duplicatedBrushes.map((brush) => brush.id)
|
||||
},
|
||||
brushes: duplicatedBrushes,
|
||||
entities: null,
|
||||
modelInstances: null
|
||||
};
|
||||
}
|
||||
|
||||
if (selection.kind === "entities") {
|
||||
if (selection.ids.length === 0) {
|
||||
throw new Error("Select at least one entity to duplicate.");
|
||||
}
|
||||
|
||||
const duplicatedEntities = selection.ids.map((entityId) => {
|
||||
const sourceEntity = currentDocument.entities[entityId];
|
||||
|
||||
if (sourceEntity === undefined) {
|
||||
throw new Error(`Entity ${entityId} does not exist.`);
|
||||
}
|
||||
|
||||
return duplicateEntity(sourceEntity);
|
||||
});
|
||||
|
||||
return {
|
||||
selection: {
|
||||
kind: "entities",
|
||||
ids: duplicatedEntities.map((entity) => entity.id)
|
||||
},
|
||||
brushes: null,
|
||||
entities: duplicatedEntities,
|
||||
modelInstances: null
|
||||
};
|
||||
}
|
||||
|
||||
if (selection.kind === "modelInstances") {
|
||||
if (selection.ids.length === 0) {
|
||||
throw new Error("Select at least one model instance to duplicate.");
|
||||
}
|
||||
|
||||
const duplicatedModelInstances = selection.ids.map((modelInstanceId) => {
|
||||
const sourceModelInstance = currentDocument.modelInstances[modelInstanceId];
|
||||
|
||||
if (sourceModelInstance === undefined) {
|
||||
throw new Error(`Model instance ${modelInstanceId} does not exist.`);
|
||||
}
|
||||
|
||||
return duplicateModelInstance(sourceModelInstance);
|
||||
});
|
||||
|
||||
return {
|
||||
selection: {
|
||||
kind: "modelInstances",
|
||||
ids: duplicatedModelInstances.map((modelInstance) => modelInstance.id)
|
||||
},
|
||||
brushes: null,
|
||||
entities: null,
|
||||
modelInstances: duplicatedModelInstances
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Selection must contain whitebox solids, entities, or model instances to duplicate.");
|
||||
}
|
||||
|
||||
export function createDuplicateSelectionCommand() {
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
let duplicateSelectionResult = null;
|
||||
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: "Duplicate selection",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneEditorSelection(context.getSelection());
|
||||
}
|
||||
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
|
||||
if (duplicateSelectionResult === null) {
|
||||
duplicateSelectionResult = createDuplicateSelectionResult(currentDocument, context.getSelection());
|
||||
}
|
||||
|
||||
if (duplicateSelectionResult.brushes !== null) {
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
brushes: {
|
||||
...currentDocument.brushes,
|
||||
...Object.fromEntries(duplicateSelectionResult.brushes.map((brush) => [brush.id, cloneBoxBrush(brush)]))
|
||||
}
|
||||
});
|
||||
} else if (duplicateSelectionResult.entities !== null) {
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
entities: {
|
||||
...currentDocument.entities,
|
||||
...Object.fromEntries(duplicateSelectionResult.entities.map((entity) => [entity.id, cloneEntityInstance(entity)]))
|
||||
}
|
||||
});
|
||||
} else if (duplicateSelectionResult.modelInstances !== null) {
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
modelInstances: {
|
||||
...currentDocument.modelInstances,
|
||||
...Object.fromEntries(
|
||||
duplicateSelectionResult.modelInstances.map((modelInstance) => [modelInstance.id, cloneModelInstance(modelInstance)])
|
||||
)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
context.setSelection(cloneEditorSelection(duplicateSelectionResult.selection));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
if (duplicateSelectionResult === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDocument = context.getDocument();
|
||||
|
||||
if (duplicateSelectionResult.brushes !== null) {
|
||||
const nextBrushes = {
|
||||
...currentDocument.brushes
|
||||
};
|
||||
|
||||
for (const duplicatedBrush of duplicateSelectionResult.brushes) {
|
||||
delete nextBrushes[duplicatedBrush.id];
|
||||
}
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
brushes: nextBrushes
|
||||
});
|
||||
} else if (duplicateSelectionResult.entities !== null) {
|
||||
const nextEntities = {
|
||||
...currentDocument.entities
|
||||
};
|
||||
|
||||
for (const duplicatedEntity of duplicateSelectionResult.entities) {
|
||||
delete nextEntities[duplicatedEntity.id];
|
||||
}
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
entities: nextEntities
|
||||
});
|
||||
} else if (duplicateSelectionResult.modelInstances !== null) {
|
||||
const nextModelInstances = {
|
||||
...currentDocument.modelInstances
|
||||
};
|
||||
|
||||
for (const duplicatedModelInstance of duplicateSelectionResult.modelInstances) {
|
||||
delete nextModelInstances[duplicatedModelInstance.id];
|
||||
}
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
modelInstances: nextModelInstances
|
||||
});
|
||||
}
|
||||
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneProjectAssetRecord } from "../assets/project-assets";
|
||||
export function createImportAudioAssetCommand(options) {
|
||||
const nextAsset = cloneProjectAssetRecord(options.asset);
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? `Import ${nextAsset.sourceName}`,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
if (currentDocument.assets[nextAsset.id] !== undefined) {
|
||||
throw new Error(`Asset ${nextAsset.id} already exists.`);
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
assets: {
|
||||
...currentDocument.assets,
|
||||
[nextAsset.id]: cloneProjectAssetRecord(nextAsset)
|
||||
}
|
||||
});
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const nextAssets = {
|
||||
...currentDocument.assets
|
||||
};
|
||||
delete nextAssets[nextAsset.id];
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
assets: nextAssets
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneWorldSettings } from "../document/world-settings";
|
||||
import { cloneProjectAssetRecord } from "../assets/project-assets";
|
||||
export function createImportBackgroundImageAssetCommand(options) {
|
||||
const nextAsset = cloneProjectAssetRecord(options.asset);
|
||||
const nextWorld = cloneWorldSettings(options.world);
|
||||
let previousWorld = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? `Import ${nextAsset.sourceName} as background`,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
if (currentDocument.assets[nextAsset.id] !== undefined) {
|
||||
throw new Error(`Asset ${nextAsset.id} already exists.`);
|
||||
}
|
||||
if (previousWorld === null) {
|
||||
previousWorld = cloneWorldSettings(currentDocument.world);
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
assets: {
|
||||
...currentDocument.assets,
|
||||
[nextAsset.id]: cloneProjectAssetRecord(nextAsset)
|
||||
},
|
||||
world: cloneWorldSettings(nextWorld)
|
||||
});
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const nextAssets = {
|
||||
...currentDocument.assets
|
||||
};
|
||||
delete nextAssets[nextAsset.id];
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
assets: nextAssets,
|
||||
world: previousWorld === null ? cloneWorldSettings(currentDocument.world) : cloneWorldSettings(previousWorld)
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneEditorSelection } from "../core/selection";
|
||||
import { cloneModelInstance } from "../assets/model-instances";
|
||||
import { cloneProjectAssetRecord } from "../assets/project-assets";
|
||||
function setSingleModelInstanceSelection(modelInstanceId) {
|
||||
return {
|
||||
kind: "modelInstances",
|
||||
ids: [modelInstanceId]
|
||||
};
|
||||
}
|
||||
export function createImportModelAssetCommand(options) {
|
||||
const nextAsset = cloneProjectAssetRecord(options.asset);
|
||||
const nextModelInstance = cloneModelInstance(options.modelInstance);
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? `Import ${nextAsset.sourceName}`,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
if (currentDocument.assets[nextAsset.id] !== undefined) {
|
||||
throw new Error(`Asset ${nextAsset.id} already exists.`);
|
||||
}
|
||||
if (currentDocument.modelInstances[nextModelInstance.id] !== undefined) {
|
||||
throw new Error(`Model instance ${nextModelInstance.id} already exists.`);
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneEditorSelection(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
assets: {
|
||||
...currentDocument.assets,
|
||||
[nextAsset.id]: cloneProjectAssetRecord(nextAsset)
|
||||
},
|
||||
modelInstances: {
|
||||
...currentDocument.modelInstances,
|
||||
[nextModelInstance.id]: cloneModelInstance(nextModelInstance)
|
||||
}
|
||||
});
|
||||
context.setSelection(setSingleModelInstanceSelection(nextModelInstance.id));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const nextAssets = {
|
||||
...currentDocument.assets
|
||||
};
|
||||
const nextModelInstances = {
|
||||
...currentDocument.modelInstances
|
||||
};
|
||||
delete nextAssets[nextAsset.id];
|
||||
delete nextModelInstances[nextModelInstance.id];
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
assets: nextAssets,
|
||||
modelInstances: nextModelInstances
|
||||
});
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { DEFAULT_GRID_SIZE, snapVec3ToGrid } from "../geometry/grid-snapping";
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushSelection } from "./brush-command-helpers";
|
||||
export function createMoveBoxBrushCommand(options) {
|
||||
const resolvedCenter = options.snapToGrid === false ? options.center : snapVec3ToGrid(options.center, options.gridSize ?? DEFAULT_GRID_SIZE);
|
||||
let previousCenter = null;
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? "Move box brush",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
|
||||
if (previousCenter === null) {
|
||||
previousCenter = {
|
||||
...brush.center
|
||||
};
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneSelectionForCommand(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
center: {
|
||||
...resolvedCenter
|
||||
}
|
||||
}));
|
||||
context.setSelection(setSingleBrushSelection(options.brushId));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
if (previousCenter === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
center: {
|
||||
...previousCenter
|
||||
}
|
||||
}));
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid } from "../geometry/grid-snapping";
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneBoxBrushGeometry, scaleBoxBrushGeometryToSize } from "../document/brushes";
|
||||
import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushSelection } from "./brush-command-helpers";
|
||||
export function createResizeBoxBrushCommand(options) {
|
||||
const resolvedSize = options.snapToGrid === false ? options.size : snapPositiveSizeToGrid(options.size, options.gridSize ?? DEFAULT_GRID_SIZE);
|
||||
let previousSize = null;
|
||||
let previousGeometry = null;
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? "Resize box brush",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
|
||||
if (previousSize === null) {
|
||||
previousSize = {
|
||||
...brush.size
|
||||
};
|
||||
previousGeometry = cloneBoxBrushGeometry(brush.geometry);
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneSelectionForCommand(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
const nextGeometry = scaleBoxBrushGeometryToSize(brush.geometry, resolvedSize);
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
size: {
|
||||
...resolvedSize
|
||||
},
|
||||
geometry: nextGeometry
|
||||
}));
|
||||
context.setSelection(setSingleBrushSelection(options.brushId));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
if (previousSize === null || previousGeometry === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
size: {
|
||||
...previousSize
|
||||
},
|
||||
geometry: cloneBoxBrushGeometry(previousGeometry)
|
||||
}));
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushSelection } from "./brush-command-helpers";
|
||||
export function createRotateBoxBrushCommand(options) {
|
||||
let previousRotationDegrees = null;
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? "Rotate box brush",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
|
||||
if (previousRotationDegrees === null) {
|
||||
previousRotationDegrees = {
|
||||
...brush.rotationDegrees
|
||||
};
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneSelectionForCommand(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
rotationDegrees: {
|
||||
...options.rotationDegrees
|
||||
}
|
||||
}));
|
||||
context.setSelection(setSingleBrushSelection(options.brushId));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
if (previousRotationDegrees === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
rotationDegrees: {
|
||||
...previousRotationDegrees
|
||||
}
|
||||
}));
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneSelectionForCommand, getBoxBrushFaceOrThrow, replaceBoxBrushFace, setSingleBrushFaceSelection } from "./brush-command-helpers";
|
||||
export function createSetBoxBrushFaceMaterialCommand(options) {
|
||||
let previousMaterialId;
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.materialId === null ? `Clear ${options.faceId} face material` : `Apply material to ${options.faceId} face`,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
|
||||
if (options.materialId !== null && currentDocument.materials[options.materialId] === undefined) {
|
||||
throw new Error(`Material ${options.materialId} does not exist in the document registry.`);
|
||||
}
|
||||
if (previousMaterialId === undefined) {
|
||||
previousMaterialId = currentFace.materialId;
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneSelectionForCommand(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
|
||||
...currentFace,
|
||||
materialId: options.materialId
|
||||
}));
|
||||
context.setSelection(setSingleBrushFaceSelection(options.brushId, options.faceId));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
if (previousMaterialId === undefined) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
|
||||
context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
|
||||
...currentFace,
|
||||
materialId: previousMaterialId
|
||||
}));
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneFaceUvState } from "../document/brushes";
|
||||
import { cloneSelectionForCommand, getBoxBrushFaceOrThrow, replaceBoxBrushFace, setSingleBrushFaceSelection } from "./brush-command-helpers";
|
||||
export function createSetBoxBrushFaceUvStateCommand(options) {
|
||||
let previousUvState = null;
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? `Update ${options.faceId} face UVs`,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
|
||||
if (previousUvState === null) {
|
||||
previousUvState = cloneFaceUvState(currentFace.uv);
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneSelectionForCommand(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
|
||||
...currentFace,
|
||||
uv: cloneFaceUvState(options.uvState)
|
||||
}));
|
||||
context.setSelection(setSingleBrushFaceSelection(options.brushId, options.faceId));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
if (previousUvState === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
|
||||
context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
|
||||
...currentFace,
|
||||
uv: cloneFaceUvState(previousUvState)
|
||||
}));
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { normalizeBrushName } from "../document/brushes";
|
||||
import { getBoxBrushOrThrow, replaceBrush } from "./brush-command-helpers";
|
||||
export function createSetBoxBrushNameCommand(options) {
|
||||
const normalizedName = normalizeBrushName(options.name);
|
||||
let previousName;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: normalizedName === undefined ? "Clear box brush name" : `Rename box brush to ${normalizedName}`,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
|
||||
if (previousName === undefined) {
|
||||
previousName = brush.name;
|
||||
}
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
name: normalizedName
|
||||
}));
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
name: previousName
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneBoxBrushGeometry, deriveBoxBrushSizeFromGeometry, scaleBoxBrushGeometryToSize } from "../document/brushes";
|
||||
import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushEdgeSelection, setSingleBrushFaceSelection, setSingleBrushSelection, setSingleBrushVertexSelection } from "./brush-command-helpers";
|
||||
function cloneVec3(vector) {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
function selectionToEditorSelection(selection) {
|
||||
switch (selection.kind) {
|
||||
case "brush":
|
||||
return setSingleBrushSelection(selection.brushId);
|
||||
case "brushFace":
|
||||
return setSingleBrushFaceSelection(selection.brushId, selection.faceId);
|
||||
case "brushEdge":
|
||||
return setSingleBrushEdgeSelection(selection.brushId, selection.edgeId);
|
||||
case "brushVertex":
|
||||
return setSingleBrushVertexSelection(selection.brushId, selection.vertexId);
|
||||
}
|
||||
}
|
||||
function getBrushId(selection) {
|
||||
return selection.brushId;
|
||||
}
|
||||
function assertPositiveSize(size) {
|
||||
if (!(size.x > 0 && size.y > 0 && size.z > 0)) {
|
||||
throw new Error("Whitebox box size must remain positive on every axis.");
|
||||
}
|
||||
if (!Number.isFinite(size.x) || !Number.isFinite(size.y) || !Number.isFinite(size.z)) {
|
||||
throw new Error("Whitebox box size values must be finite numbers.");
|
||||
}
|
||||
}
|
||||
export function createSetBoxBrushTransformCommand(options) {
|
||||
assertPositiveSize(options.size);
|
||||
let previousSnapshot = null;
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? "Set box brush transform",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const brushId = getBrushId(options.selection);
|
||||
const brush = getBoxBrushOrThrow(currentDocument, brushId);
|
||||
if (previousSnapshot === null) {
|
||||
previousSnapshot = {
|
||||
center: cloneVec3(brush.center),
|
||||
rotationDegrees: cloneVec3(brush.rotationDegrees),
|
||||
size: cloneVec3(brush.size),
|
||||
geometry: cloneBoxBrushGeometry(brush.geometry)
|
||||
};
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneSelectionForCommand(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
const nextGeometry = options.geometry === undefined ? scaleBoxBrushGeometryToSize(brush.geometry, options.size) : cloneBoxBrushGeometry(options.geometry);
|
||||
const nextSize = deriveBoxBrushSizeFromGeometry(nextGeometry);
|
||||
assertPositiveSize(nextSize);
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
center: cloneVec3(options.center),
|
||||
rotationDegrees: cloneVec3(options.rotationDegrees),
|
||||
size: nextSize,
|
||||
geometry: nextGeometry
|
||||
}));
|
||||
context.setSelection(selectionToEditorSelection(options.selection));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
if (previousSnapshot === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
const brushId = getBrushId(options.selection);
|
||||
const brush = getBoxBrushOrThrow(currentDocument, brushId);
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
center: cloneVec3(previousSnapshot.center),
|
||||
rotationDegrees: cloneVec3(previousSnapshot.rotationDegrees),
|
||||
size: cloneVec3(previousSnapshot.size),
|
||||
geometry: cloneBoxBrushGeometry(previousSnapshot.geometry)
|
||||
}));
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneBoxBrushVolumeSettings } from "../document/brushes";
|
||||
import { getBoxBrushOrThrow, replaceBrush } from "./brush-command-helpers";
|
||||
export function createSetBoxBrushVolumeSettingsCommand(options) {
|
||||
const nextVolume = cloneBoxBrushVolumeSettings(options.volume);
|
||||
let previousVolume = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? "Set box volume settings",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
|
||||
if (previousVolume === null) {
|
||||
previousVolume = cloneBoxBrushVolumeSettings(brush.volume);
|
||||
}
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
volume: cloneBoxBrushVolumeSettings(nextVolume)
|
||||
}));
|
||||
},
|
||||
undo(context) {
|
||||
if (previousVolume === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
|
||||
context.setDocument(replaceBrush(currentDocument, {
|
||||
...brush,
|
||||
volume: cloneBoxBrushVolumeSettings(previousVolume)
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneEntityInstance, normalizeEntityName } from "../entities/entity-instances";
|
||||
export function createSetEntityNameCommand(options) {
|
||||
const normalizedName = normalizeEntityName(options.name);
|
||||
let previousName;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: normalizedName === undefined ? "Clear entity name" : `Rename entity to ${normalizedName}`,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const entity = currentDocument.entities[options.entityId];
|
||||
if (entity === undefined) {
|
||||
throw new Error(`Entity ${options.entityId} does not exist.`);
|
||||
}
|
||||
if (previousName === undefined) {
|
||||
previousName = entity.name;
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
entities: {
|
||||
...currentDocument.entities,
|
||||
[entity.id]: cloneEntityInstance({
|
||||
...entity,
|
||||
name: normalizedName
|
||||
})
|
||||
}
|
||||
});
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const entity = currentDocument.entities[options.entityId];
|
||||
if (entity === undefined) {
|
||||
throw new Error(`Entity ${options.entityId} does not exist.`);
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
entities: {
|
||||
...currentDocument.entities,
|
||||
[entity.id]: cloneEntityInstance({
|
||||
...entity,
|
||||
name: previousName
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneModelInstance, normalizeModelInstanceName } from "../assets/model-instances";
|
||||
export function createSetModelInstanceNameCommand(options) {
|
||||
const normalizedName = normalizeModelInstanceName(options.name);
|
||||
let previousName;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: normalizedName === undefined ? "Clear model instance name" : `Rename model instance to ${normalizedName}`,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const modelInstance = currentDocument.modelInstances[options.modelInstanceId];
|
||||
if (modelInstance === undefined) {
|
||||
throw new Error(`Model instance ${options.modelInstanceId} does not exist.`);
|
||||
}
|
||||
if (previousName === undefined) {
|
||||
previousName = modelInstance.name;
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
modelInstances: {
|
||||
...currentDocument.modelInstances,
|
||||
[modelInstance.id]: cloneModelInstance({
|
||||
...modelInstance,
|
||||
name: normalizedName
|
||||
})
|
||||
}
|
||||
});
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const modelInstance = currentDocument.modelInstances[options.modelInstanceId];
|
||||
if (modelInstance === undefined) {
|
||||
throw new Error(`Model instance ${options.modelInstanceId} does not exist.`);
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
modelInstances: {
|
||||
...currentDocument.modelInstances,
|
||||
[modelInstance.id]: cloneModelInstance({
|
||||
...modelInstance,
|
||||
name: previousName
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { createPlayerStartEntity } from "../entities/entity-instances";
|
||||
import { createUpsertEntityCommand } from "./upsert-entity-command";
|
||||
export function createSetPlayerStartCommand(options) {
|
||||
return createUpsertEntityCommand({
|
||||
entity: createPlayerStartEntity({
|
||||
id: options.entityId,
|
||||
position: options.position,
|
||||
yawDegrees: options.yawDegrees
|
||||
}),
|
||||
label: options.entityId === undefined ? "Place player start" : "Move player start"
|
||||
});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
export function createSetSceneNameCommand(nextName) {
|
||||
const normalizedName = nextName.trim() || "Untitled Scene";
|
||||
let previousName = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: `Rename scene to ${normalizedName}`,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
if (previousName === null) {
|
||||
previousName = currentDocument.name;
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
name: normalizedName
|
||||
});
|
||||
},
|
||||
undo(context) {
|
||||
if (previousName === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
name: previousName
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneWorldSettings } from "../document/world-settings";
|
||||
export function createSetWorldSettingsCommand(options) {
|
||||
const nextWorld = cloneWorldSettings(options.world);
|
||||
let previousWorld = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
if (previousWorld === null) {
|
||||
previousWorld = cloneWorldSettings(currentDocument.world);
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
world: cloneWorldSettings(nextWorld)
|
||||
});
|
||||
},
|
||||
undo(context) {
|
||||
if (previousWorld === null) {
|
||||
return;
|
||||
}
|
||||
const currentDocument = context.getDocument();
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
world: cloneWorldSettings(previousWorld)
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { cloneEditorSelection } from "../core/selection";
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneEntityInstance, getEntityKindLabel } from "../entities/entity-instances";
|
||||
function setSingleEntitySelection(entityId) {
|
||||
return {
|
||||
kind: "entities",
|
||||
ids: [entityId]
|
||||
};
|
||||
}
|
||||
function createDefaultEntityCommandLabel(entity, isNewEntity) {
|
||||
const action = isNewEntity ? "Place" : "Update";
|
||||
return `${action} ${getEntityKindLabel(entity.kind).toLowerCase()}`;
|
||||
}
|
||||
export function createUpsertEntityCommand(options) {
|
||||
const nextEntity = cloneEntityInstance(options.entity);
|
||||
let previousEntity = null;
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? createDefaultEntityCommandLabel(nextEntity, true),
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const currentEntity = currentDocument.entities[nextEntity.id];
|
||||
if (currentEntity !== undefined && currentEntity.kind !== nextEntity.kind) {
|
||||
throw new Error(`Entity ${nextEntity.id} is a ${currentEntity.kind}, not a ${nextEntity.kind}.`);
|
||||
}
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneEditorSelection(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
if (previousEntity === null && currentEntity !== undefined) {
|
||||
previousEntity = cloneEntityInstance(currentEntity);
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
entities: {
|
||||
...currentDocument.entities,
|
||||
[nextEntity.id]: cloneEntityInstance(nextEntity)
|
||||
}
|
||||
});
|
||||
context.setSelection(setSingleEntitySelection(nextEntity.id));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const nextEntities = {
|
||||
...currentDocument.entities
|
||||
};
|
||||
if (previousEntity === null) {
|
||||
delete nextEntities[nextEntity.id];
|
||||
}
|
||||
else {
|
||||
nextEntities[nextEntity.id] = cloneEntityInstance(previousEntity);
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
entities: nextEntities
|
||||
});
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { cloneInteractionLink } from "../interactions/interaction-links";
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
export function createUpsertInteractionLinkCommand(options) {
|
||||
const nextLink = cloneInteractionLink(options.link);
|
||||
let previousLink = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? "Update interaction link",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const currentLink = currentDocument.interactionLinks[nextLink.id];
|
||||
if (previousLink === null && currentLink !== undefined) {
|
||||
previousLink = cloneInteractionLink(currentLink);
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
interactionLinks: {
|
||||
...currentDocument.interactionLinks,
|
||||
[nextLink.id]: cloneInteractionLink(nextLink)
|
||||
}
|
||||
});
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const nextInteractionLinks = {
|
||||
...currentDocument.interactionLinks
|
||||
};
|
||||
if (previousLink === null) {
|
||||
delete nextInteractionLinks[nextLink.id];
|
||||
}
|
||||
else {
|
||||
nextInteractionLinks[nextLink.id] = cloneInteractionLink(previousLink);
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
interactionLinks: nextInteractionLinks
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneEditorSelection } from "../core/selection";
|
||||
import { cloneModelInstance, getModelInstanceKindLabel } from "../assets/model-instances";
|
||||
import { getProjectAssetKindLabel } from "../assets/project-assets";
|
||||
function setSingleModelInstanceSelection(modelInstanceId) {
|
||||
return {
|
||||
kind: "modelInstances",
|
||||
ids: [modelInstanceId]
|
||||
};
|
||||
}
|
||||
function createDefaultModelInstanceCommandLabel(isNewModelInstance) {
|
||||
const action = isNewModelInstance ? "Place" : "Update";
|
||||
return `${action} ${getModelInstanceKindLabel().toLowerCase()}`;
|
||||
}
|
||||
export function createUpsertModelInstanceCommand(options) {
|
||||
const nextModelInstance = cloneModelInstance(options.modelInstance);
|
||||
let previousModelInstance = null;
|
||||
let previousSelection = null;
|
||||
let previousToolMode = null;
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? createDefaultModelInstanceCommandLabel(true),
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const currentAsset = currentDocument.assets[nextModelInstance.assetId];
|
||||
if (currentAsset === undefined) {
|
||||
throw new Error(`Model instance ${nextModelInstance.id} cannot reference missing asset ${nextModelInstance.assetId}.`);
|
||||
}
|
||||
if (currentAsset.kind !== "model") {
|
||||
throw new Error(`Model instance ${nextModelInstance.id} must reference a model asset, not ${getProjectAssetKindLabel(currentAsset.kind).toLowerCase()}.`);
|
||||
}
|
||||
const currentModelInstance = currentDocument.modelInstances[nextModelInstance.id];
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneEditorSelection(context.getSelection());
|
||||
}
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
if (previousModelInstance === null && currentModelInstance !== undefined) {
|
||||
previousModelInstance = cloneModelInstance(currentModelInstance);
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
modelInstances: {
|
||||
...currentDocument.modelInstances,
|
||||
[nextModelInstance.id]: cloneModelInstance(nextModelInstance)
|
||||
}
|
||||
});
|
||||
context.setSelection(setSingleModelInstanceSelection(nextModelInstance.id));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const nextModelInstances = {
|
||||
...currentDocument.modelInstances
|
||||
};
|
||||
if (previousModelInstance === null) {
|
||||
delete nextModelInstances[nextModelInstance.id];
|
||||
}
|
||||
else {
|
||||
nextModelInstances[nextModelInstance.id] = cloneModelInstance(previousModelInstance);
|
||||
}
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
modelInstances: nextModelInstances
|
||||
});
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
let fallbackCounter = 0;
|
||||
export function createOpaqueId(prefix) {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
fallbackCounter += 1;
|
||||
return `${prefix}-${Date.now()}-${fallbackCounter}`;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
export function cloneEditorSelection(selection) {
|
||||
if (selection.kind === "none") {
|
||||
return {
|
||||
kind: "none"
|
||||
};
|
||||
}
|
||||
if (selection.kind === "brushFace") {
|
||||
return {
|
||||
kind: "brushFace",
|
||||
brushId: selection.brushId,
|
||||
faceId: selection.faceId
|
||||
};
|
||||
}
|
||||
if (selection.kind === "brushEdge") {
|
||||
return {
|
||||
kind: "brushEdge",
|
||||
brushId: selection.brushId,
|
||||
edgeId: selection.edgeId
|
||||
};
|
||||
}
|
||||
if (selection.kind === "brushVertex") {
|
||||
return {
|
||||
kind: "brushVertex",
|
||||
brushId: selection.brushId,
|
||||
vertexId: selection.vertexId
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: selection.kind,
|
||||
ids: [...selection.ids]
|
||||
};
|
||||
}
|
||||
export function areEditorSelectionsEqual(left, right) {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
switch (left.kind) {
|
||||
case "none":
|
||||
return true;
|
||||
case "brushFace":
|
||||
return right.kind === "brushFace" && left.brushId === right.brushId && left.faceId === right.faceId;
|
||||
case "brushEdge":
|
||||
return right.kind === "brushEdge" && left.brushId === right.brushId && left.edgeId === right.edgeId;
|
||||
case "brushVertex":
|
||||
return right.kind === "brushVertex" && left.brushId === right.brushId && left.vertexId === right.vertexId;
|
||||
case "brushes":
|
||||
case "entities":
|
||||
case "modelInstances":
|
||||
return right.kind === left.kind && left.ids.length === right.ids.length && left.ids.every((id, index) => id === right.ids[index]);
|
||||
}
|
||||
}
|
||||
export function getSingleSelectedBrushId(selection) {
|
||||
if (selection.kind === "brushFace" || selection.kind === "brushEdge" || selection.kind === "brushVertex") {
|
||||
return selection.brushId;
|
||||
}
|
||||
if (selection.kind !== "brushes" || selection.ids.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
return selection.ids[0];
|
||||
}
|
||||
export function getSelectedBrushFaceId(selection) {
|
||||
if (selection.kind !== "brushFace") {
|
||||
return null;
|
||||
}
|
||||
return selection.faceId;
|
||||
}
|
||||
export function getSelectedBrushEdgeId(selection) {
|
||||
if (selection.kind !== "brushEdge") {
|
||||
return null;
|
||||
}
|
||||
return selection.edgeId;
|
||||
}
|
||||
export function getSelectedBrushVertexId(selection) {
|
||||
if (selection.kind !== "brushVertex") {
|
||||
return null;
|
||||
}
|
||||
return selection.vertexId;
|
||||
}
|
||||
export function getSingleSelectedEntityId(selection) {
|
||||
if (selection.kind !== "entities" || selection.ids.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
return selection.ids[0];
|
||||
}
|
||||
export function getSingleSelectedModelInstanceId(selection) {
|
||||
if (selection.kind !== "modelInstances" || selection.ids.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
return selection.ids[0];
|
||||
}
|
||||
export function isBrushSelected(selection, brushId) {
|
||||
return ((selection.kind === "brushes" && selection.ids.includes(brushId)) ||
|
||||
((selection.kind === "brushFace" || selection.kind === "brushEdge" || selection.kind === "brushVertex") &&
|
||||
selection.brushId === brushId));
|
||||
}
|
||||
export function isBrushFaceSelected(selection, brushId, faceId) {
|
||||
return selection.kind === "brushFace" && selection.brushId === brushId && selection.faceId === faceId;
|
||||
}
|
||||
export function isBrushEdgeSelected(selection, brushId, edgeId) {
|
||||
return selection.kind === "brushEdge" && selection.brushId === brushId && selection.edgeId === edgeId;
|
||||
}
|
||||
export function isBrushVertexSelected(selection, brushId, vertexId) {
|
||||
return selection.kind === "brushVertex" && selection.brushId === brushId && selection.vertexId === vertexId;
|
||||
}
|
||||
export function isModelInstanceSelected(selection, modelInstanceId) {
|
||||
return selection.kind === "modelInstances" && selection.ids.includes(modelInstanceId);
|
||||
}
|
||||
export function normalizeSelectionForWhiteboxSelectionMode(selection, mode) {
|
||||
switch (selection.kind) {
|
||||
case "brushFace":
|
||||
return mode === "face"
|
||||
? selection
|
||||
: {
|
||||
kind: "brushes",
|
||||
ids: [selection.brushId]
|
||||
};
|
||||
case "brushEdge":
|
||||
return mode === "edge"
|
||||
? selection
|
||||
: {
|
||||
kind: "brushes",
|
||||
ids: [selection.brushId]
|
||||
};
|
||||
case "brushVertex":
|
||||
return mode === "vertex"
|
||||
? selection
|
||||
: {
|
||||
kind: "brushes",
|
||||
ids: [selection.brushId]
|
||||
};
|
||||
default:
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {};
|
||||
@@ -1,596 +0,0 @@
|
||||
import { createOpaqueId } from "./ids";
|
||||
import { BOX_VERTEX_IDS, BOX_EDGE_LABELS, BOX_FACE_LABELS, BOX_VERTEX_LABELS, cloneBoxBrushGeometry } from "../document/brushes";
|
||||
import { cloneEntityInstance, getEntityKindLabel } from "../entities/entity-instances";
|
||||
import { cloneModelInstance, getModelInstanceKindLabel } from "../assets/model-instances";
|
||||
function areBrushGeometriesEqual(left, right) {
|
||||
return BOX_VERTEX_IDS.every((vertexId) => {
|
||||
const leftVertex = left.vertices[vertexId];
|
||||
const rightVertex = right.vertices[vertexId];
|
||||
return areVec3Equal(leftVertex, rightVertex);
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
function cloneEntityTransformRotationState(rotation) {
|
||||
switch (rotation.kind) {
|
||||
case "none":
|
||||
return {
|
||||
kind: "none"
|
||||
};
|
||||
case "yaw":
|
||||
return {
|
||||
kind: "yaw",
|
||||
yawDegrees: rotation.yawDegrees
|
||||
};
|
||||
case "direction":
|
||||
return {
|
||||
kind: "direction",
|
||||
direction: cloneVec3(rotation.direction)
|
||||
};
|
||||
}
|
||||
}
|
||||
function areEntityTransformRotationsEqual(left, right) {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
switch (left.kind) {
|
||||
case "none":
|
||||
return true;
|
||||
case "yaw":
|
||||
return right.kind === "yaw" && left.yawDegrees === right.yawDegrees;
|
||||
case "direction":
|
||||
return right.kind === "direction" && areVec3Equal(left.direction, right.direction);
|
||||
}
|
||||
}
|
||||
export function createInactiveTransformSession() {
|
||||
return {
|
||||
kind: "none"
|
||||
};
|
||||
}
|
||||
export function cloneTransformTarget(target) {
|
||||
switch (target.kind) {
|
||||
case "brush":
|
||||
return {
|
||||
kind: "brush",
|
||||
brushId: target.brushId,
|
||||
initialCenter: cloneVec3(target.initialCenter),
|
||||
initialRotationDegrees: cloneVec3(target.initialRotationDegrees),
|
||||
initialSize: cloneVec3(target.initialSize),
|
||||
initialGeometry: cloneBoxBrushGeometry(target.initialGeometry)
|
||||
};
|
||||
case "brushFace":
|
||||
return {
|
||||
kind: "brushFace",
|
||||
brushId: target.brushId,
|
||||
faceId: target.faceId,
|
||||
initialCenter: cloneVec3(target.initialCenter),
|
||||
initialRotationDegrees: cloneVec3(target.initialRotationDegrees),
|
||||
initialSize: cloneVec3(target.initialSize),
|
||||
initialGeometry: cloneBoxBrushGeometry(target.initialGeometry)
|
||||
};
|
||||
case "brushEdge":
|
||||
return {
|
||||
kind: "brushEdge",
|
||||
brushId: target.brushId,
|
||||
edgeId: target.edgeId,
|
||||
initialCenter: cloneVec3(target.initialCenter),
|
||||
initialRotationDegrees: cloneVec3(target.initialRotationDegrees),
|
||||
initialSize: cloneVec3(target.initialSize),
|
||||
initialGeometry: cloneBoxBrushGeometry(target.initialGeometry)
|
||||
};
|
||||
case "brushVertex":
|
||||
return {
|
||||
kind: "brushVertex",
|
||||
brushId: target.brushId,
|
||||
vertexId: target.vertexId,
|
||||
initialCenter: cloneVec3(target.initialCenter),
|
||||
initialRotationDegrees: cloneVec3(target.initialRotationDegrees),
|
||||
initialSize: cloneVec3(target.initialSize),
|
||||
initialGeometry: cloneBoxBrushGeometry(target.initialGeometry)
|
||||
};
|
||||
case "modelInstance":
|
||||
return {
|
||||
kind: "modelInstance",
|
||||
modelInstanceId: target.modelInstanceId,
|
||||
assetId: target.assetId,
|
||||
initialPosition: cloneVec3(target.initialPosition),
|
||||
initialRotationDegrees: cloneVec3(target.initialRotationDegrees),
|
||||
initialScale: cloneVec3(target.initialScale)
|
||||
};
|
||||
case "entity":
|
||||
return {
|
||||
kind: "entity",
|
||||
entityId: target.entityId,
|
||||
entityKind: target.entityKind,
|
||||
initialPosition: cloneVec3(target.initialPosition),
|
||||
initialRotation: cloneEntityTransformRotationState(target.initialRotation)
|
||||
};
|
||||
}
|
||||
}
|
||||
export function cloneTransformPreview(preview) {
|
||||
switch (preview.kind) {
|
||||
case "brush":
|
||||
return {
|
||||
kind: "brush",
|
||||
center: cloneVec3(preview.center),
|
||||
rotationDegrees: cloneVec3(preview.rotationDegrees),
|
||||
size: cloneVec3(preview.size),
|
||||
geometry: cloneBoxBrushGeometry(preview.geometry)
|
||||
};
|
||||
case "modelInstance":
|
||||
return {
|
||||
kind: "modelInstance",
|
||||
position: cloneVec3(preview.position),
|
||||
rotationDegrees: cloneVec3(preview.rotationDegrees),
|
||||
scale: cloneVec3(preview.scale)
|
||||
};
|
||||
case "entity":
|
||||
return {
|
||||
kind: "entity",
|
||||
position: cloneVec3(preview.position),
|
||||
rotation: cloneEntityTransformRotationState(preview.rotation)
|
||||
};
|
||||
}
|
||||
}
|
||||
export function cloneTransformSession(session) {
|
||||
if (session.kind === "none") {
|
||||
return session;
|
||||
}
|
||||
return {
|
||||
kind: "active",
|
||||
id: session.id,
|
||||
source: session.source,
|
||||
sourcePanelId: session.sourcePanelId,
|
||||
operation: session.operation,
|
||||
axisConstraint: session.axisConstraint,
|
||||
target: cloneTransformTarget(session.target),
|
||||
preview: cloneTransformPreview(session.preview)
|
||||
};
|
||||
}
|
||||
export function areTransformSessionsEqual(left, right) {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "none" || right.kind === "none") {
|
||||
return true;
|
||||
}
|
||||
return (left.id === right.id &&
|
||||
left.source === right.source &&
|
||||
left.sourcePanelId === right.sourcePanelId &&
|
||||
left.operation === right.operation &&
|
||||
left.axisConstraint === right.axisConstraint &&
|
||||
areTransformTargetsEqual(left.target, right.target) &&
|
||||
areTransformPreviewsEqual(left.preview, right.preview));
|
||||
}
|
||||
function areTransformTargetsEqual(left, right) {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
switch (left.kind) {
|
||||
case "brush":
|
||||
return (right.kind === "brush" &&
|
||||
left.brushId === right.brushId &&
|
||||
areVec3Equal(left.initialCenter, right.initialCenter) &&
|
||||
areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) &&
|
||||
areVec3Equal(left.initialSize, right.initialSize) &&
|
||||
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry));
|
||||
case "brushFace":
|
||||
return (right.kind === "brushFace" &&
|
||||
left.brushId === right.brushId &&
|
||||
left.faceId === right.faceId &&
|
||||
areVec3Equal(left.initialCenter, right.initialCenter) &&
|
||||
areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) &&
|
||||
areVec3Equal(left.initialSize, right.initialSize) &&
|
||||
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry));
|
||||
case "brushEdge":
|
||||
return (right.kind === "brushEdge" &&
|
||||
left.brushId === right.brushId &&
|
||||
left.edgeId === right.edgeId &&
|
||||
areVec3Equal(left.initialCenter, right.initialCenter) &&
|
||||
areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) &&
|
||||
areVec3Equal(left.initialSize, right.initialSize) &&
|
||||
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry));
|
||||
case "brushVertex":
|
||||
return (right.kind === "brushVertex" &&
|
||||
left.brushId === right.brushId &&
|
||||
left.vertexId === right.vertexId &&
|
||||
areVec3Equal(left.initialCenter, right.initialCenter) &&
|
||||
areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) &&
|
||||
areVec3Equal(left.initialSize, right.initialSize) &&
|
||||
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry));
|
||||
case "modelInstance":
|
||||
return (right.kind === "modelInstance" &&
|
||||
left.modelInstanceId === right.modelInstanceId &&
|
||||
left.assetId === right.assetId &&
|
||||
areVec3Equal(left.initialPosition, right.initialPosition) &&
|
||||
areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) &&
|
||||
areVec3Equal(left.initialScale, right.initialScale));
|
||||
case "entity":
|
||||
return (right.kind === "entity" &&
|
||||
left.entityId === right.entityId &&
|
||||
left.entityKind === right.entityKind &&
|
||||
areVec3Equal(left.initialPosition, right.initialPosition) &&
|
||||
areEntityTransformRotationsEqual(left.initialRotation, right.initialRotation));
|
||||
}
|
||||
}
|
||||
function areTransformPreviewsEqual(left, right) {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
switch (left.kind) {
|
||||
case "brush":
|
||||
return (right.kind === "brush" &&
|
||||
areVec3Equal(left.center, right.center) &&
|
||||
areVec3Equal(left.rotationDegrees, right.rotationDegrees) &&
|
||||
areVec3Equal(left.size, right.size) &&
|
||||
areBrushGeometriesEqual(left.geometry, right.geometry));
|
||||
case "modelInstance":
|
||||
return (right.kind === "modelInstance" &&
|
||||
areVec3Equal(left.position, right.position) &&
|
||||
areVec3Equal(left.rotationDegrees, right.rotationDegrees) &&
|
||||
areVec3Equal(left.scale, right.scale));
|
||||
case "entity":
|
||||
return right.kind === "entity" && areVec3Equal(left.position, right.position) && areEntityTransformRotationsEqual(left.rotation, right.rotation);
|
||||
}
|
||||
}
|
||||
export function createTransformSession(options) {
|
||||
return {
|
||||
kind: "active",
|
||||
id: createOpaqueId("transform-session"),
|
||||
source: options.source,
|
||||
sourcePanelId: options.sourcePanelId,
|
||||
operation: options.operation,
|
||||
axisConstraint: options.axisConstraint ?? null,
|
||||
target: cloneTransformTarget(options.target),
|
||||
preview: createTransformPreviewFromTarget(options.target)
|
||||
};
|
||||
}
|
||||
export function createTransformPreviewFromTarget(target) {
|
||||
switch (target.kind) {
|
||||
case "brush":
|
||||
case "brushFace":
|
||||
case "brushEdge":
|
||||
case "brushVertex":
|
||||
return {
|
||||
kind: "brush",
|
||||
center: cloneVec3(target.initialCenter),
|
||||
rotationDegrees: cloneVec3(target.initialRotationDegrees),
|
||||
size: cloneVec3(target.initialSize),
|
||||
geometry: cloneBoxBrushGeometry(target.initialGeometry)
|
||||
};
|
||||
case "modelInstance":
|
||||
return {
|
||||
kind: "modelInstance",
|
||||
position: cloneVec3(target.initialPosition),
|
||||
rotationDegrees: cloneVec3(target.initialRotationDegrees),
|
||||
scale: cloneVec3(target.initialScale)
|
||||
};
|
||||
case "entity":
|
||||
return {
|
||||
kind: "entity",
|
||||
position: cloneVec3(target.initialPosition),
|
||||
rotation: cloneEntityTransformRotationState(target.initialRotation)
|
||||
};
|
||||
}
|
||||
}
|
||||
export function doesTransformSessionChangeTarget(session) {
|
||||
switch (session.target.kind) {
|
||||
case "brush":
|
||||
case "brushFace":
|
||||
case "brushEdge":
|
||||
case "brushVertex":
|
||||
return (session.preview.kind === "brush" &&
|
||||
(!areVec3Equal(session.preview.center, session.target.initialCenter) ||
|
||||
!areVec3Equal(session.preview.rotationDegrees, session.target.initialRotationDegrees) ||
|
||||
!areVec3Equal(session.preview.size, session.target.initialSize) ||
|
||||
!areBrushGeometriesEqual(session.preview.geometry, session.target.initialGeometry)));
|
||||
case "modelInstance":
|
||||
return (session.preview.kind === "modelInstance" &&
|
||||
(!areVec3Equal(session.preview.position, session.target.initialPosition) ||
|
||||
!areVec3Equal(session.preview.rotationDegrees, session.target.initialRotationDegrees) ||
|
||||
!areVec3Equal(session.preview.scale, session.target.initialScale)));
|
||||
case "entity":
|
||||
return (session.preview.kind === "entity" &&
|
||||
(!areVec3Equal(session.preview.position, session.target.initialPosition) ||
|
||||
!areEntityTransformRotationsEqual(session.preview.rotation, session.target.initialRotation)));
|
||||
}
|
||||
}
|
||||
export function getTransformOperationLabel(operation) {
|
||||
switch (operation) {
|
||||
case "translate":
|
||||
return "Move";
|
||||
case "rotate":
|
||||
return "Rotate";
|
||||
case "scale":
|
||||
return "Scale";
|
||||
}
|
||||
}
|
||||
export function getTransformAxisLabel(axis) {
|
||||
return axis.toUpperCase();
|
||||
}
|
||||
export function getTransformTargetLabel(target) {
|
||||
switch (target.kind) {
|
||||
case "brush":
|
||||
return "Whitebox Box";
|
||||
case "brushFace":
|
||||
return `Whitebox Face (${BOX_FACE_LABELS[target.faceId]})`;
|
||||
case "brushEdge":
|
||||
return `Whitebox Edge (${BOX_EDGE_LABELS[target.edgeId]})`;
|
||||
case "brushVertex":
|
||||
return `Whitebox Vertex (${BOX_VERTEX_LABELS[target.vertexId]})`;
|
||||
case "modelInstance":
|
||||
return getModelInstanceKindLabel();
|
||||
case "entity":
|
||||
return getEntityKindLabel(target.entityKind);
|
||||
}
|
||||
}
|
||||
export function getSupportedTransformOperations(target) {
|
||||
switch (target.kind) {
|
||||
case "brush":
|
||||
case "brushFace":
|
||||
case "brushEdge":
|
||||
return ["translate", "rotate", "scale"];
|
||||
case "brushVertex":
|
||||
return ["translate"];
|
||||
case "modelInstance":
|
||||
return ["translate", "rotate", "scale"];
|
||||
case "entity":
|
||||
return target.initialRotation.kind === "none" ? ["translate"] : ["translate", "rotate"];
|
||||
}
|
||||
}
|
||||
export function supportsTransformOperation(target, operation) {
|
||||
return getSupportedTransformOperations(target).includes(operation);
|
||||
}
|
||||
export function supportsTransformAxisConstraint(session, axis) {
|
||||
switch (session.operation) {
|
||||
case "translate":
|
||||
return true;
|
||||
case "scale":
|
||||
if (session.target.kind === "modelInstance" || session.target.kind === "brush" || session.target.kind === "brushVertex") {
|
||||
return session.target.kind !== "brushVertex";
|
||||
}
|
||||
if (session.target.kind === "brushFace") {
|
||||
const normalAxis = session.target.faceId === "posX" || session.target.faceId === "negX" ? "x" : session.target.faceId === "posY" || session.target.faceId === "negY" ? "y" : "z";
|
||||
return axis === normalAxis;
|
||||
}
|
||||
if (session.target.kind === "brushEdge") {
|
||||
if (session.target.edgeId.startsWith("edgeX_")) {
|
||||
return axis !== "x";
|
||||
}
|
||||
if (session.target.edgeId.startsWith("edgeY_")) {
|
||||
return axis !== "y";
|
||||
}
|
||||
return axis !== "z";
|
||||
}
|
||||
return false;
|
||||
case "rotate":
|
||||
if (session.target.kind === "entity" && session.target.initialRotation.kind === "yaw") {
|
||||
return axis === "y";
|
||||
}
|
||||
if (session.target.kind === "brushFace") {
|
||||
const normalAxis = session.target.faceId === "posX" || session.target.faceId === "negX" ? "x" : session.target.faceId === "posY" || session.target.faceId === "negY" ? "y" : "z";
|
||||
return axis === normalAxis;
|
||||
}
|
||||
if (session.target.kind === "brushEdge") {
|
||||
if (session.target.edgeId.startsWith("edgeX_")) {
|
||||
return axis === "x";
|
||||
}
|
||||
if (session.target.edgeId.startsWith("edgeY_")) {
|
||||
return axis === "y";
|
||||
}
|
||||
return axis === "z";
|
||||
}
|
||||
if (session.target.kind === "brushVertex") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
function resolveEntityRotation(entity) {
|
||||
switch (entity.kind) {
|
||||
case "playerStart":
|
||||
case "teleportTarget":
|
||||
return {
|
||||
kind: "yaw",
|
||||
yawDegrees: entity.yawDegrees
|
||||
};
|
||||
case "spotLight":
|
||||
return {
|
||||
kind: "direction",
|
||||
direction: cloneVec3(entity.direction)
|
||||
};
|
||||
case "pointLight":
|
||||
case "soundEmitter":
|
||||
case "triggerVolume":
|
||||
case "interactable":
|
||||
return {
|
||||
kind: "none"
|
||||
};
|
||||
}
|
||||
}
|
||||
function createBrushTransformTarget(document, brushId) {
|
||||
const brush = document.brushes[brushId];
|
||||
if (brush === undefined || brush.kind !== "box") {
|
||||
return {
|
||||
target: null,
|
||||
message: "Select a supported whitebox box before transforming it."
|
||||
};
|
||||
}
|
||||
return {
|
||||
target: {
|
||||
kind: "brush",
|
||||
brushId: brush.id,
|
||||
initialCenter: cloneVec3(brush.center),
|
||||
initialRotationDegrees: cloneVec3(brush.rotationDegrees),
|
||||
initialSize: cloneVec3(brush.size),
|
||||
initialGeometry: cloneBoxBrushGeometry(brush.geometry)
|
||||
},
|
||||
message: null
|
||||
};
|
||||
}
|
||||
function createBrushFaceTransformTarget(document, brushId, faceId) {
|
||||
const brushResolution = createBrushTransformTarget(document, brushId);
|
||||
if (brushResolution.target === null || brushResolution.target.kind !== "brush") {
|
||||
return brushResolution;
|
||||
}
|
||||
return {
|
||||
target: {
|
||||
kind: "brushFace",
|
||||
brushId,
|
||||
faceId,
|
||||
initialCenter: cloneVec3(brushResolution.target.initialCenter),
|
||||
initialRotationDegrees: cloneVec3(brushResolution.target.initialRotationDegrees),
|
||||
initialSize: cloneVec3(brushResolution.target.initialSize),
|
||||
initialGeometry: cloneBoxBrushGeometry(brushResolution.target.initialGeometry)
|
||||
},
|
||||
message: null
|
||||
};
|
||||
}
|
||||
function createBrushEdgeTransformTarget(document, brushId, edgeId) {
|
||||
const brushResolution = createBrushTransformTarget(document, brushId);
|
||||
if (brushResolution.target === null || brushResolution.target.kind !== "brush") {
|
||||
return brushResolution;
|
||||
}
|
||||
return {
|
||||
target: {
|
||||
kind: "brushEdge",
|
||||
brushId,
|
||||
edgeId,
|
||||
initialCenter: cloneVec3(brushResolution.target.initialCenter),
|
||||
initialRotationDegrees: cloneVec3(brushResolution.target.initialRotationDegrees),
|
||||
initialSize: cloneVec3(brushResolution.target.initialSize),
|
||||
initialGeometry: cloneBoxBrushGeometry(brushResolution.target.initialGeometry)
|
||||
},
|
||||
message: null
|
||||
};
|
||||
}
|
||||
function createBrushVertexTransformTarget(document, brushId, vertexId) {
|
||||
const brushResolution = createBrushTransformTarget(document, brushId);
|
||||
if (brushResolution.target === null || brushResolution.target.kind !== "brush") {
|
||||
return brushResolution;
|
||||
}
|
||||
return {
|
||||
target: {
|
||||
kind: "brushVertex",
|
||||
brushId,
|
||||
vertexId,
|
||||
initialCenter: cloneVec3(brushResolution.target.initialCenter),
|
||||
initialRotationDegrees: cloneVec3(brushResolution.target.initialRotationDegrees),
|
||||
initialSize: cloneVec3(brushResolution.target.initialSize),
|
||||
initialGeometry: cloneBoxBrushGeometry(brushResolution.target.initialGeometry)
|
||||
},
|
||||
message: null
|
||||
};
|
||||
}
|
||||
function createEntityTransformTarget(document, entityId) {
|
||||
const entity = document.entities[entityId];
|
||||
if (entity === undefined) {
|
||||
return {
|
||||
target: null,
|
||||
message: "Select an authored entity before transforming it."
|
||||
};
|
||||
}
|
||||
const clonedEntity = cloneEntityInstance(entity);
|
||||
return {
|
||||
target: {
|
||||
kind: "entity",
|
||||
entityId: clonedEntity.id,
|
||||
entityKind: clonedEntity.kind,
|
||||
initialPosition: cloneVec3(clonedEntity.position),
|
||||
initialRotation: resolveEntityRotation(clonedEntity)
|
||||
},
|
||||
message: null
|
||||
};
|
||||
}
|
||||
function createModelInstanceTransformTarget(document, modelInstanceId) {
|
||||
const modelInstance = document.modelInstances[modelInstanceId];
|
||||
if (modelInstance === undefined) {
|
||||
return {
|
||||
target: null,
|
||||
message: "Select a model instance before transforming it."
|
||||
};
|
||||
}
|
||||
const clonedModelInstance = cloneModelInstance(modelInstance);
|
||||
return {
|
||||
target: {
|
||||
kind: "modelInstance",
|
||||
modelInstanceId: clonedModelInstance.id,
|
||||
assetId: clonedModelInstance.assetId,
|
||||
initialPosition: cloneVec3(clonedModelInstance.position),
|
||||
initialRotationDegrees: cloneVec3(clonedModelInstance.rotationDegrees),
|
||||
initialScale: cloneVec3(clonedModelInstance.scale)
|
||||
},
|
||||
message: null
|
||||
};
|
||||
}
|
||||
export function resolveTransformTarget(document, selection, whiteboxSelectionMode = "object") {
|
||||
switch (selection.kind) {
|
||||
case "none":
|
||||
return {
|
||||
target: null,
|
||||
message: "Select a single brush, entity, or model instance before transforming it."
|
||||
};
|
||||
case "brushFace":
|
||||
if (whiteboxSelectionMode !== "face") {
|
||||
return {
|
||||
target: null,
|
||||
message: "Switch to Face mode to transform a selected whitebox face."
|
||||
};
|
||||
}
|
||||
return createBrushFaceTransformTarget(document, selection.brushId, selection.faceId);
|
||||
case "brushEdge":
|
||||
if (whiteboxSelectionMode !== "edge") {
|
||||
return {
|
||||
target: null,
|
||||
message: "Switch to Edge mode to transform a selected whitebox edge."
|
||||
};
|
||||
}
|
||||
return createBrushEdgeTransformTarget(document, selection.brushId, selection.edgeId);
|
||||
case "brushVertex":
|
||||
if (whiteboxSelectionMode !== "vertex") {
|
||||
return {
|
||||
target: null,
|
||||
message: "Switch to Vertex mode to transform a selected whitebox vertex."
|
||||
};
|
||||
}
|
||||
return createBrushVertexTransformTarget(document, selection.brushId, selection.vertexId);
|
||||
case "brushes":
|
||||
if (whiteboxSelectionMode !== "object") {
|
||||
return {
|
||||
target: null,
|
||||
message: "Switch to Object mode to transform the whole whitebox box."
|
||||
};
|
||||
}
|
||||
if (selection.ids.length !== 1) {
|
||||
return {
|
||||
target: null,
|
||||
message: "Select a single brush before transforming it."
|
||||
};
|
||||
}
|
||||
return createBrushTransformTarget(document, selection.ids[0]);
|
||||
case "entities":
|
||||
if (selection.ids.length !== 1) {
|
||||
return {
|
||||
target: null,
|
||||
message: "Select a single entity before transforming it."
|
||||
};
|
||||
}
|
||||
return createEntityTransformTarget(document, selection.ids[0]);
|
||||
case "modelInstances":
|
||||
if (selection.ids.length !== 1) {
|
||||
return {
|
||||
target: null,
|
||||
message: "Select a single model instance before transforming it."
|
||||
};
|
||||
}
|
||||
return createModelInstanceTransformTarget(document, selection.ids[0]);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const DEFAULT_SUN_DIRECTION = {
|
||||
x: -0.6,
|
||||
y: 1,
|
||||
z: 0.35
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { BOX_EDGE_LABELS, BOX_FACE_LABELS, BOX_VERTEX_LABELS } from "../document/brushes";
|
||||
function getBrushDisplayLabel(document, brushId) {
|
||||
const brushes = Object.values(document.brushes);
|
||||
const brushIndex = brushes.findIndex((brush) => brush.id === brushId);
|
||||
if (brushIndex === -1) {
|
||||
return "Whitebox Box";
|
||||
}
|
||||
return brushes[brushIndex].name ?? `Whitebox Box ${brushIndex + 1}`;
|
||||
}
|
||||
export function getWhiteboxSelectionFeedbackLabel(document, selection) {
|
||||
switch (selection.kind) {
|
||||
case "brushes":
|
||||
return selection.ids.length === 1 ? `Solid · ${getBrushDisplayLabel(document, selection.ids[0])}` : null;
|
||||
case "brushFace":
|
||||
return `Face · ${BOX_FACE_LABELS[selection.faceId]} · ${getBrushDisplayLabel(document, selection.brushId)}`;
|
||||
case "brushEdge":
|
||||
return `Edge · ${BOX_EDGE_LABELS[selection.edgeId]} · ${getBrushDisplayLabel(document, selection.brushId)}`;
|
||||
case "brushVertex":
|
||||
return `Vertex · ${BOX_VERTEX_LABELS[selection.vertexId]} · ${getBrushDisplayLabel(document, selection.brushId)}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export const WHITEBOX_SELECTION_MODES = ["object", "face", "edge", "vertex"];
|
||||
export const WHITEBOX_SELECTION_MODE_LABELS = {
|
||||
object: "Object",
|
||||
face: "Face",
|
||||
edge: "Edge",
|
||||
vertex: "Vertex"
|
||||
};
|
||||
export function getWhiteboxSelectionModeLabel(mode) {
|
||||
return WHITEBOX_SELECTION_MODE_LABELS[mode];
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
export const BOX_FACE_IDS = ["posX", "negX", "posY", "negY", "posZ", "negZ"];
|
||||
export const BOX_EDGE_IDS = [
|
||||
"edgeX_negY_negZ",
|
||||
"edgeX_posY_negZ",
|
||||
"edgeX_negY_posZ",
|
||||
"edgeX_posY_posZ",
|
||||
"edgeY_negX_negZ",
|
||||
"edgeY_posX_negZ",
|
||||
"edgeY_negX_posZ",
|
||||
"edgeY_posX_posZ",
|
||||
"edgeZ_negX_negY",
|
||||
"edgeZ_posX_negY",
|
||||
"edgeZ_negX_posY",
|
||||
"edgeZ_posX_posY"
|
||||
];
|
||||
export const BOX_VERTEX_IDS = [
|
||||
"negX_negY_negZ",
|
||||
"posX_negY_negZ",
|
||||
"negX_posY_negZ",
|
||||
"posX_posY_negZ",
|
||||
"negX_negY_posZ",
|
||||
"posX_negY_posZ",
|
||||
"negX_posY_posZ",
|
||||
"posX_posY_posZ"
|
||||
];
|
||||
export const FACE_UV_ROTATION_QUARTER_TURNS = [0, 1, 2, 3];
|
||||
export const BOX_BRUSH_VOLUME_MODES = ["none", "water", "fog"];
|
||||
export const BOX_FACE_LABELS = {
|
||||
posX: "Right",
|
||||
negX: "Left",
|
||||
posY: "Top",
|
||||
negY: "Bottom",
|
||||
posZ: "Front",
|
||||
negZ: "Back"
|
||||
};
|
||||
export const BOX_EDGE_LABELS = {
|
||||
edgeX_negY_negZ: "X Edge (-Y, -Z)",
|
||||
edgeX_posY_negZ: "X Edge (+Y, -Z)",
|
||||
edgeX_negY_posZ: "X Edge (-Y, +Z)",
|
||||
edgeX_posY_posZ: "X Edge (+Y, +Z)",
|
||||
edgeY_negX_negZ: "Y Edge (-X, -Z)",
|
||||
edgeY_posX_negZ: "Y Edge (+X, -Z)",
|
||||
edgeY_negX_posZ: "Y Edge (-X, +Z)",
|
||||
edgeY_posX_posZ: "Y Edge (+X, +Z)",
|
||||
edgeZ_negX_negY: "Z Edge (-X, -Y)",
|
||||
edgeZ_posX_negY: "Z Edge (+X, -Y)",
|
||||
edgeZ_negX_posY: "Z Edge (-X, +Y)",
|
||||
edgeZ_posX_posY: "Z Edge (+X, +Y)"
|
||||
};
|
||||
export const BOX_VERTEX_LABELS = {
|
||||
negX_negY_negZ: "Vertex (-X, -Y, -Z)",
|
||||
posX_negY_negZ: "Vertex (+X, -Y, -Z)",
|
||||
negX_posY_negZ: "Vertex (-X, +Y, -Z)",
|
||||
posX_posY_negZ: "Vertex (+X, +Y, -Z)",
|
||||
negX_negY_posZ: "Vertex (-X, -Y, +Z)",
|
||||
posX_negY_posZ: "Vertex (+X, -Y, +Z)",
|
||||
negX_posY_posZ: "Vertex (-X, +Y, +Z)",
|
||||
posX_posY_posZ: "Vertex (+X, +Y, +Z)"
|
||||
};
|
||||
export const DEFAULT_BOX_BRUSH_CENTER = {
|
||||
x: 0,
|
||||
y: 1,
|
||||
z: 0
|
||||
};
|
||||
export const DEFAULT_BOX_BRUSH_SIZE = {
|
||||
x: 2,
|
||||
y: 2,
|
||||
z: 2
|
||||
};
|
||||
export const DEFAULT_BOX_BRUSH_ROTATION_DEGREES = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
export const DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT = 6;
|
||||
export const MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT = 24;
|
||||
const DEFAULT_BOX_BRUSH_WATER_SETTINGS = {
|
||||
colorHex: "#4da6d9",
|
||||
surfaceOpacity: 0.55,
|
||||
waveStrength: 0.35,
|
||||
foamContactLimit: DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT,
|
||||
surfaceDisplacementEnabled: false
|
||||
};
|
||||
const DEFAULT_BOX_BRUSH_FOG_SETTINGS = {
|
||||
colorHex: "#9cb7c7",
|
||||
density: 0.08,
|
||||
padding: 0.2
|
||||
};
|
||||
export function normalizeBrushName(name) {
|
||||
if (name === undefined || name === null) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
return trimmedName.length === 0 ? undefined : trimmedName;
|
||||
}
|
||||
function cloneVec3(vector) {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
function cloneBrushFace(face) {
|
||||
return {
|
||||
materialId: face.materialId,
|
||||
uv: cloneFaceUvState(face.uv)
|
||||
};
|
||||
}
|
||||
function cloneBoxBrushGeometryVertex(vertex) {
|
||||
return {
|
||||
x: vertex.x,
|
||||
y: vertex.y,
|
||||
z: vertex.z
|
||||
};
|
||||
}
|
||||
export function cloneBoxBrushGeometry(geometry) {
|
||||
return {
|
||||
vertices: {
|
||||
negX_negY_negZ: cloneBoxBrushGeometryVertex(geometry.vertices.negX_negY_negZ),
|
||||
posX_negY_negZ: cloneBoxBrushGeometryVertex(geometry.vertices.posX_negY_negZ),
|
||||
negX_posY_negZ: cloneBoxBrushGeometryVertex(geometry.vertices.negX_posY_negZ),
|
||||
posX_posY_negZ: cloneBoxBrushGeometryVertex(geometry.vertices.posX_posY_negZ),
|
||||
negX_negY_posZ: cloneBoxBrushGeometryVertex(geometry.vertices.negX_negY_posZ),
|
||||
posX_negY_posZ: cloneBoxBrushGeometryVertex(geometry.vertices.posX_negY_posZ),
|
||||
negX_posY_posZ: cloneBoxBrushGeometryVertex(geometry.vertices.negX_posY_posZ),
|
||||
posX_posY_posZ: cloneBoxBrushGeometryVertex(geometry.vertices.posX_posY_posZ)
|
||||
}
|
||||
};
|
||||
}
|
||||
export function getBoxBrushGeometryLocalBounds(geometry) {
|
||||
const vertices = Object.values(geometry.vertices);
|
||||
const firstVertex = vertices[0];
|
||||
const min = { ...firstVertex };
|
||||
const max = { ...firstVertex };
|
||||
for (const vertex of vertices.slice(1)) {
|
||||
min.x = Math.min(min.x, vertex.x);
|
||||
min.y = Math.min(min.y, vertex.y);
|
||||
min.z = Math.min(min.z, vertex.z);
|
||||
max.x = Math.max(max.x, vertex.x);
|
||||
max.y = Math.max(max.y, vertex.y);
|
||||
max.z = Math.max(max.z, vertex.z);
|
||||
}
|
||||
return {
|
||||
min,
|
||||
max
|
||||
};
|
||||
}
|
||||
export function deriveBoxBrushSizeFromGeometry(geometry) {
|
||||
const bounds = getBoxBrushGeometryLocalBounds(geometry);
|
||||
return {
|
||||
x: bounds.max.x - bounds.min.x,
|
||||
y: bounds.max.y - bounds.min.y,
|
||||
z: bounds.max.z - bounds.min.z
|
||||
};
|
||||
}
|
||||
export function scaleBoxBrushGeometryToSize(geometry, size) {
|
||||
const bounds = getBoxBrushGeometryLocalBounds(geometry);
|
||||
const currentSize = deriveBoxBrushSizeFromGeometry(geometry);
|
||||
if (!hasPositiveBoxSize(currentSize) || !hasPositiveBoxSize(size)) {
|
||||
throw new Error("Box brush geometry size must remain positive on every axis.");
|
||||
}
|
||||
const center = {
|
||||
x: (bounds.min.x + bounds.max.x) * 0.5,
|
||||
y: (bounds.min.y + bounds.max.y) * 0.5,
|
||||
z: (bounds.min.z + bounds.max.z) * 0.5
|
||||
};
|
||||
const scale = {
|
||||
x: size.x / currentSize.x,
|
||||
y: size.y / currentSize.y,
|
||||
z: size.z / currentSize.z
|
||||
};
|
||||
return {
|
||||
vertices: {
|
||||
negX_negY_negZ: {
|
||||
x: center.x + (geometry.vertices.negX_negY_negZ.x - center.x) * scale.x,
|
||||
y: center.y + (geometry.vertices.negX_negY_negZ.y - center.y) * scale.y,
|
||||
z: center.z + (geometry.vertices.negX_negY_negZ.z - center.z) * scale.z
|
||||
},
|
||||
posX_negY_negZ: {
|
||||
x: center.x + (geometry.vertices.posX_negY_negZ.x - center.x) * scale.x,
|
||||
y: center.y + (geometry.vertices.posX_negY_negZ.y - center.y) * scale.y,
|
||||
z: center.z + (geometry.vertices.posX_negY_negZ.z - center.z) * scale.z
|
||||
},
|
||||
negX_posY_negZ: {
|
||||
x: center.x + (geometry.vertices.negX_posY_negZ.x - center.x) * scale.x,
|
||||
y: center.y + (geometry.vertices.negX_posY_negZ.y - center.y) * scale.y,
|
||||
z: center.z + (geometry.vertices.negX_posY_negZ.z - center.z) * scale.z
|
||||
},
|
||||
posX_posY_negZ: {
|
||||
x: center.x + (geometry.vertices.posX_posY_negZ.x - center.x) * scale.x,
|
||||
y: center.y + (geometry.vertices.posX_posY_negZ.y - center.y) * scale.y,
|
||||
z: center.z + (geometry.vertices.posX_posY_negZ.z - center.z) * scale.z
|
||||
},
|
||||
negX_negY_posZ: {
|
||||
x: center.x + (geometry.vertices.negX_negY_posZ.x - center.x) * scale.x,
|
||||
y: center.y + (geometry.vertices.negX_negY_posZ.y - center.y) * scale.y,
|
||||
z: center.z + (geometry.vertices.negX_negY_posZ.z - center.z) * scale.z
|
||||
},
|
||||
posX_negY_posZ: {
|
||||
x: center.x + (geometry.vertices.posX_negY_posZ.x - center.x) * scale.x,
|
||||
y: center.y + (geometry.vertices.posX_negY_posZ.y - center.y) * scale.y,
|
||||
z: center.z + (geometry.vertices.posX_negY_posZ.z - center.z) * scale.z
|
||||
},
|
||||
negX_posY_posZ: {
|
||||
x: center.x + (geometry.vertices.negX_posY_posZ.x - center.x) * scale.x,
|
||||
y: center.y + (geometry.vertices.negX_posY_posZ.y - center.y) * scale.y,
|
||||
z: center.z + (geometry.vertices.negX_posY_posZ.z - center.z) * scale.z
|
||||
},
|
||||
posX_posY_posZ: {
|
||||
x: center.x + (geometry.vertices.posX_posY_posZ.x - center.x) * scale.x,
|
||||
y: center.y + (geometry.vertices.posX_posY_posZ.y - center.y) * scale.y,
|
||||
z: center.z + (geometry.vertices.posX_posY_posZ.z - center.z) * scale.z
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
export function createDefaultBoxBrushGeometry(size = DEFAULT_BOX_BRUSH_SIZE) {
|
||||
const halfSize = {
|
||||
x: size.x * 0.5,
|
||||
y: size.y * 0.5,
|
||||
z: size.z * 0.5
|
||||
};
|
||||
return {
|
||||
vertices: {
|
||||
negX_negY_negZ: { x: -halfSize.x, y: -halfSize.y, z: -halfSize.z },
|
||||
posX_negY_negZ: { x: halfSize.x, y: -halfSize.y, z: -halfSize.z },
|
||||
negX_posY_negZ: { x: -halfSize.x, y: halfSize.y, z: -halfSize.z },
|
||||
posX_posY_negZ: { x: halfSize.x, y: halfSize.y, z: -halfSize.z },
|
||||
negX_negY_posZ: { x: -halfSize.x, y: -halfSize.y, z: halfSize.z },
|
||||
posX_negY_posZ: { x: halfSize.x, y: -halfSize.y, z: halfSize.z },
|
||||
negX_posY_posZ: { x: -halfSize.x, y: halfSize.y, z: halfSize.z },
|
||||
posX_posY_posZ: { x: halfSize.x, y: halfSize.y, z: halfSize.z }
|
||||
}
|
||||
};
|
||||
}
|
||||
export function isBoxFaceId(value) {
|
||||
return typeof value === "string" && BOX_FACE_IDS.some((faceId) => faceId === value);
|
||||
}
|
||||
export function isBoxEdgeId(value) {
|
||||
return typeof value === "string" && BOX_EDGE_IDS.some((edgeId) => edgeId === value);
|
||||
}
|
||||
export function isBoxVertexId(value) {
|
||||
return typeof value === "string" && BOX_VERTEX_IDS.some((vertexId) => vertexId === value);
|
||||
}
|
||||
export function isFaceUvRotationQuarterTurns(value) {
|
||||
return typeof value === "number" && FACE_UV_ROTATION_QUARTER_TURNS.includes(value);
|
||||
}
|
||||
export function isBoxBrushVolumeMode(value) {
|
||||
return typeof value === "string" && BOX_BRUSH_VOLUME_MODES.includes(value);
|
||||
}
|
||||
export function hasPositiveBoxSize(size) {
|
||||
return size.x > 0 && size.y > 0 && size.z > 0;
|
||||
}
|
||||
export function createDefaultFaceUvState() {
|
||||
return {
|
||||
offset: {
|
||||
x: 0,
|
||||
y: 0
|
||||
},
|
||||
scale: {
|
||||
x: 1,
|
||||
y: 1
|
||||
},
|
||||
rotationQuarterTurns: 0,
|
||||
flipU: false,
|
||||
flipV: false
|
||||
};
|
||||
}
|
||||
export function cloneFaceUvState(uv) {
|
||||
return {
|
||||
offset: {
|
||||
...uv.offset
|
||||
},
|
||||
scale: {
|
||||
...uv.scale
|
||||
},
|
||||
rotationQuarterTurns: uv.rotationQuarterTurns,
|
||||
flipU: uv.flipU,
|
||||
flipV: uv.flipV
|
||||
};
|
||||
}
|
||||
export function cloneBoxBrushFaces(faces) {
|
||||
return {
|
||||
posX: cloneBrushFace(faces.posX),
|
||||
negX: cloneBrushFace(faces.negX),
|
||||
posY: cloneBrushFace(faces.posY),
|
||||
negY: cloneBrushFace(faces.negY),
|
||||
posZ: cloneBrushFace(faces.posZ),
|
||||
negZ: cloneBrushFace(faces.negZ)
|
||||
};
|
||||
}
|
||||
export function createDefaultBoxBrushFaces() {
|
||||
return {
|
||||
posX: {
|
||||
materialId: null,
|
||||
uv: createDefaultFaceUvState()
|
||||
},
|
||||
negX: {
|
||||
materialId: null,
|
||||
uv: createDefaultFaceUvState()
|
||||
},
|
||||
posY: {
|
||||
materialId: null,
|
||||
uv: createDefaultFaceUvState()
|
||||
},
|
||||
negY: {
|
||||
materialId: null,
|
||||
uv: createDefaultFaceUvState()
|
||||
},
|
||||
posZ: {
|
||||
materialId: null,
|
||||
uv: createDefaultFaceUvState()
|
||||
},
|
||||
negZ: {
|
||||
materialId: null,
|
||||
uv: createDefaultFaceUvState()
|
||||
}
|
||||
};
|
||||
}
|
||||
export function createDefaultBoxBrushWaterSettings() {
|
||||
return {
|
||||
colorHex: DEFAULT_BOX_BRUSH_WATER_SETTINGS.colorHex,
|
||||
surfaceOpacity: DEFAULT_BOX_BRUSH_WATER_SETTINGS.surfaceOpacity,
|
||||
waveStrength: DEFAULT_BOX_BRUSH_WATER_SETTINGS.waveStrength,
|
||||
foamContactLimit: DEFAULT_BOX_BRUSH_WATER_SETTINGS.foamContactLimit,
|
||||
surfaceDisplacementEnabled: DEFAULT_BOX_BRUSH_WATER_SETTINGS.surfaceDisplacementEnabled
|
||||
};
|
||||
}
|
||||
export function createDefaultBoxBrushFogSettings() {
|
||||
return {
|
||||
colorHex: DEFAULT_BOX_BRUSH_FOG_SETTINGS.colorHex,
|
||||
density: DEFAULT_BOX_BRUSH_FOG_SETTINGS.density,
|
||||
padding: DEFAULT_BOX_BRUSH_FOG_SETTINGS.padding
|
||||
};
|
||||
}
|
||||
export function createDefaultBoxBrushVolumeSettings() {
|
||||
return {
|
||||
mode: "none"
|
||||
};
|
||||
}
|
||||
export function cloneBoxBrushVolumeSettings(volume) {
|
||||
switch (volume.mode) {
|
||||
case "none":
|
||||
return {
|
||||
mode: "none"
|
||||
};
|
||||
case "water":
|
||||
return {
|
||||
mode: "water",
|
||||
water: {
|
||||
colorHex: volume.water.colorHex,
|
||||
surfaceOpacity: volume.water.surfaceOpacity,
|
||||
waveStrength: volume.water.waveStrength,
|
||||
foamContactLimit: volume.water.foamContactLimit,
|
||||
surfaceDisplacementEnabled: volume.water.surfaceDisplacementEnabled
|
||||
}
|
||||
};
|
||||
case "fog":
|
||||
return {
|
||||
mode: "fog",
|
||||
fog: {
|
||||
colorHex: volume.fog.colorHex,
|
||||
density: volume.fog.density,
|
||||
padding: volume.fog.padding
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
export function createBoxBrush(overrides = {}) {
|
||||
const center = cloneVec3(overrides.center ?? DEFAULT_BOX_BRUSH_CENTER);
|
||||
const rotationDegrees = cloneVec3(overrides.rotationDegrees ?? DEFAULT_BOX_BRUSH_ROTATION_DEGREES);
|
||||
const fallbackSize = cloneVec3(overrides.size ?? DEFAULT_BOX_BRUSH_SIZE);
|
||||
const geometry = overrides.geometry === undefined ? createDefaultBoxBrushGeometry(fallbackSize) : cloneBoxBrushGeometry(overrides.geometry);
|
||||
const size = deriveBoxBrushSizeFromGeometry(geometry);
|
||||
if (!hasPositiveBoxSize(size)) {
|
||||
throw new Error("Box brush size must remain positive on every axis.");
|
||||
}
|
||||
return {
|
||||
id: overrides.id ?? createOpaqueId("brush"),
|
||||
kind: "box",
|
||||
name: normalizeBrushName(overrides.name),
|
||||
center,
|
||||
rotationDegrees,
|
||||
size,
|
||||
geometry,
|
||||
faces: overrides.faces === undefined ? createDefaultBoxBrushFaces() : cloneBoxBrushFaces(overrides.faces),
|
||||
volume: overrides.volume === undefined ? createDefaultBoxBrushVolumeSettings() : cloneBoxBrushVolumeSettings(overrides.volume),
|
||||
layerId: overrides.layerId,
|
||||
groupId: overrides.groupId
|
||||
};
|
||||
}
|
||||
export function cloneBoxBrush(brush) {
|
||||
return createBoxBrush(brush);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,690 +0,0 @@
|
||||
import {} from "../assets/project-assets";
|
||||
import { isModelInstanceCollisionMode } from "../assets/model-instances";
|
||||
import { isPlayerStartColliderMode, getPlayerStartColliderHeight } from "../entities/entity-instances";
|
||||
import {} from "../interactions/interaction-links";
|
||||
import { BOX_FACE_IDS, BOX_VERTEX_IDS, MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT, hasPositiveBoxSize, isBoxBrushVolumeMode } from "./brushes";
|
||||
import { isAdvancedRenderingShadowMapSize, isAdvancedRenderingShadowType, isBoxVolumeRenderPath, isAdvancedRenderingToneMappingMode, isHexColorString } from "./world-settings";
|
||||
export function createDiagnostic(severity, code, message, path, scope = "document") {
|
||||
return {
|
||||
code,
|
||||
severity,
|
||||
scope,
|
||||
message,
|
||||
path
|
||||
};
|
||||
}
|
||||
function isFiniteNumber(value) {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
function isFiniteVec3(vector) {
|
||||
return isFiniteNumber(vector.x) && isFiniteNumber(vector.y) && isFiniteNumber(vector.z);
|
||||
}
|
||||
function hasPositiveFiniteVec3(vector) {
|
||||
return isFiniteVec3(vector) && vector.x > 0 && vector.y > 0 && vector.z > 0;
|
||||
}
|
||||
function isNonNegativeFiniteNumber(value) {
|
||||
return isFiniteNumber(value) && value >= 0;
|
||||
}
|
||||
function isPositiveFiniteNumber(value) {
|
||||
return isFiniteNumber(value) && value > 0;
|
||||
}
|
||||
function isPositiveInteger(value) {
|
||||
return isFiniteNumber(value) && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
function isPositiveIntegerInRange(value, max) {
|
||||
return isPositiveInteger(value) && value <= max;
|
||||
}
|
||||
function isBoolean(value) {
|
||||
return typeof value === "boolean";
|
||||
}
|
||||
function hasNonZeroVectorLength(vector) {
|
||||
return vector.x !== 0 || vector.y !== 0 || vector.z !== 0;
|
||||
}
|
||||
function validateWorldSettings(world, document, diagnostics) {
|
||||
if (world.background.mode === "solid") {
|
||||
if (!isHexColorString(world.background.colorHex)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-world-background-color", "Solid world backgrounds must use #RRGGBB colors.", "world.background.colorHex"));
|
||||
}
|
||||
}
|
||||
else if (world.background.mode === "verticalGradient") {
|
||||
if (!isHexColorString(world.background.topColorHex)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-world-background-top-color", "Gradient world backgrounds must use #RRGGBB colors for the top color.", "world.background.topColorHex"));
|
||||
}
|
||||
if (!isHexColorString(world.background.bottomColorHex)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-world-background-bottom-color", "Gradient world backgrounds must use #RRGGBB colors for the bottom color.", "world.background.bottomColorHex"));
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (typeof world.background.assetId !== "string" || world.background.assetId.trim().length === 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-world-background-asset-id", "World background images must reference a non-empty asset id.", "world.background.assetId"));
|
||||
}
|
||||
else {
|
||||
const backgroundAsset = document.assets[world.background.assetId];
|
||||
if (backgroundAsset === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-world-background-asset", `World background image asset ${world.background.assetId} does not exist.`, "world.background.assetId"));
|
||||
}
|
||||
else if (backgroundAsset.kind !== "image") {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-world-background-asset-kind", "World background images must reference image assets.", "world.background.assetId"));
|
||||
}
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(world.background.environmentIntensity)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-world-background-environment-intensity", "World background environment intensity must be a non-negative finite number.", "world.background.environmentIntensity"));
|
||||
}
|
||||
}
|
||||
if (!isHexColorString(world.ambientLight.colorHex)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-world-ambient-color", "World ambient light must use a #RRGGBB color.", "world.ambientLight.colorHex"));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(world.ambientLight.intensity)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-world-ambient-intensity", "World ambient light intensity must remain finite and zero or greater.", "world.ambientLight.intensity"));
|
||||
}
|
||||
if (!isHexColorString(world.sunLight.colorHex)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-world-sun-color", "World sun color must use a #RRGGBB color.", "world.sunLight.colorHex"));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(world.sunLight.intensity)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-world-sun-intensity", "World sun intensity must remain finite and zero or greater.", "world.sunLight.intensity"));
|
||||
}
|
||||
if (!isFiniteVec3(world.sunLight.direction) || !hasNonZeroVectorLength(world.sunLight.direction)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-world-sun-direction", "World sun direction must remain finite and must not be the zero vector.", "world.sunLight.direction"));
|
||||
}
|
||||
const advancedRendering = world.advancedRendering;
|
||||
if (!isBoolean(advancedRendering.enabled)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-enabled", "Advanced rendering enabled must be a boolean.", "world.advancedRendering.enabled"));
|
||||
}
|
||||
if (!isBoolean(advancedRendering.shadows.enabled)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-shadows-enabled", "Advanced rendering shadow enabled must be a boolean.", "world.advancedRendering.shadows.enabled"));
|
||||
}
|
||||
if (!isAdvancedRenderingShadowMapSize(advancedRendering.shadows.mapSize)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-shadow-map-size", "Advanced rendering shadow map size must be one of 512, 1024, 2048, or 4096.", "world.advancedRendering.shadows.mapSize"));
|
||||
}
|
||||
if (!isAdvancedRenderingShadowType(advancedRendering.shadows.type)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-shadow-type", "Advanced rendering shadow type must be basic, pcf, or pcfSoft.", "world.advancedRendering.shadows.type"));
|
||||
}
|
||||
if (!isFiniteNumber(advancedRendering.shadows.bias)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-shadow-bias", "Advanced rendering shadow bias must be a finite number.", "world.advancedRendering.shadows.bias"));
|
||||
}
|
||||
if (!isBoolean(advancedRendering.ambientOcclusion.enabled)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-ao-enabled", "Advanced rendering ambient occlusion enabled must be a boolean.", "world.advancedRendering.ambientOcclusion.enabled"));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(advancedRendering.ambientOcclusion.intensity)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-ao-intensity", "Advanced rendering ambient occlusion intensity must be a non-negative finite number.", "world.advancedRendering.ambientOcclusion.intensity"));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(advancedRendering.ambientOcclusion.radius)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-ao-radius", "Advanced rendering ambient occlusion radius must be a non-negative finite number.", "world.advancedRendering.ambientOcclusion.radius"));
|
||||
}
|
||||
if (!isPositiveInteger(advancedRendering.ambientOcclusion.samples)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-ao-samples", "Advanced rendering ambient occlusion samples must be a positive integer.", "world.advancedRendering.ambientOcclusion.samples"));
|
||||
}
|
||||
if (!isBoolean(advancedRendering.bloom.enabled)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-bloom-enabled", "Advanced rendering bloom enabled must be a boolean.", "world.advancedRendering.bloom.enabled"));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(advancedRendering.bloom.intensity)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-bloom-intensity", "Advanced rendering bloom intensity must be a non-negative finite number.", "world.advancedRendering.bloom.intensity"));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(advancedRendering.bloom.threshold)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-bloom-threshold", "Advanced rendering bloom threshold must be a non-negative finite number.", "world.advancedRendering.bloom.threshold"));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(advancedRendering.bloom.radius)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-bloom-radius", "Advanced rendering bloom radius must be a non-negative finite number.", "world.advancedRendering.bloom.radius"));
|
||||
}
|
||||
if (!isAdvancedRenderingToneMappingMode(advancedRendering.toneMapping.mode)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-tone-mapping-mode", "Advanced rendering tone mapping mode must be none, linear, reinhard, cineon, or acesFilmic.", "world.advancedRendering.toneMapping.mode"));
|
||||
}
|
||||
if (!isPositiveFiniteNumber(advancedRendering.toneMapping.exposure)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-tone-mapping-exposure", "Advanced rendering tone mapping exposure must be a positive finite number.", "world.advancedRendering.toneMapping.exposure"));
|
||||
}
|
||||
if (!isBoolean(advancedRendering.depthOfField.enabled)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-dof-enabled", "Advanced rendering depth of field enabled must be a boolean.", "world.advancedRendering.depthOfField.enabled"));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(advancedRendering.depthOfField.focusDistance)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-dof-focus-distance", "Advanced rendering depth of field focus distance must be a non-negative finite number.", "world.advancedRendering.depthOfField.focusDistance"));
|
||||
}
|
||||
if (!isPositiveFiniteNumber(advancedRendering.depthOfField.focalLength)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-dof-focal-length", "Advanced rendering depth of field focal length must be a positive finite number.", "world.advancedRendering.depthOfField.focalLength"));
|
||||
}
|
||||
if (!isPositiveFiniteNumber(advancedRendering.depthOfField.bokehScale)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-dof-bokeh-scale", "Advanced rendering depth of field bokeh scale must be a positive finite number.", "world.advancedRendering.depthOfField.bokehScale"));
|
||||
}
|
||||
if (!isBoxVolumeRenderPath(advancedRendering.fogPath)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-fog-path", "Advanced rendering fog path must be performance or quality.", "world.advancedRendering.fogPath"));
|
||||
}
|
||||
if (!isBoxVolumeRenderPath(advancedRendering.waterPath)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-water-path", "Advanced rendering water path must be performance or quality.", "world.advancedRendering.waterPath"));
|
||||
}
|
||||
if (!["none", "world", "all"].includes(advancedRendering.waterReflectionMode)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-advanced-rendering-water-reflection-mode", "Advanced rendering water reflection mode must be none, world, or all.", "world.advancedRendering.waterReflectionMode"));
|
||||
}
|
||||
}
|
||||
function validatePointLightEntity(entity, path, diagnostics) {
|
||||
if (!isFiniteVec3(entity.position)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-point-light-position", "Point Light position must remain finite on every axis.", `${path}.position`));
|
||||
}
|
||||
if (!isHexColorString(entity.colorHex)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-point-light-color", "Point Light color must use a #RRGGBB color.", `${path}.colorHex`));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(entity.intensity)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-point-light-intensity", "Point Light intensity must remain finite and zero or greater.", `${path}.intensity`));
|
||||
}
|
||||
if (!isPositiveFiniteNumber(entity.distance)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-point-light-distance", "Point Light distance must remain finite and greater than zero.", `${path}.distance`));
|
||||
}
|
||||
}
|
||||
function validateSpotLightEntity(entity, path, diagnostics) {
|
||||
if (!isFiniteVec3(entity.position)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-spot-light-position", "Spot Light position must remain finite on every axis.", `${path}.position`));
|
||||
}
|
||||
if (!isFiniteVec3(entity.direction) || !hasNonZeroVectorLength(entity.direction)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-spot-light-direction", "Spot Light direction must remain finite and must not be the zero vector.", `${path}.direction`));
|
||||
}
|
||||
if (!isHexColorString(entity.colorHex)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-spot-light-color", "Spot Light color must use a #RRGGBB color.", `${path}.colorHex`));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(entity.intensity)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-spot-light-intensity", "Spot Light intensity must remain finite and zero or greater.", `${path}.intensity`));
|
||||
}
|
||||
if (!isPositiveFiniteNumber(entity.distance)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-spot-light-distance", "Spot Light distance must remain finite and greater than zero.", `${path}.distance`));
|
||||
}
|
||||
if (!isFiniteNumber(entity.angleDegrees) || entity.angleDegrees <= 0 || entity.angleDegrees >= 180) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-spot-light-angle", "Spot Light angle must remain a finite degree value between 0 and 180.", `${path}.angleDegrees`));
|
||||
}
|
||||
}
|
||||
function validateProjectAssetBoundingBox(boundingBox, path, diagnostics) {
|
||||
if (boundingBox === null) {
|
||||
return;
|
||||
}
|
||||
if (!isFiniteVec3(boundingBox.min)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-asset-bounding-box-min", "Model asset bounding boxes must have finite minimum coordinates.", `${path}.min`));
|
||||
}
|
||||
if (!isFiniteVec3(boundingBox.max)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-asset-bounding-box-max", "Model asset bounding boxes must have finite maximum coordinates.", `${path}.max`));
|
||||
}
|
||||
if (!isFiniteVec3(boundingBox.size) || boundingBox.size.x < 0 || boundingBox.size.y < 0 || boundingBox.size.z < 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-asset-bounding-box-size", "Model asset bounding boxes must have finite, zero-or-greater size values.", `${path}.size`));
|
||||
}
|
||||
}
|
||||
function validateModelAssetMetadata(metadata, path, diagnostics) {
|
||||
if (metadata.format !== "glb" && metadata.format !== "gltf") {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-asset-format", "Model asset format must be glb or gltf.", `${path}.format`));
|
||||
}
|
||||
if (metadata.sceneName !== null && metadata.sceneName.trim().length === 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-asset-scene-name", "Model asset scene names must be non-empty when authored.", `${path}.sceneName`));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(metadata.nodeCount)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-asset-node-count", "Model asset node counts must be finite and zero or greater.", `${path}.nodeCount`));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(metadata.meshCount)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-asset-mesh-count", "Model asset mesh counts must be finite and zero or greater.", `${path}.meshCount`));
|
||||
}
|
||||
if (!Array.isArray(metadata.materialNames) || metadata.materialNames.some((name) => typeof name !== "string")) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-asset-material-names", "Model asset material names must be string arrays.", `${path}.materialNames`));
|
||||
}
|
||||
if (!Array.isArray(metadata.textureNames) || metadata.textureNames.some((name) => typeof name !== "string")) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-asset-texture-names", "Model asset texture names must be string arrays.", `${path}.textureNames`));
|
||||
}
|
||||
if (!Array.isArray(metadata.animationNames) || metadata.animationNames.some((name) => typeof name !== "string")) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-asset-animation-names", "Model asset animation names must be string arrays.", `${path}.animationNames`));
|
||||
}
|
||||
validateProjectAssetBoundingBox(metadata.boundingBox, `${path}.boundingBox`, diagnostics);
|
||||
if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-asset-warnings", "Model asset warnings must be string arrays.", `${path}.warnings`));
|
||||
}
|
||||
}
|
||||
function validateImageAssetMetadata(metadata, path, diagnostics) {
|
||||
if (!isPositiveFiniteNumber(metadata.width)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-image-asset-width", "Image asset width must be finite and greater than zero.", `${path}.width`));
|
||||
}
|
||||
if (!isPositiveFiniteNumber(metadata.height)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-image-asset-height", "Image asset height must be finite and greater than zero.", `${path}.height`));
|
||||
}
|
||||
if (!isBoolean(metadata.hasAlpha)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-image-asset-alpha", "Image asset alpha flags must be booleans.", `${path}.hasAlpha`));
|
||||
}
|
||||
if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-image-asset-warnings", "Image asset warnings must be string arrays.", `${path}.warnings`));
|
||||
}
|
||||
}
|
||||
function validateAudioAssetMetadata(metadata, path, diagnostics) {
|
||||
if (metadata.durationSeconds !== null && !isNonNegativeFiniteNumber(metadata.durationSeconds)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-audio-asset-duration", "Audio asset durations must be finite and zero or greater when authored.", `${path}.durationSeconds`));
|
||||
}
|
||||
if (metadata.channelCount !== null && !isPositiveFiniteNumber(metadata.channelCount)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-audio-asset-channel-count", "Audio asset channel counts must be finite and greater than zero when authored.", `${path}.channelCount`));
|
||||
}
|
||||
if (metadata.sampleRateHz !== null && !isPositiveFiniteNumber(metadata.sampleRateHz)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-audio-asset-sample-rate", "Audio asset sample rates must be finite and greater than zero when authored.", `${path}.sampleRateHz`));
|
||||
}
|
||||
if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-audio-asset-warnings", "Audio asset warnings must be string arrays.", `${path}.warnings`));
|
||||
}
|
||||
}
|
||||
function validateProjectAsset(asset, path, diagnostics) {
|
||||
if (asset.sourceName.trim().length === 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-asset-source-name", "Asset source names must be non-empty strings.", `${path}.sourceName`));
|
||||
}
|
||||
if (asset.mimeType.trim().length === 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-asset-mime-type", "Asset mime types must be non-empty strings.", `${path}.mimeType`));
|
||||
}
|
||||
if (asset.storageKey.trim().length === 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-asset-storage-key", "Asset storage keys must be non-empty strings.", `${path}.storageKey`));
|
||||
}
|
||||
if (!isPositiveFiniteNumber(asset.byteLength)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-asset-byte-length", "Asset byte lengths must be finite and greater than zero.", `${path}.byteLength`));
|
||||
}
|
||||
switch (asset.kind) {
|
||||
case "model":
|
||||
validateModelAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics);
|
||||
break;
|
||||
case "image":
|
||||
validateImageAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics);
|
||||
break;
|
||||
case "audio":
|
||||
validateAudioAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics);
|
||||
break;
|
||||
}
|
||||
}
|
||||
function validateModelInstance(modelInstance, path, document, diagnostics) {
|
||||
if (modelInstance.name !== undefined && modelInstance.name.trim().length === 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-instance-name", "Model instance names must be non-empty when authored.", `${path}.name`));
|
||||
}
|
||||
if (!isFiniteVec3(modelInstance.position)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-instance-position", "Model instance positions must remain finite on every axis.", `${path}.position`));
|
||||
}
|
||||
if (!isFiniteVec3(modelInstance.rotationDegrees)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-instance-rotation", "Model instance rotations must remain finite on every axis.", `${path}.rotationDegrees`));
|
||||
}
|
||||
if (!hasPositiveFiniteVec3(modelInstance.scale)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-instance-scale", "Model instance scales must remain finite and positive on every axis.", `${path}.scale`));
|
||||
}
|
||||
if (!isModelInstanceCollisionMode(modelInstance.collision.mode)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-instance-collision-mode", "Model instance collision mode must be one of none, terrain, static, dynamic, or simple.", `${path}.collision.mode`));
|
||||
}
|
||||
if (!isBoolean(modelInstance.collision.visible)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-instance-collision-visibility", "Model instance collision visibility must be a boolean.", `${path}.collision.visible`));
|
||||
}
|
||||
const asset = document.assets[modelInstance.assetId];
|
||||
if (asset === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-model-instance-asset", `Model instance asset ${modelInstance.assetId} does not exist.`, `${path}.assetId`));
|
||||
return;
|
||||
}
|
||||
if (asset.kind !== "model") {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-model-instance-asset-kind", "Model instances may only reference model assets.", `${path}.assetId`));
|
||||
}
|
||||
}
|
||||
function validateEntityName(name, path, diagnostics) {
|
||||
if (name !== undefined && name.trim().length === 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-entity-name", "Entity names must be non-empty when authored.", `${path}.name`));
|
||||
}
|
||||
}
|
||||
function validatePlayerStartEntity(entity, path, diagnostics) {
|
||||
if (!isFiniteVec3(entity.position)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-player-start-position", "Player Start position must remain finite on every axis.", `${path}.position`));
|
||||
}
|
||||
if (!isFiniteNumber(entity.yawDegrees)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-player-start-yaw", "Player Start yaw must remain a finite number.", `${path}.yawDegrees`));
|
||||
}
|
||||
if (!isPlayerStartColliderMode(entity.collider.mode)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-player-start-collider-mode", "Player Start collider mode must be capsule, box, or none.", `${path}.collider.mode`));
|
||||
}
|
||||
if (!isFiniteNumber(entity.collider.eyeHeight) || entity.collider.eyeHeight <= 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-player-start-eye-height", "Player Start eye height must remain a finite number greater than zero.", `${path}.collider.eyeHeight`));
|
||||
}
|
||||
if (!isFiniteNumber(entity.collider.capsuleRadius) || entity.collider.capsuleRadius <= 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-player-start-capsule-radius", "Player Start capsule radius must remain a finite number greater than zero.", `${path}.collider.capsuleRadius`));
|
||||
}
|
||||
if (!isFiniteNumber(entity.collider.capsuleHeight) || entity.collider.capsuleHeight <= 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-player-start-capsule-height", "Player Start capsule height must remain a finite number greater than zero.", `${path}.collider.capsuleHeight`));
|
||||
}
|
||||
if (!isFiniteVec3(entity.collider.boxSize) ||
|
||||
entity.collider.boxSize.x <= 0 ||
|
||||
entity.collider.boxSize.y <= 0 ||
|
||||
entity.collider.boxSize.z <= 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-player-start-box-size", "Player Start box size must remain finite and positive on every axis.", `${path}.collider.boxSize`));
|
||||
}
|
||||
if (entity.collider.capsuleHeight < entity.collider.capsuleRadius * 2) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-player-start-capsule-proportions", "Player Start capsule height must be at least twice the capsule radius.", `${path}.collider.capsuleHeight`));
|
||||
}
|
||||
const colliderHeight = getPlayerStartColliderHeight(entity.collider);
|
||||
if (colliderHeight !== null && entity.collider.eyeHeight > colliderHeight) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-player-start-eye-height", "Player Start eye height must fit within the authored collider height.", `${path}.collider.eyeHeight`));
|
||||
}
|
||||
}
|
||||
function validateSoundEmitterAudioAsset(entity, path, document, diagnostics, missingSeverity) {
|
||||
if (entity.audioAssetId === null) {
|
||||
diagnostics.push(createDiagnostic(missingSeverity, "missing-sound-emitter-audio-asset", entity.autoplay
|
||||
? "Sound Emitter autoplay requires an assigned audio asset."
|
||||
: "Sound Emitter has no audio asset assigned yet.", `${path}.audioAssetId`));
|
||||
return null;
|
||||
}
|
||||
const asset = document.assets[entity.audioAssetId];
|
||||
if (asset === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-sound-emitter-audio-asset", `Sound Emitter audio asset ${entity.audioAssetId} does not exist.`, `${path}.audioAssetId`));
|
||||
return null;
|
||||
}
|
||||
if (asset.kind !== "audio") {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-audio-asset-kind", "Sound Emitter audioAssetId must reference an audio asset.", `${path}.audioAssetId`));
|
||||
return null;
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
function validateSoundEmitterEntity(entity, path, document, diagnostics) {
|
||||
if (!isFiniteVec3(entity.position)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-position", "Sound Emitter position must remain finite on every axis.", `${path}.position`));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(entity.volume)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-volume", "Sound Emitter volume must remain finite and zero or greater.", `${path}.volume`));
|
||||
}
|
||||
if (!isPositiveFiniteNumber(entity.refDistance)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-ref-distance", "Sound Emitter ref distance must remain finite and greater than zero.", `${path}.refDistance`));
|
||||
}
|
||||
if (!isPositiveFiniteNumber(entity.maxDistance)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-max-distance", "Sound Emitter max distance must remain finite and greater than zero.", `${path}.maxDistance`));
|
||||
}
|
||||
if (isPositiveFiniteNumber(entity.refDistance) && isPositiveFiniteNumber(entity.maxDistance) && entity.maxDistance < entity.refDistance) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-distance-order", "Sound Emitter max distance must be greater than or equal to ref distance.", `${path}.maxDistance`));
|
||||
}
|
||||
if (!isBoolean(entity.autoplay)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-autoplay", "Sound Emitter autoplay must remain a boolean.", `${path}.autoplay`));
|
||||
}
|
||||
if (!isBoolean(entity.loop)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-loop", "Sound Emitter loop must remain a boolean.", `${path}.loop`));
|
||||
}
|
||||
validateSoundEmitterAudioAsset(entity, path, document, diagnostics, entity.autoplay ? "error" : "warning");
|
||||
}
|
||||
function validateTriggerVolumeEntity(entity, path, diagnostics) {
|
||||
if (!isFiniteVec3(entity.position)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-trigger-volume-position", "Trigger Volume position must remain finite on every axis.", `${path}.position`));
|
||||
}
|
||||
if (!hasPositiveFiniteVec3(entity.size)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-trigger-volume-size", "Trigger Volume size must remain finite and positive on every axis.", `${path}.size`));
|
||||
}
|
||||
if (!isBoolean(entity.triggerOnEnter)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-trigger-volume-enter-flag", "Trigger Volume triggerOnEnter must remain a boolean.", `${path}.triggerOnEnter`));
|
||||
}
|
||||
if (!isBoolean(entity.triggerOnExit)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-trigger-volume-exit-flag", "Trigger Volume triggerOnExit must remain a boolean.", `${path}.triggerOnExit`));
|
||||
}
|
||||
}
|
||||
function validateTeleportTargetEntity(entity, path, diagnostics) {
|
||||
if (!isFiniteVec3(entity.position)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-teleport-target-position", "Teleport Target position must remain finite on every axis.", `${path}.position`));
|
||||
}
|
||||
if (!isFiniteNumber(entity.yawDegrees)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-teleport-target-yaw", "Teleport Target yaw must remain a finite number.", `${path}.yawDegrees`));
|
||||
}
|
||||
}
|
||||
function validateInteractableEntity(entity, path, diagnostics) {
|
||||
if (!isFiniteVec3(entity.position)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-interactable-position", "Interactable position must remain finite on every axis.", `${path}.position`));
|
||||
}
|
||||
if (!isPositiveFiniteNumber(entity.radius)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-interactable-radius", "Interactable radius must remain finite and greater than zero.", `${path}.radius`));
|
||||
}
|
||||
if (typeof entity.prompt !== "string" || entity.prompt.trim().length === 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-interactable-prompt", "Interactable prompt must remain a non-empty string.", `${path}.prompt`));
|
||||
}
|
||||
if (!isBoolean(entity.enabled)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-interactable-enabled", "Interactable enabled must remain a boolean.", `${path}.enabled`));
|
||||
}
|
||||
}
|
||||
function validateInteractionLink(link, path, document, diagnostics) {
|
||||
const sourceEntity = document.entities[link.sourceEntityId];
|
||||
if (sourceEntity === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-interaction-source-entity", `Interaction source entity ${link.sourceEntityId} does not exist.`, `${path}.sourceEntityId`));
|
||||
return;
|
||||
}
|
||||
if (sourceEntity.kind !== "triggerVolume" && sourceEntity.kind !== "interactable") {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-interaction-source-kind", "Interaction links may only source from Trigger Volume or Interactable entities in the current slice.", `${path}.sourceEntityId`));
|
||||
}
|
||||
if (sourceEntity.kind === "triggerVolume") {
|
||||
if (link.trigger !== "enter" && link.trigger !== "exit") {
|
||||
diagnostics.push(createDiagnostic("error", "unsupported-interaction-trigger", "Trigger Volume links may only use enter or exit triggers.", `${path}.trigger`));
|
||||
}
|
||||
}
|
||||
else if (sourceEntity.kind === "interactable") {
|
||||
if (link.trigger !== "click") {
|
||||
diagnostics.push(createDiagnostic("error", "unsupported-interaction-trigger", "Interactable links may only use the click trigger.", `${path}.trigger`));
|
||||
}
|
||||
}
|
||||
switch (link.action.type) {
|
||||
case "teleportPlayer": {
|
||||
const targetEntity = document.entities[link.action.targetEntityId];
|
||||
if (targetEntity === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-teleport-target-entity", `Teleport target entity ${link.action.targetEntityId} does not exist.`, `${path}.action.targetEntityId`));
|
||||
return;
|
||||
}
|
||||
if (targetEntity.kind !== "teleportTarget") {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-teleport-target-kind", "Teleport player actions must target a Teleport Target entity.", `${path}.action.targetEntityId`));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "toggleVisibility":
|
||||
if (document.brushes[link.action.targetBrushId] === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-visibility-target-brush", `Visibility target brush ${link.action.targetBrushId} does not exist.`, `${path}.action.targetBrushId`));
|
||||
}
|
||||
if (link.action.visible !== undefined && typeof link.action.visible !== "boolean") {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-visibility-action-visible", "Visibility actions must use a boolean visible value when authored.", `${path}.action.visible`));
|
||||
}
|
||||
break;
|
||||
case "playAnimation":
|
||||
{
|
||||
const targetModelInstance = document.modelInstances[link.action.targetModelInstanceId];
|
||||
if (targetModelInstance === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-play-animation-target-instance", `Play animation target model instance ${link.action.targetModelInstanceId} does not exist.`, `${path}.action.targetModelInstanceId`));
|
||||
return;
|
||||
}
|
||||
if (link.action.clipName.trim().length === 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-play-animation-clip-name", "Play animation clip name must be non-empty.", `${path}.action.clipName`));
|
||||
return;
|
||||
}
|
||||
const targetAsset = document.assets[targetModelInstance.assetId];
|
||||
if (targetAsset === undefined || targetAsset.kind !== "model") {
|
||||
return;
|
||||
}
|
||||
if (!targetAsset.metadata.animationNames.includes(link.action.clipName)) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-play-animation-clip", `Play animation clip ${link.action.clipName} does not exist on model asset ${targetAsset.id}.`, `${path}.action.clipName`));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "stopAnimation":
|
||||
// Validate that the target model instance exists in the document
|
||||
if (document.modelInstances[link.action.targetModelInstanceId] === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-stop-animation-target-instance", `Stop animation target model instance ${link.action.targetModelInstanceId} does not exist.`, `${path}.action.targetModelInstanceId`));
|
||||
}
|
||||
break;
|
||||
case "playSound":
|
||||
case "stopSound": {
|
||||
const targetEntity = document.entities[link.action.targetSoundEmitterId];
|
||||
if (targetEntity === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-sound-emitter-entity", `Sound emitter entity ${link.action.targetSoundEmitterId} does not exist.`, `${path}.action.targetSoundEmitterId`));
|
||||
break;
|
||||
}
|
||||
if (targetEntity.kind !== "soundEmitter") {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-kind", "Sound playback actions must target a Sound Emitter entity.", `${path}.action.targetSoundEmitterId`));
|
||||
break;
|
||||
}
|
||||
if (targetEntity.audioAssetId === null) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-sound-emitter-audio-asset", "Sound playback actions require a Sound Emitter that references an audio asset.", `${path}.action.targetSoundEmitterId`));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
diagnostics.push(createDiagnostic("error", "unsupported-interaction-action", `Unsupported interaction action ${link.action.type}.`, `${path}.action.type`));
|
||||
break;
|
||||
}
|
||||
}
|
||||
function registerAuthoredId(id, path, seenIds, diagnostics) {
|
||||
const previousPath = seenIds.get(id);
|
||||
if (previousPath !== undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "duplicate-authored-id", `Duplicate authored id ${id} is already used at ${previousPath}.`, path));
|
||||
return;
|
||||
}
|
||||
seenIds.set(id, path);
|
||||
}
|
||||
export function formatSceneDiagnostic(diagnostic) {
|
||||
return diagnostic.path === undefined ? diagnostic.message : `${diagnostic.path}: ${diagnostic.message}`;
|
||||
}
|
||||
export function formatSceneDiagnosticSummary(diagnostics, limit = 3) {
|
||||
if (diagnostics.length === 0) {
|
||||
return "No diagnostics.";
|
||||
}
|
||||
const visibleDiagnostics = diagnostics.slice(0, Math.max(1, limit));
|
||||
const summary = visibleDiagnostics.map((diagnostic) => formatSceneDiagnostic(diagnostic)).join("; ");
|
||||
const remainingCount = diagnostics.length - visibleDiagnostics.length;
|
||||
return remainingCount > 0 ? `${summary}; +${remainingCount} more` : summary;
|
||||
}
|
||||
export function validateSceneDocument(document) {
|
||||
const diagnostics = [];
|
||||
const seenIds = new Map();
|
||||
validateWorldSettings(document.world, document, diagnostics);
|
||||
for (const [materialKey, material] of Object.entries(document.materials)) {
|
||||
const path = `materials.${materialKey}`;
|
||||
if (material.id !== materialKey) {
|
||||
diagnostics.push(createDiagnostic("error", "material-id-mismatch", "Material ids must match their registry key.", `${path}.id`));
|
||||
}
|
||||
registerAuthoredId(material.id, path, seenIds, diagnostics);
|
||||
}
|
||||
for (const [assetKey, asset] of Object.entries(document.assets)) {
|
||||
const path = `assets.${assetKey}`;
|
||||
if (asset.id !== assetKey) {
|
||||
diagnostics.push(createDiagnostic("error", "asset-id-mismatch", "Asset ids must match their registry key.", `${path}.id`));
|
||||
}
|
||||
registerAuthoredId(asset.id, path, seenIds, diagnostics);
|
||||
validateProjectAsset(asset, path, diagnostics);
|
||||
}
|
||||
for (const [brushKey, brush] of Object.entries(document.brushes)) {
|
||||
const path = `brushes.${brushKey}`;
|
||||
if (brush.id !== brushKey) {
|
||||
diagnostics.push(createDiagnostic("error", "brush-id-mismatch", "Brush ids must match their registry key.", `${path}.id`));
|
||||
}
|
||||
registerAuthoredId(brush.id, path, seenIds, diagnostics);
|
||||
if (brush.name !== undefined && brush.name.trim().length === 0) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-name", "Box brush names must be non-empty when authored.", `${path}.name`));
|
||||
}
|
||||
if (!isFiniteVec3(brush.center)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-center", "Box brush centers must remain finite on every axis.", `${path}.center`));
|
||||
}
|
||||
if (!isFiniteVec3(brush.rotationDegrees)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-rotation", "Box brush rotations must remain finite on every axis.", `${path}.rotationDegrees`));
|
||||
}
|
||||
if (!isFiniteVec3(brush.size) || !hasPositiveBoxSize(brush.size)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-size", "Box brush sizes must remain finite and positive on every axis.", `${path}.size`));
|
||||
}
|
||||
for (const vertexId of BOX_VERTEX_IDS) {
|
||||
if (!isFiniteVec3(brush.geometry.vertices[vertexId])) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-geometry-vertex", "Box brush geometry vertices must remain finite on every axis.", `${path}.geometry.vertices.${vertexId}`));
|
||||
}
|
||||
}
|
||||
for (const faceId of BOX_FACE_IDS) {
|
||||
const materialId = brush.faces[faceId].materialId;
|
||||
if (materialId !== null && document.materials[materialId] === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-material-ref", `Face material reference ${materialId} does not exist in the document material registry.`, `${path}.faces.${faceId}.materialId`));
|
||||
}
|
||||
}
|
||||
const volume = brush.volume;
|
||||
if (!isBoxBrushVolumeMode(volume?.mode)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-volume-mode", "Box volume mode must be none, water, or fog.", `${path}.volume.mode`));
|
||||
continue;
|
||||
}
|
||||
if (volume.mode === "water") {
|
||||
const water = volume.water;
|
||||
if (water === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-water-settings", "Water volumes must define water settings.", `${path}.volume.water`));
|
||||
}
|
||||
else {
|
||||
if (typeof water.colorHex !== "string" || !isHexColorString(water.colorHex)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-water-color", "Water volume color must use #RRGGBB format.", `${path}.volume.water.colorHex`));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(water.surfaceOpacity)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-water-surface-opacity", "Water surface opacity must be a non-negative finite number.", `${path}.volume.water.surfaceOpacity`));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(water.waveStrength)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-water-wave-strength", "Water wave strength must be a non-negative finite number.", `${path}.volume.water.waveStrength`));
|
||||
}
|
||||
if (!isPositiveIntegerInRange(water.foamContactLimit, MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-water-foam-contact-limit", `Water foam contact limit must be a positive integer between 1 and ${MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT}.`, `${path}.volume.water.foamContactLimit`));
|
||||
}
|
||||
if (typeof water.surfaceDisplacementEnabled !== "boolean") {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-water-surface-displacement-enabled", "Water surface displacement must be enabled or disabled explicitly.", `${path}.volume.water.surfaceDisplacementEnabled`));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (volume.mode === "fog") {
|
||||
const fog = volume.fog;
|
||||
if (fog === undefined) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-fog-settings", "Fog volumes must define fog settings.", `${path}.volume.fog`));
|
||||
}
|
||||
else {
|
||||
if (typeof fog.colorHex !== "string" || !isHexColorString(fog.colorHex)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-fog-color", "Fog volume color must use #RRGGBB format.", `${path}.volume.fog.colorHex`));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(fog.density)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-fog-density", "Fog volume density must be a non-negative finite number.", `${path}.volume.fog.density`));
|
||||
}
|
||||
if (!isNonNegativeFiniteNumber(fog.padding)) {
|
||||
diagnostics.push(createDiagnostic("error", "invalid-box-fog-padding", "Fog volume padding must be a non-negative finite number.", `${path}.volume.fog.padding`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [modelInstanceKey, modelInstance] of Object.entries(document.modelInstances)) {
|
||||
const path = `modelInstances.${modelInstanceKey}`;
|
||||
if (modelInstance.id !== modelInstanceKey) {
|
||||
diagnostics.push(createDiagnostic("error", "model-instance-id-mismatch", "Model instance ids must match their registry key.", `${path}.id`));
|
||||
}
|
||||
registerAuthoredId(modelInstance.id, path, seenIds, diagnostics);
|
||||
validateModelInstance(modelInstance, path, document, diagnostics);
|
||||
}
|
||||
for (const [entityKey, entity] of Object.entries(document.entities)) {
|
||||
const path = `entities.${entityKey}`;
|
||||
if (entity.id !== entityKey) {
|
||||
diagnostics.push(createDiagnostic("error", "entity-id-mismatch", "Entity ids must match their registry key.", `${path}.id`));
|
||||
}
|
||||
registerAuthoredId(entity.id, path, seenIds, diagnostics);
|
||||
validateEntityName(entity.name, path, diagnostics);
|
||||
switch (entity.kind) {
|
||||
case "pointLight":
|
||||
validatePointLightEntity(entity, path, diagnostics);
|
||||
break;
|
||||
case "spotLight":
|
||||
validateSpotLightEntity(entity, path, diagnostics);
|
||||
break;
|
||||
case "playerStart":
|
||||
validatePlayerStartEntity(entity, path, diagnostics);
|
||||
break;
|
||||
case "soundEmitter":
|
||||
validateSoundEmitterEntity(entity, path, document, diagnostics);
|
||||
break;
|
||||
case "triggerVolume":
|
||||
validateTriggerVolumeEntity(entity, path, diagnostics);
|
||||
break;
|
||||
case "teleportTarget":
|
||||
validateTeleportTargetEntity(entity, path, diagnostics);
|
||||
break;
|
||||
case "interactable":
|
||||
validateInteractableEntity(entity, path, diagnostics);
|
||||
break;
|
||||
default:
|
||||
diagnostics.push(createDiagnostic("error", "unsupported-entity-kind", `Unsupported entity kind ${entity.kind}.`, `${path}.kind`));
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const [linkKey, link] of Object.entries(document.interactionLinks)) {
|
||||
const path = `interactionLinks.${linkKey}`;
|
||||
if (link.id !== linkKey) {
|
||||
diagnostics.push(createDiagnostic("error", "interaction-link-id-mismatch", "Interaction link ids must match their registry key.", `${path}.id`));
|
||||
}
|
||||
registerAuthoredId(link.id, path, seenIds, diagnostics);
|
||||
validateInteractionLink(link, path, document, diagnostics);
|
||||
}
|
||||
return {
|
||||
diagnostics,
|
||||
errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"),
|
||||
warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning")
|
||||
};
|
||||
}
|
||||
export function assertSceneDocumentIsValid(document) {
|
||||
const validation = validateSceneDocument(document);
|
||||
if (validation.errors.length > 0) {
|
||||
throw new Error(`Scene document has ${validation.errors.length} validation error(s): ${formatSceneDiagnosticSummary(validation.errors)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { cloneMaterialRegistry, createStarterMaterialRegistry } from "../materials/starter-material-library";
|
||||
import { createDefaultWorldSettings } from "./world-settings";
|
||||
export const SCENE_DOCUMENT_VERSION = 21;
|
||||
export const WATER_SURFACE_DISPLACEMENT_SCENE_DOCUMENT_VERSION = 21;
|
||||
export const WHITEBOX_BOX_VOLUME_SCENE_DOCUMENT_VERSION = 20;
|
||||
export const WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION = 19;
|
||||
export const WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION = 18;
|
||||
export const PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION = 17;
|
||||
export const IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION = 16;
|
||||
export const ENTITY_NAMES_SCENE_DOCUMENT_VERSION = 15;
|
||||
export const SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION = 13;
|
||||
export const ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION = 12;
|
||||
export const LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION = 10;
|
||||
export const MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION = 9;
|
||||
export const FOUNDATION_SCENE_DOCUMENT_VERSION = 1;
|
||||
export const BOX_BRUSH_SCENE_DOCUMENT_VERSION = 2;
|
||||
export const FACE_MATERIALS_SCENE_DOCUMENT_VERSION = 3;
|
||||
export const RUNNER_V1_SCENE_DOCUMENT_VERSION = 4;
|
||||
export const FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION = 5;
|
||||
export const WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION = 6;
|
||||
export const ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION = 7;
|
||||
export const TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION = 8;
|
||||
export function createEmptySceneDocument(overrides = {}) {
|
||||
return {
|
||||
version: SCENE_DOCUMENT_VERSION,
|
||||
name: overrides.name ?? "Untitled Scene",
|
||||
world: overrides.world ?? createDefaultWorldSettings(),
|
||||
materials: cloneMaterialRegistry(overrides.materials ?? createStarterMaterialRegistry()),
|
||||
textures: {},
|
||||
assets: {},
|
||||
brushes: {},
|
||||
modelInstances: {},
|
||||
entities: {},
|
||||
interactionLinks: {}
|
||||
};
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import { DEFAULT_SUN_DIRECTION } from "../core/vector";
|
||||
export const ADVANCED_RENDERING_SHADOW_MAP_SIZES = [512, 1024, 2048, 4096];
|
||||
export const ADVANCED_RENDERING_SHADOW_TYPES = ["basic", "pcf", "pcfSoft"];
|
||||
export const ADVANCED_RENDERING_TONE_MAPPING_MODES = ["none", "linear", "reinhard", "cineon", "acesFilmic"];
|
||||
export const BOX_VOLUME_RENDER_PATHS = ["performance", "quality"];
|
||||
export const ADVANCED_RENDERING_WATER_REFLECTION_MODES = ["none", "world", "all"];
|
||||
const DEFAULT_SOLID_BACKGROUND_COLOR = "#2f3947";
|
||||
const DEFAULT_GRADIENT_TOP_COLOR = DEFAULT_SOLID_BACKGROUND_COLOR;
|
||||
const DEFAULT_GRADIENT_BOTTOM_COLOR = "#141a22";
|
||||
const DEFAULT_ADVANCED_RENDERING_SHADOW_MAP_SIZE = 2048;
|
||||
const DEFAULT_ADVANCED_RENDERING_SHADOW_TYPE = "pcfSoft";
|
||||
const DEFAULT_ADVANCED_RENDERING_SHADOW_BIAS = -0.0005;
|
||||
const DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_INTENSITY = 1;
|
||||
const DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_RADIUS = 0.5;
|
||||
const DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_SAMPLES = 8;
|
||||
const DEFAULT_ADVANCED_RENDERING_BLOOM_INTENSITY = 0.75;
|
||||
const DEFAULT_ADVANCED_RENDERING_BLOOM_THRESHOLD = 0.85;
|
||||
const DEFAULT_ADVANCED_RENDERING_BLOOM_RADIUS = 0.35;
|
||||
const DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_MODE = "acesFilmic";
|
||||
const DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_EXPOSURE = 1;
|
||||
const DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_FOCUS_DISTANCE = 10;
|
||||
const DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_FOCAL_LENGTH = 0.03;
|
||||
const DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_BOKEH_SCALE = 1.5;
|
||||
const DEFAULT_BOX_VOLUME_RENDER_PATH = "performance";
|
||||
const DEFAULT_ADVANCED_RENDERING_WATER_REFLECTION_MODE = "none";
|
||||
export function isAdvancedRenderingShadowMapSize(value) {
|
||||
return ADVANCED_RENDERING_SHADOW_MAP_SIZES.includes(value);
|
||||
}
|
||||
export function isAdvancedRenderingShadowType(value) {
|
||||
return ADVANCED_RENDERING_SHADOW_TYPES.includes(value);
|
||||
}
|
||||
export function isAdvancedRenderingToneMappingMode(value) {
|
||||
return ADVANCED_RENDERING_TONE_MAPPING_MODES.includes(value);
|
||||
}
|
||||
export function isBoxVolumeRenderPath(value) {
|
||||
return BOX_VOLUME_RENDER_PATHS.includes(value);
|
||||
}
|
||||
export function isAdvancedRenderingWaterReflectionMode(value) {
|
||||
return ADVANCED_RENDERING_WATER_REFLECTION_MODES.includes(value);
|
||||
}
|
||||
export function createDefaultAdvancedRenderingSettings() {
|
||||
return {
|
||||
enabled: false,
|
||||
shadows: {
|
||||
enabled: false,
|
||||
mapSize: DEFAULT_ADVANCED_RENDERING_SHADOW_MAP_SIZE,
|
||||
type: DEFAULT_ADVANCED_RENDERING_SHADOW_TYPE,
|
||||
bias: DEFAULT_ADVANCED_RENDERING_SHADOW_BIAS
|
||||
},
|
||||
ambientOcclusion: {
|
||||
enabled: false,
|
||||
intensity: DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_INTENSITY,
|
||||
radius: DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_RADIUS,
|
||||
samples: DEFAULT_ADVANCED_RENDERING_AMBIENT_OCCLUSION_SAMPLES
|
||||
},
|
||||
bloom: {
|
||||
enabled: false,
|
||||
intensity: DEFAULT_ADVANCED_RENDERING_BLOOM_INTENSITY,
|
||||
threshold: DEFAULT_ADVANCED_RENDERING_BLOOM_THRESHOLD,
|
||||
radius: DEFAULT_ADVANCED_RENDERING_BLOOM_RADIUS
|
||||
},
|
||||
toneMapping: {
|
||||
mode: DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_MODE,
|
||||
exposure: DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_EXPOSURE
|
||||
},
|
||||
depthOfField: {
|
||||
enabled: false,
|
||||
focusDistance: DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_FOCUS_DISTANCE,
|
||||
focalLength: DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_FOCAL_LENGTH,
|
||||
bokehScale: DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_BOKEH_SCALE
|
||||
},
|
||||
fogPath: DEFAULT_BOX_VOLUME_RENDER_PATH,
|
||||
waterPath: DEFAULT_BOX_VOLUME_RENDER_PATH,
|
||||
waterReflectionMode: DEFAULT_ADVANCED_RENDERING_WATER_REFLECTION_MODE
|
||||
};
|
||||
}
|
||||
export function createDefaultWorldSettings() {
|
||||
return {
|
||||
background: {
|
||||
mode: "solid",
|
||||
colorHex: DEFAULT_SOLID_BACKGROUND_COLOR
|
||||
},
|
||||
ambientLight: {
|
||||
colorHex: "#f7f1e8",
|
||||
intensity: 1
|
||||
},
|
||||
sunLight: {
|
||||
colorHex: "#fff1d5",
|
||||
intensity: 1.75,
|
||||
direction: {
|
||||
...DEFAULT_SUN_DIRECTION
|
||||
}
|
||||
},
|
||||
advancedRendering: createDefaultAdvancedRenderingSettings()
|
||||
};
|
||||
}
|
||||
export function isHexColorString(value) {
|
||||
return /^#[0-9a-f]{6}$/i.test(value);
|
||||
}
|
||||
export function isWorldBackgroundMode(value) {
|
||||
return value === "solid" || value === "verticalGradient" || value === "image";
|
||||
}
|
||||
export function cloneWorldBackgroundSettings(background) {
|
||||
if (background.mode === "solid") {
|
||||
return {
|
||||
mode: "solid",
|
||||
colorHex: background.colorHex
|
||||
};
|
||||
}
|
||||
if (background.mode === "verticalGradient") {
|
||||
return {
|
||||
mode: "verticalGradient",
|
||||
topColorHex: background.topColorHex,
|
||||
bottomColorHex: background.bottomColorHex
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: "image",
|
||||
assetId: background.assetId,
|
||||
environmentIntensity: background.environmentIntensity
|
||||
};
|
||||
}
|
||||
export function cloneWorldSettings(world) {
|
||||
return {
|
||||
background: cloneWorldBackgroundSettings(world.background),
|
||||
ambientLight: {
|
||||
...world.ambientLight
|
||||
},
|
||||
sunLight: {
|
||||
...world.sunLight,
|
||||
direction: {
|
||||
...world.sunLight.direction
|
||||
}
|
||||
},
|
||||
advancedRendering: cloneAdvancedRenderingSettings(world.advancedRendering)
|
||||
};
|
||||
}
|
||||
export function cloneAdvancedRenderingSettings(settings) {
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
shadows: {
|
||||
...settings.shadows
|
||||
},
|
||||
ambientOcclusion: {
|
||||
...settings.ambientOcclusion
|
||||
},
|
||||
bloom: {
|
||||
...settings.bloom
|
||||
},
|
||||
toneMapping: {
|
||||
...settings.toneMapping
|
||||
},
|
||||
depthOfField: {
|
||||
...settings.depthOfField
|
||||
},
|
||||
fogPath: settings.fogPath,
|
||||
waterPath: settings.waterPath,
|
||||
waterReflectionMode: settings.waterReflectionMode
|
||||
};
|
||||
}
|
||||
export function areWorldBackgroundSettingsEqual(left, right) {
|
||||
if (left.mode !== right.mode) {
|
||||
return false;
|
||||
}
|
||||
if (left.mode === "solid" && right.mode === "solid") {
|
||||
return left.colorHex === right.colorHex;
|
||||
}
|
||||
if (left.mode === "verticalGradient" && right.mode === "verticalGradient") {
|
||||
return left.topColorHex === right.topColorHex && left.bottomColorHex === right.bottomColorHex;
|
||||
}
|
||||
return left.mode === "image" && right.mode === "image" && left.assetId === right.assetId && left.environmentIntensity === right.environmentIntensity;
|
||||
}
|
||||
export function areWorldSettingsEqual(left, right) {
|
||||
return (areWorldBackgroundSettingsEqual(left.background, right.background) &&
|
||||
left.ambientLight.colorHex === right.ambientLight.colorHex &&
|
||||
left.ambientLight.intensity === right.ambientLight.intensity &&
|
||||
left.sunLight.colorHex === right.sunLight.colorHex &&
|
||||
left.sunLight.intensity === right.sunLight.intensity &&
|
||||
left.sunLight.direction.x === right.sunLight.direction.x &&
|
||||
left.sunLight.direction.y === right.sunLight.direction.y &&
|
||||
left.sunLight.direction.z === right.sunLight.direction.z &&
|
||||
areAdvancedRenderingSettingsEqual(left.advancedRendering, right.advancedRendering));
|
||||
}
|
||||
export function areAdvancedRenderingSettingsEqual(left, right) {
|
||||
return (left.enabled === right.enabled &&
|
||||
left.shadows.enabled === right.shadows.enabled &&
|
||||
left.shadows.mapSize === right.shadows.mapSize &&
|
||||
left.shadows.type === right.shadows.type &&
|
||||
left.shadows.bias === right.shadows.bias &&
|
||||
left.ambientOcclusion.enabled === right.ambientOcclusion.enabled &&
|
||||
left.ambientOcclusion.intensity === right.ambientOcclusion.intensity &&
|
||||
left.ambientOcclusion.radius === right.ambientOcclusion.radius &&
|
||||
left.ambientOcclusion.samples === right.ambientOcclusion.samples &&
|
||||
left.bloom.enabled === right.bloom.enabled &&
|
||||
left.bloom.intensity === right.bloom.intensity &&
|
||||
left.bloom.threshold === right.bloom.threshold &&
|
||||
left.bloom.radius === right.bloom.radius &&
|
||||
left.toneMapping.mode === right.toneMapping.mode &&
|
||||
left.toneMapping.exposure === right.toneMapping.exposure &&
|
||||
left.depthOfField.enabled === right.depthOfField.enabled &&
|
||||
left.depthOfField.focusDistance === right.depthOfField.focusDistance &&
|
||||
left.depthOfField.focalLength === right.depthOfField.focalLength &&
|
||||
left.depthOfField.bokehScale === right.depthOfField.bokehScale &&
|
||||
left.fogPath === right.fogPath &&
|
||||
left.waterPath === right.waterPath &&
|
||||
left.waterReflectionMode === right.waterReflectionMode);
|
||||
}
|
||||
export function changeWorldBackgroundMode(background, mode, imageAssetId) {
|
||||
if (mode === "image") {
|
||||
if (imageAssetId === undefined || imageAssetId.trim().length === 0) {
|
||||
if (background.mode === "image") {
|
||||
return cloneWorldBackgroundSettings(background);
|
||||
}
|
||||
throw new Error("An image asset must be selected to use an image background.");
|
||||
}
|
||||
return {
|
||||
mode: "image",
|
||||
assetId: imageAssetId,
|
||||
environmentIntensity: background.mode === "image" ? background.environmentIntensity : 0.5
|
||||
};
|
||||
}
|
||||
if (background.mode === mode) {
|
||||
return cloneWorldBackgroundSettings(background);
|
||||
}
|
||||
if (mode === "solid") {
|
||||
return {
|
||||
mode: "solid",
|
||||
colorHex: background.mode === "solid"
|
||||
? background.colorHex
|
||||
: background.mode === "verticalGradient"
|
||||
? background.topColorHex
|
||||
: DEFAULT_SOLID_BACKGROUND_COLOR
|
||||
};
|
||||
}
|
||||
if (background.mode === "solid") {
|
||||
return {
|
||||
mode: "verticalGradient",
|
||||
topColorHex: background.colorHex,
|
||||
bottomColorHex: DEFAULT_GRADIENT_BOTTOM_COLOR
|
||||
};
|
||||
}
|
||||
if (background.mode === "verticalGradient") {
|
||||
return {
|
||||
mode: "verticalGradient",
|
||||
topColorHex: background.topColorHex,
|
||||
bottomColorHex: background.bottomColorHex
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: "verticalGradient",
|
||||
topColorHex: DEFAULT_GRADIENT_TOP_COLOR,
|
||||
bottomColorHex: DEFAULT_GRADIENT_BOTTOM_COLOR
|
||||
};
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { isHexColorString } from "../document/world-settings";
|
||||
export const PLAYER_START_COLLIDER_MODES = ["capsule", "box", "none"];
|
||||
export const ENTITY_KIND_ORDER = [
|
||||
"pointLight",
|
||||
"spotLight",
|
||||
"playerStart",
|
||||
"soundEmitter",
|
||||
"triggerVolume",
|
||||
"teleportTarget",
|
||||
"interactable"
|
||||
];
|
||||
export const DEFAULT_POINT_LIGHT_POSITION = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
export const DEFAULT_POINT_LIGHT_COLOR_HEX = "#ffffff";
|
||||
export const DEFAULT_POINT_LIGHT_INTENSITY = 1.25;
|
||||
export const DEFAULT_POINT_LIGHT_DISTANCE = 8;
|
||||
export const DEFAULT_SPOT_LIGHT_POSITION = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
export const DEFAULT_SPOT_LIGHT_DIRECTION = {
|
||||
x: 0,
|
||||
y: -1,
|
||||
z: 0
|
||||
};
|
||||
export const DEFAULT_SPOT_LIGHT_COLOR_HEX = "#ffffff";
|
||||
export const DEFAULT_SPOT_LIGHT_INTENSITY = 1.5;
|
||||
export const DEFAULT_SPOT_LIGHT_DISTANCE = 12;
|
||||
export const DEFAULT_SPOT_LIGHT_ANGLE_DEGREES = 35;
|
||||
export const DEFAULT_ENTITY_POSITION = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
export const DEFAULT_PLAYER_START_POSITION = DEFAULT_ENTITY_POSITION;
|
||||
export const DEFAULT_PLAYER_START_YAW_DEGREES = 0;
|
||||
export const DEFAULT_PLAYER_START_COLLIDER_MODE = "capsule";
|
||||
export const DEFAULT_PLAYER_START_EYE_HEIGHT = 1.6;
|
||||
export const DEFAULT_PLAYER_START_CAPSULE_RADIUS = 0.3;
|
||||
export const DEFAULT_PLAYER_START_CAPSULE_HEIGHT = 1.8;
|
||||
export const DEFAULT_PLAYER_START_BOX_SIZE = {
|
||||
x: 0.6,
|
||||
y: 1.8,
|
||||
z: 0.6
|
||||
};
|
||||
export const DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID = null;
|
||||
export const DEFAULT_SOUND_EMITTER_VOLUME = 1;
|
||||
export const DEFAULT_SOUND_EMITTER_GAIN = DEFAULT_SOUND_EMITTER_VOLUME;
|
||||
export const DEFAULT_SOUND_EMITTER_REF_DISTANCE = 6;
|
||||
export const DEFAULT_SOUND_EMITTER_RADIUS = DEFAULT_SOUND_EMITTER_REF_DISTANCE;
|
||||
export const DEFAULT_SOUND_EMITTER_MAX_DISTANCE = 24;
|
||||
export const DEFAULT_TRIGGER_VOLUME_SIZE = {
|
||||
x: 2,
|
||||
y: 2,
|
||||
z: 2
|
||||
};
|
||||
export const DEFAULT_TELEPORT_TARGET_YAW_DEGREES = 0;
|
||||
export const DEFAULT_INTERACTABLE_RADIUS = 1.5;
|
||||
export const DEFAULT_INTERACTABLE_PROMPT = "Use";
|
||||
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;
|
||||
}
|
||||
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 assertPositiveFiniteNumber(value, label) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${label} must be a finite number greater than zero.`);
|
||||
}
|
||||
}
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
function assertNonNegativeFiniteNumber(value, label) {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${label} must be a finite number greater than or equal to zero.`);
|
||||
}
|
||||
}
|
||||
function assertHexColorString(value, label) {
|
||||
if (!isHexColorString(value)) {
|
||||
throw new Error(`${label} must use #RRGGBB format.`);
|
||||
}
|
||||
}
|
||||
function assertNonZeroVec3(vector, label) {
|
||||
if (vector.x === 0 && vector.y === 0 && vector.z === 0) {
|
||||
throw new Error(`${label} must not be the zero vector.`);
|
||||
}
|
||||
}
|
||||
function assertBoolean(value, label) {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`${label} must be a boolean.`);
|
||||
}
|
||||
}
|
||||
export function isPlayerStartColliderMode(value) {
|
||||
return PLAYER_START_COLLIDER_MODES.includes(value);
|
||||
}
|
||||
export function clonePlayerStartColliderSettings(settings) {
|
||||
return {
|
||||
mode: settings.mode,
|
||||
eyeHeight: settings.eyeHeight,
|
||||
capsuleRadius: settings.capsuleRadius,
|
||||
capsuleHeight: settings.capsuleHeight,
|
||||
boxSize: cloneVec3(settings.boxSize)
|
||||
};
|
||||
}
|
||||
export function getPlayerStartColliderHeight(settings) {
|
||||
switch (settings.mode) {
|
||||
case "capsule":
|
||||
return settings.capsuleHeight;
|
||||
case "box":
|
||||
return settings.boxSize.y;
|
||||
case "none":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function createPlayerStartColliderSettings(overrides = {}) {
|
||||
const mode = overrides.mode ?? DEFAULT_PLAYER_START_COLLIDER_MODE;
|
||||
const eyeHeight = overrides.eyeHeight ?? DEFAULT_PLAYER_START_EYE_HEIGHT;
|
||||
const capsuleRadius = overrides.capsuleRadius ?? DEFAULT_PLAYER_START_CAPSULE_RADIUS;
|
||||
const capsuleHeight = overrides.capsuleHeight ?? DEFAULT_PLAYER_START_CAPSULE_HEIGHT;
|
||||
const boxSize = cloneVec3(overrides.boxSize ?? DEFAULT_PLAYER_START_BOX_SIZE);
|
||||
if (!isPlayerStartColliderMode(mode)) {
|
||||
throw new Error("Player Start collider mode must be capsule, box, or none.");
|
||||
}
|
||||
assertPositiveFiniteNumber(eyeHeight, "Player Start eye height");
|
||||
assertPositiveFiniteNumber(capsuleRadius, "Player Start capsule radius");
|
||||
assertPositiveFiniteNumber(capsuleHeight, "Player Start capsule height");
|
||||
assertPositiveFiniteVec3(boxSize, "Player Start box size");
|
||||
if (capsuleHeight < capsuleRadius * 2) {
|
||||
throw new Error("Player Start capsule height must be at least twice the capsule radius.");
|
||||
}
|
||||
if (mode === "capsule" && eyeHeight > capsuleHeight) {
|
||||
throw new Error("Player Start eye height must be less than or equal to the capsule height.");
|
||||
}
|
||||
if (mode === "box" && eyeHeight > boxSize.y) {
|
||||
throw new Error("Player Start eye height must be less than or equal to the box height.");
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
eyeHeight,
|
||||
capsuleRadius,
|
||||
capsuleHeight,
|
||||
boxSize
|
||||
};
|
||||
}
|
||||
function normalizeSoundEmitterAudioAssetId(audioAssetId) {
|
||||
if (audioAssetId === undefined || audioAssetId === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmedAudioAssetId = audioAssetId.trim();
|
||||
if (trimmedAudioAssetId.length === 0) {
|
||||
throw new Error("Sound Emitter audio asset id must be non-empty when authored.");
|
||||
}
|
||||
return trimmedAudioAssetId;
|
||||
}
|
||||
export function normalizeEntityName(name) {
|
||||
if (name === undefined || name === null) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
return trimmedName.length === 0 ? undefined : trimmedName;
|
||||
}
|
||||
export function normalizeYawDegrees(yawDegrees) {
|
||||
const normalizedYaw = yawDegrees % 360;
|
||||
return normalizedYaw < 0 ? normalizedYaw + 360 : normalizedYaw;
|
||||
}
|
||||
export function normalizeInteractablePrompt(prompt) {
|
||||
const normalizedPrompt = prompt.trim();
|
||||
if (normalizedPrompt.length === 0) {
|
||||
throw new Error("Interactable prompt must be non-empty.");
|
||||
}
|
||||
return normalizedPrompt;
|
||||
}
|
||||
export function createPointLightEntity(overrides = {}) {
|
||||
const position = cloneVec3(overrides.position ?? DEFAULT_POINT_LIGHT_POSITION);
|
||||
const colorHex = overrides.colorHex ?? DEFAULT_POINT_LIGHT_COLOR_HEX;
|
||||
const intensity = overrides.intensity ?? DEFAULT_POINT_LIGHT_INTENSITY;
|
||||
const distance = overrides.distance ?? DEFAULT_POINT_LIGHT_DISTANCE;
|
||||
assertFiniteVec3(position, "Point Light position");
|
||||
assertHexColorString(colorHex, "Point Light color");
|
||||
assertNonNegativeFiniteNumber(intensity, "Point Light intensity");
|
||||
assertPositiveFiniteNumber(distance, "Point Light distance");
|
||||
return {
|
||||
id: overrides.id ?? createOpaqueId("entity-point-light"),
|
||||
kind: "pointLight",
|
||||
name: normalizeEntityName(overrides.name),
|
||||
position,
|
||||
colorHex,
|
||||
intensity,
|
||||
distance
|
||||
};
|
||||
}
|
||||
export function createSpotLightEntity(overrides = {}) {
|
||||
const position = cloneVec3(overrides.position ?? DEFAULT_SPOT_LIGHT_POSITION);
|
||||
const direction = cloneVec3(overrides.direction ?? DEFAULT_SPOT_LIGHT_DIRECTION);
|
||||
const colorHex = overrides.colorHex ?? DEFAULT_SPOT_LIGHT_COLOR_HEX;
|
||||
const intensity = overrides.intensity ?? DEFAULT_SPOT_LIGHT_INTENSITY;
|
||||
const distance = overrides.distance ?? DEFAULT_SPOT_LIGHT_DISTANCE;
|
||||
const angleDegrees = overrides.angleDegrees ?? DEFAULT_SPOT_LIGHT_ANGLE_DEGREES;
|
||||
assertFiniteVec3(position, "Spot Light position");
|
||||
assertFiniteVec3(direction, "Spot Light direction");
|
||||
assertNonZeroVec3(direction, "Spot Light direction");
|
||||
assertHexColorString(colorHex, "Spot Light color");
|
||||
assertNonNegativeFiniteNumber(intensity, "Spot Light intensity");
|
||||
assertPositiveFiniteNumber(distance, "Spot Light distance");
|
||||
if (!Number.isFinite(angleDegrees) || angleDegrees <= 0 || angleDegrees >= 180) {
|
||||
throw new Error("Spot Light angle must be a finite degree value between 0 and 180.");
|
||||
}
|
||||
return {
|
||||
id: overrides.id ?? createOpaqueId("entity-spot-light"),
|
||||
kind: "spotLight",
|
||||
name: normalizeEntityName(overrides.name),
|
||||
position,
|
||||
direction,
|
||||
colorHex,
|
||||
intensity,
|
||||
distance,
|
||||
angleDegrees
|
||||
};
|
||||
}
|
||||
export function createPlayerStartEntity(overrides = {}) {
|
||||
const position = cloneVec3(overrides.position ?? DEFAULT_PLAYER_START_POSITION);
|
||||
const yawDegrees = overrides.yawDegrees ?? DEFAULT_PLAYER_START_YAW_DEGREES;
|
||||
const collider = createPlayerStartColliderSettings(overrides.collider);
|
||||
assertFiniteVec3(position, "Player Start position");
|
||||
if (!Number.isFinite(yawDegrees)) {
|
||||
throw new Error("Player Start yaw must be a finite number.");
|
||||
}
|
||||
return {
|
||||
id: overrides.id ?? createOpaqueId("entity-player-start"),
|
||||
kind: "playerStart",
|
||||
name: normalizeEntityName(overrides.name),
|
||||
position,
|
||||
yawDegrees: normalizeYawDegrees(yawDegrees),
|
||||
collider
|
||||
};
|
||||
}
|
||||
export function createSoundEmitterEntity(overrides = {}) {
|
||||
const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION);
|
||||
const audioAssetId = normalizeSoundEmitterAudioAssetId(overrides.audioAssetId ?? DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID);
|
||||
const volume = overrides.volume ?? DEFAULT_SOUND_EMITTER_VOLUME;
|
||||
const refDistance = overrides.refDistance ?? DEFAULT_SOUND_EMITTER_REF_DISTANCE;
|
||||
const maxDistance = overrides.maxDistance ?? DEFAULT_SOUND_EMITTER_MAX_DISTANCE;
|
||||
const autoplay = overrides.autoplay ?? false;
|
||||
const loop = overrides.loop ?? false;
|
||||
assertFiniteVec3(position, "Sound Emitter position");
|
||||
assertNonNegativeFiniteNumber(volume, "Sound Emitter volume");
|
||||
assertPositiveFiniteNumber(refDistance, "Sound Emitter ref distance");
|
||||
assertPositiveFiniteNumber(maxDistance, "Sound Emitter max distance");
|
||||
if (maxDistance < refDistance) {
|
||||
throw new Error("Sound Emitter max distance must be greater than or equal to ref distance.");
|
||||
}
|
||||
assertBoolean(autoplay, "Sound Emitter autoplay");
|
||||
assertBoolean(loop, "Sound Emitter loop");
|
||||
return {
|
||||
id: overrides.id ?? createOpaqueId("entity-sound-emitter"),
|
||||
kind: "soundEmitter",
|
||||
name: normalizeEntityName(overrides.name),
|
||||
position,
|
||||
audioAssetId,
|
||||
volume,
|
||||
refDistance,
|
||||
maxDistance,
|
||||
autoplay,
|
||||
loop
|
||||
};
|
||||
}
|
||||
export function createTriggerVolumeEntity(overrides = {}) {
|
||||
const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION);
|
||||
const size = cloneVec3(overrides.size ?? DEFAULT_TRIGGER_VOLUME_SIZE);
|
||||
const triggerOnEnter = overrides.triggerOnEnter ?? true;
|
||||
const triggerOnExit = overrides.triggerOnExit ?? false;
|
||||
assertFiniteVec3(position, "Trigger Volume position");
|
||||
assertPositiveFiniteVec3(size, "Trigger Volume size");
|
||||
assertBoolean(triggerOnEnter, "Trigger Volume triggerOnEnter");
|
||||
assertBoolean(triggerOnExit, "Trigger Volume triggerOnExit");
|
||||
return {
|
||||
id: overrides.id ?? createOpaqueId("entity-trigger-volume"),
|
||||
kind: "triggerVolume",
|
||||
name: normalizeEntityName(overrides.name),
|
||||
position,
|
||||
size,
|
||||
triggerOnEnter,
|
||||
triggerOnExit
|
||||
};
|
||||
}
|
||||
export function createTeleportTargetEntity(overrides = {}) {
|
||||
const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION);
|
||||
const yawDegrees = overrides.yawDegrees ?? DEFAULT_TELEPORT_TARGET_YAW_DEGREES;
|
||||
assertFiniteVec3(position, "Teleport Target position");
|
||||
if (!Number.isFinite(yawDegrees)) {
|
||||
throw new Error("Teleport Target yaw must be a finite number.");
|
||||
}
|
||||
return {
|
||||
id: overrides.id ?? createOpaqueId("entity-teleport-target"),
|
||||
kind: "teleportTarget",
|
||||
name: normalizeEntityName(overrides.name),
|
||||
position,
|
||||
yawDegrees: normalizeYawDegrees(yawDegrees)
|
||||
};
|
||||
}
|
||||
export function createInteractableEntity(overrides = {}) {
|
||||
const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION);
|
||||
const radius = overrides.radius ?? DEFAULT_INTERACTABLE_RADIUS;
|
||||
const prompt = normalizeInteractablePrompt(overrides.prompt ?? DEFAULT_INTERACTABLE_PROMPT);
|
||||
const enabled = overrides.enabled ?? true;
|
||||
assertFiniteVec3(position, "Interactable position");
|
||||
assertPositiveFiniteNumber(radius, "Interactable radius");
|
||||
assertBoolean(enabled, "Interactable enabled");
|
||||
return {
|
||||
id: overrides.id ?? createOpaqueId("entity-interactable"),
|
||||
kind: "interactable",
|
||||
name: normalizeEntityName(overrides.name),
|
||||
position,
|
||||
radius,
|
||||
prompt,
|
||||
enabled
|
||||
};
|
||||
}
|
||||
export const ENTITY_REGISTRY = {
|
||||
pointLight: {
|
||||
kind: "pointLight",
|
||||
label: "Point Light",
|
||||
description: "Authored local point light that illuminates nearby geometry in a spherical radius.",
|
||||
createDefaultEntity: createPointLightEntity
|
||||
},
|
||||
spotLight: {
|
||||
kind: "spotLight",
|
||||
label: "Spot Light",
|
||||
description: "Authored local spotlight with an explicit direction and cone angle.",
|
||||
createDefaultEntity: createSpotLightEntity
|
||||
},
|
||||
playerStart: {
|
||||
kind: "playerStart",
|
||||
label: "Player Start",
|
||||
description: "Primary authored spawn point for first-person runtime navigation.",
|
||||
createDefaultEntity: createPlayerStartEntity
|
||||
},
|
||||
soundEmitter: {
|
||||
kind: "soundEmitter",
|
||||
label: "Sound Emitter",
|
||||
description: "Authored positional audio source wired to an audio asset and configurable for looping, volume, and distance falloff.",
|
||||
createDefaultEntity: createSoundEmitterEntity
|
||||
},
|
||||
triggerVolume: {
|
||||
kind: "triggerVolume",
|
||||
label: "Trigger Volume",
|
||||
description: "Axis-aligned authored trigger volume for enter and exit events.",
|
||||
createDefaultEntity: createTriggerVolumeEntity
|
||||
},
|
||||
teleportTarget: {
|
||||
kind: "teleportTarget",
|
||||
label: "Teleport Target",
|
||||
description: "Explicit authored teleport destination with a facing direction.",
|
||||
createDefaultEntity: createTeleportTargetEntity
|
||||
},
|
||||
interactable: {
|
||||
kind: "interactable",
|
||||
label: "Interactable",
|
||||
description: "Explicit authored interaction point for later click and use behavior.",
|
||||
createDefaultEntity: createInteractableEntity
|
||||
}
|
||||
};
|
||||
export function isEntityKind(value) {
|
||||
return typeof value === "string" && Object.prototype.hasOwnProperty.call(ENTITY_REGISTRY, value);
|
||||
}
|
||||
export function getEntityRegistryEntry(kind) {
|
||||
return ENTITY_REGISTRY[kind];
|
||||
}
|
||||
export function createDefaultEntityInstance(kind, overrides = {}) {
|
||||
switch (kind) {
|
||||
case "pointLight":
|
||||
return createPointLightEntity(overrides);
|
||||
case "spotLight":
|
||||
return createSpotLightEntity(overrides);
|
||||
case "playerStart":
|
||||
return createPlayerStartEntity(overrides);
|
||||
case "soundEmitter":
|
||||
return createSoundEmitterEntity(overrides);
|
||||
case "triggerVolume":
|
||||
return createTriggerVolumeEntity(overrides);
|
||||
case "teleportTarget":
|
||||
return createTeleportTargetEntity(overrides);
|
||||
case "interactable":
|
||||
return createInteractableEntity(overrides);
|
||||
}
|
||||
}
|
||||
export function cloneEntityInstance(entity) {
|
||||
switch (entity.kind) {
|
||||
case "pointLight":
|
||||
return createPointLightEntity(entity);
|
||||
case "spotLight":
|
||||
return createSpotLightEntity(entity);
|
||||
case "playerStart":
|
||||
return createPlayerStartEntity(entity);
|
||||
case "soundEmitter":
|
||||
return createSoundEmitterEntity(entity);
|
||||
case "triggerVolume":
|
||||
return createTriggerVolumeEntity(entity);
|
||||
case "teleportTarget":
|
||||
return createTeleportTargetEntity(entity);
|
||||
case "interactable":
|
||||
return createInteractableEntity(entity);
|
||||
}
|
||||
}
|
||||
export function cloneEntityRegistry(entities) {
|
||||
return Object.fromEntries(Object.entries(entities).map(([entityId, entity]) => [entityId, cloneEntityInstance(entity)]));
|
||||
}
|
||||
export function areEntityInstancesEqual(left, right) {
|
||||
if (left.kind !== right.kind || left.id !== right.id || left.name !== right.name || !areVec3Equal(left.position, right.position)) {
|
||||
return false;
|
||||
}
|
||||
switch (left.kind) {
|
||||
case "pointLight": {
|
||||
const typedRight = right;
|
||||
return (left.colorHex === typedRight.colorHex &&
|
||||
left.intensity === typedRight.intensity &&
|
||||
left.distance === typedRight.distance);
|
||||
}
|
||||
case "spotLight": {
|
||||
const typedRight = right;
|
||||
return (areVec3Equal(left.direction, typedRight.direction) &&
|
||||
left.colorHex === typedRight.colorHex &&
|
||||
left.intensity === typedRight.intensity &&
|
||||
left.distance === typedRight.distance &&
|
||||
left.angleDegrees === typedRight.angleDegrees);
|
||||
}
|
||||
case "playerStart": {
|
||||
const typedRight = right;
|
||||
return (left.yawDegrees === typedRight.yawDegrees &&
|
||||
left.collider.mode === typedRight.collider.mode &&
|
||||
left.collider.eyeHeight === typedRight.collider.eyeHeight &&
|
||||
left.collider.capsuleRadius === typedRight.collider.capsuleRadius &&
|
||||
left.collider.capsuleHeight === typedRight.collider.capsuleHeight &&
|
||||
areVec3Equal(left.collider.boxSize, typedRight.collider.boxSize));
|
||||
}
|
||||
case "soundEmitter": {
|
||||
const typedRight = right;
|
||||
return (left.audioAssetId === typedRight.audioAssetId &&
|
||||
left.volume === typedRight.volume &&
|
||||
left.refDistance === typedRight.refDistance &&
|
||||
left.maxDistance === typedRight.maxDistance &&
|
||||
left.autoplay === typedRight.autoplay &&
|
||||
left.loop === typedRight.loop);
|
||||
}
|
||||
case "triggerVolume": {
|
||||
const typedRight = right;
|
||||
return (areVec3Equal(left.size, typedRight.size) &&
|
||||
left.triggerOnEnter === typedRight.triggerOnEnter &&
|
||||
left.triggerOnExit === typedRight.triggerOnExit);
|
||||
}
|
||||
case "teleportTarget": {
|
||||
const typedRight = right;
|
||||
return left.yawDegrees === typedRight.yawDegrees;
|
||||
}
|
||||
case "interactable": {
|
||||
const typedRight = right;
|
||||
return left.radius === typedRight.radius && left.prompt === typedRight.prompt && left.enabled === typedRight.enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
export function compareEntityInstances(left, right) {
|
||||
const leftOrder = ENTITY_KIND_ORDER.indexOf(left.kind);
|
||||
const rightOrder = ENTITY_KIND_ORDER.indexOf(right.kind);
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
export function getEntityInstances(entities) {
|
||||
return Object.values(entities).sort(compareEntityInstances);
|
||||
}
|
||||
export function getEntitiesOfKind(entities, kind) {
|
||||
return getEntityInstances(entities).filter((entity) => entity.kind === kind);
|
||||
}
|
||||
export function getPlayerStartEntities(entities) {
|
||||
return getEntitiesOfKind(entities, "playerStart");
|
||||
}
|
||||
export function getPrimaryPlayerStartEntity(entities) {
|
||||
return getPlayerStartEntities(entities)[0] ?? null;
|
||||
}
|
||||
export function getEntityKindLabel(kind) {
|
||||
return getEntityRegistryEntry(kind).label;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { compareEntityInstances, getEntityKindLabel, getEntityInstances } from "./entity-instances";
|
||||
function getSortedEntitiesByKind(entities, kind) {
|
||||
return Object.values(entities)
|
||||
.filter((entity) => entity.kind === kind)
|
||||
.sort(compareEntityInstances);
|
||||
}
|
||||
function getSoundEmitterLabelSuffix(entity, assets) {
|
||||
if (entity.audioAssetId === null) {
|
||||
return "No Audio Asset";
|
||||
}
|
||||
const asset = assets?.[entity.audioAssetId];
|
||||
if (asset === undefined) {
|
||||
return `Missing Audio Asset (${entity.audioAssetId})`;
|
||||
}
|
||||
if (asset.kind !== "audio") {
|
||||
return `Invalid Audio Asset (${asset.sourceName})`;
|
||||
}
|
||||
return asset.sourceName;
|
||||
}
|
||||
export function getEntityDisplayLabel(entity, entities, assets) {
|
||||
if (entity.name !== undefined) {
|
||||
return entity.name;
|
||||
}
|
||||
const typedEntities = getSortedEntitiesByKind(entities, entity.kind);
|
||||
const entityIndex = typedEntities.findIndex((candidate) => candidate.id === entity.id);
|
||||
const baseLabel = getEntityKindLabel(entity.kind);
|
||||
const numberedLabel = entityIndex <= 0 ? baseLabel : `${baseLabel} ${entityIndex + 1}`;
|
||||
if (entity.kind !== "soundEmitter" || assets === undefined) {
|
||||
return numberedLabel;
|
||||
}
|
||||
return `${numberedLabel} · ${getSoundEmitterLabelSuffix(entity, assets)}`;
|
||||
}
|
||||
export function getEntityDisplayLabelById(entityId, entities, assets) {
|
||||
const entity = entities[entityId];
|
||||
if (entity === undefined) {
|
||||
return "Entity";
|
||||
}
|
||||
return getEntityDisplayLabel(entity, entities, assets);
|
||||
}
|
||||
export function getSortedEntityDisplayLabels(entities, assets) {
|
||||
return getEntityInstances(entities).map((entity) => ({
|
||||
entity,
|
||||
label: getEntityDisplayLabel(entity, entities, assets)
|
||||
}));
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { Euler, MathUtils, Quaternion, Vector3 } from "three";
|
||||
import { BOX_FACE_IDS } from "../document/brushes";
|
||||
import { getBoxBrushFaceVertexIds, getBoxBrushLocalVertexPosition } from "./box-brush-mesh";
|
||||
const BOX_VERTEX_SIGNS = {
|
||||
negX_negY_negZ: { x: -1, y: -1, z: -1 },
|
||||
posX_negY_negZ: { x: 1, y: -1, z: -1 },
|
||||
negX_posY_negZ: { x: -1, y: 1, z: -1 },
|
||||
posX_posY_negZ: { x: 1, y: 1, z: -1 },
|
||||
negX_negY_posZ: { x: -1, y: -1, z: 1 },
|
||||
posX_negY_posZ: { x: 1, y: -1, z: 1 },
|
||||
negX_posY_posZ: { x: -1, y: 1, z: 1 },
|
||||
posX_posY_posZ: { x: 1, y: 1, z: 1 }
|
||||
};
|
||||
const BOX_FACE_TRANSFORM_META = {
|
||||
posX: { axis: "x", sign: 1 },
|
||||
negX: { axis: "x", sign: -1 },
|
||||
posY: { axis: "y", sign: 1 },
|
||||
negY: { axis: "y", sign: -1 },
|
||||
posZ: { axis: "z", sign: 1 },
|
||||
negZ: { axis: "z", sign: -1 }
|
||||
};
|
||||
const BOX_EDGE_TRANSFORM_META = {
|
||||
edgeX_negY_negZ: {
|
||||
axis: "x",
|
||||
signs: { x: null, y: -1, z: -1 }
|
||||
},
|
||||
edgeX_posY_negZ: {
|
||||
axis: "x",
|
||||
signs: { x: null, y: 1, z: -1 }
|
||||
},
|
||||
edgeX_negY_posZ: {
|
||||
axis: "x",
|
||||
signs: { x: null, y: -1, z: 1 }
|
||||
},
|
||||
edgeX_posY_posZ: {
|
||||
axis: "x",
|
||||
signs: { x: null, y: 1, z: 1 }
|
||||
},
|
||||
edgeY_negX_negZ: {
|
||||
axis: "y",
|
||||
signs: { x: -1, y: null, z: -1 }
|
||||
},
|
||||
edgeY_posX_negZ: {
|
||||
axis: "y",
|
||||
signs: { x: 1, y: null, z: -1 }
|
||||
},
|
||||
edgeY_negX_posZ: {
|
||||
axis: "y",
|
||||
signs: { x: -1, y: null, z: 1 }
|
||||
},
|
||||
edgeY_posX_posZ: {
|
||||
axis: "y",
|
||||
signs: { x: 1, y: null, z: 1 }
|
||||
},
|
||||
edgeZ_negX_negY: {
|
||||
axis: "z",
|
||||
signs: { x: -1, y: -1, z: null }
|
||||
},
|
||||
edgeZ_posX_negY: {
|
||||
axis: "z",
|
||||
signs: { x: 1, y: -1, z: null }
|
||||
},
|
||||
edgeZ_negX_posY: {
|
||||
axis: "z",
|
||||
signs: { x: -1, y: 1, z: null }
|
||||
},
|
||||
edgeZ_posX_posY: {
|
||||
axis: "z",
|
||||
signs: { x: 1, y: 1, z: null }
|
||||
}
|
||||
};
|
||||
const BOX_EDGE_VERTEX_IDS = {
|
||||
edgeX_negY_negZ: { start: "negX_negY_negZ", end: "posX_negY_negZ" },
|
||||
edgeX_posY_negZ: { start: "negX_posY_negZ", end: "posX_posY_negZ" },
|
||||
edgeX_negY_posZ: { start: "negX_negY_posZ", end: "posX_negY_posZ" },
|
||||
edgeX_posY_posZ: { start: "negX_posY_posZ", end: "posX_posY_posZ" },
|
||||
edgeY_negX_negZ: { start: "negX_negY_negZ", end: "negX_posY_negZ" },
|
||||
edgeY_posX_negZ: { start: "posX_negY_negZ", end: "posX_posY_negZ" },
|
||||
edgeY_negX_posZ: { start: "negX_negY_posZ", end: "negX_posY_posZ" },
|
||||
edgeY_posX_posZ: { start: "posX_negY_posZ", end: "posX_posY_posZ" },
|
||||
edgeZ_negX_negY: { start: "negX_negY_negZ", end: "negX_negY_posZ" },
|
||||
edgeZ_posX_negY: { start: "posX_negY_negZ", end: "posX_negY_posZ" },
|
||||
edgeZ_negX_posY: { start: "negX_posY_negZ", end: "negX_posY_posZ" },
|
||||
edgeZ_posX_posY: { start: "posX_posY_negZ", end: "posX_posY_posZ" }
|
||||
};
|
||||
function createBrushRotationEuler(brush) {
|
||||
return new Euler(MathUtils.degToRad(brush.rotationDegrees.x), MathUtils.degToRad(brush.rotationDegrees.y), MathUtils.degToRad(brush.rotationDegrees.z), "XYZ");
|
||||
}
|
||||
export function transformBoxBrushWorldVectorToLocal(brush, worldVector) {
|
||||
const rotation = createBrushRotationEuler(brush);
|
||||
const inverseRotation = new Quaternion().setFromEuler(rotation).invert();
|
||||
const localVector = new Vector3(worldVector.x, worldVector.y, worldVector.z).applyQuaternion(inverseRotation);
|
||||
return {
|
||||
x: localVector.x,
|
||||
y: localVector.y,
|
||||
z: localVector.z
|
||||
};
|
||||
}
|
||||
export function transformBoxBrushWorldPointToLocal(brush, worldPoint) {
|
||||
const rotation = createBrushRotationEuler(brush);
|
||||
const inverseRotation = new Quaternion().setFromEuler(rotation).invert();
|
||||
const localPoint = new Vector3(worldPoint.x - brush.center.x, worldPoint.y - brush.center.y, worldPoint.z - brush.center.z).applyQuaternion(inverseRotation);
|
||||
return {
|
||||
x: localPoint.x,
|
||||
y: localPoint.y,
|
||||
z: localPoint.z
|
||||
};
|
||||
}
|
||||
export function transformBoxBrushLocalPointToWorld(brush, localPoint) {
|
||||
const rotation = createBrushRotationEuler(brush);
|
||||
const rotatedOffset = new Vector3(localPoint.x, localPoint.y, localPoint.z).applyEuler(rotation);
|
||||
return {
|
||||
x: brush.center.x + rotatedOffset.x,
|
||||
y: brush.center.y + rotatedOffset.y,
|
||||
z: brush.center.z + rotatedOffset.z
|
||||
};
|
||||
}
|
||||
export function getBoxBrushFaceTransformMeta(faceId) {
|
||||
return BOX_FACE_TRANSFORM_META[faceId];
|
||||
}
|
||||
export function getBoxBrushEdgeTransformMeta(edgeId) {
|
||||
return BOX_EDGE_TRANSFORM_META[edgeId];
|
||||
}
|
||||
export function getBoxBrushVertexSigns(vertexId) {
|
||||
return BOX_VERTEX_SIGNS[vertexId];
|
||||
}
|
||||
export function getBoxBrushFaceWorldCenter(brush, faceId) {
|
||||
const faceVertexIds = getBoxBrushFaceVertexIds(faceId);
|
||||
const localCenter = faceVertexIds.reduce((accumulator, vertexId) => {
|
||||
const vertex = getBoxBrushLocalVertexPosition(brush, vertexId);
|
||||
return {
|
||||
x: accumulator.x + vertex.x * 0.25,
|
||||
y: accumulator.y + vertex.y * 0.25,
|
||||
z: accumulator.z + vertex.z * 0.25
|
||||
};
|
||||
}, { x: 0, y: 0, z: 0 });
|
||||
return transformBoxBrushLocalPointToWorld(brush, localCenter);
|
||||
}
|
||||
export function getBoxBrushFaceAxis(faceId) {
|
||||
return BOX_FACE_TRANSFORM_META[faceId].axis;
|
||||
}
|
||||
export function getBoxBrushEdgeAxis(edgeId) {
|
||||
return BOX_EDGE_TRANSFORM_META[edgeId].axis;
|
||||
}
|
||||
export function getBoxBrushFaceIdsForAxis(axis) {
|
||||
return BOX_FACE_IDS.filter((faceId) => BOX_FACE_TRANSFORM_META[faceId].axis === axis);
|
||||
}
|
||||
export function getBoxBrushVertexLocalPosition(brush, vertexId) {
|
||||
return getBoxBrushLocalVertexPosition(brush, vertexId);
|
||||
}
|
||||
export function getBoxBrushVertexWorldPosition(brush, vertexId) {
|
||||
return transformBoxBrushLocalPointToWorld(brush, getBoxBrushVertexLocalPosition(brush, vertexId));
|
||||
}
|
||||
export function getBoxBrushEdgeWorldSegment(brush, edgeId) {
|
||||
const vertexIds = BOX_EDGE_VERTEX_IDS[edgeId];
|
||||
const start = getBoxBrushVertexWorldPosition(brush, vertexIds.start);
|
||||
const end = getBoxBrushVertexWorldPosition(brush, vertexIds.end);
|
||||
return {
|
||||
id: edgeId,
|
||||
start,
|
||||
end,
|
||||
center: {
|
||||
x: (start.x + end.x) * 0.5,
|
||||
y: (start.y + end.y) * 0.5,
|
||||
z: (start.z + end.z) * 0.5
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
import { BufferAttribute, BufferGeometry } from "three";
|
||||
import { BOX_EDGE_IDS, BOX_FACE_IDS } from "../document/brushes";
|
||||
import { transformProjectedFaceUv } from "./box-face-uvs";
|
||||
const FACE_VERTEX_IDS = {
|
||||
posX: ["posX_negY_posZ", "posX_negY_negZ", "posX_posY_negZ", "posX_posY_posZ"],
|
||||
negX: ["negX_negY_negZ", "negX_negY_posZ", "negX_posY_posZ", "negX_posY_negZ"],
|
||||
posY: ["negX_posY_posZ", "posX_posY_posZ", "posX_posY_negZ", "negX_posY_negZ"],
|
||||
negY: ["negX_negY_negZ", "posX_negY_negZ", "posX_negY_posZ", "negX_negY_posZ"],
|
||||
posZ: ["negX_negY_posZ", "posX_negY_posZ", "posX_posY_posZ", "negX_posY_posZ"],
|
||||
negZ: ["posX_negY_negZ", "negX_negY_negZ", "negX_posY_negZ", "posX_posY_negZ"]
|
||||
};
|
||||
const EDGE_VERTEX_IDS = {
|
||||
edgeX_negY_negZ: ["negX_negY_negZ", "posX_negY_negZ"],
|
||||
edgeX_posY_negZ: ["negX_posY_negZ", "posX_posY_negZ"],
|
||||
edgeX_negY_posZ: ["negX_negY_posZ", "posX_negY_posZ"],
|
||||
edgeX_posY_posZ: ["negX_posY_posZ", "posX_posY_posZ"],
|
||||
edgeY_negX_negZ: ["negX_negY_negZ", "negX_posY_negZ"],
|
||||
edgeY_posX_negZ: ["posX_negY_negZ", "posX_posY_negZ"],
|
||||
edgeY_negX_posZ: ["negX_negY_posZ", "negX_posY_posZ"],
|
||||
edgeY_posX_posZ: ["posX_negY_posZ", "posX_posY_posZ"],
|
||||
edgeZ_negX_negY: ["negX_negY_negZ", "negX_negY_posZ"],
|
||||
edgeZ_posX_negY: ["posX_negY_negZ", "posX_negY_posZ"],
|
||||
edgeZ_negX_posY: ["negX_posY_negZ", "negX_posY_posZ"],
|
||||
edgeZ_posX_posY: ["posX_posY_negZ", "posX_posY_posZ"]
|
||||
};
|
||||
const WATER_TOP_FACE_RENDER_SEGMENTS = 12;
|
||||
function cloneVec3(vector) {
|
||||
return { x: vector.x, y: vector.y, z: vector.z };
|
||||
}
|
||||
function subtractVec3(left, right) {
|
||||
return {
|
||||
x: left.x - right.x,
|
||||
y: left.y - right.y,
|
||||
z: left.z - right.z
|
||||
};
|
||||
}
|
||||
function crossVec3(left, right) {
|
||||
return {
|
||||
x: left.y * right.z - left.z * right.y,
|
||||
y: left.z * right.x - left.x * right.z,
|
||||
z: left.x * right.y - left.y * right.x
|
||||
};
|
||||
}
|
||||
function dotVec3(left, right) {
|
||||
return left.x * right.x + left.y * right.y + left.z * right.z;
|
||||
}
|
||||
function getVectorLength(vector) {
|
||||
return Math.sqrt(dotVec3(vector, vector));
|
||||
}
|
||||
function normalizeVec3(vector) {
|
||||
const length = getVectorLength(vector);
|
||||
if (length <= 1e-8) {
|
||||
return { x: 0, y: 0, z: 0 };
|
||||
}
|
||||
return {
|
||||
x: vector.x / length,
|
||||
y: vector.y / length,
|
||||
z: vector.z / length
|
||||
};
|
||||
}
|
||||
function computeNewellNormal(vertices) {
|
||||
let normal = { x: 0, y: 0, z: 0 };
|
||||
for (let index = 0; index < vertices.length; index += 1) {
|
||||
const current = vertices[index];
|
||||
const next = vertices[(index + 1) % vertices.length];
|
||||
normal.x += (current.y - next.y) * (current.z + next.z);
|
||||
normal.y += (current.z - next.z) * (current.x + next.x);
|
||||
normal.z += (current.x - next.x) * (current.y + next.y);
|
||||
}
|
||||
return normalizeVec3(normal);
|
||||
}
|
||||
function chooseProjectionAxes(normal) {
|
||||
const absoluteNormal = {
|
||||
x: Math.abs(normal.x),
|
||||
y: Math.abs(normal.y),
|
||||
z: Math.abs(normal.z)
|
||||
};
|
||||
if (absoluteNormal.x >= absoluteNormal.y && absoluteNormal.x >= absoluteNormal.z) {
|
||||
return ["y", "z"];
|
||||
}
|
||||
if (absoluteNormal.y >= absoluteNormal.z) {
|
||||
return ["x", "z"];
|
||||
}
|
||||
return ["x", "y"];
|
||||
}
|
||||
function projectVerticesTo2d(vertices, normal) {
|
||||
const [uAxis, vAxis] = chooseProjectionAxes(normal);
|
||||
return vertices.map((vertex) => ({
|
||||
x: vertex[uAxis],
|
||||
y: vertex[vAxis]
|
||||
}));
|
||||
}
|
||||
function computeSignedArea(points) {
|
||||
let area = 0;
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const current = points[index];
|
||||
const next = points[(index + 1) % points.length];
|
||||
area += current.x * next.y - next.x * current.y;
|
||||
}
|
||||
return area * 0.5;
|
||||
}
|
||||
function isPointInTriangle(point, triangle, orientation) {
|
||||
const [a, b, c] = triangle;
|
||||
const edges = [
|
||||
(b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x),
|
||||
(c.x - b.x) * (point.y - b.y) - (c.y - b.y) * (point.x - b.x),
|
||||
(a.x - c.x) * (point.y - c.y) - (a.y - c.y) * (point.x - c.x)
|
||||
];
|
||||
return orientation > 0 ? edges.every((value) => value >= -1e-8) : edges.every((value) => value <= 1e-8);
|
||||
}
|
||||
function triangulateQuad(vertices) {
|
||||
const normal = computeNewellNormal(vertices);
|
||||
const projected = projectVerticesTo2d(vertices, normal);
|
||||
const orientation = computeSignedArea(projected);
|
||||
if (Math.abs(orientation) <= 1e-8) {
|
||||
throw new Error("Face projection is degenerate.");
|
||||
}
|
||||
const remaining = [0, 1, 2, 3];
|
||||
const triangles = [];
|
||||
while (remaining.length > 3) {
|
||||
let earFound = false;
|
||||
for (let offset = 0; offset < remaining.length; offset += 1) {
|
||||
const previousIndex = remaining[(offset + remaining.length - 1) % remaining.length];
|
||||
const currentIndex = remaining[offset];
|
||||
const nextIndex = remaining[(offset + 1) % remaining.length];
|
||||
const previousPoint = projected[previousIndex];
|
||||
const currentPoint = projected[currentIndex];
|
||||
const nextPoint = projected[nextIndex];
|
||||
const cross = (currentPoint.x - previousPoint.x) * (nextPoint.y - previousPoint.y) -
|
||||
(currentPoint.y - previousPoint.y) * (nextPoint.x - previousPoint.x);
|
||||
if ((orientation > 0 && cross <= 1e-8) || (orientation < 0 && cross >= -1e-8)) {
|
||||
continue;
|
||||
}
|
||||
const candidateTriangle = [previousPoint, currentPoint, nextPoint];
|
||||
const containsOtherPoint = remaining.some((candidateIndex) => {
|
||||
if (candidateIndex === previousIndex || candidateIndex === currentIndex || candidateIndex === nextIndex) {
|
||||
return false;
|
||||
}
|
||||
return isPointInTriangle(projected[candidateIndex], candidateTriangle, orientation);
|
||||
});
|
||||
if (containsOtherPoint) {
|
||||
continue;
|
||||
}
|
||||
triangles.push([previousIndex, currentIndex, nextIndex]);
|
||||
remaining.splice(offset, 1);
|
||||
earFound = true;
|
||||
break;
|
||||
}
|
||||
if (!earFound) {
|
||||
throw new Error("Face triangulation could not find a stable ear.");
|
||||
}
|
||||
}
|
||||
triangles.push([remaining[0], remaining[1], remaining[2]]);
|
||||
return triangles;
|
||||
}
|
||||
function projectLocalVertexToFaceUv(vertexPosition, faceId, faceBounds) {
|
||||
switch (faceId) {
|
||||
case "posX":
|
||||
return {
|
||||
x: faceBounds.max.z - vertexPosition.z,
|
||||
y: vertexPosition.y - faceBounds.min.y
|
||||
};
|
||||
case "negX":
|
||||
return {
|
||||
x: vertexPosition.z - faceBounds.min.z,
|
||||
y: vertexPosition.y - faceBounds.min.y
|
||||
};
|
||||
case "posY":
|
||||
return {
|
||||
x: vertexPosition.x - faceBounds.min.x,
|
||||
y: faceBounds.max.z - vertexPosition.z
|
||||
};
|
||||
case "negY":
|
||||
return {
|
||||
x: vertexPosition.x - faceBounds.min.x,
|
||||
y: vertexPosition.z - faceBounds.min.z
|
||||
};
|
||||
case "posZ":
|
||||
return {
|
||||
x: vertexPosition.x - faceBounds.min.x,
|
||||
y: vertexPosition.y - faceBounds.min.y
|
||||
};
|
||||
case "negZ":
|
||||
return {
|
||||
x: faceBounds.max.x - vertexPosition.x,
|
||||
y: vertexPosition.y - faceBounds.min.y
|
||||
};
|
||||
}
|
||||
}
|
||||
function getFaceUvSize(faceId, faceBounds) {
|
||||
switch (faceId) {
|
||||
case "posX":
|
||||
case "negX":
|
||||
return {
|
||||
x: faceBounds.max.z - faceBounds.min.z,
|
||||
y: faceBounds.max.y - faceBounds.min.y
|
||||
};
|
||||
case "posY":
|
||||
case "negY":
|
||||
return {
|
||||
x: faceBounds.max.x - faceBounds.min.x,
|
||||
y: faceBounds.max.z - faceBounds.min.z
|
||||
};
|
||||
case "posZ":
|
||||
case "negZ":
|
||||
return {
|
||||
x: faceBounds.max.x - faceBounds.min.x,
|
||||
y: faceBounds.max.y - faceBounds.min.y
|
||||
};
|
||||
}
|
||||
}
|
||||
function computeFaceBounds(vertices) {
|
||||
const firstVertex = vertices[0];
|
||||
const min = { ...firstVertex };
|
||||
const max = { ...firstVertex };
|
||||
for (const vertex of vertices.slice(1)) {
|
||||
min.x = Math.min(min.x, vertex.x);
|
||||
min.y = Math.min(min.y, vertex.y);
|
||||
min.z = Math.min(min.z, vertex.z);
|
||||
max.x = Math.max(max.x, vertex.x);
|
||||
max.y = Math.max(max.y, vertex.y);
|
||||
max.z = Math.max(max.z, vertex.z);
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
function lerpNumber(start, end, amount) {
|
||||
return start + (end - start) * amount;
|
||||
}
|
||||
function lerpVec3(start, end, amount) {
|
||||
return {
|
||||
x: lerpNumber(start.x, end.x, amount),
|
||||
y: lerpNumber(start.y, end.y, amount),
|
||||
z: lerpNumber(start.z, end.z, amount)
|
||||
};
|
||||
}
|
||||
function interpolateQuadSurfaceVertex(corners, u, v) {
|
||||
const topEdge = lerpVec3(corners[0], corners[1], u);
|
||||
const bottomEdge = lerpVec3(corners[3], corners[2], u);
|
||||
return lerpVec3(topEdge, bottomEdge, v);
|
||||
}
|
||||
function pushRenderedFaceVertex(positions, normals, uvs, indices, vertex, normal, faceId, faceBounds, uvSize, uvState) {
|
||||
const projectedUv = projectLocalVertexToFaceUv(vertex, faceId, faceBounds);
|
||||
const transformedUv = transformProjectedFaceUv(projectedUv, uvSize, uvState);
|
||||
positions.push(vertex.x, vertex.y, vertex.z);
|
||||
normals.push(normal.x, normal.y, normal.z);
|
||||
uvs.push(transformedUv.x, transformedUv.y);
|
||||
indices.push(indices.length);
|
||||
}
|
||||
export function getBoxBrushFaceVertexIds(faceId) {
|
||||
return FACE_VERTEX_IDS[faceId];
|
||||
}
|
||||
export function getBoxBrushEdgeVertexIds(edgeId) {
|
||||
return EDGE_VERTEX_IDS[edgeId];
|
||||
}
|
||||
export function getBoxBrushLocalVertexPosition(brush, vertexId) {
|
||||
return cloneVec3(brush.geometry.vertices[vertexId]);
|
||||
}
|
||||
export function buildBoxBrushDerivedMeshData(brush) {
|
||||
const diagnostics = validateBoxBrushGeometry(brush);
|
||||
if (diagnostics.length > 0) {
|
||||
throw new Error(diagnostics[0].message);
|
||||
}
|
||||
const positions = [];
|
||||
const normals = [];
|
||||
const uvs = [];
|
||||
const indices = [];
|
||||
const colliderVertices = [];
|
||||
const colliderIndices = [];
|
||||
const faceSurfaces = [];
|
||||
const groups = [];
|
||||
const vertexIndexMap = new Map();
|
||||
for (const vertexId of Object.keys(brush.geometry.vertices)) {
|
||||
const vertex = brush.geometry.vertices[vertexId];
|
||||
vertexIndexMap.set(vertexId, colliderVertices.length / 3);
|
||||
colliderVertices.push(vertex.x, vertex.y, vertex.z);
|
||||
}
|
||||
for (const [materialIndex, faceId] of BOX_FACE_IDS.entries()) {
|
||||
const faceVertexIds = FACE_VERTEX_IDS[faceId];
|
||||
const faceVertices = faceVertexIds.map((vertexId) => getBoxBrushLocalVertexPosition(brush, vertexId));
|
||||
const triangles = triangulateQuad(faceVertices);
|
||||
const normal = computeNewellNormal(faceVertices);
|
||||
const faceBounds = computeFaceBounds(faceVertices);
|
||||
const uvSize = getFaceUvSize(faceId, faceBounds);
|
||||
const uvState = brush.faces[faceId].uv;
|
||||
const indexStart = indices.length;
|
||||
faceSurfaces.push({
|
||||
faceId,
|
||||
vertexIds: faceVertexIds,
|
||||
triangles,
|
||||
normal
|
||||
});
|
||||
const useSubdividedWaterTopFace = brush.volume.mode === "water" && faceId === "posY" && brush.volume.water.surfaceDisplacementEnabled;
|
||||
if (useSubdividedWaterTopFace) {
|
||||
const faceCorners = faceVertices;
|
||||
for (let row = 0; row < WATER_TOP_FACE_RENDER_SEGMENTS; row += 1) {
|
||||
const v0 = row / WATER_TOP_FACE_RENDER_SEGMENTS;
|
||||
const v1 = (row + 1) / WATER_TOP_FACE_RENDER_SEGMENTS;
|
||||
for (let column = 0; column < WATER_TOP_FACE_RENDER_SEGMENTS; column += 1) {
|
||||
const u0 = column / WATER_TOP_FACE_RENDER_SEGMENTS;
|
||||
const u1 = (column + 1) / WATER_TOP_FACE_RENDER_SEGMENTS;
|
||||
const quadVertices = [
|
||||
interpolateQuadSurfaceVertex(faceCorners, u0, v0),
|
||||
interpolateQuadSurfaceVertex(faceCorners, u1, v0),
|
||||
interpolateQuadSurfaceVertex(faceCorners, u1, v1),
|
||||
interpolateQuadSurfaceVertex(faceCorners, u0, v1)
|
||||
];
|
||||
for (const vertex of [quadVertices[0], quadVertices[1], quadVertices[2], quadVertices[0], quadVertices[2], quadVertices[3]]) {
|
||||
pushRenderedFaceVertex(positions, normals, uvs, indices, vertex, normal, faceId, faceBounds, uvSize, uvState);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (const triangle of triangles) {
|
||||
for (const vertexOffset of triangle) {
|
||||
pushRenderedFaceVertex(positions, normals, uvs, indices, faceVertices[vertexOffset], normal, faceId, faceBounds, uvSize, uvState);
|
||||
}
|
||||
}
|
||||
}
|
||||
groups.push({
|
||||
start: indexStart,
|
||||
count: indices.length - indexStart,
|
||||
materialIndex
|
||||
});
|
||||
for (const triangle of triangles) {
|
||||
colliderIndices.push(vertexIndexMap.get(faceVertexIds[triangle[0]]) ?? 0, vertexIndexMap.get(faceVertexIds[triangle[1]]) ?? 0, vertexIndexMap.get(faceVertexIds[triangle[2]]) ?? 0);
|
||||
}
|
||||
}
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
|
||||
geometry.setAttribute("normal", new BufferAttribute(new Float32Array(normals), 3));
|
||||
geometry.setAttribute("uv", new BufferAttribute(new Float32Array(uvs), 2));
|
||||
geometry.setIndex(indices);
|
||||
for (const group of groups) {
|
||||
geometry.addGroup(group.start, group.count, group.materialIndex);
|
||||
}
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
const firstVertex = brush.geometry.vertices.negX_negY_negZ;
|
||||
const localBounds = {
|
||||
min: cloneVec3(firstVertex),
|
||||
max: cloneVec3(firstVertex)
|
||||
};
|
||||
for (const vertex of Object.values(brush.geometry.vertices)) {
|
||||
localBounds.min.x = Math.min(localBounds.min.x, vertex.x);
|
||||
localBounds.min.y = Math.min(localBounds.min.y, vertex.y);
|
||||
localBounds.min.z = Math.min(localBounds.min.z, vertex.z);
|
||||
localBounds.max.x = Math.max(localBounds.max.x, vertex.x);
|
||||
localBounds.max.y = Math.max(localBounds.max.y, vertex.y);
|
||||
localBounds.max.z = Math.max(localBounds.max.z, vertex.z);
|
||||
}
|
||||
return {
|
||||
geometry,
|
||||
faceSurfaces,
|
||||
edgeSegments: BOX_EDGE_IDS.map((edgeId) => {
|
||||
const [startId, endId] = EDGE_VERTEX_IDS[edgeId];
|
||||
return {
|
||||
edgeId,
|
||||
start: getBoxBrushLocalVertexPosition(brush, startId),
|
||||
end: getBoxBrushLocalVertexPosition(brush, endId)
|
||||
};
|
||||
}),
|
||||
colliderVertices: new Float32Array(colliderVertices),
|
||||
colliderIndices: new Uint32Array(colliderIndices),
|
||||
localBounds
|
||||
};
|
||||
}
|
||||
export function validateBoxBrushGeometry(brush) {
|
||||
const diagnostics = [];
|
||||
for (const [vertexId, vertex] of Object.entries(brush.geometry.vertices)) {
|
||||
if (!Number.isFinite(vertex.x) || !Number.isFinite(vertex.y) || !Number.isFinite(vertex.z)) {
|
||||
diagnostics.push({
|
||||
code: "invalid-box-geometry-vertex",
|
||||
message: `Whitebox vertex ${vertexId} must remain finite.`
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const faceId of BOX_FACE_IDS) {
|
||||
const faceVertices = FACE_VERTEX_IDS[faceId].map((vertexId) => brush.geometry.vertices[vertexId]);
|
||||
const normal = computeNewellNormal(faceVertices);
|
||||
if (getVectorLength(normal) <= 1e-8) {
|
||||
diagnostics.push({
|
||||
code: "degenerate-box-face",
|
||||
message: `Whitebox face ${faceId} is degenerate and cannot be triangulated.`,
|
||||
faceId
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
triangulateQuad(faceVertices);
|
||||
}
|
||||
catch (error) {
|
||||
diagnostics.push({
|
||||
code: "invalid-box-face-triangulation",
|
||||
message: error instanceof Error ? `Whitebox face ${faceId} could not be triangulated: ${error.message}` : `Whitebox face ${faceId} could not be triangulated.`,
|
||||
faceId
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Euler, MathUtils, Vector3 } from "three";
|
||||
import { BOX_VERTEX_IDS } from "../document/brushes";
|
||||
import { getBoxBrushLocalVertexPosition } from "./box-brush-mesh";
|
||||
export function getBoxBrushHalfSize(brush) {
|
||||
return {
|
||||
x: brush.size.x * 0.5,
|
||||
y: brush.size.y * 0.5,
|
||||
z: brush.size.z * 0.5
|
||||
};
|
||||
}
|
||||
export function getBoxBrushBounds(brush) {
|
||||
const corners = getBoxBrushCornerPositions(brush);
|
||||
const firstCorner = corners[0];
|
||||
const min = { ...firstCorner };
|
||||
const max = { ...firstCorner };
|
||||
for (const corner of corners.slice(1)) {
|
||||
min.x = Math.min(min.x, corner.x);
|
||||
min.y = Math.min(min.y, corner.y);
|
||||
min.z = Math.min(min.z, corner.z);
|
||||
max.x = Math.max(max.x, corner.x);
|
||||
max.y = Math.max(max.y, corner.y);
|
||||
max.z = Math.max(max.z, corner.z);
|
||||
}
|
||||
return {
|
||||
min,
|
||||
max
|
||||
};
|
||||
}
|
||||
export function getBoxBrushCornerPositions(brush) {
|
||||
const rotation = new Euler(MathUtils.degToRad(brush.rotationDegrees.x), MathUtils.degToRad(brush.rotationDegrees.y), MathUtils.degToRad(brush.rotationDegrees.z), "XYZ");
|
||||
const offsets = BOX_VERTEX_IDS.map((vertexId) => {
|
||||
const localVertex = getBoxBrushLocalVertexPosition(brush, vertexId);
|
||||
return new Vector3(localVertex.x, localVertex.y, localVertex.z);
|
||||
});
|
||||
return offsets.map((offset) => {
|
||||
const rotatedOffset = offset.clone().applyEuler(rotation);
|
||||
return {
|
||||
x: brush.center.x + rotatedOffset.x,
|
||||
y: brush.center.y + rotatedOffset.y,
|
||||
z: brush.center.z + rotatedOffset.z
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { BoxGeometry } from "three";
|
||||
import { BOX_FACE_IDS, createDefaultFaceUvState } from "../document/brushes";
|
||||
import { getBoxBrushHalfSize } from "./box-brush";
|
||||
export function getBoxBrushFaceSize(brush, faceId) {
|
||||
switch (faceId) {
|
||||
case "posX":
|
||||
case "negX":
|
||||
return {
|
||||
x: brush.size.z,
|
||||
y: brush.size.y
|
||||
};
|
||||
case "posY":
|
||||
case "negY":
|
||||
return {
|
||||
x: brush.size.x,
|
||||
y: brush.size.z
|
||||
};
|
||||
case "posZ":
|
||||
case "negZ":
|
||||
return {
|
||||
x: brush.size.x,
|
||||
y: brush.size.y
|
||||
};
|
||||
}
|
||||
}
|
||||
export function createFitToFaceBoxBrushFaceUvState(brush, faceId) {
|
||||
const faceSize = getBoxBrushFaceSize(brush, faceId);
|
||||
return {
|
||||
...createDefaultFaceUvState(),
|
||||
scale: {
|
||||
x: 1 / faceSize.x,
|
||||
y: 1 / faceSize.y
|
||||
}
|
||||
};
|
||||
}
|
||||
export function projectBoxFaceVertexToUv(vertexPosition, brush, faceId) {
|
||||
const halfSize = getBoxBrushHalfSize(brush);
|
||||
switch (faceId) {
|
||||
case "posX":
|
||||
return {
|
||||
x: halfSize.z - vertexPosition.z,
|
||||
y: vertexPosition.y + halfSize.y
|
||||
};
|
||||
case "negX":
|
||||
return {
|
||||
x: vertexPosition.z + halfSize.z,
|
||||
y: vertexPosition.y + halfSize.y
|
||||
};
|
||||
case "posY":
|
||||
return {
|
||||
x: vertexPosition.x + halfSize.x,
|
||||
y: halfSize.z - vertexPosition.z
|
||||
};
|
||||
case "negY":
|
||||
return {
|
||||
x: vertexPosition.x + halfSize.x,
|
||||
y: vertexPosition.z + halfSize.z
|
||||
};
|
||||
case "posZ":
|
||||
return {
|
||||
x: vertexPosition.x + halfSize.x,
|
||||
y: vertexPosition.y + halfSize.y
|
||||
};
|
||||
case "negZ":
|
||||
return {
|
||||
x: halfSize.x - vertexPosition.x,
|
||||
y: vertexPosition.y + halfSize.y
|
||||
};
|
||||
}
|
||||
}
|
||||
export function transformProjectedFaceUv(baseUv, faceSize, uvState) {
|
||||
let u = (baseUv.x - faceSize.x * 0.5) * uvState.scale.x;
|
||||
let v = (baseUv.y - faceSize.y * 0.5) * uvState.scale.y;
|
||||
if (uvState.flipU) {
|
||||
u *= -1;
|
||||
}
|
||||
if (uvState.flipV) {
|
||||
v *= -1;
|
||||
}
|
||||
switch (uvState.rotationQuarterTurns) {
|
||||
case 1: {
|
||||
const nextU = -v;
|
||||
v = u;
|
||||
u = nextU;
|
||||
break;
|
||||
}
|
||||
case 2:
|
||||
u *= -1;
|
||||
v *= -1;
|
||||
break;
|
||||
case 3: {
|
||||
const nextU = v;
|
||||
v = -u;
|
||||
u = nextU;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: u + faceSize.x * 0.5 * uvState.scale.x + uvState.offset.x,
|
||||
y: v + faceSize.y * 0.5 * uvState.scale.y + uvState.offset.y
|
||||
};
|
||||
}
|
||||
export function applyBoxBrushFaceUvsToGeometry(geometry, brush) {
|
||||
const positionAttribute = geometry.getAttribute("position");
|
||||
const uvAttribute = geometry.getAttribute("uv");
|
||||
const indexAttribute = geometry.getIndex();
|
||||
if (indexAttribute === null) {
|
||||
throw new Error("BoxGeometry is expected to be indexed for face UV projection.");
|
||||
}
|
||||
// BoxGeometry groups follow the same px, nx, py, ny, pz, nz order as the canonical face ids.
|
||||
for (const [materialIndex, faceId] of BOX_FACE_IDS.entries()) {
|
||||
const group = geometry.groups.find((candidate) => candidate.materialIndex === materialIndex);
|
||||
if (group === undefined) {
|
||||
continue;
|
||||
}
|
||||
const faceSize = getBoxBrushFaceSize(brush, faceId);
|
||||
const vertexIndices = new Set();
|
||||
for (let indexOffset = group.start; indexOffset < group.start + group.count; indexOffset += 1) {
|
||||
vertexIndices.add(indexAttribute.getX(indexOffset));
|
||||
}
|
||||
for (const vertexIndex of vertexIndices) {
|
||||
const localVertexPosition = {
|
||||
x: positionAttribute.getX(vertexIndex),
|
||||
y: positionAttribute.getY(vertexIndex),
|
||||
z: positionAttribute.getZ(vertexIndex)
|
||||
};
|
||||
const projectedUv = projectBoxFaceVertexToUv(localVertexPosition, brush, faceId);
|
||||
const transformedUv = transformProjectedFaceUv(projectedUv, faceSize, brush.faces[faceId].uv);
|
||||
uvAttribute.setXY(vertexIndex, transformedUv.x, transformedUv.y);
|
||||
}
|
||||
}
|
||||
uvAttribute.needsUpdate = true;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
export const DEFAULT_GRID_SIZE = 1;
|
||||
function assertGridSize(gridSize) {
|
||||
if (!Number.isFinite(gridSize) || gridSize <= 0) {
|
||||
throw new Error("Grid size must be a positive finite number.");
|
||||
}
|
||||
return gridSize;
|
||||
}
|
||||
export function snapValueToGrid(value, gridSize = DEFAULT_GRID_SIZE) {
|
||||
const step = assertGridSize(gridSize);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error("Grid-snapped values must be finite numbers.");
|
||||
}
|
||||
return Math.round(value / step) * step;
|
||||
}
|
||||
function snapPositiveSizeValue(value, gridSize) {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error("Box brush size values must be finite numbers.");
|
||||
}
|
||||
const snappedSize = Math.round(Math.abs(value) / gridSize) * gridSize;
|
||||
return snappedSize > 0 ? snappedSize : gridSize;
|
||||
}
|
||||
export function snapVec3ToGrid(vector, gridSize = DEFAULT_GRID_SIZE) {
|
||||
return {
|
||||
x: snapValueToGrid(vector.x, gridSize),
|
||||
y: snapValueToGrid(vector.y, gridSize),
|
||||
z: snapValueToGrid(vector.z, gridSize)
|
||||
};
|
||||
}
|
||||
export function snapPositiveSizeToGrid(size, gridSize = DEFAULT_GRID_SIZE) {
|
||||
const step = assertGridSize(gridSize);
|
||||
return {
|
||||
x: snapPositiveSizeValue(size.x, step),
|
||||
y: snapPositiveSizeValue(size.y, step),
|
||||
z: snapPositiveSizeValue(size.z, step)
|
||||
};
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { BoxGeometry, BufferGeometry, Float32BufferAttribute, Group, Mesh, MeshBasicMaterial, Vector3 } from "three";
|
||||
import { ConvexGeometry } from "three/examples/jsm/geometries/ConvexGeometry.js";
|
||||
const DEBUG_COLLIDER_COLORS = {
|
||||
simple: 0x87d2ff,
|
||||
terrain: 0x7be7b4,
|
||||
static: 0xffc66d,
|
||||
dynamic: 0xff8b7a
|
||||
};
|
||||
function createWireframeMaterial(color) {
|
||||
return new MeshBasicMaterial({
|
||||
color,
|
||||
wireframe: true,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
depthWrite: false,
|
||||
toneMapped: false
|
||||
});
|
||||
}
|
||||
function markDebugMesh(mesh) {
|
||||
mesh.userData.shadowIgnored = true;
|
||||
mesh.userData.nonPickable = true;
|
||||
mesh.renderOrder = 3_500;
|
||||
}
|
||||
function createBoxColliderDebugMesh(collider) {
|
||||
const mesh = new Mesh(new BoxGeometry(collider.size.x, collider.size.y, collider.size.z), createWireframeMaterial(DEBUG_COLLIDER_COLORS.simple));
|
||||
mesh.position.set(collider.center.x, collider.center.y, collider.center.z);
|
||||
markDebugMesh(mesh);
|
||||
return mesh;
|
||||
}
|
||||
function createTriMeshColliderDebugMesh(collider) {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(collider.vertices, 3));
|
||||
geometry.setIndex(Array.from(collider.indices));
|
||||
const mesh = new Mesh(geometry, createWireframeMaterial(DEBUG_COLLIDER_COLORS.static));
|
||||
markDebugMesh(mesh);
|
||||
return mesh;
|
||||
}
|
||||
function createHeightfieldColliderDebugMesh(collider) {
|
||||
const vertices = [];
|
||||
const indices = [];
|
||||
const width = collider.maxX - collider.minX;
|
||||
const depth = collider.maxZ - collider.minZ;
|
||||
for (let zIndex = 0; zIndex < collider.cols; zIndex += 1) {
|
||||
const zLerp = collider.cols === 1 ? 0 : zIndex / (collider.cols - 1);
|
||||
const z = collider.minZ + depth * zLerp;
|
||||
for (let xIndex = 0; xIndex < collider.rows; xIndex += 1) {
|
||||
const xLerp = collider.rows === 1 ? 0 : xIndex / (collider.rows - 1);
|
||||
const x = collider.minX + width * xLerp;
|
||||
const y = collider.heights[xIndex + zIndex * collider.rows];
|
||||
vertices.push(x, y, z);
|
||||
}
|
||||
}
|
||||
for (let zIndex = 0; zIndex < collider.cols - 1; zIndex += 1) {
|
||||
for (let xIndex = 0; xIndex < collider.rows - 1; xIndex += 1) {
|
||||
const topLeft = xIndex + zIndex * collider.rows;
|
||||
const topRight = topLeft + 1;
|
||||
const bottomLeft = topLeft + collider.rows;
|
||||
const bottomRight = bottomLeft + 1;
|
||||
indices.push(topLeft, bottomLeft, bottomRight, topLeft, bottomRight, topRight);
|
||||
}
|
||||
}
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3));
|
||||
geometry.setIndex(indices);
|
||||
const mesh = new Mesh(geometry, createWireframeMaterial(DEBUG_COLLIDER_COLORS.terrain));
|
||||
markDebugMesh(mesh);
|
||||
return mesh;
|
||||
}
|
||||
function createCompoundColliderDebugGroup(collider) {
|
||||
const group = new Group();
|
||||
for (const piece of collider.pieces) {
|
||||
const points = [];
|
||||
for (let index = 0; index < piece.points.length; index += 3) {
|
||||
points.push(new Vector3(piece.points[index], piece.points[index + 1], piece.points[index + 2]));
|
||||
}
|
||||
const mesh = new Mesh(new ConvexGeometry(points), createWireframeMaterial(DEBUG_COLLIDER_COLORS.dynamic));
|
||||
markDebugMesh(mesh);
|
||||
group.add(mesh);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
export function createModelColliderDebugGroup(collider) {
|
||||
const group = new Group();
|
||||
switch (collider.kind) {
|
||||
case "box":
|
||||
group.add(createBoxColliderDebugMesh(collider));
|
||||
break;
|
||||
case "trimesh":
|
||||
group.add(createTriMeshColliderDebugMesh(collider));
|
||||
break;
|
||||
case "heightfield":
|
||||
group.add(createHeightfieldColliderDebugMesh(collider));
|
||||
break;
|
||||
case "compound":
|
||||
group.add(createCompoundColliderDebugGroup(collider));
|
||||
break;
|
||||
}
|
||||
group.userData.nonPickable = true;
|
||||
return group;
|
||||
}
|
||||
function disposeMaterial(material) {
|
||||
if (Array.isArray(material)) {
|
||||
for (const item of material) {
|
||||
item.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
export function disposeModelColliderDebugGroup(group) {
|
||||
group.traverse((object) => {
|
||||
const maybeMesh = object;
|
||||
if (maybeMesh.isMesh !== true) {
|
||||
return;
|
||||
}
|
||||
maybeMesh.geometry.dispose();
|
||||
disposeMaterial(maybeMesh.material);
|
||||
});
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
import { Euler, Group, MathUtils, Matrix4, Mesh, Quaternion, Vector3 } from "three";
|
||||
const TERRAIN_GRID_EPSILON = 1e-4;
|
||||
const DYNAMIC_TRIANGLE_TARGET = 48;
|
||||
const DYNAMIC_SPLIT_DEPTH_LIMIT = 3;
|
||||
export class ModelColliderGenerationError extends Error {
|
||||
code;
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = "ModelColliderGenerationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
function cloneVec3(vector) {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
function vector3ToVec3(vector) {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
function createBounds(min, max) {
|
||||
return {
|
||||
min: vector3ToVec3(min),
|
||||
max: vector3ToVec3(max)
|
||||
};
|
||||
}
|
||||
function createModelTransform(modelInstance) {
|
||||
return {
|
||||
position: cloneVec3(modelInstance.position),
|
||||
rotationDegrees: cloneVec3(modelInstance.rotationDegrees),
|
||||
scale: cloneVec3(modelInstance.scale)
|
||||
};
|
||||
}
|
||||
function createModelTransformMatrix(modelInstance) {
|
||||
const rotation = new Euler(MathUtils.degToRad(modelInstance.rotationDegrees.x), MathUtils.degToRad(modelInstance.rotationDegrees.y), MathUtils.degToRad(modelInstance.rotationDegrees.z), "XYZ");
|
||||
const quaternion = new Quaternion().setFromEuler(rotation);
|
||||
return new Matrix4().compose(new Vector3(modelInstance.position.x, modelInstance.position.y, modelInstance.position.z), quaternion, new Vector3(modelInstance.scale.x, modelInstance.scale.y, modelInstance.scale.z));
|
||||
}
|
||||
function computeBoundsFromPoints(points) {
|
||||
const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
|
||||
const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY);
|
||||
let hasPoint = false;
|
||||
for (const point of points) {
|
||||
hasPoint = true;
|
||||
min.min(point);
|
||||
max.max(point);
|
||||
}
|
||||
if (!hasPoint) {
|
||||
throw new ModelColliderGenerationError("missing-model-collider-geometry", "The selected model does not contain any collision-capable geometry.");
|
||||
}
|
||||
return createBounds(min, max);
|
||||
}
|
||||
function computeBoundsFromFloat32Points(points) {
|
||||
if (points.length < 3) {
|
||||
throw new ModelColliderGenerationError("missing-model-collider-geometry", "The selected model does not contain any collision-capable geometry.");
|
||||
}
|
||||
const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
|
||||
const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY);
|
||||
for (let index = 0; index < points.length; index += 3) {
|
||||
min.x = Math.min(min.x, points[index]);
|
||||
min.y = Math.min(min.y, points[index + 1]);
|
||||
min.z = Math.min(min.z, points[index + 2]);
|
||||
max.x = Math.max(max.x, points[index]);
|
||||
max.y = Math.max(max.y, points[index + 1]);
|
||||
max.z = Math.max(max.z, points[index + 2]);
|
||||
}
|
||||
return createBounds(min, max);
|
||||
}
|
||||
function computeWorldBoundsFromLocalBox(localBounds, modelMatrix) {
|
||||
const min = localBounds.min;
|
||||
const max = localBounds.max;
|
||||
const corners = [
|
||||
new Vector3(min.x, min.y, min.z),
|
||||
new Vector3(min.x, min.y, max.z),
|
||||
new Vector3(min.x, max.y, min.z),
|
||||
new Vector3(min.x, max.y, max.z),
|
||||
new Vector3(max.x, min.y, min.z),
|
||||
new Vector3(max.x, min.y, max.z),
|
||||
new Vector3(max.x, max.y, min.z),
|
||||
new Vector3(max.x, max.y, max.z)
|
||||
];
|
||||
return computeBoundsFromPoints(corners.map((corner) => corner.applyMatrix4(modelMatrix)));
|
||||
}
|
||||
function readIndexedVertex(position, index, matrix) {
|
||||
return new Vector3(position.getX(index), position.getY(index), position.getZ(index)).applyMatrix4(matrix);
|
||||
}
|
||||
function getMeshGeometry(object) {
|
||||
const maybeMesh = object;
|
||||
if (maybeMesh.isMesh !== true) {
|
||||
return null;
|
||||
}
|
||||
return maybeMesh.geometry;
|
||||
}
|
||||
function collectMeshTriangleClusters(template) {
|
||||
template.updateMatrixWorld(true);
|
||||
const clusters = [];
|
||||
template.traverse((object) => {
|
||||
const geometry = getMeshGeometry(object);
|
||||
if (geometry === null) {
|
||||
return;
|
||||
}
|
||||
const position = geometry.getAttribute("position");
|
||||
if (position === undefined || position.itemSize < 3 || position.count < 3) {
|
||||
return;
|
||||
}
|
||||
const matrix = object.matrixWorld;
|
||||
const index = geometry.getIndex();
|
||||
const triangles = [];
|
||||
if (index === null) {
|
||||
for (let vertexIndex = 0; vertexIndex <= position.count - 3; vertexIndex += 3) {
|
||||
triangles.push({
|
||||
a: readIndexedVertex(position, vertexIndex, matrix),
|
||||
b: readIndexedVertex(position, vertexIndex + 1, matrix),
|
||||
c: readIndexedVertex(position, vertexIndex + 2, matrix)
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (let triangleIndex = 0; triangleIndex <= index.count - 3; triangleIndex += 3) {
|
||||
triangles.push({
|
||||
a: readIndexedVertex(position, index.getX(triangleIndex), matrix),
|
||||
b: readIndexedVertex(position, index.getX(triangleIndex + 1), matrix),
|
||||
c: readIndexedVertex(position, index.getX(triangleIndex + 2), matrix)
|
||||
});
|
||||
}
|
||||
}
|
||||
if (triangles.length > 0) {
|
||||
clusters.push({
|
||||
triangles
|
||||
});
|
||||
}
|
||||
});
|
||||
return clusters;
|
||||
}
|
||||
function flattenTriangleClusters(clusters) {
|
||||
return clusters.flatMap((cluster) => cluster.triangles);
|
||||
}
|
||||
function buildTriMeshBuffers(triangles) {
|
||||
const vertices = new Float32Array(triangles.length * 9);
|
||||
const indices = new Uint32Array(triangles.length * 3);
|
||||
let vertexOffset = 0;
|
||||
for (let triangleIndex = 0; triangleIndex < triangles.length; triangleIndex += 1) {
|
||||
const triangle = triangles[triangleIndex];
|
||||
vertices[vertexOffset] = triangle.a.x;
|
||||
vertices[vertexOffset + 1] = triangle.a.y;
|
||||
vertices[vertexOffset + 2] = triangle.a.z;
|
||||
vertices[vertexOffset + 3] = triangle.b.x;
|
||||
vertices[vertexOffset + 4] = triangle.b.y;
|
||||
vertices[vertexOffset + 5] = triangle.b.z;
|
||||
vertices[vertexOffset + 6] = triangle.c.x;
|
||||
vertices[vertexOffset + 7] = triangle.c.y;
|
||||
vertices[vertexOffset + 8] = triangle.c.z;
|
||||
indices[triangleIndex * 3] = triangleIndex * 3;
|
||||
indices[triangleIndex * 3 + 1] = triangleIndex * 3 + 1;
|
||||
indices[triangleIndex * 3 + 2] = triangleIndex * 3 + 2;
|
||||
vertexOffset += 9;
|
||||
}
|
||||
return {
|
||||
vertices,
|
||||
indices
|
||||
};
|
||||
}
|
||||
function computeClusterCentroid(triangles) {
|
||||
const centroid = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
let pointCount = 0;
|
||||
for (const triangle of triangles) {
|
||||
centroid.x += triangle.a.x + triangle.b.x + triangle.c.x;
|
||||
centroid.y += triangle.a.y + triangle.b.y + triangle.c.y;
|
||||
centroid.z += triangle.a.z + triangle.b.z + triangle.c.z;
|
||||
pointCount += 3;
|
||||
}
|
||||
return {
|
||||
x: centroid.x / pointCount,
|
||||
y: centroid.y / pointCount,
|
||||
z: centroid.z / pointCount
|
||||
};
|
||||
}
|
||||
function getTriangleBounds(triangles) {
|
||||
return computeBoundsFromPoints(triangles.flatMap((triangle) => [triangle.a, triangle.b, triangle.c]));
|
||||
}
|
||||
function splitTriangleCluster(triangles, depth) {
|
||||
if (triangles.length <= DYNAMIC_TRIANGLE_TARGET || depth >= DYNAMIC_SPLIT_DEPTH_LIMIT) {
|
||||
return {
|
||||
kind: "leaf",
|
||||
triangles
|
||||
};
|
||||
}
|
||||
const bounds = getTriangleBounds(triangles);
|
||||
const size = {
|
||||
x: bounds.max.x - bounds.min.x,
|
||||
y: bounds.max.y - bounds.min.y,
|
||||
z: bounds.max.z - bounds.min.z
|
||||
};
|
||||
const splitAxis = size.x >= size.y && size.x >= size.z ? "x" : size.y >= size.z ? "y" : "z";
|
||||
const sortedTriangles = [...triangles].sort((left, right) => computeClusterCentroid([left])[splitAxis] - computeClusterCentroid([right])[splitAxis]);
|
||||
const splitIndex = Math.floor(sortedTriangles.length * 0.5);
|
||||
if (splitIndex <= 0 || splitIndex >= sortedTriangles.length) {
|
||||
return {
|
||||
kind: "leaf",
|
||||
triangles
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "split",
|
||||
left: sortedTriangles.slice(0, splitIndex),
|
||||
right: sortedTriangles.slice(splitIndex)
|
||||
};
|
||||
}
|
||||
function collectConvexHullPointClouds(cluster, depth = 0) {
|
||||
const split = splitTriangleCluster(cluster, depth);
|
||||
if (split.kind === "leaf") {
|
||||
return [dedupeTriangleClusterPoints(split.triangles)];
|
||||
}
|
||||
return [...collectConvexHullPointClouds(split.left, depth + 1), ...collectConvexHullPointClouds(split.right, depth + 1)];
|
||||
}
|
||||
function quantizeCoordinate(value) {
|
||||
return (Math.round(value / TERRAIN_GRID_EPSILON) * TERRAIN_GRID_EPSILON).toFixed(4);
|
||||
}
|
||||
function dedupeTriangleClusterPoints(triangles) {
|
||||
const pointLookup = new Map();
|
||||
for (const triangle of triangles) {
|
||||
for (const point of [triangle.a, triangle.b, triangle.c]) {
|
||||
const key = `${quantizeCoordinate(point.x)}:${quantizeCoordinate(point.y)}:${quantizeCoordinate(point.z)}`;
|
||||
if (!pointLookup.has(key)) {
|
||||
pointLookup.set(key, {
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
z: point.z
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pointLookup.size < 4) {
|
||||
throw new ModelColliderGenerationError("unsupported-dynamic-model-collider", "Dynamic collision requires volumetric geometry that can form at least one convex hull.");
|
||||
}
|
||||
return new Float32Array(Array.from(pointLookup.values()).flatMap((point) => [point.x, point.y, point.z]));
|
||||
}
|
||||
function buildSimpleBoxCollider(modelInstance, asset) {
|
||||
const boundingBox = asset.metadata.boundingBox;
|
||||
if (boundingBox === null) {
|
||||
throw new ModelColliderGenerationError("missing-model-collider-bounds", `Model instance ${modelInstance.id} cannot use simple collision because the asset does not have a measurable bounding box.`);
|
||||
}
|
||||
const localBounds = createBounds(new Vector3(boundingBox.min.x, boundingBox.min.y, boundingBox.min.z), new Vector3(boundingBox.max.x, boundingBox.max.y, boundingBox.max.z));
|
||||
return {
|
||||
source: "modelInstance",
|
||||
instanceId: modelInstance.id,
|
||||
assetId: modelInstance.assetId,
|
||||
mode: "simple",
|
||||
kind: "box",
|
||||
visible: modelInstance.collision.visible,
|
||||
transform: createModelTransform(modelInstance),
|
||||
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: cloneVec3(boundingBox.size),
|
||||
localBounds,
|
||||
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
|
||||
};
|
||||
}
|
||||
function buildTriMeshCollider(modelInstance, asset, loadedAsset) {
|
||||
if (loadedAsset === undefined) {
|
||||
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot build ${modelInstance.collision.mode} collision until asset geometry has loaded.`);
|
||||
}
|
||||
const triangles = flattenTriangleClusters(collectMeshTriangleClusters(loadedAsset.template));
|
||||
if (triangles.length === 0) {
|
||||
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot use ${modelInstance.collision.mode} collision because the asset has no mesh triangles.`);
|
||||
}
|
||||
const buffers = buildTriMeshBuffers(triangles);
|
||||
const localBounds = computeBoundsFromFloat32Points(buffers.vertices);
|
||||
return {
|
||||
source: "modelInstance",
|
||||
instanceId: modelInstance.id,
|
||||
assetId: asset.id,
|
||||
mode: "static",
|
||||
kind: "trimesh",
|
||||
visible: modelInstance.collision.visible,
|
||||
transform: createModelTransform(modelInstance),
|
||||
vertices: buffers.vertices,
|
||||
indices: buffers.indices,
|
||||
triangleCount: triangles.length,
|
||||
localBounds,
|
||||
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
|
||||
};
|
||||
}
|
||||
function buildTerrainCollider(modelInstance, asset, loadedAsset) {
|
||||
if (loadedAsset === undefined) {
|
||||
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot build terrain collision until asset geometry has loaded.`);
|
||||
}
|
||||
const triangles = flattenTriangleClusters(collectMeshTriangleClusters(loadedAsset.template));
|
||||
if (triangles.length === 0) {
|
||||
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot use terrain collision because the asset has no mesh triangles.`);
|
||||
}
|
||||
const heightLookup = new Map();
|
||||
const xValues = new Map();
|
||||
const zValues = new Map();
|
||||
for (const triangle of triangles) {
|
||||
for (const point of [triangle.a, triangle.b, triangle.c]) {
|
||||
const xKey = quantizeCoordinate(point.x);
|
||||
const zKey = quantizeCoordinate(point.z);
|
||||
const key = `${xKey}:${zKey}`;
|
||||
const previousPoint = heightLookup.get(key);
|
||||
if (previousPoint !== undefined && Math.abs(previousPoint.y - point.y) > TERRAIN_GRID_EPSILON) {
|
||||
throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is not a single-valued heightfield over X/Z.`);
|
||||
}
|
||||
heightLookup.set(key, {
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
z: point.z
|
||||
});
|
||||
xValues.set(xKey, point.x);
|
||||
zValues.set(zKey, point.z);
|
||||
}
|
||||
}
|
||||
const sortedX = Array.from(xValues.values()).sort((left, right) => left - right);
|
||||
const sortedZ = Array.from(zValues.values()).sort((left, right) => left - right);
|
||||
if (sortedX.length < 2 || sortedZ.length < 2) {
|
||||
throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh does not form a regular X/Z grid.`);
|
||||
}
|
||||
const expectedTriangleCount = (sortedX.length - 1) * (sortedZ.length - 1) * 2;
|
||||
if (triangles.length !== expectedTriangleCount) {
|
||||
throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is not a clean regular-grid terrain surface.`);
|
||||
}
|
||||
const heights = new Float32Array(sortedX.length * sortedZ.length);
|
||||
for (let zIndex = 0; zIndex < sortedZ.length; zIndex += 1) {
|
||||
for (let xIndex = 0; xIndex < sortedX.length; xIndex += 1) {
|
||||
const key = `${quantizeCoordinate(sortedX[xIndex])}:${quantizeCoordinate(sortedZ[zIndex])}`;
|
||||
const point = heightLookup.get(key);
|
||||
if (point === undefined) {
|
||||
throw new ModelColliderGenerationError("unsupported-terrain-model-collider", `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is missing one or more regular-grid height samples.`);
|
||||
}
|
||||
heights[xIndex + zIndex * sortedX.length] = point.y;
|
||||
}
|
||||
}
|
||||
const localBounds = computeBoundsFromPoints(Array.from(heightLookup.values(), (point) => new Vector3(point.x, point.y, point.z)));
|
||||
return {
|
||||
source: "modelInstance",
|
||||
instanceId: modelInstance.id,
|
||||
assetId: asset.id,
|
||||
mode: "terrain",
|
||||
kind: "heightfield",
|
||||
visible: modelInstance.collision.visible,
|
||||
transform: createModelTransform(modelInstance),
|
||||
rows: sortedX.length,
|
||||
cols: sortedZ.length,
|
||||
heights,
|
||||
minX: sortedX[0],
|
||||
maxX: sortedX.at(-1) ?? sortedX[0],
|
||||
minZ: sortedZ[0],
|
||||
maxZ: sortedZ.at(-1) ?? sortedZ[0],
|
||||
localBounds,
|
||||
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
|
||||
};
|
||||
}
|
||||
function buildDynamicCollider(modelInstance, asset, loadedAsset) {
|
||||
if (loadedAsset === undefined) {
|
||||
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot build dynamic collision until asset geometry has loaded.`);
|
||||
}
|
||||
const triangleClusters = collectMeshTriangleClusters(loadedAsset.template);
|
||||
if (triangleClusters.length === 0) {
|
||||
throw new ModelColliderGenerationError("missing-model-collider-geometry", `Model instance ${modelInstance.id} cannot use dynamic collision because the asset has no mesh triangles.`);
|
||||
}
|
||||
const pieces = triangleClusters
|
||||
.flatMap((cluster) => collectConvexHullPointClouds(cluster.triangles))
|
||||
.map((points, index) => ({
|
||||
id: `${modelInstance.id}-piece-${index + 1}`,
|
||||
points,
|
||||
localBounds: computeBoundsFromFloat32Points(points)
|
||||
}));
|
||||
if (pieces.length === 0) {
|
||||
throw new ModelColliderGenerationError("unsupported-dynamic-model-collider", `Model instance ${modelInstance.id} could not derive any convex pieces for dynamic collision.`);
|
||||
}
|
||||
const localBounds = computeBoundsFromPoints(pieces.flatMap((piece) => {
|
||||
const points = [];
|
||||
for (let pointIndex = 0; pointIndex < piece.points.length; pointIndex += 3) {
|
||||
points.push(new Vector3(piece.points[pointIndex], piece.points[pointIndex + 1], piece.points[pointIndex + 2]));
|
||||
}
|
||||
return points;
|
||||
}));
|
||||
return {
|
||||
source: "modelInstance",
|
||||
instanceId: modelInstance.id,
|
||||
assetId: asset.id,
|
||||
mode: "dynamic",
|
||||
kind: "compound",
|
||||
visible: modelInstance.collision.visible,
|
||||
transform: createModelTransform(modelInstance),
|
||||
pieces,
|
||||
decomposition: "spatial-bisect",
|
||||
runtimeBehavior: "fixedQueryOnly",
|
||||
localBounds,
|
||||
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
|
||||
};
|
||||
}
|
||||
export function buildGeneratedModelCollider(modelInstance, asset, loadedAsset) {
|
||||
switch (modelInstance.collision.mode) {
|
||||
case "none":
|
||||
return null;
|
||||
case "simple":
|
||||
return buildSimpleBoxCollider(modelInstance, asset);
|
||||
case "static":
|
||||
return buildTriMeshCollider(modelInstance, asset, loadedAsset);
|
||||
case "terrain":
|
||||
return buildTerrainCollider(modelInstance, asset, loadedAsset);
|
||||
case "dynamic":
|
||||
return buildDynamicCollider(modelInstance, asset, loadedAsset);
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
export const INTERACTION_TRIGGER_KINDS = ["enter", "exit", "click"];
|
||||
function assertNonEmptyString(value, label) {
|
||||
if (value.trim().length === 0) {
|
||||
throw new Error(`${label} must be non-empty.`);
|
||||
}
|
||||
}
|
||||
function cloneAction(action) {
|
||||
switch (action.type) {
|
||||
case "teleportPlayer":
|
||||
return {
|
||||
type: "teleportPlayer",
|
||||
targetEntityId: action.targetEntityId
|
||||
};
|
||||
case "toggleVisibility":
|
||||
return {
|
||||
type: "toggleVisibility",
|
||||
targetBrushId: action.targetBrushId,
|
||||
visible: action.visible
|
||||
};
|
||||
case "playAnimation":
|
||||
return {
|
||||
type: "playAnimation",
|
||||
targetModelInstanceId: action.targetModelInstanceId,
|
||||
clipName: action.clipName,
|
||||
loop: action.loop
|
||||
};
|
||||
case "stopAnimation":
|
||||
return {
|
||||
type: "stopAnimation",
|
||||
targetModelInstanceId: action.targetModelInstanceId
|
||||
};
|
||||
case "playSound":
|
||||
return {
|
||||
type: "playSound",
|
||||
targetSoundEmitterId: action.targetSoundEmitterId
|
||||
};
|
||||
case "stopSound":
|
||||
return {
|
||||
type: "stopSound",
|
||||
targetSoundEmitterId: action.targetSoundEmitterId
|
||||
};
|
||||
}
|
||||
}
|
||||
export function isInteractionTriggerKind(value) {
|
||||
return value === "enter" || value === "exit" || value === "click";
|
||||
}
|
||||
export function createTeleportPlayerInteractionLink(options) {
|
||||
assertNonEmptyString(options.sourceEntityId, "Interaction source entity id");
|
||||
assertNonEmptyString(options.targetEntityId, "Teleport target entity id");
|
||||
return {
|
||||
id: options.id ?? createOpaqueId("interaction-link"),
|
||||
sourceEntityId: options.sourceEntityId,
|
||||
trigger: options.trigger ?? "enter",
|
||||
action: {
|
||||
type: "teleportPlayer",
|
||||
targetEntityId: options.targetEntityId
|
||||
}
|
||||
};
|
||||
}
|
||||
export function createToggleVisibilityInteractionLink(options) {
|
||||
assertNonEmptyString(options.sourceEntityId, "Interaction source entity id");
|
||||
assertNonEmptyString(options.targetBrushId, "Visibility target brush id");
|
||||
if (options.visible !== undefined && typeof options.visible !== "boolean") {
|
||||
throw new Error("Visibility action visible must be a boolean when authored.");
|
||||
}
|
||||
return {
|
||||
id: options.id ?? createOpaqueId("interaction-link"),
|
||||
sourceEntityId: options.sourceEntityId,
|
||||
trigger: options.trigger ?? "enter",
|
||||
action: {
|
||||
type: "toggleVisibility",
|
||||
targetBrushId: options.targetBrushId,
|
||||
visible: options.visible
|
||||
}
|
||||
};
|
||||
}
|
||||
export function createPlayAnimationInteractionLink(options) {
|
||||
assertNonEmptyString(options.sourceEntityId, "Interaction source entity id");
|
||||
assertNonEmptyString(options.targetModelInstanceId, "Play animation target model instance id");
|
||||
assertNonEmptyString(options.clipName, "Play animation clip name");
|
||||
return {
|
||||
id: options.id ?? createOpaqueId("interaction-link"),
|
||||
sourceEntityId: options.sourceEntityId,
|
||||
trigger: options.trigger ?? "enter",
|
||||
action: {
|
||||
type: "playAnimation",
|
||||
targetModelInstanceId: options.targetModelInstanceId,
|
||||
clipName: options.clipName,
|
||||
loop: options.loop
|
||||
}
|
||||
};
|
||||
}
|
||||
export function createStopAnimationInteractionLink(options) {
|
||||
assertNonEmptyString(options.sourceEntityId, "Interaction source entity id");
|
||||
assertNonEmptyString(options.targetModelInstanceId, "Stop animation target model instance id");
|
||||
return {
|
||||
id: options.id ?? createOpaqueId("interaction-link"),
|
||||
sourceEntityId: options.sourceEntityId,
|
||||
trigger: options.trigger ?? "enter",
|
||||
action: {
|
||||
type: "stopAnimation",
|
||||
targetModelInstanceId: options.targetModelInstanceId
|
||||
}
|
||||
};
|
||||
}
|
||||
export function createPlaySoundInteractionLink(options) {
|
||||
assertNonEmptyString(options.sourceEntityId, "Interaction source entity id");
|
||||
assertNonEmptyString(options.targetSoundEmitterId, "Play sound target sound emitter id");
|
||||
return {
|
||||
id: options.id ?? createOpaqueId("interaction-link"),
|
||||
sourceEntityId: options.sourceEntityId,
|
||||
trigger: options.trigger ?? "enter",
|
||||
action: {
|
||||
type: "playSound",
|
||||
targetSoundEmitterId: options.targetSoundEmitterId
|
||||
}
|
||||
};
|
||||
}
|
||||
export function createStopSoundInteractionLink(options) {
|
||||
assertNonEmptyString(options.sourceEntityId, "Interaction source entity id");
|
||||
assertNonEmptyString(options.targetSoundEmitterId, "Stop sound target sound emitter id");
|
||||
return {
|
||||
id: options.id ?? createOpaqueId("interaction-link"),
|
||||
sourceEntityId: options.sourceEntityId,
|
||||
trigger: options.trigger ?? "enter",
|
||||
action: {
|
||||
type: "stopSound",
|
||||
targetSoundEmitterId: options.targetSoundEmitterId
|
||||
}
|
||||
};
|
||||
}
|
||||
export function cloneInteractionLink(link) {
|
||||
return {
|
||||
id: link.id,
|
||||
sourceEntityId: link.sourceEntityId,
|
||||
trigger: link.trigger,
|
||||
action: cloneAction(link.action)
|
||||
};
|
||||
}
|
||||
export function areInteractionLinksEqual(left, right) {
|
||||
if (left.id !== right.id || left.sourceEntityId !== right.sourceEntityId || left.trigger !== right.trigger) {
|
||||
return false;
|
||||
}
|
||||
if (left.action.type !== right.action.type) {
|
||||
return false;
|
||||
}
|
||||
switch (left.action.type) {
|
||||
case "teleportPlayer":
|
||||
return left.action.targetEntityId === right.action.targetEntityId;
|
||||
case "toggleVisibility":
|
||||
return (left.action.targetBrushId === right.action.targetBrushId &&
|
||||
left.action.visible === right.action.visible);
|
||||
case "playAnimation":
|
||||
return (left.action.targetModelInstanceId === right.action.targetModelInstanceId &&
|
||||
left.action.clipName === right.action.clipName &&
|
||||
left.action.loop === right.action.loop);
|
||||
case "stopAnimation":
|
||||
return left.action.targetModelInstanceId === right.action.targetModelInstanceId;
|
||||
case "playSound":
|
||||
return left.action.targetSoundEmitterId === right.action.targetSoundEmitterId;
|
||||
case "stopSound":
|
||||
return left.action.targetSoundEmitterId === right.action.targetSoundEmitterId;
|
||||
default: {
|
||||
// Exhaustive check — TypeScript should never reach here
|
||||
const _exhaustive = left.action;
|
||||
void _exhaustive;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
export function cloneInteractionLinkRegistry(links) {
|
||||
return Object.fromEntries(Object.entries(links).map(([linkId, link]) => [linkId, cloneInteractionLink(link)]));
|
||||
}
|
||||
export function compareInteractionLinks(left, right) {
|
||||
if (left.sourceEntityId !== right.sourceEntityId) {
|
||||
return left.sourceEntityId.localeCompare(right.sourceEntityId);
|
||||
}
|
||||
if (left.trigger !== right.trigger) {
|
||||
return left.trigger.localeCompare(right.trigger);
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
export function getInteractionLinks(links) {
|
||||
return Object.values(links).sort(compareInteractionLinks);
|
||||
}
|
||||
export function getInteractionLinksForSource(links, sourceEntityId) {
|
||||
return getInteractionLinks(links).filter((link) => link.sourceEntityId === sourceEntityId);
|
||||
}
|
||||
23
src/main.js
23
src/main.js
@@ -1,23 +0,0 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./app/App";
|
||||
import "./app/app.css";
|
||||
import { createEditorStore } from "./app/editor-store";
|
||||
import { getBrowserStorageAccess, loadOrCreateSceneDocument } from "./serialization/local-draft-storage";
|
||||
const rootElement = document.getElementById("root");
|
||||
if (rootElement === null) {
|
||||
throw new Error("Expected #root element to bootstrap the editor.");
|
||||
}
|
||||
const storageAccess = getBrowserStorageAccess();
|
||||
const bootstrapResult = loadOrCreateSceneDocument(storageAccess.storage);
|
||||
const editorStore = createEditorStore({
|
||||
initialDocument: bootstrapResult.document,
|
||||
initialViewportLayoutState: bootstrapResult.viewportLayoutState ?? undefined,
|
||||
storage: storageAccess.storage
|
||||
});
|
||||
const initialStatusMessage = [storageAccess.diagnostic, bootstrapResult.diagnostic].filter(Boolean).join(" ") || undefined;
|
||||
if (import.meta.env.DEV) {
|
||||
window.__webeditor3dEditorStore = editorStore;
|
||||
}
|
||||
ReactDOM.createRoot(rootElement).render(_jsx(React.StrictMode, { children: _jsx(App, { store: editorStore, initialStatusMessage: initialStatusMessage }) }));
|
||||
@@ -1,46 +0,0 @@
|
||||
export const STARTER_MATERIAL_LIBRARY = [
|
||||
{
|
||||
id: "starter-amber-grid",
|
||||
name: "Amber Grid",
|
||||
baseColorHex: "#c79a63",
|
||||
accentColorHex: "#5f3820",
|
||||
pattern: "grid",
|
||||
tags: ["starter", "wall"]
|
||||
},
|
||||
{
|
||||
id: "starter-concrete-checker",
|
||||
name: "Concrete Checker",
|
||||
baseColorHex: "#7d838c",
|
||||
accentColorHex: "#5a616a",
|
||||
pattern: "checker",
|
||||
tags: ["starter", "floor"]
|
||||
},
|
||||
{
|
||||
id: "starter-hazard-stripe",
|
||||
name: "Hazard Stripe",
|
||||
baseColorHex: "#d1a245",
|
||||
accentColorHex: "#211b16",
|
||||
pattern: "stripes",
|
||||
tags: ["starter", "warning"]
|
||||
},
|
||||
{
|
||||
id: "starter-night-diamond",
|
||||
name: "Night Diamond",
|
||||
baseColorHex: "#5a6985",
|
||||
accentColorHex: "#1f2836",
|
||||
pattern: "diamond",
|
||||
tags: ["starter", "trim"]
|
||||
}
|
||||
];
|
||||
export function cloneMaterialDef(material) {
|
||||
return {
|
||||
...material,
|
||||
tags: [...material.tags]
|
||||
};
|
||||
}
|
||||
export function cloneMaterialRegistry(materials) {
|
||||
return Object.fromEntries(Object.entries(materials).map(([materialId, material]) => [materialId, cloneMaterialDef(material)]));
|
||||
}
|
||||
export function createStarterMaterialRegistry() {
|
||||
return Object.fromEntries(STARTER_MATERIAL_LIBRARY.map((material) => [material.id, cloneMaterialDef(material)]));
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { CanvasTexture, RepeatWrapping, SRGBColorSpace } from "three";
|
||||
export function createStarterMaterialSignature(material) {
|
||||
return `${material.baseColorHex}|${material.accentColorHex}|${material.pattern}`;
|
||||
}
|
||||
function fillMaterialPattern(context, material, size) {
|
||||
context.fillStyle = material.baseColorHex;
|
||||
context.fillRect(0, 0, size, size);
|
||||
context.strokeStyle = material.accentColorHex;
|
||||
context.fillStyle = material.accentColorHex;
|
||||
switch (material.pattern) {
|
||||
case "grid":
|
||||
context.lineWidth = Math.max(2, size / 32);
|
||||
for (let offset = 0; offset <= size; offset += size / 4) {
|
||||
context.beginPath();
|
||||
context.moveTo(offset, 0);
|
||||
context.lineTo(offset, size);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(0, offset);
|
||||
context.lineTo(size, offset);
|
||||
context.stroke();
|
||||
}
|
||||
break;
|
||||
case "checker": {
|
||||
const checkerSize = size / 4;
|
||||
for (let row = 0; row < 4; row += 1) {
|
||||
for (let column = 0; column < 4; column += 1) {
|
||||
if ((row + column) % 2 === 0) {
|
||||
context.fillRect(column * checkerSize, row * checkerSize, checkerSize, checkerSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "stripes":
|
||||
context.lineWidth = size / 6;
|
||||
for (let offset = -size; offset <= size * 2; offset += size / 3) {
|
||||
context.beginPath();
|
||||
context.moveTo(offset, size);
|
||||
context.lineTo(offset + size, 0);
|
||||
context.stroke();
|
||||
}
|
||||
break;
|
||||
case "diamond":
|
||||
context.lineWidth = Math.max(2, size / 28);
|
||||
for (let offset = -size; offset <= size; offset += size / 3) {
|
||||
context.beginPath();
|
||||
context.moveTo(size * 0.5, offset);
|
||||
context.lineTo(size - offset, size * 0.5);
|
||||
context.lineTo(size * 0.5, size - offset);
|
||||
context.lineTo(-offset, size * 0.5);
|
||||
context.closePath();
|
||||
context.stroke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
export function createStarterMaterialTexture(material, size = 128) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext("2d");
|
||||
if (context === null) {
|
||||
throw new Error("2D canvas context is unavailable for starter material texture generation.");
|
||||
}
|
||||
fillMaterialPattern(context, material, size);
|
||||
const texture = new CanvasTexture(canvas);
|
||||
texture.wrapS = RepeatWrapping;
|
||||
texture.wrapT = RepeatWrapping;
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { BasicShadowMap, DirectionalLight, HalfFloatType, Mesh, NoToneMapping, PCFShadowMap, PCFSoftShadowMap, PointLight, SpotLight, UnsignedByteType } from "three";
|
||||
import { BloomEffect, DepthOfFieldEffect, EffectComposer, EffectPass, NormalPass, RenderPass, SMAAEffect, SMAAPreset, SSAOEffect, ToneMappingEffect, ToneMappingMode } from "postprocessing";
|
||||
const AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE = 0.15;
|
||||
const MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS = 0.02;
|
||||
const MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS = 0.2;
|
||||
const MIN_AMBIENT_OCCLUSION_SAMPLES = 12;
|
||||
const COARSE_AMBIENT_OCCLUSION_RESOLUTION_SCALE = 0.5;
|
||||
const DETAIL_AMBIENT_OCCLUSION_RESOLUTION_SCALE = 0.75;
|
||||
const DETAIL_AMBIENT_OCCLUSION_RADIUS_SCALE = 0.35;
|
||||
const COARSE_AMBIENT_OCCLUSION_INTENSITY_SCALE = 0.45;
|
||||
const DETAIL_AMBIENT_OCCLUSION_INTENSITY_SCALE = 0.35;
|
||||
export function resolveBoxVolumeRenderPaths(settings) {
|
||||
if (!settings.enabled) {
|
||||
return {
|
||||
fog: "performance",
|
||||
water: "performance"
|
||||
};
|
||||
}
|
||||
return {
|
||||
fog: settings.fogPath,
|
||||
water: settings.waterPath
|
||||
};
|
||||
}
|
||||
export function getAdvancedRenderingShadowMapType(shadowType) {
|
||||
switch (shadowType) {
|
||||
case "basic":
|
||||
return BasicShadowMap;
|
||||
case "pcf":
|
||||
return PCFShadowMap;
|
||||
case "pcfSoft":
|
||||
return PCFSoftShadowMap;
|
||||
}
|
||||
}
|
||||
export function getAdvancedRenderingToneMappingMode(mode) {
|
||||
switch (mode) {
|
||||
case "none":
|
||||
return ToneMappingMode.LINEAR;
|
||||
case "linear":
|
||||
return ToneMappingMode.LINEAR;
|
||||
case "reinhard":
|
||||
return ToneMappingMode.REINHARD;
|
||||
case "cineon":
|
||||
return ToneMappingMode.CINEON;
|
||||
case "acesFilmic":
|
||||
return ToneMappingMode.ACES_FILMIC;
|
||||
}
|
||||
}
|
||||
export function configureAdvancedRenderingRenderer(renderer, settings) {
|
||||
renderer.shadowMap.enabled = settings.enabled && settings.shadows.enabled;
|
||||
renderer.shadowMap.type = getAdvancedRenderingShadowMapType(settings.shadows.type);
|
||||
renderer.toneMapping = NoToneMapping;
|
||||
renderer.toneMappingExposure = settings.toneMapping.exposure;
|
||||
}
|
||||
function clampAmbientOcclusionEffectRadius(radius) {
|
||||
return Math.min(Math.max(radius, MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS), MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS);
|
||||
}
|
||||
function getAmbientOcclusionSampleCount(samples) {
|
||||
return Math.max(samples, MIN_AMBIENT_OCCLUSION_SAMPLES);
|
||||
}
|
||||
export function createAdvancedRenderingComposer(renderer, scene, camera, settings) {
|
||||
// The scene is always rendered into the composer's offscreen targets first,
|
||||
// so those targets need depth for correct visibility even when no effect samples it.
|
||||
const composer = new EffectComposer(renderer, {
|
||||
depthBuffer: true,
|
||||
stencilBuffer: false,
|
||||
multisampling: 0,
|
||||
frameBufferType: renderer.capabilities.isWebGL2 ? HalfFloatType : UnsignedByteType
|
||||
});
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
const effects = [];
|
||||
if (settings.ambientOcclusion.enabled) {
|
||||
// postprocessing's internal depth-downsampling path writes zero normals unless
|
||||
// a real normal buffer is supplied, which turns SSAO into speckled noise.
|
||||
const normalPass = new NormalPass(scene, camera);
|
||||
composer.addPass(normalPass);
|
||||
const ambientOcclusionRadius = clampAmbientOcclusionEffectRadius(settings.ambientOcclusion.radius);
|
||||
const ambientOcclusionSamples = getAmbientOcclusionSampleCount(settings.ambientOcclusion.samples);
|
||||
const detailAmbientOcclusionRadius = Math.max(ambientOcclusionRadius * DETAIL_AMBIENT_OCCLUSION_RADIUS_SCALE, MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS);
|
||||
composer.addPass(new EffectPass(camera, new SSAOEffect(camera, normalPass.texture, {
|
||||
depthAwareUpsampling: true,
|
||||
luminanceInfluence: AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE,
|
||||
resolutionScale: COARSE_AMBIENT_OCCLUSION_RESOLUTION_SCALE,
|
||||
samples: ambientOcclusionSamples,
|
||||
radius: ambientOcclusionRadius,
|
||||
intensity: settings.ambientOcclusion.intensity * COARSE_AMBIENT_OCCLUSION_INTENSITY_SCALE
|
||||
}), new SSAOEffect(camera, normalPass.texture, {
|
||||
depthAwareUpsampling: true,
|
||||
luminanceInfluence: AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE,
|
||||
resolutionScale: DETAIL_AMBIENT_OCCLUSION_RESOLUTION_SCALE,
|
||||
samples: ambientOcclusionSamples,
|
||||
radius: detailAmbientOcclusionRadius,
|
||||
intensity: settings.ambientOcclusion.intensity * DETAIL_AMBIENT_OCCLUSION_INTENSITY_SCALE
|
||||
})));
|
||||
}
|
||||
if (settings.bloom.enabled) {
|
||||
effects.push(new BloomEffect({
|
||||
intensity: settings.bloom.intensity,
|
||||
luminanceThreshold: settings.bloom.threshold,
|
||||
radius: settings.bloom.radius
|
||||
}));
|
||||
}
|
||||
if (settings.depthOfField.enabled) {
|
||||
effects.push(new DepthOfFieldEffect(camera, {
|
||||
focusDistance: settings.depthOfField.focusDistance,
|
||||
focalLength: settings.depthOfField.focalLength,
|
||||
bokehScale: settings.depthOfField.bokehScale
|
||||
}));
|
||||
}
|
||||
effects.push(new ToneMappingEffect({
|
||||
mode: getAdvancedRenderingToneMappingMode(settings.toneMapping.mode)
|
||||
}));
|
||||
effects.push(new SMAAEffect({
|
||||
preset: SMAAPreset.MEDIUM
|
||||
}));
|
||||
composer.addPass(new EffectPass(camera, ...effects));
|
||||
return composer;
|
||||
}
|
||||
export function applyAdvancedRenderingRenderableShadowFlags(root, enabled) {
|
||||
root.traverse((object) => {
|
||||
if (object.isMesh === true && object.userData.shadowIgnored !== true) {
|
||||
const mesh = object;
|
||||
mesh.castShadow = enabled;
|
||||
mesh.receiveShadow = enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
export function applyAdvancedRenderingLightShadowFlags(root, settings) {
|
||||
const shadowEnabled = settings.enabled && settings.shadows.enabled;
|
||||
root.traverse((object) => {
|
||||
if (object instanceof DirectionalLight || object instanceof PointLight || object instanceof SpotLight) {
|
||||
object.castShadow = shadowEnabled;
|
||||
object.shadow.bias = settings.shadows.bias;
|
||||
object.shadow.mapSize.set(settings.shadows.mapSize, settings.shadows.mapSize);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { BackSide, Color, ShaderMaterial, UniformsLib, UniformsUtils, Vector3 } from "three";
|
||||
const MIN_FOG_HALF_SIZE = 0.05;
|
||||
export function createFogQualityMaterial(options) {
|
||||
const halfSize = new Vector3(Math.max(MIN_FOG_HALF_SIZE, options.halfSize.x), Math.max(MIN_FOG_HALF_SIZE, options.halfSize.y), Math.max(MIN_FOG_HALF_SIZE, options.halfSize.z));
|
||||
const minHalfExtent = Math.min(halfSize.x, halfSize.y, halfSize.z);
|
||||
const padding = Math.max(0, Math.min(options.padding, minHalfExtent * 0.82));
|
||||
const animationUniform = { value: options.time };
|
||||
const uniforms = UniformsUtils.clone(UniformsLib.fog);
|
||||
uniforms["time"] = animationUniform;
|
||||
uniforms["volumeFogColor"] = { value: new Color(options.colorHex) };
|
||||
uniforms["volumeFogDensity"] = { value: Math.max(0, options.density) };
|
||||
uniforms["volumeHalfSize"] = { value: halfSize };
|
||||
uniforms["volumePadding"] = { value: padding };
|
||||
uniforms["opacityMultiplier"] = { value: Math.max(0.6, Math.min(1.5, options.opacityMultiplier ?? 1)) };
|
||||
uniforms["colorLift"] = { value: Math.max(0, Math.min(0.22, options.colorLift ?? 0)) };
|
||||
uniforms["localCameraPosition"] = { value: new Vector3() };
|
||||
const vertexShader = /* glsl */ `
|
||||
varying vec3 vLocalPosition;
|
||||
#include <fog_pars_vertex>
|
||||
|
||||
void main() {
|
||||
vLocalPosition = position;
|
||||
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
||||
vec4 mvPosition = viewMatrix * worldPosition;
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
#include <fog_vertex>
|
||||
}
|
||||
`;
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform vec3 volumeFogColor;
|
||||
uniform float volumeFogDensity;
|
||||
uniform vec3 volumeHalfSize;
|
||||
uniform float volumePadding;
|
||||
uniform float opacityMultiplier;
|
||||
uniform float colorLift;
|
||||
uniform float time;
|
||||
uniform vec3 localCameraPosition;
|
||||
|
||||
varying vec3 vLocalPosition;
|
||||
#include <fog_pars_fragment>
|
||||
|
||||
#define FOG_STEPS 10
|
||||
|
||||
float hash13(vec3 point) {
|
||||
point = fract(point * 0.1031);
|
||||
point += dot(point, point.yzx + 33.33);
|
||||
return fract((point.x + point.y) * point.z);
|
||||
}
|
||||
|
||||
float noise3(vec3 point) {
|
||||
vec3 cell = floor(point);
|
||||
vec3 local = fract(point);
|
||||
vec3 smoothLocal = local * local * (3.0 - 2.0 * local);
|
||||
|
||||
float n000 = hash13(cell + vec3(0.0, 0.0, 0.0));
|
||||
float n100 = hash13(cell + vec3(1.0, 0.0, 0.0));
|
||||
float n010 = hash13(cell + vec3(0.0, 1.0, 0.0));
|
||||
float n110 = hash13(cell + vec3(1.0, 1.0, 0.0));
|
||||
float n001 = hash13(cell + vec3(0.0, 0.0, 1.0));
|
||||
float n101 = hash13(cell + vec3(1.0, 0.0, 1.0));
|
||||
float n011 = hash13(cell + vec3(0.0, 1.0, 1.0));
|
||||
float n111 = hash13(cell + vec3(1.0, 1.0, 1.0));
|
||||
|
||||
float nx00 = mix(n000, n100, smoothLocal.x);
|
||||
float nx10 = mix(n010, n110, smoothLocal.x);
|
||||
float nx01 = mix(n001, n101, smoothLocal.x);
|
||||
float nx11 = mix(n011, n111, smoothLocal.x);
|
||||
float nxy0 = mix(nx00, nx10, smoothLocal.y);
|
||||
float nxy1 = mix(nx01, nx11, smoothLocal.y);
|
||||
return mix(nxy0, nxy1, smoothLocal.z);
|
||||
}
|
||||
|
||||
float fbm(vec3 point) {
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
|
||||
for (int octave = 0; octave < 3; octave += 1) {
|
||||
value += amplitude * noise3(point);
|
||||
point = point * 2.04 + vec3(17.1, 31.7, 9.2);
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
vec2 intersectBox(vec3 rayOrigin, vec3 rayDirection, vec3 halfSize) {
|
||||
vec3 safeDirection = sign(rayDirection) * max(abs(rayDirection), vec3(1e-4));
|
||||
vec3 invDirection = 1.0 / safeDirection;
|
||||
vec3 t0 = (-halfSize - rayOrigin) * invDirection;
|
||||
vec3 t1 = (halfSize - rayOrigin) * invDirection;
|
||||
vec3 tMin = min(t0, t1);
|
||||
vec3 tMax = max(t0, t1);
|
||||
float nearHit = max(max(tMin.x, tMin.y), tMin.z);
|
||||
float farHit = min(min(tMax.x, tMax.y), tMax.z);
|
||||
return vec2(nearHit, farHit);
|
||||
}
|
||||
|
||||
float sampleShape(vec3 samplePosition) {
|
||||
float minHalfExtent = min(min(volumeHalfSize.x, volumeHalfSize.y), volumeHalfSize.z);
|
||||
float edgeSoftness = max(0.08, min(volumePadding + minHalfExtent * 0.16, minHalfExtent * 0.72));
|
||||
vec3 innerHalfSize = max(volumeHalfSize - vec3(edgeSoftness), vec3(minHalfExtent * 0.18));
|
||||
vec3 distanceToCore = abs(samplePosition) - innerHalfSize;
|
||||
float outsideDistance = length(max(distanceToCore, 0.0));
|
||||
float insideDistance = min(max(distanceToCore.x, max(distanceToCore.y, distanceToCore.z)), 0.0);
|
||||
float roundedBoxDistance = outsideDistance + insideDistance;
|
||||
float edgeMask = 1.0 - smoothstep(-edgeSoftness * 0.7, edgeSoftness * 1.35, roundedBoxDistance);
|
||||
|
||||
vec3 ellipsoidPosition = samplePosition / max(volumeHalfSize - vec3(edgeSoftness * 0.18), vec3(1e-3));
|
||||
float roundedMask = 1.0 - smoothstep(0.54, 1.03, length(ellipsoidPosition * vec3(0.96, 1.08, 0.96)));
|
||||
|
||||
return edgeMask * mix(0.42, 1.0, roundedMask);
|
||||
}
|
||||
|
||||
float sampleVolumeDensity(vec3 samplePosition) {
|
||||
vec3 normalizedPosition = samplePosition / max(volumeHalfSize, vec3(1e-3));
|
||||
float shape = sampleShape(samplePosition);
|
||||
|
||||
if (shape <= 1e-3) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
vec3 drift = vec3(time * 0.1, time * 0.04, -time * 0.065);
|
||||
float primary = fbm(samplePosition * 0.58 + drift);
|
||||
float secondary = fbm(samplePosition * 1.18 - drift * 1.45 + vec3(4.3, 9.7, 2.1));
|
||||
float wisps = noise3(samplePosition * 2.15 + vec3(0.0, time * 0.08, 0.0));
|
||||
float cloud = smoothstep(0.34, 0.92, primary * 0.68 + secondary * 0.24 + wisps * 0.08);
|
||||
float centerBias = 1.0 - smoothstep(0.18, 1.08, length(normalizedPosition * vec3(1.05, 0.92, 1.05)));
|
||||
float verticalBias = mix(0.9, 1.08, smoothstep(-0.75, 0.35, normalizedPosition.y));
|
||||
float carvedCloud = mix(0.42, 1.04, cloud) * mix(0.72, 1.0, centerBias);
|
||||
|
||||
return volumeFogDensity * shape * carvedCloud * verticalBias;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 rayDirection = normalize(vLocalPosition - localCameraPosition);
|
||||
vec2 hitRange = intersectBox(localCameraPosition, rayDirection, volumeHalfSize);
|
||||
float startDistance = max(hitRange.x, 0.0);
|
||||
float endDistance = hitRange.y;
|
||||
|
||||
if (endDistance <= startDistance) {
|
||||
discard;
|
||||
}
|
||||
|
||||
float rayLength = endDistance - startDistance;
|
||||
float stepLength = rayLength / float(FOG_STEPS);
|
||||
float jitter = hash13(vLocalPosition * 1.73 + vec3(time * 0.17)) - 0.5;
|
||||
float transmittance = 1.0;
|
||||
vec3 accumulatedColor = vec3(0.0);
|
||||
|
||||
for (int stepIndex = 0; stepIndex < FOG_STEPS; stepIndex += 1) {
|
||||
float sampleDistance = startDistance + (float(stepIndex) + 0.5 + jitter * 0.35) * stepLength;
|
||||
vec3 samplePosition = localCameraPosition + rayDirection * sampleDistance;
|
||||
float sampleDensity = sampleVolumeDensity(samplePosition);
|
||||
|
||||
if (sampleDensity <= 1e-4) {
|
||||
continue;
|
||||
}
|
||||
|
||||
vec3 normalizedPosition = samplePosition / max(volumeHalfSize, vec3(1e-3));
|
||||
float forwardScatter = 1.0 - abs(dot(rayDirection, normalize(samplePosition + vec3(1e-3, 2e-3, -1e-3))));
|
||||
float topLight = smoothstep(-0.2, 0.95, normalizedPosition.y);
|
||||
float coolShadow = smoothstep(0.18, 0.88, noise3(samplePosition * 0.88 - vec3(time * 0.08, 0.0, time * 0.05)));
|
||||
vec3 sampleColor = mix(volumeFogColor * 0.76, vec3(1.0), 0.06 + topLight * 0.12 + forwardScatter * 0.12);
|
||||
sampleColor = mix(sampleColor * 0.92, sampleColor, coolShadow);
|
||||
|
||||
float extinction = sampleDensity * stepLength * 1.5;
|
||||
float sampleAlpha = 1.0 - exp(-extinction);
|
||||
accumulatedColor += transmittance * sampleColor * sampleAlpha;
|
||||
transmittance *= 1.0 - sampleAlpha;
|
||||
|
||||
if (transmittance < 0.03) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
float baseAlpha = 1.0 - transmittance;
|
||||
float alpha = clamp(baseAlpha * opacityMultiplier, 0.0, 0.96);
|
||||
|
||||
if (alpha <= 0.01) {
|
||||
discard;
|
||||
}
|
||||
|
||||
vec3 color = accumulatedColor / max(baseAlpha, 1e-4);
|
||||
color = mix(color, vec3(1.0), colorLift);
|
||||
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
#include <fog_fragment>
|
||||
}
|
||||
`;
|
||||
return {
|
||||
material: new ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
uniforms,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
fog: true,
|
||||
side: BackSide
|
||||
}),
|
||||
animationUniform
|
||||
};
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Euler, Matrix4, PerspectiveCamera, Plane, Quaternion, Vector3, Vector4 } from "three";
|
||||
|
||||
const SURFACE_UP = new Vector3(0, 1, 0);
|
||||
const CAMERA_FORWARD = new Vector3(0, 0, -1);
|
||||
|
||||
function createRotationQuaternion(rotationDegrees) {
|
||||
return new Quaternion().setFromEuler(new Euler((rotationDegrees.x * Math.PI) / 180, (rotationDegrees.y * Math.PI) / 180, (rotationDegrees.z * Math.PI) / 180, "XYZ"));
|
||||
}
|
||||
|
||||
export function updatePlanarReflectionCamera(surface, sourceCamera, reflectionCamera, reflectionMatrix, clipBias = 0.003) {
|
||||
const rotation = createRotationQuaternion(surface.rotationDegrees);
|
||||
const surfaceNormal = SURFACE_UP.clone().applyQuaternion(rotation).normalize();
|
||||
const surfaceCenter = new Vector3(surface.center.x, surface.center.y, surface.center.z).add(surfaceNormal.clone().multiplyScalar(surface.size.y * 0.5));
|
||||
const cameraWorldPosition = new Vector3().setFromMatrixPosition(sourceCamera.matrixWorld);
|
||||
const sourceRotationMatrix = new Matrix4().extractRotation(sourceCamera.matrixWorld);
|
||||
const lookAtPosition = CAMERA_FORWARD.clone().applyMatrix4(sourceRotationMatrix).add(cameraWorldPosition);
|
||||
const reflectedViewPosition = surfaceCenter.clone().sub(cameraWorldPosition);
|
||||
if (reflectedViewPosition.dot(surfaceNormal) > 0) {
|
||||
return false;
|
||||
}
|
||||
reflectedViewPosition.reflect(surfaceNormal).negate();
|
||||
reflectedViewPosition.add(surfaceCenter);
|
||||
const reflectedTarget = surfaceCenter.clone().sub(lookAtPosition);
|
||||
reflectedTarget.reflect(surfaceNormal).negate();
|
||||
reflectedTarget.add(surfaceCenter);
|
||||
reflectionCamera.position.copy(reflectedViewPosition);
|
||||
reflectionCamera.up.set(0, 1, 0).applyMatrix4(sourceRotationMatrix).reflect(surfaceNormal);
|
||||
reflectionCamera.near = sourceCamera.near;
|
||||
reflectionCamera.far = sourceCamera.far;
|
||||
reflectionCamera.aspect = sourceCamera.aspect;
|
||||
reflectionCamera.projectionMatrix.copy(sourceCamera.projectionMatrix);
|
||||
reflectionCamera.projectionMatrixInverse.copy(sourceCamera.projectionMatrixInverse);
|
||||
reflectionCamera.lookAt(reflectedTarget);
|
||||
reflectionCamera.updateMatrixWorld();
|
||||
reflectionCamera.matrixWorldInverse.copy(reflectionCamera.matrixWorld).invert();
|
||||
reflectionMatrix.set(0.5, 0, 0, 0.5, 0, 0.5, 0, 0.5, 0, 0, 0.5, 0.5, 0, 0, 0, 1);
|
||||
reflectionMatrix.multiply(reflectionCamera.projectionMatrix);
|
||||
reflectionMatrix.multiply(reflectionCamera.matrixWorldInverse);
|
||||
const clipPlane = new Plane().setFromNormalAndCoplanarPoint(surfaceNormal, surfaceCenter).applyMatrix4(reflectionCamera.matrixWorldInverse);
|
||||
const clipVector = new Vector4(clipPlane.normal.x, clipPlane.normal.y, clipPlane.normal.z, clipPlane.constant);
|
||||
const projectionElements = reflectionCamera.projectionMatrix.elements;
|
||||
const q = new Vector4((Math.sign(clipVector.x) + projectionElements[8]) / projectionElements[0], (Math.sign(clipVector.y) + projectionElements[9]) / projectionElements[5], -1, (1 + projectionElements[10]) / projectionElements[14]);
|
||||
clipVector.multiplyScalar(2 / clipVector.dot(q));
|
||||
projectionElements[2] = clipVector.x;
|
||||
projectionElements[6] = clipVector.y;
|
||||
projectionElements[10] = clipVector.z + 1 - clipBias;
|
||||
projectionElements[14] = clipVector.w;
|
||||
reflectionCamera.projectionMatrixInverse.copy(reflectionCamera.projectionMatrix).invert();
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { RuntimeHost } from "../runtime-three/runtime-host";
|
||||
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style";
|
||||
export function RunnerCanvas({ runtimeScene, projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets, navigationMode, onRuntimeMessageChange, onFirstPersonTelemetryChange, onInteractionPromptChange }) {
|
||||
const containerRef = useRef(null);
|
||||
const hostRef = useRef(null);
|
||||
const [runnerMessage, setRunnerMessage] = useState(null);
|
||||
const [interactionPrompt, setInteractionPrompt] = useState(null);
|
||||
const [firstPersonTelemetry, setFirstPersonTelemetry] = useState(null);
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const runtimeHost = new RuntimeHost({
|
||||
enableRendering: true
|
||||
});
|
||||
hostRef.current = runtimeHost;
|
||||
runtimeHost.mount(container);
|
||||
runtimeHost.setRuntimeMessageHandler(onRuntimeMessageChange);
|
||||
runtimeHost.setFirstPersonTelemetryHandler((telemetry) => {
|
||||
setFirstPersonTelemetry(telemetry);
|
||||
onFirstPersonTelemetryChange(telemetry);
|
||||
});
|
||||
runtimeHost.setInteractionPromptHandler((prompt) => {
|
||||
setInteractionPrompt(prompt);
|
||||
onInteractionPromptChange(prompt);
|
||||
});
|
||||
setRunnerMessage(null);
|
||||
return () => {
|
||||
onInteractionPromptChange(null);
|
||||
setFirstPersonTelemetry(null);
|
||||
runtimeHost.dispose();
|
||||
hostRef.current = null;
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Runner initialization failed.";
|
||||
setRunnerMessage(`Runner initialization failed: ${message}`);
|
||||
onInteractionPromptChange(null);
|
||||
return;
|
||||
}
|
||||
}, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.updateAssets(projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets);
|
||||
}, [projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.loadScene(runtimeScene);
|
||||
}, [runtimeScene]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setNavigationMode(navigationMode);
|
||||
}, [navigationMode]);
|
||||
return (_jsxs("div", { ref: containerRef, className: `runner-canvas ${navigationMode === "firstPerson" && firstPersonTelemetry?.cameraSubmerged ? "runner-canvas--underwater" : ""}`, "data-testid": "runner-shell", "aria-label": "Built-in scene runner", style: createWorldBackgroundStyle(runtimeScene.world.background, runtimeScene.world.background.mode === "image" ? loadedImageAssets[runtimeScene.world.background.assetId]?.sourceUrl ?? null : null), children: [navigationMode === "firstPerson" && firstPersonTelemetry?.cameraSubmerged ? _jsx("div", { className: "runner-canvas__underwater", "aria-hidden": "true" }) : null, navigationMode === "firstPerson" ? _jsx("div", { className: "runner-canvas__crosshair", "aria-hidden": "true" }) : null, interactionPrompt !== null ? (_jsxs("div", { className: "runner-canvas__prompt", "data-testid": "runner-interaction-prompt", role: "status", "aria-live": "polite", children: [_jsx("div", { className: "runner-canvas__prompt-badge", children: "Click" }), _jsx("div", { className: "runner-canvas__prompt-text", "data-testid": "runner-interaction-prompt-text", children: interactionPrompt.prompt }), _jsxs("div", { className: "runner-canvas__prompt-meta", "data-testid": "runner-interaction-prompt-meta", children: [interactionPrompt.distance.toFixed(1), "m away \u00B7 ", interactionPrompt.range.toFixed(1), "m range"] })] })) : null, runnerMessage === null ? null : (_jsxs("div", { className: "runner-canvas__fallback", role: "status", children: [_jsx("div", { className: "runner-canvas__fallback-title", children: "Runner Unavailable" }), _jsx("div", { children: runnerMessage })] }))] }));
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { Euler, Vector3 } from "three";
|
||||
import { getFirstPersonPlayerEyeHeight } from "./player-collision";
|
||||
const LOOK_SENSITIVITY = 0.0022;
|
||||
const MOVE_SPEED = 4.5;
|
||||
const GRAVITY = 22;
|
||||
const MAX_PITCH_RADIANS = Math.PI * 0.48;
|
||||
function clampPitch(pitchRadians) {
|
||||
return Math.max(-MAX_PITCH_RADIANS, Math.min(MAX_PITCH_RADIANS, pitchRadians));
|
||||
}
|
||||
function toEyePosition(feetPosition, eyeHeight) {
|
||||
return {
|
||||
x: feetPosition.x,
|
||||
y: feetPosition.y + eyeHeight,
|
||||
z: feetPosition.z
|
||||
};
|
||||
}
|
||||
export class FirstPersonNavigationController {
|
||||
id = "firstPerson";
|
||||
context = null;
|
||||
pressedKeys = new Set();
|
||||
cameraRotation = new Euler(0, 0, 0, "YXZ");
|
||||
forwardVector = new Vector3();
|
||||
rightVector = new Vector3();
|
||||
feetPosition = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
yawRadians = 0;
|
||||
pitchRadians = 0;
|
||||
verticalVelocity = 0;
|
||||
grounded = false;
|
||||
locomotionState = "flying";
|
||||
inWaterVolume = false;
|
||||
inFogVolume = false;
|
||||
pointerLocked = false;
|
||||
initializedFromSpawn = false;
|
||||
activate(ctx) {
|
||||
this.context = ctx;
|
||||
if (!this.initializedFromSpawn) {
|
||||
const spawn = ctx.getRuntimeScene().spawn;
|
||||
this.feetPosition = {
|
||||
...spawn.position
|
||||
};
|
||||
this.yawRadians = (spawn.yawDegrees * Math.PI) / 180;
|
||||
this.pitchRadians = 0;
|
||||
this.verticalVelocity = 0;
|
||||
this.grounded = false;
|
||||
this.locomotionState = "flying";
|
||||
this.inWaterVolume = false;
|
||||
this.inFogVolume = false;
|
||||
this.initializedFromSpawn = true;
|
||||
}
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
window.addEventListener("keyup", this.handleKeyUp);
|
||||
window.addEventListener("blur", this.handleBlur);
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("pointerlockchange", this.handlePointerLockChange);
|
||||
document.addEventListener("pointerlockerror", this.handlePointerLockError);
|
||||
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
||||
this.syncPointerLockState();
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
}
|
||||
deactivate(ctx) {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
window.removeEventListener("keyup", this.handleKeyUp);
|
||||
window.removeEventListener("blur", this.handleBlur);
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("pointerlockchange", this.handlePointerLockChange);
|
||||
document.removeEventListener("pointerlockerror", this.handlePointerLockError);
|
||||
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
||||
this.pressedKeys.clear();
|
||||
if (document.pointerLockElement === ctx.domElement) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
this.pointerLocked = false;
|
||||
ctx.setRuntimeMessage(null);
|
||||
ctx.setFirstPersonTelemetry(null);
|
||||
this.context = null;
|
||||
}
|
||||
update(dt) {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
const playerShape = this.context.getRuntimeScene().playerCollider;
|
||||
const currentVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition);
|
||||
const inputX = (this.pressedKeys.has("KeyD") ? 1 : 0) - (this.pressedKeys.has("KeyA") ? 1 : 0);
|
||||
const inputZ = (this.pressedKeys.has("KeyW") ? 1 : 0) - (this.pressedKeys.has("KeyS") ? 1 : 0);
|
||||
const inputLength = Math.hypot(inputX, inputZ);
|
||||
let horizontalX = 0;
|
||||
let horizontalZ = 0;
|
||||
if (inputLength > 0) {
|
||||
const normalizedInputX = inputX / inputLength;
|
||||
const normalizedInputZ = inputZ / inputLength;
|
||||
const moveDistance = MOVE_SPEED * dt;
|
||||
this.forwardVector.set(Math.sin(this.yawRadians), 0, Math.cos(this.yawRadians));
|
||||
this.rightVector.set(-Math.cos(this.yawRadians), 0, Math.sin(this.yawRadians));
|
||||
horizontalX = (this.forwardVector.x * normalizedInputZ + this.rightVector.x * normalizedInputX) * moveDistance;
|
||||
horizontalZ = (this.forwardVector.z * normalizedInputZ + this.rightVector.z * normalizedInputX) * moveDistance;
|
||||
}
|
||||
if (playerShape.mode === "none") {
|
||||
this.verticalVelocity = 0;
|
||||
}
|
||||
else if (currentVolumeState.inWater) {
|
||||
this.verticalVelocity = 0;
|
||||
}
|
||||
else {
|
||||
this.verticalVelocity -= GRAVITY * dt;
|
||||
}
|
||||
const resolvedMotion = this.context.resolveFirstPersonMotion(this.feetPosition, {
|
||||
x: horizontalX,
|
||||
y: playerShape.mode === "none" || currentVolumeState.inWater ? 0 : this.verticalVelocity * dt,
|
||||
z: horizontalZ
|
||||
}, playerShape);
|
||||
if (resolvedMotion === null) {
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
return;
|
||||
}
|
||||
this.feetPosition = resolvedMotion.feetPosition;
|
||||
const nextVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition);
|
||||
this.inWaterVolume = nextVolumeState.inWater;
|
||||
this.inFogVolume = nextVolumeState.inFog;
|
||||
this.grounded = nextVolumeState.inWater ? false : resolvedMotion.grounded;
|
||||
if (playerShape.mode === "none") {
|
||||
this.locomotionState = "flying";
|
||||
}
|
||||
else if (this.inWaterVolume) {
|
||||
this.locomotionState = "swimming";
|
||||
}
|
||||
else if (this.grounded) {
|
||||
this.locomotionState = "grounded";
|
||||
}
|
||||
else {
|
||||
this.locomotionState = "flying";
|
||||
}
|
||||
if (this.grounded && this.verticalVelocity < 0) {
|
||||
this.verticalVelocity = 0;
|
||||
}
|
||||
else if (this.inWaterVolume) {
|
||||
this.verticalVelocity = 0;
|
||||
}
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
}
|
||||
teleportTo(feetPosition, yawDegrees) {
|
||||
this.feetPosition = {
|
||||
...feetPosition
|
||||
};
|
||||
this.yawRadians = (yawDegrees * Math.PI) / 180;
|
||||
this.pitchRadians = 0;
|
||||
this.verticalVelocity = 0;
|
||||
this.grounded = false;
|
||||
this.locomotionState = "flying";
|
||||
this.inWaterVolume = false;
|
||||
this.inFogVolume = false;
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
}
|
||||
updateCameraTransform() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider));
|
||||
this.cameraRotation.x = this.pitchRadians;
|
||||
// Authoring yaw treats 0 degrees as facing +Z, while a three.js camera
|
||||
// looks down -Z by default. Offset by 180 degrees so runtime view matches
|
||||
// the authored PlayerStart marker and movement basis.
|
||||
this.cameraRotation.y = this.yawRadians + Math.PI;
|
||||
this.cameraRotation.z = 0;
|
||||
this.context.camera.position.set(eyePosition.x, eyePosition.y, eyePosition.z);
|
||||
this.context.camera.rotation.copy(this.cameraRotation);
|
||||
}
|
||||
publishTelemetry() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider));
|
||||
const cameraVolumeState = this.context.resolvePlayerVolumeState(eyePosition);
|
||||
this.context.setFirstPersonTelemetry({
|
||||
feetPosition: {
|
||||
...this.feetPosition
|
||||
},
|
||||
eyePosition,
|
||||
grounded: this.grounded,
|
||||
locomotionState: this.locomotionState,
|
||||
inWaterVolume: this.inWaterVolume,
|
||||
cameraSubmerged: cameraVolumeState.inWater,
|
||||
inFogVolume: this.inFogVolume,
|
||||
pointerLocked: this.pointerLocked,
|
||||
spawn: this.context.getRuntimeScene().spawn
|
||||
});
|
||||
}
|
||||
syncPointerLockState() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
const pointerLocked = document.pointerLockElement === this.context.domElement;
|
||||
this.pointerLocked = pointerLocked;
|
||||
this.context.setRuntimeMessage(pointerLocked
|
||||
? "Mouse look active. Press Escape to release the cursor or switch to Orbit Visitor."
|
||||
: "Click inside the runner viewport to capture mouse look. If pointer lock fails, switch to Orbit Visitor.");
|
||||
this.publishTelemetry();
|
||||
}
|
||||
handleKeyDown = (event) => {
|
||||
this.pressedKeys.add(event.code);
|
||||
};
|
||||
handleKeyUp = (event) => {
|
||||
this.pressedKeys.delete(event.code);
|
||||
};
|
||||
handleBlur = () => {
|
||||
this.pressedKeys.clear();
|
||||
};
|
||||
handleMouseMove = (event) => {
|
||||
if (!this.pointerLocked) {
|
||||
return;
|
||||
}
|
||||
this.yawRadians -= event.movementX * LOOK_SENSITIVITY;
|
||||
this.pitchRadians = clampPitch(this.pitchRadians - event.movementY * LOOK_SENSITIVITY);
|
||||
};
|
||||
handlePointerLockChange = () => {
|
||||
this.syncPointerLockState();
|
||||
};
|
||||
handlePointerLockError = () => {
|
||||
this.context?.setRuntimeMessage("Pointer lock was unavailable in this browser context. Orbit Visitor remains available as the non-FPS fallback.");
|
||||
};
|
||||
handlePointerDown = () => {
|
||||
if (this.context === null || document.pointerLockElement === this.context.domElement) {
|
||||
return;
|
||||
}
|
||||
const pointerLockCapableElement = this.context.domElement;
|
||||
const pointerLockResult = pointerLockCapableElement.requestPointerLock();
|
||||
if (pointerLockResult instanceof Promise) {
|
||||
pointerLockResult.catch(() => {
|
||||
this.context?.setRuntimeMessage("Pointer lock request was denied. Click again or use Orbit Visitor for non-locked navigation.");
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {};
|
||||
@@ -1,117 +0,0 @@
|
||||
import { Vector3 } from "three";
|
||||
const MIN_DISTANCE = 2;
|
||||
const MAX_DISTANCE = 48;
|
||||
const MIN_PITCH = 0.15;
|
||||
const MAX_PITCH = Math.PI * 0.48;
|
||||
function clampDistance(distance) {
|
||||
return Math.max(MIN_DISTANCE, Math.min(MAX_DISTANCE, distance));
|
||||
}
|
||||
function clampPitch(pitchRadians) {
|
||||
return Math.max(MIN_PITCH, Math.min(MAX_PITCH, pitchRadians));
|
||||
}
|
||||
function cloneVec3(vector) {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
export class OrbitVisitorNavigationController {
|
||||
id = "orbitVisitor";
|
||||
context = null;
|
||||
lookAtVector = new Vector3();
|
||||
target = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
distance = 8;
|
||||
yawRadians = Math.PI * 0.25;
|
||||
pitchRadians = Math.PI * 0.35;
|
||||
dragging = false;
|
||||
lastPointerClientX = 0;
|
||||
lastPointerClientY = 0;
|
||||
initializedFromScene = false;
|
||||
activate(ctx) {
|
||||
this.context = ctx;
|
||||
if (!this.initializedFromScene) {
|
||||
const runtimeScene = ctx.getRuntimeScene();
|
||||
const focusPoint = runtimeScene.playerStart?.position ?? runtimeScene.sceneBounds?.center ?? this.target;
|
||||
const focusDistance = runtimeScene.sceneBounds
|
||||
? Math.max(runtimeScene.sceneBounds.size.x, runtimeScene.sceneBounds.size.y, runtimeScene.sceneBounds.size.z) * 1.1
|
||||
: 8;
|
||||
this.target = cloneVec3(focusPoint);
|
||||
this.distance = clampDistance(focusDistance);
|
||||
this.initializedFromScene = true;
|
||||
}
|
||||
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
||||
ctx.domElement.addEventListener("wheel", this.handleWheel, { passive: false });
|
||||
ctx.domElement.addEventListener("contextmenu", this.handleContextMenu);
|
||||
window.addEventListener("pointermove", this.handlePointerMove);
|
||||
window.addEventListener("pointerup", this.handlePointerUp);
|
||||
ctx.setRuntimeMessage("Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom.");
|
||||
ctx.setFirstPersonTelemetry(null);
|
||||
this.updateCameraTransform();
|
||||
}
|
||||
deactivate(ctx) {
|
||||
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
||||
ctx.domElement.removeEventListener("wheel", this.handleWheel);
|
||||
ctx.domElement.removeEventListener("contextmenu", this.handleContextMenu);
|
||||
window.removeEventListener("pointermove", this.handlePointerMove);
|
||||
window.removeEventListener("pointerup", this.handlePointerUp);
|
||||
ctx.setRuntimeMessage(null);
|
||||
this.dragging = false;
|
||||
this.context = null;
|
||||
}
|
||||
update(_dt) {
|
||||
void _dt;
|
||||
this.updateCameraTransform();
|
||||
}
|
||||
setFocusPoint(target) {
|
||||
this.target = cloneVec3(target);
|
||||
this.updateCameraTransform();
|
||||
}
|
||||
updateCameraTransform() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
const horizontalDistance = Math.cos(this.pitchRadians) * this.distance;
|
||||
const cameraPosition = {
|
||||
x: this.target.x + Math.sin(this.yawRadians) * horizontalDistance,
|
||||
y: this.target.y + Math.sin(this.pitchRadians) * this.distance,
|
||||
z: this.target.z + Math.cos(this.yawRadians) * horizontalDistance
|
||||
};
|
||||
this.context.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
|
||||
this.lookAtVector.set(this.target.x, this.target.y, this.target.z);
|
||||
this.context.camera.lookAt(this.lookAtVector);
|
||||
}
|
||||
handlePointerDown = (event) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
this.dragging = true;
|
||||
this.lastPointerClientX = event.clientX;
|
||||
this.lastPointerClientY = event.clientY;
|
||||
};
|
||||
handlePointerMove = (event) => {
|
||||
if (!this.dragging) {
|
||||
return;
|
||||
}
|
||||
const deltaX = event.clientX - this.lastPointerClientX;
|
||||
const deltaY = event.clientY - this.lastPointerClientY;
|
||||
this.lastPointerClientX = event.clientX;
|
||||
this.lastPointerClientY = event.clientY;
|
||||
this.yawRadians -= deltaX * 0.008;
|
||||
this.pitchRadians = clampPitch(this.pitchRadians + deltaY * 0.008);
|
||||
};
|
||||
handlePointerUp = () => {
|
||||
this.dragging = false;
|
||||
};
|
||||
handleWheel = (event) => {
|
||||
event.preventDefault();
|
||||
this.distance = clampDistance(this.distance + event.deltaY * 0.01);
|
||||
};
|
||||
handleContextMenu = (event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export const FIRST_PERSON_PLAYER_SHAPE = {
|
||||
mode: "capsule",
|
||||
radius: 0.3,
|
||||
height: 1.8,
|
||||
eyeHeight: 1.6
|
||||
};
|
||||
export function getFirstPersonPlayerEyeHeight(shape) {
|
||||
return shape.eyeHeight;
|
||||
}
|
||||
export function getFirstPersonPlayerHeight(shape) {
|
||||
switch (shape.mode) {
|
||||
case "capsule":
|
||||
return shape.height;
|
||||
case "box":
|
||||
return shape.size.y;
|
||||
case "none":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { Euler, MathUtils, Quaternion } from "three";
|
||||
const CHARACTER_CONTROLLER_OFFSET = 0.01;
|
||||
const COLLISION_EPSILON = 1e-5;
|
||||
let rapierInitPromise = null;
|
||||
function componentScale(vector, scale) {
|
||||
return {
|
||||
x: vector.x * scale.x,
|
||||
y: vector.y * scale.y,
|
||||
z: vector.z * scale.z
|
||||
};
|
||||
}
|
||||
function createRapierQuaternion(rotationDegrees) {
|
||||
const quaternion = new Quaternion().setFromEuler(new Euler(MathUtils.degToRad(rotationDegrees.x), MathUtils.degToRad(rotationDegrees.y), MathUtils.degToRad(rotationDegrees.z), "XYZ"));
|
||||
return {
|
||||
x: quaternion.x,
|
||||
y: quaternion.y,
|
||||
z: quaternion.z,
|
||||
w: quaternion.w
|
||||
};
|
||||
}
|
||||
function scaleVertices(vertices, scale) {
|
||||
const scaledVertices = new Float32Array(vertices.length);
|
||||
for (let index = 0; index < vertices.length; index += 3) {
|
||||
scaledVertices[index] = vertices[index] * scale.x;
|
||||
scaledVertices[index + 1] = vertices[index + 1] * scale.y;
|
||||
scaledVertices[index + 2] = vertices[index + 2] * scale.z;
|
||||
}
|
||||
return scaledVertices;
|
||||
}
|
||||
function scaleBoundsCenter(bounds, scale) {
|
||||
return {
|
||||
x: ((bounds.min.x + bounds.max.x) * 0.5) * scale.x,
|
||||
y: ((bounds.min.y + bounds.max.y) * 0.5) * scale.y,
|
||||
z: ((bounds.min.z + bounds.max.z) * 0.5) * scale.z
|
||||
};
|
||||
}
|
||||
function createRapierHeightfieldHeights(collider) {
|
||||
const heights = new Float32Array(collider.heights.length);
|
||||
// Rapier's heightfield samples are column-major, with the Z axis varying
|
||||
// fastest inside each X column. Our generated collider stores X-major rows
|
||||
// for easier editor/debug mesh reconstruction, so transpose here.
|
||||
for (let zIndex = 0; zIndex < collider.cols; zIndex += 1) {
|
||||
for (let xIndex = 0; xIndex < collider.rows; xIndex += 1) {
|
||||
heights[zIndex + xIndex * collider.cols] = collider.heights[xIndex + zIndex * collider.rows];
|
||||
}
|
||||
}
|
||||
return heights;
|
||||
}
|
||||
function createFixedBodyForModelCollider(world, collider) {
|
||||
return world.createRigidBody(RAPIER.RigidBodyDesc.fixed()
|
||||
.setTranslation(collider.transform.position.x, collider.transform.position.y, collider.transform.position.z)
|
||||
.setRotation(createRapierQuaternion(collider.transform.rotationDegrees)));
|
||||
}
|
||||
function attachBrushCollider(world, collider) {
|
||||
const body = world.createRigidBody(RAPIER.RigidBodyDesc.fixed()
|
||||
.setTranslation(collider.center.x, collider.center.y, collider.center.z)
|
||||
.setRotation(createRapierQuaternion(collider.rotationDegrees)));
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(collider.vertices, collider.indices), body);
|
||||
}
|
||||
function attachSimpleModelCollider(world, collider) {
|
||||
const body = createFixedBodyForModelCollider(world, collider);
|
||||
const scaledCenter = componentScale(collider.center, collider.transform.scale);
|
||||
const scaledHalfExtents = componentScale({
|
||||
x: collider.size.x * 0.5,
|
||||
y: collider.size.y * 0.5,
|
||||
z: collider.size.z * 0.5
|
||||
}, collider.transform.scale);
|
||||
world.createCollider(RAPIER.ColliderDesc.cuboid(scaledHalfExtents.x, scaledHalfExtents.y, scaledHalfExtents.z).setTranslation(scaledCenter.x, scaledCenter.y, scaledCenter.z), body);
|
||||
}
|
||||
function attachStaticModelCollider(world, collider) {
|
||||
const body = createFixedBodyForModelCollider(world, collider);
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(scaleVertices(collider.vertices, collider.transform.scale), collider.indices), body);
|
||||
}
|
||||
function attachTerrainModelCollider(world, collider) {
|
||||
if (collider.rows < 2 || collider.cols < 2) {
|
||||
throw new Error(`Terrain collider ${collider.instanceId} must have at least a 2x2 height sample grid.`);
|
||||
}
|
||||
const body = createFixedBodyForModelCollider(world, collider);
|
||||
const center = scaleBoundsCenter({
|
||||
min: {
|
||||
x: collider.minX,
|
||||
y: 0,
|
||||
z: collider.minZ
|
||||
},
|
||||
max: {
|
||||
x: collider.maxX,
|
||||
y: 0,
|
||||
z: collider.maxZ
|
||||
}
|
||||
}, collider.transform.scale);
|
||||
const rowSubdivisions = collider.rows - 1;
|
||||
const colSubdivisions = collider.cols - 1;
|
||||
world.createCollider(
|
||||
// Rapier expects the number of grid subdivisions here, while our generated
|
||||
// collider stores the sampled height grid dimensions.
|
||||
RAPIER.ColliderDesc.heightfield(rowSubdivisions, colSubdivisions, createRapierHeightfieldHeights(collider), {
|
||||
x: (collider.maxX - collider.minX) * collider.transform.scale.x,
|
||||
y: collider.transform.scale.y,
|
||||
z: (collider.maxZ - collider.minZ) * collider.transform.scale.z
|
||||
}).setTranslation(center.x, center.y, center.z), body);
|
||||
}
|
||||
function attachDynamicModelCollider(world, collider) {
|
||||
const body = createFixedBodyForModelCollider(world, collider);
|
||||
for (const piece of collider.pieces) {
|
||||
const scaledPoints = scaleVertices(piece.points, collider.transform.scale);
|
||||
const descriptor = RAPIER.ColliderDesc.convexHull(scaledPoints);
|
||||
if (descriptor === null) {
|
||||
throw new Error(`Dynamic collider piece ${piece.id} could not form a valid convex hull.`);
|
||||
}
|
||||
world.createCollider(descriptor, body);
|
||||
}
|
||||
}
|
||||
function attachModelCollider(world, collider) {
|
||||
switch (collider.kind) {
|
||||
case "box":
|
||||
attachSimpleModelCollider(world, collider);
|
||||
break;
|
||||
case "trimesh":
|
||||
attachStaticModelCollider(world, collider);
|
||||
break;
|
||||
case "heightfield":
|
||||
attachTerrainModelCollider(world, collider);
|
||||
break;
|
||||
case "compound":
|
||||
attachDynamicModelCollider(world, collider);
|
||||
break;
|
||||
}
|
||||
}
|
||||
function feetPositionToColliderCenter(feetPosition, shape) {
|
||||
switch (shape.mode) {
|
||||
case "capsule": {
|
||||
const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5);
|
||||
return {
|
||||
x: feetPosition.x,
|
||||
y: feetPosition.y + shape.radius + cylindricalHalfHeight,
|
||||
z: feetPosition.z
|
||||
};
|
||||
}
|
||||
case "box":
|
||||
return {
|
||||
x: feetPosition.x,
|
||||
y: feetPosition.y + shape.size.y * 0.5,
|
||||
z: feetPosition.z
|
||||
};
|
||||
case "none":
|
||||
return {
|
||||
...feetPosition
|
||||
};
|
||||
}
|
||||
}
|
||||
function colliderCenterToFeetPosition(center, shape) {
|
||||
switch (shape.mode) {
|
||||
case "capsule": {
|
||||
const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5);
|
||||
return {
|
||||
x: center.x,
|
||||
y: center.y - (shape.radius + cylindricalHalfHeight),
|
||||
z: center.z
|
||||
};
|
||||
}
|
||||
case "box":
|
||||
return {
|
||||
x: center.x,
|
||||
y: center.y - shape.size.y * 0.5,
|
||||
z: center.z
|
||||
};
|
||||
case "none":
|
||||
return {
|
||||
...center
|
||||
};
|
||||
}
|
||||
}
|
||||
function createPlayerCollider(world, rapier, playerShape) {
|
||||
switch (playerShape.mode) {
|
||||
case "capsule":
|
||||
return world.createCollider(rapier.ColliderDesc.capsule(Math.max(0, (playerShape.height - playerShape.radius * 2) * 0.5), playerShape.radius));
|
||||
case "box":
|
||||
return world.createCollider(rapier.ColliderDesc.cuboid(playerShape.size.x * 0.5, playerShape.size.y * 0.5, playerShape.size.z * 0.5));
|
||||
case "none":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export async function initializeRapierCollisionWorld() {
|
||||
rapierInitPromise ??= RAPIER.init().then(() => RAPIER);
|
||||
return rapierInitPromise;
|
||||
}
|
||||
export class RapierCollisionWorld {
|
||||
world;
|
||||
characterController;
|
||||
playerCollider;
|
||||
static async create(colliders, playerShape) {
|
||||
const rapier = await initializeRapierCollisionWorld();
|
||||
const world = new rapier.World({
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
});
|
||||
for (const collider of colliders) {
|
||||
if (collider.source === "brush") {
|
||||
attachBrushCollider(world, collider);
|
||||
continue;
|
||||
}
|
||||
attachModelCollider(world, collider);
|
||||
}
|
||||
const playerCollider = createPlayerCollider(world, rapier, playerShape);
|
||||
const characterController = playerCollider === null ? null : world.createCharacterController(CHARACTER_CONTROLLER_OFFSET);
|
||||
if (characterController !== null) {
|
||||
characterController.setUp({ x: 0, y: 1, z: 0 });
|
||||
characterController.setSlideEnabled(true);
|
||||
characterController.enableSnapToGround(0.2);
|
||||
characterController.enableAutostep(0.35, 0.15, false);
|
||||
characterController.setMaxSlopeClimbAngle(Math.PI * 0.45);
|
||||
characterController.setMinSlopeSlideAngle(Math.PI * 0.5);
|
||||
}
|
||||
world.step();
|
||||
return new RapierCollisionWorld(world, characterController, playerCollider);
|
||||
}
|
||||
constructor(world, characterController, playerCollider) {
|
||||
this.world = world;
|
||||
this.characterController = characterController;
|
||||
this.playerCollider = playerCollider;
|
||||
}
|
||||
resolveFirstPersonMotion(feetPosition, motion, shape) {
|
||||
if (this.playerCollider === null || this.characterController === null || shape.mode === "none") {
|
||||
return {
|
||||
feetPosition: {
|
||||
x: feetPosition.x + motion.x,
|
||||
y: feetPosition.y + motion.y,
|
||||
z: feetPosition.z + motion.z
|
||||
},
|
||||
grounded: false,
|
||||
collidedAxes: {
|
||||
x: false,
|
||||
y: false,
|
||||
z: false
|
||||
}
|
||||
};
|
||||
}
|
||||
const currentCenter = feetPositionToColliderCenter(feetPosition, shape);
|
||||
this.playerCollider.setTranslation(currentCenter);
|
||||
this.characterController.computeColliderMovement(this.playerCollider, motion);
|
||||
const correctedMovement = this.characterController.computedMovement();
|
||||
const collidedAxes = {
|
||||
x: Math.abs(correctedMovement.x - motion.x) > COLLISION_EPSILON,
|
||||
y: Math.abs(correctedMovement.y - motion.y) > COLLISION_EPSILON,
|
||||
z: Math.abs(correctedMovement.z - motion.z) > COLLISION_EPSILON
|
||||
};
|
||||
const nextCenter = {
|
||||
x: currentCenter.x + correctedMovement.x,
|
||||
y: currentCenter.y + correctedMovement.y,
|
||||
z: currentCenter.z + correctedMovement.z
|
||||
};
|
||||
this.playerCollider.setTranslation(nextCenter);
|
||||
return {
|
||||
feetPosition: colliderCenterToFeetPosition(nextCenter, shape),
|
||||
grounded: this.characterController.computedGrounded() || (motion.y < 0 && collidedAxes.y),
|
||||
collidedAxes
|
||||
};
|
||||
}
|
||||
dispose() {
|
||||
if (this.characterController !== null) {
|
||||
this.world.removeCharacterController(this.characterController);
|
||||
}
|
||||
this.world.free();
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
import { AudioListener, Group, PositionalAudio, Scene, Vector3 } from "three";
|
||||
const _listenerPosition = /*@__PURE__*/ new Vector3();
|
||||
const _emitterPosition = /*@__PURE__*/ new Vector3();
|
||||
function getErrorDetail(error) {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message.trim();
|
||||
}
|
||||
return "Unknown error.";
|
||||
}
|
||||
function formatSoundEmitterLabel(entityId, link) {
|
||||
return link === null ? entityId : `${entityId} (${link.id})`;
|
||||
}
|
||||
export function computeSoundEmitterDistanceGain(distance, refDistance, maxDistance) {
|
||||
if (!Number.isFinite(distance) || !Number.isFinite(refDistance) || !Number.isFinite(maxDistance)) {
|
||||
return 0;
|
||||
}
|
||||
if (distance <= refDistance) {
|
||||
return 1;
|
||||
}
|
||||
if (maxDistance <= refDistance) {
|
||||
return 0;
|
||||
}
|
||||
if (distance >= maxDistance) {
|
||||
return 0;
|
||||
}
|
||||
const normalizedDistance = (distance - refDistance) / (maxDistance - refDistance);
|
||||
const clampedDistance = Math.min(1, Math.max(0, normalizedDistance));
|
||||
const proximity = 1 - clampedDistance;
|
||||
const easedProximity = proximity * proximity * proximity * proximity;
|
||||
return easedProximity;
|
||||
}
|
||||
export class RuntimeAudioSystem {
|
||||
camera;
|
||||
scene;
|
||||
soundGroup = new Group();
|
||||
soundEmitters = new Map();
|
||||
pendingPlayEmitterIds = new Set();
|
||||
listener;
|
||||
runtimeScene = null;
|
||||
projectAssets = {};
|
||||
loadedAudioAssets = {};
|
||||
runtimeMessageHandler;
|
||||
currentRuntimeMessage = null;
|
||||
unlockRequested = false;
|
||||
constructor(scene, camera, runtimeMessageHandler) {
|
||||
this.scene = scene;
|
||||
this.camera = camera;
|
||||
this.runtimeMessageHandler = runtimeMessageHandler;
|
||||
this.scene.add(this.soundGroup);
|
||||
let listener = null;
|
||||
try {
|
||||
listener = new AudioListener();
|
||||
this.camera.add(listener);
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Audio is unavailable in this browser environment: ${getErrorDetail(error)}`);
|
||||
}
|
||||
this.listener = listener;
|
||||
}
|
||||
setRuntimeMessageHandler(handler) {
|
||||
this.runtimeMessageHandler = handler;
|
||||
}
|
||||
loadScene(runtimeScene) {
|
||||
this.runtimeScene = runtimeScene;
|
||||
this.rebuildSoundEmitters();
|
||||
this.queueAutoplayEmitters();
|
||||
}
|
||||
updateAssets(projectAssets, loadedAudioAssets) {
|
||||
this.projectAssets = projectAssets;
|
||||
this.loadedAudioAssets = loadedAudioAssets;
|
||||
this.rebuildSoundEmitters();
|
||||
this.queueAutoplayEmitters();
|
||||
}
|
||||
updateListenerTransform() {
|
||||
this.listener?.updateMatrixWorld(true);
|
||||
this.updateSoundEmitterVolumes();
|
||||
}
|
||||
handleUserGesture() {
|
||||
if (this.listener === null) {
|
||||
return;
|
||||
}
|
||||
const context = this.listener.context;
|
||||
if (context.state === "running") {
|
||||
if (this.unlockRequested) {
|
||||
this.unlockRequested = false;
|
||||
this.setRuntimeMessage(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.unlockRequested = true;
|
||||
void context
|
||||
.resume()
|
||||
.then(() => {
|
||||
this.unlockRequested = false;
|
||||
this.flushPendingPlays();
|
||||
this.setRuntimeMessage(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setRuntimeMessage(`Audio unlock failed: ${getErrorDetail(error)}`);
|
||||
});
|
||||
}
|
||||
playSound(soundEmitterId, link) {
|
||||
const soundEmitter = this.soundEmitters.get(soundEmitterId);
|
||||
if (soundEmitter === undefined) {
|
||||
this.setRuntimeMessage(`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} could not be found.`);
|
||||
return;
|
||||
}
|
||||
if (this.listener === null) {
|
||||
this.setRuntimeMessage("Audio is unavailable in this browser environment.");
|
||||
return;
|
||||
}
|
||||
if (soundEmitter.buffer === null) {
|
||||
const assetLabel = this.describeAudioAssetAvailability(soundEmitter.entity.audioAssetId);
|
||||
this.setRuntimeMessage(`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} cannot play because ${assetLabel}.`);
|
||||
console.warn(`playSound: ${soundEmitterId} has no playable audio buffer.`);
|
||||
return;
|
||||
}
|
||||
if (this.listener.context.state !== "running") {
|
||||
this.pendingPlayEmitterIds.add(soundEmitterId);
|
||||
this.setRuntimeMessage("Audio is locked. Click the runner to enable sound.");
|
||||
return;
|
||||
}
|
||||
this.playBufferedSound(soundEmitterId);
|
||||
}
|
||||
stopSound(soundEmitterId) {
|
||||
this.pendingPlayEmitterIds.delete(soundEmitterId);
|
||||
const soundEmitter = this.soundEmitters.get(soundEmitterId);
|
||||
if (soundEmitter === undefined || soundEmitter.audio === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
soundEmitter.audio.stop();
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`stopSound: ${soundEmitterId} could not be stopped: ${getErrorDetail(error)}`);
|
||||
}
|
||||
}
|
||||
dispose() {
|
||||
for (const soundEmitterId of this.soundEmitters.keys()) {
|
||||
this.stopSound(soundEmitterId);
|
||||
}
|
||||
this.pendingPlayEmitterIds.clear();
|
||||
for (const soundEmitter of this.soundEmitters.values()) {
|
||||
this.soundGroup.remove(soundEmitter.group);
|
||||
if (soundEmitter.audio !== null) {
|
||||
soundEmitter.group.remove(soundEmitter.audio);
|
||||
}
|
||||
}
|
||||
this.soundEmitters.clear();
|
||||
this.scene.remove(this.soundGroup);
|
||||
if (this.listener !== null) {
|
||||
this.camera.remove(this.listener);
|
||||
}
|
||||
}
|
||||
setRuntimeMessage(message) {
|
||||
if (this.currentRuntimeMessage === message) {
|
||||
return;
|
||||
}
|
||||
this.currentRuntimeMessage = message;
|
||||
this.runtimeMessageHandler?.(message);
|
||||
}
|
||||
rebuildSoundEmitters() {
|
||||
if (this.runtimeScene === null) {
|
||||
return;
|
||||
}
|
||||
for (const soundEmitter of this.soundEmitters.values()) {
|
||||
this.stopSound(soundEmitter.entity.entityId);
|
||||
this.soundGroup.remove(soundEmitter.group);
|
||||
if (soundEmitter.audio !== null) {
|
||||
soundEmitter.group.remove(soundEmitter.audio);
|
||||
}
|
||||
}
|
||||
this.soundEmitters.clear();
|
||||
for (const entity of this.runtimeScene.entities.soundEmitters) {
|
||||
const group = new Group();
|
||||
group.position.set(entity.position.x, entity.position.y, entity.position.z);
|
||||
let audio = null;
|
||||
if (this.listener !== null) {
|
||||
audio = new PositionalAudio(this.listener);
|
||||
this.configurePositionalAudio(audio, entity);
|
||||
audio.position.set(0, 0, 0);
|
||||
group.add(audio);
|
||||
}
|
||||
const buffer = this.resolveAudioBuffer(entity.audioAssetId);
|
||||
if (audio !== null && buffer !== null) {
|
||||
audio.setBuffer(buffer);
|
||||
}
|
||||
this.soundGroup.add(group);
|
||||
this.soundEmitters.set(entity.entityId, {
|
||||
entity,
|
||||
group,
|
||||
audio,
|
||||
buffer
|
||||
});
|
||||
}
|
||||
}
|
||||
resolveAudioBuffer(audioAssetId) {
|
||||
if (audioAssetId === null) {
|
||||
return null;
|
||||
}
|
||||
const loadedAsset = this.loadedAudioAssets[audioAssetId];
|
||||
if (loadedAsset !== undefined) {
|
||||
return loadedAsset.buffer;
|
||||
}
|
||||
const asset = this.projectAssets[audioAssetId];
|
||||
if (asset === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (asset.kind !== "audio") {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
describeAudioAssetAvailability(audioAssetId) {
|
||||
if (audioAssetId === null) {
|
||||
return "no assigned audio asset";
|
||||
}
|
||||
const asset = this.projectAssets[audioAssetId];
|
||||
if (asset === undefined) {
|
||||
return `missing audio asset ${audioAssetId}`;
|
||||
}
|
||||
if (asset.kind !== "audio") {
|
||||
return `asset ${audioAssetId} is not an audio asset`;
|
||||
}
|
||||
return `audio asset ${audioAssetId} is unavailable`;
|
||||
}
|
||||
queueAutoplayEmitters() {
|
||||
if (this.runtimeScene === null) {
|
||||
return;
|
||||
}
|
||||
for (const entity of this.runtimeScene.entities.soundEmitters) {
|
||||
if (entity.autoplay) {
|
||||
this.pendingPlayEmitterIds.add(entity.entityId);
|
||||
}
|
||||
}
|
||||
this.flushPendingPlays();
|
||||
}
|
||||
flushPendingPlays() {
|
||||
if (this.listener === null || this.listener.context.state !== "running") {
|
||||
return;
|
||||
}
|
||||
const pendingEmitterIds = [...this.pendingPlayEmitterIds];
|
||||
this.pendingPlayEmitterIds.clear();
|
||||
for (const soundEmitterId of pendingEmitterIds) {
|
||||
this.playBufferedSound(soundEmitterId);
|
||||
}
|
||||
}
|
||||
playBufferedSound(soundEmitterId) {
|
||||
const soundEmitter = this.soundEmitters.get(soundEmitterId);
|
||||
if (soundEmitter === undefined || soundEmitter.audio === null || soundEmitter.buffer === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
soundEmitter.audio.stop();
|
||||
}
|
||||
catch {
|
||||
// three.js audio.stop() can throw when the underlying source is not active yet.
|
||||
}
|
||||
this.configurePositionalAudio(soundEmitter.audio, soundEmitter.entity);
|
||||
this.updateSoundEmitterVolume(soundEmitter);
|
||||
soundEmitter.audio.setBuffer(soundEmitter.buffer);
|
||||
soundEmitter.audio.play();
|
||||
}
|
||||
configurePositionalAudio(audio, entity) {
|
||||
audio.setLoop(entity.loop);
|
||||
audio.setRefDistance(entity.refDistance);
|
||||
audio.setMaxDistance(entity.maxDistance);
|
||||
audio.setDistanceModel("inverse");
|
||||
audio.setRolloffFactor(0);
|
||||
}
|
||||
updateSoundEmitterVolumes() {
|
||||
if (this.listener === null) {
|
||||
return;
|
||||
}
|
||||
for (const soundEmitter of this.soundEmitters.values()) {
|
||||
this.updateSoundEmitterVolume(soundEmitter);
|
||||
}
|
||||
}
|
||||
updateSoundEmitterVolume(soundEmitter) {
|
||||
if (soundEmitter.audio === null) {
|
||||
return;
|
||||
}
|
||||
this.camera.getWorldPosition(_listenerPosition);
|
||||
soundEmitter.group.getWorldPosition(_emitterPosition);
|
||||
const distance = _listenerPosition.distanceTo(_emitterPosition);
|
||||
const attenuation = computeSoundEmitterDistanceGain(distance, soundEmitter.entity.refDistance, soundEmitter.entity.maxDistance);
|
||||
soundEmitter.audio.setVolume(soundEmitter.entity.volume * attenuation);
|
||||
}
|
||||
}
|
||||
@@ -1,991 +0,0 @@
|
||||
import { AmbientLight, AnimationClip, AnimationMixer, DirectionalLight, Euler, FogExp2, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, PointLight, Quaternion, Scene, ShaderMaterial, Vector3, SpotLight, WebGLRenderTarget, WebGLRenderer } from "three";
|
||||
import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering";
|
||||
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
|
||||
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
|
||||
import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, configureAdvancedRenderingRenderer, createAdvancedRenderingComposer, resolveBoxVolumeRenderPaths } from "../rendering/advanced-rendering";
|
||||
import { createFogQualityMaterial } from "../rendering/fog-material";
|
||||
import { collectWaterContactPatches, createWaterContactPatchAxisUniformValue, createWaterContactPatchShapeUniformValue, createWaterContactPatchUniformValue, createWaterMaterial } from "../rendering/water-material";
|
||||
import { updatePlanarReflectionCamera } from "../rendering/planar-reflection";
|
||||
import { areAdvancedRenderingSettingsEqual, cloneAdvancedRenderingSettings } from "../document/world-settings";
|
||||
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
|
||||
import { RapierCollisionWorld } from "./rapier-collision-world";
|
||||
import { RuntimeInteractionSystem } from "./runtime-interaction-system";
|
||||
import { RuntimeAudioSystem } from "./runtime-audio-system";
|
||||
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
|
||||
import { resolveUnderwaterFogState } from "./underwater-fog";
|
||||
const FALLBACK_FACE_COLOR = 0xf2ece2;
|
||||
const BOX_FACE_MATERIAL_COUNT = 6;
|
||||
const WATER_REFLECTION_UPDATE_INTERVAL_MS = 96;
|
||||
export class RuntimeHost {
|
||||
scene = new Scene();
|
||||
camera = new PerspectiveCamera(70, 1, 0.05, 1000);
|
||||
cameraForward = new Vector3();
|
||||
volumeOffset = new Vector3();
|
||||
volumeInverseRotation = new Quaternion();
|
||||
fogLocalCameraPosition = new Vector3();
|
||||
domElement;
|
||||
ambientLight = new AmbientLight();
|
||||
sunLight = new DirectionalLight();
|
||||
localLightGroup = new Group();
|
||||
brushGroup = new Group();
|
||||
modelGroup = new Group();
|
||||
waterReflectionCamera = new PerspectiveCamera();
|
||||
firstPersonController = new FirstPersonNavigationController();
|
||||
orbitVisitorController = new OrbitVisitorNavigationController();
|
||||
interactionSystem = new RuntimeInteractionSystem();
|
||||
audioSystem = new RuntimeAudioSystem(this.scene, this.camera, null);
|
||||
underwaterSceneFog = new FogExp2("#2c6f8d", 0.03);
|
||||
brushMeshes = new Map();
|
||||
volumeTime = 0;
|
||||
volumeAnimatedUniforms = [];
|
||||
runtimeWaterContactUniforms = [];
|
||||
localLightObjects = new Map();
|
||||
modelRenderObjects = new Map();
|
||||
materialTextureCache = new Map();
|
||||
animationMixers = new Map();
|
||||
instanceAnimationClips = new Map();
|
||||
controllerContext;
|
||||
renderer;
|
||||
runtimeScene = null;
|
||||
collisionWorld = null;
|
||||
collisionWorldRequestId = 0;
|
||||
currentWorld = null;
|
||||
currentAdvancedRenderingSettings = null;
|
||||
advancedRenderingComposer = null;
|
||||
projectAssets = {};
|
||||
loadedModelAssets = {};
|
||||
loadedImageAssets = {};
|
||||
resizeObserver = null;
|
||||
animationFrame = 0;
|
||||
previousFrameTime = 0;
|
||||
container = null;
|
||||
activeController = null;
|
||||
runtimeMessageHandler = null;
|
||||
firstPersonTelemetryHandler = null;
|
||||
interactionPromptHandler = null;
|
||||
currentRuntimeMessage = null;
|
||||
currentFirstPersonTelemetry = null;
|
||||
currentInteractionPrompt = null;
|
||||
constructor(options = {}) {
|
||||
const enableRendering = options.enableRendering ?? true;
|
||||
this.scene.add(this.ambientLight);
|
||||
this.scene.add(this.sunLight);
|
||||
this.scene.add(this.localLightGroup);
|
||||
this.scene.add(this.brushGroup);
|
||||
this.scene.add(this.modelGroup);
|
||||
this.underwaterSceneFog.density = 0;
|
||||
this.scene.fog = this.underwaterSceneFog;
|
||||
this.renderer = enableRendering ? new WebGLRenderer({ antialias: false, alpha: true }) : null;
|
||||
this.domElement = this.renderer?.domElement ?? document.createElement("canvas");
|
||||
if (this.renderer !== null) {
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.setClearAlpha(0);
|
||||
}
|
||||
else {
|
||||
this.domElement.className = "runner-canvas__surface";
|
||||
}
|
||||
this.controllerContext = {
|
||||
camera: this.camera,
|
||||
domElement: this.domElement,
|
||||
getRuntimeScene: () => {
|
||||
if (this.runtimeScene === null) {
|
||||
throw new Error("Runtime scene has not been loaded.");
|
||||
}
|
||||
return this.runtimeScene;
|
||||
},
|
||||
resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null,
|
||||
resolvePlayerVolumeState: (feetPosition) => this.resolvePlayerVolumeState(feetPosition),
|
||||
setRuntimeMessage: (message) => {
|
||||
if (message === this.currentRuntimeMessage) {
|
||||
return;
|
||||
}
|
||||
this.currentRuntimeMessage = message;
|
||||
this.runtimeMessageHandler?.(message);
|
||||
},
|
||||
setFirstPersonTelemetry: (telemetry) => {
|
||||
this.currentFirstPersonTelemetry = telemetry;
|
||||
this.firstPersonTelemetryHandler?.(telemetry);
|
||||
}
|
||||
};
|
||||
}
|
||||
resolvePlayerVolumeState(feetPosition) {
|
||||
if (this.runtimeScene === null) {
|
||||
return {
|
||||
inWater: false,
|
||||
inFog: false
|
||||
};
|
||||
}
|
||||
const inWater = this.runtimeScene.volumes.water.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume));
|
||||
const inFog = this.runtimeScene.volumes.fog.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume));
|
||||
return {
|
||||
inWater,
|
||||
inFog
|
||||
};
|
||||
}
|
||||
isPointInsideOrientedVolume(point, volume) {
|
||||
this.volumeOffset.set(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z);
|
||||
this.volumeInverseRotation
|
||||
.setFromEuler(new Euler((volume.rotationDegrees.x * Math.PI) / 180, (volume.rotationDegrees.y * Math.PI) / 180, (volume.rotationDegrees.z * Math.PI) / 180, "XYZ"))
|
||||
.invert();
|
||||
this.volumeOffset.applyQuaternion(this.volumeInverseRotation);
|
||||
const halfX = volume.size.x * 0.5;
|
||||
const halfY = volume.size.y * 0.5;
|
||||
const halfZ = volume.size.z * 0.5;
|
||||
return (Math.abs(this.volumeOffset.x) <= halfX &&
|
||||
Math.abs(this.volumeOffset.y) <= halfY &&
|
||||
Math.abs(this.volumeOffset.z) <= halfZ);
|
||||
}
|
||||
mount(container) {
|
||||
this.container = container;
|
||||
container.appendChild(this.domElement);
|
||||
this.domElement.addEventListener("click", this.handleRuntimeClick);
|
||||
this.domElement.addEventListener("pointerdown", this.handleRuntimePointerDown);
|
||||
this.resize();
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.resize();
|
||||
});
|
||||
this.resizeObserver.observe(container);
|
||||
this.previousFrameTime = performance.now();
|
||||
this.render();
|
||||
}
|
||||
loadScene(runtimeScene) {
|
||||
this.runtimeScene = runtimeScene;
|
||||
this.currentWorld = runtimeScene.world;
|
||||
this.interactionSystem.reset();
|
||||
this.setInteractionPrompt(null);
|
||||
this.applyWorld();
|
||||
this.rebuildLocalLights(runtimeScene.localLights);
|
||||
this.rebuildBrushMeshes(runtimeScene.brushes);
|
||||
this.rebuildModelInstances(runtimeScene.modelInstances);
|
||||
void this.rebuildCollisionWorld(runtimeScene.colliders, runtimeScene.playerCollider);
|
||||
this.audioSystem.loadScene(runtimeScene);
|
||||
}
|
||||
updateAssets(projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets) {
|
||||
this.projectAssets = projectAssets;
|
||||
this.loadedModelAssets = loadedModelAssets;
|
||||
this.loadedImageAssets = loadedImageAssets;
|
||||
if (this.currentWorld !== null) {
|
||||
this.applyWorld();
|
||||
}
|
||||
if (this.runtimeScene !== null) {
|
||||
this.rebuildModelInstances(this.runtimeScene.modelInstances);
|
||||
}
|
||||
this.audioSystem.updateAssets(projectAssets, loadedAudioAssets);
|
||||
}
|
||||
setNavigationMode(mode) {
|
||||
if (this.runtimeScene === null) {
|
||||
return;
|
||||
}
|
||||
const nextController = mode === "firstPerson" ? this.firstPersonController : this.orbitVisitorController;
|
||||
if (this.activeController?.id === nextController.id) {
|
||||
return;
|
||||
}
|
||||
if (this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null && nextController === this.orbitVisitorController) {
|
||||
this.orbitVisitorController.setFocusPoint(this.currentFirstPersonTelemetry.feetPosition);
|
||||
}
|
||||
this.activeController?.deactivate(this.controllerContext);
|
||||
this.interactionSystem.reset();
|
||||
this.setInteractionPrompt(null);
|
||||
this.activeController = nextController;
|
||||
this.activeController.activate(this.controllerContext);
|
||||
}
|
||||
setRuntimeMessageHandler(handler) {
|
||||
this.runtimeMessageHandler = handler;
|
||||
this.audioSystem.setRuntimeMessageHandler(handler);
|
||||
}
|
||||
setFirstPersonTelemetryHandler(handler) {
|
||||
this.firstPersonTelemetryHandler = handler;
|
||||
}
|
||||
setInteractionPromptHandler(handler) {
|
||||
this.interactionPromptHandler = handler;
|
||||
}
|
||||
dispose() {
|
||||
if (this.animationFrame !== 0) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = 0;
|
||||
}
|
||||
this.activeController?.deactivate(this.controllerContext);
|
||||
this.activeController = null;
|
||||
this.setInteractionPrompt(null);
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
this.clearLocalLights();
|
||||
this.clearBrushMeshes();
|
||||
this.clearModelInstances();
|
||||
this.collisionWorldRequestId += 1;
|
||||
this.clearCollisionWorld();
|
||||
this.audioSystem.dispose();
|
||||
this.advancedRenderingComposer?.dispose();
|
||||
this.advancedRenderingComposer = null;
|
||||
this.currentAdvancedRenderingSettings = null;
|
||||
this.scene.fog = null;
|
||||
if (this.renderer !== null) {
|
||||
this.renderer.autoClear = true;
|
||||
}
|
||||
for (const cachedTexture of this.materialTextureCache.values()) {
|
||||
cachedTexture.texture.dispose();
|
||||
}
|
||||
this.materialTextureCache.clear();
|
||||
this.renderer?.forceContextLoss();
|
||||
this.renderer?.dispose();
|
||||
this.domElement.removeEventListener("click", this.handleRuntimeClick);
|
||||
this.domElement.removeEventListener("pointerdown", this.handleRuntimePointerDown);
|
||||
if (this.container !== null && this.container.contains(this.domElement)) {
|
||||
this.container.removeChild(this.domElement);
|
||||
}
|
||||
this.container = null;
|
||||
}
|
||||
applyWorld() {
|
||||
if (this.currentWorld === null) {
|
||||
return;
|
||||
}
|
||||
const world = this.currentWorld;
|
||||
this.ambientLight.color.set(world.ambientLight.colorHex);
|
||||
this.ambientLight.intensity = world.ambientLight.intensity;
|
||||
this.sunLight.color.set(world.sunLight.colorHex);
|
||||
this.sunLight.intensity = world.sunLight.intensity;
|
||||
this.sunLight.position
|
||||
.set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z)
|
||||
.normalize()
|
||||
.multiplyScalar(18);
|
||||
if (world.background.mode === "image") {
|
||||
const texture = this.loadedImageAssets[world.background.assetId]?.texture ?? null;
|
||||
this.scene.background = texture;
|
||||
this.scene.environment = texture;
|
||||
this.scene.environmentIntensity = world.background.environmentIntensity;
|
||||
}
|
||||
else {
|
||||
this.scene.background = null;
|
||||
this.scene.environment = null;
|
||||
this.scene.environmentIntensity = 1;
|
||||
}
|
||||
if (this.renderer !== null) {
|
||||
configureAdvancedRenderingRenderer(this.renderer, world.advancedRendering);
|
||||
this.syncAdvancedRenderingComposer(world.advancedRendering);
|
||||
}
|
||||
this.applyShadowState();
|
||||
}
|
||||
async rebuildCollisionWorld(colliders, playerShape) {
|
||||
const requestId = ++this.collisionWorldRequestId;
|
||||
this.clearCollisionWorld();
|
||||
try {
|
||||
const nextCollisionWorld = await RapierCollisionWorld.create(colliders, playerShape);
|
||||
if (requestId !== this.collisionWorldRequestId) {
|
||||
nextCollisionWorld.dispose();
|
||||
return;
|
||||
}
|
||||
this.collisionWorld = nextCollisionWorld;
|
||||
}
|
||||
catch (error) {
|
||||
if (requestId !== this.collisionWorldRequestId) {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Runner collision initialization failed.";
|
||||
this.currentRuntimeMessage = `Runner collision initialization failed: ${message}`;
|
||||
this.runtimeMessageHandler?.(this.currentRuntimeMessage);
|
||||
}
|
||||
}
|
||||
clearCollisionWorld() {
|
||||
this.collisionWorld?.dispose();
|
||||
this.collisionWorld = null;
|
||||
}
|
||||
syncAdvancedRenderingComposer(settings) {
|
||||
if (this.renderer === null) {
|
||||
return;
|
||||
}
|
||||
const shouldUseComposer = settings.enabled;
|
||||
const settingsChanged = this.currentAdvancedRenderingSettings === null ||
|
||||
!areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings);
|
||||
if (!shouldUseComposer) {
|
||||
if (this.advancedRenderingComposer !== null) {
|
||||
this.advancedRenderingComposer.dispose();
|
||||
this.advancedRenderingComposer = null;
|
||||
}
|
||||
this.currentAdvancedRenderingSettings = null;
|
||||
this.renderer.autoClear = true;
|
||||
return;
|
||||
}
|
||||
if (this.advancedRenderingComposer !== null && !settingsChanged) {
|
||||
return;
|
||||
}
|
||||
if (this.advancedRenderingComposer !== null) {
|
||||
this.advancedRenderingComposer.dispose();
|
||||
}
|
||||
this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.camera, settings);
|
||||
this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings);
|
||||
this.renderer.autoClear = false;
|
||||
}
|
||||
applyShadowState() {
|
||||
if (this.currentWorld === null) {
|
||||
return;
|
||||
}
|
||||
const advancedRendering = this.currentWorld.advancedRendering;
|
||||
const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled;
|
||||
applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering);
|
||||
for (const renderGroup of this.localLightObjects.values()) {
|
||||
applyAdvancedRenderingLightShadowFlags(renderGroup, advancedRendering);
|
||||
}
|
||||
for (const mesh of this.brushMeshes.values()) {
|
||||
applyAdvancedRenderingRenderableShadowFlags(mesh, shadowsEnabled);
|
||||
}
|
||||
for (const renderGroup of this.modelRenderObjects.values()) {
|
||||
applyAdvancedRenderingRenderableShadowFlags(renderGroup, shadowsEnabled);
|
||||
}
|
||||
}
|
||||
rebuildLocalLights(localLights) {
|
||||
this.clearLocalLights();
|
||||
for (const pointLight of localLights.pointLights) {
|
||||
const renderObjects = this.createPointLightRuntimeObjects(pointLight);
|
||||
this.localLightGroup.add(renderObjects.group);
|
||||
this.localLightObjects.set(pointLight.entityId, renderObjects.group);
|
||||
}
|
||||
for (const spotLight of localLights.spotLights) {
|
||||
const renderObjects = this.createSpotLightRuntimeObjects(spotLight);
|
||||
this.localLightGroup.add(renderObjects.group);
|
||||
this.localLightObjects.set(spotLight.entityId, renderObjects.group);
|
||||
}
|
||||
this.applyShadowState();
|
||||
}
|
||||
createPointLightRuntimeObjects(pointLight) {
|
||||
const group = new Group();
|
||||
const light = new PointLight(pointLight.colorHex, pointLight.intensity, pointLight.distance);
|
||||
group.position.set(pointLight.position.x, pointLight.position.y, pointLight.position.z);
|
||||
light.position.set(0, 0, 0);
|
||||
group.add(light);
|
||||
return {
|
||||
group
|
||||
};
|
||||
}
|
||||
createSpotLightRuntimeObjects(spotLight) {
|
||||
const group = new Group();
|
||||
const light = new SpotLight(spotLight.colorHex, spotLight.intensity, spotLight.distance, (spotLight.angleDegrees * Math.PI) / 180, 0.18, 1);
|
||||
const direction = new Vector3(spotLight.direction.x, spotLight.direction.y, spotLight.direction.z).normalize();
|
||||
const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction);
|
||||
group.position.set(spotLight.position.x, spotLight.position.y, spotLight.position.z);
|
||||
group.quaternion.copy(orientation);
|
||||
light.position.set(0, 0, 0);
|
||||
light.target.position.set(0, 1, 0);
|
||||
group.add(light);
|
||||
group.add(light.target);
|
||||
return {
|
||||
group
|
||||
};
|
||||
}
|
||||
rebuildBrushMeshes(brushes) {
|
||||
this.clearBrushMeshes();
|
||||
const volumeRenderPaths = this.currentWorld === null ? { fog: "performance", water: "performance" } : resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering);
|
||||
for (const brush of brushes) {
|
||||
const geometry = buildBoxBrushDerivedMeshData(brush).geometry;
|
||||
const staticContactPatches = brush.volume.mode === "water" ? this.collectRuntimeStaticWaterContactPatches(brush) : [];
|
||||
const contactPatches = brush.volume.mode === "water"
|
||||
? this.mergeRuntimeWaterContactPatches(staticContactPatches, this.collectRuntimePlayerWaterContactPatches(brush))
|
||||
: [];
|
||||
const materials = this.createFogMaterialSet(brush, volumeRenderPaths) ??
|
||||
[
|
||||
this.createFaceMaterial(brush, "posX", brush.faces.posX.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
||||
this.createFaceMaterial(brush, "negX", brush.faces.negX.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
||||
this.createFaceMaterial(brush, "posY", brush.faces.posY.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
||||
this.createFaceMaterial(brush, "negY", brush.faces.negY.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
||||
this.createFaceMaterial(brush, "posZ", brush.faces.posZ.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
||||
this.createFaceMaterial(brush, "negZ", brush.faces.negZ.material, volumeRenderPaths, contactPatches, staticContactPatches)
|
||||
];
|
||||
const mesh = new Mesh(geometry, materials);
|
||||
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
|
||||
mesh.rotation.set((brush.rotationDegrees.x * Math.PI) / 180, (brush.rotationDegrees.y * Math.PI) / 180, (brush.rotationDegrees.z * Math.PI) / 180);
|
||||
this.configureFogVolumeMesh(mesh, materials);
|
||||
this.brushGroup.add(mesh);
|
||||
this.brushMeshes.set(brush.id, mesh);
|
||||
}
|
||||
this.applyShadowState();
|
||||
}
|
||||
createFogMaterialSet(brush, volumeRenderPaths) {
|
||||
if (brush.volume.mode !== "fog") {
|
||||
return null;
|
||||
}
|
||||
if (volumeRenderPaths.fog === "quality") {
|
||||
const fogMaterial = createFogQualityMaterial({
|
||||
colorHex: brush.volume.fog.colorHex,
|
||||
density: brush.volume.fog.density,
|
||||
padding: brush.volume.fog.padding,
|
||||
time: this.volumeTime,
|
||||
halfSize: {
|
||||
x: brush.size.x * 0.5,
|
||||
y: brush.size.y * 0.5,
|
||||
z: brush.size.z * 0.5
|
||||
}
|
||||
});
|
||||
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
|
||||
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial.material);
|
||||
}
|
||||
const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08));
|
||||
const fogMaterial = new MeshBasicMaterial({
|
||||
color: brush.volume.fog.colorHex,
|
||||
transparent: true,
|
||||
opacity: densityOpacity,
|
||||
depthWrite: false
|
||||
});
|
||||
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial);
|
||||
}
|
||||
configureFogVolumeMesh(mesh, materials) {
|
||||
const fogMaterials = materials.filter((material) => material instanceof ShaderMaterial && material.uniforms["localCameraPosition"] !== undefined);
|
||||
if (fogMaterials.length === 0) {
|
||||
return;
|
||||
}
|
||||
mesh.onBeforeRender = (_renderer, _scene, camera) => {
|
||||
const localCameraPosition = mesh.worldToLocal(this.fogLocalCameraPosition.copy(camera.position));
|
||||
for (const material of fogMaterials) {
|
||||
material.uniforms["localCameraPosition"].value.copy(localCameraPosition);
|
||||
}
|
||||
};
|
||||
}
|
||||
rebuildModelInstances(modelInstances) {
|
||||
this.clearModelInstances();
|
||||
for (const modelInstance of modelInstances) {
|
||||
const asset = this.projectAssets[modelInstance.assetId];
|
||||
const loadedAsset = this.loadedModelAssets[modelInstance.assetId];
|
||||
const renderGroup = createModelInstanceRenderGroup({
|
||||
id: modelInstance.instanceId,
|
||||
kind: "modelInstance",
|
||||
assetId: modelInstance.assetId,
|
||||
name: modelInstance.name,
|
||||
position: modelInstance.position,
|
||||
rotationDegrees: modelInstance.rotationDegrees,
|
||||
scale: modelInstance.scale,
|
||||
collision: {
|
||||
mode: "none",
|
||||
visible: false
|
||||
}
|
||||
}, asset, loadedAsset, false);
|
||||
this.modelGroup.add(renderGroup);
|
||||
this.modelRenderObjects.set(modelInstance.instanceId, renderGroup);
|
||||
if (loadedAsset?.animations && loadedAsset.animations.length > 0) {
|
||||
const mixer = new AnimationMixer(renderGroup);
|
||||
this.animationMixers.set(modelInstance.instanceId, mixer);
|
||||
this.instanceAnimationClips.set(modelInstance.instanceId, loadedAsset.animations);
|
||||
if (modelInstance.animationAutoplay === true && modelInstance.animationClipName) {
|
||||
const clip = AnimationClip.findByName(loadedAsset.animations, modelInstance.animationClipName);
|
||||
if (clip) {
|
||||
mixer.clipAction(clip).play();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.applyShadowState();
|
||||
}
|
||||
createFaceMaterial(brush, faceId, material, volumeRenderPaths, contactPatches, staticContactPatches) {
|
||||
if (brush.volume.mode === "water") {
|
||||
const baseOpacity = Math.max(0.05, Math.min(1, brush.volume.water.surfaceOpacity));
|
||||
const waterMaterial = createWaterMaterial({
|
||||
colorHex: brush.volume.water.colorHex,
|
||||
surfaceOpacity: brush.volume.water.surfaceOpacity,
|
||||
waveStrength: brush.volume.water.waveStrength,
|
||||
surfaceDisplacementEnabled: brush.volume.water.surfaceDisplacementEnabled,
|
||||
opacity: faceId === "posY" ? Math.min(1, baseOpacity + 0.18) : baseOpacity * 0.5,
|
||||
quality: volumeRenderPaths.water === "quality",
|
||||
wireframe: false,
|
||||
isTopFace: faceId === "posY",
|
||||
time: this.volumeTime,
|
||||
halfSize: {
|
||||
x: brush.size.x * 0.5,
|
||||
z: brush.size.z * 0.5
|
||||
},
|
||||
contactPatches,
|
||||
reflection: {
|
||||
texture: null,
|
||||
enabled: faceId === "posY"
|
||||
}
|
||||
});
|
||||
if (waterMaterial.animationUniform !== null) {
|
||||
this.volumeAnimatedUniforms.push(waterMaterial.animationUniform);
|
||||
}
|
||||
if (faceId === "posY" && waterMaterial.contactPatchesUniform !== null && waterMaterial.contactPatchAxesUniform !== null) {
|
||||
this.runtimeWaterContactUniforms.push({
|
||||
brush,
|
||||
uniform: waterMaterial.contactPatchesUniform,
|
||||
axisUniform: waterMaterial.contactPatchAxesUniform,
|
||||
shapeUniform: waterMaterial.contactPatchShapesUniform ?? { value: [] },
|
||||
staticContactPatches,
|
||||
reflectionTextureUniform: waterMaterial.reflectionTextureUniform,
|
||||
reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform,
|
||||
reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform,
|
||||
reflectionRenderTarget: this.getWaterReflectionMode() !== "none" ? this.createWaterReflectionRenderTarget() : null,
|
||||
lastReflectionUpdateTime: Number.NEGATIVE_INFINITY
|
||||
});
|
||||
}
|
||||
return waterMaterial.material;
|
||||
}
|
||||
if (brush.volume.mode === "fog") {
|
||||
if (volumeRenderPaths.fog === "quality") {
|
||||
const fogMaterial = createFogQualityMaterial({
|
||||
colorHex: brush.volume.fog.colorHex,
|
||||
density: brush.volume.fog.density,
|
||||
padding: brush.volume.fog.padding,
|
||||
time: this.volumeTime,
|
||||
halfSize: {
|
||||
x: brush.size.x * 0.5,
|
||||
y: brush.size.y * 0.5,
|
||||
z: brush.size.z * 0.5
|
||||
}
|
||||
});
|
||||
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
|
||||
return fogMaterial.material;
|
||||
}
|
||||
const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08));
|
||||
return new MeshBasicMaterial({
|
||||
color: brush.volume.fog.colorHex,
|
||||
transparent: true,
|
||||
opacity: densityOpacity,
|
||||
depthWrite: false
|
||||
});
|
||||
}
|
||||
if (material === null) {
|
||||
return new MeshStandardMaterial({
|
||||
color: FALLBACK_FACE_COLOR,
|
||||
roughness: 0.9,
|
||||
metalness: 0.05
|
||||
});
|
||||
}
|
||||
return new MeshStandardMaterial({
|
||||
color: 0xffffff,
|
||||
map: this.getOrCreateTexture(material),
|
||||
roughness: 0.92,
|
||||
metalness: 0.02
|
||||
});
|
||||
}
|
||||
const fogState = this.activeController === this.firstPersonController
|
||||
? resolveUnderwaterFogState(this.runtimeScene, this.currentFirstPersonTelemetry)
|
||||
: null;
|
||||
if (fogState === null) {
|
||||
this.underwaterSceneFog.density = 0;
|
||||
return;
|
||||
}
|
||||
this.underwaterSceneFog.color.set(fogState.colorHex);
|
||||
this.underwaterSceneFog.density = fogState.density;
|
||||
}
|
||||
getWaterReflectionMode() {
|
||||
if (this.currentWorld === null || !this.currentWorld.advancedRendering.enabled || this.currentWorld.advancedRendering.waterPath !== "quality") {
|
||||
return "none";
|
||||
}
|
||||
return this.currentWorld.advancedRendering.waterReflectionMode;
|
||||
}
|
||||
createWaterReflectionRenderTarget() {
|
||||
const canvasWidth = this.container?.clientWidth ?? this.domElement.width;
|
||||
const canvasHeight = this.container?.clientHeight ?? this.domElement.height;
|
||||
const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5));
|
||||
const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5));
|
||||
return new WebGLRenderTarget(width, height);
|
||||
}
|
||||
resizeWaterReflectionTargets() {
|
||||
const canvasWidth = this.container?.clientWidth ?? this.domElement.width;
|
||||
const canvasHeight = this.container?.clientHeight ?? this.domElement.height;
|
||||
const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5));
|
||||
const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5));
|
||||
for (const binding of this.runtimeWaterContactUniforms) {
|
||||
binding.reflectionRenderTarget?.setSize(width, height);
|
||||
binding.lastReflectionUpdateTime = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
updateRuntimeWaterReflections() {
|
||||
if (this.renderer === null || this.runtimeScene === null) {
|
||||
return;
|
||||
}
|
||||
const reflectionMode = this.getWaterReflectionMode();
|
||||
const now = performance.now();
|
||||
for (const binding of this.runtimeWaterContactUniforms) {
|
||||
if (reflectionMode === "none" ||
|
||||
binding.reflectionTextureUniform === null ||
|
||||
binding.reflectionMatrixUniform === null ||
|
||||
binding.reflectionEnabledUniform === null) {
|
||||
if (binding.reflectionEnabledUniform !== null) {
|
||||
binding.reflectionEnabledUniform.value = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (binding.reflectionRenderTarget === null) {
|
||||
binding.reflectionRenderTarget = this.createWaterReflectionRenderTarget();
|
||||
}
|
||||
const canRenderReflection = updatePlanarReflectionCamera(binding.brush, this.camera, this.waterReflectionCamera, binding.reflectionMatrixUniform.value);
|
||||
if (!canRenderReflection || binding.reflectionRenderTarget === null) {
|
||||
binding.reflectionEnabledUniform.value = 0;
|
||||
continue;
|
||||
}
|
||||
if (binding.reflectionTextureUniform.value !== null && now - binding.lastReflectionUpdateTime < WATER_REFLECTION_UPDATE_INTERVAL_MS) {
|
||||
binding.reflectionEnabledUniform.value = 0.36;
|
||||
continue;
|
||||
}
|
||||
const hiddenWaterMeshes = [];
|
||||
for (const runtimeBrush of this.runtimeScene.brushes) {
|
||||
if (runtimeBrush.volume.mode !== "water") {
|
||||
continue;
|
||||
}
|
||||
const mesh = this.brushMeshes.get(runtimeBrush.id);
|
||||
if (mesh === undefined) {
|
||||
continue;
|
||||
}
|
||||
hiddenWaterMeshes.push({ mesh, visible: mesh.visible });
|
||||
mesh.visible = false;
|
||||
}
|
||||
const previousModelGroupVisibility = this.modelGroup.visible;
|
||||
if (reflectionMode === "world") {
|
||||
this.modelGroup.visible = false;
|
||||
}
|
||||
const previousAutoClear = this.renderer.autoClear;
|
||||
const previousRenderTarget = this.renderer.getRenderTarget();
|
||||
const previousFogDensity = this.underwaterSceneFog.density;
|
||||
const previousReflectionStates = this.runtimeWaterContactUniforms.map((waterBinding) => ({
|
||||
binding: waterBinding,
|
||||
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
|
||||
texture: waterBinding.reflectionTextureUniform?.value ?? null
|
||||
}));
|
||||
try {
|
||||
this.underwaterSceneFog.density = 0;
|
||||
for (const state of previousReflectionStates) {
|
||||
if (state.binding.reflectionEnabledUniform !== null) {
|
||||
state.binding.reflectionEnabledUniform.value = 0;
|
||||
}
|
||||
}
|
||||
binding.reflectionTextureUniform.value = null;
|
||||
this.renderer.autoClear = true;
|
||||
this.renderer.setRenderTarget(binding.reflectionRenderTarget);
|
||||
this.renderer.clear();
|
||||
this.renderer.render(this.scene, this.waterReflectionCamera);
|
||||
}
|
||||
finally {
|
||||
this.renderer.setRenderTarget(previousRenderTarget);
|
||||
this.renderer.autoClear = previousAutoClear;
|
||||
this.modelGroup.visible = previousModelGroupVisibility;
|
||||
this.underwaterSceneFog.density = previousFogDensity;
|
||||
for (const state of previousReflectionStates) {
|
||||
if (state.binding.reflectionEnabledUniform !== null) {
|
||||
state.binding.reflectionEnabledUniform.value = state.enabled;
|
||||
}
|
||||
if (state.binding.reflectionTextureUniform !== null) {
|
||||
state.binding.reflectionTextureUniform.value = state.texture;
|
||||
}
|
||||
}
|
||||
for (const hiddenWaterMesh of hiddenWaterMeshes) {
|
||||
hiddenWaterMesh.mesh.visible = hiddenWaterMesh.visible;
|
||||
}
|
||||
}
|
||||
binding.reflectionTextureUniform.value = binding.reflectionRenderTarget.texture;
|
||||
binding.reflectionEnabledUniform.value = 0.36;
|
||||
binding.lastReflectionUpdateTime = now;
|
||||
}
|
||||
}
|
||||
getOrCreateTexture(material) {
|
||||
const signature = createStarterMaterialSignature(material);
|
||||
const cachedTexture = this.materialTextureCache.get(material.id);
|
||||
if (cachedTexture !== undefined && cachedTexture.signature === signature) {
|
||||
return cachedTexture.texture;
|
||||
}
|
||||
cachedTexture?.texture.dispose();
|
||||
const texture = createStarterMaterialTexture(material);
|
||||
this.materialTextureCache.set(material.id, {
|
||||
signature,
|
||||
texture
|
||||
});
|
||||
return texture;
|
||||
}
|
||||
clearLocalLights() {
|
||||
for (const renderGroup of this.localLightObjects.values()) {
|
||||
this.localLightGroup.remove(renderGroup);
|
||||
}
|
||||
this.localLightObjects.clear();
|
||||
}
|
||||
clearBrushMeshes() {
|
||||
for (const mesh of this.brushMeshes.values()) {
|
||||
this.brushGroup.remove(mesh);
|
||||
mesh.geometry.dispose();
|
||||
this.disposeUniqueMaterials(mesh.material);
|
||||
}
|
||||
this.brushMeshes.clear();
|
||||
this.volumeAnimatedUniforms.length = 0;
|
||||
for (const binding of this.runtimeWaterContactUniforms) {
|
||||
binding.reflectionRenderTarget?.dispose();
|
||||
}
|
||||
this.runtimeWaterContactUniforms.length = 0;
|
||||
}
|
||||
disposeUniqueMaterials(materials) {
|
||||
for (const material of new Set(materials)) {
|
||||
material.dispose();
|
||||
}
|
||||
}
|
||||
createPlayerWaterContactBounds() {
|
||||
if (this.runtimeScene === null || this.currentFirstPersonTelemetry === null) {
|
||||
return null;
|
||||
}
|
||||
const feetPosition = this.currentFirstPersonTelemetry.feetPosition;
|
||||
const playerShape = this.runtimeScene.playerCollider;
|
||||
switch (playerShape.mode) {
|
||||
case "capsule":
|
||||
return {
|
||||
min: {
|
||||
x: feetPosition.x - playerShape.radius,
|
||||
y: feetPosition.y,
|
||||
z: feetPosition.z - playerShape.radius
|
||||
},
|
||||
max: {
|
||||
x: feetPosition.x + playerShape.radius,
|
||||
y: feetPosition.y + playerShape.height,
|
||||
z: feetPosition.z + playerShape.radius
|
||||
}
|
||||
};
|
||||
case "box":
|
||||
return {
|
||||
min: {
|
||||
x: feetPosition.x - playerShape.size.x * 0.5,
|
||||
y: feetPosition.y,
|
||||
z: feetPosition.z - playerShape.size.z * 0.5
|
||||
},
|
||||
max: {
|
||||
x: feetPosition.x + playerShape.size.x * 0.5,
|
||||
y: feetPosition.y + playerShape.size.y,
|
||||
z: feetPosition.z + playerShape.size.z * 0.5
|
||||
}
|
||||
};
|
||||
case "none":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
collectRuntimeStaticWaterContactPatches(brush) {
|
||||
const contactBounds = [];
|
||||
const runtimeBrushesById = new Map((this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [runtimeBrush.id, runtimeBrush]));
|
||||
for (const collider of this.runtimeScene?.colliders ?? []) {
|
||||
if (collider.source === "brush") {
|
||||
const otherBrush = runtimeBrushesById.get(collider.brushId);
|
||||
if (otherBrush === undefined || otherBrush.id === brush.id || otherBrush.volume.mode !== "none") {
|
||||
continue;
|
||||
}
|
||||
contactBounds.push({
|
||||
kind: "triangleMesh",
|
||||
vertices: collider.vertices,
|
||||
indices: collider.indices,
|
||||
transform: {
|
||||
position: collider.center,
|
||||
rotationDegrees: collider.rotationDegrees,
|
||||
scale: {
|
||||
x: 1,
|
||||
y: 1,
|
||||
z: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (collider.kind === "trimesh") {
|
||||
contactBounds.push({
|
||||
kind: "triangleMesh",
|
||||
vertices: collider.vertices,
|
||||
indices: collider.indices,
|
||||
mergeProfile: "aggressive",
|
||||
transform: collider.transform
|
||||
});
|
||||
continue;
|
||||
}
|
||||
contactBounds.push({
|
||||
min: collider.worldBounds.min,
|
||||
max: collider.worldBounds.max
|
||||
});
|
||||
}
|
||||
return collectWaterContactPatches({
|
||||
center: brush.center,
|
||||
rotationDegrees: brush.rotationDegrees,
|
||||
size: brush.size
|
||||
}, contactBounds, this.getRuntimeWaterFoamContactLimit(brush));
|
||||
}
|
||||
collectRuntimePlayerWaterContactPatches(brush) {
|
||||
const playerBounds = this.createPlayerWaterContactBounds();
|
||||
if (playerBounds === null) {
|
||||
return [];
|
||||
}
|
||||
return collectWaterContactPatches({
|
||||
center: brush.center,
|
||||
rotationDegrees: brush.rotationDegrees,
|
||||
size: brush.size
|
||||
}, [playerBounds], this.getRuntimeWaterFoamContactLimit(brush));
|
||||
}
|
||||
getRuntimeWaterFoamContactLimit(brush) {
|
||||
return brush.volume.mode === "water" ? brush.volume.water.foamContactLimit : 0;
|
||||
}
|
||||
mergeRuntimeWaterContactPatches(brush, staticContactPatches, dynamicContactPatches) {
|
||||
return [...dynamicContactPatches, ...staticContactPatches].slice(0, this.getRuntimeWaterFoamContactLimit(brush));
|
||||
}
|
||||
updateRuntimeWaterContactUniforms() {
|
||||
for (const binding of this.runtimeWaterContactUniforms) {
|
||||
const mergedPatches = this.mergeRuntimeWaterContactPatches(binding.brush, binding.staticContactPatches, this.collectRuntimePlayerWaterContactPatches(binding.brush));
|
||||
binding.uniform.value = createWaterContactPatchUniformValue(mergedPatches);
|
||||
binding.axisUniform.value = createWaterContactPatchAxisUniformValue(mergedPatches);
|
||||
binding.shapeUniform.value = createWaterContactPatchShapeUniformValue(mergedPatches);
|
||||
}
|
||||
}
|
||||
clearModelInstances() {
|
||||
for (const mixer of this.animationMixers.values()) {
|
||||
mixer.stopAllAction();
|
||||
}
|
||||
this.animationMixers.clear();
|
||||
this.instanceAnimationClips.clear();
|
||||
for (const renderGroup of this.modelRenderObjects.values()) {
|
||||
this.modelGroup.remove(renderGroup);
|
||||
disposeModelInstance(renderGroup);
|
||||
}
|
||||
this.modelRenderObjects.clear();
|
||||
}
|
||||
resize() {
|
||||
if (this.container === null) {
|
||||
return;
|
||||
}
|
||||
const width = this.container.clientWidth;
|
||||
const height = this.container.clientHeight;
|
||||
if (width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.domElement.width = width;
|
||||
this.domElement.height = height;
|
||||
this.renderer?.setSize(width, height, false);
|
||||
this.advancedRenderingComposer?.setSize(width, height);
|
||||
this.resizeWaterReflectionTargets();
|
||||
}
|
||||
render = () => {
|
||||
this.animationFrame = window.requestAnimationFrame(this.render);
|
||||
const now = performance.now();
|
||||
const dt = Math.min((now - this.previousFrameTime) / 1000, 1 / 20);
|
||||
this.previousFrameTime = now;
|
||||
this.activeController?.update(dt);
|
||||
this.audioSystem.updateListenerTransform();
|
||||
this.volumeTime += dt;
|
||||
for (const uniform of this.volumeAnimatedUniforms) {
|
||||
uniform.value = this.volumeTime;
|
||||
}
|
||||
for (const mixer of this.animationMixers.values()) {
|
||||
mixer.update(dt);
|
||||
}
|
||||
if (this.sceneReady && this.runtimeScene !== null && this.currentFirstPersonTelemetry !== null) {
|
||||
this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher());
|
||||
this.setInteractionPrompt(this.resolveInteractionPrompt());
|
||||
}
|
||||
else {
|
||||
this.setInteractionPrompt(null);
|
||||
}
|
||||
if (this.runtimeWaterContactUniforms.length > 0) {
|
||||
this.updateRuntimeWaterContactUniforms();
|
||||
this.updateRuntimeWaterReflections();
|
||||
}
|
||||
this.updateUnderwaterSceneFog();
|
||||
if (this.advancedRenderingComposer !== null) {
|
||||
this.advancedRenderingComposer.render(dt);
|
||||
return;
|
||||
}
|
||||
this.renderer?.render(this.scene, this.camera);
|
||||
};
|
||||
applyTeleportPlayerAction(target) {
|
||||
if (this.activeController === this.thirdPersonController) {
|
||||
this.thirdPersonController.teleportTo(target.position, target.yawDegrees);
|
||||
return;
|
||||
}
|
||||
this.firstPersonController.teleportTo(target.position, target.yawDegrees);
|
||||
}
|
||||
applyToggleBrushVisibilityAction(brushId, visible) {
|
||||
const mesh = this.brushMeshes.get(brushId);
|
||||
if (mesh === undefined) {
|
||||
return;
|
||||
}
|
||||
mesh.visible = visible ?? !mesh.visible;
|
||||
}
|
||||
applyPlayAnimationAction(instanceId, clipName, loop) {
|
||||
const mixer = this.animationMixers.get(instanceId);
|
||||
const clips = this.instanceAnimationClips.get(instanceId);
|
||||
if (!mixer || !clips) {
|
||||
console.warn(`playAnimation: no mixer for instance ${instanceId}`);
|
||||
return;
|
||||
}
|
||||
const clip = AnimationClip.findByName(clips, clipName);
|
||||
if (!clip) {
|
||||
console.warn(`playAnimation: clip "${clipName}" not found on instance ${instanceId}`);
|
||||
return;
|
||||
}
|
||||
// LoopRepeat is the three.js default; LoopOnce plays the clip a single time then stops.
|
||||
const action = mixer.clipAction(clip);
|
||||
action.loop = loop === false ? LoopOnce : LoopRepeat;
|
||||
action.clampWhenFinished = loop === false;
|
||||
mixer.stopAllAction();
|
||||
action.reset().play();
|
||||
}
|
||||
applyStopAnimationAction(instanceId) {
|
||||
const mixer = this.animationMixers.get(instanceId);
|
||||
if (!mixer) {
|
||||
console.warn(`stopAnimation: no mixer for instance ${instanceId}`);
|
||||
return;
|
||||
}
|
||||
mixer.stopAllAction();
|
||||
}
|
||||
createInteractionDispatcher() {
|
||||
return {
|
||||
teleportPlayer: (target) => {
|
||||
this.applyTeleportPlayerAction(target);
|
||||
},
|
||||
toggleBrushVisibility: (brushId, visible) => {
|
||||
this.applyToggleBrushVisibilityAction(brushId, visible);
|
||||
},
|
||||
playAnimation: (instanceId, clipName, loop) => {
|
||||
this.applyPlayAnimationAction(instanceId, clipName, loop);
|
||||
},
|
||||
stopAnimation: (instanceId) => {
|
||||
this.applyStopAnimationAction(instanceId);
|
||||
},
|
||||
playSound: (soundEmitterId, link) => {
|
||||
this.audioSystem.playSound(soundEmitterId, link);
|
||||
},
|
||||
stopSound: (soundEmitterId) => {
|
||||
this.audioSystem.stopSound(soundEmitterId);
|
||||
}
|
||||
};
|
||||
}
|
||||
setInteractionPrompt(prompt) {
|
||||
if (this.currentInteractionPrompt?.sourceEntityId === prompt?.sourceEntityId &&
|
||||
this.currentInteractionPrompt?.prompt === prompt?.prompt &&
|
||||
this.currentInteractionPrompt?.distance === prompt?.distance &&
|
||||
this.currentInteractionPrompt?.range === prompt?.range) {
|
||||
return;
|
||||
}
|
||||
this.currentInteractionPrompt = prompt;
|
||||
this.interactionPromptHandler?.(prompt);
|
||||
}
|
||||
resolveInteractionPrompt() {
|
||||
if (this.runtimeScene === null ||
|
||||
this.currentFirstPersonTelemetry === null ||
|
||||
(this.activeController !== this.firstPersonController &&
|
||||
this.activeController !== this.thirdPersonController)) {
|
||||
return null;
|
||||
}
|
||||
this.camera.getWorldDirection(this.cameraForward);
|
||||
const interactionOrigin = this.currentFirstPersonTelemetry.eyePosition;
|
||||
const rayOrigin = this.activeController === this.thirdPersonController
|
||||
? {
|
||||
x: this.camera.position.x,
|
||||
y: this.camera.position.y,
|
||||
z: this.camera.position.z
|
||||
}
|
||||
: interactionOrigin;
|
||||
return this.interactionSystem.resolveClickInteractionPrompt(interactionOrigin, rayOrigin, {
|
||||
x: this.cameraForward.x,
|
||||
y: this.cameraForward.y,
|
||||
z: this.cameraForward.z
|
||||
}, this.runtimeScene);
|
||||
}
|
||||
handleRuntimeClick = () => {
|
||||
if (!this.sceneReady ||
|
||||
this.runtimeScene === null ||
|
||||
(this.activeController !== this.firstPersonController &&
|
||||
this.activeController !== this.thirdPersonController) ||
|
||||
this.currentInteractionPrompt === null) {
|
||||
return;
|
||||
}
|
||||
this.audioSystem.handleUserGesture();
|
||||
this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher());
|
||||
};
|
||||
handleRuntimePointerDown = () => {
|
||||
this.audioSystem.handleUserGesture();
|
||||
};
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
const DEFAULT_INTERACTABLE_TARGET_RADIUS = 0.75;
|
||||
function subtractVec3(left, right) {
|
||||
return {
|
||||
x: left.x - right.x,
|
||||
y: left.y - right.y,
|
||||
z: left.z - right.z
|
||||
};
|
||||
}
|
||||
function scaleVec3(vector, scalar) {
|
||||
return {
|
||||
x: vector.x * scalar,
|
||||
y: vector.y * scalar,
|
||||
z: vector.z * scalar
|
||||
};
|
||||
}
|
||||
function dotVec3(left, right) {
|
||||
return left.x * right.x + left.y * right.y + left.z * right.z;
|
||||
}
|
||||
function lengthSquaredVec3(vector) {
|
||||
return dotVec3(vector, vector);
|
||||
}
|
||||
function distanceBetweenVec3(left, right) {
|
||||
return Math.sqrt(lengthSquaredVec3(subtractVec3(left, right)));
|
||||
}
|
||||
function normalizeVec3(vector) {
|
||||
const lengthSquared = lengthSquaredVec3(vector);
|
||||
if (lengthSquared <= Number.EPSILON) {
|
||||
return null;
|
||||
}
|
||||
return scaleVec3(vector, 1 / Math.sqrt(lengthSquared));
|
||||
}
|
||||
function isPointInsideTriggerVolume(position, triggerVolume) {
|
||||
const halfSize = {
|
||||
x: triggerVolume.size.x * 0.5,
|
||||
y: triggerVolume.size.y * 0.5,
|
||||
z: triggerVolume.size.z * 0.5
|
||||
};
|
||||
return (position.x >= triggerVolume.position.x - halfSize.x &&
|
||||
position.x <= triggerVolume.position.x + halfSize.x &&
|
||||
position.y >= triggerVolume.position.y - halfSize.y &&
|
||||
position.y <= triggerVolume.position.y + halfSize.y &&
|
||||
position.z >= triggerVolume.position.z - halfSize.z &&
|
||||
position.z <= triggerVolume.position.z + halfSize.z);
|
||||
}
|
||||
function raySphereHitDistance(origin, direction, center, radius) {
|
||||
const offset = subtractVec3(origin, center);
|
||||
const halfB = dotVec3(offset, direction);
|
||||
const c = dotVec3(offset, offset) - radius * radius;
|
||||
const discriminant = halfB * halfB - c;
|
||||
if (discriminant < 0) {
|
||||
return null;
|
||||
}
|
||||
const discriminantRoot = Math.sqrt(discriminant);
|
||||
const nearestHit = -halfB - discriminantRoot;
|
||||
if (nearestHit >= 0) {
|
||||
return nearestHit;
|
||||
}
|
||||
const farHit = -halfB + discriminantRoot;
|
||||
return farHit >= 0 ? 0 : null;
|
||||
}
|
||||
function resolveTeleportTarget(runtimeScene, entityId) {
|
||||
return runtimeScene.entities.teleportTargets.find((teleportTarget) => teleportTarget.entityId === entityId) ?? null;
|
||||
}
|
||||
function hasTriggerLinks(runtimeScene, sourceEntityId, trigger) {
|
||||
return runtimeScene.interactionLinks.some((link) => link.sourceEntityId === sourceEntityId && link.trigger === trigger);
|
||||
}
|
||||
function getInteractableTargetRadius(interactable) {
|
||||
return Math.min(DEFAULT_INTERACTABLE_TARGET_RADIUS, interactable.radius);
|
||||
}
|
||||
export class RuntimeInteractionSystem {
|
||||
occupiedTriggerVolumes = new Set();
|
||||
reset() {
|
||||
this.occupiedTriggerVolumes.clear();
|
||||
}
|
||||
updatePlayerPosition(feetPosition, runtimeScene, dispatcher) {
|
||||
for (const triggerVolume of runtimeScene.entities.triggerVolumes) {
|
||||
const containsPlayer = isPointInsideTriggerVolume(feetPosition, triggerVolume);
|
||||
const wasOccupied = this.occupiedTriggerVolumes.has(triggerVolume.entityId);
|
||||
if (!wasOccupied && containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "enter")) {
|
||||
this.dispatchLinks(triggerVolume.entityId, "enter", runtimeScene, dispatcher);
|
||||
}
|
||||
else if (wasOccupied && !containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "exit")) {
|
||||
this.dispatchLinks(triggerVolume.entityId, "exit", runtimeScene, dispatcher);
|
||||
}
|
||||
if (containsPlayer) {
|
||||
this.occupiedTriggerVolumes.add(triggerVolume.entityId);
|
||||
}
|
||||
else {
|
||||
this.occupiedTriggerVolumes.delete(triggerVolume.entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
resolveClickInteractionPrompt(interactionOrigin, rayOrigin, rayDirection, runtimeScene) {
|
||||
const normalizedViewDirection = normalizeVec3(rayDirection);
|
||||
if (normalizedViewDirection === null) {
|
||||
return null;
|
||||
}
|
||||
let bestPrompt = null;
|
||||
let bestHitDistance = Number.POSITIVE_INFINITY;
|
||||
for (const interactable of runtimeScene.entities.interactables) {
|
||||
if (!interactable.enabled || !hasTriggerLinks(runtimeScene, interactable.entityId, "click")) {
|
||||
continue;
|
||||
}
|
||||
const distance = distanceBetweenVec3(interactionOrigin, interactable.position);
|
||||
if (distance > interactable.radius) {
|
||||
continue;
|
||||
}
|
||||
const hitDistance = raySphereHitDistance(rayOrigin, normalizedViewDirection, interactable.position, getInteractableTargetRadius(interactable));
|
||||
if (hitDistance === null) {
|
||||
continue;
|
||||
}
|
||||
const nextPrompt = {
|
||||
sourceEntityId: interactable.entityId,
|
||||
prompt: interactable.prompt,
|
||||
distance,
|
||||
range: interactable.radius
|
||||
};
|
||||
if (hitDistance < bestHitDistance ||
|
||||
(hitDistance === bestHitDistance &&
|
||||
(bestPrompt === null ||
|
||||
distance < bestPrompt.distance ||
|
||||
(distance === bestPrompt.distance && interactable.entityId.localeCompare(bestPrompt.sourceEntityId) < 0)))) {
|
||||
bestHitDistance = hitDistance;
|
||||
bestPrompt = nextPrompt;
|
||||
}
|
||||
}
|
||||
return bestPrompt;
|
||||
}
|
||||
dispatchClickInteraction(sourceEntityId, runtimeScene, dispatcher) {
|
||||
this.dispatchLinks(sourceEntityId, "click", runtimeScene, dispatcher);
|
||||
}
|
||||
dispatchLinks(sourceEntityId, trigger, runtimeScene, dispatcher) {
|
||||
for (const link of runtimeScene.interactionLinks) {
|
||||
if (link.sourceEntityId !== sourceEntityId || link.trigger !== trigger) {
|
||||
continue;
|
||||
}
|
||||
switch (link.action.type) {
|
||||
case "teleportPlayer": {
|
||||
const teleportTarget = resolveTeleportTarget(runtimeScene, link.action.targetEntityId);
|
||||
if (teleportTarget !== null) {
|
||||
dispatcher.teleportPlayer(teleportTarget, link);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "toggleVisibility":
|
||||
dispatcher.toggleBrushVisibility(link.action.targetBrushId, link.action.visible, link);
|
||||
break;
|
||||
case "playAnimation":
|
||||
dispatcher.playAnimation(link.action.targetModelInstanceId, link.action.clipName, link.action.loop, link);
|
||||
break;
|
||||
case "stopAnimation":
|
||||
dispatcher.stopAnimation(link.action.targetModelInstanceId, link);
|
||||
break;
|
||||
case "playSound":
|
||||
dispatcher.playSound(link.action.targetSoundEmitterId, link);
|
||||
break;
|
||||
case "stopSound":
|
||||
dispatcher.stopSound(link.action.targetSoundEmitterId, link);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
import { getModelInstances } from "../assets/model-instances";
|
||||
import { cloneWorldSettings } from "../document/world-settings";
|
||||
import { getEntityInstances, getPrimaryPlayerStartEntity } from "../entities/entity-instances";
|
||||
import { getBoxBrushBounds } from "../geometry/box-brush";
|
||||
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
|
||||
import { buildGeneratedModelCollider } from "../geometry/model-instance-collider-generation";
|
||||
import { cloneInteractionLink, getInteractionLinks } from "../interactions/interaction-links";
|
||||
import { cloneMaterialDef } from "../materials/starter-material-library";
|
||||
import { cloneBoxBrushGeometry, cloneBoxBrushVolumeSettings, cloneFaceUvState } from "../document/brushes";
|
||||
import { assertRuntimeSceneBuildable } from "./runtime-scene-validation";
|
||||
import { FIRST_PERSON_PLAYER_SHAPE } from "./player-collision";
|
||||
function cloneVec3(vector) {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
function resolveRuntimeMaterial(document, materialId) {
|
||||
if (materialId === null) {
|
||||
return null;
|
||||
}
|
||||
const material = document.materials[materialId];
|
||||
if (material === undefined) {
|
||||
throw new Error(`Runtime build could not resolve material ${materialId}.`);
|
||||
}
|
||||
return cloneMaterialDef(material);
|
||||
}
|
||||
function buildRuntimeBrush(brush, document) {
|
||||
return {
|
||||
id: brush.id,
|
||||
kind: "box",
|
||||
center: cloneVec3(brush.center),
|
||||
rotationDegrees: cloneVec3(brush.rotationDegrees),
|
||||
size: cloneVec3(brush.size),
|
||||
geometry: cloneBoxBrushGeometry(brush.geometry),
|
||||
volume: cloneBoxBrushVolumeSettings(brush.volume),
|
||||
faces: {
|
||||
posX: {
|
||||
materialId: brush.faces.posX.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.posX.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.posX.uv)
|
||||
},
|
||||
negX: {
|
||||
materialId: brush.faces.negX.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.negX.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.negX.uv)
|
||||
},
|
||||
posY: {
|
||||
materialId: brush.faces.posY.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.posY.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.posY.uv)
|
||||
},
|
||||
negY: {
|
||||
materialId: brush.faces.negY.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.negY.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.negY.uv)
|
||||
},
|
||||
posZ: {
|
||||
materialId: brush.faces.posZ.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.posZ.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.posZ.uv)
|
||||
},
|
||||
negZ: {
|
||||
materialId: brush.faces.negZ.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.negZ.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.negZ.uv)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
function buildRuntimeFogVolume(brush) {
|
||||
if (brush.volume.mode !== "fog") {
|
||||
throw new Error(`Cannot build fog volume from non-fog brush ${brush.id}.`);
|
||||
}
|
||||
return {
|
||||
brushId: brush.id,
|
||||
center: cloneVec3(brush.center),
|
||||
rotationDegrees: cloneVec3(brush.rotationDegrees),
|
||||
size: cloneVec3(brush.size),
|
||||
colorHex: brush.volume.fog.colorHex,
|
||||
density: brush.volume.fog.density,
|
||||
padding: brush.volume.fog.padding
|
||||
};
|
||||
}
|
||||
function buildRuntimeWaterVolume(brush) {
|
||||
if (brush.volume.mode !== "water") {
|
||||
throw new Error(`Cannot build water volume from non-water brush ${brush.id}.`);
|
||||
}
|
||||
return {
|
||||
brushId: brush.id,
|
||||
center: cloneVec3(brush.center),
|
||||
rotationDegrees: cloneVec3(brush.rotationDegrees),
|
||||
size: cloneVec3(brush.size),
|
||||
colorHex: brush.volume.water.colorHex,
|
||||
surfaceOpacity: brush.volume.water.surfaceOpacity,
|
||||
waveStrength: brush.volume.water.waveStrength
|
||||
};
|
||||
}
|
||||
function buildRuntimeCollider(brush) {
|
||||
const bounds = getBoxBrushBounds(brush);
|
||||
const derivedMesh = buildBoxBrushDerivedMeshData(brush);
|
||||
return {
|
||||
kind: "trimesh",
|
||||
source: "brush",
|
||||
brushId: brush.id,
|
||||
center: cloneVec3(brush.center),
|
||||
rotationDegrees: cloneVec3(brush.rotationDegrees),
|
||||
vertices: derivedMesh.colliderVertices,
|
||||
indices: derivedMesh.colliderIndices,
|
||||
worldBounds: {
|
||||
min: cloneVec3(bounds.min),
|
||||
max: cloneVec3(bounds.max)
|
||||
}
|
||||
};
|
||||
}
|
||||
function buildRuntimeModelInstance(modelInstance) {
|
||||
return {
|
||||
instanceId: modelInstance.id,
|
||||
assetId: modelInstance.assetId,
|
||||
name: modelInstance.name,
|
||||
position: cloneVec3(modelInstance.position),
|
||||
rotationDegrees: cloneVec3(modelInstance.rotationDegrees),
|
||||
scale: cloneVec3(modelInstance.scale),
|
||||
animationClipName: modelInstance.animationClipName,
|
||||
animationAutoplay: modelInstance.animationAutoplay
|
||||
};
|
||||
}
|
||||
function getColliderBounds(collider) {
|
||||
if (collider.source === "brush") {
|
||||
return {
|
||||
min: cloneVec3(collider.worldBounds.min),
|
||||
max: cloneVec3(collider.worldBounds.max)
|
||||
};
|
||||
}
|
||||
return {
|
||||
min: cloneVec3(collider.worldBounds.min),
|
||||
max: cloneVec3(collider.worldBounds.max)
|
||||
};
|
||||
}
|
||||
function combineColliderBounds(colliders) {
|
||||
if (colliders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const firstBounds = getColliderBounds(colliders[0]);
|
||||
const min = cloneVec3(firstBounds.min);
|
||||
const max = cloneVec3(firstBounds.max);
|
||||
for (const collider of colliders.slice(1)) {
|
||||
const bounds = getColliderBounds(collider);
|
||||
min.x = Math.min(min.x, bounds.min.x);
|
||||
min.y = Math.min(min.y, bounds.min.y);
|
||||
min.z = Math.min(min.z, bounds.min.z);
|
||||
max.x = Math.max(max.x, bounds.max.x);
|
||||
max.y = Math.max(max.y, bounds.max.y);
|
||||
max.z = Math.max(max.z, bounds.max.z);
|
||||
}
|
||||
return {
|
||||
min,
|
||||
max,
|
||||
center: {
|
||||
x: (min.x + max.x) * 0.5,
|
||||
y: (min.y + max.y) * 0.5,
|
||||
z: (min.z + max.z) * 0.5
|
||||
},
|
||||
size: {
|
||||
x: max.x - min.x,
|
||||
y: max.y - min.y,
|
||||
z: max.z - min.z
|
||||
}
|
||||
};
|
||||
}
|
||||
function buildFallbackSpawn(sceneBounds) {
|
||||
if (sceneBounds === null) {
|
||||
return {
|
||||
source: "fallback",
|
||||
entityId: null,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: -4
|
||||
},
|
||||
yawDegrees: 0
|
||||
};
|
||||
}
|
||||
return {
|
||||
source: "fallback",
|
||||
entityId: null,
|
||||
position: {
|
||||
x: sceneBounds.center.x,
|
||||
y: sceneBounds.max.y + 0.1,
|
||||
z: sceneBounds.max.z + 3
|
||||
},
|
||||
yawDegrees: 180
|
||||
};
|
||||
}
|
||||
function buildRuntimeSceneCollections(document) {
|
||||
const runtimeEntities = {
|
||||
playerStarts: [],
|
||||
soundEmitters: [],
|
||||
triggerVolumes: [],
|
||||
teleportTargets: [],
|
||||
interactables: []
|
||||
};
|
||||
const localLights = {
|
||||
pointLights: [],
|
||||
spotLights: []
|
||||
};
|
||||
for (const entity of getEntityInstances(document.entities)) {
|
||||
switch (entity.kind) {
|
||||
case "pointLight":
|
||||
localLights.pointLights.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
colorHex: entity.colorHex,
|
||||
intensity: entity.intensity,
|
||||
distance: entity.distance
|
||||
});
|
||||
break;
|
||||
case "spotLight":
|
||||
localLights.spotLights.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
direction: cloneVec3(entity.direction),
|
||||
colorHex: entity.colorHex,
|
||||
intensity: entity.intensity,
|
||||
distance: entity.distance,
|
||||
angleDegrees: entity.angleDegrees
|
||||
});
|
||||
break;
|
||||
case "playerStart":
|
||||
runtimeEntities.playerStarts.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
yawDegrees: entity.yawDegrees,
|
||||
collider: buildRuntimePlayerShape(entity)
|
||||
});
|
||||
break;
|
||||
case "soundEmitter":
|
||||
runtimeEntities.soundEmitters.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
audioAssetId: entity.audioAssetId,
|
||||
volume: entity.volume,
|
||||
refDistance: entity.refDistance,
|
||||
maxDistance: entity.maxDistance,
|
||||
autoplay: entity.autoplay,
|
||||
loop: entity.loop
|
||||
});
|
||||
break;
|
||||
case "triggerVolume":
|
||||
runtimeEntities.triggerVolumes.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
size: cloneVec3(entity.size),
|
||||
// Derive from links so flags are always correct regardless of stored entity state
|
||||
triggerOnEnter: Object.values(document.interactionLinks).some((l) => l.sourceEntityId === entity.id && l.trigger === "enter"),
|
||||
triggerOnExit: Object.values(document.interactionLinks).some((l) => l.sourceEntityId === entity.id && l.trigger === "exit")
|
||||
});
|
||||
break;
|
||||
case "teleportTarget":
|
||||
runtimeEntities.teleportTargets.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
yawDegrees: entity.yawDegrees
|
||||
});
|
||||
break;
|
||||
case "interactable":
|
||||
runtimeEntities.interactables.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
radius: entity.radius,
|
||||
prompt: entity.prompt,
|
||||
enabled: entity.enabled
|
||||
});
|
||||
break;
|
||||
default:
|
||||
assertNever(entity);
|
||||
}
|
||||
}
|
||||
return {
|
||||
entities: runtimeEntities,
|
||||
localLights
|
||||
};
|
||||
}
|
||||
function assertNever(value) {
|
||||
throw new Error(`Unsupported runtime entity: ${String(value.kind)}`);
|
||||
}
|
||||
function buildRuntimePlayerShape(playerStartEntity) {
|
||||
if (playerStartEntity === null) {
|
||||
return FIRST_PERSON_PLAYER_SHAPE;
|
||||
}
|
||||
switch (playerStartEntity.collider.mode) {
|
||||
case "capsule":
|
||||
return {
|
||||
mode: "capsule",
|
||||
radius: playerStartEntity.collider.capsuleRadius,
|
||||
height: playerStartEntity.collider.capsuleHeight,
|
||||
eyeHeight: playerStartEntity.collider.eyeHeight
|
||||
};
|
||||
case "box":
|
||||
return {
|
||||
mode: "box",
|
||||
size: cloneVec3(playerStartEntity.collider.boxSize),
|
||||
eyeHeight: playerStartEntity.collider.eyeHeight
|
||||
};
|
||||
case "none":
|
||||
return {
|
||||
mode: "none",
|
||||
eyeHeight: playerStartEntity.collider.eyeHeight
|
||||
};
|
||||
}
|
||||
}
|
||||
export function buildRuntimeSceneFromDocument(document, options = {}) {
|
||||
assertRuntimeSceneBuildable(document, {
|
||||
navigationMode: options.navigationMode ?? "orbitVisitor",
|
||||
loadedModelAssets: options.loadedModelAssets
|
||||
});
|
||||
const brushes = Object.values(document.brushes).map((brush) => buildRuntimeBrush(brush, document));
|
||||
const colliders = [];
|
||||
const volumes = {
|
||||
fog: [],
|
||||
water: []
|
||||
};
|
||||
for (const brush of Object.values(document.brushes)) {
|
||||
if (brush.volume.mode === "none") {
|
||||
colliders.push(buildRuntimeCollider(brush));
|
||||
continue;
|
||||
}
|
||||
if (brush.volume.mode === "fog") {
|
||||
volumes.fog.push(buildRuntimeFogVolume(brush));
|
||||
continue;
|
||||
}
|
||||
volumes.water.push(buildRuntimeWaterVolume(brush));
|
||||
}
|
||||
const modelInstances = getModelInstances(document.modelInstances).map(buildRuntimeModelInstance);
|
||||
const collections = buildRuntimeSceneCollections(document);
|
||||
const interactionLinks = getInteractionLinks(document.interactionLinks).map((link) => cloneInteractionLink(link));
|
||||
const playerStartEntity = getPrimaryPlayerStartEntity(document.entities);
|
||||
const playerCollider = buildRuntimePlayerShape(playerStartEntity);
|
||||
for (const modelInstance of getModelInstances(document.modelInstances)) {
|
||||
const asset = document.assets[modelInstance.assetId];
|
||||
if (asset === undefined || asset.kind !== "model") {
|
||||
continue;
|
||||
}
|
||||
const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]);
|
||||
if (generatedCollider !== null) {
|
||||
colliders.push(generatedCollider);
|
||||
}
|
||||
}
|
||||
const combinedSceneBounds = combineColliderBounds(colliders);
|
||||
const playerStart = playerStartEntity === null
|
||||
? null
|
||||
: {
|
||||
entityId: playerStartEntity.id,
|
||||
position: cloneVec3(playerStartEntity.position),
|
||||
yawDegrees: playerStartEntity.yawDegrees,
|
||||
collider: playerCollider
|
||||
};
|
||||
return {
|
||||
world: cloneWorldSettings(document.world),
|
||||
localLights: collections.localLights,
|
||||
brushes,
|
||||
volumes,
|
||||
colliders,
|
||||
sceneBounds: combinedSceneBounds,
|
||||
modelInstances,
|
||||
entities: collections.entities,
|
||||
interactionLinks,
|
||||
playerStart,
|
||||
playerCollider,
|
||||
spawn: playerStart === null
|
||||
? buildFallbackSpawn(combinedSceneBounds)
|
||||
: {
|
||||
source: "playerStart",
|
||||
entityId: playerStart.entityId,
|
||||
position: cloneVec3(playerStart.position),
|
||||
yawDegrees: playerStart.yawDegrees
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { getModelInstances } from "../assets/model-instances";
|
||||
import { assertSceneDocumentIsValid, createDiagnostic, formatSceneDiagnosticSummary } from "../document/scene-document-validation";
|
||||
import { getPrimaryPlayerStartEntity } from "../entities/entity-instances";
|
||||
import { validateBoxBrushGeometry } from "../geometry/box-brush-mesh";
|
||||
import { buildGeneratedModelCollider, ModelColliderGenerationError } from "../geometry/model-instance-collider-generation";
|
||||
function validateBrushGeometry(brush, path, diagnostics) {
|
||||
for (const diagnostic of validateBoxBrushGeometry(brush)) {
|
||||
diagnostics.push(createDiagnostic("error", diagnostic.code, diagnostic.message, `${path}.geometry`, "build"));
|
||||
}
|
||||
}
|
||||
export function validateRuntimeSceneBuild(document, options) {
|
||||
const diagnostics = [];
|
||||
if (options.navigationMode === "firstPerson" && getPrimaryPlayerStartEntity(document.entities) === null) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-player-start", "First-person run requires an authored Player Start. Place one or switch to Orbit Visitor.", "entities", "build"));
|
||||
}
|
||||
for (const brush of Object.values(document.brushes)) {
|
||||
validateBrushGeometry(brush, `brushes.${brush.id}`, diagnostics);
|
||||
}
|
||||
for (const modelInstance of getModelInstances(document.modelInstances)) {
|
||||
const path = `modelInstances.${modelInstance.id}.collision.mode`;
|
||||
const asset = document.assets[modelInstance.assetId];
|
||||
if (modelInstance.collision.mode === "none" || asset === undefined || asset.kind !== "model") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]);
|
||||
if (generatedCollider?.mode === "dynamic") {
|
||||
diagnostics.push(createDiagnostic("warning", "dynamic-model-collider-fixed-query-only", "Dynamic model collision currently generates convex compound pieces for Rapier queries, but the runner still uses them as fixed world collision rather than fully simulated rigid bodies.", path, "build"));
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Imported model collision generation failed.";
|
||||
const code = error instanceof ModelColliderGenerationError
|
||||
? error.code
|
||||
: "invalid-model-instance-collision-mode";
|
||||
diagnostics.push(createDiagnostic("error", code, message, path, "build"));
|
||||
}
|
||||
}
|
||||
return {
|
||||
diagnostics,
|
||||
errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"),
|
||||
warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning")
|
||||
};
|
||||
}
|
||||
export function assertRuntimeSceneBuildable(document, options) {
|
||||
assertSceneDocumentIsValid(document);
|
||||
const validation = validateRuntimeSceneBuild(document, options);
|
||||
if (validation.errors.length > 0) {
|
||||
throw new Error(`Runtime build is blocked: ${formatSceneDiagnosticSummary(validation.errors)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Euler, Quaternion, Vector3 } from "three";
|
||||
|
||||
const MIN_UNDERWATER_FOG_DENSITY = 0.018;
|
||||
const MAX_UNDERWATER_FOG_DENSITY = 0.12;
|
||||
|
||||
function clampNumber(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getWaterVolumeLocalPoint(point, volume) {
|
||||
const offset = new Vector3(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z);
|
||||
const inverseRotation = new Quaternion()
|
||||
.setFromEuler(new Euler((volume.rotationDegrees.x * Math.PI) / 180, (volume.rotationDegrees.y * Math.PI) / 180, (volume.rotationDegrees.z * Math.PI) / 180, "XYZ"))
|
||||
.invert();
|
||||
offset.applyQuaternion(inverseRotation);
|
||||
return offset;
|
||||
}
|
||||
function isPointInsideWaterVolume(point, volume) {
|
||||
const offset = getWaterVolumeLocalPoint(point, volume);
|
||||
return (Math.abs(offset.x) <= volume.size.x * 0.5 &&
|
||||
Math.abs(offset.y) <= volume.size.y * 0.5 &&
|
||||
Math.abs(offset.z) <= volume.size.z * 0.5);
|
||||
}
|
||||
|
||||
function resolveUnderwaterFogDensity(volume, point) {
|
||||
const localPoint = getWaterVolumeLocalPoint(point, volume);
|
||||
const halfHeight = Math.max(volume.size.y * 0.5, 0.0001);
|
||||
const submersionDepth = clampNumber((halfHeight - localPoint.y) / (halfHeight * 2), 0, 1);
|
||||
return clampNumber(0.045 + volume.surfaceOpacity * 0.035 + Math.max(volume.waveStrength, 0) * 0.015 + submersionDepth * 0.03, MIN_UNDERWATER_FOG_DENSITY, MAX_UNDERWATER_FOG_DENSITY);
|
||||
}
|
||||
|
||||
export function resolveUnderwaterFogState(runtimeScene, telemetry) {
|
||||
if (runtimeScene === null || telemetry === null || telemetry.cameraSubmerged !== true) {
|
||||
return null;
|
||||
}
|
||||
const containingVolume = runtimeScene.volumes.water.find((volume) => isPointInsideWaterVolume(telemetry.eyePosition, volume));
|
||||
if (containingVolume === undefined) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
colorHex: containingVolume.colorHex,
|
||||
density: resolveUnderwaterFogDensity(containingVolume, telemetry.eyePosition)
|
||||
};
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { createEmptySceneDocument } from "../document/scene-document";
|
||||
import { VIEWPORT_PANEL_IDS, cloneViewportLayoutState, createDefaultViewportLayoutState } from "../viewport-three/viewport-layout";
|
||||
import { parseSceneDocumentJson, serializeSceneDocument } from "./scene-document-json";
|
||||
export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft";
|
||||
const EDITOR_DRAFT_ENVELOPE_FORMAT = "webeditor3d.editor-draft.v1";
|
||||
function getErrorDetail(error) {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message.trim();
|
||||
}
|
||||
return "Unknown error.";
|
||||
}
|
||||
function formatStorageDiagnostic(prefix, error) {
|
||||
return `${prefix} ${getErrorDetail(error)}`;
|
||||
}
|
||||
function isRecord(value) {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
function isFiniteNumber(value) {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
function parseViewportLayoutMode(value) {
|
||||
return value === "single" || value === "quad" ? value : null;
|
||||
}
|
||||
function parseViewportPanelId(value) {
|
||||
return typeof value === "string" && VIEWPORT_PANEL_IDS.includes(value) ? value : null;
|
||||
}
|
||||
function parseViewportLayoutState(value) {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const layoutMode = parseViewportLayoutMode(value.layoutMode);
|
||||
const activePanelId = parseViewportPanelId(value.activePanelId);
|
||||
const viewportQuadSplit = isRecord(value.viewportQuadSplit) ? value.viewportQuadSplit : null;
|
||||
const panels = isRecord(value.panels) ? value.panels : null;
|
||||
if (layoutMode === null || activePanelId === null || viewportQuadSplit === null || panels === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isFiniteNumber(viewportQuadSplit.x) || !isFiniteNumber(viewportQuadSplit.y)) {
|
||||
return null;
|
||||
}
|
||||
const defaultLayoutState = createDefaultViewportLayoutState();
|
||||
const nextLayoutState = cloneViewportLayoutState(defaultLayoutState);
|
||||
nextLayoutState.layoutMode = layoutMode;
|
||||
nextLayoutState.activePanelId = activePanelId;
|
||||
nextLayoutState.viewportQuadSplit = {
|
||||
x: viewportQuadSplit.x,
|
||||
y: viewportQuadSplit.y
|
||||
};
|
||||
for (const panelId of VIEWPORT_PANEL_IDS) {
|
||||
const storedPanel = panels[panelId];
|
||||
if (!isRecord(storedPanel)) {
|
||||
return null;
|
||||
}
|
||||
const storedViewMode = storedPanel.viewMode;
|
||||
const storedDisplayMode = storedPanel.displayMode;
|
||||
const storedCameraState = isRecord(storedPanel.cameraState) ? storedPanel.cameraState : null;
|
||||
const storedPerspectiveOrbit = storedCameraState !== null && isRecord(storedCameraState.perspectiveOrbit) ? storedCameraState.perspectiveOrbit : null;
|
||||
const storedTarget = storedCameraState !== null && isRecord(storedCameraState.target) ? storedCameraState.target : null;
|
||||
if ((storedViewMode !== "perspective" && storedViewMode !== "top" && storedViewMode !== "front" && storedViewMode !== "side") ||
|
||||
(storedDisplayMode !== "normal" && storedDisplayMode !== "authoring" && storedDisplayMode !== "wireframe") ||
|
||||
storedCameraState === null ||
|
||||
storedPerspectiveOrbit === null ||
|
||||
storedTarget === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isFiniteNumber(storedTarget.x) ||
|
||||
!isFiniteNumber(storedTarget.y) ||
|
||||
!isFiniteNumber(storedTarget.z) ||
|
||||
!isFiniteNumber(storedPerspectiveOrbit.radius) ||
|
||||
!isFiniteNumber(storedPerspectiveOrbit.theta) ||
|
||||
!isFiniteNumber(storedPerspectiveOrbit.phi) ||
|
||||
!isFiniteNumber(storedCameraState.orthographicZoom)) {
|
||||
return null;
|
||||
}
|
||||
nextLayoutState.panels[panelId] = {
|
||||
viewMode: storedViewMode,
|
||||
displayMode: storedDisplayMode,
|
||||
cameraState: {
|
||||
target: {
|
||||
x: storedTarget.x,
|
||||
y: storedTarget.y,
|
||||
z: storedTarget.z
|
||||
},
|
||||
perspectiveOrbit: {
|
||||
radius: storedPerspectiveOrbit.radius,
|
||||
theta: storedPerspectiveOrbit.theta,
|
||||
phi: storedPerspectiveOrbit.phi
|
||||
},
|
||||
orthographicZoom: storedCameraState.orthographicZoom
|
||||
}
|
||||
};
|
||||
}
|
||||
return nextLayoutState;
|
||||
}
|
||||
function isStoredEditorDraftEnvelope(value) {
|
||||
return isRecord(value) && value.format === EDITOR_DRAFT_ENVELOPE_FORMAT && "document" in value;
|
||||
}
|
||||
export function getBrowserStorageAccess() {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
storage: null,
|
||||
diagnostic: null
|
||||
};
|
||||
}
|
||||
try {
|
||||
return {
|
||||
storage: window.localStorage,
|
||||
diagnostic: null
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
storage: null,
|
||||
diagnostic: formatStorageDiagnostic("Browser local storage is unavailable.", error)
|
||||
};
|
||||
}
|
||||
}
|
||||
export function getBrowserStorage() {
|
||||
return getBrowserStorageAccess().storage;
|
||||
}
|
||||
export function saveSceneDocumentDraft(storage, document, viewportLayoutState = null, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY) {
|
||||
try {
|
||||
const rawDocument = serializeSceneDocument(document);
|
||||
storage.setItem(key, JSON.stringify({
|
||||
format: EDITOR_DRAFT_ENVELOPE_FORMAT,
|
||||
document: JSON.parse(rawDocument),
|
||||
viewportLayoutState: viewportLayoutState === null ? null : cloneViewportLayoutState(viewportLayoutState)
|
||||
}));
|
||||
return {
|
||||
status: "saved",
|
||||
message: "Local draft saved."
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
message: formatStorageDiagnostic("Local draft could not be saved.", error)
|
||||
};
|
||||
}
|
||||
}
|
||||
export function loadSceneDocumentDraft(storage, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY) {
|
||||
try {
|
||||
const rawDocument = storage.getItem(key);
|
||||
if (rawDocument === null) {
|
||||
return {
|
||||
status: "missing",
|
||||
message: "No local draft was found."
|
||||
};
|
||||
}
|
||||
const parsedDraft = JSON.parse(rawDocument);
|
||||
if (isStoredEditorDraftEnvelope(parsedDraft)) {
|
||||
return {
|
||||
status: "loaded",
|
||||
document: parseSceneDocumentJson(JSON.stringify(parsedDraft.document)),
|
||||
viewportLayoutState: parseViewportLayoutState(parsedDraft.viewportLayoutState ?? null),
|
||||
message: "Local draft loaded."
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "loaded",
|
||||
document: parseSceneDocumentJson(rawDocument),
|
||||
viewportLayoutState: null,
|
||||
message: "Local draft loaded."
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
message: formatStorageDiagnostic("Stored local draft could not be loaded.", error)
|
||||
};
|
||||
}
|
||||
}
|
||||
export function loadOrCreateSceneDocument(storage, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY) {
|
||||
if (storage === null) {
|
||||
return {
|
||||
document: createEmptySceneDocument(),
|
||||
viewportLayoutState: null,
|
||||
diagnostic: null
|
||||
};
|
||||
}
|
||||
const draftResult = loadSceneDocumentDraft(storage, key);
|
||||
switch (draftResult.status) {
|
||||
case "loaded":
|
||||
return {
|
||||
document: draftResult.document,
|
||||
viewportLayoutState: draftResult.viewportLayoutState,
|
||||
diagnostic: null
|
||||
};
|
||||
case "missing":
|
||||
return {
|
||||
document: createEmptySceneDocument(),
|
||||
viewportLayoutState: null,
|
||||
diagnostic: null
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
document: createEmptySceneDocument(),
|
||||
viewportLayoutState: null,
|
||||
diagnostic: `${draftResult.message} Starting with a fresh empty document.`
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { migrateSceneDocument } from "../document/migrate-scene-document";
|
||||
import { assertSceneDocumentIsValid } from "../document/scene-document-validation";
|
||||
export function serializeSceneDocument(document) {
|
||||
assertSceneDocumentIsValid(document);
|
||||
return JSON.stringify(document, null, 2);
|
||||
}
|
||||
export function parseSceneDocumentJson(source) {
|
||||
let parsedValue;
|
||||
try {
|
||||
parsedValue = JSON.parse(source);
|
||||
}
|
||||
catch (error) {
|
||||
const cause = error instanceof Error ? error.message : "Unknown JSON parse failure.";
|
||||
throw new Error(`Scene document JSON could not be parsed: ${cause}`);
|
||||
}
|
||||
const document = migrateSceneDocument(parsedValue);
|
||||
assertSceneDocumentIsValid(document);
|
||||
return document;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
function clampMenuPosition(position) {
|
||||
const horizontalPadding = 12;
|
||||
const verticalPadding = 12;
|
||||
const estimatedMenuWidth = 300;
|
||||
const estimatedMenuHeight = 420;
|
||||
return {
|
||||
x: Math.max(horizontalPadding, Math.min(position.x, window.innerWidth - estimatedMenuWidth - horizontalPadding)),
|
||||
y: Math.max(verticalPadding, Math.min(position.y, window.innerHeight - estimatedMenuHeight - verticalPadding))
|
||||
};
|
||||
}
|
||||
function renderHierarchicalMenuItems(items, onClose) {
|
||||
return items.map((item, index) => {
|
||||
if (item.kind === "separator") {
|
||||
return _jsx("div", { className: "hierarchical-menu__separator", role: "separator" }, `separator-${index}`);
|
||||
}
|
||||
if (item.kind === "group") {
|
||||
return (_jsxs("details", { className: "hierarchical-menu__group", children: [_jsxs("summary", { className: "hierarchical-menu__group-summary", "data-testid": item.testId, children: [_jsx("span", { className: "hierarchical-menu__group-label", children: item.label }), _jsx("span", { className: "hierarchical-menu__group-chevron", "aria-hidden": "true" })] }), _jsx("div", { className: "hierarchical-menu__children", children: renderHierarchicalMenuItems(item.children, onClose) })] }, `${item.label}-${index}`));
|
||||
}
|
||||
return (_jsxs("button", { className: "hierarchical-menu__action", type: "button", role: "menuitem", "data-testid": item.testId, disabled: item.disabled, onClick: () => {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
item.onSelect();
|
||||
onClose();
|
||||
}, onPointerEnter: () => item.onHoverChange?.(true), onPointerLeave: () => item.onHoverChange?.(false), onFocus: () => item.onHoverChange?.(true), onBlur: () => item.onHoverChange?.(false), children: [_jsx("span", { className: "hierarchical-menu__action-label", children: item.label }), _jsx("span", { className: "hierarchical-menu__action-plus", "aria-hidden": "true", children: "+" })] }, `${item.label}-${index}`));
|
||||
});
|
||||
}
|
||||
export function HierarchicalMenu({ title, position, items, onClose }) {
|
||||
const clampedPosition = clampMenuPosition(position);
|
||||
const style = {
|
||||
left: `${clampedPosition.x}px`,
|
||||
top: `${clampedPosition.y}px`
|
||||
};
|
||||
return (_jsx("div", { className: "hierarchical-menu__backdrop", onPointerDown: onClose, role: "presentation", children: _jsxs("div", { className: "hierarchical-menu", role: "menu", "aria-label": title, style: style, onPointerDown: (event) => event.stopPropagation(), children: [_jsx("div", { className: "hierarchical-menu__title", children: title }), _jsx("div", { className: "hierarchical-menu__list", children: renderHierarchicalMenuItems(items, onClose) })] }) }));
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useId, useState } from "react";
|
||||
export function Panel({ title, children, defaultExpanded = true }) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const bodyId = useId();
|
||||
return (_jsxs("section", { className: `panel ${isExpanded ? "" : "panel--collapsed"}`, children: [_jsxs("button", { className: "panel__header", type: "button", "aria-expanded": isExpanded, "aria-controls": bodyId, onClick: () => setIsExpanded((expanded) => !expanded), children: [_jsx("span", { className: `panel__chevron ${isExpanded ? "panel__chevron--expanded" : ""}`, "aria-hidden": "true" }), _jsx("span", { children: title })] }), isExpanded ? (_jsx("div", { className: "panel__body", id: bodyId, children: children })) : null] }));
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
export function createWorldBackgroundStyle(background, imageUrl = null) {
|
||||
if (background.mode === "solid") {
|
||||
return {
|
||||
backgroundColor: background.colorHex,
|
||||
backgroundImage: "none"
|
||||
};
|
||||
}
|
||||
if (background.mode === "image") {
|
||||
return {
|
||||
backgroundColor: "#0d1116",
|
||||
backgroundImage: imageUrl === null ? "none" : `url("${imageUrl}")`,
|
||||
backgroundPosition: "center center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover"
|
||||
};
|
||||
}
|
||||
return {
|
||||
backgroundColor: background.bottomColorHex,
|
||||
backgroundImage: `linear-gradient(180deg, ${background.topColorHex} 0%, ${background.bottomColorHex} 100%)`
|
||||
};
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getWhiteboxSelectionFeedbackLabel } from "../core/whitebox-selection-feedback";
|
||||
import { getWhiteboxSelectionModeLabel } from "../core/whitebox-selection-mode";
|
||||
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style";
|
||||
import { getViewportPanelLabel } from "./viewport-layout";
|
||||
import { getViewportViewModeLabel } from "./viewport-view-modes";
|
||||
import { ViewportHost } from "./viewport-host";
|
||||
export function ViewportCanvas({ panelId, world, sceneDocument, projectAssets, loadedModelAssets, loadedImageAssets, whiteboxSelectionMode, whiteboxSnapEnabled, whiteboxSnapStep, selection, toolMode, toolPreview, transformSession, cameraState, viewMode, displayMode, layoutMode, isActivePanel, focusRequestId, focusSelection, onSelectionChange, onCommitCreation, onCameraStateChange, onToolPreviewChange, onTransformSessionChange, onTransformCommit, onTransformCancel }) {
|
||||
const containerRef = useRef(null);
|
||||
const hostRef = useRef(null);
|
||||
const shouldRenderPanel = layoutMode === "quad" || isActivePanel;
|
||||
const [viewportMessage, setViewportMessage] = useState(null);
|
||||
const [hoveredWhiteboxLabel, setHoveredWhiteboxLabel] = useState(null);
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const viewportHost = new ViewportHost();
|
||||
hostRef.current = viewportHost;
|
||||
viewportHost.setPanelId(panelId);
|
||||
viewportHost.setRenderEnabled(shouldRenderPanel);
|
||||
viewportHost.mount(container);
|
||||
setViewportMessage(null);
|
||||
return () => {
|
||||
viewportHost.dispose();
|
||||
hostRef.current = null;
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Viewport initialization failed.";
|
||||
setViewportMessage(`Viewport initialization failed: ${message}`);
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setRenderEnabled(shouldRenderPanel);
|
||||
}, [shouldRenderPanel]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setPanelId(panelId);
|
||||
}, [panelId]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.updateWorld(world);
|
||||
}, [world]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.updateAssets(projectAssets, loadedModelAssets, loadedImageAssets);
|
||||
}, [projectAssets, loadedModelAssets, loadedImageAssets]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setWhiteboxSnapSettings(whiteboxSnapEnabled, whiteboxSnapStep);
|
||||
}, [whiteboxSnapEnabled, whiteboxSnapStep]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setWhiteboxSelectionMode(whiteboxSelectionMode);
|
||||
}, [whiteboxSelectionMode]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.updateDocument(sceneDocument, selection);
|
||||
}, [sceneDocument, selection]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setViewMode(viewMode);
|
||||
}, [viewMode]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setDisplayMode(displayMode);
|
||||
}, [displayMode]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setCameraState(cameraState);
|
||||
}, [cameraState]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setBrushSelectionChangeHandler(onSelectionChange);
|
||||
}, [onSelectionChange]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setWhiteboxHoverLabelChangeHandler(setHoveredWhiteboxLabel);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setCameraStateChangeHandler(onCameraStateChange);
|
||||
}, [onCameraStateChange]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setCreationPreviewChangeHandler((nextToolPreview) => {
|
||||
onToolPreviewChange(nextToolPreview.kind === "create"
|
||||
? {
|
||||
...nextToolPreview,
|
||||
sourcePanelId: panelId
|
||||
}
|
||||
: nextToolPreview);
|
||||
});
|
||||
}, [onToolPreviewChange, panelId]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setCreationCommitHandler(onCommitCreation);
|
||||
}, [onCommitCreation]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setTransformSessionChangeHandler(onTransformSessionChange);
|
||||
}, [onTransformSessionChange]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setTransformCommitHandler(onTransformCommit);
|
||||
}, [onTransformCommit]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setTransformCancelHandler(onTransformCancel);
|
||||
}, [onTransformCancel]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setToolMode(toolMode);
|
||||
}, [toolMode]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setCreationPreview(toolMode === "create" && toolPreview.kind === "create" ? toolPreview : null);
|
||||
}, [toolMode, toolPreview]);
|
||||
useEffect(() => {
|
||||
hostRef.current?.setTransformSession(transformSession);
|
||||
}, [transformSession]);
|
||||
useEffect(() => {
|
||||
if (focusRequestId === 0) {
|
||||
return;
|
||||
}
|
||||
hostRef.current?.focusSelection(sceneDocument, focusSelection);
|
||||
}, [focusRequestId, focusSelection, sceneDocument]);
|
||||
const previewVisible = toolMode === "create" && toolPreview.kind === "create" && toolPreview.center !== null;
|
||||
const transformPreviewVisible = transformSession.kind === "active";
|
||||
const selectionModeVisible = toolMode === "select";
|
||||
const selectedWhiteboxLabel = selectionModeVisible ? getWhiteboxSelectionFeedbackLabel(sceneDocument, selection) : null;
|
||||
const showViewModeOverlay = layoutMode === "quad";
|
||||
const showOverlay = showViewModeOverlay || selectionModeVisible || previewVisible || transformPreviewVisible || selectedWhiteboxLabel !== null || hoveredWhiteboxLabel !== null;
|
||||
return (_jsxs("div", { ref: containerRef, className: `viewport-canvas viewport-canvas--${toolMode} viewport-canvas--${viewMode} viewport-canvas--${displayMode} viewport-canvas--${layoutMode}`, "data-testid": `viewport-canvas-${panelId}`, "data-active": isActivePanel ? "true" : "false", "aria-label": `${getViewportPanelLabel(panelId)} editor viewport`, style: displayMode !== "normal"
|
||||
? {
|
||||
backgroundColor: "#000000",
|
||||
backgroundImage: "none"
|
||||
}
|
||||
: createWorldBackgroundStyle(world.background, world.background.mode === "image" ? loadedImageAssets[world.background.assetId]?.sourceUrl ?? null : null), children: [!showOverlay ? null : (_jsxs("div", { className: "viewport-canvas__overlay", "data-testid": `viewport-overlay-${panelId}`, children: [!showViewModeOverlay ? null : (_jsxs("div", { className: "viewport-canvas__overlay-badges", children: [_jsx("div", { className: "viewport-canvas__overlay-badge viewport-canvas__overlay-badge--view", children: getViewportViewModeLabel(viewMode) }), !selectionModeVisible ? null : (_jsx("div", { className: "viewport-canvas__overlay-badge viewport-canvas__overlay-badge--selection", "data-testid": `viewport-selection-mode-${panelId}`, children: getWhiteboxSelectionModeLabel(whiteboxSelectionMode) }))] })), showViewModeOverlay || !selectionModeVisible ? null : (_jsx("div", { className: "viewport-canvas__overlay-badges", children: _jsx("div", { className: "viewport-canvas__overlay-badge viewport-canvas__overlay-badge--selection", "data-testid": `viewport-selection-mode-${panelId}`, children: getWhiteboxSelectionModeLabel(whiteboxSelectionMode) }) })), !previewVisible ? null : (_jsxs("div", { className: "viewport-canvas__overlay-preview", "data-testid": `viewport-snap-preview-${panelId}`, children: ["Preview: ", toolPreview.center.x, ", ", toolPreview.center.y, ", ", toolPreview.center.z] })), !transformPreviewVisible ? null : (_jsx("div", { className: "viewport-canvas__overlay-preview", "data-testid": `viewport-transform-preview-${panelId}`, children: transformSession.kind !== "active"
|
||||
? null
|
||||
: `${transformSession.operation}${transformSession.axisConstraint === null ? "" : ` · ${transformSession.axisConstraint.toUpperCase()}`}` })), selectedWhiteboxLabel === null ? null : (_jsxs("div", { className: "viewport-canvas__overlay-preview", "data-testid": `viewport-selected-whitebox-${panelId}`, children: ["Selected: ", selectedWhiteboxLabel] })), hoveredWhiteboxLabel === null ? null : (_jsxs("div", { className: "viewport-canvas__overlay-preview", "data-testid": `viewport-hovered-whitebox-${panelId}`, children: ["Hover: ", hoveredWhiteboxLabel] }))] })), viewportMessage === null ? null : (_jsxs("div", { className: "viewport-canvas__fallback", role: "status", children: [_jsx("div", { className: "viewport-canvas__fallback-title", children: "Viewport Unavailable" }), _jsx("div", { children: viewportMessage }), toolMode !== "create" || toolPreview.kind !== "create" ? null : (_jsx("button", { className: "toolbar__button toolbar__button--accent", type: "button", "data-testid": `viewport-fallback-create-${panelId}`, onClick: () => {
|
||||
onCommitCreation(toolPreview);
|
||||
}, children: "Commit Creation Preview" }))] }))] }));
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { ViewportCanvas } from "./ViewportCanvas";
|
||||
import {
|
||||
VIEWPORT_LAYOUT_MODES,
|
||||
getViewportDisplayModeLabel,
|
||||
getViewportLayoutModeLabel,
|
||||
getViewportPanelLabel
|
||||
} from "./viewport-layout";
|
||||
import { VIEWPORT_VIEW_MODES, getViewportViewModeLabel } from "./viewport-view-modes";
|
||||
import {
|
||||
WHITEBOX_SELECTION_MODES,
|
||||
getWhiteboxSelectionModeLabel
|
||||
} from "../core/whitebox-selection-mode";
|
||||
const VIEWPORT_DISPLAY_MODES = ["normal", "authoring", "wireframe"];
|
||||
function getPanelScopedTestId(panelId, name) {
|
||||
return `viewport-panel-${panelId}-${name}`;
|
||||
}
|
||||
function getSharedControlTestId(panelId, isActive, sharedId, fallbackId = sharedId) {
|
||||
return isActive ? sharedId : getPanelScopedTestId(panelId, fallbackId);
|
||||
}
|
||||
export function ViewportPanel({ panelId, panelState, layoutMode, isActive, className, style, world, sceneDocument, projectAssets, loadedModelAssets, loadedImageAssets, whiteboxSelectionMode, whiteboxSnapEnabled, whiteboxSnapStepDraft, whiteboxSnapStep, viewportGridVisible, selection, toolMode, toolPreview, transformSession, canTranslateSelectedTarget, canRotateSelectedTarget, canScaleSelectedTarget, cameraState, focusRequestId, focusSelection, isAddMenuOpen, onActivatePanel, onOpenAddMenu, onSetViewportLayoutMode, onSetPanelViewMode, onSetPanelDisplayMode, onCommitCreation, onCameraStateChange, onToolPreviewChange, onBeginTransformOperation, onWhiteboxSelectionModeChange, onViewportGridToggle, onWhiteboxSnapToggle, onWhiteboxSnapStepDraftChange, onWhiteboxSnapStepBlur, onTransformSessionChange, onTransformCommit, onTransformCancel, onSelectionChange }) {
|
||||
const shouldShow = layoutMode === "quad" || isActive;
|
||||
const panelStyle = shouldShow ? style : { ...(style ?? {}), display: "none" };
|
||||
const transformButtonsDisabled = toolMode !== "select";
|
||||
return (_jsxs("section", { className: `viewport-panel ${layoutMode === "single" ? "viewport-panel--single" : "viewport-panel--quad"} ${className ?? ""}`.trim(), "data-testid": `viewport-panel-${panelId}`, "data-active": isActive ? "true" : "false", "data-viewport-panel-id": panelId, "aria-hidden": shouldShow ? undefined : true, "aria-label": `${getViewportPanelLabel(panelId)} viewport panel`, style: panelStyle, onPointerDownCapture: () => onActivatePanel(panelId), onFocusCapture: () => onActivatePanel(panelId), children: [_jsx(ViewportCanvas, { panelId: panelId, world: world, sceneDocument: sceneDocument, projectAssets: projectAssets, loadedModelAssets: loadedModelAssets, loadedImageAssets: loadedImageAssets, whiteboxSelectionMode: whiteboxSelectionMode, whiteboxSnapEnabled: whiteboxSnapEnabled, whiteboxSnapStep: whiteboxSnapStep, viewportGridVisible: viewportGridVisible, selection: selection, toolMode: toolMode, toolPreview: toolPreview, transformSession: transformSession, cameraState: cameraState, viewMode: panelState.viewMode, displayMode: panelState.displayMode, layoutMode: layoutMode, isActivePanel: isActive, focusRequestId: focusRequestId, focusSelection: focusSelection, onSelectionChange: onSelectionChange, onCommitCreation: onCommitCreation, onCameraStateChange: onCameraStateChange, onToolPreviewChange: onToolPreviewChange, onTransformSessionChange: onTransformSessionChange, onTransformCommit: onTransformCommit, onTransformCancel: onTransformCancel }), _jsx("div", { className: "viewport-panel__overlay viewport-panel__overlay--top", children: _jsxs("div", { className: "viewport-panel__overlay-scroll", children: [_jsx("button", { className: "viewport-panel__button viewport-panel__button--accent", type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "outliner-add-button", "add-button"), "aria-haspopup": "menu", "aria-expanded": isAddMenuOpen, onClick: onOpenAddMenu, children: "Add" }), _jsx("div", { className: "viewport-panel__control-group", role: "group", "aria-label": "Viewport layout mode", children: VIEWPORT_LAYOUT_MODES.map((mode) => (_jsx("button", { className: `viewport-panel__button ${layoutMode === mode ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, `viewport-layout-${mode}`, `layout-${mode}`), "aria-pressed": layoutMode === mode, onClick: () => onSetViewportLayoutMode(mode), children: getViewportLayoutModeLabel(mode) }, mode))) }), _jsx("div", { className: "viewport-panel__control-group", role: "group", "aria-label": `${getViewportPanelLabel(panelId)} view mode`, children: VIEWPORT_VIEW_MODES.map((viewMode) => (_jsx("button", { className: `viewport-panel__button ${panelState.viewMode === viewMode ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": `viewport-panel-${panelId}-view-${viewMode}`, "aria-pressed": panelState.viewMode === viewMode, onClick: () => onSetPanelViewMode(panelId, viewMode), children: getViewportViewModeLabel(viewMode) }, viewMode))) }), _jsx("div", { className: "viewport-panel__control-group", role: "group", "aria-label": `${getViewportPanelLabel(panelId)} display mode`, children: VIEWPORT_DISPLAY_MODES.map((displayMode) => (_jsx("button", { className: `viewport-panel__button ${panelState.displayMode === displayMode ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": `viewport-panel-${panelId}-display-${displayMode}`, "aria-pressed": panelState.displayMode === displayMode, onClick: () => onSetPanelDisplayMode(panelId, displayMode), children: getViewportDisplayModeLabel(displayMode) }, displayMode))) }), _jsxs("div", { className: "viewport-panel__control-group", role: "group", "aria-label": "Whitebox snap settings", children: [_jsx("button", { className: `viewport-panel__button ${viewportGridVisible ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "viewport-grid-toggle"), "aria-pressed": viewportGridVisible, onClick: onViewportGridToggle, children: viewportGridVisible ? "Grid On" : "Grid Off" }), _jsx("button", { className: `viewport-panel__button ${whiteboxSnapEnabled ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "whitebox-snap-toggle"), "aria-pressed": whiteboxSnapEnabled, onClick: onWhiteboxSnapToggle, children: whiteboxSnapEnabled ? "Snap On" : "Snap Off" }), _jsxs("label", { className: "viewport-panel__inline-field", children: [_jsx("span", { className: "viewport-panel__inline-label", children: "Step" }), _jsx("input", { "data-testid": getSharedControlTestId(panelId, isActive, "whitebox-snap-step"), className: "text-input viewport-panel__inline-input", type: "number", min: "0.01", step: "0.1", value: whiteboxSnapStepDraft, onChange: (event) => onWhiteboxSnapStepDraftChange(event.currentTarget.value), onBlur: onWhiteboxSnapStepBlur, onKeyDown: (event) => {
|
||||
if (event.key === "Enter") {
|
||||
onWhiteboxSnapStepBlur();
|
||||
}
|
||||
} })] })] })] }) }), _jsxs("div", { className: "viewport-panel__overlay viewport-panel__overlay--left", children: [_jsxs("div", { className: "viewport-panel__control-group viewport-panel__control-group--stack", role: "group", "aria-label": "Transform operations", children: [_jsx("button", { className: `viewport-panel__button ${transformSession.kind === "active" && transformSession.operation === "translate" ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "transform-translate-button"), "aria-pressed": transformSession.kind === "active" && transformSession.operation === "translate", disabled: transformButtonsDisabled || !canTranslateSelectedTarget, onClick: () => onBeginTransformOperation("translate"), children: "Move" }), _jsx("button", { className: `viewport-panel__button ${transformSession.kind === "active" && transformSession.operation === "rotate" ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "transform-rotate-button"), "aria-pressed": transformSession.kind === "active" && transformSession.operation === "rotate", disabled: transformButtonsDisabled || !canRotateSelectedTarget, onClick: () => onBeginTransformOperation("rotate"), children: "Rotate" }), _jsx("button", { className: `viewport-panel__button ${transformSession.kind === "active" && transformSession.operation === "scale" ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, "transform-scale-button"), "aria-pressed": transformSession.kind === "active" && transformSession.operation === "scale", disabled: transformButtonsDisabled || !canScaleSelectedTarget, onClick: () => onBeginTransformOperation("scale"), children: "Scale" })] }), _jsx("div", { className: "viewport-panel__control-group viewport-panel__control-group--stack", role: "group", "aria-label": "Whitebox selection mode", children: WHITEBOX_SELECTION_MODES.map((mode) => (_jsx("button", { className: `viewport-panel__button ${whiteboxSelectionMode === mode ? "viewport-panel__button--active" : ""}`, type: "button", "data-testid": getSharedControlTestId(panelId, isActive, `whitebox-selection-mode-${mode}`), "aria-pressed": whiteboxSelectionMode === mode, onClick: () => onWhiteboxSelectionModeChange(mode), children: getWhiteboxSelectionModeLabel(mode) }, mode))) })] })] }));
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { BoxGeometry, CylinderGeometry, Mesh, MeshStandardMaterial, TorusGeometry } from "three";
|
||||
const SOUND_EMITTER_CABINET_SIZE = {
|
||||
x: 0.38,
|
||||
y: 0.5,
|
||||
z: 0.18
|
||||
};
|
||||
const SOUND_EMITTER_FRONT_OFFSET = 0.1;
|
||||
const SOUND_EMITTER_TWEETER_RADIUS = 0.045;
|
||||
const SOUND_EMITTER_TWEETER_Y = 0.15;
|
||||
const SOUND_EMITTER_WOOFER_RADIUS = 0.11;
|
||||
const SOUND_EMITTER_WOOFER_Y = -0.08;
|
||||
function createSpeakerMaterial(color, selected, emissiveIntensity, roughness, metalness) {
|
||||
return new MeshStandardMaterial({
|
||||
color,
|
||||
emissive: color,
|
||||
emissiveIntensity: selected ? emissiveIntensity : emissiveIntensity * 0.35,
|
||||
roughness,
|
||||
metalness
|
||||
});
|
||||
}
|
||||
export function createSoundEmitterMarkerMeshes(markerColor, selected) {
|
||||
const cabinet = new Mesh(new BoxGeometry(SOUND_EMITTER_CABINET_SIZE.x, SOUND_EMITTER_CABINET_SIZE.y, SOUND_EMITTER_CABINET_SIZE.z), createSpeakerMaterial(0x23272e, selected, 0.08, 0.88, 0.02));
|
||||
const tweeterRing = new Mesh(new TorusGeometry(SOUND_EMITTER_TWEETER_RADIUS, 0.012, 8, 18), createSpeakerMaterial(markerColor, selected, 0.22, 0.4, 0.04));
|
||||
tweeterRing.rotation.x = Math.PI * 0.5;
|
||||
tweeterRing.position.set(0, SOUND_EMITTER_TWEETER_Y, SOUND_EMITTER_FRONT_OFFSET);
|
||||
const tweeterCone = new Mesh(new CylinderGeometry(SOUND_EMITTER_TWEETER_RADIUS * 0.58, SOUND_EMITTER_TWEETER_RADIUS * 0.58, 0.028, 18), createSpeakerMaterial(0x14171c, selected, 0.08, 0.68, 0.01));
|
||||
tweeterCone.rotation.x = Math.PI * 0.5;
|
||||
tweeterCone.position.set(0, SOUND_EMITTER_TWEETER_Y, SOUND_EMITTER_FRONT_OFFSET + 0.006);
|
||||
const wooferRing = new Mesh(new TorusGeometry(SOUND_EMITTER_WOOFER_RADIUS, 0.016, 8, 20), createSpeakerMaterial(markerColor, selected, 0.22, 0.42, 0.04));
|
||||
wooferRing.rotation.x = Math.PI * 0.5;
|
||||
wooferRing.position.set(0, SOUND_EMITTER_WOOFER_Y, SOUND_EMITTER_FRONT_OFFSET);
|
||||
const wooferCone = new Mesh(new CylinderGeometry(SOUND_EMITTER_WOOFER_RADIUS * 0.6, SOUND_EMITTER_WOOFER_RADIUS * 0.6, 0.032, 20), createSpeakerMaterial(0x14171c, selected, 0.08, 0.72, 0.01));
|
||||
wooferCone.rotation.x = Math.PI * 0.5;
|
||||
wooferCone.position.set(0, SOUND_EMITTER_WOOFER_Y, SOUND_EMITTER_FRONT_OFFSET + 0.007);
|
||||
return [cabinet, tweeterRing, tweeterCone, wooferRing, wooferCone];
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { getSingleSelectedBrushId, getSingleSelectedEntityId, getSingleSelectedModelInstanceId } from "../core/selection";
|
||||
import { getBoxBrushBounds } from "../geometry/box-brush";
|
||||
const PLAYER_START_FOCUS_HALF_EXTENTS = {
|
||||
x: 0.35,
|
||||
y: 0.3,
|
||||
z: 0.55
|
||||
};
|
||||
const TELEPORT_TARGET_FOCUS_HALF_EXTENTS = {
|
||||
x: 0.42,
|
||||
y: 0.28,
|
||||
z: 0.42
|
||||
};
|
||||
function createEmptyBoundsAccumulator() {
|
||||
return {
|
||||
min: {
|
||||
x: Number.POSITIVE_INFINITY,
|
||||
y: Number.POSITIVE_INFINITY,
|
||||
z: Number.POSITIVE_INFINITY
|
||||
},
|
||||
max: {
|
||||
x: Number.NEGATIVE_INFINITY,
|
||||
y: Number.NEGATIVE_INFINITY,
|
||||
z: Number.NEGATIVE_INFINITY
|
||||
}
|
||||
};
|
||||
}
|
||||
function includeBounds(bounds, min, max) {
|
||||
bounds.min.x = Math.min(bounds.min.x, min.x);
|
||||
bounds.min.y = Math.min(bounds.min.y, min.y);
|
||||
bounds.min.z = Math.min(bounds.min.z, min.z);
|
||||
bounds.max.x = Math.max(bounds.max.x, max.x);
|
||||
bounds.max.y = Math.max(bounds.max.y, max.y);
|
||||
bounds.max.z = Math.max(bounds.max.z, max.z);
|
||||
}
|
||||
function finishBounds(bounds) {
|
||||
if (!Number.isFinite(bounds.min.x) || !Number.isFinite(bounds.max.x)) {
|
||||
return null;
|
||||
}
|
||||
const center = {
|
||||
x: (bounds.min.x + bounds.max.x) * 0.5,
|
||||
y: (bounds.min.y + bounds.max.y) * 0.5,
|
||||
z: (bounds.min.z + bounds.max.z) * 0.5
|
||||
};
|
||||
const radius = Math.max(0.5, Math.hypot(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y, bounds.max.z - bounds.min.z) * 0.5);
|
||||
return {
|
||||
center,
|
||||
radius
|
||||
};
|
||||
}
|
||||
function createBrushFocusTarget(brush) {
|
||||
return {
|
||||
center: {
|
||||
...brush.center
|
||||
},
|
||||
radius: Math.max(0.5, Math.hypot(brush.size.x, brush.size.y, brush.size.z) * 0.5)
|
||||
};
|
||||
}
|
||||
function includeBrush(bounds, brush) {
|
||||
const brushBounds = getBoxBrushBounds(brush);
|
||||
includeBounds(bounds, brushBounds.min, brushBounds.max);
|
||||
}
|
||||
function includePlayerStart(bounds, position) {
|
||||
includeBounds(bounds, {
|
||||
x: position.x - PLAYER_START_FOCUS_HALF_EXTENTS.x,
|
||||
y: position.y,
|
||||
z: position.z - PLAYER_START_FOCUS_HALF_EXTENTS.z
|
||||
}, {
|
||||
x: position.x + PLAYER_START_FOCUS_HALF_EXTENTS.x,
|
||||
y: position.y + PLAYER_START_FOCUS_HALF_EXTENTS.y * 2,
|
||||
z: position.z + PLAYER_START_FOCUS_HALF_EXTENTS.z
|
||||
});
|
||||
}
|
||||
function createBoundsFocusTarget(center, halfExtents, minimumRadius) {
|
||||
return {
|
||||
center,
|
||||
radius: Math.max(minimumRadius, Math.hypot(halfExtents.x, halfExtents.y, halfExtents.z))
|
||||
};
|
||||
}
|
||||
function createPlayerStartFocusTarget(position) {
|
||||
return createBoundsFocusTarget({
|
||||
x: position.x,
|
||||
y: position.y + PLAYER_START_FOCUS_HALF_EXTENTS.y,
|
||||
z: position.z
|
||||
}, PLAYER_START_FOCUS_HALF_EXTENTS, 0.45);
|
||||
}
|
||||
function includeTeleportTarget(bounds, position) {
|
||||
includeBounds(bounds, {
|
||||
x: position.x - TELEPORT_TARGET_FOCUS_HALF_EXTENTS.x,
|
||||
y: position.y,
|
||||
z: position.z - TELEPORT_TARGET_FOCUS_HALF_EXTENTS.z
|
||||
}, {
|
||||
x: position.x + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.x,
|
||||
y: position.y + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.y * 2,
|
||||
z: position.z + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.z
|
||||
});
|
||||
}
|
||||
function createTeleportTargetFocusTarget(position) {
|
||||
return createBoundsFocusTarget({
|
||||
x: position.x,
|
||||
y: position.y + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.y,
|
||||
z: position.z
|
||||
}, TELEPORT_TARGET_FOCUS_HALF_EXTENTS, 0.45);
|
||||
}
|
||||
function getModelInstanceBoundingBox(modelInstance, asset) {
|
||||
if (asset?.kind === "model" && asset.metadata.boundingBox !== null) {
|
||||
const boundingBox = asset.metadata.boundingBox;
|
||||
const scaledMin = {
|
||||
x: boundingBox.min.x * modelInstance.scale.x,
|
||||
y: boundingBox.min.y * modelInstance.scale.y,
|
||||
z: boundingBox.min.z * modelInstance.scale.z
|
||||
};
|
||||
const scaledMax = {
|
||||
x: boundingBox.max.x * modelInstance.scale.x,
|
||||
y: boundingBox.max.y * modelInstance.scale.y,
|
||||
z: boundingBox.max.z * modelInstance.scale.z
|
||||
};
|
||||
return {
|
||||
center: {
|
||||
x: modelInstance.position.x + (scaledMin.x + scaledMax.x) * 0.5,
|
||||
y: modelInstance.position.y + (scaledMin.y + scaledMax.y) * 0.5,
|
||||
z: modelInstance.position.z + (scaledMin.z + scaledMax.z) * 0.5
|
||||
},
|
||||
size: {
|
||||
x: Math.abs(scaledMax.x - scaledMin.x),
|
||||
y: Math.abs(scaledMax.y - scaledMin.y),
|
||||
z: Math.abs(scaledMax.z - scaledMin.z)
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
center: {
|
||||
...modelInstance.position
|
||||
},
|
||||
size: {
|
||||
x: modelInstance.scale.x,
|
||||
y: modelInstance.scale.y,
|
||||
z: modelInstance.scale.z
|
||||
}
|
||||
};
|
||||
}
|
||||
function includeModelInstance(bounds, modelInstance, asset) {
|
||||
const modelBounds = getModelInstanceBoundingBox(modelInstance, asset);
|
||||
const halfSize = {
|
||||
x: modelBounds.size.x * 0.5,
|
||||
y: modelBounds.size.y * 0.5,
|
||||
z: modelBounds.size.z * 0.5
|
||||
};
|
||||
includeBounds(bounds, {
|
||||
x: modelBounds.center.x - halfSize.x,
|
||||
y: modelBounds.center.y - halfSize.y,
|
||||
z: modelBounds.center.z - halfSize.z
|
||||
}, {
|
||||
x: modelBounds.center.x + halfSize.x,
|
||||
y: modelBounds.center.y + halfSize.y,
|
||||
z: modelBounds.center.z + halfSize.z
|
||||
});
|
||||
}
|
||||
function createModelInstanceFocusTarget(modelInstance, asset) {
|
||||
const modelBounds = getModelInstanceBoundingBox(modelInstance, asset);
|
||||
return createBoundsFocusTarget(modelBounds.center, {
|
||||
x: modelBounds.size.x * 0.5,
|
||||
y: modelBounds.size.y * 0.5,
|
||||
z: modelBounds.size.z * 0.5
|
||||
}, 0.5);
|
||||
}
|
||||
function includeSphereEntity(bounds, position, radius) {
|
||||
includeBounds(bounds, {
|
||||
x: position.x - radius,
|
||||
y: position.y - radius,
|
||||
z: position.z - radius
|
||||
}, {
|
||||
x: position.x + radius,
|
||||
y: position.y + radius,
|
||||
z: position.z + radius
|
||||
});
|
||||
}
|
||||
function createSphereEntityFocusTarget(position, radius, minimumRadius) {
|
||||
return {
|
||||
center: {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
z: position.z
|
||||
},
|
||||
radius: Math.max(minimumRadius, radius)
|
||||
};
|
||||
}
|
||||
function includeTriggerVolume(bounds, position, size) {
|
||||
const halfSize = {
|
||||
x: size.x * 0.5,
|
||||
y: size.y * 0.5,
|
||||
z: size.z * 0.5
|
||||
};
|
||||
includeBounds(bounds, {
|
||||
x: position.x - halfSize.x,
|
||||
y: position.y - halfSize.y,
|
||||
z: position.z - halfSize.z
|
||||
}, {
|
||||
x: position.x + halfSize.x,
|
||||
y: position.y + halfSize.y,
|
||||
z: position.z + halfSize.z
|
||||
});
|
||||
}
|
||||
function createTriggerVolumeFocusTarget(position, size) {
|
||||
const halfSize = {
|
||||
x: size.x * 0.5,
|
||||
y: size.y * 0.5,
|
||||
z: size.z * 0.5
|
||||
};
|
||||
return createBoundsFocusTarget({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
z: position.z
|
||||
}, halfSize, 0.75);
|
||||
}
|
||||
function includeEntity(bounds, entity) {
|
||||
switch (entity.kind) {
|
||||
case "pointLight":
|
||||
includeSphereEntity(bounds, entity.position, Math.max(0.5, entity.distance));
|
||||
break;
|
||||
case "spotLight":
|
||||
includeSphereEntity(bounds, entity.position, Math.max(0.75, entity.distance));
|
||||
break;
|
||||
case "playerStart":
|
||||
includePlayerStart(bounds, entity.position);
|
||||
break;
|
||||
case "soundEmitter":
|
||||
includeSphereEntity(bounds, entity.position, Math.max(0.4, entity.maxDistance));
|
||||
break;
|
||||
case "triggerVolume":
|
||||
includeTriggerVolume(bounds, entity.position, entity.size);
|
||||
break;
|
||||
case "teleportTarget":
|
||||
includeTeleportTarget(bounds, entity.position);
|
||||
break;
|
||||
case "interactable":
|
||||
includeSphereEntity(bounds, entity.position, Math.max(0.4, entity.radius));
|
||||
break;
|
||||
}
|
||||
}
|
||||
function createEntityFocusTarget(entity) {
|
||||
switch (entity.kind) {
|
||||
case "pointLight":
|
||||
return createSphereEntityFocusTarget(entity.position, Math.max(0.6, entity.distance), 0.75);
|
||||
case "spotLight":
|
||||
return createSphereEntityFocusTarget(entity.position, Math.max(0.8, entity.distance), 0.9);
|
||||
case "playerStart":
|
||||
return createPlayerStartFocusTarget(entity.position);
|
||||
case "soundEmitter":
|
||||
return createSphereEntityFocusTarget(entity.position, entity.maxDistance, 0.75);
|
||||
case "triggerVolume":
|
||||
return createTriggerVolumeFocusTarget(entity.position, entity.size);
|
||||
case "teleportTarget":
|
||||
return createTeleportTargetFocusTarget(entity.position);
|
||||
case "interactable":
|
||||
return createSphereEntityFocusTarget(entity.position, entity.radius, 0.65);
|
||||
}
|
||||
}
|
||||
function getSceneFocusTarget(document) {
|
||||
const bounds = createEmptyBoundsAccumulator();
|
||||
for (const brush of Object.values(document.brushes)) {
|
||||
includeBrush(bounds, brush);
|
||||
}
|
||||
for (const modelInstance of Object.values(document.modelInstances)) {
|
||||
includeModelInstance(bounds, modelInstance, document.assets[modelInstance.assetId]);
|
||||
}
|
||||
for (const entity of Object.values(document.entities)) {
|
||||
includeEntity(bounds, entity);
|
||||
}
|
||||
return finishBounds(bounds);
|
||||
}
|
||||
export function resolveViewportFocusTarget(document, selection) {
|
||||
const selectedBrushId = getSingleSelectedBrushId(selection);
|
||||
if (selectedBrushId !== null) {
|
||||
const brush = document.brushes[selectedBrushId];
|
||||
if (brush !== undefined && brush.kind === "box") {
|
||||
return createBrushFocusTarget(brush);
|
||||
}
|
||||
}
|
||||
const selectedEntityId = getSingleSelectedEntityId(selection);
|
||||
if (selectedEntityId !== null) {
|
||||
const entity = document.entities[selectedEntityId];
|
||||
if (entity !== undefined) {
|
||||
return createEntityFocusTarget(entity);
|
||||
}
|
||||
}
|
||||
const selectedModelInstanceId = getSingleSelectedModelInstanceId(selection);
|
||||
if (selectedModelInstanceId !== null) {
|
||||
const modelInstance = document.modelInstances[selectedModelInstanceId];
|
||||
if (modelInstance !== undefined) {
|
||||
return createModelInstanceFocusTarget(modelInstance, document.assets[modelInstance.assetId]);
|
||||
}
|
||||
}
|
||||
return getSceneFocusTarget(document);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
||||
export const VIEWPORT_LAYOUT_MODES = ["single", "quad"];
|
||||
export const VIEWPORT_PANEL_IDS = ["topLeft", "topRight", "bottomLeft", "bottomRight"];
|
||||
const DEFAULT_PERSPECTIVE_CAMERA_POSITION = {
|
||||
x: 10,
|
||||
y: 9,
|
||||
z: 10
|
||||
};
|
||||
export const DEFAULT_VIEWPORT_LAYOUT_STATE = {
|
||||
layoutMode: "single",
|
||||
activePanelId: "topLeft",
|
||||
panels: {
|
||||
topLeft: {
|
||||
viewMode: "perspective",
|
||||
displayMode: "normal",
|
||||
cameraState: createDefaultViewportPanelCameraState()
|
||||
},
|
||||
topRight: {
|
||||
viewMode: "top",
|
||||
displayMode: "authoring",
|
||||
cameraState: createDefaultViewportPanelCameraState()
|
||||
},
|
||||
bottomLeft: {
|
||||
viewMode: "front",
|
||||
displayMode: "authoring",
|
||||
cameraState: createDefaultViewportPanelCameraState()
|
||||
},
|
||||
bottomRight: {
|
||||
viewMode: "side",
|
||||
displayMode: "authoring",
|
||||
cameraState: createDefaultViewportPanelCameraState()
|
||||
}
|
||||
},
|
||||
viewportQuadSplit: {
|
||||
x: 0.5,
|
||||
y: 0.5
|
||||
}
|
||||
};
|
||||
function createDefaultPerspectiveOrbitState() {
|
||||
const { x, y, z } = DEFAULT_PERSPECTIVE_CAMERA_POSITION;
|
||||
const radius = Math.sqrt(x * x + y * y + z * z);
|
||||
return {
|
||||
radius,
|
||||
theta: Math.atan2(x, z),
|
||||
phi: Math.acos(y / radius)
|
||||
};
|
||||
}
|
||||
export function createDefaultViewportPanelCameraState() {
|
||||
return {
|
||||
target: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
},
|
||||
perspectiveOrbit: createDefaultPerspectiveOrbitState(),
|
||||
orthographicZoom: 1
|
||||
};
|
||||
}
|
||||
export function cloneViewportPanelCameraState(cameraState) {
|
||||
return {
|
||||
target: {
|
||||
...cameraState.target
|
||||
},
|
||||
perspectiveOrbit: {
|
||||
...cameraState.perspectiveOrbit
|
||||
},
|
||||
orthographicZoom: cameraState.orthographicZoom
|
||||
};
|
||||
}
|
||||
export function areViewportPanelCameraStatesEqual(a, b) {
|
||||
return (a.target.x === b.target.x &&
|
||||
a.target.y === b.target.y &&
|
||||
a.target.z === b.target.z &&
|
||||
a.perspectiveOrbit.radius === b.perspectiveOrbit.radius &&
|
||||
a.perspectiveOrbit.theta === b.perspectiveOrbit.theta &&
|
||||
a.perspectiveOrbit.phi === b.perspectiveOrbit.phi &&
|
||||
a.orthographicZoom === b.orthographicZoom);
|
||||
}
|
||||
export function cloneViewportPanelState(panelState) {
|
||||
return {
|
||||
viewMode: panelState.viewMode,
|
||||
displayMode: panelState.displayMode,
|
||||
cameraState: cloneViewportPanelCameraState(panelState.cameraState)
|
||||
};
|
||||
}
|
||||
export function cloneViewportLayoutState(layoutState) {
|
||||
return {
|
||||
layoutMode: layoutState.layoutMode,
|
||||
activePanelId: layoutState.activePanelId,
|
||||
panels: {
|
||||
topLeft: cloneViewportPanelState(layoutState.panels.topLeft),
|
||||
topRight: cloneViewportPanelState(layoutState.panels.topRight),
|
||||
bottomLeft: cloneViewportPanelState(layoutState.panels.bottomLeft),
|
||||
bottomRight: cloneViewportPanelState(layoutState.panels.bottomRight)
|
||||
},
|
||||
viewportQuadSplit: {
|
||||
...layoutState.viewportQuadSplit
|
||||
}
|
||||
};
|
||||
}
|
||||
export function createDefaultViewportLayoutState() {
|
||||
return cloneViewportLayoutState(DEFAULT_VIEWPORT_LAYOUT_STATE);
|
||||
}
|
||||
const VIEWPORT_PANEL_LABELS = {
|
||||
topLeft: "Top Left",
|
||||
topRight: "Top Right",
|
||||
bottomLeft: "Bottom Left",
|
||||
bottomRight: "Bottom Right"
|
||||
};
|
||||
const VIEWPORT_LAYOUT_MODE_LABELS = {
|
||||
single: "Single View",
|
||||
quad: "4-Panel"
|
||||
};
|
||||
const VIEWPORT_DISPLAY_MODE_LABELS = {
|
||||
normal: "Normal",
|
||||
authoring: "Authoring",
|
||||
wireframe: "Wireframe"
|
||||
};
|
||||
export function getViewportPanelLabel(panelId) {
|
||||
return VIEWPORT_PANEL_LABELS[panelId];
|
||||
}
|
||||
export function getViewportLayoutModeLabel(layoutMode) {
|
||||
return VIEWPORT_LAYOUT_MODE_LABELS[layoutMode];
|
||||
}
|
||||
export function getViewportDisplayModeLabel(displayMode) {
|
||||
return VIEWPORT_DISPLAY_MODE_LABELS[displayMode];
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { areTransformSessionsEqual, cloneTransformSession, createInactiveTransformSession } from "../core/transform-session";
|
||||
export function createDefaultViewportTransientState() {
|
||||
return {
|
||||
toolPreview: {
|
||||
kind: "none"
|
||||
},
|
||||
transformSession: createInactiveTransformSession()
|
||||
};
|
||||
}
|
||||
export function cloneViewportToolPreview(toolPreview) {
|
||||
if (toolPreview.kind === "none") {
|
||||
return toolPreview;
|
||||
}
|
||||
return {
|
||||
kind: "create",
|
||||
sourcePanelId: toolPreview.sourcePanelId,
|
||||
target: toolPreview.target.kind === "entity"
|
||||
? {
|
||||
kind: "entity",
|
||||
entityKind: toolPreview.target.entityKind,
|
||||
audioAssetId: toolPreview.target.audioAssetId
|
||||
}
|
||||
: toolPreview.target.kind === "model-instance"
|
||||
? {
|
||||
kind: "model-instance",
|
||||
assetId: toolPreview.target.assetId
|
||||
}
|
||||
: {
|
||||
kind: "box-brush"
|
||||
},
|
||||
center: toolPreview.center === null ? null : { ...toolPreview.center }
|
||||
};
|
||||
}
|
||||
export function areViewportToolPreviewsEqual(left, right) {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "none" || right.kind === "none") {
|
||||
return true;
|
||||
}
|
||||
if (left.kind !== "create" || right.kind !== "create") {
|
||||
return false;
|
||||
}
|
||||
if (left.sourcePanelId !== right.sourcePanelId) {
|
||||
return false;
|
||||
}
|
||||
if (left.target.kind !== right.target.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.target.kind === "entity" && right.target.kind === "entity") {
|
||||
if (left.target.entityKind !== right.target.entityKind || left.target.audioAssetId !== right.target.audioAssetId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (left.target.kind === "model-instance" && right.target.kind === "model-instance" && left.target.assetId !== right.target.assetId) {
|
||||
return false;
|
||||
}
|
||||
if (left.center === null || right.center === null) {
|
||||
return left.center === right.center;
|
||||
}
|
||||
return left.center.x === right.center.x && left.center.y === right.center.y && left.center.z === right.center.z;
|
||||
}
|
||||
export function isViewportToolPreviewCompatible(toolMode, toolPreview) {
|
||||
if (toolPreview.kind === "none") {
|
||||
return true;
|
||||
}
|
||||
return toolMode === "create" && toolPreview.kind === "create";
|
||||
}
|
||||
export function cloneViewportTransientState(transientState) {
|
||||
return {
|
||||
toolPreview: cloneViewportToolPreview(transientState.toolPreview),
|
||||
transformSession: cloneTransformSession(transientState.transformSession)
|
||||
};
|
||||
}
|
||||
export function areViewportTransientStatesEqual(left, right) {
|
||||
return areViewportToolPreviewsEqual(left.toolPreview, right.toolPreview) && areTransformSessionsEqual(left.transformSession, right.transformSession);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
export const VIEWPORT_VIEW_MODES = ["perspective", "top", "front", "side"];
|
||||
const VIEWPORT_VIEW_MODE_DEFINITIONS = {
|
||||
perspective: {
|
||||
id: "perspective",
|
||||
label: "Perspective",
|
||||
cameraType: "perspective",
|
||||
cameraDirection: null,
|
||||
cameraUp: {
|
||||
x: 0,
|
||||
y: 1,
|
||||
z: 0
|
||||
},
|
||||
gridPlane: "xz",
|
||||
snapAxis: "y",
|
||||
controlHint: "Middle-drag orbits, Shift + middle-drag pans, wheel zooms, and Numpad Comma frames the selection."
|
||||
},
|
||||
top: {
|
||||
id: "top",
|
||||
label: "Top",
|
||||
cameraType: "orthographic",
|
||||
cameraDirection: {
|
||||
x: 0,
|
||||
y: 1,
|
||||
z: 0
|
||||
},
|
||||
cameraUp: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: -1
|
||||
},
|
||||
gridPlane: "xz",
|
||||
snapAxis: "y",
|
||||
controlHint: "Middle-drag pans, wheel zooms, and Numpad Comma frames the selection."
|
||||
},
|
||||
front: {
|
||||
id: "front",
|
||||
label: "Front",
|
||||
cameraType: "orthographic",
|
||||
cameraDirection: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1
|
||||
},
|
||||
cameraUp: {
|
||||
x: 0,
|
||||
y: 1,
|
||||
z: 0
|
||||
},
|
||||
gridPlane: "xy",
|
||||
snapAxis: "z",
|
||||
controlHint: "Middle-drag pans, wheel zooms, and Numpad Comma frames the selection."
|
||||
},
|
||||
side: {
|
||||
id: "side",
|
||||
label: "Side",
|
||||
cameraType: "orthographic",
|
||||
cameraDirection: {
|
||||
x: -1,
|
||||
y: 0,
|
||||
z: 0
|
||||
},
|
||||
cameraUp: {
|
||||
x: 0,
|
||||
y: 1,
|
||||
z: 0
|
||||
},
|
||||
gridPlane: "yz",
|
||||
snapAxis: "x",
|
||||
controlHint: "Middle-drag pans, wheel zooms, and Numpad Comma frames the selection."
|
||||
}
|
||||
};
|
||||
export function getViewportViewModeDefinition(viewMode) {
|
||||
return VIEWPORT_VIEW_MODE_DEFINITIONS[viewMode];
|
||||
}
|
||||
export function getViewportViewModeLabel(viewMode) {
|
||||
return VIEWPORT_VIEW_MODE_DEFINITIONS[viewMode].label;
|
||||
}
|
||||
export function getViewportViewModeGridPlaneLabel(viewMode) {
|
||||
return VIEWPORT_VIEW_MODE_DEFINITIONS[viewMode].gridPlane.toUpperCase();
|
||||
}
|
||||
export function getViewportViewModeControlHint(viewMode) {
|
||||
return VIEWPORT_VIEW_MODE_DEFINITIONS[viewMode].controlHint;
|
||||
}
|
||||
export function getViewportViewModeSnapAxis(viewMode) {
|
||||
return VIEWPORT_VIEW_MODE_DEFINITIONS[viewMode].snapAxis;
|
||||
}
|
||||
export function isOrthographicViewportViewMode(viewMode) {
|
||||
return viewMode !== "perspective";
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// Prefer TypeScript source over compiled JS mirrors when both exist
|
||||
extensions: [".mjs", ".mts", ".ts", ".tsx", ".jsx", ".js", ".json"]
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import viteConfig from "./vite.config";
|
||||
export default mergeConfig(viteConfig, defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./tests/setup/vitest.setup.ts"],
|
||||
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"]
|
||||
}
|
||||
}));
|
||||
Reference in New Issue
Block a user