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)
+ }
+}