Add path-related commands and update selection handling

This commit is contained in:
2026-04-13 21:21:40 +02:00
parent 0eac096de5
commit 3f4e951f41
11 changed files with 778 additions and 2 deletions

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

View File

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

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

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

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

View File

@@ -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":

View File

@@ -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 {

View File

@@ -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
)) {

View File

@@ -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,

View File

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

View File

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