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 { cloneEditorSelection, type EditorSelection } from "../core/selection";
|
||||||
import type { ToolMode } from "../core/tool-mode";
|
import type { ToolMode } from "../core/tool-mode";
|
||||||
import { cloneBoxBrush, type BoxBrush } from "../document/brushes";
|
import { cloneBoxBrush, type BoxBrush } from "../document/brushes";
|
||||||
|
import { cloneScenePath, type ScenePath } from "../document/paths";
|
||||||
import type { SceneDocument } from "../document/scene-document";
|
import type { SceneDocument } from "../document/scene-document";
|
||||||
import {
|
import {
|
||||||
cloneEntityInstance,
|
cloneEntityInstance,
|
||||||
@@ -15,6 +16,7 @@ import type { EditorCommand } from "./command";
|
|||||||
interface DuplicateSelectionResult {
|
interface DuplicateSelectionResult {
|
||||||
selection: EditorSelection;
|
selection: EditorSelection;
|
||||||
brushes: BoxBrush[] | null;
|
brushes: BoxBrush[] | null;
|
||||||
|
paths: ScenePath[] | null;
|
||||||
entities: EntityInstance[] | null;
|
entities: EntityInstance[] | null;
|
||||||
modelInstances: ModelInstance[] | null;
|
modelInstances: ModelInstance[] | null;
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,16 @@ function duplicateBrush(brush: BoxBrush): BoxBrush {
|
|||||||
return duplicatedBrush;
|
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 {
|
function duplicateEntity(entity: EntityInstance): EntityInstance {
|
||||||
const duplicatedEntity = cloneEntityInstance(entity);
|
const duplicatedEntity = cloneEntityInstance(entity);
|
||||||
duplicatedEntity.id = createOpaqueId(`entity-${duplicatedEntity.kind}`);
|
duplicatedEntity.id = createOpaqueId(`entity-${duplicatedEntity.kind}`);
|
||||||
@@ -83,6 +95,34 @@ function createDuplicateSelectionResult(currentDocument: SceneDocument, selectio
|
|||||||
ids: duplicatedBrushes.map((brush) => brush.id)
|
ids: duplicatedBrushes.map((brush) => brush.id)
|
||||||
},
|
},
|
||||||
brushes: duplicatedBrushes,
|
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,
|
entities: null,
|
||||||
modelInstances: null
|
modelInstances: null
|
||||||
};
|
};
|
||||||
@@ -109,6 +149,7 @@ function createDuplicateSelectionResult(currentDocument: SceneDocument, selectio
|
|||||||
ids: duplicatedEntities.map((entity) => entity.id)
|
ids: duplicatedEntities.map((entity) => entity.id)
|
||||||
},
|
},
|
||||||
brushes: null,
|
brushes: null,
|
||||||
|
paths: null,
|
||||||
entities: duplicatedEntities,
|
entities: duplicatedEntities,
|
||||||
modelInstances: null
|
modelInstances: null
|
||||||
};
|
};
|
||||||
@@ -135,12 +176,13 @@ function createDuplicateSelectionResult(currentDocument: SceneDocument, selectio
|
|||||||
ids: duplicatedModelInstances.map((modelInstance) => modelInstance.id)
|
ids: duplicatedModelInstances.map((modelInstance) => modelInstance.id)
|
||||||
},
|
},
|
||||||
brushes: null,
|
brushes: null,
|
||||||
|
paths: null,
|
||||||
entities: null,
|
entities: null,
|
||||||
modelInstances: duplicatedModelInstances
|
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 {
|
export function createDuplicateSelectionCommand(): EditorCommand {
|
||||||
@@ -174,6 +216,16 @@ export function createDuplicateSelectionCommand(): EditorCommand {
|
|||||||
...Object.fromEntries(duplicateSelectionResult.brushes.map((brush) => [brush.id, cloneBoxBrush(brush)]))
|
...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) {
|
} else if (duplicateSelectionResult.entities !== null) {
|
||||||
context.setDocument({
|
context.setDocument({
|
||||||
...currentDocument,
|
...currentDocument,
|
||||||
@@ -217,6 +269,19 @@ export function createDuplicateSelectionCommand(): EditorCommand {
|
|||||||
...currentDocument,
|
...currentDocument,
|
||||||
brushes: nextBrushes
|
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) {
|
} else if (duplicateSelectionResult.entities !== null) {
|
||||||
const nextEntities = {
|
const nextEntities = {
|
||||||
...currentDocument.entities
|
...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: "brushFace"; brushId: string; faceId: BoxFaceId }
|
||||||
| { kind: "brushEdge"; brushId: string; edgeId: BoxEdgeId }
|
| { kind: "brushEdge"; brushId: string; edgeId: BoxEdgeId }
|
||||||
| { kind: "brushVertex"; brushId: string; vertexId: BoxVertexId }
|
| { kind: "brushVertex"; brushId: string; vertexId: BoxVertexId }
|
||||||
|
| { kind: "paths"; ids: string[] }
|
||||||
| { kind: "entities"; ids: string[] }
|
| { kind: "entities"; ids: string[] }
|
||||||
| { kind: "modelInstances"; ids: string[] };
|
| { kind: "modelInstances"; ids: string[] };
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ export function areEditorSelectionsEqual(left: EditorSelection, right: EditorSel
|
|||||||
case "brushVertex":
|
case "brushVertex":
|
||||||
return right.kind === "brushVertex" && left.brushId === right.brushId && left.vertexId === right.vertexId;
|
return right.kind === "brushVertex" && left.brushId === right.brushId && left.vertexId === right.vertexId;
|
||||||
case "brushes":
|
case "brushes":
|
||||||
|
case "paths":
|
||||||
case "entities":
|
case "entities":
|
||||||
case "modelInstances":
|
case "modelInstances":
|
||||||
return right.kind === left.kind && left.ids.length === right.ids.length && left.ids.every((id, index) => id === right.ids[index]);
|
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];
|
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 {
|
export function getSingleSelectedModelInstanceId(selection: EditorSelection): string | null {
|
||||||
if (selection.kind !== "modelInstances" || selection.ids.length !== 1) {
|
if (selection.kind !== "modelInstances" || selection.ids.length !== 1) {
|
||||||
return null;
|
return null;
|
||||||
@@ -144,6 +154,10 @@ export function isModelInstanceSelected(selection: EditorSelection, modelInstanc
|
|||||||
return selection.kind === "modelInstances" && selection.ids.includes(modelInstanceId);
|
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 {
|
export function normalizeSelectionForWhiteboxSelectionMode(selection: EditorSelection, mode: WhiteboxSelectionMode): EditorSelection {
|
||||||
switch (selection.kind) {
|
switch (selection.kind) {
|
||||||
case "brushFace":
|
case "brushFace":
|
||||||
|
|||||||
@@ -1042,6 +1042,12 @@ export function resolveTransformTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return createEntityTransformTarget(document, selection.ids[0]);
|
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":
|
case "modelInstances":
|
||||||
if (selection.ids.length !== 1) {
|
if (selection.ids.length !== 1) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ import {
|
|||||||
HOURS_PER_DAY,
|
HOURS_PER_DAY,
|
||||||
type ProjectTimeSettings
|
type ProjectTimeSettings
|
||||||
} from "./project-time-settings";
|
} from "./project-time-settings";
|
||||||
|
import {
|
||||||
|
MIN_SCENE_PATH_POINT_COUNT,
|
||||||
|
type ScenePath
|
||||||
|
} from "./paths";
|
||||||
import {
|
import {
|
||||||
isAdvancedRenderingWaterReflectionMode,
|
isAdvancedRenderingWaterReflectionMode,
|
||||||
isAdvancedRenderingShadowMapSize,
|
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(
|
function validateAuthoredEntityState(
|
||||||
entity: EntityInstance,
|
entity: EntityInstance,
|
||||||
path: string,
|
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(
|
for (const [modelInstanceKey, modelInstance] of Object.entries(
|
||||||
document.modelInstances
|
document.modelInstances
|
||||||
)) {
|
)) {
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ import {
|
|||||||
cloneProjectTimeSettings,
|
cloneProjectTimeSettings,
|
||||||
type ProjectTimeSettings
|
type ProjectTimeSettings
|
||||||
} from "../document/project-time-settings";
|
} from "../document/project-time-settings";
|
||||||
|
import {
|
||||||
|
getScenePaths,
|
||||||
|
resolveScenePath,
|
||||||
|
type ScenePath,
|
||||||
|
type ScenePathPoint
|
||||||
|
} from "../document/paths";
|
||||||
import { cloneWorldSettings, type WorldSettings } from "../document/world-settings";
|
import { cloneWorldSettings, type WorldSettings } from "../document/world-settings";
|
||||||
import {
|
import {
|
||||||
type CharacterColliderSettings,
|
type CharacterColliderSettings,
|
||||||
@@ -240,6 +246,34 @@ export interface RuntimeModelInstance {
|
|||||||
animationAutoplay?: boolean;
|
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 {
|
export interface RuntimeEntityCollection {
|
||||||
playerStarts: RuntimePlayerStart[];
|
playerStarts: RuntimePlayerStart[];
|
||||||
sceneEntries: RuntimeSceneEntry[];
|
sceneEntries: RuntimeSceneEntry[];
|
||||||
@@ -267,6 +301,7 @@ export interface RuntimeSceneDefinition {
|
|||||||
colliders: RuntimeSceneCollider[];
|
colliders: RuntimeSceneCollider[];
|
||||||
sceneBounds: RuntimeSceneBounds | null;
|
sceneBounds: RuntimeSceneBounds | null;
|
||||||
modelInstances: RuntimeModelInstance[];
|
modelInstances: RuntimeModelInstance[];
|
||||||
|
paths: RuntimePath[];
|
||||||
entities: RuntimeEntityCollection;
|
entities: RuntimeEntityCollection;
|
||||||
interactionLinks: InteractionLink[];
|
interactionLinks: InteractionLink[];
|
||||||
playerStart: RuntimePlayerStart | null;
|
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 {
|
function getColliderBounds(collider: RuntimeSceneCollider): GeneratedColliderBounds {
|
||||||
return {
|
return {
|
||||||
min: cloneVec3(collider.worldBounds.min),
|
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 enabledModelInstances = getModelInstances(document.modelInstances).filter((modelInstance) => modelInstance.enabled);
|
||||||
const modelInstances = enabledModelInstances.map(buildRuntimeModelInstance);
|
const modelInstances = enabledModelInstances.map(buildRuntimeModelInstance);
|
||||||
|
const paths = getScenePaths(document.paths)
|
||||||
|
.filter((path) => path.enabled)
|
||||||
|
.map(buildRuntimePath);
|
||||||
const collections = buildRuntimeSceneCollections(document);
|
const collections = buildRuntimeSceneCollections(document);
|
||||||
const enabledBrushIds = new Set(enabledBrushes.map((brush) => brush.id));
|
const enabledBrushIds = new Set(enabledBrushes.map((brush) => brush.id));
|
||||||
const enabledModelInstanceIds = new Set(enabledModelInstances.map((modelInstance) => modelInstance.id));
|
const enabledModelInstanceIds = new Set(enabledModelInstances.map((modelInstance) => modelInstance.id));
|
||||||
@@ -982,6 +1052,7 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options:
|
|||||||
colliders,
|
colliders,
|
||||||
sceneBounds: combinedSceneBounds,
|
sceneBounds: combinedSceneBounds,
|
||||||
modelInstances,
|
modelInstances,
|
||||||
|
paths,
|
||||||
entities: collections.entities,
|
entities: collections.entities,
|
||||||
interactionLinks,
|
interactionLinks,
|
||||||
playerStart,
|
playerStart,
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import {
|
|||||||
getSingleSelectedBrushId,
|
getSingleSelectedBrushId,
|
||||||
getSingleSelectedEntityId,
|
getSingleSelectedEntityId,
|
||||||
getSingleSelectedModelInstanceId,
|
getSingleSelectedModelInstanceId,
|
||||||
|
getSingleSelectedPathId,
|
||||||
type EditorSelection
|
type EditorSelection
|
||||||
} from "../core/selection";
|
} from "../core/selection";
|
||||||
import type { Vec3 } from "../core/vector";
|
import type { Vec3 } from "../core/vector";
|
||||||
import type { BoxBrush } from "../document/brushes";
|
import type { BoxBrush } from "../document/brushes";
|
||||||
import type { SceneDocument } from "../document/scene-document";
|
import type { SceneDocument } from "../document/scene-document";
|
||||||
|
import type { ScenePath } from "../document/paths";
|
||||||
import type { EntityInstance } from "../entities/entity-instances";
|
import type { EntityInstance } from "../entities/entity-instances";
|
||||||
import type { ModelInstance } from "../assets/model-instances";
|
import type { ModelInstance } from "../assets/model-instances";
|
||||||
import type { ProjectAssetRecord } from "../assets/project-assets";
|
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) {
|
function includeSphereEntity(bounds: FocusBoundsAccumulator, position: Vec3, radius: number) {
|
||||||
includeBounds(
|
includeBounds(
|
||||||
bounds,
|
bounds,
|
||||||
@@ -361,6 +375,10 @@ function getSceneFocusTarget(document: SceneDocument): ViewportFocusTarget | nul
|
|||||||
includeModelInstance(bounds, modelInstance, document.assets[modelInstance.assetId]);
|
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)) {
|
for (const entity of Object.values(document.entities)) {
|
||||||
includeEntity(bounds, entity);
|
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);
|
return getSceneFocusTarget(document);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
isBrushSelected,
|
isBrushSelected,
|
||||||
isBrushVertexSelected,
|
isBrushVertexSelected,
|
||||||
isModelInstanceSelected,
|
isModelInstanceSelected,
|
||||||
|
isPathSelected,
|
||||||
type EditorSelection
|
type EditorSelection
|
||||||
} from "../core/selection";
|
} from "../core/selection";
|
||||||
import { getWhiteboxSelectionFeedbackLabel } from "../core/whitebox-selection-feedback";
|
import { getWhiteboxSelectionFeedbackLabel } from "../core/whitebox-selection-feedback";
|
||||||
@@ -83,6 +84,10 @@ import {
|
|||||||
type ModelInstance
|
type ModelInstance
|
||||||
} from "../assets/model-instances";
|
} from "../assets/model-instances";
|
||||||
import type { SceneDocument } from "../document/scene-document";
|
import type { SceneDocument } from "../document/scene-document";
|
||||||
|
import {
|
||||||
|
getScenePaths,
|
||||||
|
type ScenePath
|
||||||
|
} from "../document/paths";
|
||||||
import {
|
import {
|
||||||
areAdvancedRenderingSettingsEqual,
|
areAdvancedRenderingSettingsEqual,
|
||||||
cloneAdvancedRenderingSettings,
|
cloneAdvancedRenderingSettings,
|
||||||
@@ -212,6 +217,14 @@ interface BrushRenderObjects {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PathRenderObjects {
|
||||||
|
line: Line<BufferGeometry, LineBasicMaterial>;
|
||||||
|
pointMeshes: Array<{
|
||||||
|
pointId: string;
|
||||||
|
mesh: Mesh<SphereGeometry, MeshBasicMaterial>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
interface ViewportWaterSurfaceBinding {
|
interface ViewportWaterSurfaceBinding {
|
||||||
brush: BoxBrush;
|
brush: BoxBrush;
|
||||||
reflectionTextureUniform: { value: unknown } | null;
|
reflectionTextureUniform: { value: unknown } | null;
|
||||||
@@ -253,6 +266,13 @@ const INTERACTABLE_COLOR = 0x92de7e;
|
|||||||
const INTERACTABLE_SELECTED_COLOR = 0xf1cf7e;
|
const INTERACTABLE_SELECTED_COLOR = 0xf1cf7e;
|
||||||
const SCENE_EXIT_COLOR = 0xff9c6d;
|
const SCENE_EXIT_COLOR = 0xff9c6d;
|
||||||
const SCENE_EXIT_SELECTED_COLOR = 0xf5dd88;
|
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_FILL = 0x89b6ff;
|
||||||
const BOX_CREATE_PREVIEW_EDGE = 0xf3be8f;
|
const BOX_CREATE_PREVIEW_EDGE = 0xf3be8f;
|
||||||
const PLACEMENT_PREVIEW_COLOR_HEX = "#89b6ff";
|
const PLACEMENT_PREVIEW_COLOR_HEX = "#89b6ff";
|
||||||
@@ -355,6 +375,7 @@ export class ViewportHost {
|
|||||||
private readonly sunLight = new DirectionalLight();
|
private readonly sunLight = new DirectionalLight();
|
||||||
private readonly localLightGroup = new Group();
|
private readonly localLightGroup = new Group();
|
||||||
private readonly brushGroup = new Group();
|
private readonly brushGroup = new Group();
|
||||||
|
private readonly pathGroup = new Group();
|
||||||
private readonly entityGroup = new Group();
|
private readonly entityGroup = new Group();
|
||||||
private readonly modelGroup = new Group();
|
private readonly modelGroup = new Group();
|
||||||
private readonly waterReflectionCamera = new PerspectiveCamera();
|
private readonly waterReflectionCamera = new PerspectiveCamera();
|
||||||
@@ -366,6 +387,7 @@ export class ViewportHost {
|
|||||||
private readonly transformIntersection = new Vector3();
|
private readonly transformIntersection = new Vector3();
|
||||||
private readonly transformGizmoGroup = new Group();
|
private readonly transformGizmoGroup = new Group();
|
||||||
private readonly brushRenderObjects = new Map<string, BrushRenderObjects>();
|
private readonly brushRenderObjects = new Map<string, BrushRenderObjects>();
|
||||||
|
private readonly pathRenderObjects = new Map<string, PathRenderObjects>();
|
||||||
private readonly entityRenderObjects = new Map<string, EntityRenderObjects>();
|
private readonly entityRenderObjects = new Map<string, EntityRenderObjects>();
|
||||||
private readonly localLightRenderObjects = new Map<
|
private readonly localLightRenderObjects = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -517,6 +539,7 @@ export class ViewportHost {
|
|||||||
this.scene.add(this.sunLight);
|
this.scene.add(this.sunLight);
|
||||||
this.scene.add(this.localLightGroup);
|
this.scene.add(this.localLightGroup);
|
||||||
this.scene.add(this.brushGroup);
|
this.scene.add(this.brushGroup);
|
||||||
|
this.scene.add(this.pathGroup);
|
||||||
this.scene.add(this.entityGroup);
|
this.scene.add(this.entityGroup);
|
||||||
this.scene.add(this.modelGroup);
|
this.scene.add(this.modelGroup);
|
||||||
this.transformGizmoGroup.visible = false;
|
this.transformGizmoGroup.visible = false;
|
||||||
@@ -613,6 +636,7 @@ export class ViewportHost {
|
|||||||
});
|
});
|
||||||
this.rebuildLocalLights(document);
|
this.rebuildLocalLights(document);
|
||||||
this.rebuildBrushMeshes(document, selection);
|
this.rebuildBrushMeshes(document, selection);
|
||||||
|
this.rebuildPaths(document, selection);
|
||||||
this.rebuildEntityMarkers(document, selection);
|
this.rebuildEntityMarkers(document, selection);
|
||||||
this.rebuildModelInstances(document, selection);
|
this.rebuildModelInstances(document, selection);
|
||||||
this.applyTransformPreview();
|
this.applyTransformPreview();
|
||||||
@@ -3412,6 +3436,74 @@ export class ViewportHost {
|
|||||||
this.applyShadowState();
|
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(
|
private configureFogVolumeMesh(
|
||||||
mesh: Mesh<BufferGeometry, Material[]>,
|
mesh: Mesh<BufferGeometry, Material[]>,
|
||||||
materials: 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[]) {
|
private disposeUniqueMaterials(materials: Material[]) {
|
||||||
for (const material of new Set(materials)) {
|
for (const material of new Set(materials)) {
|
||||||
material.dispose();
|
material.dispose();
|
||||||
@@ -5140,6 +5265,22 @@ export class ViewportHost {
|
|||||||
this.resetViewportWaterSurfaceBindings(false);
|
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() {
|
private clearEntityMarkers() {
|
||||||
for (const renderObjects of this.entityRenderObjects.values()) {
|
for (const renderObjects of this.entityRenderObjects.values()) {
|
||||||
this.entityGroup.remove(renderObjects.group);
|
this.entityGroup.remove(renderObjects.group);
|
||||||
|
|||||||
Reference in New Issue
Block a user