diff --git a/Engine/Game/World/park_editor.h b/Engine/Game/World/park_editor.h index c45a6e54..6539b633 100644 --- a/Engine/Game/World/park_editor.h +++ b/Engine/Game/World/park_editor.h @@ -53,6 +53,24 @@ 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 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; @@ -71,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/Test/park_editor_transaction_tests.cpp b/Extension/Objects/ParkEditor/Test/park_editor_transaction_tests.cpp index b936f372..872fe794 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 *) {} @@ -256,8 +260,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(); @@ -764,6 +777,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"); @@ -793,9 +807,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_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 706c7a9b..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" @@ -90,6 +91,56 @@ 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_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 cd78af8c..00d5c10e 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()) { @@ -255,6 +260,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); @@ -311,6 +318,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()) @@ -556,6 +567,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; }); @@ -649,6 +669,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 48729186..a35dad90 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.h +++ b/Extension/Objects/ParkEditor/park_editor_runtime.h @@ -24,6 +24,8 @@ 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` // carries a park mod's name, author, version and description (mod-create: @@ -41,6 +43,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/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/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 a7876041..88110207 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -61,6 +61,8 @@ 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(); 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..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,8 +61,20 @@ struct Fixture { std::vector previews; std::vector selections; std::vector pastes; + std::vector set_requests; + 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_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; @@ -245,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(); @@ -459,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 = @@ -513,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); @@ -593,6 +606,184 @@ 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(); + } + { + // 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 2cbbb179..5bab90c6 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -499,6 +499,10 @@ 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 { 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..cad21e23 100644 --- a/Extension/UI/Overlay/park_editor.cpp +++ b/Extension/UI/Overlay/park_editor.cpp @@ -50,6 +50,12 @@ 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); +} 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; @@ -59,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; @@ -81,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; @@ -231,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 @@ -508,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); @@ -522,6 +535,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.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 82b526de..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,12 +129,153 @@ 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(); + 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) { + // 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() && @@ -145,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(); @@ -197,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 8cba4e31..4618d54b 100644 --- a/Extension/UI/Overlay/park_editor_internal.h +++ b/Extension/UI/Overlay/park_editor_internal.h @@ -13,6 +13,8 @@ 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; inline constexpr ImU32 accent = skate_theme::blue, muted = skate_theme::grey_text; @@ -31,6 +33,18 @@ 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); +// 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 6171fc12..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,17 +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"); + 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 d3a0ffff..40feb472 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 a6066fc8..9f2dba60 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -273,6 +273,8 @@ 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."); return FALSE; 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)