diff --git a/src/commands/add-path-point-command.ts b/src/commands/add-path-point-command.ts new file mode 100644 index 00000000..49d2a905 --- /dev/null +++ b/src/commands/add-path-point-command.ts @@ -0,0 +1,108 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneEditorSelection, type EditorSelection } from "../core/selection"; +import type { ToolMode } from "../core/tool-mode"; +import { + cloneScenePath, + cloneScenePathPoint, + createAppendedScenePathPoint, + type ScenePathPoint +} from "../document/paths"; + +import type { EditorCommand } from "./command"; + +interface AddPathPointCommandOptions { + pathId: string; + point?: ScenePathPoint; + label?: string; +} + +function setSelectedPathPointSelection( + pathId: string, + pointId: string +): EditorSelection { + return { + kind: "pathPoint", + pathId, + pointId + }; +} + +export function createAddPathPointCommand( + options: AddPathPointCommandOptions +): EditorCommand { + let appendedPoint: ScenePathPoint | null = + options.point === undefined ? null : cloneScenePathPoint(options.point); + let previousSelection: EditorSelection | null = null; + let previousToolMode: ToolMode | null = null; + + return { + id: createOpaqueId("command"), + label: options.label ?? "Add path point", + 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 (appendedPoint === null) { + appendedPoint = createAppendedScenePathPoint(path); + } + + if (previousSelection === null) { + previousSelection = cloneEditorSelection(context.getSelection()); + } + + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + + context.setDocument({ + ...currentDocument, + paths: { + ...currentDocument.paths, + [path.id]: cloneScenePath({ + ...path, + points: [...path.points, cloneScenePathPoint(appendedPoint)] + }) + } + }); + context.setSelection( + setSelectedPathPointSelection(options.pathId, appendedPoint.id) + ); + context.setToolMode("select"); + }, + undo(context) { + if (appendedPoint === null) { + return; + } + + 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, + points: path.points.filter((point) => point.id !== appendedPoint?.id) + }) + } + }); + + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/delete-path-point-command.ts b/src/commands/delete-path-point-command.ts new file mode 100644 index 00000000..28a134a8 --- /dev/null +++ b/src/commands/delete-path-point-command.ts @@ -0,0 +1,141 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneEditorSelection, type EditorSelection } from "../core/selection"; +import type { ToolMode } from "../core/tool-mode"; +import { + cloneScenePath, + cloneScenePathPoint, + getScenePathPointIndex, + MIN_SCENE_PATH_POINT_COUNT, + type ScenePathPoint +} from "../document/paths"; + +import type { EditorCommand } from "./command"; + +interface DeletePathPointCommandOptions { + pathId: string; + pointId: string; + label?: string; +} + +function setSinglePathSelection(pathId: string): EditorSelection { + return { + kind: "paths", + ids: [pathId] + }; +} + +function setSelectedPathPointSelection( + pathId: string, + pointId: string +): EditorSelection { + return { + kind: "pathPoint", + pathId, + pointId + }; +} + +export function createDeletePathPointCommand( + options: DeletePathPointCommandOptions +): EditorCommand { + let deletedPoint: ScenePathPoint | null = null; + let deletedPointIndex: number | null = null; + let previousSelection: EditorSelection | null = null; + let previousToolMode: ToolMode | null = null; + + return { + id: createOpaqueId("command"), + label: options.label ?? "Delete path point", + 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 (path.points.length <= MIN_SCENE_PATH_POINT_COUNT) { + throw new Error( + `Paths must keep at least ${MIN_SCENE_PATH_POINT_COUNT} points.` + ); + } + + const pointIndex = getScenePathPointIndex(path, options.pointId); + + if (pointIndex === -1) { + throw new Error(`Path point ${options.pointId} does not exist on path ${options.pathId}.`); + } + + if (deletedPoint === null) { + deletedPoint = cloneScenePathPoint(path.points[pointIndex]); + } + + if (deletedPointIndex === null) { + deletedPointIndex = pointIndex; + } + + if (previousSelection === null) { + previousSelection = cloneEditorSelection(context.getSelection()); + } + + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + + const nextPoints = path.points.filter((point) => point.id !== options.pointId); + const fallbackPoint = + nextPoints[Math.min(pointIndex, nextPoints.length - 1)] ?? null; + + context.setDocument({ + ...currentDocument, + paths: { + ...currentDocument.paths, + [path.id]: cloneScenePath({ + ...path, + points: nextPoints + }) + } + }); + context.setSelection( + fallbackPoint === null + ? setSinglePathSelection(options.pathId) + : setSelectedPathPointSelection(options.pathId, fallbackPoint.id) + ); + context.setToolMode("select"); + }, + undo(context) { + if (deletedPoint === null || deletedPointIndex === null) { + return; + } + + const currentDocument = context.getDocument(); + const path = currentDocument.paths[options.pathId]; + + if (path === undefined) { + throw new Error(`Path ${options.pathId} does not exist.`); + } + + const nextPoints = [...path.points]; + nextPoints.splice(deletedPointIndex, 0, cloneScenePathPoint(deletedPoint)); + + context.setDocument({ + ...currentDocument, + paths: { + ...currentDocument.paths, + [path.id]: cloneScenePath({ + ...path, + points: nextPoints + }) + } + }); + + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +} diff --git a/src/commands/set-path-point-position-command.ts b/src/commands/set-path-point-position-command.ts new file mode 100644 index 00000000..6a81316a --- /dev/null +++ b/src/commands/set-path-point-position-command.ts @@ -0,0 +1,142 @@ +import { createOpaqueId } from "../core/ids"; +import { cloneEditorSelection, type EditorSelection } from "../core/selection"; +import type { ToolMode } from "../core/tool-mode"; +import { + cloneScenePath, + getScenePathPointIndex +} from "../document/paths"; +import type { Vec3 } from "../core/vector"; + +import type { EditorCommand } from "./command"; + +interface SetPathPointPositionCommandOptions { + pathId: string; + pointId: string; + position: Vec3; + label?: string; +} + +function cloneVec3(vector: Vec3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +function setSelectedPathPointSelection( + pathId: string, + pointId: string +): EditorSelection { + return { + kind: "pathPoint", + pathId, + pointId + }; +} + +export function createSetPathPointPositionCommand( + options: SetPathPointPositionCommandOptions +): EditorCommand { + const nextPosition = cloneVec3(options.position); + let previousPosition: Vec3 | null = null; + let previousSelection: EditorSelection | null = null; + let previousToolMode: ToolMode | null = null; + + return { + id: createOpaqueId("command"), + label: options.label ?? "Move path point", + execute(context) { + const currentDocument = context.getDocument(); + const path = currentDocument.paths[options.pathId]; + + if (path === undefined) { + throw new Error(`Path ${options.pathId} does not exist.`); + } + + const pointIndex = getScenePathPointIndex(path, options.pointId); + + if (pointIndex === -1) { + throw new Error(`Path point ${options.pointId} does not exist on path ${options.pathId}.`); + } + + if (previousPosition === null) { + previousPosition = cloneVec3(path.points[pointIndex].position); + } + + if (previousSelection === null) { + previousSelection = cloneEditorSelection(context.getSelection()); + } + + if (previousToolMode === null) { + previousToolMode = context.getToolMode(); + } + + context.setDocument({ + ...currentDocument, + paths: { + ...currentDocument.paths, + [path.id]: cloneScenePath({ + ...path, + points: path.points.map((point, index) => + index === pointIndex + ? { + ...point, + position: cloneVec3(nextPosition) + } + : point + ) + }) + } + }); + context.setSelection( + setSelectedPathPointSelection(options.pathId, options.pointId) + ); + context.setToolMode("select"); + }, + undo(context) { + if (previousPosition === null) { + return; + } + + const currentDocument = context.getDocument(); + const path = currentDocument.paths[options.pathId]; + + if (path === undefined) { + throw new Error(`Path ${options.pathId} does not exist.`); + } + + const pointIndex = getScenePathPointIndex(path, options.pointId); + + if (pointIndex === -1) { + throw new Error(`Path point ${options.pointId} does not exist on path ${options.pathId}.`); + } + + context.setDocument({ + ...currentDocument, + paths: { + ...currentDocument.paths, + [path.id]: cloneScenePath({ + ...path, + points: path.points.map((point, index) => + index === pointIndex + ? { + ...point, + position: cloneVec3(previousPosition) + } + : point + ) + }) + } + }); + + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + + if (previousToolMode !== null) { + context.setToolMode(previousToolMode); + } + } + }; +}