Working on detail eraser

This commit is contained in:
Yousef Khadadeh 2026-05-24 20:32:21 +03:00
parent 60ab8f3c73
commit c54fef383c
15 changed files with 255 additions and 58 deletions

View file

@ -71,6 +71,7 @@ set(sources "src/main.cpp"
"src/RichText/TextBox.cpp"
"src/RichText/TextStyleModifier.cpp"
"src/RichText/ParagraphStyleData.cpp"
"src/DrawingProgram/EditCanvasComponentWorldUndoAction.cpp"
"src/DrawingProgram/ToolConfiguration.cpp"
"src/DrawingProgram/DrawingProgramSelection.cpp"
"src/DrawingProgram/DrawingProgramCache.cpp"

View file

@ -71,4 +71,12 @@ bool CanvasComponent::should_draw_extra(const DrawData& drawData, const CoordSpa
return true;
}
bool CanvasComponent::can_erase_detail() const {
return false;
}
CanvasComponentEraseDetailResult CanvasComponent::erase_detail(const SkPath& eraseAgainst) {
return CanvasComponentEraseDetailResult::NO_CHANGE;
}
CanvasComponent::~CanvasComponent() {}

View file

@ -29,6 +29,7 @@ class DrawingProgram;
class CanvasComponent {
public:
virtual CanvasComponentType get_type() const = 0;
virtual ~CanvasComponent();
static CanvasComponent* allocate_comp(CanvasComponentType type);
@ -59,6 +60,9 @@ class CanvasComponent {
virtual bool collides_within_coords_point(const Vector2f& checkAgainst) const = 0;
virtual bool collides_within_coords_skpath(const SkPath& checkAgainst) const = 0;
virtual bool can_erase_detail() const;
virtual CanvasComponentEraseDetailResult erase_detail(const SkPath& eraseAgainst);
virtual SCollision::AABB<float> get_obj_coord_bounds() const = 0;
CanvasComponentContainer* compContainer = nullptr;

View file

@ -142,6 +142,28 @@ void CanvasComponentContainer::commit_transform_dont_invalidate_cache() {
calculate_world_bounds();
}
CanvasComponentEraseDetailResult CanvasComponentContainer::collides_with_erase_detail(const CoordSpaceHelper& camCoords, const SkPath& checkAgainstCam) const {
if(!worldAABB.has_value())
return CanvasComponentEraseDetailResult::NO_CHANGE;
if((camCoords.inverseScale << COMP_MAX_SHIFT_BEFORE_STOP_COLLISIONS) < coords.inverseScale) // Object is too large, just dismiss the collision
return CanvasComponentEraseDetailResult::NO_CHANGE;
else if(coords.inverseScale < (camCoords.inverseScale >> COMP_COLLIDE_MIN_SHIFT_TINY)) {
if(checkAgainstCam.contains(convert_vec2<SkPoint>(camCoords.to_space(worldAABB.value().min))))
return CanvasComponentEraseDetailResult::REMOVED;
return CanvasComponentEraseDetailResult::NO_CHANGE;
}
else if(get_comp().can_erase_detail()) {
TransformData drawTransform = calculate_draw_transform(camCoords);
SkMatrix m = SkMatrix::I();
m.postScale(1.0 / drawTransform.scale, 1.0 / drawTransform.scale).postRotate(-drawTransform.rotation).postTranslate(-drawTransform.translation.x(), -drawTransform.translation.y());
std::optional<SkPath> transformedPath = checkAgainstCam.tryMakeTransform(m);
if(!transformedPath.has_value())
return CanvasComponentEraseDetailResult::NO_CHANGE;
return get_comp().erase_detail(transformedPath.value());
}
return CanvasComponentEraseDetailResult::NO_CHANGE;
}
// We could just send one of the checkAgainst colliders, since they represent the same thing, but sending both saves on redundant transformations, since we can save both of the versions on the executor side
bool CanvasComponentContainer::collides_with(const CoordSpaceHelper& camCoords, const SkPath& checkAgainstCam) const {
if(!worldAABB.has_value())

View file

@ -30,6 +30,12 @@ class DrawingProgram;
class DrawingProgramLayerListItem;
struct DrawingProgramCacheBVHNode;
enum class CanvasComponentEraseDetailResult {
NO_CHANGE,
CHANGED,
REMOVED
};
class CanvasComponentContainer {
public:
typedef NetworkingObjects::NetObjOrderedList<CanvasComponentContainer> NetList;
@ -83,6 +89,7 @@ class CanvasComponentContainer {
void commit_transform(DrawingProgram& drawP);
void commit_update_dont_invalidate_cache(DrawingProgram& drawP); // Must be thread safe
bool should_draw(const DrawData& drawData) const;
CanvasComponentEraseDetailResult collides_with_erase_detail(const CoordSpaceHelper& camCoords, const SkPath& checkAgainstCam) const;
bool collides_with(const CoordSpaceHelper& camCoords, const SkPath& checkAgainstCam) const;
bool collides_with_point(const CoordSpaceHelper& camCoords, const Vector2f& checkAgainstCam) const;
void send_comp_update(DrawingProgram& drawP, bool finalUpdate);

View file

@ -23,6 +23,7 @@
#include <limits>
#include <Eigen/Geometry>
#include "BrushComponentCode.hpp"
#include "CanvasComponent.hpp"
#include "Eigen/Core"
#include "Helpers/MathExtras.hpp"
#include "../TimePoint.hpp"
@ -263,6 +264,24 @@ bool MeshCanvasComponent::should_draw_extra(const DrawData& drawData, const Coor
//return collisionTree.is_collide(viewGenerousColliderInObjSpace);
}
bool MeshCanvasComponent::can_erase_detail() const {
return true;
}
CanvasComponentEraseDetailResult MeshCanvasComponent::erase_detail(const SkPath& eraseAgainst) {
bool intersectsAABB = d.meshPath.getBounds().intersects(eraseAgainst.getBounds());
if(!intersectsAABB)
return CanvasComponentEraseDetailResult::NO_CHANGE;
std::optional<SkPath> newPath = Op(d.meshPath, eraseAgainst, SkPathOp::kDifference_SkPathOp);
if(newPath.has_value()) {
d.meshPath = newPath.value();
if(d.meshPath.isEmpty())
return CanvasComponentEraseDetailResult::REMOVED;
return CanvasComponentEraseDetailResult::CHANGED;
}
return CanvasComponentEraseDetailResult::NO_CHANGE;
}
SCollision::AABB<float> MeshCanvasComponent::get_obj_coord_bounds() const {
return d.meshPath.getBounds();
}

View file

@ -47,6 +47,8 @@ class MeshCanvasComponent : public CanvasComponent {
virtual void initialize_draw_data(DrawingProgram& drawP) override;
virtual bool collides_within_coords_point(const Vector2f& checkAgainst) const override;
virtual bool collides_within_coords_skpath(const SkPath& checkAgainst) const override;
virtual bool can_erase_detail() const override;
virtual CanvasComponentEraseDetailResult erase_detail(const SkPath& eraseAgainst) override;
bool should_draw_extra(const DrawData& drawData, const CoordSpaceHelper& coords) const override;
virtual SCollision::AABB<float> get_obj_coord_bounds() const override;
SCollision::BVHContainer<float> collisionTree;

View file

@ -0,0 +1,53 @@
/*
* InfiniPaint
* Copyright (C) 2025-2026 Yousef Khadadeh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "EditCanvasComponentWorldUndoAction.hpp"
#include "../CanvasComponents/CanvasComponent.hpp"
#include "../World.hpp"
EditCanvasComponentWorldUndoAction::EditCanvasComponentWorldUndoAction(std::unique_ptr<CanvasComponent> initData, WorldUndoManager::UndoObjectID initUndoID):
data(std::move(initData)),
undoID(initUndoID)
{}
std::string EditCanvasComponentWorldUndoAction::get_name() const {
return "Edit Canvas Component";
}
bool EditCanvasComponentWorldUndoAction::undo(WorldUndoManager& undoMan) {
return undo_redo(undoMan);
}
bool EditCanvasComponentWorldUndoAction::redo(WorldUndoManager& undoMan) {
return undo_redo(undoMan);
}
bool EditCanvasComponentWorldUndoAction::undo_redo(WorldUndoManager& undoMan) {
std::optional<NetworkingObjects::NetObjID> toEditID = undoMan.get_netid_from_undoid(undoID);
if(!toEditID.has_value())
return false;
auto objPtr = undoMan.world.netObjMan.get_obj_temporary_ref_from_id<CanvasComponentContainer>(toEditID.value());
std::unique_ptr<CanvasComponent> newData = objPtr->get_comp().get_data_copy();
objPtr->get_comp().set_data_from(*data);
data = std::move(newData);
objPtr->commit_update(undoMan.world.drawProg);
objPtr->send_comp_update(undoMan.world.drawProg, true);
return true;
}
EditCanvasComponentWorldUndoAction::~EditCanvasComponentWorldUndoAction() {}

View file

@ -0,0 +1,33 @@
/*
* InfiniPaint
* Copyright (C) 2025-2026 Yousef Khadadeh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include "../CanvasComponents/CanvasComponentContainer.hpp"
class EditCanvasComponentWorldUndoAction : public WorldUndoAction {
public:
EditCanvasComponentWorldUndoAction(std::unique_ptr<CanvasComponent> initData, WorldUndoManager::UndoObjectID initUndoID);
std::string get_name() const override;
bool undo(WorldUndoManager& undoMan) override;
bool redo(WorldUndoManager& undoMan) override;
bool undo_redo(WorldUndoManager& undoMan);
~EditCanvasComponentWorldUndoAction();
std::unique_ptr<CanvasComponent> data;
WorldUndoManager::UndoObjectID undoID;
};

View file

@ -33,7 +33,8 @@ class ToolConfiguration {
struct EraserToolConfig {
float relativeWidth = 15.0f;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(EraserToolConfig, relativeWidth)
bool eraseDetail = false;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(EraserToolConfig, relativeWidth, eraseDetail)
} eraser;
struct EllipseDrawToolConfig {

View file

@ -31,6 +31,7 @@
#include "EditTools/ImageEditTool.hpp"
#include "EditTools/EllipseDrawEditTool.hpp"
#include "EditTools/MeshEditTool.hpp"
#include "../EditCanvasComponentWorldUndoAction.hpp"
#include "../../GUIStuff/ElementHelpers/TextLabelHelpers.hpp"
@ -214,42 +215,8 @@ void EditTool::switch_tool(DrawingProgramToolType newTool) {
objInfoBeingEdited->obj->commit_update(drawP);
objInfoBeingEdited->obj->send_comp_update(drawP, true);
if(undoAfterEditDone) {
class EditCanvasComponentWorldUndoAction : public WorldUndoAction {
public:
EditCanvasComponentWorldUndoAction(std::unique_ptr<CanvasComponent> initData, WorldUndoManager::UndoObjectID initUndoID):
data(std::move(initData)),
undoID(initUndoID)
{}
std::string get_name() const override {
return "Edit Canvas Component";
}
bool undo(WorldUndoManager& undoMan) override {
return undo_redo(undoMan);
}
bool redo(WorldUndoManager& undoMan) override {
return undo_redo(undoMan);
}
bool undo_redo(WorldUndoManager& undoMan) {
std::optional<NetworkingObjects::NetObjID> toEditID = undoMan.get_netid_from_undoid(undoID);
if(!toEditID.has_value())
return false;
auto objPtr = undoMan.world.netObjMan.get_obj_temporary_ref_from_id<CanvasComponentContainer>(toEditID.value());
std::unique_ptr<CanvasComponent> newData = objPtr->get_comp().get_data_copy();
objPtr->get_comp().set_data_from(*data);
data = std::move(newData);
objPtr->commit_update(undoMan.world.drawProg);
objPtr->send_comp_update(undoMan.world.drawProg, true);
return true;
}
~EditCanvasComponentWorldUndoAction() {}
std::unique_ptr<CanvasComponent> data;
WorldUndoManager::UndoObjectID undoID;
};
if(undoAfterEditDone)
drawP.world.undo.push(std::make_unique<EditCanvasComponentWorldUndoAction>(std::move(oldData), drawP.world.undo.get_undoid_from_netid(objInfoBeingEdited->obj.get_net_id())));
}
oldData = nullptr;
objInfoBeingEdited = nullptr;

View file

@ -23,9 +23,11 @@
#include <include/core/SkBlendMode.h>
#include <include/core/SkPathBuilder.h>
#include "../../CanvasComponents/BrushComponentCode.hpp"
#include "../EditCanvasComponentWorldUndoAction.hpp"
#include "../../GUIStuff/ElementHelpers/TextLabelHelpers.hpp"
#include "../../GUIStuff/ElementHelpers/RadioButtonHelpers.hpp"
#include "../../GUIStuff/ElementHelpers/CheckBoxHelpers.hpp"
EraserTool::EraserTool(DrawingProgram& initDrawP):
DrawingProgramToolBase(initDrawP)
@ -48,6 +50,7 @@ void EraserTool::gui_toolbox(Toolbar& t) {
{"Layer being edited", DrawingProgramLayerManager::LayerSelector::LAYER_BEING_EDITED},
{"All visible layers", DrawingProgramLayerManager::LayerSelector::ALL_VISIBLE_LAYERS}
});
checkbox_boolean_field(gui, "erase details", "Erase details", &drawP.world.main.toolConfig.eraser.eraseDetail);
});
}
@ -64,6 +67,7 @@ void EraserTool::gui_phone_toolbox(PhoneDrawingProgramScreen& t) {
{"Layer being edited", DrawingProgramLayerManager::LayerSelector::LAYER_BEING_EDITED},
{"All visible layers", DrawingProgramLayerManager::LayerSelector::ALL_VISIBLE_LAYERS}
});
checkbox_boolean_field(gui, "erase details", "Erase details", &drawP.world.main.toolConfig.eraser.eraseDetail);
});
}
@ -98,6 +102,11 @@ void EraserTool::erase_between_points() {
drawP.drawCache.traverse_bvh_run_function_starting_at_node_no_collision_check(bvhNode, [&](const auto& bvhNodeChild) {
drawP.drawCache.node_loop_erase_if_components(bvhNodeChild, [&](auto c) {
if(drawP.layerMan.component_passes_layer_selector(c, drawP.controls.layerSelector)) {
auto it = updatedComponents.find(c);
if(it != updatedComponents.end()) {
c->obj->get_comp().set_data_from(*(it->second));
updatedComponents.erase(it);
}
erasedComponents.emplace(c);
return true;
}
@ -108,10 +117,37 @@ void EraserTool::erase_between_points() {
return false;
}
drawP.drawCache.node_loop_erase_if_components(bvhNode, [&](auto c) {
if(drawP.layerMan.component_passes_layer_selector(c, drawP.controls.layerSelector) && c->obj->collides_with(drawP.world.drawData.cam.c, erasePath)) {
erasedComponents.emplace(c);
drawP.drawCache.invalidate_cache_at_optional_aabb(c->obj->get_world_bounds());
return true;
if(drawP.world.main.toolConfig.eraser.eraseDetail) {
if(drawP.layerMan.component_passes_layer_selector(c, drawP.controls.layerSelector)) {
auto dataCopy = c->obj->get_comp().get_data_copy();
CanvasComponentEraseDetailResult result = c->obj->collides_with_erase_detail(drawP.world.drawData.cam.c, erasePath);
switch(result) {
case CanvasComponentEraseDetailResult::NO_CHANGE:
return false;
case CanvasComponentEraseDetailResult::CHANGED: {
if(!updatedComponents.contains(c))
updatedComponents.emplace(c, std::move(dataCopy));
c->obj->commit_update(drawP);
return false;
}
case CanvasComponentEraseDetailResult::REMOVED:
auto it = updatedComponents.find(c);
if(it != updatedComponents.end()) {
c->obj->get_comp().set_data_from(*(it->second));
updatedComponents.erase(it);
}
erasedComponents.emplace(c);
drawP.drawCache.invalidate_cache_at_optional_aabb(c->obj->get_world_bounds());
return true;
}
}
}
else {
if(drawP.layerMan.component_passes_layer_selector(c, drawP.controls.layerSelector) && c->obj->collides_with(drawP.world.drawData.cam.c, erasePath)) {
erasedComponents.emplace(c);
drawP.drawCache.invalidate_cache_at_optional_aabb(c->obj->get_world_bounds());
return true;
}
}
return false;
});
@ -121,10 +157,23 @@ void EraserTool::erase_between_points() {
void EraserTool::erase_component(CanvasComponentContainer::ObjInfo* erasedComp) {
erasedComponents.erase(erasedComp);
updatedComponents.erase(erasedComp);
}
void EraserTool::switch_tool(DrawingProgramToolType newTool) {
drawP.layerMan.erase_component_container(erasedComponents);
bool first = true;
for(auto& [comp, oldData] : updatedComponents) {
comp->obj->send_comp_update(drawP, true);
if(erasedComponents.empty() && first) {
first = false;
drawP.world.undo.push(std::make_unique<EditCanvasComponentWorldUndoAction>(std::move(oldData), drawP.world.undo.get_undoid_from_netid(comp->obj.get_net_id())));
}
else
drawP.world.undo.push_on_last(std::make_unique<EditCanvasComponentWorldUndoAction>(std::move(oldData), drawP.world.undo.get_undoid_from_netid(comp->obj.get_net_id())));
}
erasedComponents.clear();
updatedComponents.clear();
isErasing = false;
}

View file

@ -38,6 +38,7 @@ class EraserTool : public DrawingProgramToolBase {
virtual void input_mouse_button_on_canvas_callback(const InputManager::MouseButtonCallbackArgs& button) override;
virtual void input_mouse_motion_callback(const InputManager::MouseMotionCallbackArgs& motion) override;
std::unordered_map<CanvasComponentContainer::ObjInfo*, std::unique_ptr<CanvasComponent>> updatedComponents;
std::unordered_set<CanvasComponentContainer::ObjInfo*> erasedComponents; // Pointers will be erased from this set if theyre erased in the main list (done by callback)
private:
SkPath erasePath;

View file

@ -36,15 +36,24 @@ bool WorldUndoManager::can_redo() {
}
void WorldUndoManager::push(std::unique_ptr<WorldUndoAction> undoAction) {
push_undo(std::move(undoAction));
std::vector<std::unique_ptr<WorldUndoAction>> fullUndoAction;
fullUndoAction.emplace_back(std::move(undoAction));
push_undo(std::move(fullUndoAction));
redoQueue.clear();
world.set_has_unsaved_local_changes(true);
}
void WorldUndoManager::push_undo(std::unique_ptr<WorldUndoAction> undoAction) {
void WorldUndoManager::push_on_last(std::unique_ptr<WorldUndoAction> undoAction) {
if(undoQueue.empty())
push(std::move(undoAction));
else
undoQueue.back().emplace_back(std::move(undoAction));
}
void WorldUndoManager::push_undo(std::vector<std::unique_ptr<WorldUndoAction>> undoAction) {
if(undoQueue.size() == UNDO_QUEUE_LIMIT) {
if(undoActionSavedAt.has_value()) {
if(undoQueue.front().get() == undoActionSavedAt.value())
if(undoQueue.front().front().get() == undoActionSavedAt.value())
undoActionSavedAt = nullptr;
else if(undoActionSavedAt.value() == nullptr)
undoActionSavedAt = std::nullopt;
@ -54,7 +63,7 @@ void WorldUndoManager::push_undo(std::unique_ptr<WorldUndoAction> undoAction) {
undoQueue.emplace_back(std::move(undoAction));
}
void WorldUndoManager::push_redo(std::unique_ptr<WorldUndoAction> undoAction) {
void WorldUndoManager::push_redo(std::vector<std::unique_ptr<WorldUndoAction>> undoAction) {
redoQueue.emplace_back(std::move(undoAction));
}
@ -63,8 +72,16 @@ void WorldUndoManager::undo() {
return;
world.bMan.refresh_gui_data();
if(!undoQueue.back()->undo(*this))
bool undoFail = false;
for(auto& u : std::views::reverse(undoQueue.back())) {
if(!u->undo(*this)) {
undoFail = true;
break;
}
}
if(undoFail)
clear();
else {
push_redo(std::move(undoQueue.back()));
@ -82,7 +99,15 @@ void WorldUndoManager::redo() {
world.bMan.refresh_gui_data();
if(!redoQueue.back()->redo(*this))
bool redoFail = false;
for(auto& u : redoQueue.back()) {
if(!u->redo(*this)) {
redoFail = true;
break;
}
}
if(redoFail)
clear();
else {
push_undo(std::move(redoQueue.back()));
@ -101,10 +126,14 @@ void WorldUndoManager::clear() {
}
void WorldUndoManager::scale_up(const WorldScalar& scaleAmount) {
for(auto& u : undoQueue)
u->scale_up(scaleAmount);
for(auto& r : redoQueue)
r->scale_up(scaleAmount);
for(auto& u : undoQueue) {
for(auto& uStep : u)
uStep->scale_up(scaleAmount);
}
for(auto& r : redoQueue) {
for(auto& uStep : r)
uStep->scale_up(scaleAmount);
}
}
void WorldUndoManager::reassign_netid(const NetworkingObjects::NetObjID& oldNetObjID, const NetworkingObjects::NetObjID& newNetObjID) {
@ -177,7 +206,7 @@ bool WorldUndoManager::fill_netid_list_from_undoid_list(std::vector<NetworkingOb
std::vector<std::string> WorldUndoManager::get_front_undo_queue_names(unsigned count) {
std::vector<std::string> toRet;
for(auto& u : undoQueue | std::views::reverse) {
toRet.emplace_back(u->get_name());
toRet.emplace_back(u.front()->get_name());
if(toRet.size() == count)
return toRet;
}
@ -188,7 +217,7 @@ void WorldUndoManager::set_save_action() {
if(undoQueue.empty())
undoActionSavedAt = nullptr;
else
undoActionSavedAt = undoQueue.back().get();
undoActionSavedAt = undoQueue.back().front().get();
world.set_has_unsaved_local_changes(false);
}
@ -203,6 +232,6 @@ void WorldUndoManager::set_world_has_unsaved_local_changes() {
if(undoQueue.empty())
world.set_has_unsaved_local_changes(undoActionSavedAt.value() != nullptr);
else
world.set_has_unsaved_local_changes(undoActionSavedAt.value() != undoQueue.back().get());
world.set_has_unsaved_local_changes(undoActionSavedAt.value() != undoQueue.back().front().get());
}
}

View file

@ -40,6 +40,7 @@ class WorldUndoManager {
WorldUndoManager(World& initWorld);
void push(std::unique_ptr<WorldUndoAction> undoAction);
void push_on_last(std::unique_ptr<WorldUndoAction> undoAction);
void undo();
void redo();
void clear();
@ -60,14 +61,14 @@ class WorldUndoManager {
World& world;
private:
void push_undo(std::unique_ptr<WorldUndoAction> undoAction);
void push_redo(std::unique_ptr<WorldUndoAction> undoAction);
void push_undo(std::vector<std::unique_ptr<WorldUndoAction>> undoAction);
void push_redo(std::vector<std::unique_ptr<WorldUndoAction>> undoAction);
constexpr static size_t UNDO_QUEUE_LIMIT = 250;
std::unordered_map<UndoObjectID, NetworkingObjects::NetObjID> undoIDToNetID;
std::unordered_map<NetworkingObjects::NetObjID, UndoObjectID> netIDToUndoID;
std::deque<std::unique_ptr<WorldUndoAction>> undoQueue;
std::deque<std::unique_ptr<WorldUndoAction>> redoQueue;
std::deque<std::vector<std::unique_ptr<WorldUndoAction>>> undoQueue;
std::deque<std::vector<std::unique_ptr<WorldUndoAction>>> redoQueue;
std::optional<WorldUndoAction*> undoActionSavedAt = nullptr;