diff --git a/Sources/UntoldEditor/Editor/EditorUndoManager.swift b/Sources/UntoldEditor/Editor/EditorUndoManager.swift new file mode 100644 index 0000000..d49e219 --- /dev/null +++ b/Sources/UntoldEditor/Editor/EditorUndoManager.swift @@ -0,0 +1,326 @@ +// +// EditorUndoManager.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import Foundation +import simd +import SwiftUI +import UntoldEngine + +protocol EditorUndoCommand { + var name: String { get } + func undo() + func redo() +} + +struct EditorTransformSnapshot: Equatable { + var position: simd_float3 + var rotation: simd_float3 + var scale: simd_float3 + + init(entityId: EntityID) { + position = getLocalPosition(entityId: entityId) + rotation = getAxisRotations(entityId: entityId) + scale = getScale(entityId: entityId) + } + + func apply(to entityId: EntityID) { + guard hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) else { + return + } + + translateTo(entityId: entityId, position: position) + applyAxisRotations(entityId: entityId, axis: rotation) + scaleTo(entityId: entityId, scale: scale) + } +} + +struct EditorNameChangeCommand: EditorUndoCommand { + let entityId: EntityID + let oldName: String + let newName: String + + var name: String { + "Rename Entity" + } + + func undo() { + setEntityName(entityId: entityId, name: oldName) + } + + func redo() { + setEntityName(entityId: entityId, name: newName) + } +} + +struct EditorTransformChangeCommand: EditorUndoCommand { + let entityId: EntityID + let before: EditorTransformSnapshot + let after: EditorTransformSnapshot + + var name: String { + "Transform Entity" + } + + func undo() { + before.apply(to: entityId) + } + + func redo() { + after.apply(to: entityId) + } +} + +struct EditorValueChangeCommand: EditorUndoCommand { + let name: String + let oldValue: Value + let newValue: Value + let apply: (Value) -> Void + + func undo() { + apply(oldValue) + } + + func redo() { + apply(newValue) + } +} + +struct EditorPostFXSnapshot: Equatable { + var colorGradingEnabled: Bool + var exposure: Float + var brightness: Float + var contrast: Float + var saturation: Float + var temperature: Float + var tint: Float + var bloomEnabled: Bool + var bloomThreshold: Float + var bloomIntensity: Float + var vignetteEnabled: Bool + var vignetteIntensity: Float + var vignetteRadius: Float + var vignetteSoftness: Float + var chromaticAberrationEnabled: Bool + var chromaticAberrationIntensity: Float + var depthOfFieldEnabled: Bool + var focusDistance: Float + var focusRange: Float + var maxBlur: Float + var ssaoEnabled: Bool + var ssaoRadius: Float + var ssaoBias: Float + var ssaoIntensity: Float + + init() { + colorGradingEnabled = ColorGradingParams.shared.enabled + exposure = ColorGradingParams.shared.exposure + brightness = ColorGradingParams.shared.brightness + contrast = ColorGradingParams.shared.contrast + saturation = ColorGradingParams.shared.saturation + temperature = ColorGradingParams.shared.temperature + tint = ColorGradingParams.shared.tint + + bloomEnabled = BloomThresholdParams.shared.enabled + bloomThreshold = BloomThresholdParams.shared.threshold + bloomIntensity = BloomThresholdParams.shared.intensity + + vignetteEnabled = VignetteParams.shared.enabled + vignetteIntensity = VignetteParams.shared.intensity + vignetteRadius = VignetteParams.shared.radius + vignetteSoftness = VignetteParams.shared.softness + + chromaticAberrationEnabled = ChromaticAberrationParams.shared.enabled + chromaticAberrationIntensity = ChromaticAberrationParams.shared.intensity + + depthOfFieldEnabled = DepthOfFieldParams.shared.enabled + focusDistance = DepthOfFieldParams.shared.focusDistance + focusRange = DepthOfFieldParams.shared.focusRange + maxBlur = DepthOfFieldParams.shared.maxBlur + + ssaoEnabled = SSAOParams.shared.enabled + ssaoRadius = SSAOParams.shared.radius + ssaoBias = SSAOParams.shared.bias + ssaoIntensity = SSAOParams.shared.intensity + } + + func apply() { + ColorGradingParams.shared.enabled = colorGradingEnabled + ColorGradingParams.shared.exposure = exposure + ColorGradingParams.shared.brightness = brightness + ColorGradingParams.shared.contrast = contrast + ColorGradingParams.shared.saturation = saturation + ColorGradingParams.shared.temperature = temperature + ColorGradingParams.shared.tint = tint + + BloomThresholdParams.shared.enabled = bloomEnabled + BloomThresholdParams.shared.threshold = bloomThreshold + BloomThresholdParams.shared.intensity = bloomIntensity + + VignetteParams.shared.enabled = vignetteEnabled + VignetteParams.shared.intensity = vignetteIntensity + VignetteParams.shared.radius = vignetteRadius + VignetteParams.shared.softness = vignetteSoftness + + ChromaticAberrationParams.shared.enabled = chromaticAberrationEnabled + ChromaticAberrationParams.shared.intensity = chromaticAberrationIntensity + + DepthOfFieldParams.shared.enabled = depthOfFieldEnabled + DepthOfFieldParams.shared.focusDistance = focusDistance + DepthOfFieldParams.shared.focusRange = focusRange + DepthOfFieldParams.shared.maxBlur = maxBlur + + SSAOParams.shared.enabled = ssaoEnabled + SSAOParams.shared.radius = ssaoRadius + SSAOParams.shared.bias = ssaoBias + SSAOParams.shared.intensity = ssaoIntensity + } +} + +struct EditorPostFXChangeCommand: EditorUndoCommand { + let name: String + let before: EditorPostFXSnapshot + let after: EditorPostFXSnapshot + + func undo() { + before.apply() + } + + func redo() { + after.apply() + } +} + +final class EditorUndoManager: ObservableObject { + static let shared = EditorUndoManager() + + @Published private(set) var canUndo = false + @Published private(set) var canRedo = false + + private var undoStack: [EditorUndoCommand] = [] + private var redoStack: [EditorUndoCommand] = [] + private var transformEditStart: [EntityID: EditorTransformSnapshot] = [:] + private var isReplaying = false + + var onStateRestored: (() -> Void)? + + func register(_ command: EditorUndoCommand) { + guard isReplaying == false else { + return + } + + undoStack.append(command) + redoStack.removeAll() + updateAvailability() + } + + func registerNameChange(entityId: EntityID, oldName: String, newName: String) { + guard oldName != newName else { + return + } + + register(EditorNameChangeCommand(entityId: entityId, oldName: oldName, newName: newName)) + } + + func registerTransformChange(entityId: EntityID, before: EditorTransformSnapshot, after: EditorTransformSnapshot) { + guard before != after else { + return + } + + register(EditorTransformChangeCommand(entityId: entityId, before: before, after: after)) + } + + func beginTransformEdit(entityId: EntityID) { + guard isReplaying == false, + hasComponent(entityId: entityId, componentType: LocalTransformComponent.self), + transformEditStart[entityId] == nil + else { + return + } + + transformEditStart[entityId] = EditorTransformSnapshot(entityId: entityId) + } + + func commitTransformEdit(entityId: EntityID) { + guard let before = transformEditStart.removeValue(forKey: entityId), + hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) + else { + return + } + + registerTransformChange( + entityId: entityId, + before: before, + after: EditorTransformSnapshot(entityId: entityId) + ) + } + + func registerValueChange( + name: String, + oldValue: Value, + newValue: Value, + apply: @escaping (Value) -> Void + ) { + guard oldValue != newValue else { + return + } + + register(EditorValueChangeCommand(name: name, oldValue: oldValue, newValue: newValue, apply: apply)) + } + + func registerPostFXChange(name: String, before: EditorPostFXSnapshot, after: EditorPostFXSnapshot) { + guard before != after else { + return + } + + register(EditorPostFXChangeCommand(name: name, before: before, after: after)) + } + + func undo() { + guard let command = undoStack.popLast() else { + return + } + + replay { + command.undo() + } + redoStack.append(command) + updateAvailability() + } + + func redo() { + guard let command = redoStack.popLast() else { + return + } + + replay { + command.redo() + } + undoStack.append(command) + updateAvailability() + } + + func clear() { + undoStack.removeAll() + redoStack.removeAll() + transformEditStart.removeAll() + updateAvailability() + } + + private func replay(_ operation: () -> Void) { + isReplaying = true + operation() + isReplaying = false + onStateRestored?() + } + + private func updateAvailability() { + canUndo = undoStack.isEmpty == false + canRedo = redoStack.isEmpty == false + } +} diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index c543aba..5dd8213 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -154,6 +154,12 @@ public struct EditorView: View { .allowsHitTesting(false) } .onAppear { + EditorUndoManager.shared.onStateRestored = { + editor_entities = getAllGameEntities() + selectionManager.objectWillChange.send() + sceneGraphModel.refreshHierarchy() + } + sceneGraphModel.refreshHierarchy() // Listen for asset instance loading completion @@ -343,6 +349,7 @@ public struct EditorView: View { destroyAllEntities() removeGizmo() EditorComponentsState.shared.clear() + EditorUndoManager.shared.clear() deserializeScene(sceneData: sceneData) editorController?.currentSceneURL = nil editor_entities = getAllGameEntities() @@ -360,6 +367,7 @@ public struct EditorView: View { destroyAllEntities() removeGizmo() EditorComponentsState.shared.clear() + EditorUndoManager.shared.clear() let light = createEntity() setEntityName(entityId: light, name: "Directional Light") diff --git a/Sources/UntoldEditor/Editor/EngineStatsView.swift b/Sources/UntoldEditor/Editor/EngineStatsView.swift index 4432a9f..04c2c40 100644 --- a/Sources/UntoldEditor/Editor/EngineStatsView.swift +++ b/Sources/UntoldEditor/Editor/EngineStatsView.swift @@ -102,7 +102,6 @@ struct EngineStatsOverlayView: View { } } - @ViewBuilder private func simplifiedOverlay(_ snapshot: EngineStatsSnapshot) -> some View { VStack(alignment: .leading, spacing: 4) { Text("Engine Stats") @@ -127,7 +126,6 @@ struct EngineStatsOverlayView: View { .padding(12) } - @ViewBuilder private func advancedOverlay(_ snapshot: EngineStatsSnapshot) -> some View { VStack(alignment: .leading, spacing: 6) { Text("Engine Stats (Advanced)") diff --git a/Sources/UntoldEditor/Editor/EnvironmentView.swift b/Sources/UntoldEditor/Editor/EnvironmentView.swift index 5678bd4..1872b24 100644 --- a/Sources/UntoldEditor/Editor/EnvironmentView.swift +++ b/Sources/UntoldEditor/Editor/EnvironmentView.swift @@ -137,30 +137,83 @@ struct EnvironmentView: View { } } +private struct UndoableEffectToggle: View { + let undoName: String + @Binding var isOn: Bool + @ViewBuilder let label: () -> Label + + var body: some View { + Toggle(isOn: Binding( + get: { isOn }, + set: { newValue in + let oldValue = isOn + isOn = newValue + DispatchQueue.main.async { + EditorUndoManager.shared.registerValueChange( + name: undoName, + oldValue: oldValue, + newValue: newValue, + apply: { isOn = $0 } + ) + } + } + ), label: label) + } +} + +private struct UndoableEffectSlider: View { + let label: String + let undoName: String + let range: ClosedRange + var format: String = "%.2f" + let get: () -> Float + let set: (Float) -> Void + + @State private var editStartValue: Float? + + var body: some View { + Text(label) + Slider( + value: Binding( + get: get, + set: set + ), + in: range, + onEditingChanged: { isEditing in + if isEditing { + editStartValue = get() + } else if let oldValue = editStartValue { + let newValue = get() + EditorUndoManager.shared.registerValueChange( + name: undoName, + oldValue: oldValue, + newValue: newValue, + apply: set + ) + editStartValue = nil + } + } + ) + Text(String(format: format, get())) + } +} + struct ColorGradingEditorView: View { @ObservedObject var settings = ColorGradingParams.shared var body: some View { VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: $settings.enabled) { + UndoableEffectToggle( + undoName: "Toggle Color Grading", + isOn: $settings.enabled + ) { Text("Enable Color Grading") } - Text("Exposure") - Slider(value: $settings.exposure, in: -5.0 ... 5.0) - Text(String(format: "%.2f", settings.exposure)) - - Text("Brightness") - Slider(value: $settings.brightness, in: -1.0 ... 1.0) - Text(String(format: "%.2f", settings.brightness)) - - Text("Contrast") - Slider(value: $settings.contrast, in: -5.0 ... 5.0) - Text(String(format: "%.2f", settings.contrast)) - - Text("Saturation") - Slider(value: $settings.saturation, in: 0.0 ... 5.0) - Text(String(format: "%.2f", settings.saturation)) + UndoableEffectSlider(label: "Exposure", undoName: "Change Exposure", range: -5.0 ... 5.0, get: { settings.exposure }, set: { settings.exposure = $0 }) + UndoableEffectSlider(label: "Brightness", undoName: "Change Brightness", range: -1.0 ... 1.0, get: { settings.brightness }, set: { settings.brightness = $0 }) + UndoableEffectSlider(label: "Contrast", undoName: "Change Contrast", range: -5.0 ... 5.0, get: { settings.contrast }, set: { settings.contrast = $0 }) + UndoableEffectSlider(label: "Saturation", undoName: "Change Saturation", range: 0.0 ... 5.0, get: { settings.saturation }, set: { settings.saturation = $0 }) } .padding() } @@ -171,13 +224,8 @@ struct WhiteBalanceEditorView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text("Temperature") - Slider(value: $settings.temperature, in: -100.0 ... 100.0) - Text(String(format: "%.2f", settings.temperature)) - - Text("Tint") - Slider(value: $settings.tint, in: -100.0 ... 100.0) - Text(String(format: "%.2f", settings.tint)) + UndoableEffectSlider(label: "Temperature", undoName: "Change Temperature", range: -100.0 ... 100.0, get: { settings.temperature }, set: { settings.temperature = $0 }) + UndoableEffectSlider(label: "Tint", undoName: "Change Tint", range: -100.0 ... 100.0, get: { settings.tint }, set: { settings.tint = $0 }) // TextInputVectorView(label: "Lift", value: Binding( // get: { settings.lift }, @@ -206,17 +254,15 @@ struct BloomEditorView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: $settings.enabled) { + UndoableEffectToggle( + undoName: "Toggle Bloom", + isOn: $settings.enabled + ) { Text("Enable Bloom") } - Text("Threshold") - Slider(value: $settings.threshold, in: 0.0 ... 5.0) - Text(String(format: "%.2f", settings.threshold)) - - Text("Intensity") - Slider(value: $settings.intensity, in: 0.0 ... 100.0) - Text(String(format: "%.2f", settings.intensity)) + UndoableEffectSlider(label: "Threshold", undoName: "Change Bloom Threshold", range: 0.0 ... 5.0, get: { settings.threshold }, set: { settings.threshold = $0 }) + UndoableEffectSlider(label: "Intensity", undoName: "Change Bloom Intensity", range: 0.0 ... 100.0, get: { settings.intensity }, set: { settings.intensity = $0 }) } .padding() } @@ -227,21 +273,16 @@ struct VignetteEditorView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: $settings.enabled) { + UndoableEffectToggle( + undoName: "Toggle Vignette", + isOn: $settings.enabled + ) { Text("Enable Vignette") } - Text("Intensity") - Slider(value: $settings.intensity, in: 0.0 ... 1.0) - Text(String(format: "%.2f", settings.intensity)) - - Text("Radius") - Slider(value: $settings.radius, in: 0.0 ... 1.0) - Text(String(format: "%.2f", settings.radius)) - - Text("Softness") - Slider(value: $settings.softness, in: 0.0 ... 1.0) - Text(String(format: "%.2f", settings.softness)) + UndoableEffectSlider(label: "Intensity", undoName: "Change Vignette Intensity", range: 0.0 ... 1.0, get: { settings.intensity }, set: { settings.intensity = $0 }) + UndoableEffectSlider(label: "Radius", undoName: "Change Vignette Radius", range: 0.0 ... 1.0, get: { settings.radius }, set: { settings.radius = $0 }) + UndoableEffectSlider(label: "Softness", undoName: "Change Vignette Softness", range: 0.0 ... 1.0, get: { settings.softness }, set: { settings.softness = $0 }) // TextInputVectorView(label: "Center", value: Binding( // get: { settings.center }, @@ -258,13 +299,14 @@ struct ChromaticAberrationEditorView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: $settings.enabled) { + UndoableEffectToggle( + undoName: "Toggle Chromatic Aberration", + isOn: $settings.enabled + ) { Text("Enable Chromatic Aberration") } - Text("Intensity") - Slider(value: $settings.intensity, in: 0.0 ... 0.01) - Text(String(format: "%.4f", settings.intensity)) + UndoableEffectSlider(label: "Intensity", undoName: "Change Chromatic Aberration Intensity", range: 0.0 ... 0.01, format: "%.4f", get: { settings.intensity }, set: { settings.intensity = $0 }) // TextInputVectorView(label: "Center", value: Binding( // get: { settings.center }, @@ -281,21 +323,16 @@ struct DepthOfFieldEditorView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: $settings.enabled) { + UndoableEffectToggle( + undoName: "Toggle Depth of Field", + isOn: $settings.enabled + ) { Text("Enable Depth of Field") } - Text("Focus Distance") - Slider(value: $settings.focusDistance, in: 0.0 ... 10.0) - Text(String(format: "%.2f", settings.focusDistance)) - - Text("Focus Range") - Slider(value: $settings.focusRange, in: 0.0 ... 10.0) - Text(String(format: "%.4f", settings.focusRange)) - - Text("Max Blur") - Slider(value: $settings.maxBlur, in: 0.0 ... 0.05) - Text(String(format: "%.4f", settings.maxBlur)) + UndoableEffectSlider(label: "Focus Distance", undoName: "Change Focus Distance", range: 0.0 ... 10.0, get: { settings.focusDistance }, set: { settings.focusDistance = $0 }) + UndoableEffectSlider(label: "Focus Range", undoName: "Change Focus Range", range: 0.0 ... 10.0, format: "%.4f", get: { settings.focusRange }, set: { settings.focusRange = $0 }) + UndoableEffectSlider(label: "Max Blur", undoName: "Change Max Blur", range: 0.0 ... 0.05, format: "%.4f", get: { settings.maxBlur }, set: { settings.maxBlur = $0 }) } .padding() } @@ -306,13 +343,14 @@ struct SSAOEditorView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: $settings.enabled) { + UndoableEffectToggle( + undoName: "Toggle SSAO", + isOn: $settings.enabled + ) { Text("Enable SSAO") } - Text("Radius") - Slider(value: $settings.radius, in: 0.1 ... 1.0) - Text(String(format: "%.2f", settings.radius)) + UndoableEffectSlider(label: "Radius", undoName: "Change SSAO Radius", range: 0.1 ... 1.0, get: { settings.radius }, set: { settings.radius = $0 }) // Text("Bias") // Slider(value: $settings.bias, in: 0.0 ... 0.1) @@ -373,7 +411,13 @@ struct PostProcessingEditorView: View { } .pickerStyle(.menu) .onChange(of: selectedPreset) { _, newValue in + let before = EditorPostFXSnapshot() PostFX.apply(newValue.enginePreset) + EditorUndoManager.shared.registerPostFXChange( + name: "Apply \(newValue.rawValue) Preset", + before: before, + after: EditorPostFXSnapshot() + ) } } .padding() diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index 07d4abe..b4d04b9 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -253,6 +253,7 @@ struct InspectorView: View { var onAddName_Editor: () -> Void // @State private var editor_entityComponents: [EntityID: [ObjectIdentifier: ComponentOption_Editor]] = [:] @FocusState private var isNameTextFieldFocused: Bool + @State private var nameEditStartValue: String? @Binding var selectedAsset: Asset? var body: some View { @@ -279,10 +280,31 @@ struct InspectorView: View { .focused($isNameTextFieldFocused) .disabled(isDerivedAssetNode(entityId)) .onSubmit { + if let oldName = nameEditStartValue { + EditorUndoManager.shared.registerNameChange( + entityId: entityId, + oldName: oldName, + newName: getEntityName(entityId: entityId) + ) + } + nameEditStartValue = nil onAddName_Editor() refreshView() isNameTextFieldFocused = false } + .onChange(of: isNameTextFieldFocused) { _, isFocused in + if isFocused { + nameEditStartValue = getEntityName(entityId: entityId) + } else if let oldName = nameEditStartValue { + EditorUndoManager.shared.registerNameChange( + entityId: entityId, + oldName: oldName, + newName: getEntityName(entityId: entityId) + ) + nameEditStartValue = nil + refreshView() + } + } } if isDerivedAssetNode(entityId) { @@ -1009,8 +1031,14 @@ struct TransformationEditorView: View { TextInputVectorView(label: "Position", value: Binding( get: { position }, set: { newPosition in + let before = EditorTransformSnapshot(entityId: entityId) handleTransformChange() translateTo(entityId: entityId, position: newPosition) + EditorUndoManager.shared.registerTransformChange( + entityId: entityId, + before: before, + after: EditorTransformSnapshot(entityId: entityId) + ) refreshView() } )) @@ -1018,8 +1046,14 @@ struct TransformationEditorView: View { TextInputVectorView(label: "Orientation", value: Binding( get: { orientation }, set: { newOrientation in + let before = EditorTransformSnapshot(entityId: entityId) handleTransformChange() applyAxisRotations(entityId: entityId, axis: newOrientation) + EditorUndoManager.shared.registerTransformChange( + entityId: entityId, + before: before, + after: EditorTransformSnapshot(entityId: entityId) + ) refreshView() } )) @@ -1027,8 +1061,14 @@ struct TransformationEditorView: View { TextInputVectorView(label: "Scale", value: Binding( get: { scale }, set: { newScale in + let before = EditorTransformSnapshot(entityId: entityId) handleTransformChange() scaleTo(entityId: entityId, scale: newScale) + EditorUndoManager.shared.registerTransformChange( + entityId: entityId, + before: before, + after: EditorTransformSnapshot(entityId: entityId) + ) refreshView() } )) diff --git a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift index 32904da..e9ec122 100644 --- a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift +++ b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift @@ -37,6 +37,18 @@ } NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if self?.shouldHandleKey(event) == true, + event.modifierFlags.contains(.command), + event.charactersIgnoringModifiers?.lowercased() == "z" + { + if event.modifierFlags.contains(.shift) { + EditorUndoManager.shared.redo() + } else { + EditorUndoManager.shared.undo() + } + return nil + } + if self?.shouldHandleKey(event) == true { self?.keyPressed(event.keyCode) return nil // Mark event as handled @@ -239,6 +251,9 @@ let (hitEntityId, hit) = getRaycastedEntity(currentLocation: currentLocation, view: view) if hit { activeHitGizmoEntity = hitEntityId + if activeEntity != .invalid { + EditorUndoManager.shared.beginTransformEdit(entityId: activeEntity) + } } else { activeHitGizmoEntity = .invalid editorController?.activeMode = .none @@ -279,6 +294,13 @@ orbitAround(entityId: findSceneCamera(), uPosition: InputSystem.shared.panDelta * 0.005) case .ended, .cancelled, .failed: + if isEditorEnabled, + activeHitGizmoEntity != .invalid, + activeEntity != .invalid + { + EditorUndoManager.shared.commitTransformEdit(entityId: activeEntity) + } + // Reset panDelta = simd_float2(0, 0) initialPanLocation = nil diff --git a/Tests/UntoldEditorTests/EditorUndoManagerTests.swift b/Tests/UntoldEditorTests/EditorUndoManagerTests.swift new file mode 100644 index 0000000..5bf0960 --- /dev/null +++ b/Tests/UntoldEditorTests/EditorUndoManagerTests.swift @@ -0,0 +1,196 @@ +// +// EditorUndoManagerTests.swift +// UntoldEditorTests +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import simd +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +final class EditorUndoManagerTests: XCTestCase { + override func setUp() { + super.setUp() + scene = Scene() + EditorUndoManager.shared.clear() + EditorUndoManager.shared.onStateRestored = nil + } + + override func tearDown() { + EditorUndoManager.shared.clear() + EditorUndoManager.shared.onStateRestored = nil + super.tearDown() + } + + func test_nameChangeUndoRedo_restoresEntityName() { + let entity = createEntity() + setEntityName(entityId: entity, name: "Before") + + setEntityName(entityId: entity, name: "After") + EditorUndoManager.shared.registerNameChange(entityId: entity, oldName: "Before", newName: "After") + + XCTAssertTrue(EditorUndoManager.shared.canUndo) + XCTAssertFalse(EditorUndoManager.shared.canRedo) + + EditorUndoManager.shared.undo() + + XCTAssertEqual(getEntityName(entityId: entity), "Before") + XCTAssertFalse(EditorUndoManager.shared.canUndo) + XCTAssertTrue(EditorUndoManager.shared.canRedo) + + EditorUndoManager.shared.redo() + + XCTAssertEqual(getEntityName(entityId: entity), "After") + XCTAssertTrue(EditorUndoManager.shared.canUndo) + XCTAssertFalse(EditorUndoManager.shared.canRedo) + } + + func test_transformChangeUndoRedo_restoresPositionRotationAndScale() { + let entity = createEntity() + registerTransformComponent(entityId: entity) + + translateTo(entityId: entity, position: simd_float3(1, 2, 3)) + applyAxisRotations(entityId: entity, axis: simd_float3(10, 20, 30)) + scaleTo(entityId: entity, scale: simd_float3(1, 1, 1)) + let before = EditorTransformSnapshot(entityId: entity) + + translateTo(entityId: entity, position: simd_float3(4, 5, 6)) + applyAxisRotations(entityId: entity, axis: simd_float3(40, 50, 60)) + scaleTo(entityId: entity, scale: simd_float3(2, 3, 4)) + EditorUndoManager.shared.registerTransformChange( + entityId: entity, + before: before, + after: EditorTransformSnapshot(entityId: entity) + ) + + EditorUndoManager.shared.undo() + + XCTAssertEqual(getLocalPosition(entityId: entity), simd_float3(1, 2, 3)) + XCTAssertEqual(getAxisRotations(entityId: entity), simd_float3(10, 20, 30)) + XCTAssertEqual(getScale(entityId: entity), simd_float3(1, 1, 1)) + + EditorUndoManager.shared.redo() + + XCTAssertEqual(getLocalPosition(entityId: entity), simd_float3(4, 5, 6)) + XCTAssertEqual(getAxisRotations(entityId: entity), simd_float3(40, 50, 60)) + XCTAssertEqual(getScale(entityId: entity), simd_float3(2, 3, 4)) + } + + func test_beginAndCommitTransformEdit_coalescesToSingleCommand() { + let entity = createEntity() + registerTransformComponent(entityId: entity) + + translateTo(entityId: entity, position: .zero) + EditorUndoManager.shared.beginTransformEdit(entityId: entity) + translateTo(entityId: entity, position: simd_float3(1, 0, 0)) + translateTo(entityId: entity, position: simd_float3(2, 0, 0)) + translateTo(entityId: entity, position: simd_float3(3, 0, 0)) + EditorUndoManager.shared.commitTransformEdit(entityId: entity) + + EditorUndoManager.shared.undo() + + XCTAssertEqual(getLocalPosition(entityId: entity), .zero) + + EditorUndoManager.shared.redo() + + XCTAssertEqual(getLocalPosition(entityId: entity), simd_float3(3, 0, 0)) + } + + func test_valueChangeUndoRedo_supportsSSAOStyleSettings() { + SSAOParams.shared.radius = 0.25 + + SSAOParams.shared.radius = 0.75 + EditorUndoManager.shared.registerValueChange( + name: "Change SSAO Radius", + oldValue: Float(0.25), + newValue: Float(0.75) + ) { value in + SSAOParams.shared.radius = value + } + + EditorUndoManager.shared.undo() + XCTAssertEqual(SSAOParams.shared.radius, 0.25, accuracy: 0.0001) + + EditorUndoManager.shared.redo() + XCTAssertEqual(SSAOParams.shared.radius, 0.75, accuracy: 0.0001) + } + + func test_valueChangeUndoRedo_supportsOtherEffectSettings() { + BloomThresholdParams.shared.intensity = 0.2 + + BloomThresholdParams.shared.intensity = 3.5 + EditorUndoManager.shared.registerValueChange( + name: "Change Bloom Intensity", + oldValue: Float(0.2), + newValue: Float(3.5) + ) { value in + BloomThresholdParams.shared.intensity = value + } + + EditorUndoManager.shared.undo() + XCTAssertEqual(BloomThresholdParams.shared.intensity, 0.2, accuracy: 0.0001) + + EditorUndoManager.shared.redo() + XCTAssertEqual(BloomThresholdParams.shared.intensity, 3.5, accuracy: 0.0001) + } + + func test_postFXSnapshotUndoRedo_restoresPresetWideChanges() { + ColorGradingParams.shared.enabled = false + ColorGradingParams.shared.exposure = 0.0 + ColorGradingParams.shared.brightness = 0.0 + ColorGradingParams.shared.contrast = 1.0 + ColorGradingParams.shared.saturation = 1.0 + ColorGradingParams.shared.temperature = 0.0 + ColorGradingParams.shared.tint = 0.0 + BloomThresholdParams.shared.enabled = false + BloomThresholdParams.shared.threshold = 0.5 + BloomThresholdParams.shared.intensity = 0.0 + VignetteParams.shared.enabled = false + ChromaticAberrationParams.shared.enabled = false + DepthOfFieldParams.shared.enabled = false + SSAOParams.shared.enabled = false + SSAOParams.shared.radius = 0.5 + SSAOParams.shared.bias = 0.025 + SSAOParams.shared.intensity = 0.0 + + let before = EditorPostFXSnapshot() + PostFX.apply(.cinematic) + EditorUndoManager.shared.registerPostFXChange( + name: "Apply Cinematic Preset", + before: before, + after: EditorPostFXSnapshot() + ) + + XCTAssertTrue(ColorGradingParams.shared.enabled) + XCTAssertTrue(SSAOParams.shared.enabled) + XCTAssertEqual(ColorGradingParams.shared.exposure, -0.2, accuracy: 0.0001) + + EditorUndoManager.shared.undo() + + XCTAssertFalse(ColorGradingParams.shared.enabled) + XCTAssertFalse(SSAOParams.shared.enabled) + XCTAssertEqual(ColorGradingParams.shared.exposure, 0.0, accuracy: 0.0001) + XCTAssertEqual(SSAOParams.shared.radius, 0.5, accuracy: 0.0001) + + EditorUndoManager.shared.redo() + + XCTAssertTrue(ColorGradingParams.shared.enabled) + XCTAssertTrue(SSAOParams.shared.enabled) + XCTAssertEqual(ColorGradingParams.shared.exposure, -0.2, accuracy: 0.0001) + XCTAssertEqual(SSAOParams.shared.intensity, 0.5, accuracy: 0.0001) + } + + func test_noopChangesAreNotRegistered() { + let entity = createEntity() + setEntityName(entityId: entity, name: "Same") + + EditorUndoManager.shared.registerNameChange(entityId: entity, oldName: "Same", newName: "Same") + + XCTAssertFalse(EditorUndoManager.shared.canUndo) + XCTAssertFalse(EditorUndoManager.shared.canRedo) + } +}