XEditorCopyScriptTag = "--[[HGE place script 2.0]]" if FirstLoad then XEditorUndo = false EditorMapDirty = false EditorDirtyObjects = false EditorPasteInProgress = false EditorUndoPreserveHandles = false end local function init_undo() XEditorUndo = XEditorUndoQueue:new() SetEditorMapDirty(false) end OnMsg.ChangeMap = init_undo OnMsg.LoadGame = init_undo function OnMsg.SaveMapDone() SetEditorMapDirty(false) end function SetEditorMapDirty(dirty) EditorMapDirty = dirty if dirty then Msg("EditorMapDirty") end end local s_IsEditorObjectOperation = { ["EditorCallbackMove"] = true, ["EditorCallbackRotate"] = true, ["EditorCallbackScale"] = true, ["EditorCallbackClone"] = true, } function OnMsg.EditorCallback(id, objects) if s_IsEditorObjectOperation[id] then Msg("EditorObjectOperation", false, objects) end end -- the following object data keys are undo-related and not actual object properties local special_props = { __undo_handle = true, class = true, op = true, after = true, eFlags = true, gFlags = true } local ef_to_restore = const.efVisible | const.efCollision | const.efApplyToGrids local gf_to_restore = const.gofPermanent | const.gofMirrored local ef_to_ignore = const.efSelectable | const.efAudible local gf_to_ignore = const.gofEditorHighlight | const.gofSolidShadow | const.gofRealTimeAnim | const.gofEditorSelection | const.gofAnimated DefineClass.XEditorUndoQueue = { __parents = { "InitDone" }, last_handle = 0, obj_to_handle = false, handle_to_obj = false, handle_remap = false, -- when pasting, store old_handle => new_handle for each pasted object here current_op = false, tracked_obj_data = false, collapse_with_previous = false, op_depth = 0, undo_queue = false, undo_index = 0, last_save_undo_index = 0, names_index = 1, names_to_queue_idx_map = false, watch_thread = false, undoredo_in_progress = false, update_collections_thread = false, } function XEditorUndoQueue:Init() self.obj_to_handle = {} self.handle_to_obj = {} self.undo_queue = {} self.names_to_queue_idx_map = {} self.watch_thread = CreateRealTimeThread(function() while true do while self.op_depth == 0 or terminal.desktop:GetMouseCapture() do Sleep(250) end --assert(false, "Undo error detected - please report this and the last thing you did in the editor!") self.op_depth = 0 Sleep(250) end end) end function XEditorUndoQueue:Done() DeleteThread(self.watch_thread) end ----- Handles function XEditorUndoQueue:GetUndoRedoHandle(obj) assert(type(obj) == "table" and (obj.class or obj.Index)) local handle = self.obj_to_handle[obj] if not handle then handle = self.last_handle + 1 self.last_handle = handle self.obj_to_handle[obj] = handle self.handle_to_obj[handle] = obj end return handle end function XEditorUndoQueue:GetUndoRedoObject(handle, is_collection, assign_specific_object) if not handle then return false end -- support for pasting objects local obj = self.handle_to_obj[handle] if self.handle_remap then local new_handle = self.handle_remap[handle] if new_handle then return self.handle_to_obj[new_handle] else new_handle = assign_specific_object and self.obj_to_handle[assign_specific_object] or self.last_handle + 1 self.handle_remap[handle] = new_handle handle = new_handle self.last_handle = Max(self.last_handle, handle) obj = nil end end if not obj then obj = assign_specific_object or {} self.handle_to_obj[handle] = obj self.obj_to_handle[obj] = handle if is_collection then Collection.SetIndex(obj, -1) end end return obj end function XEditorUndoQueue:UndoRedoHandleClear(handle) handle = self.handle_remap and self.handle_remap[handle] or handle local obj = self.handle_to_obj[handle] self.handle_to_obj[handle] = nil self.obj_to_handle[obj] = nil end ----- Storing/restoring object properties local function store_objects_prop(value) if not value then return false end local ret = {} for k, v in pairs(value) do ret[k] = IsValid(v) and XEditorUndo:GetUndoRedoHandle(v) or store_objects_prop(v) end return ret end local function restore_objects_prop(value) if not value then return false end local ret = {} for k, v in pairs(value) do ret[k] = type(v) == "table" and restore_objects_prop(v) or XEditorUndo:GetUndoRedoObject(v) end return ret end function XEditorUndoQueue:ProcessPropertyValue(obj, id, prop_meta, value) local editor = prop_meta.editor if id == "CollectionIndex" then return self:GetUndoRedoHandle(obj:GetCollection()) elseif editor == "objects" then return store_objects_prop(value) elseif editor == "object" then return self:GetUndoRedoHandle(value) elseif editor == "nested_list" then local ret = value and {} for i, o in ipairs(value) do ret[i] = o:Clone() end return ret elseif editor == "nested_obj" or editor == "script" then return value and value:Clone() elseif editor == "grid" and value then return value:clone() else return value end end function XEditorUndoQueue:GetObjectData(obj) local data = { __undo_handle = self:GetUndoRedoHandle(obj), class = obj.class } for _, prop_meta in ipairs(obj:GetProperties()) do local id = prop_meta.id assert(not special_props[id]) local value = obj:GetProperty(id) if (EditorUndoPreserveHandles and id == "Handle") or not obj:ShouldCleanPropForSave(id, prop_meta, value) then data[id] = self:ProcessPropertyValue(obj, id, prop_meta, value) end end data.eFlags = band(obj:GetEnumFlags(), ef_to_restore) data.gFlags = band(obj:GetGameFlags(), gf_to_restore) return data end local function get_flags_xor(flags1, flags2, flagsList) local result = {} for i, flag in pairs(flagsList) do if flag ~= "gofDirtyTransform" and flag ~= "gofDirtyVisuals" and flag ~= "gofEditorSelection" then if band(flags1, shift(1, i - 1)) ~= band(flags2, shift(1, i - 1)) then table.insert(result, flag.name or flag) end end end return table.concat(result, ", ") end function XEditorUndoQueue:RestoreObject(obj, obj_data, prev_data) if not IsValid(obj) then return end assert(obj.class ~= "CollectionsToHideContainer") for _, prop_meta in ipairs(obj:GetProperties()) do local id = prop_meta.id local value = obj_data[id] if value == nil and prev_data and prev_data[id] then value = obj:GetDefaultPropertyValue(id, prop_meta) end if value ~= nil then local prop = prop_meta.editor if id == "CollectionIndex" then if value == 0 then CObject.SetCollectionIndex(obj, 0) else local collection = self:GetUndoRedoObject(value, "Collection") if obj_data.class == "Collection" and collection.Index == editor.GetLockedCollectionIdx() then editor.AddToLockedCollectionIdx(obj.Index) end CObject.SetCollectionIndex(obj, collection.Index) end elseif prop == "objects" then obj:SetProperty(id, restore_objects_prop(value)) elseif prop == "object" then obj:SetProperty(id, self:GetUndoRedoObject(value)) elseif prop == "nested_list" then local objects = {} for i, o in ipairs(value) do objects[i] = o:Clone() end obj:SetProperty(id, value and objects) elseif prop == "nested_obj" then obj:SetProperty(id, value and value:Clone()) elseif id == "Handle" then if EditorUndoPreserveHandles and not EditorPasteInProgress then -- resolve handle collisions, e.g. from multiple applied map patches local start, size = GetHandlesAutoLimits() while HandleToObject[value] do value = value + 1 if value >= start + size then value = start end end obj:SetProperty(id, value) end else obj:SetProperty(id, value) end end end if obj_data.eFlags then obj:SetEnumFlags(obj_data.eFlags) obj:ClearEnumFlags(band(bnot(obj_data.eFlags), ef_to_restore)) obj:SetGameFlags(obj_data.gFlags) obj:ClearGameFlags(band(bnot(obj_data.gFlags), gf_to_restore)) obj:ClearGameFlags(const.gofEditorHighlight) end return obj end ----- Undo/redo operations -- -- Capturing undo data works using the concept of tracked objects. Start capturing an undo operation -- by calling BeginOp; complete it with EndOp; in-between add extra tracked objects via StartTracking. -- -- Objects are assigned "undo handles" to keep their identity between undo & redo operations that might -- delete them. The tracked objects' initial states are kept by handle in 'tracked_obj_data'. For newly -- created objects the value kept will be 'false'. -- -- Complex objects such as Volumes/Rooms are handles via the concept of "children" objects (e.g. Slab). -- Whenever the an object is tracked, we get related objects via GetEditorRelatedObjects/GetEditorParentObject. -- The state of those object also get tracked automatically. -- -- BeginOp takes a table of settings to provide it with information about what needs to be tracked: -- 1. Pass a list of objects in the "objects" field. -- 2. Mark any grid that will be changed as a "true" field in settings, e.g. { terrain_type = true }. -- 3. Pass the operation name for the list of operations combo as e.g. { name = "Deleted objects" }. -- -- EndOp only takes a list of extra objects to be tracked - usually newly created objects during the operation. -- -- BeginOp/EndOp calls can be nested - a new undo operation is created and pushed into the undo queue when -- the last EndOp call balances out with BeginOp calls. This allows for easy tracking of editor operations -- that use other operations to complete, or merging different editor operations into a single one. -- -- The editor's copy/paste & map patching funcionalities uses the same mechanism for capturing/storing objects. -- When pasting or applying a patch, newly created objects are assigned new handles via a handle remapping -- mechanism to prevent collisions with existing handles (see handle_remap member). -- -- The 'data' member of ObjectsEditOp is a single table with entries for each affected object in order: -- { op = "delete", __undo_handle = 1, ... }, -- { op = "create", __undo_handle = 1, ... }, -- { op = "update", __undo_handle = 1, after = { ... }, ... }, local function add_child_objects(objects, method, param) local added = {} for _, obj in ipairs(objects) do added[obj] = true end for _, obj in ipairs(objects) do for _, related in ipairs(obj[method or "GetEditorRelatedObjects"](obj, param)) do if IsValid(related) and not added[related] then objects[#objects + 1] = related added[related] = true end end end end local function add_parent_objects(objects, for_copy, locked_collection) local added = {} for _, obj in ipairs(objects) do added[obj] = true end local i = 1 while i <= #objects do local obj = objects[i] local parent = obj:GetEditorParentObject() if not for_copy and IsValid(parent) and not added[parent] then objects[#objects + 1] = parent added[parent] = true end local collection = obj:GetCollection() if IsValid(collection) and collection ~= locked_collection and not added[collection] then objects[#objects + 1] = collection added[collection] = true end i = i + 1 end end function XEditorUndoQueue:TrackInternal(objects, idx, created) local data = self.tracked_obj_data assert(data) -- tracking an object is only possible after :BeginOp is called to create an undo operation if not data then return end for i = idx, #objects do local obj = objects[i] local handle = self:GetUndoRedoHandle(obj) if data[handle] == nil then data[handle] = not created and self:GetObjectData(obj) end end end function XEditorUndoQueue:StartTracking(objects, created, omit_children) objects = table.copy_valid(objects) for idx, obj in ipairs(objects) do assert(obj.class ~= "CollectionsToHideContainer") end if #objects == 0 then return end if not omit_children then add_child_objects(objects) end self:TrackInternal(objects, 1, created) local start_idx = #objects + 1 add_parent_objects(objects) self:TrackInternal(objects, start_idx) -- non-explicit parents are assumed to have existed before the operation Msg("EditorObjectOperation", false, objects) EditorDirtyObjects = table.union(objects, table.validate(EditorDirtyObjects)) end function XEditorUndoQueue:BeginOp(settings) if self.undoredo_in_progress then return end settings = settings or empty_table self.current_op = self.current_op or { clipboard = settings.clipboard } self.tracked_obj_data = self.tracked_obj_data or {} self.op_depth = self.op_depth + 1 if self.op_depth == 1 then self.collapse_with_previous = settings.collapse_with_previous EditorDirtyObjects = empty_table end PauseInfiniteLoopDetection("Undo") if settings.objects then self:StartTracking(settings.objects) end -- store the "before" state of selection and edited grids local op = self.current_op if not op.selection then op.selection = SelectionEditOp:new() for i, obj in ipairs(editor.GetSel()) do op.selection.before[i] = self:GetUndoRedoHandle(obj) end end for _, grid in ipairs(editor.GetGridNames()) do if settings[grid] and not op[grid] then op[grid] = GridEditOp:new{ name = grid, before = editor.GetGrid(grid) } end end op.name = op.name or settings.name ResumeInfiniteLoopDetection("Undo") end -- collections must be at the front of undo data; collections need to be created first -- and allocate/restore their indexes before objects are added to them via SetCollection local function add_obj_data(data, obj_data) if obj_data then if obj_data.class == "Collection" then table.insert(data, 1, obj_data) else data[#data + 1] = obj_data end end end local function is_nop(obj_data) local after = obj_data.after for k, v in pairs(after) do if not special_props[k] and not CompareValues(obj_data[k], v) then return false end end for k in pairs(obj_data) do if after[k] == nil then return false end end return true end function XEditorUndoQueue:OpCaptureInProgress() return self.op_depth > 0 end function XEditorUndoQueue:AssertOpCapture() return not IsEditorActive() or IsChangingMap() or XEditorUndo.undoredo_in_progress or XEditorUndo:OpCaptureInProgress() end function XEditorUndoQueue:EndOpInternal(objects, bbox) assert(self:OpCaptureInProgress(), "Unbalanced calls between BeginOp and EndOp") if not self:OpCaptureInProgress() then return end PauseInfiniteLoopDetection("Undo") if objects then self:StartTracking(objects, "created") end -- messages for final cleanup when an editor operation involving objects ends if self.op_depth == 1 then -- keeping op_depth == 1 at this point prevents an infinite loop if the Msgs invoke undo ops if next(self.tracked_obj_data) then Msg("EditorObjectOperation", true, table.validate(EditorDirtyObjects)) Msg("EditorObjectOperationEnding") end EditorDirtyObjects = false end self.op_depth = self.op_depth - 1 -- finalize operation when the BeginOp/EndOp calls become balanced if self.op_depth == 0 then local edit_operation = self.current_op -- drop selection op if selection is the same if edit_operation.selection then local selDiff = #editor.GetSel() ~= #edit_operation.selection.before for i, obj in ipairs(editor.GetSel()) do edit_operation.selection.after[i] = self:GetUndoRedoHandle(obj) if edit_operation.selection.after[i] ~= edit_operation.selection.before[i] then selDiff = true end end if not selDiff then edit_operation.selection:delete() edit_operation.selection = nil end end -- calculate grid diffs for _, grid in ipairs(editor.GetGridNames()) do local grid_op = edit_operation[grid] if grid_op then local before, after = grid_op.before, editor.GetGrid(grid) -- Find the boxes where there are differences between the two grids and save them in the op's array part local diff_boxes = editor.GetGridDifferenceBoxes(grid, after, before, bbox) if diff_boxes then for idx, box in ipairs(diff_boxes) do local change = { box = box, before = editor.GetGrid(grid, box, before), after = editor.GetGrid(grid, box, after), } table.insert(grid_op, change) end end before:free() after:free() grid_op.before = nil end end -- capture the "after" data and create the object undo operation self.handle_remap = nil if next(self.tracked_obj_data) then local data = {} for handle, obj_data in sorted_pairs(self.tracked_obj_data) do local obj = self.handle_to_obj[handle] if obj_data then if IsValid(obj) then obj_data.after = self:GetObjectData(obj) obj_data.op = "update" if is_nop(obj_data) then obj_data = nil end else if self.handle_to_obj[handle] then self:UndoRedoHandleClear(handle) end obj_data.op = "delete" end elseif IsValid(obj) then obj_data = self:GetObjectData(obj) obj_data.op = "create" end add_obj_data(data, obj_data) end edit_operation.objects = ObjectsEditOp:new{ data = data } end self.current_op = false self.tracked_obj_data = false ResumeInfiniteLoopDetection("Undo") return edit_operation end ResumeInfiniteLoopDetection("Undo") end function XEditorUndoQueue:EndOp(objects, bbox) if self.undoredo_in_progress then return end local edit_operation = self:EndOpInternal(objects, bbox) if edit_operation then self:AddEditOp(edit_operation) if self.collapse_with_previous and self:CanMergeOps(self.undo_index - 1, self.undo_index, "same_names") then self:MergeOps(self.undo_index - 1, self.undo_index) end self.collapse_with_previous = false self:UpdateOnOperationEnd(edit_operation) end end function XEditorUndoQueue:AddEditOp(edit_operation) self.undo_index = self.undo_index + 1 self.undo_queue[self.undo_index] = edit_operation for i = self.undo_index + 1, #self.undo_queue do self.undo_queue[i] = nil end end local allowed_keys = { name = true, objects = true } function XEditorUndoQueue:CanMergeOps(idx1, idx2, same_names) if idx1 < 0 then return end local name = same_names and self.undo_queue[idx1].name for idx = idx1, idx2 do local edit_op = self.undo_queue[idx] for k in pairs(edit_op) do if not allowed_keys[k] then return end end if name and edit_op.name ~= name then return end end return true end function XEditorUndoQueue:MergeOps(idx1, idx2, name) local before, after = {}, {} -- these store object data by handle, just like in tracked_obj_data for idx = idx1, idx2 do local edit_op = self.undo_queue[idx] local objs_data = edit_op and edit_op.objects and edit_op.objects.data for _, obj_data in ipairs(objs_data) do local op = obj_data.op local handle = obj_data.__undo_handle if before[handle] == nil then before[handle] = op ~= "create" and obj_data or false end after[handle] = op == "create" and obj_data or op == "update" and obj_data.after or false end end local data = {} for handle, obj_data in sorted_pairs(before) do if not obj_data then obj_data = after[handle] if obj_data then obj_data.op = "create" end elseif after[handle] then obj_data.after = after[handle] obj_data.op = "update" else obj_data.op = "delete" end add_obj_data(data, obj_data) end name = name or self.undo_queue[idx1].name for idx = idx1, #self.undo_queue do self.undo_queue[idx] = nil end table.insert(self.undo_queue, { name = name, objects = ObjectsEditOp:new{ data = data }}) self.undo_index = idx1 end function XEditorUndoQueue:UndoRedo(op_type, update_map_hashes) local undo = op_type == "undo" local edit_op = undo and self.undo_queue[self.undo_index] or self.undo_queue[self.undo_index + 1] if not edit_op then return end self.undo_index = undo and self.undo_index - 1 or self.undo_index + 1 if self.undo_index < 0 or self.undo_index > #self.undo_queue then self.undo_index = Clamp(self.undo_index, 0, #self.undo_queue) return end self.undoredo_in_progress = true SuspendPassEditsForEditOp(edit_op.objects and edit_op.objects.data or empty_table) PauseInfiniteLoopDetection("XEditorEditOps") SuspendObjModified("XEditorEditOps") for _, op in sorted_pairs(edit_op) do if IsKindOf(op, "EditOp") then procall(undo and op.Undo or op.Do, op) if update_map_hashes then op:UpdateMapHashes() end end end if edit_op.clipboard then CopyToClipboard(edit_op.clipboard) end self:UpdateOnOperationEnd(edit_op) ResumeObjModified("XEditorEditOps") ResumeInfiniteLoopDetection("XEditorEditOps") ResumePassEditsForEditOp() self.undoredo_in_progress = false end function XEditorUndoQueue:UpdateOnOperationEnd(edit_op) for key in pairs(edit_op) do if key ~= "selection" and key ~= "clipboard" then SetEditorMapDirty(true) end end XEditorUpdateToolbars() -- doesn't update the toolbar if it was updated soon -- these are okay to be delayed by 1 sec. if edit_op.objects and not self.update_collections_thread then self.update_collections_thread = CreateRealTimeThread(function() Sleep(1000) UpdateCollectionsEditor() self.update_collections_thread = false end) end end ----- Editor statusbar combo function XEditorUndoQueue:GetOpNames(plain) local names = { "No operations" } local idx_map = { 0 } local cur_op_passed, cur_op_idx = false, false for i = 1, #self.undo_queue do local cur = self.undo_queue[i] and self.undo_queue[i].name cur_op_passed = cur_op_passed or i == self.undo_index + 1 if cur then local prev = names[#names] if prev and string.ends_with(prev, cur) and not cur_op_passed then local n = (tonumber(string.match(prev, "%s(%d+)[^%s%d]")) or 1) + 1 cur = string.format("%d. %dX %s", #names - 1, n, cur) names[#names] = cur idx_map[#idx_map] = i else if cur_op_passed then cur_op_idx = #idx_map cur_op_passed = false end table.insert(names, string.format("%d. %s", #names, cur)) table.insert(idx_map, i) end end end if not plain then self.names_to_queue_idx_map = idx_map self.names_index = cur_op_idx or Max(#idx_map, 1) for i = self.names_index + 1, #names do names[i] = "" .. names[i] .. "" end end return names end function XEditorUndoQueue:GetCurrentOpNameIdx() return self.names_index end function XEditorUndoQueue:RollToOpIndex(new_index) if new_index ~= self.names_index then local new_undo_index = self.names_to_queue_idx_map[new_index] local op = self.undo_index > new_undo_index and "undo" or "redo" while self.undo_index ~= new_undo_index do self:UndoRedo(op) end self.names_index = new_index end end ----- EditOp classes DefineClass.EditOp = { __parents = { "InitDone" }, StoreAsTable = true, } function EditOp:Do() end function EditOp:Undo() end function EditOp:UpdateMapHashes() end DefineClass.ObjectsEditOp = { __parents = { "EditOp" }, data = false, -- see comments above XEditorUndo:BeginOp for details by_handle = false, } function ObjectsEditOp:GetAffectedObjectsBefore() local ret = {} for _, obj_data in ipairs(self.data) do local op = obj_data.op if op == "delete" or op == "update" then local handle = obj_data.__undo_handle table.insert(ret, XEditorUndo:GetUndoRedoObject(handle)) end end return ret end function ObjectsEditOp:GetAffectedObjectsAfter() local ret = {} for _, obj_data in ipairs(self.data) do local op = obj_data.op if op == "create" or op == "update" then local handle = obj_data.__undo_handle table.insert(ret, XEditorUndo:GetUndoRedoObject(handle)) end end return ret end function ObjectsEditOp:EditorCallbackPreUndoRedo() local objs = {} for _, obj_data in ipairs(self.data) do table.insert(objs, XEditorUndo.handle_to_obj[obj_data.__undo_handle]) -- don't use GetUndoRedoObject, it has side effects end Msg("EditorCallbackPreUndoRedo", table.validate(objs)) end function ObjectsEditOp:Do() self:EditorCallbackPreUndoRedo() local newobjs = {} local oldobjs = {} local movedobjs = {} for _, obj_data in ipairs(self.data) do local op = obj_data.op local handle = obj_data.__undo_handle local obj = XEditorUndo:GetUndoRedoObject(handle) if op == "delete" then XEditorUndo:UndoRedoHandleClear(handle) oldobjs[#oldobjs + 1] = obj elseif op == "create" then obj = XEditorPlaceObjectByClass(obj_data.class, obj) XEditorUndo:RestoreObject(obj, obj_data) newobjs[#newobjs + 1] = obj else -- update XEditorUndo:RestoreObject(obj, obj_data.after, obj_data) if obj_data.after and obj_data.Pos ~= obj_data.after.Pos then movedobjs[#movedobjs + 1] = obj end ObjModified(obj) end end for _, obj_data in ipairs(self.data) do if obj_data.op ~= "delete" then local obj = XEditorUndo:GetUndoRedoObject(obj_data.__undo_handle) if IsValid(obj) and obj:HasMember("PostLoad") then obj:PostLoad("undo") end end end Msg("EditorCallback", "EditorCallbackPlace", table.validate(newobjs), "undo") Msg("EditorCallback", "EditorCallbackDelete", table.validate(oldobjs), "undo") Msg("EditorCallback", "EditorCallbackMove", table.validate(movedobjs), "undo") DoneObjects(oldobjs) end function ObjectsEditOp:Undo() self:EditorCallbackPreUndoRedo() local newobjs = {} local oldobjs = {} local movedobjs = {} for _, obj_data in ipairs(self.data) do local op = obj_data.op local handle = obj_data.__undo_handle local obj = XEditorUndo:GetUndoRedoObject(handle) if op == "delete" then obj = XEditorPlaceObjectByClass(obj_data.class, obj) XEditorUndo:RestoreObject(obj, obj_data) newobjs[#newobjs + 1] = obj elseif op == "create" then XEditorUndo:UndoRedoHandleClear(handle) oldobjs[#oldobjs + 1] = obj else -- update XEditorUndo:RestoreObject(obj, obj_data, obj_data.after) if obj_data.after and obj_data.Pos ~= obj_data.after.Pos then movedobjs[#movedobjs + 1] = obj end ObjModified(obj) end end for _, obj_data in ipairs(self.data) do if obj_data.op ~= "create" then local obj = XEditorUndo:GetUndoRedoObject(obj_data.__undo_handle) if IsValid(obj) and obj:HasMember("PostLoad") then obj:PostLoad("undo") end end end Msg("EditorCallback", "EditorCallbackPlace", table.validate(newobjs), "undo") Msg("EditorCallback", "EditorCallbackDelete", table.validate(oldobjs), "undo") Msg("EditorCallback", "EditorCallbackMove", table.validate(movedobjs), "undo") DoneObjects(oldobjs) end function ObjectsEditOp:UpdateMapHashes() local hash = table.hash(self.data) mapdata.ObjectsHash = xxhash(mapdata.ObjectsHash, hash) mapdata.NetHash = xxhash(mapdata.NetHash, hash) end DefineClass.SelectionEditOp = { __parents = { "EditOp" }, before = false, after = false, } function SelectionEditOp:Init() self.before = {} self.after = {} end function SelectionEditOp:Do() editor.SetSel(table.map(self.after, function(handle) return XEditorUndo:GetUndoRedoObject(handle) end)) end function SelectionEditOp:Undo() editor.SetSel(table.map(self.before, function(handle) return XEditorUndo:GetUndoRedoObject(handle) end)) end DefineClass.GridEditOp = { __parents = { "EditOp" }, name = false, before = false, after = false, box = false, } function GridEditOp:Do() for _, change in ipairs(self) do editor.SetGrid(self.name, change.after, change.box) if self.name == "height" then Msg("EditorHeightChanged", true, change.box) end if self.name == "terrain_type" then Msg("EditorTerrainTypeChanged", change.box) end end end function GridEditOp:Undo() for _, change in ipairs(self) do editor.SetGrid(self.name, change.before, change.box) if self.name == "height" then Msg("EditorHeightChanged", true, change.box) end if self.name == "terrain_type" then Msg("EditorTerrainTypeChanged", change.box) end end end function GridEditOp:UpdateMapHashes() if self.name == "height" or self.name == "terrain_type" then for _, change in ipairs(self) do local hash = change.after:hash() mapdata.TerrainHash = xxhash(mapdata.TerrainHash, hash) mapdata.NetHash = xxhash(mapdata.NetHash, hash) end end end ----- Serialization for copy/paste/duplicate function XEditorSerialize(objs, root_collection) local obj_data = {} local org_count = #objs objs = table.copy(objs) add_child_objects(objs) add_parent_objects(objs, "for_copy", root_collection) table.remove_value(objs, root_collection) Msg("EditorPreSerialize", objs) -- some debug functionalities hook up here to clear temporary visualization properties PauseInfiniteLoopDetection("XEditorSerialize") for idx, obj in ipairs(objs) do local data = XEditorUndo:GetObjectData(obj) if obj.class == "Collection" then data.Index = -1 -- force creation of new collections indexes when pasting collections end if obj:GetCollection() == root_collection or XEditorSelectSingleObjects == 1 then data.CollectionIndex = nil -- ignore collection end data.__original_object = idx <= org_count or nil add_obj_data(obj_data, data) end ResumeInfiniteLoopDetection("XEditorSerialize") Msg("EditorPostSerialize", objs) return { obj_data = obj_data } end function XEditorDeserialize(data, root_collection, ...) EditorPasteInProgress = true PauseInfiniteLoopDetection("XEditorPaste") SuspendPassEditsForEditOp(data.obj_data) XEditorUndo:BeginOp() XEditorUndo.handle_remap = {} -- will force the creation of new objects when resolving handles local objs, orig_objs = {}, {} for _, obj_data in ipairs(data.obj_data) do local obj = XEditorUndo:GetUndoRedoObject(obj_data.__undo_handle) obj = XEditorPlaceObjectByClass(obj_data.class, obj) obj = XEditorUndo:RestoreObject(obj, obj_data) if root_collection and not obj:GetCollection() then obj:SetCollection(root_collection) -- paste in the currently locked collection end objs[#objs + 1] = obj if obj_data.__original_object then orig_objs[#orig_objs + 1] = obj end end -- call PostLoad; it sometimes deletes objects (e.g. wires if they are partially unattached) for _, obj in ipairs(objs) do if obj:HasMember("PostLoad") then obj:PostLoad("paste") end end Msg("EditorCallback", "EditorCallbackPlace", table.validate(table.copy(orig_objs)), ...) XEditorUndo:EndOp(table.validate(objs)) ResumePassEditsForEditOp() ResumeInfiniteLoopDetection("XEditorPaste") EditorPasteInProgress = false return orig_objs end function XEditorToClipboardFormat(data) return ValueToLuaCode(data, nil, pstr(XEditorCopyScriptTag, 32768)):str() end function XEditorPaste(lua_code) local err, data = LuaCodeToTuple(lua_code, LuaValueEnv{ GridReadStr = GridReadStr }) if err or type(data) ~= "table" or not data.obj_data then print("Error restoring objects:", err) return end local fn = data.paste_fn or "Default" if not XEditorPasteFuncs[fn] then print("Error restoring objects: invalid paste function ", fn) return end procall(XEditorPasteFuncs[fn], data, lua_code, "paste") end ----- Interface functions for copy/paste/duplicate function XEditorPasteFuncs.Default(data, lua_code, ...) XEditorUndo:BeginOp{ name = "Paste" } local objs = XEditorDeserialize(data, Collection.GetLockedCollection(), ...) local place = editor.GetPlacementPoint(GetTerrainCursor()) local offs = (place:IsValidZ() and place or place:SetTerrainZ()) - data.pivot objs = XEditorSelectAndMoveObjects(objs, offs) XEditorUndo.current_op.name = string.format("Pasted %d objects", #objs) XEditorUndo:EndOp(objs) end function XEditorCopyToClipboard() local objs = editor.GetSel("permanent") local data = XEditorSerialize(objs, Collection.GetLockedCollection()) data.pivot = CenterPointOnBase(objs) CopyToClipboard(XEditorToClipboardFormat(data)) end function XEditorPasteFromClipboard() local lua_code = GetFromClipboard(-1) if lua_code:starts_with(XEditorCopyScriptTag) then XEditorPaste(lua_code) end end function XEditorClone(objs) -- cloned objects from a single selected collection are added to their current collection, as per level designers request local locked_collection = Collection.GetLockedCollection() local single_collection = editor.GetSingleSelectedCollection(objs) if single_collection and #objs < MapCount("map", "collection", single_collection.Index, true) then locked_collection = single_collection end return XEditorDeserialize(XEditorSerialize(objs, locked_collection), locked_collection, "clone") end ----- Map patches (storing and restoring map changes from their undo operations) function OnMsg.SaveMapDone() XEditorUndo.last_save_undo_index = XEditorUndo.undo_index end local function redo_and_capture(name) local op = XEditorUndo.undo_queue[XEditorUndo.undo_index + 1] local affected = { name = name } for key in pairs(op) do if key ~= "name" then affected[key] = true end end if op.objects then affected.objects = op.objects:GetAffectedObjectsBefore() end XEditorUndo:BeginOp(affected) XEditorUndo:UndoRedo("redo", IsChangingMap() and "update_map_hashes") XEditorUndo:EndOp(op.objects and op.objects:GetAffectedObjectsAfter()) end -- TODO: Only save hash_to_handle information for handles that are actually referenced in the patch local function create_combined_patch_edit_op() if XEditorUndo.undo_index <= XEditorUndo.last_save_undo_index then return {} end Msg("OnMapPatchBegin") SuspendPassEditsForEditOp() PauseInfiniteLoopDetection("XEditorCreateMapPatch") -- undo operations back to the last map save local undo_index = XEditorUndo.undo_index while XEditorUndo.undo_index ~= XEditorUndo.last_save_undo_index do XEditorUndo:UndoRedo("undo") end -- store object identifying information (for objects that are to be deleted or modified - all they have undo handles) local hash_to_handle = {} for handle, obj in pairs(XEditorUndo.handle_to_obj) do if IsValid(obj) then assert(not hash_to_handle[obj:GetObjIdentifier()]) -- hash collision, likely the data used to construct the hash is identical hash_to_handle[obj:GetObjIdentifier()] = handle end end -- redo all undo operations, collapsing them into a single one EditorUndoPreserveHandles = true XEditorUndo:BeginOp() for idx = XEditorUndo.undo_index, undo_index - 1 do assert(XEditorUndo.undo_index == idx) redo_and_capture() end ResumeInfiniteLoopDetection("XEditorCreateMapPatch") ResumePassEditsForEditOp() -- get and cleanup the combined operation local edit_op = XEditorUndo:EndOpInternal() local obj_datas = edit_op.objects and edit_op.objects.data or empty_table for idx, obj_data in ipairs(obj_datas) do local op, handle = obj_data.op, obj_data.__undo_handle if op == "delete" then obj_datas[idx] = { op = op, __undo_handle = handle } elseif op == "update" then local after = obj_data.after for k, v in pairs(after) do if not special_props[k] and CompareValues(obj_data[k], v) then after[k] = nil end end obj_datas[idx] = { op = op, __undo_handle = handle, after = obj_data.after } end end edit_op.hash_to_handle = hash_to_handle edit_op.selection = nil assert(XEditorUndo.undo_index == undo_index) EditorUndoPreserveHandles = false Msg("OnMapPatchEnd") return edit_op end function XEditorCreateMapPatch(filename, add_to_svn) local edit_op = create_combined_patch_edit_op() -- serialize this combined operation, along with the object identifiers local str = "return " .. ValueToLuaCode(edit_op, nil, pstr("", 32768)):str() filename = filename or "svnAssets/Bin/win32/Bin/map.patch" local path = SplitPath(filename) AsyncCreatePath(path) local err = AsyncStringToFile(filename, str) if err then print("Failed to write patch file", filename) return end if add_to_svn then SVNAddFile(path) SVNAddFile(filename) end local affected_grids = {} for _, grid in ipairs(editor.GetGridNames()) do if edit_op[grid] then affected_grids[grid] = edit_op[grid].box end end edit_op.compacted_obj_boxes = empty_table if edit_op.objects then local affected_objs = edit_op.objects:GetAffectedObjectsAfter() local obj_box_list = {} for _, obj in ipairs(affected_objs) do assert(IsValid(obj)) if IsValid(obj) then table.insert(obj_box_list, obj:GetObjectBBox()) end end edit_op.compacted_obj_boxes = CompactAABBList(obj_box_list, 4 * guim, "optimize_boxes") end -- return: -- - hashes of changed objects (newly created objects are not included), -- - affected grid boxes -- - a compacted list of the affected objects' bounding boxes (updated and created objects) return (edit_op.hash_to_handle and table.keys(edit_op.hash_to_handle)), affected_grids, edit_op.compacted_obj_boxes end function XEditorApplyMapPatch(filename) filename = filename or "svnAssets/Bin/win32/Bin/map.patch" local func, err = loadfile(filename) if err then print("Failed to load patch", filename) return end local edit_op = func() if not next(edit_op) then return end Msg("OnMapPatchBegin") XEditorUndo.handle_remap = {} -- as with pasting, generate new undo handles for the objects from the patch EditorUndoPreserveHandles = true -- restore object handles that were stored in the patch -- lookup objects to be deleted/modified by their stored identifier hashes local hash_to_handle = edit_op.hash_to_handle MapForEach(true, "attached", false, function(obj) local hash = obj:GetObjIdentifier() local handle = hash_to_handle[hash] if handle then XEditorUndo:GetUndoRedoObject(handle, nil, obj) -- "assign" this object to the handle, via handle_remap end end) -- apply the changes via the "redo" mechanism XEditorUndo:AddEditOp(edit_op) XEditorUndo.undo_index = XEditorUndo.undo_index - 1 redo_and_capture("Applied map patch") -- remove the added edit op and readjust the undo index table.remove(XEditorUndo.undo_queue, XEditorUndo.undo_index - 1) XEditorUndo.undo_index = XEditorUndo.undo_index - 1 EditorUndoPreserveHandles = false MapPatchesApplied = true Msg("OnMapPatchEnd") end ----- Misc function CenterPointOnBase(objs) local minz for _, obj in ipairs(objs) do local pos = obj:GetVisualPos() local z = Max(terrain.GetHeight(pos), pos:z()) if not minz or minz > z then minz = z end end return CenterOfMasses(objs):SetZ(minz) end function XEditorSelectAndMoveObjects(objs, offs) editor.SetSel(objs) SuspendPassEditsForEditOp() objs = editor.SelectionCollapseChildObjects() if const.SlabSizeX and HasAlignedObjs(objs) then -- snap offset to a whole number of voxels, so auto-snapped object don't get displaced local x = offs:x() / const.SlabSizeX * const.SlabSizeX local y = offs:y() / const.SlabSizeY * const.SlabSizeY local z = offs:z() and (offs:z() + const.SlabSizeZ / 2) / const.SlabSizeZ * const.SlabSizeZ or 0 offs = point(x, y, z) end for _, obj in ipairs(objs) do if obj:IsKindOf("AlignedObj") then obj:AlignObj(obj:GetPos() + offs) elseif obj:IsValidPos() then obj:SetPos(obj:GetPos() + offs) end end Msg("EditorCallback", "EditorCallbackMove", objs) ResumePassEditsForEditOp() return objs end -- Makes sure that if a parent object (as per GetEditorParentObject) is in the input list, -- then all children objects are in the output, and vice versa. Used by XAreaCopyTool. function XEditorPropagateParentAndChildObjects(objs) add_parent_objects(objs) add_child_objects(objs) return objs end function XEditorPropagateChildObjects(objs) add_child_objects(objs) return objs end function XEditorCollapseChildObjects(objs) local objset = {} for _, obj in ipairs(objs) do objset[obj] = true end local i, count = 1, #objs while i <= count do local obj = objs[i] if objset[obj:GetEditorParentObject()] then objs[i] = objs[count] objs[count] = nil count = count - 1 else i = i + 1 end end return objs end