Add path-related commands and update selection handling
This commit is contained in:
82
src/commands/delete-path-command.ts
Normal file
82
src/commands/delete-path-command.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneEditorSelection, type EditorSelection } from "../core/selection";
|
||||
import type { ToolMode } from "../core/tool-mode";
|
||||
import { cloneScenePath, type ScenePath } from "../document/paths";
|
||||
|
||||
import type { EditorCommand } from "./command";
|
||||
|
||||
function selectionIncludesPath(selection: EditorSelection, pathId: string): boolean {
|
||||
return selection.kind === "paths" && selection.ids.includes(pathId);
|
||||
}
|
||||
|
||||
export function createDeletePathCommand(pathId: string): EditorCommand {
|
||||
let previousPath: ScenePath | null = null;
|
||||
let previousSelection: EditorSelection | null = null;
|
||||
let previousToolMode: ToolMode | null = null;
|
||||
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: "Delete path",
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const currentPath = currentDocument.paths[pathId];
|
||||
|
||||
if (currentPath === undefined) {
|
||||
throw new Error(`Path ${pathId} does not exist.`);
|
||||
}
|
||||
|
||||
if (previousPath === null) {
|
||||
previousPath = cloneScenePath(currentPath);
|
||||
}
|
||||
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneEditorSelection(context.getSelection());
|
||||
}
|
||||
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
|
||||
const nextPaths = {
|
||||
...currentDocument.paths
|
||||
};
|
||||
delete nextPaths[pathId];
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
paths: nextPaths
|
||||
});
|
||||
|
||||
if (selectionIncludesPath(context.getSelection(), pathId)) {
|
||||
context.setSelection({
|
||||
kind: "none"
|
||||
});
|
||||
}
|
||||
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
if (previousPath === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDocument = context.getDocument();
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
paths: {
|
||||
...currentDocument.paths,
|
||||
[previousPath.id]: cloneScenePath(previousPath)
|
||||
}
|
||||
});
|
||||
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createOpaqueId } from "../core/ids";
|
||||
import { cloneEditorSelection, type EditorSelection } from "../core/selection";
|
||||
import type { ToolMode } from "../core/tool-mode";
|
||||
import { cloneBoxBrush, type BoxBrush } from "../document/brushes";
|
||||
import { cloneScenePath, type ScenePath } from "../document/paths";
|
||||
import type { SceneDocument } from "../document/scene-document";
|
||||
import {
|
||||
cloneEntityInstance,
|
||||
@@ -15,6 +16,7 @@ import type { EditorCommand } from "./command";
|
||||
interface DuplicateSelectionResult {
|
||||
selection: EditorSelection;
|
||||
brushes: BoxBrush[] | null;
|
||||
paths: ScenePath[] | null;
|
||||
entities: EntityInstance[] | null;
|
||||
modelInstances: ModelInstance[] | null;
|
||||
}
|
||||
@@ -25,6 +27,16 @@ function duplicateBrush(brush: BoxBrush): BoxBrush {
|
||||
return duplicatedBrush;
|
||||
}
|
||||
|
||||
function duplicatePath(path: ScenePath): ScenePath {
|
||||
const duplicatedPath = cloneScenePath(path);
|
||||
duplicatedPath.id = createOpaqueId("path");
|
||||
duplicatedPath.points = duplicatedPath.points.map((point) => ({
|
||||
...point,
|
||||
id: createOpaqueId("path-point")
|
||||
}));
|
||||
return duplicatedPath;
|
||||
}
|
||||
|
||||
function duplicateEntity(entity: EntityInstance): EntityInstance {
|
||||
const duplicatedEntity = cloneEntityInstance(entity);
|
||||
duplicatedEntity.id = createOpaqueId(`entity-${duplicatedEntity.kind}`);
|
||||
@@ -83,6 +95,34 @@ function createDuplicateSelectionResult(currentDocument: SceneDocument, selectio
|
||||
ids: duplicatedBrushes.map((brush) => brush.id)
|
||||
},
|
||||
brushes: duplicatedBrushes,
|
||||
paths: null,
|
||||
entities: null,
|
||||
modelInstances: null
|
||||
};
|
||||
}
|
||||
|
||||
if (selection.kind === "paths") {
|
||||
if (selection.ids.length === 0) {
|
||||
throw new Error("Select at least one path to duplicate.");
|
||||
}
|
||||
|
||||
const duplicatedPaths = selection.ids.map((pathId) => {
|
||||
const sourcePath = currentDocument.paths[pathId];
|
||||
|
||||
if (sourcePath === undefined) {
|
||||
throw new Error(`Path ${pathId} does not exist.`);
|
||||
}
|
||||
|
||||
return duplicatePath(sourcePath);
|
||||
});
|
||||
|
||||
return {
|
||||
selection: {
|
||||
kind: "paths",
|
||||
ids: duplicatedPaths.map((path) => path.id)
|
||||
},
|
||||
brushes: null,
|
||||
paths: duplicatedPaths,
|
||||
entities: null,
|
||||
modelInstances: null
|
||||
};
|
||||
@@ -109,6 +149,7 @@ function createDuplicateSelectionResult(currentDocument: SceneDocument, selectio
|
||||
ids: duplicatedEntities.map((entity) => entity.id)
|
||||
},
|
||||
brushes: null,
|
||||
paths: null,
|
||||
entities: duplicatedEntities,
|
||||
modelInstances: null
|
||||
};
|
||||
@@ -135,12 +176,13 @@ function createDuplicateSelectionResult(currentDocument: SceneDocument, selectio
|
||||
ids: duplicatedModelInstances.map((modelInstance) => modelInstance.id)
|
||||
},
|
||||
brushes: null,
|
||||
paths: null,
|
||||
entities: null,
|
||||
modelInstances: duplicatedModelInstances
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Selection must contain whitebox solids, entities, or model instances to duplicate.");
|
||||
throw new Error("Selection must contain whitebox solids, paths, entities, or model instances to duplicate.");
|
||||
}
|
||||
|
||||
export function createDuplicateSelectionCommand(): EditorCommand {
|
||||
@@ -174,6 +216,16 @@ export function createDuplicateSelectionCommand(): EditorCommand {
|
||||
...Object.fromEntries(duplicateSelectionResult.brushes.map((brush) => [brush.id, cloneBoxBrush(brush)]))
|
||||
}
|
||||
});
|
||||
} else if (duplicateSelectionResult.paths !== null) {
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
paths: {
|
||||
...currentDocument.paths,
|
||||
...Object.fromEntries(
|
||||
duplicateSelectionResult.paths.map((path) => [path.id, cloneScenePath(path)])
|
||||
)
|
||||
}
|
||||
});
|
||||
} else if (duplicateSelectionResult.entities !== null) {
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
@@ -217,6 +269,19 @@ export function createDuplicateSelectionCommand(): EditorCommand {
|
||||
...currentDocument,
|
||||
brushes: nextBrushes
|
||||
});
|
||||
} else if (duplicateSelectionResult.paths !== null) {
|
||||
const nextPaths = {
|
||||
...currentDocument.paths
|
||||
};
|
||||
|
||||
for (const duplicatedPath of duplicateSelectionResult.paths) {
|
||||
delete nextPaths[duplicatedPath.id];
|
||||
}
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
paths: nextPaths
|
||||
});
|
||||
} else if (duplicateSelectionResult.entities !== null) {
|
||||
const nextEntities = {
|
||||
...currentDocument.entities
|
||||
@@ -254,4 +319,4 @@ export function createDuplicateSelectionCommand(): EditorCommand {
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
90
src/commands/set-path-authored-state-command.ts
Normal file
90
src/commands/set-path-authored-state-command.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneScenePath } from "../document/paths";
|
||||
|
||||
import type { EditorCommand } from "./command";
|
||||
|
||||
interface SetPathAuthoredStateCommandOptions {
|
||||
pathId: string;
|
||||
visible?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function createCommandLabel(options: SetPathAuthoredStateCommandOptions): string {
|
||||
if (options.enabled !== undefined && options.visible === undefined) {
|
||||
return options.enabled ? "Enable path" : "Disable path";
|
||||
}
|
||||
|
||||
if (options.visible !== undefined && options.enabled === undefined) {
|
||||
return options.visible ? "Show path" : "Hide path";
|
||||
}
|
||||
|
||||
return "Update path state";
|
||||
}
|
||||
|
||||
export function createSetPathAuthoredStateCommand(
|
||||
options: SetPathAuthoredStateCommandOptions
|
||||
): EditorCommand {
|
||||
if (options.visible === undefined && options.enabled === undefined) {
|
||||
throw new Error("Path authored state command requires at least one change.");
|
||||
}
|
||||
|
||||
let previousVisible: boolean | null = null;
|
||||
let previousEnabled: boolean | null = null;
|
||||
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: createCommandLabel(options),
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const path = currentDocument.paths[options.pathId];
|
||||
|
||||
if (path === undefined) {
|
||||
throw new Error(`Path ${options.pathId} does not exist.`);
|
||||
}
|
||||
|
||||
if (previousVisible === null) {
|
||||
previousVisible = path.visible;
|
||||
}
|
||||
|
||||
if (previousEnabled === null) {
|
||||
previousEnabled = path.enabled;
|
||||
}
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
paths: {
|
||||
...currentDocument.paths,
|
||||
[path.id]: cloneScenePath({
|
||||
...path,
|
||||
visible: options.visible ?? path.visible,
|
||||
enabled: options.enabled ?? path.enabled
|
||||
})
|
||||
}
|
||||
});
|
||||
},
|
||||
undo(context) {
|
||||
if (previousVisible === null || previousEnabled === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDocument = context.getDocument();
|
||||
const path = currentDocument.paths[options.pathId];
|
||||
|
||||
if (path === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
paths: {
|
||||
...currentDocument.paths,
|
||||
[path.id]: cloneScenePath({
|
||||
...path,
|
||||
visible: previousVisible,
|
||||
enabled: previousEnabled
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
69
src/commands/set-path-name-command.ts
Normal file
69
src/commands/set-path-name-command.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import {
|
||||
cloneScenePath,
|
||||
normalizeScenePathName
|
||||
} from "../document/paths";
|
||||
|
||||
import type { EditorCommand } from "./command";
|
||||
|
||||
interface SetPathNameCommandOptions {
|
||||
pathId: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export function createSetPathNameCommand(
|
||||
options: SetPathNameCommandOptions
|
||||
): EditorCommand {
|
||||
const normalizedName = normalizeScenePathName(options.name);
|
||||
let previousName: string | undefined;
|
||||
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label:
|
||||
normalizedName === undefined
|
||||
? "Clear path name"
|
||||
: `Rename path to ${normalizedName}`,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const path = currentDocument.paths[options.pathId];
|
||||
|
||||
if (path === undefined) {
|
||||
throw new Error(`Path ${options.pathId} does not exist.`);
|
||||
}
|
||||
|
||||
if (previousName === undefined) {
|
||||
previousName = path.name;
|
||||
}
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
paths: {
|
||||
...currentDocument.paths,
|
||||
[path.id]: cloneScenePath({
|
||||
...path,
|
||||
name: normalizedName
|
||||
})
|
||||
}
|
||||
});
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const path = currentDocument.paths[options.pathId];
|
||||
|
||||
if (path === undefined) {
|
||||
throw new Error(`Path ${options.pathId} does not exist.`);
|
||||
}
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
paths: {
|
||||
...currentDocument.paths,
|
||||
[path.id]: cloneScenePath({
|
||||
...path,
|
||||
name: previousName
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
90
src/commands/upsert-path-command.ts
Normal file
90
src/commands/upsert-path-command.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneEditorSelection, type EditorSelection } from "../core/selection";
|
||||
import type { ToolMode } from "../core/tool-mode";
|
||||
import {
|
||||
cloneScenePath,
|
||||
type ScenePath
|
||||
} from "../document/paths";
|
||||
|
||||
import type { EditorCommand } from "./command";
|
||||
|
||||
interface UpsertPathCommandOptions {
|
||||
path: ScenePath;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function setSinglePathSelection(pathId: string): EditorSelection {
|
||||
return {
|
||||
kind: "paths",
|
||||
ids: [pathId]
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultPathCommandLabel(isNewPath: boolean): string {
|
||||
return isNewPath ? "Create path" : "Update path";
|
||||
}
|
||||
|
||||
export function createUpsertPathCommand(
|
||||
options: UpsertPathCommandOptions
|
||||
): EditorCommand {
|
||||
const nextPath = cloneScenePath(options.path);
|
||||
let previousPath: ScenePath | null = null;
|
||||
let previousSelection: EditorSelection | null = null;
|
||||
let previousToolMode: ToolMode | null = null;
|
||||
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? createDefaultPathCommandLabel(true),
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const currentPath = currentDocument.paths[nextPath.id];
|
||||
|
||||
if (previousSelection === null) {
|
||||
previousSelection = cloneEditorSelection(context.getSelection());
|
||||
}
|
||||
|
||||
if (previousToolMode === null) {
|
||||
previousToolMode = context.getToolMode();
|
||||
}
|
||||
|
||||
if (previousPath === null && currentPath !== undefined) {
|
||||
previousPath = cloneScenePath(currentPath);
|
||||
}
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
paths: {
|
||||
...currentDocument.paths,
|
||||
[nextPath.id]: cloneScenePath(nextPath)
|
||||
}
|
||||
});
|
||||
context.setSelection(setSinglePathSelection(nextPath.id));
|
||||
context.setToolMode("select");
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const nextPaths = {
|
||||
...currentDocument.paths
|
||||
};
|
||||
|
||||
if (previousPath === null) {
|
||||
delete nextPaths[nextPath.id];
|
||||
} else {
|
||||
nextPaths[nextPath.id] = cloneScenePath(previousPath);
|
||||
}
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
paths: nextPaths
|
||||
});
|
||||
|
||||
if (previousSelection !== null) {
|
||||
context.setSelection(previousSelection);
|
||||
}
|
||||
|
||||
if (previousToolMode !== null) {
|
||||
context.setToolMode(previousToolMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type EditorSelection =
|
||||
| { kind: "brushFace"; brushId: string; faceId: BoxFaceId }
|
||||
| { kind: "brushEdge"; brushId: string; edgeId: BoxEdgeId }
|
||||
| { kind: "brushVertex"; brushId: string; vertexId: BoxVertexId }
|
||||
| { kind: "paths"; ids: string[] }
|
||||
| { kind: "entities"; ids: string[] }
|
||||
| { kind: "modelInstances"; ids: string[] };
|
||||
|
||||
@@ -62,6 +63,7 @@ export function areEditorSelectionsEqual(left: EditorSelection, right: EditorSel
|
||||
case "brushVertex":
|
||||
return right.kind === "brushVertex" && left.brushId === right.brushId && left.vertexId === right.vertexId;
|
||||
case "brushes":
|
||||
case "paths":
|
||||
case "entities":
|
||||
case "modelInstances":
|
||||
return right.kind === left.kind && left.ids.length === right.ids.length && left.ids.every((id, index) => id === right.ids[index]);
|
||||
@@ -112,6 +114,14 @@ export function getSingleSelectedEntityId(selection: EditorSelection): string |
|
||||
return selection.ids[0];
|
||||
}
|
||||
|
||||
export function getSingleSelectedPathId(selection: EditorSelection): string | null {
|
||||
if (selection.kind !== "paths" || selection.ids.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return selection.ids[0];
|
||||
}
|
||||
|
||||
export function getSingleSelectedModelInstanceId(selection: EditorSelection): string | null {
|
||||
if (selection.kind !== "modelInstances" || selection.ids.length !== 1) {
|
||||
return null;
|
||||
@@ -144,6 +154,10 @@ export function isModelInstanceSelected(selection: EditorSelection, modelInstanc
|
||||
return selection.kind === "modelInstances" && selection.ids.includes(modelInstanceId);
|
||||
}
|
||||
|
||||
export function isPathSelected(selection: EditorSelection, pathId: string): boolean {
|
||||
return selection.kind === "paths" && selection.ids.includes(pathId);
|
||||
}
|
||||
|
||||
export function normalizeSelectionForWhiteboxSelectionMode(selection: EditorSelection, mode: WhiteboxSelectionMode): EditorSelection {
|
||||
switch (selection.kind) {
|
||||
case "brushFace":
|
||||
|
||||
@@ -1042,6 +1042,12 @@ export function resolveTransformTarget(
|
||||
}
|
||||
|
||||
return createEntityTransformTarget(document, selection.ids[0]);
|
||||
case "paths":
|
||||
return {
|
||||
target: null,
|
||||
message:
|
||||
"Path transforms are not available in this slice. Edit path points in the Inspector."
|
||||
};
|
||||
case "modelInstances":
|
||||
if (selection.ids.length !== 1) {
|
||||
return {
|
||||
|
||||
@@ -48,6 +48,10 @@ import {
|
||||
HOURS_PER_DAY,
|
||||
type ProjectTimeSettings
|
||||
} from "./project-time-settings";
|
||||
import {
|
||||
MIN_SCENE_PATH_POINT_COUNT,
|
||||
type ScenePath
|
||||
} from "./paths";
|
||||
import {
|
||||
isAdvancedRenderingWaterReflectionMode,
|
||||
isAdvancedRenderingShadowMapSize,
|
||||
@@ -1513,6 +1517,102 @@ function validateEntityName(
|
||||
}
|
||||
}
|
||||
|
||||
function validateScenePath(pathValue: ScenePath, path: string, diagnostics: SceneDiagnostic[]) {
|
||||
if (!isBoolean(pathValue.visible)) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-path-visible",
|
||||
"Path visible must remain a boolean.",
|
||||
`${path}.visible`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!isBoolean(pathValue.enabled)) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-path-enabled",
|
||||
"Path enabled must remain a boolean.",
|
||||
`${path}.enabled`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (pathValue.name !== undefined && pathValue.name.trim().length === 0) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-path-name",
|
||||
"Path names must be non-empty when authored.",
|
||||
`${path}.name`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!isBoolean(pathValue.loop)) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-path-loop",
|
||||
"Path loop must remain a boolean.",
|
||||
`${path}.loop`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (pathValue.points.length < MIN_SCENE_PATH_POINT_COUNT) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-path-point-count",
|
||||
`Paths must define at least ${MIN_SCENE_PATH_POINT_COUNT} points.`,
|
||||
`${path}.points`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const seenPointIds = new Set<string>();
|
||||
|
||||
for (const [pointIndex, point] of pathValue.points.entries()) {
|
||||
const pointPath = `${path}.points.${pointIndex}`;
|
||||
|
||||
if (point.id.trim().length === 0) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-path-point-id",
|
||||
"Path point ids must be non-empty strings.",
|
||||
`${pointPath}.id`
|
||||
)
|
||||
);
|
||||
} else if (seenPointIds.has(point.id)) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"duplicate-path-point-id",
|
||||
`Path point id ${point.id} is already used within this path.`,
|
||||
`${pointPath}.id`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
seenPointIds.add(point.id);
|
||||
}
|
||||
|
||||
if (!isFiniteVec3(point.position)) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-path-point-position",
|
||||
"Path point positions must remain finite on every axis.",
|
||||
`${pointPath}.position`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateAuthoredEntityState(
|
||||
entity: EntityInstance,
|
||||
path: string,
|
||||
@@ -3454,6 +3554,26 @@ export function validateSceneDocument(
|
||||
}
|
||||
}
|
||||
|
||||
for (const [modelInstanceKey, modelInstance] of Object.entries(
|
||||
document.paths
|
||||
)) {
|
||||
const path = `paths.${modelInstanceKey}`;
|
||||
|
||||
if (modelInstance.id !== modelInstanceKey) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"path-id-mismatch",
|
||||
"Path ids must match their registry key.",
|
||||
`${path}.id`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
registerAuthoredId(modelInstance.id, path, seenIds, diagnostics);
|
||||
validateScenePath(modelInstance, path, diagnostics);
|
||||
}
|
||||
|
||||
for (const [modelInstanceKey, modelInstance] of Object.entries(
|
||||
document.modelInstances
|
||||
)) {
|
||||
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
cloneProjectTimeSettings,
|
||||
type ProjectTimeSettings
|
||||
} from "../document/project-time-settings";
|
||||
import {
|
||||
getScenePaths,
|
||||
resolveScenePath,
|
||||
type ScenePath,
|
||||
type ScenePathPoint
|
||||
} from "../document/paths";
|
||||
import { cloneWorldSettings, type WorldSettings } from "../document/world-settings";
|
||||
import {
|
||||
type CharacterColliderSettings,
|
||||
@@ -240,6 +246,34 @@ export interface RuntimeModelInstance {
|
||||
animationAutoplay?: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimePathPoint {
|
||||
pointId: string;
|
||||
position: Vec3;
|
||||
}
|
||||
|
||||
export interface RuntimePathSegment {
|
||||
index: number;
|
||||
startPointId: string;
|
||||
endPointId: string;
|
||||
start: Vec3;
|
||||
end: Vec3;
|
||||
length: number;
|
||||
distanceStart: number;
|
||||
distanceEnd: number;
|
||||
tangent: Vec3;
|
||||
}
|
||||
|
||||
export interface RuntimePath {
|
||||
id: string;
|
||||
name?: string;
|
||||
visible: boolean;
|
||||
enabled: boolean;
|
||||
loop: boolean;
|
||||
points: RuntimePathPoint[];
|
||||
segments: RuntimePathSegment[];
|
||||
totalLength: number;
|
||||
}
|
||||
|
||||
export interface RuntimeEntityCollection {
|
||||
playerStarts: RuntimePlayerStart[];
|
||||
sceneEntries: RuntimeSceneEntry[];
|
||||
@@ -267,6 +301,7 @@ export interface RuntimeSceneDefinition {
|
||||
colliders: RuntimeSceneCollider[];
|
||||
sceneBounds: RuntimeSceneBounds | null;
|
||||
modelInstances: RuntimeModelInstance[];
|
||||
paths: RuntimePath[];
|
||||
entities: RuntimeEntityCollection;
|
||||
interactionLinks: InteractionLink[];
|
||||
playerStart: RuntimePlayerStart | null;
|
||||
@@ -528,6 +563,38 @@ function buildRuntimeModelInstance(modelInstance: SceneDocument["modelInstances"
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimePathPoint(point: ScenePathPoint): RuntimePathPoint {
|
||||
return {
|
||||
pointId: point.id,
|
||||
position: cloneVec3(point.position)
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimePath(path: ScenePath): RuntimePath {
|
||||
const resolvedPath = resolveScenePath(path);
|
||||
|
||||
return {
|
||||
id: path.id,
|
||||
name: path.name,
|
||||
visible: path.visible,
|
||||
enabled: path.enabled,
|
||||
loop: path.loop,
|
||||
points: resolvedPath.points.map(buildRuntimePathPoint),
|
||||
segments: resolvedPath.segments.map((segment) => ({
|
||||
index: segment.index,
|
||||
startPointId: segment.startPointId,
|
||||
endPointId: segment.endPointId,
|
||||
start: cloneVec3(segment.start),
|
||||
end: cloneVec3(segment.end),
|
||||
length: segment.length,
|
||||
distanceStart: segment.distanceStart,
|
||||
distanceEnd: segment.distanceEnd,
|
||||
tangent: cloneVec3(segment.tangent)
|
||||
})),
|
||||
totalLength: resolvedPath.totalLength
|
||||
};
|
||||
}
|
||||
|
||||
function getColliderBounds(collider: RuntimeSceneCollider): GeneratedColliderBounds {
|
||||
return {
|
||||
min: cloneVec3(collider.worldBounds.min),
|
||||
@@ -901,6 +968,9 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options:
|
||||
}
|
||||
const enabledModelInstances = getModelInstances(document.modelInstances).filter((modelInstance) => modelInstance.enabled);
|
||||
const modelInstances = enabledModelInstances.map(buildRuntimeModelInstance);
|
||||
const paths = getScenePaths(document.paths)
|
||||
.filter((path) => path.enabled)
|
||||
.map(buildRuntimePath);
|
||||
const collections = buildRuntimeSceneCollections(document);
|
||||
const enabledBrushIds = new Set(enabledBrushes.map((brush) => brush.id));
|
||||
const enabledModelInstanceIds = new Set(enabledModelInstances.map((modelInstance) => modelInstance.id));
|
||||
@@ -982,6 +1052,7 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options:
|
||||
colliders,
|
||||
sceneBounds: combinedSceneBounds,
|
||||
modelInstances,
|
||||
paths,
|
||||
entities: collections.entities,
|
||||
interactionLinks,
|
||||
playerStart,
|
||||
|
||||
@@ -2,11 +2,13 @@ import {
|
||||
getSingleSelectedBrushId,
|
||||
getSingleSelectedEntityId,
|
||||
getSingleSelectedModelInstanceId,
|
||||
getSingleSelectedPathId,
|
||||
type EditorSelection
|
||||
} from "../core/selection";
|
||||
import type { Vec3 } from "../core/vector";
|
||||
import type { BoxBrush } from "../document/brushes";
|
||||
import type { SceneDocument } from "../document/scene-document";
|
||||
import type { ScenePath } from "../document/paths";
|
||||
import type { EntityInstance } from "../entities/entity-instances";
|
||||
import type { ModelInstance } from "../assets/model-instances";
|
||||
import type { ProjectAssetRecord } from "../assets/project-assets";
|
||||
@@ -232,6 +234,18 @@ function createModelInstanceFocusTarget(modelInstance: ModelInstance, asset: Pro
|
||||
);
|
||||
}
|
||||
|
||||
function includePath(bounds: FocusBoundsAccumulator, path: ScenePath) {
|
||||
for (const point of path.points) {
|
||||
includeBounds(bounds, point.position, point.position);
|
||||
}
|
||||
}
|
||||
|
||||
function createPathFocusTarget(path: ScenePath): ViewportFocusTarget | null {
|
||||
const bounds = createEmptyBoundsAccumulator();
|
||||
includePath(bounds, path);
|
||||
return finishBounds(bounds);
|
||||
}
|
||||
|
||||
function includeSphereEntity(bounds: FocusBoundsAccumulator, position: Vec3, radius: number) {
|
||||
includeBounds(
|
||||
bounds,
|
||||
@@ -361,6 +375,10 @@ function getSceneFocusTarget(document: SceneDocument): ViewportFocusTarget | nul
|
||||
includeModelInstance(bounds, modelInstance, document.assets[modelInstance.assetId]);
|
||||
}
|
||||
|
||||
for (const path of Object.values(document.paths)) {
|
||||
includePath(bounds, path);
|
||||
}
|
||||
|
||||
for (const entity of Object.values(document.entities)) {
|
||||
includeEntity(bounds, entity);
|
||||
}
|
||||
@@ -399,5 +417,15 @@ export function resolveViewportFocusTarget(document: SceneDocument, selection: E
|
||||
}
|
||||
}
|
||||
|
||||
const selectedPathId = getSingleSelectedPathId(selection);
|
||||
|
||||
if (selectedPathId !== null) {
|
||||
const path = document.paths[selectedPathId];
|
||||
|
||||
if (path !== undefined) {
|
||||
return createPathFocusTarget(path);
|
||||
}
|
||||
}
|
||||
|
||||
return getSceneFocusTarget(document);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
isBrushSelected,
|
||||
isBrushVertexSelected,
|
||||
isModelInstanceSelected,
|
||||
isPathSelected,
|
||||
type EditorSelection
|
||||
} from "../core/selection";
|
||||
import { getWhiteboxSelectionFeedbackLabel } from "../core/whitebox-selection-feedback";
|
||||
@@ -83,6 +84,10 @@ import {
|
||||
type ModelInstance
|
||||
} from "../assets/model-instances";
|
||||
import type { SceneDocument } from "../document/scene-document";
|
||||
import {
|
||||
getScenePaths,
|
||||
type ScenePath
|
||||
} from "../document/paths";
|
||||
import {
|
||||
areAdvancedRenderingSettingsEqual,
|
||||
cloneAdvancedRenderingSettings,
|
||||
@@ -212,6 +217,14 @@ interface BrushRenderObjects {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PathRenderObjects {
|
||||
line: Line<BufferGeometry, LineBasicMaterial>;
|
||||
pointMeshes: Array<{
|
||||
pointId: string;
|
||||
mesh: Mesh<SphereGeometry, MeshBasicMaterial>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ViewportWaterSurfaceBinding {
|
||||
brush: BoxBrush;
|
||||
reflectionTextureUniform: { value: unknown } | null;
|
||||
@@ -253,6 +266,13 @@ const INTERACTABLE_COLOR = 0x92de7e;
|
||||
const INTERACTABLE_SELECTED_COLOR = 0xf1cf7e;
|
||||
const SCENE_EXIT_COLOR = 0xff9c6d;
|
||||
const SCENE_EXIT_SELECTED_COLOR = 0xf5dd88;
|
||||
const PATH_COLOR = 0x4b82d6;
|
||||
const PATH_HOVERED_COLOR = 0x86b6ff;
|
||||
const PATH_SELECTED_COLOR = 0xf3be8f;
|
||||
const PATH_POINT_COLOR = 0xb7cbec;
|
||||
const PATH_POINT_HOVERED_COLOR = 0xf3be8f;
|
||||
const PATH_POINT_SELECTED_COLOR = 0xcf7b42;
|
||||
const PATH_POINT_RADIUS = 0.12;
|
||||
const BOX_CREATE_PREVIEW_FILL = 0x89b6ff;
|
||||
const BOX_CREATE_PREVIEW_EDGE = 0xf3be8f;
|
||||
const PLACEMENT_PREVIEW_COLOR_HEX = "#89b6ff";
|
||||
@@ -355,6 +375,7 @@ export class ViewportHost {
|
||||
private readonly sunLight = new DirectionalLight();
|
||||
private readonly localLightGroup = new Group();
|
||||
private readonly brushGroup = new Group();
|
||||
private readonly pathGroup = new Group();
|
||||
private readonly entityGroup = new Group();
|
||||
private readonly modelGroup = new Group();
|
||||
private readonly waterReflectionCamera = new PerspectiveCamera();
|
||||
@@ -366,6 +387,7 @@ export class ViewportHost {
|
||||
private readonly transformIntersection = new Vector3();
|
||||
private readonly transformGizmoGroup = new Group();
|
||||
private readonly brushRenderObjects = new Map<string, BrushRenderObjects>();
|
||||
private readonly pathRenderObjects = new Map<string, PathRenderObjects>();
|
||||
private readonly entityRenderObjects = new Map<string, EntityRenderObjects>();
|
||||
private readonly localLightRenderObjects = new Map<
|
||||
string,
|
||||
@@ -517,6 +539,7 @@ export class ViewportHost {
|
||||
this.scene.add(this.sunLight);
|
||||
this.scene.add(this.localLightGroup);
|
||||
this.scene.add(this.brushGroup);
|
||||
this.scene.add(this.pathGroup);
|
||||
this.scene.add(this.entityGroup);
|
||||
this.scene.add(this.modelGroup);
|
||||
this.transformGizmoGroup.visible = false;
|
||||
@@ -613,6 +636,7 @@ export class ViewportHost {
|
||||
});
|
||||
this.rebuildLocalLights(document);
|
||||
this.rebuildBrushMeshes(document, selection);
|
||||
this.rebuildPaths(document, selection);
|
||||
this.rebuildEntityMarkers(document, selection);
|
||||
this.rebuildModelInstances(document, selection);
|
||||
this.applyTransformPreview();
|
||||
@@ -3412,6 +3436,74 @@ export class ViewportHost {
|
||||
this.applyShadowState();
|
||||
}
|
||||
|
||||
private createPathLineGeometry(path: ScenePath): BufferGeometry {
|
||||
const points = path.points.map(
|
||||
(point) =>
|
||||
new Vector3(point.position.x, point.position.y, point.position.z)
|
||||
);
|
||||
|
||||
if (path.loop && points.length > 1) {
|
||||
points.push(
|
||||
new Vector3(
|
||||
path.points[0].position.x,
|
||||
path.points[0].position.y,
|
||||
path.points[0].position.z
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return new BufferGeometry().setFromPoints(points);
|
||||
}
|
||||
|
||||
private rebuildPaths(document: SceneDocument, selection: EditorSelection) {
|
||||
this.clearPaths();
|
||||
|
||||
for (const path of getScenePaths(document.paths)) {
|
||||
if (!path.enabled || !path.visible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const line = new Line(
|
||||
this.createPathLineGeometry(path),
|
||||
new LineBasicMaterial({
|
||||
color: isPathSelected(selection, path.id)
|
||||
? PATH_SELECTED_COLOR
|
||||
: PATH_COLOR
|
||||
})
|
||||
);
|
||||
line.userData.pathId = path.id;
|
||||
|
||||
const pointMeshes = path.points.map((point) => {
|
||||
const mesh = new Mesh(
|
||||
new SphereGeometry(PATH_POINT_RADIUS, 12, 12),
|
||||
new MeshBasicMaterial({
|
||||
color: isPathSelected(selection, path.id)
|
||||
? PATH_POINT_SELECTED_COLOR
|
||||
: PATH_POINT_COLOR
|
||||
})
|
||||
);
|
||||
|
||||
mesh.position.set(point.position.x, point.position.y, point.position.z);
|
||||
mesh.userData.pathId = path.id;
|
||||
mesh.userData.pathPointId = point.id;
|
||||
this.pathGroup.add(mesh);
|
||||
|
||||
return {
|
||||
pointId: point.id,
|
||||
mesh
|
||||
};
|
||||
});
|
||||
|
||||
this.pathGroup.add(line);
|
||||
this.pathRenderObjects.set(path.id, {
|
||||
line,
|
||||
pointMeshes
|
||||
});
|
||||
}
|
||||
|
||||
this.refreshPathPresentation();
|
||||
}
|
||||
|
||||
private configureFogVolumeMesh(
|
||||
mesh: Mesh<BufferGeometry, Material[]>,
|
||||
materials: Material[]
|
||||
@@ -5100,6 +5192,39 @@ export class ViewportHost {
|
||||
}
|
||||
}
|
||||
|
||||
private refreshPathPresentation() {
|
||||
if (this.currentDocument === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const path of Object.values(this.currentDocument.paths)) {
|
||||
const renderObjects = this.pathRenderObjects.get(path.id);
|
||||
|
||||
if (renderObjects === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const selected = isPathSelected(this.currentSelection, path.id);
|
||||
const hovered =
|
||||
this.hoveredSelection.kind === "paths" &&
|
||||
this.hoveredSelection.ids.includes(path.id);
|
||||
|
||||
renderObjects.line.material.color.setHex(
|
||||
selected ? PATH_SELECTED_COLOR : hovered ? PATH_HOVERED_COLOR : PATH_COLOR
|
||||
);
|
||||
|
||||
for (const pointMesh of renderObjects.pointMeshes) {
|
||||
pointMesh.mesh.material.color.setHex(
|
||||
selected
|
||||
? PATH_POINT_SELECTED_COLOR
|
||||
: hovered
|
||||
? PATH_POINT_HOVERED_COLOR
|
||||
: PATH_POINT_COLOR
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private disposeUniqueMaterials(materials: Material[]) {
|
||||
for (const material of new Set(materials)) {
|
||||
material.dispose();
|
||||
@@ -5140,6 +5265,22 @@ export class ViewportHost {
|
||||
this.resetViewportWaterSurfaceBindings(false);
|
||||
}
|
||||
|
||||
private clearPaths() {
|
||||
for (const renderObjects of this.pathRenderObjects.values()) {
|
||||
this.pathGroup.remove(renderObjects.line);
|
||||
renderObjects.line.geometry.dispose();
|
||||
renderObjects.line.material.dispose();
|
||||
|
||||
for (const pointMesh of renderObjects.pointMeshes) {
|
||||
this.pathGroup.remove(pointMesh.mesh);
|
||||
pointMesh.mesh.geometry.dispose();
|
||||
pointMesh.mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
this.pathRenderObjects.clear();
|
||||
}
|
||||
|
||||
private clearEntityMarkers() {
|
||||
for (const renderObjects of this.entityRenderObjects.values()) {
|
||||
this.entityGroup.remove(renderObjects.group);
|
||||
|
||||
Reference in New Issue
Block a user