From 2a1b8530b71d090533b139659a9f7542b1e56077 Mon Sep 17 00:00:00 2001 From: tsullivan13 <25965236+tsullivan13@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:02:17 -0700 Subject: [PATCH 1/2] Park editor: keep scale and links in multi-object edits; delete selections Fixes in the stock park editor that a multi-object edit (a paste, an undo, a loaded park mod) runs into: - Pasted objects lost their world link. A paste creates its objects one by one and saves them when the whole edit completes, but the placements runtime reconciles after every create and unlinked rows whose id was not saved yet. Every create unlinked the ones before it: only the last pasted object could be selected, moved or deleted, and deleting the others removed them from the layout but left them standing in the world. The reconcile now counts the editor's pending layout as saved. - Scale was lost on paste, undo and park-mod load. The native create recipe has no scale lane, so a created object spawns at 1x. Only the placement preview resized it afterwards; a create step with a scale now becomes a move, verified by the server tick's readback like any move. - Scale was lost on level reload. Restored objects spawn at 1x and the pose poll then saved 1x over the saved scale. Restores with a scale are resized on the next server tick, and the poll leaves them alone until the resize is read back. - Delete did nothing with several objects selected. Delete (key or button) now removes the whole selection as one edit and one undo step. Tests: the transaction fixture's create now spawns at 1x like the game and reconciles after every create like placement_finish_batch; new checks cover pasting a scaled group, every pasted object keeping its link, moving and rotating a pasted group, and multi-delete with undo. Co-Authored-By: Claude Opus 5.5 --- Engine/Game/World/park_editor.h | 5 + .../Test/park_editor_transaction_tests.cpp | 100 +++++++++++++++++- .../ParkEditor/park_editor_preview.cpp | 21 ++++ .../ParkEditor/park_editor_runtime.cpp | 13 +++ .../Objects/ParkEditor/park_editor_runtime.h | 4 + .../Objects/local_placements_runtime.cpp | 38 ++++++- Extension/Objects/local_placements_runtime.h | 10 ++ Extension/Profile/local_profile_runtime.h | 1 + .../UI/Overlay/Test/park_editor_tests.cpp | 23 ++++ Extension/UI/Overlay/overlay.h | 2 + Extension/UI/Overlay/park_editor.cpp | 5 + Extension/UI/Overlay/park_editor_actions.cpp | 17 +++ Extension/UI/Overlay/park_editor_internal.h | 3 + Extension/UI/Overlay/park_editor_panels.cpp | 6 ++ Runtime/runtime_init.cpp | 1 + 15 files changed, 244 insertions(+), 5 deletions(-) diff --git a/Engine/Game/World/park_editor.h b/Engine/Game/World/park_editor.h index c45a6e54..2d982a11 100644 --- a/Engine/Game/World/park_editor.h +++ b/Engine/Game/World/park_editor.h @@ -53,6 +53,11 @@ struct EditorPasteRequest { std::uint64_t generation{}, revision{}; std::vector objects; }; +// Removes several objects in one edit (one undo step), as a paste adds several. +struct EditorDeleteRequest { + std::uint64_t generation{}, revision{}; + std::vector objects; +}; // A mod with a parks/ folder (Mods//parks/.park.json). struct EditorParkMod { std::string folder, title, author, version, description; diff --git a/Extension/Objects/ParkEditor/Test/park_editor_transaction_tests.cpp b/Extension/Objects/ParkEditor/Test/park_editor_transaction_tests.cpp index 3fd9389f..59b7871c 100644 --- a/Extension/Objects/ParkEditor/Test/park_editor_transaction_tests.cpp +++ b/Extension/Objects/ParkEditor/Test/park_editor_transaction_tests.cpp @@ -81,12 +81,16 @@ void create(const void *wrapper, std::uint32_t mode, std::uint8_t flag, std::uin !network_object_observe(next_entity, object, source - 1), "An untagged/local placement was claimed by the remote owner"); object.position[1] += .25f; - } + } else + object.scale = 1; // Like the game: the create recipe carries no scale. const auto id = next_entity++; world[id] = object; - if (editor_state().transaction) + if (editor_state().transaction) { park_editor_observe(id, object); - else + // placement_finish_batch reconciles after every create batch. + if (const auto live = placement_live_entities(0)) + reconcile_placement_rows(*live); + } else require(network_object_observe(id, object, source), "Remote creation escaped its ownership callback"); } void message_destroy(void *) {} @@ -252,8 +256,17 @@ std::optional> placement_manager_ids(std::uintptr_t) { return objects; } void reconcile_placement_rows(const std::set &live) { + // As the real one: a row whose id is neither saved nor in the editor's pending edit is unlinked. auto &r = placements_runtime(); std::erase_if(r.rows, [&](const auto &row) { return !live.contains(row.entity); }); + const auto pending = park_editor_pending_layout(); + for (auto &row : r.rows) { + const auto has = [&](const profile::ObjectLayout &layout) { + return std::any_of(layout.begin(), layout.end(), [&](const auto &object) { return object.id == row.saved_id; }); + }; + if (row.saved_id && !has(r.document.maps[r.map]) && !(pending && has(*pending))) + row.saved_id = 0; + } } void observe_placement(std::uint64_t entity, const profile::PlacedObject &object) { auto &r = placements_runtime(); @@ -760,6 +773,7 @@ int main() { for (const auto &object : group_before) paste.objects.push_back( {object.id, object.item, editor::add(object.position, {1, 0, 1}), object.rotation, true}); + paste.objects[1].scale = 2.5f; // A scaled copy, as a saved set or Ctrl+V of a resized object set_lobby_object_placement_allowed(false); require(!queue_local_park_paste(paste) && !e.transaction, "Disabled editor accepted a clipboard paste"); @@ -789,9 +803,87 @@ int main() { [&](const auto &object) { return object.id >= paste_next_id && object.item == copy.item && object.position == copy.position && - object.rotation == copy.rotation; + object.rotation == copy.rotation && object.scale == copy.scale; }), "Paste lost a copied object's pose or reused its persistent ID"); + for (const auto &object : r.document.maps[r.map]) + require(std::any_of(r.rows.begin(), r.rows.end(), + [&](const auto &row) { return row.saved_id == object.id && row.spawned; }), + "A pasted object lost its world link while the rest of the paste was created"); + for (const auto © : paste.objects) + require(std::any_of(fixture::world.begin(), fixture::world.end(), + [&](const auto &entry) { + return !paste_world.contains(entry.first) && entry.second.item == copy.item && + entry.second.scale == copy.scale; + }), + "A pasted object spawned in the world without its scale"); + { + // The pasted group moves and turns together right away (the gizmo drag on a placed set). + const auto pasted_layout = r.document.maps[r.map]; + const editor::Quat turn{0, .7071068f, 0, .7071068f}; + live.token++; + live.revision = e.revision; + live.transforms.clear(); + for (const auto &object : pasted_layout) + if (object.id >= paste_next_id) { + live.object = object.id; + live.transforms.push_back({object.id, editor::add(object.position, {3, 0, -2}), + editor::multiplied(turn, object.rotation), object.scale}); + } + require(live.transforms.size() == 2, "The pasted group is not two objects"); + sample(EditorPreviewAction::begin_move); + update_park_editor_moves(); + sample(EditorPreviewAction::commit); + finish(); + for (const auto &moved : live.transforms) { + const auto *saved = find_object(r.document.maps[r.map], moved.id); + require(saved && saved->position == moved.position && saved->rotation == moved.rotation && + saved->scale == moved.scale, + "Moving the pasted group did not save every object's new pose"); + require(std::any_of(fixture::world.begin(), fixture::world.end(), + [&](const auto &entry) { + return entry.second.id == moved.id && entry.second.position == moved.position && + entry.second.rotation == moved.rotation; + }), + "Moving the pasted group left an object behind in the world"); + } + run("undo"); + finish(); + require(r.document.maps[r.map] == pasted_layout, "Undo did not put the moved group back"); + live.transforms.clear(); + live.object = 0; + } + { + // Deleting a multi-selection: every object leaves the world in one edit and one undo + // step, and undo brings them all back at their scale. + std::vector pasted_ids; + for (const auto &object : r.document.maps[r.map]) + if (object.id >= paste_next_id) + pasted_ids.push_back(object.id); + const auto pasted_layout = r.document.maps[r.map]; + const auto pasted_live = fixture::world; + const auto delete_undo = e.undo.size(); + EditorDeleteRequest erase{e.generation, e.revision, pasted_ids}; + auto stale_erase = erase; + stale_erase.objects.push_back(999999); + require(pasted_ids.size() == 2 && !queue_local_park_delete(stale_erase) && !e.transaction, + "Deleting a selection with a missing object began an edit"); + stale_erase = erase; + --stale_erase.revision; + require(!queue_local_park_delete(stale_erase), "A stale_erase selection was deleted"); + require(queue_local_park_delete(erase) && !queue_local_park_delete(erase), + "Multi-delete was refused, or accepted twice while busy"); + finish(); + require(fixture::world == paste_world && r.document.maps[r.map] == group_before && + e.undo.size() == delete_undo + 1, + "Multi-delete left objects in the world or took more than one undo step"); + run("undo"); + finish(); + require(r.document.maps[r.map] == pasted_layout && fixture::world.size() == pasted_live.size() && + std::any_of(fixture::world.begin(), fixture::world.end(), + [](const auto &entry) { return entry.second.scale == 2.5f; }), + "Undoing a multi-delete did not bring every object back at its scale"); + } run("undo"); finish(); require(fixture::world == paste_world && r.document.maps[r.map] == group_before, diff --git a/Extension/Objects/ParkEditor/park_editor_preview.cpp b/Extension/Objects/ParkEditor/park_editor_preview.cpp index 706c7a9b..87b48aad 100644 --- a/Extension/Objects/ParkEditor/park_editor_preview.cpp +++ b/Extension/Objects/ParkEditor/park_editor_preview.cpp @@ -90,6 +90,27 @@ bool queue_local_park_paste(const EditorPasteRequest &request) { return false; } } +bool queue_local_park_delete(const EditorDeleteRequest &request) { + std::lock_guard lock(local_runtime().native_mutex); + auto &e = editor_state(); + auto &r = placements_runtime(); + try { + if (!lobby_object_placement_allowed() || !local_runtime().active || !placement_session_ready() || !idle() || + request.generation != e.generation || request.revision != e.revision || request.objects.empty() || + request.objects.size() > 1024) + return false; + auto target = r.document.maps[r.map]; + const std::set ids(request.objects.begin(), request.objects.end()); + const auto before = target.size(); + std::erase_if(target, [&](const auto &object) { return ids.contains(object.id); }); + if (before - target.size() != ids.size()) + return false; // A selected object is gone: the selection is stale. + // One validated layout change gives the whole selection one undo step. + return begin(std::move(target), 0); + } catch (...) { + return false; + } +} bool queue_local_park_preview(const EditorPreviewRequest &request) { std::lock_guard lock(local_runtime().native_mutex); auto &e = editor_state(); diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.cpp b/Extension/Objects/ParkEditor/park_editor_runtime.cpp index d204cc0d..96b8bfa0 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.cpp +++ b/Extension/Objects/ParkEditor/park_editor_runtime.cpp @@ -312,6 +312,10 @@ bool park_editor_owns_placements() { // permanently starved the regular place/delete updater. return editor_state().transaction.has_value(); } +const profile::ObjectLayout *park_editor_pending_layout() { + const auto &e = editor_state(); + return e.transaction ? &e.transaction->change.after : nullptr; +} void park_editor_external_change() { auto &e = editor_state(); if (park_editor_owns_placements()) @@ -557,6 +561,15 @@ void update_park_editor() { } if (tx.preview && !tx.preview->ending) return; // Live poses remain temporary until mouse release. + if (step.kind == Kind::create && step.object.scale != 1.0f) { + // The create recipe has no scale, so the object spawned at 1x (a paste, a set, an + // undo or a loaded park). Resize it with a move, verified by the server tick's + // readback before the object is committed, as a placement preview does. + step = {Kind::move, step.object, step.entity}; + tx.sent = false; + tx.waiting_since = GetTickCount64(); + return; + } if (step.kind == Kind::erase) { r.tracked.erase(step.entity); std::erase_if(r.rows, [&](const auto &row) { return row.entity == step.entity; }); diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.h b/Extension/Objects/ParkEditor/park_editor_runtime.h index 48729186..60cb14e1 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.h +++ b/Extension/Objects/ParkEditor/park_editor_runtime.h @@ -24,6 +24,7 @@ bool queue_local_park_surface(const EditorSurfaceRequest &); bool queue_local_park_preview(const EditorPreviewRequest &); bool queue_local_park_selection(const EditorSelectionRequest &); bool queue_local_park_paste(const EditorPasteRequest &); +bool queue_local_park_delete(const EditorDeleteRequest &); void tick_local_park_editor() noexcept; // Invoked through the console's verified game-thread dispatcher. `texts` // carries a park mod's name, author, version and description (mod-create: @@ -41,6 +42,9 @@ void update_park_editor(); void update_park_editor_moves() noexcept; void reset_park_editor(); bool park_editor_owns_placements(); +// The layout the editor's current edit is applying (null without one). Its new objects are saved +// only when the whole edit completes, but they are live, linked objects before that. +const profile::ObjectLayout *park_editor_pending_layout(); bool park_editor_observe(std::uint64_t entity, const profile::PlacedObject &object); void park_editor_external_change(); } // namespace dingosdk::profile_runtime diff --git a/Extension/Objects/local_placements_runtime.cpp b/Extension/Objects/local_placements_runtime.cpp index 9d727bc2..493ef1f6 100644 --- a/Extension/Objects/local_placements_runtime.cpp +++ b/Extension/Objects/local_placements_runtime.cpp @@ -100,7 +100,7 @@ void placement_reset_session() { auto& r = placements_runtime(); r.creation.reset(); r.creates.clear(); - r.tracked.clear(); r.restore.clear(); r.late_restores.clear(); r.inflight.reset(); r.rows.clear(); r.teleport_token = 0; r.status.clear(); + r.tracked.clear(); r.restore.clear(); r.late_restores.clear(); r.resizes.clear(); r.inflight.reset(); r.rows.clear(); r.teleport_token = 0; r.status.clear(); r.map.clear(); r.map_context = 0; r.next_poll = 0; r.map_since = 0; r.map_generation = 0; r.restore_failed = false; r.clearing = false; r.clear_entities.clear(); @@ -177,6 +177,11 @@ void reconcile_placement_rows(const std::set& live) { std::unordered_map saved_by_id; saved_by_id.reserve(objects.size()); for (const auto& object : objects) saved_by_id.emplace(object.id, &object); + // A multi-object edit (a paste, a set, an undo) reconciles after each of its creates, before + // its objects are saved: without these, every create unlinked the ones before it, which + // could then not be selected, moved or deleted. + if (const auto* pending = park_editor_pending_layout()) + for (const auto& object : *pending) saved_by_id.emplace(object.id, &object); for (auto& row : r.rows) { if (row.entity && live.contains(row.entity)) { row.spawned = true; @@ -274,6 +279,12 @@ void placement_finish_batch(std::uintptr_t manager, const PlacementBatch& incomi } } else if (const auto restored = claim_restore()) { r.tracked[applied.entity] = *restored; + // claim_restore gave the candidate the saved scale; the spawned object is at 1x. + if (candidate->scale != 1.0f && r.resizes.size() < 1024) { + auto saved = *candidate; + saved.id = *restored; + r.resizes.push_back({applied.entity, saved}); + } dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{{"event", "local_placement_restored"}, {"map", r.map}, {"id", *restored}, {"item", candidate->item}}.dump().c_str()); } else if (reject()) { @@ -446,12 +457,35 @@ void update_placement_creates() { {"applied", batch.applied.size()}}.dump().c_str()); } +// After the native server tick, under native_mutex, like the editor's own moves. +void update_placement_resizes() { + auto& r = placements_runtime(); + if (r.resizes.empty() || !network_object_native_ready()) return; + const auto live = placement_live_entities(r.manager); + if (!live) return; + const auto now = GetTickCount64(); + std::erase_if(r.resizes, [&](auto& pending) { + if (!live->contains(pending.entity) || !r.tracked.contains(pending.entity)) return true; // gone + if (!pending.settled) { + pending.settled = true; + return false; + } + if (!pending.resized_at) { + move_network_object(pending.entity, pending.object); + pending.resized_at = now; + return false; + } + return now > pending.resized_at + 1500; + }); +} + void update_placement_poses() { auto& r = placements_runtime(); if (!placement_session_ready()) return; update_placement_creates(); update_park_editor_moves(); update_network_object_moves(); + update_placement_resizes(); if (!placement_session_ready() || r.clearing || park_editor_owns_placements() || GetTickCount64() < r.next_poll) return; r.next_poll = GetTickCount64() + 1000; const auto live = placement_live_entities(r.manager); @@ -460,6 +494,8 @@ void update_placement_poses() { auto& objects = r.document.maps[r.map]; bool changed{}; for (auto& row : r.rows) { if (!row.spawned) continue; + if (std::any_of(r.resizes.begin(), r.resizes.end(), [&](const auto& x) { return x.entity == row.entity; })) + continue; // Its native scale is still 1x, or the resize is not yet read back. alignas(16) std::array reference{}; alignas(16) std::array query{}; alignas(16) std::array transform{}; diff --git a/Extension/Objects/local_placements_runtime.h b/Extension/Objects/local_placements_runtime.h index 5d572526..511d3d4c 100644 --- a/Extension/Objects/local_placements_runtime.h +++ b/Extension/Objects/local_placements_runtime.h @@ -63,6 +63,16 @@ struct PlacementsRuntime { std::map tracked; // live entity -> layout ID std::vector restore; std::vector late_restores; + // Restored objects saved at another scale. The create recipe has no scale, so they spawn + // at 1x; the server tick resizes them, and the pose poll leaves them alone until then + // (`resized_at`, 0 while waiting) and for a moment after, so 1x is never saved over them. + struct PendingResize { + std::uint64_t entity{}; + profile::PlacedObject object; + std::uint64_t resized_at{}; + bool settled{}; // one server tick after the spawn, as the editor waits before moving + }; + std::vector resizes; std::vector rows; std::uint64_t next_token{1}, teleport_token{}, teleport_queued_at{}; // A teleport of the local skater to a position (console tp, the host's tpall). diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index cd24411a..c128db0e 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -58,6 +58,7 @@ bool queue_local_park_surface(const EditorSurfaceRequest &); bool queue_local_park_preview(const EditorPreviewRequest &); bool queue_local_park_selection(const EditorSelectionRequest &); bool queue_local_park_paste(const EditorPasteRequest &); +bool queue_local_park_delete(const EditorDeleteRequest &); void tick_local_park_editor() noexcept; ControllerBindingsModel local_profile_controller_bindings(); bool set_local_freecam_controller(bool); diff --git a/Extension/UI/Overlay/Test/park_editor_tests.cpp b/Extension/UI/Overlay/Test/park_editor_tests.cpp index aae28039..1d334077 100644 --- a/Extension/UI/Overlay/Test/park_editor_tests.cpp +++ b/Extension/UI/Overlay/Test/park_editor_tests.cpp @@ -60,8 +60,15 @@ struct Fixture { std::vector previews; std::vector selections; std::vector pastes; + std::vector deletes; Fixture() { active = this; + set_park_delete_queue([](const EditorDeleteRequest &request) { + active->deletes.push_back(request); + active->model.editor.busy = true; + ++active->model.editor.revision; + return true; + }); set_park_paste_queue([](const EditorPasteRequest &request) { active->pastes.push_back(request); active->model.editor.busy = true; @@ -593,6 +600,22 @@ int main(int argc, char **argv) { fixture.model.editor.objects.resize(2); fixture.model.editor.objects[0].position = {0, 0, 0}; fixture.ui.selection.clear(); + { + // The Delete key removes a whole multi-selection in one request (one undo step). + fixture.ui.selection = {1, 2}; + fixture.ui.selected = 2; + io.AddMousePosEvent(700, 420); + io.AddKeyEvent(ImGuiKey_Delete, true); + fixture.frame(); + io.AddKeyEvent(ImGuiKey_Delete, false); + fixture.frame(); + check(fixture.deletes.size() == 1 && fixture.deletes[0].objects == std::vector{1, 2} && + fixture.ui.selection.empty(), + "Delete with several objects selected did not delete them all"); + fixture.model.editor.busy = false; + ++fixture.model.editor.revision; + fixture.frame(); + } // With the game's thumbnails loaded, show real items, titled from the // installed content cache when there is one. if (argc > 2 && std::filesystem::exists(argv[2])) { diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index cafb7ff9..abc0bbe0 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -456,6 +456,8 @@ using ParkSelectionQueue = bool (*)(const EditorSelectionRequest &); void set_park_selection_queue(ParkSelectionQueue) noexcept; using ParkPasteQueue = bool (*)(const EditorPasteRequest &); void set_park_paste_queue(ParkPasteQueue) noexcept; +using ParkDeleteQueue = bool (*)(const EditorDeleteRequest &); +void set_park_delete_queue(ParkDeleteQueue) noexcept; struct CallbacksV3 { void* user = nullptr; void (*read_model)(void* user, Model& output) = nullptr; diff --git a/Extension/UI/Overlay/park_editor.cpp b/Extension/UI/Overlay/park_editor.cpp index 11ab2a30..97bb03c1 100644 --- a/Extension/UI/Overlay/park_editor.cpp +++ b/Extension/UI/Overlay/park_editor.cpp @@ -50,6 +50,9 @@ void set_park_selection_queue(ParkSelectionQueue queue) noexcept { void set_park_paste_queue(ParkPasteQueue queue) noexcept { paste_queue.store(queue); } +void set_park_delete_queue(ParkDeleteQueue queue) noexcept { + delete_queue.store(queue); +} bool draw_park_editor(ParkEditorUI &ui, const Model &model, const CallbacksV3 &callbacks, bool console_open, bool exit_requested, bool &visible) { const auto &state = model.editor; @@ -522,6 +525,8 @@ bool draw_park_editor(ParkEditorUI &ui, const Model &model, const CallbacksV3 &c send(ui, model, callbacks, "redo"); if (ui.selected && ui.selection.size() <= 1 && ImGui::IsKeyPressed(ImGuiKey_Delete)) send(ui, model, callbacks, "delete"); + else if (ui.selection.size() > 1 && ImGui::IsKeyPressed(ImGuiKey_Delete, false)) + delete_selection(ui, state); } } ImGui::End(); diff --git a/Extension/UI/Overlay/park_editor_actions.cpp b/Extension/UI/Overlay/park_editor_actions.cpp index 82b526de..2909ac6b 100644 --- a/Extension/UI/Overlay/park_editor_actions.cpp +++ b/Extension/UI/Overlay/park_editor_actions.cpp @@ -131,6 +131,23 @@ void paste_objects(ParkEditorUI &ui, const ParkEditorModel &model) { ui.inspector_dirty = false; ui.feedback.clear(); } +bool delete_selection(ParkEditorUI &ui, const ParkEditorModel &model) { + const auto queue = delete_queue.load(); + if (ui.selection.empty() || !queue || !queue({model.generation, model.revision, ui.selection})) { + ui.feedback = "Delete unavailable. Wait for the current edit or check the status."; + ui.feedback_until = ImGui::GetTime() + 5; + return false; + } + ui.selection.clear(); + ui.selected = 0; + ui.pending = true; + ui.queued_at = ImGui::GetTime(); + ui.queued_revision = model.revision; + ui.queued_status = model.status; + ui.inspector_dirty = false; + ui.feedback.clear(); + return true; +} void select_pasted(ParkEditorUI &ui, const ParkEditorModel &model) { std::vector ids; for (const auto © : ui.paste_expected) { diff --git a/Extension/UI/Overlay/park_editor_internal.h b/Extension/UI/Overlay/park_editor_internal.h index 8cba4e31..3f50a067 100644 --- a/Extension/UI/Overlay/park_editor_internal.h +++ b/Extension/UI/Overlay/park_editor_internal.h @@ -13,6 +13,7 @@ inline std::atomic surface_queue{}; inline std::atomic preview_queue{}; inline std::atomic selection_queue{}; inline std::atomic paste_queue{}; +inline std::atomic delete_queue{}; using theme::blue; using theme::paper; inline constexpr ImU32 accent = skate_theme::blue, muted = skate_theme::grey_text; @@ -31,6 +32,8 @@ void capture_drag(ParkEditorUI &ui, const ParkEditorModel &model); void copy_selection(ParkEditorUI &ui, const ParkEditorModel &model); void paste_objects(ParkEditorUI &ui, const ParkEditorModel &model); void select_pasted(ParkEditorUI &ui, const ParkEditorModel &model); +// Deletes every selected object as one edit; with one selected, `send` "delete" does the same. +bool delete_selection(ParkEditorUI &ui, const ParkEditorModel &model); bool preview(ParkEditorUI &ui, const Model &model, EditorPreviewAction action); void cancel_drag(ParkEditorUI &ui, const Model &model); bool send(ParkEditorUI &ui, const Model &model, const CallbacksV3 &callbacks, std::string_view operation, diff --git a/Extension/UI/Overlay/park_editor_panels.cpp b/Extension/UI/Overlay/park_editor_panels.cpp index 6171fc12..48153130 100644 --- a/Extension/UI/Overlay/park_editor_panels.cpp +++ b/Extension/UI/Overlay/park_editor_panels.cpp @@ -245,6 +245,12 @@ void inspector(ParkEditorUI &ui, const Model &model, const CallbacksV3 &callback if (ImGui::Button("Delete", {-1, 0})) send(ui, model, callbacks, "delete"); ImGui::PopStyleColor(); + } else if (ui.selection.size() > 1) { + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(skate_theme::danger)); + const auto label = "Delete " + std::to_string(ui.selection.size()) + " objects"; + if (ImGui::Button(label.c_str(), {-1, 0})) + delete_selection(ui, model.editor); + ImGui::PopStyleColor(); } ImGui::EndDisabled(); ImGui::Spacing(); diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index e22d1d12..fdf2e1cf 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -268,6 +268,7 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { dingosdk::overlay::set_park_preview_queue(dingosdk::queue_local_park_preview); dingosdk::overlay::set_park_selection_queue(dingosdk::queue_local_park_selection); dingosdk::overlay::set_park_paste_queue(dingosdk::queue_local_park_paste); + dingosdk::overlay::set_park_delete_queue(dingosdk::queue_local_park_delete); if (!DingoSDKOverlayStartV3(&callbacks)) { dingosdk::logging::write(dingosdk::logging::Level::error, dingosdk::logging::Channel::graphics, "Overlay startup failed."); return FALSE; From d9bac524db59db24a41d1d8c5ec593547faf91fc Mon Sep 17 00:00:00 2001 From: tsullivan13 <25965236+tsullivan13@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:02:36 -0700 Subject: [PATCH 2/2] Park editor: saved object sets; duplicate selections; group local axes Sets: save a selection under a name and place it again anywhere, on any map. - The library gets an OBJECTS / SETS switch. SETS saves the selected objects as a set and lists the saved sets to place or delete (Delete asks for a second press). - A set is a park file in /object_sets/.park.json, so it shares the park format, its 2 MB limit and its name rules. Positions are relative to the set's anchor: the middle of its footprint, at its lowest origin. - Placing follows the cursor as one wire cube per object, on surfaces or the grid plane; the mouse wheel turns it by the angle step. A click pastes the whole set: one undo step, under the paste path's object and session limits. Sets with objects missing from the catalog cannot be placed. - A placed or pasted group is then selected with the move gizmo. Editing groups: - Local axes work for multi-selections. A placed set's axes are the rotation it was placed with, plus every later turn; any other group uses its last selected object's rotation. Applying a moved group does not snap its own rotation to the angle grid. - Duplicate (button or Ctrl+D) copies every selected object at its saved position, rotation and scale, one grid step along X, as one paste. - On a server with object scaling off, pastes (sets, duplicates, Ctrl+V) are made at 1x. Tests cover set storage (anchor, round trip, replace, names, damaged files, delete), saving and placing a set through the UI (surface, wheel, one paste, no live preview), selecting it with its own axes, moving it along them, and duplicating with and without scaling. Co-Authored-By: Claude Opus 5.5 --- Engine/Game/World/park_editor.h | 14 ++ .../ParkEditor/Test/park_editor_tests.cpp | 40 ++++ .../Objects/ParkEditor/park_editor_internal.h | 4 + .../ParkEditor/park_editor_preview.cpp | 30 +++ .../ParkEditor/park_editor_runtime.cpp | 8 + .../Objects/ParkEditor/park_editor_runtime.h | 1 + .../Objects/ParkEditor/park_object_sets.cpp | 65 +++++++ .../Objects/ParkEditor/park_object_sets.h | 20 ++ Extension/Profile/local_profile_runtime.h | 1 + .../UI/Overlay/Test/park_editor_tests.cpp | 176 +++++++++++++++++- Extension/UI/Overlay/overlay.h | 2 + Extension/UI/Overlay/park_editor.cpp | 24 ++- Extension/UI/Overlay/park_editor.h | 14 ++ Extension/UI/Overlay/park_editor_actions.cpp | 170 +++++++++++++++-- Extension/UI/Overlay/park_editor_internal.h | 11 ++ Extension/UI/Overlay/park_editor_panels.cpp | 172 +++++++++++++++-- Extension/UI/Overlay/park_editor_viewport.cpp | 84 ++++++++- README.md | 3 +- Runtime/runtime_init.cpp | 1 + cmake/Features.cmake | 1 + 20 files changed, 786 insertions(+), 55 deletions(-) create mode 100644 Extension/Objects/ParkEditor/park_object_sets.cpp create mode 100644 Extension/Objects/ParkEditor/park_object_sets.h diff --git a/Engine/Game/World/park_editor.h b/Engine/Game/World/park_editor.h index 2d982a11..6539b633 100644 --- a/Engine/Game/World/park_editor.h +++ b/Engine/Game/World/park_editor.h @@ -58,6 +58,19 @@ struct EditorDeleteRequest { std::uint64_t generation{}, revision{}; std::vector objects; }; +// A saved set of objects (/object_sets/.park.json), placed again as one paste on any +// map. Positions are relative to the set's anchor: the middle of its footprint, at its lowest origin. +struct EditorObjectSet { + std::string name, map; // map: where the set was saved, for display only + std::vector objects; +}; +enum class EditorObjectSetAction { save, remove }; +struct EditorObjectSetRequest { + EditorObjectSetAction action{}; + std::uint64_t generation{}; + std::string name; + std::vector objects; // save: the objects in world space +}; // A mod with a parks/ folder (Mods//parks/.park.json). struct EditorParkMod { std::string folder, title, author, version, description; @@ -76,5 +89,6 @@ struct ParkEditorModel { bool can_load_parks{}; // false for multiplayer guests: only the host loads parks EditorSurfaceHit surface; std::uint64_t preview_token{}; + std::shared_ptr> object_sets; }; } // namespace dingosdk diff --git a/Extension/Objects/ParkEditor/Test/park_editor_tests.cpp b/Extension/Objects/ParkEditor/Test/park_editor_tests.cpp index 2f3e1ae6..5511d873 100644 --- a/Extension/Objects/ParkEditor/Test/park_editor_tests.cpp +++ b/Extension/Objects/ParkEditor/Test/park_editor_tests.cpp @@ -2,6 +2,7 @@ #include "Extension/Objects/local_placements_runtime.h" #include "Extension/Objects/ParkEditor/park_document.h" #include "Extension/Objects/ParkEditor/park_mods.h" +#include "Extension/Objects/ParkEditor/park_object_sets.h" #include "Extension/Objects/ParkEditor/park_editor_highlight.h" #include "Extension/Objects/ParkEditor/park_editor_surface.h" #include @@ -322,6 +323,45 @@ int main(int argc, char **argv) { "Park mod listing lost its details or maps"); std::filesystem::remove_all(data_root); } + { + // Saved object sets: anchored at the footprint's middle and lowest origin, kept by name. + const auto sets_root = std::filesystem::path(argv[1]) / "object_sets"; + std::filesystem::remove_all(sets_root); + const dingosdk::profile::ObjectLayout picked{ + {41, "own_bk_ramp", {10, 2, 20}, {0, 0, 0, 1}, 1}, + {57, "own_bk_rail", {14, 3, 24}, {0, .7071068f, 0, .7071068f}, 2}}; + const auto anchored = anchored_set(picked); + check(anchored.size() == 2 && anchored[0].id == 1 && anchored[1].id == 2, "Set ids are not the set's own"); + check(close_float(anchored[0].position[0], -2) && close_float(anchored[0].position[1], 0) && + close_float(anchored[0].position[2], -2) && close_float(anchored[1].position[0], 2) && + close_float(anchored[1].position[1], 1) && close_float(anchored[1].position[2], 2), + "Set anchor is not the footprint middle at the lowest origin"); + check(anchored[1].rotation == picked[1].rotation && anchored[1].scale == 2 && anchored[1].item == "own_bk_rail", + "Anchoring changed an object's rotation, scale or item"); + check(load_object_sets(sets_root).empty(), "A missing set folder lists sets"); + save_object_set(sets_root, "Ledge Combo", "bam", picked); + save_object_set(sets_root, "Alpha", "grom", {picked[0]}); + auto sets = load_object_sets(sets_root); + check(sets.size() == 2 && sets[0].name == "Alpha" && sets[1].name == "Ledge Combo" && sets[1].map == "bam", + "Set listing lost a set, its order or its map"); + check(sets[1].objects.size() == 2 && sets[1].objects[1].item == "own_bk_rail" && + close_float(sets[1].objects[1].position[1], 1) && sets[1].objects[1].scale == 2, + "Set round trip differs"); + save_object_set(sets_root, "Alpha", "bam", picked); + sets = load_object_sets(sets_root); + check(sets.size() == 2 && sets[0].objects.size() == 2, "Saving under an existing name did not replace it"); + rejects([&] { save_object_set(sets_root, "../escape", "bam", picked); }); + rejects([&] { save_object_set(sets_root, "Empty", "bam", {}); }); + rejects([&] { save_object_set(sets_root, "Bad", "bam", {{1, "not_buildkit", {}, {0, 0, 0, 1}, 1}}); }); + std::ofstream(sets_root / "Broken.park.json", std::ios::binary) << "{"; + check(load_object_sets(sets_root).size() == 2, "A damaged set file was listed"); + remove_object_set(sets_root, "Alpha"); + sets = load_object_sets(sets_root); + check(sets.size() == 1 && sets[0].name == "Ledge Combo", "Deleting a set failed"); + rejects([&] { remove_object_set(sets_root, "Alpha"); }); + rejects([&] { remove_object_set(sets_root, ".."); }); + std::filesystem::remove_all(sets_root); + } std::cout << "Park editor math, validation and atomic save tests passed.\n"; return 0; } catch (const std::exception &error) { diff --git a/Extension/Objects/ParkEditor/park_editor_internal.h b/Extension/Objects/ParkEditor/park_editor_internal.h index 3fc93d06..d70ed96f 100644 --- a/Extension/Objects/ParkEditor/park_editor_internal.h +++ b/Extension/Objects/ParkEditor/park_editor_internal.h @@ -61,6 +61,9 @@ struct EditorRuntime { std::vector legacy_parks; std::string project; std::uint64_t next_park_scan{}; + // Saved object sets (park_object_sets.h): read at startup and again after every change. + std::filesystem::path sets_directory; + std::shared_ptr> object_sets; std::deque undo, redo; std::optional transaction; bool failed{}, native_ready{}; @@ -88,6 +91,7 @@ EditorRuntime &editor_state(); bool same_layout(const profile::ObjectLayout &a, const profile::ObjectLayout &b); const profile::PlacedObject *find_object(const profile::ObjectLayout &objects, std::uint64_t id); void refresh_park_mods(); +void refresh_object_sets(); bool is_lobby_guest(); bool idle(); void fail(std::string reason); diff --git a/Extension/Objects/ParkEditor/park_editor_preview.cpp b/Extension/Objects/ParkEditor/park_editor_preview.cpp index 87b48aad..b82f27bb 100644 --- a/Extension/Objects/ParkEditor/park_editor_preview.cpp +++ b/Extension/Objects/ParkEditor/park_editor_preview.cpp @@ -1,4 +1,5 @@ #include "park_editor_runtime.h" +#include "park_object_sets.h" #include "park_editor_internal.h" #include "Extension/Objects/local_placements_runtime.h" @@ -111,6 +112,35 @@ bool queue_local_park_delete(const EditorDeleteRequest &request) { return false; } } +bool queue_local_park_object_set(const EditorObjectSetRequest &request) { + std::lock_guard lock(local_runtime().native_mutex); + auto &e = editor_state(); + auto &r = placements_runtime(); + // Sets are files beside the profile's parks: the layout in the world is unchanged, and + // placing one goes through queue_local_park_paste with its checks and its one undo step. + try { + if (!local_runtime().active || !idle() || request.generation != e.generation || e.sets_directory.empty()) + return false; + if (request.action == EditorObjectSetAction::save) { + profile::ObjectLayout objects; + for (const auto &object : request.objects) + objects.push_back({object.id ? object.id : 1, object.item, object.position, object.rotation, + object.scale}); + editor::save_object_set(e.sets_directory, request.name, r.map, objects); + e.status = "Saved the set " + request.name + " (" + std::to_string(objects.size()) + " objects)."; + } else { + editor::remove_object_set(e.sets_directory, request.name); + e.status = "Deleted the set " + request.name + "."; + } + refresh_object_sets(); + ++e.revision; + return true; + } catch (const std::exception &error) { + e.status = std::string("error: ") + error.what(); + ++e.revision; + return false; + } +} bool queue_local_park_preview(const EditorPreviewRequest &request) { std::lock_guard lock(local_runtime().native_mutex); auto &e = editor_state(); diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.cpp b/Extension/Objects/ParkEditor/park_editor_runtime.cpp index 96b8bfa0..2921b1de 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.cpp +++ b/Extension/Objects/ParkEditor/park_editor_runtime.cpp @@ -10,6 +10,7 @@ #include "Extension/Objects/network_object_runtime.h" #include "park_document.h" #include "park_mods.h" +#include "park_object_sets.h" #include #include @@ -66,6 +67,10 @@ void refresh_park_mods() { ++park_scans; use_park_scan(scan_parks(e.data_root, e.directory)); } +void refresh_object_sets() { + auto &e = editor_state(); + e.object_sets = std::make_shared>(editor::load_object_sets(e.sets_directory)); +} void poll_park_mods() { auto &e = editor_state(); if (park_scan.valid()) { @@ -256,6 +261,8 @@ void move_existing(std::uint64_t entity, const profile::PlacedObject &object) { void initialize_park_editor(const std::filesystem::path &directory) { auto &e = editor_state(); e.directory = directory / L"parks"; + e.sets_directory = directory / L"object_sets"; + refresh_object_sets(); e.surface_api = editor::surface_api(local_runtime().base); e.picking_api = editor::picking_api(local_runtime().base); e.highlight_api = editor::highlight_api(local_runtime().base); @@ -663,6 +670,7 @@ ParkEditorModel local_park_editor() { model.project = e.project; model.can_load_parks = !is_lobby_guest(); model.surface = e.surface; + model.object_sets = e.object_sets; if (e.transaction && e.transaction->preview) model.preview_token = e.transaction->preview->token; model.status = e.status; diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.h b/Extension/Objects/ParkEditor/park_editor_runtime.h index 60cb14e1..a35dad90 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.h +++ b/Extension/Objects/ParkEditor/park_editor_runtime.h @@ -24,6 +24,7 @@ bool queue_local_park_surface(const EditorSurfaceRequest &); bool queue_local_park_preview(const EditorPreviewRequest &); bool queue_local_park_selection(const EditorSelectionRequest &); bool queue_local_park_paste(const EditorPasteRequest &); +bool queue_local_park_object_set(const EditorObjectSetRequest &); bool queue_local_park_delete(const EditorDeleteRequest &); void tick_local_park_editor() noexcept; // Invoked through the console's verified game-thread dispatcher. `texts` diff --git a/Extension/Objects/ParkEditor/park_object_sets.cpp b/Extension/Objects/ParkEditor/park_object_sets.cpp new file mode 100644 index 00000000..eed25c3a --- /dev/null +++ b/Extension/Objects/ParkEditor/park_object_sets.cpp @@ -0,0 +1,65 @@ +#include "park_object_sets.h" +#include "park_document.h" +#include +#include + +namespace dingosdk::editor { +profile::ObjectLayout anchored_set(profile::ObjectLayout objects) { + if (objects.empty()) + return objects; + std::array low = objects.front().position, high = low; + for (const auto &object : objects) + for (unsigned i = 0; i < 3; ++i) { + low[i] = std::min(low[i], object.position[i]); + high[i] = std::max(high[i], object.position[i]); + } + const std::array anchor{(low[0] + high[0]) * .5f, low[1], (low[2] + high[2]) * .5f}; + std::uint64_t id = 1; + for (auto &object : objects) { + object.id = id++; // Ids are the set's own; placing gives every object a new one. + for (unsigned i = 0; i < 3; ++i) + object.position[i] -= anchor[i]; + } + return objects; +} +void save_object_set(const std::filesystem::path &directory, std::string_view name, std::string_view map, + const profile::ObjectLayout &objects) { + if (!valid_park_name(name)) + throw std::runtime_error("Use 1-64 letters, numbers, spaces, underscores or hyphens for the set name."); + if (objects.empty()) + throw std::runtime_error("Select objects to save as a set."); + if (objects.size() > 1024) + throw std::runtime_error("A set holds at most 1024 objects."); + const auto existing = list_parks(directory); + if (existing.size() >= max_object_sets && std::find(existing.begin(), existing.end(), name) == existing.end()) + throw std::runtime_error("You have " + std::to_string(max_object_sets) + " sets. Delete one first."); + const auto anchored = anchored_set(objects); + for (const auto &object : anchored) + if (!profile::valid_placed_object(object)) + throw std::runtime_error("The selection has an object that cannot be saved."); + save_park(directory, name, {map.empty() ? std::string("unknown") : std::string(map), anchored}); +} +void remove_object_set(const std::filesystem::path &directory, std::string_view name) { + if (!valid_park_name(name)) + throw std::runtime_error("That set does not exist."); + std::error_code error; + if (!std::filesystem::remove(directory / (std::string(name) + ".park.json"), error) || error) + throw std::runtime_error("Could not delete the set " + std::string(name) + "."); +} +std::vector load_object_sets(const std::filesystem::path &directory) { + std::vector sets; + if (directory.empty()) + return sets; + for (const auto &name : list_parks(directory)) { + try { + const auto park = load_park(directory, name); + EditorObjectSet set{name, park.map, {}}; + for (const auto &object : park.objects) + set.objects.push_back({object.id, object.item, object.position, object.rotation, true, object.scale}); + if (!set.objects.empty()) + sets.push_back(std::move(set)); + } catch (...) { /* A damaged or foreign file is not listed. */ } + } + return sets; +} +} // namespace dingosdk::editor diff --git a/Extension/Objects/ParkEditor/park_object_sets.h b/Extension/Objects/ParkEditor/park_object_sets.h new file mode 100644 index 00000000..4b5dc65d --- /dev/null +++ b/Extension/Objects/ParkEditor/park_object_sets.h @@ -0,0 +1,20 @@ +#pragma once +#include "Engine/Game/World/park_editor.h" +#include "Extension/Objects/object_placements.h" +#include +#include +#include + +// Saved object sets: a selection kept under a name and placed again later, on any map. +// Each set is a park file (park_document.h) in its own folder, so the format, the size limit +// and the name rules are the park's. Positions are relative to the set's anchor. +namespace dingosdk::editor { +inline constexpr std::size_t max_object_sets = 256; +// Moves the objects so the anchor (the middle of their footprint, at their lowest origin) is at 0. +profile::ObjectLayout anchored_set(profile::ObjectLayout objects); +void save_object_set(const std::filesystem::path &directory, std::string_view name, std::string_view map, + const profile::ObjectLayout &objects); +void remove_object_set(const std::filesystem::path &directory, std::string_view name); +// Every readable set, by name. A file that does not decode is left out. +std::vector load_object_sets(const std::filesystem::path &directory); +} // namespace dingosdk::editor diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index c128db0e..c3142667 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -58,6 +58,7 @@ bool queue_local_park_surface(const EditorSurfaceRequest &); bool queue_local_park_preview(const EditorPreviewRequest &); bool queue_local_park_selection(const EditorSelectionRequest &); bool queue_local_park_paste(const EditorPasteRequest &); +bool queue_local_park_object_set(const EditorObjectSetRequest &); bool queue_local_park_delete(const EditorDeleteRequest &); void tick_local_park_editor() noexcept; ControllerBindingsModel local_profile_controller_bindings(); diff --git a/Extension/UI/Overlay/Test/park_editor_tests.cpp b/Extension/UI/Overlay/Test/park_editor_tests.cpp index 1d334077..902eda85 100644 --- a/Extension/UI/Overlay/Test/park_editor_tests.cpp +++ b/Extension/UI/Overlay/Test/park_editor_tests.cpp @@ -1,5 +1,6 @@ #include "Engine/Core/Json/json.h" #include "Extension/UI/Overlay/park_editor.h" +#include "Extension/UI/Overlay/park_editor_internal.h" #include "Extension/UI/Overlay/park_previews.h" #include "Engine/Vfs/content_catalogs.h" #include @@ -60,6 +61,7 @@ struct Fixture { std::vector previews; std::vector selections; std::vector pastes; + std::vector set_requests; std::vector deletes; Fixture() { active = this; @@ -69,6 +71,10 @@ struct Fixture { ++active->model.editor.revision; return true; }); + set_park_object_set_queue([](const EditorObjectSetRequest &request) { + active->set_requests.push_back(request); + return true; + }); set_park_paste_queue([](const EditorPasteRequest &request) { active->pastes.push_back(request); active->model.editor.busy = true; @@ -252,7 +258,7 @@ int main(int argc, char **argv) { press_mode(ImGuiKey_Y, ParkTransformMode::scale); press_mode(ImGuiKey_T, ParkTransformMode::move); const auto click_card = [&](float x) { - io.AddMousePosEvent(x, 270); + io.AddMousePosEvent(x, 320); fixture.frame(); io.AddMouseButtonEvent(ImGuiMouseButton_Left, true); fixture.frame(); @@ -466,7 +472,7 @@ int main(int argc, char **argv) { fixture.frame(); // Group rotation must orbit the shared pivot as well as rotate each body. fixture.ui.mode = ParkTransformMode::rotate; - fixture.ui.local_axes = true; // Groups always use world axes. + fixture.ui.local_axes = true; // These objects are unrotated: the group's local axes are the world's. const editor::Camera camera{fixture.model.debug.camera_transform, 60, 1280, 720}; const editor::Vec3 pivot{2, 0, 0}; const float radius = @@ -520,11 +526,11 @@ int main(int argc, char **argv) { // Exercise ImGui's actual source/target delivery while holding the mouse. const auto dragdrop_creates = fixture.count(EditorPreviewAction::begin_place); // Inside the first library card (below the panel heading, search and filters). - io.AddMousePosEvent(100, 300); + io.AddMousePosEvent(100, 340); fixture.frame(); io.AddMouseButtonEvent(ImGuiMouseButton_Left, true); fixture.frame(); - io.AddMousePosEvent(130, 305); + io.AddMousePosEvent(130, 345); fixture.frame(); check(fixture.ui.asset_drag, "Library row did not start an ImGui drag payload"); io.AddMousePosEvent(700, 420); @@ -616,6 +622,168 @@ int main(int argc, char **argv) { ++fixture.model.editor.revision; fixture.frame(); } + { + // Saved object sets: saving sends the selected objects; placing follows the surface + // as wire cubes, turns with the wheel and pastes the whole set once, with no preview. + using namespace park_editor_detail; + fixture.ui.library_sets = true; + fixture.frame(); + check(!save_set(fixture.ui, fixture.model.editor) && fixture.set_requests.empty(), + "A set was saved with nothing selected"); + fixture.ui.selection = {1, 2}; + fixture.ui.selected = 2; + check(!save_set(fixture.ui, fixture.model.editor) && fixture.set_requests.empty(), + "A set was saved without a name"); + const std::string name = " Combo "; + std::copy(name.begin(), name.end(), fixture.ui.set_name.begin()); + check(save_set(fixture.ui, fixture.model.editor) && fixture.set_requests.size() == 1 && + fixture.set_requests[0].action == EditorObjectSetAction::save && + fixture.set_requests[0].name == "Combo" && fixture.set_requests[0].objects.size() == 2 && + fixture.set_requests[0].objects[1].position == fixture.model.editor.objects[1].position && + fixture.ui.set_name[0] == 0, + "Saving a set did not send the trimmed name and the selected objects"); + auto sets = std::make_shared>(); + sets->push_back({"Combo", "bam", + {{1, "own_bk_Ramps0", {-1, 0, 0}, {0, 0, 0, 1}, true, 1}, + {2, "own_bk_Ramps1", {1, .5f, 0}, {0, 0, 0, 1}, true, 2}}}); + fixture.model.editor.object_sets = sets; + fixture.frame(); + begin_set_placement(fixture.ui, fixture.model, "Combo"); + check(fixture.ui.placing_set == "Combo" && fixture.ui.selection.empty() && fixture.ui.placing.empty(), + "Placing a set kept the selection or a single-object placement"); + const auto previews_before = fixture.previews.size(); + const auto pastes_before = fixture.pastes.size(); + io.AddMousePosEvent(700, 420); + fixture.frame(); + check(fixture.probe.has_value(), "Set placement did not ask for the surface under the cursor"); + fixture.model.editor.surface = {fixture.probe->generation, fixture.probe->id, true, true, {10, 2, 5}}; + fixture.frame(); + check(fixture.ui.position == editor::Vec3{10, 2, 5}, "Set placement did not follow the surface"); + io.AddMouseWheelEvent(0, 1); + fixture.frame(); + check(std::abs(fixture.ui.angles[1] - fixture.ui.angle_snap) < .001f, + "The mouse wheel did not turn the set by the angle step"); + io.AddMouseButtonEvent(ImGuiMouseButton_Left, true); + fixture.frame(); + io.AddMouseButtonEvent(ImGuiMouseButton_Left, false); + check(fixture.pastes.size() == pastes_before && fixture.ui.surface_confirm, + "Set placement pasted before the clicked surface came back"); + fixture.model.editor.surface = {fixture.probe->generation, fixture.probe->id, true, true, {10, 2, 5}}; + fixture.frame(); + check(fixture.pastes.size() == pastes_before + 1 && fixture.pastes.back().objects.size() == 2 && + fixture.ui.placing_set.empty(), + "Clicking did not paste the whole set once"); + check(fixture.previews.size() == previews_before, "Set placement sent a single-object live preview"); + { + // Once the game has the set, it is selected with the move gizmo. + fixture.ui.mode = ParkTransformMode::rotate; + const auto layout = fixture.model.editor.objects; + for (auto object : fixture.pastes.back().objects) { + object.id = 500 + fixture.model.editor.objects.size(); + object.spawned = false; // a lost world link must not stop the selection + fixture.model.editor.objects.push_back(object); + } + fixture.model.editor.busy = false; + ++fixture.model.editor.revision; + fixture.frame(); + check(fixture.ui.selection.size() == 2 && fixture.ui.mode == ParkTransformMode::move, + "A placed set was not selected with the move gizmo"); + // Its own orientation is the rotation it was placed with: the local axes turn with it. + check(std::abs(fixture.ui.angles[1] - fixture.ui.angle_snap) < .01f && + std::abs(fixture.ui.angles[0]) < .01f && std::abs(fixture.ui.angles[2]) < .01f, + "A placed set did not take the rotation it was placed with as its own"); + // Moving it along its local X: every object goes along the set's turned X axis. + fixture.ui.local_axes = true; + park_editor_detail::capture_drag(fixture.ui, fixture.model.editor); + const auto local_x = editor::rotated({1, 0, 0}, editor::rotation(fixture.ui.angles)); + const auto before_move = fixture.ui.drag_objects; + fixture.ui.position = editor::add(fixture.ui.position, editor::mul(local_x, 2)); + check(park_editor_detail::preview(fixture.ui, fixture.model, EditorPreviewAction::begin_move), + "The placed set did not start a group move"); + const auto &moved = fixture.previews.back().transforms; + for (std::size_t i = 0; i < moved.size(); ++i) + for (unsigned axis = 0; axis < 3; ++axis) + check(std::abs(moved[i].position[axis] - before_move[i].position[axis] - 2 * local_x[axis]) < .001f && + moved[i].rotation == before_move[i].rotation, + "Moving the set along its local axis turned it or went another way"); + park_editor_detail::preview(fixture.ui, fixture.model, EditorPreviewAction::cancel); + fixture.ui.local_axes = false; + fixture.model.editor.objects = layout; + fixture.ui.selection.clear(); + fixture.ui.selected = 0; + fixture.model.editor.busy = true; + } + const auto q = editor::rotation({0, fixture.ui.angle_snap, 0}); + const auto &placed = fixture.pastes.back().objects; + for (std::size_t i = 0; i < 2; ++i) { + const auto expected = editor::add({10, 2, 5}, editor::rotated((*sets)[0].objects[i].position, q)); + for (unsigned axis = 0; axis < 3; ++axis) + check(std::abs(placed[i].position[axis] - expected[axis]) < .001f, + "A set object was not placed relative to the set's anchor"); + check(placed[i].item == (*sets)[0].objects[i].item && placed[i].scale == (*sets)[0].objects[i].scale, + "A set object lost its item or scale"); + } + fixture.model.editor.busy = false; + ++fixture.model.editor.revision; + fixture.frame(); + begin_set_placement(fixture.ui, fixture.model, "Combo"); + check(delete_set(fixture.ui, fixture.model.editor, "Combo") && fixture.ui.placing_set.empty() && + fixture.set_requests.back().action == EditorObjectSetAction::remove, + "Deleting the set being placed did not stop its placement"); + { + // Duplicate copies every selected object with its rotation and scale, as one paste. + auto &objects = fixture.model.editor.objects; + objects[0].rotation = editor::rotation({0, 30, 0}); + objects[1].scale = 3; + fixture.ui.selection = {objects[0].id, objects[1].id}; + fixture.ui.selected = objects[1].id; + fixture.frame(); + const auto pastes = fixture.pastes.size(); + io.AddKeyEvent(ImGuiMod_Ctrl, true); + io.AddKeyEvent(ImGuiKey_D, true); + fixture.frame(); + io.AddKeyEvent(ImGuiKey_D, false); + io.AddKeyEvent(ImGuiMod_Ctrl, false); + fixture.frame(); + check(fixture.pastes.size() == pastes + 1 && fixture.pastes.back().objects.size() == 2, + "Ctrl+D did not duplicate every selected object in one paste"); + for (std::size_t i = 0; i < 2; ++i) { + const auto © = fixture.pastes.back().objects[i]; + check(copy.item == objects[i].item && copy.rotation == objects[i].rotation && + copy.scale == objects[i].scale && + copy.position == editor::add(objects[i].position, {fixture.ui.grid, 0, 0}), + "A duplicate lost its rotation or scale, or was not one grid step along X"); + } + fixture.model.editor.busy = false; + ++fixture.model.editor.revision; + fixture.ui.paste_expected.clear(); + fixture.ui.pending = false; + fixture.frame(); + // On a server with object scaling off, the copies are made at normal size. + fixture.model.multiplayer.object_scaling = false; + fixture.ui.selection = {objects[0].id, objects[1].id}; + fixture.ui.selected = objects[1].id; + fixture.frame(); + check(park_editor_detail::duplicate_selection(fixture.ui, fixture.model.editor) && + fixture.pastes.back().objects[1].scale == 1 && + fixture.pastes.back().objects[0].rotation == objects[0].rotation, + "With scaling off, a duplicate kept its size or lost its rotation"); + fixture.model.multiplayer.object_scaling = true; + fixture.model.editor.busy = false; + ++fixture.model.editor.revision; + fixture.ui.paste_expected.clear(); + fixture.ui.pending = false; + objects[0].rotation = {0, 0, 0, 1}; + objects[1].scale = 1; + fixture.frame(); + } + fixture.model.editor.object_sets.reset(); + fixture.ui.library_sets = false; + fixture.ui.paste_expected.clear(); + fixture.ui.selection.clear(); + fixture.ui.angles = {}; + fixture.frame(); + } // With the game's thumbnails loaded, show real items, titled from the // installed content cache when there is one. if (argc > 2 && std::filesystem::exists(argv[2])) { diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index abc0bbe0..06d5b40d 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -456,6 +456,8 @@ using ParkSelectionQueue = bool (*)(const EditorSelectionRequest &); void set_park_selection_queue(ParkSelectionQueue) noexcept; using ParkPasteQueue = bool (*)(const EditorPasteRequest &); void set_park_paste_queue(ParkPasteQueue) noexcept; +using ParkObjectSetQueue = bool (*)(const EditorObjectSetRequest &); +void set_park_object_set_queue(ParkObjectSetQueue) noexcept; using ParkDeleteQueue = bool (*)(const EditorDeleteRequest &); void set_park_delete_queue(ParkDeleteQueue) noexcept; struct CallbacksV3 { diff --git a/Extension/UI/Overlay/park_editor.cpp b/Extension/UI/Overlay/park_editor.cpp index 97bb03c1..cad21e23 100644 --- a/Extension/UI/Overlay/park_editor.cpp +++ b/Extension/UI/Overlay/park_editor.cpp @@ -50,6 +50,9 @@ void set_park_selection_queue(ParkSelectionQueue queue) noexcept { void set_park_paste_queue(ParkPasteQueue queue) noexcept { paste_queue.store(queue); } +void set_park_object_set_queue(ParkObjectSetQueue queue) noexcept { + object_set_queue.store(queue); +} void set_park_delete_queue(ParkDeleteQueue queue) noexcept { delete_queue.store(queue); } @@ -62,11 +65,12 @@ bool draw_park_editor(ParkEditorUI &ui, const Model &model, const CallbacksV3 &c bool escape_closes = false; if (!console_open && !io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Escape, false) && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) { - const bool in_progress = ui.dragging >= 0 || !ui.placing.empty() || ui.preview_token || ui.surface_confirm || - ui.inspector_dirty; + const bool in_progress = ui.dragging >= 0 || !ui.placing.empty() || !ui.placing_set.empty() || + ui.preview_token || ui.surface_confirm || ui.inspector_dirty; if (in_progress) { cancel_drag(ui, model); ui.placing.clear(); + ui.placing_set.clear(); ui.dragging = -1; ui.inspector_dirty = false; ui.draft_revision = 0; @@ -84,6 +88,10 @@ bool draw_park_editor(ParkEditorUI &ui, const Model &model, const CallbacksV3 &c ui.pick_confirm = ui.asset_drag = false; ui.dragging = -1; ui.placing.clear(); + ui.placing_set.clear(); + ui.set_delete.clear(); + ui.group_ids.clear(); + ui.paste_rotation.reset(); ui.pending = false; ui.inspector_dirty = false; ui.initialized = false; @@ -234,11 +242,11 @@ bool draw_park_editor(ParkEditorUI &ui, const Model &model, const CallbacksV3 &c } ImGui::PopFont(); ImGui::SameLine(0, P(14)); - if (ui.selection.size() > 1) { - ImGui::AlignTextToFramePadding(); - ImGui::TextDisabled("World axes"); - } else - ImGui::Checkbox("Local axes", &ui.local_axes); + ImGui::Checkbox("Local axes", &ui.local_axes); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(ui.selection.size() > 1 + ? "Move and rotate along the group's own axes: a placed set's, or the last selected object's." + : "Move and rotate along the object's own axes."); ImGui::EndDisabled(); // ---- dialogs @@ -511,6 +519,8 @@ bool draw_park_editor(ParkEditorUI &ui, const Model &model, const CallbacksV3 &c copy_selection(ui, state); if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_V, false)) paste_objects(ui, state); + if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_D, false) && (ui.selected || !ui.selection.empty())) + duplicate_selection(ui, state); if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_S, false)) { if (!state.project.empty()) send(ui, model, callbacks, "mod-save", state.project); diff --git a/Extension/UI/Overlay/park_editor.h b/Extension/UI/Overlay/park_editor.h index e74f7ec2..358d3877 100644 --- a/Extension/UI/Overlay/park_editor.h +++ b/Extension/UI/Overlay/park_editor.h @@ -2,6 +2,7 @@ #include "Extension/Objects/ParkEditor/editor_math.h" #include "overlay.h" #include +#include namespace dingosdk::overlay { enum class ParkTransformMode { move, rotate, scale }; @@ -44,6 +45,19 @@ struct ParkEditorUI { std::vector clipboard, paste_expected; std::vector paste_before; unsigned paste_serial{}; + // Saved object sets: the library lists them in place of objects. `placing_set` follows the + // cursor at ui.position / ui.angles / ui.scale until a click pastes it; `set_delete` is + // the set whose Delete was pressed once and waits for the confirming press. + bool library_sets{}; + std::string placing_set, set_delete; + // A multi-selection's own orientation, for its local axes and its Rotation field: a placed set + // starts at the rotation it was placed with and keeps every turn given to it. Any other group + // uses its active (last selected) object's rotation. `group_angles` is what the field showed. + std::vector group_ids; // sorted + editor::Quat group_rotation{0, 0, 0, 1}; + std::optional paste_rotation; // the set being pasted + editor::Vec3 group_angles{}; + std::array set_name{}; // Look shared with the ReSkate menu, set by the overlay before drawing. // Null fonts fall back to the current font (the UI tests pass none). ImFont *title_font{}, *bold_font{}, *heading_font{}; diff --git a/Extension/UI/Overlay/park_editor_actions.cpp b/Extension/UI/Overlay/park_editor_actions.cpp index 2909ac6b..bbc96bf4 100644 --- a/Extension/UI/Overlay/park_editor_actions.cpp +++ b/Extension/UI/Overlay/park_editor_actions.cpp @@ -51,8 +51,11 @@ void refresh_selection(ParkEditorUI &ui, const ParkEditorModel &model) { select(ui, *it, model.revision); ui.selection = ids; if (ids.size() > 1) { + auto sorted = ids; + std::sort(sorted.begin(), sorted.end()); ui.position = {}; - ui.angles = {}; + ui.angles = angles(sorted == ui.group_ids ? ui.group_rotation : it->rotation); + ui.group_angles = ui.angles; ui.scale = 1; for (const auto &object : model.objects) if (std::find(ids.begin(), ids.end(), object.id) != ids.end()) @@ -99,26 +102,22 @@ void copy_selection(ParkEditorUI &ui, const ParkEditorModel &model) { } ui.feedback_until = ImGui::GetTime() + 5; } -void paste_objects(ParkEditorUI &ui, const ParkEditorModel &model) { - if (ui.clipboard.empty()) { - ui.feedback = "Copy a selection with Ctrl+C first."; - ui.feedback_until = ImGui::GetTime() + 5; - return; - } - EditorPasteRequest request{model.generation, model.revision, ui.clipboard}; - const float offset = - std::max(ui.snapping ? ui.grid : 1.0f, .05f) * static_cast(ui.paste_serial + 1); - for (auto &object : request.objects) { - object.position[0] += offset; - object.position[2] += offset; - } +namespace { +// Queues the objects as one new group (one undo step) and selects them once the game has them. +bool queue_paste(ParkEditorUI &ui, const ParkEditorModel &model, std::vector objects) { + // A server with object scaling off shares everything at its own size: a set, a duplicate or a + // copy made elsewhere is placed at 1x here too, so what this player sees is what others do. + if (!ui.scaling_allowed) + for (auto &object : objects) + object.scale = 1; + EditorPasteRequest request{model.generation, model.revision, std::move(objects)}; const auto queue = paste_queue.load(); if (!queue || !queue(request)) { ui.feedback = "Paste unavailable. Check object availability and the park's object limit."; ui.feedback_until = ImGui::GetTime() + 5; - return; + ui.paste_rotation.reset(); + return false; } - ++ui.paste_serial; ui.paste_expected = std::move(request.objects); ui.paste_before.clear(); for (const auto &object : model.objects) @@ -130,6 +129,128 @@ void paste_objects(ParkEditorUI &ui, const ParkEditorModel &model) { ui.placing.clear(); ui.inspector_dirty = false; ui.feedback.clear(); + return true; +} +} // namespace +void paste_objects(ParkEditorUI &ui, const ParkEditorModel &model) { + if (ui.clipboard.empty()) { + ui.feedback = "Copy a selection with Ctrl+C first."; + ui.feedback_until = ImGui::GetTime() + 5; + return; + } + auto objects = ui.clipboard; + const float offset = + std::max(ui.snapping ? ui.grid : 1.0f, .05f) * static_cast(ui.paste_serial + 1); + for (auto &object : objects) { + object.position[0] += offset; + object.position[2] += offset; + } + ui.paste_rotation.reset(); // A pasted copy takes its active object's axes. + if (queue_paste(ui, model, std::move(objects))) + ++ui.paste_serial; +} +const EditorObjectSet *placing_object_set(const ParkEditorUI &ui, const ParkEditorModel &model) { + if (ui.placing_set.empty() || !model.object_sets) + return nullptr; + const auto it = std::find_if(model.object_sets->begin(), model.object_sets->end(), + [&](const auto &set) { return set.name == ui.placing_set; }); + return it == model.object_sets->end() ? nullptr : &*it; +} +std::vector placed_set_objects(const ParkEditorUI &ui, const EditorObjectSet &set) { + const auto q = rotation(ui.angles); + const float scale = ui.scaling_allowed ? ui.scale : 1; + std::vector objects; + for (auto object : set.objects) { + object.position = add(ui.position, rotated(mul(object.position, scale), q)); + object.rotation = multiplied(q, object.rotation); + object.scale = std::clamp(object.scale * scale, .01f, 100.0f); + objects.push_back(std::move(object)); + } + return objects; +} +void begin_set_placement(ParkEditorUI &ui, const Model &model, const std::string &name) { + if (ui.preview_token) + cancel_drag(ui, model); + ui.placing.clear(); + ui.placing_set = name; + ui.selected = 0; + ui.selection.clear(); + ui.angles = {}; + ui.scale = 1; + ui.inspector_dirty = false; + ui.surface_confirm = false; + ui.feedback = "Click to place the set. Mouse wheel turns it, Escape cancels."; + if (const auto *set = placing_object_set(ui, model.editor); set && !ui.scaling_allowed && + std::any_of(set->objects.begin(), set->objects.end(), [](const auto &object) { return object.scale != 1; })) + ui.feedback = "This server has object scaling off: the set's resized objects are placed at normal size."; + ui.feedback_until = ImGui::GetTime() + 5; +} +bool place_set(ParkEditorUI &ui, const ParkEditorModel &model) { + const auto *set = placing_object_set(ui, model); + if (!set) { + ui.placing_set.clear(); + return false; + } + ui.paste_rotation = rotation(ui.angles); + if (!queue_paste(ui, model, placed_set_objects(ui, *set))) + return false; + ui.placing_set.clear(); + return true; +} +bool save_set(ParkEditorUI &ui, const ParkEditorModel &model) { + std::vector objects; + for (const auto &object : model.objects) + if (std::find(ui.selection.begin(), ui.selection.end(), object.id) != ui.selection.end()) + objects.push_back(object); + std::string name = ui.set_name.data(); + while (!name.empty() && name.back() == ' ') + name.pop_back(); + while (!name.empty() && name.front() == ' ') + name.erase(name.begin()); + ui.feedback_until = ImGui::GetTime() + 5; + if (objects.empty()) { + ui.feedback = "Select objects to save as a set. Shift-click adds to the selection."; + return false; + } + if (name.empty()) { + ui.feedback = "Name the set first."; + return false; + } + const auto queue = object_set_queue.load(); + if (!queue || !queue({EditorObjectSetAction::save, model.generation, name, std::move(objects)})) { + ui.feedback.clear(); // The editor's status says why. + return false; + } + ui.feedback.clear(); + ui.set_name.fill(0); + return true; +} +bool delete_set(ParkEditorUI &ui, const ParkEditorModel &model, const std::string &name) { + const auto queue = object_set_queue.load(); + ui.set_delete.clear(); + if (ui.placing_set == name) + ui.placing_set.clear(); + return queue && queue({EditorObjectSetAction::remove, model.generation, name, {}}); +} +bool duplicate_selection(ParkEditorUI &ui, const ParkEditorModel &model) { + std::vector copies; + for (const auto &object : model.objects) + if (object.id == ui.selected || + std::find(ui.selection.begin(), ui.selection.end(), object.id) != ui.selection.end()) + copies.push_back(object); + if (copies.empty()) { + ui.feedback = "Select objects to duplicate."; + ui.feedback_until = ImGui::GetTime() + 5; + return false; + } + const float offset = ui.snapping ? std::max(ui.grid, .05f) : 1.0f; + for (auto © : copies) + copy.position[0] += offset; + if (copies.size() > 1) + ui.paste_rotation = rotation(ui.group_angles); + else + ui.paste_rotation.reset(); + return queue_paste(ui, model, std::move(copies)); } bool delete_selection(ParkEditorUI &ui, const ParkEditorModel &model) { const auto queue = delete_queue.load(); @@ -151,8 +272,10 @@ bool delete_selection(ParkEditorUI &ui, const ParkEditorModel &model) { void select_pasted(ParkEditorUI &ui, const ParkEditorModel &model) { std::vector ids; for (const auto © : ui.paste_expected) { + // New saved objects at the requested poses; not `spawned`, which a lost world link clears + // (the placements runtime links them again within a second). const auto found = std::find_if(model.objects.begin(), model.objects.end(), [&](const auto &object) { - return object.spawned && object.item == copy.item && object.position == copy.position && + return object.item == copy.item && object.position == copy.position && object.rotation == copy.rotation && object.scale == copy.scale && std::find(ui.paste_before.begin(), ui.paste_before.end(), object.id) == ui.paste_before.end() && @@ -162,11 +285,19 @@ void select_pasted(ParkEditorUI &ui, const ParkEditorModel &model) { ids.push_back(found->id); } if (!model.failed && ids.size() == ui.paste_expected.size()) { + // Selected, with the move gizmo, ready to be put in place. + if (ui.paste_rotation) { + ui.group_ids = ids; + std::sort(ui.group_ids.begin(), ui.group_ids.end()); + ui.group_rotation = *ui.paste_rotation; + } ui.selection = std::move(ids); + ui.mode = ParkTransformMode::move; refresh_selection(ui, model); } ui.paste_expected.clear(); ui.paste_before.clear(); + ui.paste_rotation.reset(); } bool preview(ParkEditorUI &ui, const Model &model, EditorPreviewAction action) { const auto queue = preview_queue.load(); @@ -214,6 +345,11 @@ bool preview(ParkEditorUI &ui, const Model &model, EditorPreviewAction action) { ui.preview_angles = ui.angles; ui.preview_scale = ui.scale; ui.preview_heartbeat = ImGui::GetTime() + .25; + if (action == EditorPreviewAction::commit && !placing && ui.selection.size() > 1) { + ui.group_ids = ui.selection; + std::sort(ui.group_ids.begin(), ui.group_ids.end()); + ui.group_rotation = request.rotation; + } if (action == EditorPreviewAction::commit || action == EditorPreviewAction::cancel) { ui.preview_token = 0; ui.pending = true; diff --git a/Extension/UI/Overlay/park_editor_internal.h b/Extension/UI/Overlay/park_editor_internal.h index 3f50a067..4618d54b 100644 --- a/Extension/UI/Overlay/park_editor_internal.h +++ b/Extension/UI/Overlay/park_editor_internal.h @@ -13,6 +13,7 @@ inline std::atomic surface_queue{}; inline std::atomic preview_queue{}; inline std::atomic selection_queue{}; inline std::atomic paste_queue{}; +inline std::atomic object_set_queue{}; inline std::atomic delete_queue{}; using theme::blue; using theme::paper; @@ -34,6 +35,16 @@ void paste_objects(ParkEditorUI &ui, const ParkEditorModel &model); void select_pasted(ParkEditorUI &ui, const ParkEditorModel &model); // Deletes every selected object as one edit; with one selected, `send` "delete" does the same. bool delete_selection(ParkEditorUI &ui, const ParkEditorModel &model); +// Copies every selected object at its saved position, rotation and scale, one grid step along X, +// as one paste; the copies are then selected (a group keeps the group's own axes). +bool duplicate_selection(ParkEditorUI &ui, const ParkEditorModel &model); +// Saved object sets (park_editor_actions.cpp). +const EditorObjectSet *placing_object_set(const ParkEditorUI &ui, const ParkEditorModel &model); +std::vector placed_set_objects(const ParkEditorUI &ui, const EditorObjectSet &set); +void begin_set_placement(ParkEditorUI &ui, const Model &model, const std::string &name); +bool place_set(ParkEditorUI &ui, const ParkEditorModel &model); +bool save_set(ParkEditorUI &ui, const ParkEditorModel &model); +bool delete_set(ParkEditorUI &ui, const ParkEditorModel &model, const std::string &name); bool preview(ParkEditorUI &ui, const Model &model, EditorPreviewAction action); void cancel_drag(ParkEditorUI &ui, const Model &model); bool send(ParkEditorUI &ui, const Model &model, const CallbacksV3 &callbacks, std::string_view operation, diff --git a/Extension/UI/Overlay/park_editor_panels.cpp b/Extension/UI/Overlay/park_editor_panels.cpp index 48153130..8db86186 100644 --- a/Extension/UI/Overlay/park_editor_panels.cpp +++ b/Extension/UI/Overlay/park_editor_panels.cpp @@ -58,10 +58,132 @@ std::string card_title(const std::string &title, float width) { } return result; } +// OBJECTS / SETS: which list the library shows. +void library_tabs(ParkEditorUI &ui) { + const float half = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * .5f; + ImGui::PushFont(font_or(ui.bold_font)); + for (const bool sets : {false, true}) { + if (sets) + ImGui::SameLine(); + const bool on = ui.library_sets == sets; + if (on) + skate_theme::push_primary_button(); + if (ImGui::Button(sets ? "SETS" : "OBJECTS", {sets ? -1 : half, 0})) + ui.library_sets = sets; + if (on) + skate_theme::pop_primary_button(); + } + ImGui::PopFont(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Groups of objects you saved, to place again anywhere."); +} +void object_sets(ParkEditorUI &ui, const Model &model) { + const float k = ui.ui_scale; + const auto &state = model.editor; + const bool busy = state.busy || state.failed || ui.pending || ui.preview_token || !state.available; + const auto &sets = state.object_sets; + ImGui::PushTextWrapPos(0); + ImGui::TextDisabled("Select objects (Shift-click adds), name them and save them as a set. " + "Place it again anywhere, on any map."); + ImGui::PopTextWrapPos(); + ImGui::SetNextItemWidth(-1); + const bool enter = ImGui::InputTextWithHint("##set-name", "Set name", ui.set_name.data(), ui.set_name.size(), + ImGuiInputTextFlags_EnterReturnsTrue); + const auto count = ui.selection.size(); + const bool can_save = !busy && count && ui.set_name[0]; + ImGui::BeginDisabled(!can_save); + skate_theme::push_primary_button(); + ImGui::PushFont(font_or(ui.bold_font)); + const auto label = count ? "SAVE " + std::to_string(count) + (count == 1 ? " OBJECT" : " OBJECTS") + " AS SET" + : std::string("SELECT OBJECTS TO SAVE"); + const bool save = ImGui::Button(label.c_str(), {-1, 34 * k}); + ImGui::PopFont(); + skate_theme::pop_primary_button(); + ImGui::EndDisabled(); + if ((save || enter) && can_save) + save_set(ui, state); + const std::string typed = ui.set_name.data(); + if (sets && !typed.empty() && + std::any_of(sets->begin(), sets->end(), [&](const auto &set) { return set.name == typed; })) + ImGui::TextDisabled("Saving replaces the set with this name."); + ImGui::Spacing(); + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##set-search", "Search sets...", ui.search.data(), ui.search.size()); + std::vector filtered; + if (sets) + for (const auto &set : *sets) + if (matches(set.name + " " + set.map, ui.search.data())) + filtered.push_back(&set); + ImGui::TextDisabled("%zu sets / Place, then click in the world", filtered.size()); + ImGui::Spacing(); + ImGui::BeginChild("set-list", {0, 0}); + if (filtered.empty()) + ImGui::TextDisabled(sets && !sets->empty() ? "No set matches the search." : "No saved sets yet."); + for (std::size_t index = 0; index < filtered.size(); ++index) { + const auto &set = *filtered[index]; + ImGui::PushID(set.name.c_str()); + auto *draw = ImGui::GetWindowDrawList(); + const auto top = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + const float height = ImGui::GetTextLineHeight() * 2 + ImGui::GetFrameHeight() + 26 * k; + const bool chosen = ui.placing_set == set.name; + skate_theme::rough_rect(draw, top, plus(top, {width, height}), + chosen ? skate_theme::tile_light : skate_theme::tile_grey, + static_cast(index + 91), k); + if (chosen) + draw->AddRect(plus(top, {-1, -1}), plus(top, {width + 1, height + 1}), blue, 0, 0, 3 * k); + std::size_t missing = 0; + if (state.assets) + for (const auto &object : set.objects) + missing += std::none_of(state.assets->begin(), state.assets->end(), + [&](const auto &asset) { return asset.key == object.item; }); + ImGui::SetCursorScreenPos(plus(top, {10 * k, 8 * k})); + ImGui::BeginGroup(); + ImGui::PushFont(font_or(ui.bold_font)); + ImGui::TextUnformatted(set.name.c_str()); + ImGui::PopFont(); + if (missing) + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(skate_theme::danger), "%zu objects / %zu unavailable", + set.objects.size(), missing); + else + ImGui::TextDisabled("%zu objects / saved on %s", set.objects.size(), set.map.c_str()); + const float buttons = (width - 20 * k - ImGui::GetStyle().ItemSpacing.x) * .5f; + ImGui::BeginDisabled(busy || missing); + if (ImGui::Button(chosen ? "Placing..." : "Place", {buttons, 0})) { + if (chosen) + ui.placing_set.clear(); + else + begin_set_placement(ui, model, set.name); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(busy); + const bool confirming = ui.set_delete == set.name; + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(skate_theme::danger)); + if (ImGui::Button(confirming ? "Really delete?" : "Delete", {buttons, 0})) { + if (confirming) + delete_set(ui, state, set.name); + else + ui.set_delete = set.name; + } + ImGui::PopStyleColor(); + ImGui::EndDisabled(); + ImGui::EndGroup(); + ImGui::SetCursorScreenPos(plus(top, {0, height + 6 * k})); + ImGui::Dummy({width, 0}); + ImGui::PopID(); + } + ImGui::EndChild(); +} } // namespace void library(ParkEditorUI &ui, const Model &model) { const float k = ui.ui_scale; heading(ui, "OBJECT LIBRARY"); + library_tabs(ui); + if (ui.library_sets) { + object_sets(ui, model); + return; + } ImGui::SetNextItemWidth(-1); ImGui::InputTextWithHint("##library-search", "Search objects...", ui.search.data(), ui.search.size()); const auto &assets = model.editor.assets; @@ -112,6 +234,7 @@ void library(ParkEditorUI &ui, const Model &model) { if (ui.preview_token) cancel_drag(ui, model); ui.placing = item.key; + ui.placing_set.clear(); ui.selected = 0; ui.selection.clear(); ui.angles = {}; @@ -173,8 +296,10 @@ void inspector(ParkEditorUI &ui, const Model &model, const CallbacksV3 &callback const float k = ui.ui_scale; heading(ui, "TRANSFORM"); const auto *selected = selected_object(ui, model.editor); - const bool editable = selected || !ui.placing.empty(); - if (ui.selection.size() > 1) + const bool editable = selected || !ui.placing.empty() || !ui.placing_set.empty(); + if (!ui.placing_set.empty()) + ImGui::TextDisabled("Placing the set %s", ui.placing_set.c_str()); + else if (ui.selection.size() > 1) ImGui::TextDisabled("%zu objects / shared pivot", ui.selection.size()); else if (selected) ImGui::TextDisabled("Object %llu", static_cast(selected->id)); @@ -203,7 +328,10 @@ void inspector(ParkEditorUI &ui, const Model &model, const CallbacksV3 &callback ImGui::BeginDisabled(!selected && ui.surface_mode && !ui.surface_valid && !ui.inspector_dirty); skate_theme::push_primary_button(); ImGui::PushFont(font_or(ui.bold_font)); - const bool apply = ImGui::Button(selected ? "APPLY TRANSFORM" : "PLACE AT COORDINATES", {-1, 34 * k}); + const bool apply = ImGui::Button(selected ? "APPLY TRANSFORM" + : !ui.placing_set.empty() ? "PLACE SET AT COORDINATES" + : "PLACE AT COORDINATES", + {-1, 34 * k}); ImGui::PopFont(); skate_theme::pop_primary_button(); if (apply) { @@ -211,11 +339,15 @@ void inspector(ParkEditorUI &ui, const Model &model, const CallbacksV3 &callback if (selected || !ui.surface_mode || ui.inspector_dirty) for (auto &v : ui.position) v = snapped(v, ui.grid); - for (auto &v : ui.angles) - v = snapped(v, ui.angle_snap); + // A group's own rotation is not on the angle grid: snap only a rotation typed for it. + if (ui.selection.size() <= 1 || ui.angles != ui.group_angles) + for (auto &v : ui.angles) + v = snapped(v, ui.angle_snap); } bool applied{}; - if (ui.selection.size() > 1) { + if (!ui.placing_set.empty()) + applied = place_set(ui, model.editor); + else if (ui.selection.size() > 1) { const auto position = ui.position, angles = ui.angles; const auto scale = ui.scale; refresh_selection(ui, model.editor); @@ -233,23 +365,23 @@ void inspector(ParkEditorUI &ui, const Model &model, const CallbacksV3 &callback ui.placing.clear(); } ImGui::EndDisabled(); - if (selected && ui.selection.size() <= 1) { + if (selected) { + const bool group = ui.selection.size() > 1; const float half = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * .5f; - if (ImGui::Button("Duplicate", {half, 0})) { - const auto key = selected->item; - ui.position[0] += ui.snapping ? ui.grid : 1; - send(ui, model, callbacks, "place", key); - } + if (ImGui::Button(group ? "Duplicate all" : "Duplicate", {half, 0})) + duplicate_selection(ui, model.editor); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Copy %s with %s rotation and scale, one grid step along X (Ctrl+D).", + group ? "every selected object" : "the object", group ? "their" : "its"); ImGui::SameLine(); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(skate_theme::danger)); - if (ImGui::Button("Delete", {-1, 0})) - send(ui, model, callbacks, "delete"); - ImGui::PopStyleColor(); - } else if (ui.selection.size() > 1) { - ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(skate_theme::danger)); - const auto label = "Delete " + std::to_string(ui.selection.size()) + " objects"; - if (ImGui::Button(label.c_str(), {-1, 0})) - delete_selection(ui, model.editor); + const auto label = group ? "Delete " + std::to_string(ui.selection.size()) : std::string("Delete"); + if (ImGui::Button(label.c_str(), {-1, 0})) { + if (group) + delete_selection(ui, model.editor); + else + send(ui, model, callbacks, "delete"); + } ImGui::PopStyleColor(); } ImGui::EndDisabled(); diff --git a/Extension/UI/Overlay/park_editor_viewport.cpp b/Extension/UI/Overlay/park_editor_viewport.cpp index 0e81b09f..5cedbefd 100644 --- a/Extension/UI/Overlay/park_editor_viewport.cpp +++ b/Extension/UI/Overlay/park_editor_viewport.cpp @@ -93,6 +93,7 @@ void viewport(ParkEditorUI &ui, const Model &model, ImVec2 screen_origin, ImVec2 ImGui::AcceptDragDropPayload("PARK_ASSET", ImGuiDragDropFlags_AcceptBeforeDelivery)) { if (payload->DataSize > 0 && payload->DataSize < 1024) { ui.placing = static_cast(payload->Data); + ui.placing_set.clear(); ui.selected = 0; ui.selection.clear(); ui.angles = {}; @@ -179,6 +180,75 @@ void viewport(ParkEditorUI &ui, const Model &model, ImVec2 screen_origin, ImVec2 ui.placing.clear(); } } + if (!ui.placing_set.empty() && !placing_object_set(ui, model.editor)) + ui.placing_set.clear(); // deleted, or the list was read again without it + if (const auto *set = placing_object_set(ui, model.editor)) { + // A saved set follows the cursor as wire cubes, one per object, and a click pastes it: + // no live preview, since the game previews one object at a time. + auto &io = ImGui::GetIO(); + if (hovered && !ui.flying && io.MouseWheel != 0) { + const float step = ui.snapping ? ui.angle_snap : 15; + ui.angles[1] = std::fmod(ui.angles[1] + (io.MouseWheel > 0 ? step : -step), 360.0f); + } + const bool clicked = can_edit && hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left); + bool show = true, commit = false; + if (ui.surface_mode) { + const auto &hit = model.editor.surface; + const bool previous_current = + hit.id && hit.id == ui.surface_request.id && hit.generation == model.editor.generation; + const float grid = ui.snapping ? ui.grid : 0; + const bool changed = ui.surface_request.generation != model.editor.generation || + ui.surface_request.origin != camera.origin() || + ui.surface_request.direction != ray || ui.surface_request.grid != grid || + ui.surface_request.picking; + if (mouse_in && !ui.flying && !ui.surface_confirm && + (clicked || !ui.surface_request.id || + (changed && previous_current && ImGui::GetTime() >= ui.next_surface_request))) { + if (const auto queue = surface_queue.load()) { + EditorSurfaceRequest request{model.editor.generation, ui.surface_request.id + 1, + camera.origin(), ray, grid}; + if (queue(request)) { + ui.surface_request = request; + ui.next_surface_request = ImGui::GetTime() + .016; + if (clicked) + ui.surface_confirm = true; + } + } + } + const bool current = + hit.id && hit.id == ui.surface_request.id && hit.generation == model.editor.generation; + ui.surface_valid = + (current || (previous_current && !ui.surface_confirm)) && hit.available && hit.hit; + show = ui.surface_valid; + if (ui.surface_valid && (mouse_in || ui.surface_confirm)) { + ui.position = hit.position; + ui.position[1] += ui.surface_offset; + } + if (!ui.surface_valid) + draw->AddText(plus(start, {16, 38}), muted, + current ? (hit.available ? "No surface hit. Aim at solid ground." + : "Game collision query unavailable.") + : "Finding ground surface..."); + if (ui.surface_confirm && current) { + ui.surface_confirm = false; + commit = ui.surface_valid; + } + } else { + if (mouse_in && !ui.flying) { + ui.position = plane_hit(camera.origin(), ray, {0, ui.plane_height, 0}, {0, 1, 0}) + .value_or(add(camera.origin(), mul(ray, ui.distance))); + if (ui.snapping) + for (auto &value : ui.position) + value = snapped(value, ui.grid); + } + commit = clicked; + } + if (show) + for (const auto &object : placed_set_objects(ui, *set)) + wire_preview(draw, camera, screen_origin, object.position, object.rotation, object.scale, accent); + if (commit && can_edit) + place_set(ui, model.editor); + } int hover_axis = -1; const auto *selected = selected_object(ui, model.editor); if (selected && can_edit && !ui.flying) { @@ -189,7 +259,7 @@ void viewport(ParkEditorUI &ui, const Model &model, ImVec2 screen_origin, ImVec2 for (unsigned axis = 0; axis < 3; ++axis) { Vec3 unit{}; unit[axis] = 1; - if (ui.local_axes && ui.selection.size() <= 1) + if (ui.local_axes) unit = rotated(unit, ui.dragging >= 0 ? ui.drag_rotation : q); if (ui.mode != ParkTransformMode::rotate) { const auto end = project(camera, screen_origin, add(ui.position, mul(unit, gizmo_size))); @@ -209,7 +279,7 @@ void viewport(ParkEditorUI &ui, const Model &model, ImVec2 screen_origin, ImVec2 u[(axis + 1) % 3] = 1; Vec3 v{}; v[(axis + 2) % 3] = 1; - if (ui.local_axes && ui.selection.size() <= 1) { + if (ui.local_axes) { u = rotated(u, q); v = rotated(v, q); } @@ -234,7 +304,7 @@ void viewport(ParkEditorUI &ui, const Model &model, ImVec2 screen_origin, ImVec2 capture_drag(ui, model.editor); Vec3 axis{}; axis[static_cast(hover_axis)] = 1; - if (ui.local_axes && ui.selection.size() <= 1) + if (ui.local_axes) axis = rotated(axis, q); ui.drag_axis = axis; ui.drag_start = ui.position; @@ -319,7 +389,8 @@ void viewport(ParkEditorUI &ui, const Model &model, ImVec2 screen_origin, ImVec2 } } } - if (ui.placing.empty() && !ui.flying && can_edit && (ui.dragging < 0 || ui.dragging == 3)) { + if (ui.placing.empty() && ui.placing_set.empty() && !ui.flying && can_edit && + (ui.dragging < 0 || ui.dragging == 3)) { const bool clicked = hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && hover_axis < 0 && ui.dragging < 0; const bool picking = ui.dragging != 3; @@ -391,8 +462,9 @@ void viewport(ParkEditorUI &ui, const Model &model, ImVec2 screen_origin, ImVec2 } } draw->AddText(plus(start, {16, 14}), muted, - ui.placing.empty() ? "T move / R rotate / Y scale | Shift-click adds / RMB flies" - : "Click to place | Escape to cancel"); + !ui.placing_set.empty() ? "Click to place the set | Wheel turns it | Escape to cancel" + : ui.placing.empty() ? "T move / R rotate / Y scale | Shift-click adds / RMB flies" + : "Click to place | Escape to cancel"); if (ui.preview_token && can_edit) preview(ui, model, EditorPreviewAction::update); draw->PopClipRect(); diff --git a/README.md b/README.md index 83041f6c..72b5c2f2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ the launcher, the runtime that loads into the game, and the dedicated server. - **In-game menu and console.** - Map and fast travel. - World: time of day, population, district levels, rotating parks. - - The **Park Editor**: place, move and save objects with freecam, snapping and undo. + - The **Park Editor**: place, move and save objects with freecam, snapping and undo. Save a selection as a + set and place it again anywhere, on any map. - Skater options: first person, movement, boosts, noclip. - **Hall of Meat** (off by default): bail and see the bones you hurt, with a skate. 3 style Meat card. - **Grind exit triggers** (off by default): press LT or RT while grinding to pick the side you pop diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index fdf2e1cf..16aa289b 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -268,6 +268,7 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { dingosdk::overlay::set_park_preview_queue(dingosdk::queue_local_park_preview); dingosdk::overlay::set_park_selection_queue(dingosdk::queue_local_park_selection); dingosdk::overlay::set_park_paste_queue(dingosdk::queue_local_park_paste); + dingosdk::overlay::set_park_object_set_queue(dingosdk::queue_local_park_object_set); dingosdk::overlay::set_park_delete_queue(dingosdk::queue_local_park_delete); if (!DingoSDKOverlayStartV3(&callbacks)) { dingosdk::logging::write(dingosdk::logging::Level::error, dingosdk::logging::Channel::graphics, "Overlay startup failed."); diff --git a/cmake/Features.cmake b/cmake/Features.cmake index fda73c41..8b8e81a1 100644 --- a/cmake/Features.cmake +++ b/cmake/Features.cmake @@ -29,6 +29,7 @@ add_library(dingosdk_local_profile STATIC Extension/Objects/placements_store.cpp Extension/Objects/ParkEditor/park_document.cpp Extension/Objects/ParkEditor/park_mods.cpp + Extension/Objects/ParkEditor/park_object_sets.cpp ) target_link_libraries(dingosdk_local_profile PUBLIC dingosdk_fast_travel_unlock dingosdk_content_cache dingosdk_json dingosdk_storage dingosdk_mod_list)