From 3f4e951f41d64ed5e6258fe83ddbf71b9796f92e Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Mon, 13 Apr 2026 21:21:40 +0200 Subject: [PATCH] Add path-related commands and update selection handling --- src/commands/delete-path-command.ts | 82 ++++++++++ src/commands/duplicate-selection-command.ts | 69 ++++++++- .../set-path-authored-state-command.ts | 90 +++++++++++ src/commands/set-path-name-command.ts | 69 +++++++++ src/commands/upsert-path-command.ts | 90 +++++++++++ src/core/selection.ts | 14 ++ src/core/transform-session.ts | 6 + src/document/scene-document-validation.ts | 120 +++++++++++++++ src/runtime-three/runtime-scene-build.ts | 71 +++++++++ src/viewport-three/viewport-focus.ts | 28 ++++ src/viewport-three/viewport-host.ts | 141 ++++++++++++++++++ 11 files changed, 778 insertions(+), 2 deletions(-) create mode 100644 src/commands/delete-path-command.ts create mode 100644 src/commands/set-path-authored-state-command.ts create mode 100644 src/commands/set-path-name-command.ts create mode 100644 src/commands/upsert-path-command.ts diff --git a/src/commands/delete-path-command.ts b/src/commands/delete-path-command.ts new file mode 100644 index 00000000..cf08a01b --- /dev/null +++ b/src/commands/delete-path-command.ts @@ -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); + } + } + }; +} diff --git a/src/commands/duplicate-selection-command.ts b/src/commands/duplicate-selection-command.ts index 91bd1e43..05b9de09 100644 --- a/src/commands/duplicate-selection-command.ts +++ b/src/commands/duplicate-selection-command.ts @@ -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 { } } }; -} \ No newline at end of file +} diff --git a/src/commands/set-path-authored-state-command.ts b/src/commands/set-path-authored-state-command.ts new file mode 100644 index 00000000..5599b18f --- /dev/null +++ b/src/commands/set-path-authored-state-command.ts @@ -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 + }) + } + }); + } + }; +} diff --git a/src/commands/set-path-name-command.ts b/src/commands/set-path-name-command.ts new file mode 100644 index 00000000..7d08a9a1 --- /dev/null +++ b/src/commands/set-path-name-command.ts @@ -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 + }) + } + }); + } + }; +} diff --git a/src/commands/upsert-path-command.ts b/src/commands/upsert-path-command.ts new file mode 100644 index 00000000..4adb2fa3 --- /dev/null +++ b/src/commands/upsert-path-command.ts @@ -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); + } + } + }; +} diff --git a/src/core/selection.ts b/src/core/selection.ts index aba52ede..10d3f62a 100644 --- a/src/core/selection.ts +++ b/src/core/selection.ts @@ -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": diff --git a/src/core/transform-session.ts b/src/core/transform-session.ts index 748f0b01..0212d6d6 100644 --- a/src/core/transform-session.ts +++ b/src/core/transform-session.ts @@ -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 { diff --git a/src/document/scene-document-validation.ts b/src/document/scene-document-validation.ts index 9367f64f..f2c6859d 100644 --- a/src/document/scene-document-validation.ts +++ b/src/document/scene-document-validation.ts @@ -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(); + + 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 )) { diff --git a/src/runtime-three/runtime-scene-build.ts b/src/runtime-three/runtime-scene-build.ts index 25967116..4244d170 100644 --- a/src/runtime-three/runtime-scene-build.ts +++ b/src/runtime-three/runtime-scene-build.ts @@ -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, diff --git a/src/viewport-three/viewport-focus.ts b/src/viewport-three/viewport-focus.ts index aa76b33f..22da1f5b 100644 --- a/src/viewport-three/viewport-focus.ts +++ b/src/viewport-three/viewport-focus.ts @@ -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); } diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts index bc1a2fb4..9fa2271f 100644 --- a/src/viewport-three/viewport-host.ts +++ b/src/viewport-three/viewport-host.ts @@ -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; + pointMeshes: Array<{ + pointId: string; + mesh: Mesh; + }>; +} + 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(); + private readonly pathRenderObjects = new Map(); private readonly entityRenderObjects = new Map(); 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, 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);