mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 19:05:21 +02:00
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 <profile>/object_sets/<name>.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 <noreply@anthropic.com>
821 lines
44 KiB
C++
821 lines
44 KiB
C++
#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 <Windows.h>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <imgui_internal.h>
|
|
#include <iostream>
|
|
#include <stdexcept>
|
|
|
|
namespace {
|
|
using namespace dingosdk;
|
|
using namespace dingosdk::overlay;
|
|
void check(bool value, const char *message) {
|
|
if (!value)
|
|
throw std::runtime_error(message);
|
|
}
|
|
void preview_checks() {
|
|
ImFontAtlas atlas;
|
|
atlas.AddFontDefault();
|
|
vfs::ItemThumbnail thumbnail{"own_bk_test", {128, 128, {}}};
|
|
for (unsigned i = 0; i < 128 * 128; ++i)
|
|
thumbnail.image.rgba.insert(thumbnail.image.rgba.end(), {35, 75, 150, 255});
|
|
check(load_park_previews(atlas, std::span(&thumbnail, 1)) == 1, "Valid thumbnail was rejected");
|
|
const auto *rect = atlas.GetCustomRectByIndex(0);
|
|
const auto offset = (static_cast<std::size_t>(rect->Y) * atlas.TexWidth + rect->X) * 4;
|
|
const auto *pixels = reinterpret_cast<const unsigned char *>(atlas.TexPixelsRGBA32);
|
|
check(rect->IsPacked() && pixels[offset] == 35 && pixels[offset + 2] == 150,
|
|
"Thumbnail pixels were not copied into the uploaded ImGui atlas");
|
|
const int rectangles = atlas.CustomRects.Size;
|
|
auto truncated = thumbnail;
|
|
truncated.image.rgba.pop_back();
|
|
check(load_park_previews(atlas, std::span(&truncated, 1)) == 0 && atlas.CustomRects.Size == rectangles,
|
|
"Truncated thumbnail data modified the font atlas");
|
|
auto renamed = thumbnail;
|
|
renamed.item = "Own_BK_Test";
|
|
check(load_park_previews(atlas, std::span(&renamed, 1)) == 0 && atlas.CustomRects.Size == rectangles,
|
|
"An invalid thumbnail key reached the atlas allocator");
|
|
const std::array twice{thumbnail, thumbnail};
|
|
check(load_park_previews(atlas, twice) == 0 && atlas.CustomRects.Size == rectangles,
|
|
"Duplicate thumbnail keys reached the atlas allocator");
|
|
check(load_park_previews(atlas, std::chrono::milliseconds(0)) == 0,
|
|
"Thumbnails were loaded without a background read");
|
|
clear_park_previews();
|
|
}
|
|
struct Fixture {
|
|
inline static Fixture *active{};
|
|
std::optional<EditorSurfaceRequest> probe;
|
|
ParkEditorUI ui;
|
|
Model model;
|
|
CallbacksV3 callbacks;
|
|
bool visible = true;
|
|
bool flight{};
|
|
std::vector<std::string> commands;
|
|
std::vector<EditorPreviewRequest> previews;
|
|
std::vector<EditorSelectionRequest> selections;
|
|
std::vector<EditorPasteRequest> pastes;
|
|
std::vector<EditorObjectSetRequest> set_requests;
|
|
std::vector<EditorDeleteRequest> 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;
|
|
++active->model.editor.revision;
|
|
return true;
|
|
});
|
|
set_park_preview_queue([](const EditorPreviewRequest &request) {
|
|
active->previews.push_back(request);
|
|
auto &model = active->model.editor;
|
|
if (request.action == EditorPreviewAction::begin_move ||
|
|
request.action == EditorPreviewAction::begin_place) {
|
|
model.busy = true;
|
|
model.preview_token = request.token;
|
|
++model.revision;
|
|
} else if (request.action == EditorPreviewAction::commit ||
|
|
request.action == EditorPreviewAction::cancel) {
|
|
model.busy = false;
|
|
model.preview_token = 0;
|
|
++model.revision;
|
|
}
|
|
return true;
|
|
});
|
|
set_park_selection_queue([](const EditorSelectionRequest &request) {
|
|
active->selections.push_back(request);
|
|
return true;
|
|
});
|
|
set_park_surface_queue([](const EditorSurfaceRequest &request) {
|
|
active->probe = request;
|
|
return true;
|
|
});
|
|
callbacks.user = this;
|
|
callbacks.queue_console_command = [](void *context, const char *command, char *, std::size_t) {
|
|
static_cast<Fixture *>(context)->commands.emplace_back(command);
|
|
return true;
|
|
};
|
|
model.debug.park_editor = true;
|
|
model.debug.camera_transform_valid = true;
|
|
model.debug.camera_fov = 60;
|
|
model.debug.camera_transform = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 4, 16, 1};
|
|
model.debug.skater_position_valid = true;
|
|
model.debug.skater_position = {0, 0, 0};
|
|
model.editor.available = true;
|
|
model.editor.map = "bam";
|
|
model.editor.generation = 3;
|
|
model.editor.revision = 7;
|
|
auto assets = std::make_shared<std::vector<EditorAsset>>();
|
|
for (const auto &category : {"Ramps", "Rails", "Quarter Pipes", "Props"})
|
|
for (unsigned i = 0; i < 30; ++i)
|
|
assets->push_back({"own_bk_" + std::string(category) + std::to_string(i),
|
|
std::string(category) + " piece " + std::to_string(i + 1), category});
|
|
model.editor.assets = assets;
|
|
model.editor.objects = {{1, "own_bk_Ramps0", {0, 0, 0}, {0, 0, 0, 1}, true},
|
|
{2, "own_bk_Rails0", {4, 0, 0}, {0, 0, 0, 1}, true}};
|
|
model.editor.park_mods = {{"Warehouse lines", "Warehouse lines", "Zee", "1.0", "", true, true, {"bam"}},
|
|
{"Rooftop session", "Rooftop session", "", "", "", true, false, {"grom"}}};
|
|
model.editor.can_load_parks = true;
|
|
}
|
|
void frame(bool console = false, bool text_input = false) {
|
|
ImGui::NewFrame();
|
|
if (text_input)
|
|
ImGui::GetIO().WantTextInput = true;
|
|
const auto snapshot = model;
|
|
flight = draw_park_editor(ui, snapshot, callbacks, console, false, visible);
|
|
ImGui::Render();
|
|
check(ImGui::GetDrawData()->TotalVtxCount > 0, "Editor produced no UI");
|
|
}
|
|
std::size_t count(EditorPreviewAction action) const {
|
|
return static_cast<std::size_t>(std::count_if(
|
|
previews.begin(), previews.end(), [&](const auto &request) { return request.action == action; }));
|
|
}
|
|
};
|
|
// Small deterministic software rasterizer for layout inspection; no game or GPU.
|
|
void capture(const std::filesystem::path &path, const ImDrawData &data, unsigned char *atlas, int atlas_width,
|
|
int atlas_height) {
|
|
const int width = static_cast<int>(data.DisplaySize.x), height = static_cast<int>(data.DisplaySize.y);
|
|
std::vector<unsigned char> pixels(static_cast<std::size_t>(width) * height * 3);
|
|
for (int y = 0; y < height; ++y)
|
|
for (int x = 0; x < width; ++x) {
|
|
const auto n = (static_cast<std::size_t>(y) * width + x) * 3;
|
|
pixels[n] = 37;
|
|
pixels[n + 1] = 43;
|
|
pixels[n + 2] = 48;
|
|
}
|
|
for (const auto *list : data.CmdLists)
|
|
for (const auto &command : list->CmdBuffer) {
|
|
if (command.UserCallback)
|
|
continue;
|
|
const int min_clip_x = std::max(0, static_cast<int>(command.ClipRect.x)),
|
|
max_clip_x = std::min(width, static_cast<int>(command.ClipRect.z));
|
|
const int min_clip_y = std::max(0, static_cast<int>(command.ClipRect.y)),
|
|
max_clip_y = std::min(height, static_cast<int>(command.ClipRect.w));
|
|
for (unsigned index = 0; index + 2 < command.ElemCount; index += 3) {
|
|
const auto &a = list->VtxBuffer[static_cast<int>(
|
|
command.VtxOffset + list->IdxBuffer[static_cast<int>(command.IdxOffset + index)])];
|
|
const auto &b = list->VtxBuffer[static_cast<int>(
|
|
command.VtxOffset + list->IdxBuffer[static_cast<int>(command.IdxOffset + index + 1)])];
|
|
const auto &c = list->VtxBuffer[static_cast<int>(
|
|
command.VtxOffset + list->IdxBuffer[static_cast<int>(command.IdxOffset + index + 2)])];
|
|
const float area =
|
|
(b.pos.x - a.pos.x) * (c.pos.y - a.pos.y) - (b.pos.y - a.pos.y) * (c.pos.x - a.pos.x);
|
|
if (std::abs(area) < .0001f)
|
|
continue;
|
|
const int x0 = std::max(min_clip_x,
|
|
static_cast<int>(std::floor(std::min({a.pos.x, b.pos.x, c.pos.x})))),
|
|
x1 = std::min(max_clip_x,
|
|
static_cast<int>(std::ceil(std::max({a.pos.x, b.pos.x, c.pos.x}))));
|
|
const int y0 = std::max(min_clip_y,
|
|
static_cast<int>(std::floor(std::min({a.pos.y, b.pos.y, c.pos.y})))),
|
|
y1 = std::min(max_clip_y,
|
|
static_cast<int>(std::ceil(std::max({a.pos.y, b.pos.y, c.pos.y}))));
|
|
for (int y = y0; y < y1; ++y)
|
|
for (int x = x0; x < x1; ++x) {
|
|
const float px = static_cast<float>(x) + .5f, py = static_cast<float>(y) + .5f;
|
|
const float wa =
|
|
((b.pos.x - px) * (c.pos.y - py) - (b.pos.y - py) * (c.pos.x - px)) / area;
|
|
const float wb = ((c.pos.x - px) * (a.pos.y - py) - (c.pos.y - py) * (a.pos.x - px)) /
|
|
area,
|
|
wc = 1 - wa - wb;
|
|
if (wa < 0 || wb < 0 || wc < 0)
|
|
continue;
|
|
const float u = a.uv.x * wa + b.uv.x * wb + c.uv.x * wc,
|
|
v = a.uv.y * wa + b.uv.y * wb + c.uv.y * wc;
|
|
const int tx = std::clamp(static_cast<int>(u * static_cast<float>(atlas_width)), 0,
|
|
atlas_width - 1),
|
|
ty = std::clamp(static_cast<int>(v * static_cast<float>(atlas_height)), 0,
|
|
atlas_height - 1);
|
|
const auto tex = static_cast<std::size_t>(ty * atlas_width + tx) * 4;
|
|
const auto component = [&](unsigned shift) {
|
|
return static_cast<float>((a.col >> shift) & 255) * wa +
|
|
static_cast<float>((b.col >> shift) & 255) * wb +
|
|
static_cast<float>((c.col >> shift) & 255) * wc;
|
|
};
|
|
const float alpha = component(24) * static_cast<float>(atlas[tex + 3]) / (255 * 255);
|
|
const auto out = (static_cast<std::size_t>(y) * width + x) * 3;
|
|
for (unsigned channel = 0; channel < 3; ++channel)
|
|
pixels[out + channel] = static_cast<unsigned char>(
|
|
std::clamp(component(channel * 8) * static_cast<float>(atlas[tex + channel]) /
|
|
255 * alpha +
|
|
static_cast<float>(pixels[out + channel]) * (1 - alpha),
|
|
0.0f, 255.0f));
|
|
}
|
|
}
|
|
}
|
|
std::ofstream stream(path, std::ios::binary);
|
|
stream << "P6\n" << width << ' ' << height << "\n255\n";
|
|
stream.write(reinterpret_cast<const char *>(pixels.data()), static_cast<std::streamsize>(pixels.size()));
|
|
}
|
|
} // namespace
|
|
int main(int argc, char **argv) {
|
|
try {
|
|
ImGui::CreateContext();
|
|
preview_checks();
|
|
auto &io = ImGui::GetIO();
|
|
io.IniFilename = nullptr;
|
|
io.DeltaTime = 1.0f / 60;
|
|
io.DisplaySize = {1280, 720};
|
|
ImFontConfig font_config;
|
|
font_config.SizePixels = 17;
|
|
io.Fonts->AddFontDefault(&font_config);
|
|
// Optional: the installed game's folder, to lay out every real thumbnail.
|
|
if (argc > 2 && std::filesystem::exists(argv[2])) {
|
|
start_park_previews(argv[2]);
|
|
const auto loaded = load_park_previews(*io.Fonts, std::chrono::seconds(30));
|
|
check(loaded == 518, "The game's thumbnails were not loaded completely");
|
|
}
|
|
unsigned char *atlas{};
|
|
int atlas_width{}, atlas_height{};
|
|
io.Fonts->GetTexDataAsRGBA32(&atlas, &atlas_width, &atlas_height);
|
|
io.Fonts->SetTexID(1);
|
|
Fixture fixture;
|
|
fixture.frame();
|
|
fixture.frame();
|
|
const auto press_mode = [&](ImGuiKey key, ParkTransformMode expected) {
|
|
io.AddKeyEvent(key, true);
|
|
fixture.frame();
|
|
io.AddKeyEvent(key, false);
|
|
fixture.frame();
|
|
check(fixture.ui.mode == expected, "Transform-mode keybind selected the wrong gizmo");
|
|
};
|
|
press_mode(ImGuiKey_R, ParkTransformMode::rotate);
|
|
press_mode(ImGuiKey_Y, ParkTransformMode::scale);
|
|
press_mode(ImGuiKey_T, ParkTransformMode::move);
|
|
const auto click_card = [&](float x) {
|
|
io.AddMousePosEvent(x, 320);
|
|
fixture.frame();
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, true);
|
|
fixture.frame();
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, false);
|
|
fixture.frame();
|
|
};
|
|
click_card(210);
|
|
check(fixture.ui.placing == "own_bk_Ramps1", "Second grid column did not select its own asset");
|
|
const std::string filter = "Rails piece 8";
|
|
std::copy(filter.begin(), filter.end(), fixture.ui.search.begin());
|
|
fixture.frame();
|
|
click_card(60);
|
|
check(fixture.ui.placing == "own_bk_Rails7",
|
|
"Grid search did not preserve the filtered asset identity");
|
|
fixture.ui.search = {};
|
|
fixture.ui.category = "Props";
|
|
fixture.frame();
|
|
click_card(60);
|
|
check(fixture.ui.placing == "own_bk_Props0", "Grid category filter selected the wrong object");
|
|
fixture.ui.category.clear();
|
|
fixture.ui.placing.clear();
|
|
fixture.frame();
|
|
io.AddMousePosEvent(640, 360);
|
|
fixture.frame();
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Right, true);
|
|
fixture.frame();
|
|
check(fixture.flight, "RMB inside viewport did not grant flight input");
|
|
fixture.frame(true);
|
|
check(!fixture.flight, "Console failed to inhibit editor flight");
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Right, false);
|
|
fixture.frame();
|
|
io.AddMousePosEvent(90, 200);
|
|
fixture.frame();
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Right, true);
|
|
fixture.frame();
|
|
check(!fixture.flight, "RMB over library enabled camera motion");
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Right, false);
|
|
fixture.frame();
|
|
fixture.ui.selected = 1;
|
|
fixture.ui.position = {1, 2, 3};
|
|
fixture.ui.inspector_dirty = true;
|
|
fixture.ui.dragging = 0;
|
|
io.AddFocusEvent(false);
|
|
fixture.frame();
|
|
check(fixture.ui.dragging == -1 && fixture.ui.position == fixture.model.editor.objects[0].position,
|
|
"Focus loss did not cancel transform drag");
|
|
io.AddFocusEvent(true);
|
|
fixture.frame();
|
|
fixture.ui.selected = 2;
|
|
fixture.ui.pending = true;
|
|
fixture.ui.placing = "own_bk_Ramps0";
|
|
fixture.model.editor.generation++;
|
|
fixture.frame();
|
|
check(!fixture.ui.selected && !fixture.ui.pending && fixture.ui.placing.empty(),
|
|
"World change retained a stale selection or pending placement");
|
|
fixture.ui.selected = 1;
|
|
fixture.frame();
|
|
io.AddMousePosEvent(717, 516);
|
|
fixture.frame();
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, true);
|
|
fixture.frame();
|
|
check(fixture.ui.dragging == 0, "X translation handle did not begin a drag");
|
|
io.AddMousePosEvent(760, 516);
|
|
fixture.frame();
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::update) > 0 &&
|
|
fixture.count(EditorPreviewAction::commit) == 0,
|
|
"Held drag did not send live updates or committed before release");
|
|
check(fixture.ui.position[0] > 0, "X drag did not change the transform preview");
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, false);
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::commit) == 1 && fixture.commands.empty() &&
|
|
fixture.ui.pending && fixture.ui.dragging == -1,
|
|
"Releasing a drag did not submit exactly one move");
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::commit) == 1, "Released drag submitted a duplicate move");
|
|
fixture.model.editor.revision++;
|
|
fixture.frame();
|
|
io.AddMousePosEvent(717, 516);
|
|
fixture.frame();
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, true);
|
|
fixture.frame();
|
|
check(fixture.ui.dragging == 0, "Second drag did not begin");
|
|
io.AddMousePosEvent(760, 516);
|
|
fixture.frame();
|
|
io.AddKeyEvent(ImGuiKey_Escape, true);
|
|
fixture.frame();
|
|
check(fixture.ui.dragging == -1 && fixture.ui.position == fixture.model.editor.objects[0].position,
|
|
"Escape did not discard the drag preview");
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, false);
|
|
io.AddKeyEvent(ImGuiKey_Escape, false);
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::commit) == 1 &&
|
|
fixture.count(EditorPreviewAction::cancel) == 1,
|
|
"Cancelled drag committed instead of restoring");
|
|
fixture.ui.selected = 0;
|
|
fixture.ui.selection.clear();
|
|
fixture.ui.placing = "own_bk_Ramps0";
|
|
io.AddMousePosEvent(640, 420);
|
|
fixture.frame();
|
|
check(fixture.probe.has_value() && fixture.count(EditorPreviewAction::begin_place) == 0,
|
|
"Surface placement did not request real collision before creating an object");
|
|
const auto old_probe = *fixture.probe;
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, true);
|
|
fixture.frame();
|
|
check(fixture.ui.surface_confirm, "Placement click did not wait for its collision result");
|
|
fixture.model.editor.surface = {old_probe.generation, old_probe.id, true, true, {90, 90, 90}};
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::begin_place) == 0,
|
|
"Stale cursor collision result placed an object");
|
|
fixture.model.editor.surface = {
|
|
fixture.probe->generation, fixture.probe->id, true, true, {4, 2.3f, 6}};
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::begin_place) == 1 &&
|
|
fixture.count(EditorPreviewAction::commit) == 2 &&
|
|
std::abs(fixture.ui.position[1] - 2.3f) < .001f,
|
|
"Surface placement did not use the real hit height");
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, false);
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::begin_place) == 1,
|
|
"Surface placement submitted a duplicate create");
|
|
fixture.model.editor.revision++;
|
|
fixture.frame();
|
|
fixture.ui.placing = "own_bk_Ramps0";
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, true);
|
|
fixture.frame();
|
|
fixture.model.editor.surface = {fixture.probe->generation, fixture.probe->id, true, false, {}};
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::begin_place) == 1 && !fixture.ui.surface_confirm &&
|
|
!fixture.ui.surface_valid,
|
|
"Missed collision silently placed on the fake plane");
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, false);
|
|
fixture.frame();
|
|
fixture.ui.placing.clear();
|
|
fixture.ui.selection = {1};
|
|
fixture.ui.selected = 1;
|
|
fixture.ui.draft_revision = 0;
|
|
fixture.frame();
|
|
fixture.ui.mode = ParkTransformMode::scale;
|
|
io.AddMousePosEvent(717, 516);
|
|
fixture.frame();
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, true);
|
|
fixture.frame();
|
|
check(fixture.ui.dragging == 0, "X scale handle did not begin a drag");
|
|
io.AddMousePosEvent(760, 516);
|
|
fixture.frame();
|
|
check(fixture.ui.scale > 1 && !fixture.previews.back().transforms.empty() &&
|
|
fixture.previews.back().transforms[0].scale > 1,
|
|
"Scale gizmo did not submit a live uniform-scale transform");
|
|
const auto scale_commits = fixture.count(EditorPreviewAction::commit);
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, false);
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::commit) == scale_commits + 1,
|
|
"Scale gizmo release did not commit exactly once");
|
|
fixture.model.editor.revision++;
|
|
fixture.frame();
|
|
fixture.ui.mode = ParkTransformMode::move;
|
|
fixture.ui.selected = 0;
|
|
fixture.ui.selection.clear();
|
|
fixture.frame();
|
|
const auto select_hit = [&](std::uint64_t id, bool shift, bool release) {
|
|
io.AddKeyEvent(ImGuiMod_Shift, shift);
|
|
io.AddMousePosEvent(680, 516); // On the X shaft, away from its tip: body clicks must move freely.
|
|
fixture.frame();
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, true);
|
|
fixture.frame();
|
|
check(fixture.ui.pick_confirm && fixture.probe->picking,
|
|
"Object body click did not query collision");
|
|
fixture.model.editor.surface = {
|
|
fixture.probe->generation, fixture.probe->id, true, true, {4, 0, 0}, 100, id};
|
|
fixture.frame();
|
|
if (release) {
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, false);
|
|
fixture.frame();
|
|
}
|
|
};
|
|
select_hit(1, true, true);
|
|
select_hit(2, true, true);
|
|
check(fixture.ui.selection.size() == 2 && fixture.ui.position == editor::Vec3{2, 0, 0} &&
|
|
fixture.selections.back().objects == fixture.ui.selection,
|
|
"Shift-click did not select and highlight a group around its shared pivot");
|
|
select_hit(2, true, true);
|
|
check(fixture.ui.selection == std::vector<std::uint64_t>{1},
|
|
"Shift-click did not remove a group member");
|
|
select_hit(2, true, true);
|
|
const auto committed_before_free = fixture.count(EditorPreviewAction::commit);
|
|
select_hit(1, false, false);
|
|
check(fixture.ui.dragging == 3 && fixture.previews.back().transforms.size() == 2 &&
|
|
!fixture.probe->picking,
|
|
"Dragging a selected object's body did not begin a free group move");
|
|
fixture.model.editor.surface = {fixture.probe->generation, fixture.probe->id, true, true, {4, 0, 0}};
|
|
fixture.frame();
|
|
check(fixture.ui.free_anchor, "Free drag did not preserve its initial grab offset");
|
|
io.AddMousePosEvent(790, 430);
|
|
fixture.frame();
|
|
fixture.model.editor.surface = {fixture.probe->generation, fixture.probe->id, true, true, {7, 2, 1}};
|
|
fixture.frame();
|
|
check(fixture.previews.back().transforms[0].position == editor::Vec3{3, 2, 1} &&
|
|
fixture.previews.back().transforms[1].position == editor::Vec3{7, 2, 1},
|
|
"Free dragging did not preserve group spacing and surface height");
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, false);
|
|
fixture.frame();
|
|
check(fixture.ui.free_release && fixture.count(EditorPreviewAction::commit) == committed_before_free,
|
|
"Free release committed an old collision result");
|
|
fixture.model.editor.surface = {
|
|
fixture.probe->generation, fixture.probe->id, true, true, {8, 2.5f, 2}};
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::commit) == committed_before_free + 1 &&
|
|
fixture.previews.back().transforms[0].position == editor::Vec3{4, 2.5f, 2},
|
|
"Free release failed to use its final ground hit");
|
|
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; // 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 =
|
|
std::sqrt(editor::dot(editor::sub(pivot, camera.origin()), editor::sub(pivot, camera.origin()))) *
|
|
.12f * .7f;
|
|
const auto ring_start = camera.project(editor::add(pivot, {radius * .7071f, radius * .7071f, 0}));
|
|
const auto ring_end = camera.project(editor::add(pivot, {-radius * .7071f, radius * .7071f, 0}));
|
|
io.AddMousePosEvent((*ring_start)[0], (*ring_start)[1]);
|
|
fixture.frame();
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, true);
|
|
fixture.frame();
|
|
check(fixture.ui.dragging == 2, "Group rotation ring did not start a drag");
|
|
io.AddMousePosEvent((*ring_end)[0], (*ring_end)[1]);
|
|
fixture.frame();
|
|
const auto &rotated_group = fixture.previews.back().transforms;
|
|
check(rotated_group.size() == 2 && std::abs(rotated_group[0].position[0] - 2) < .001f &&
|
|
std::abs(rotated_group[0].position[1] + 2) < .001f &&
|
|
std::abs(rotated_group[1].position[1] - 2) < .001f,
|
|
"Group rotation changed orientations without orbiting the shared pivot");
|
|
io.AddKeyEvent(ImGuiKey_Escape, true);
|
|
fixture.frame();
|
|
io.AddKeyEvent(ImGuiKey_Escape, false);
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, false);
|
|
fixture.frame();
|
|
fixture.ui.mode = ParkTransformMode::move;
|
|
// Asset enters the viewport while the mouse is still held.
|
|
fixture.ui.selection.clear();
|
|
fixture.ui.selected = 0;
|
|
fixture.ui.placing = "own_bk_Ramps0";
|
|
fixture.ui.surface_request = {};
|
|
const auto spawned_previews = fixture.count(EditorPreviewAction::begin_place);
|
|
io.AddMousePosEvent(740, 420);
|
|
fixture.frame();
|
|
fixture.model.editor.surface = {fixture.probe->generation, fixture.probe->id, true, true, {1, 2, 3}};
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::begin_place) == spawned_previews + 1 &&
|
|
fixture.ui.preview_token,
|
|
"Asset did not become a live native preview before placement");
|
|
io.AddMousePosEvent(780, 440);
|
|
fixture.frame();
|
|
fixture.model.editor.surface = {fixture.probe->generation, fixture.probe->id, true, true, {4, 5, 6}};
|
|
fixture.frame();
|
|
check(fixture.previews.back().position == editor::Vec3{4, 5, 6} &&
|
|
fixture.count(EditorPreviewAction::begin_place) == spawned_previews + 1,
|
|
"Live asset did not follow ground without spawning another copy");
|
|
fixture.frame(true);
|
|
check(!fixture.ui.preview_token && fixture.previews.back().action == EditorPreviewAction::cancel,
|
|
"Console/focus inhibition left a live preview behind");
|
|
fixture.ui.placing.clear();
|
|
fixture.frame();
|
|
// 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, 340);
|
|
fixture.frame();
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, true);
|
|
fixture.frame();
|
|
io.AddMousePosEvent(130, 345);
|
|
fixture.frame();
|
|
check(fixture.ui.asset_drag, "Library row did not start an ImGui drag payload");
|
|
io.AddMousePosEvent(700, 420);
|
|
fixture.frame();
|
|
fixture.frame();
|
|
check(!fixture.ui.placing.empty() && fixture.probe,
|
|
"Viewport did not accept the asset payload before release");
|
|
fixture.model.editor.surface = {fixture.probe->generation, fixture.probe->id, true, true, {6, 1, 2}};
|
|
fixture.frame();
|
|
check(fixture.count(EditorPreviewAction::begin_place) == dragdrop_creates + 1 &&
|
|
fixture.ui.preview_token,
|
|
"Dragging a library row did not spawn an object before mouse release");
|
|
io.AddMouseButtonEvent(ImGuiMouseButton_Left, false);
|
|
fixture.frame();
|
|
check(fixture.ui.surface_confirm, "Asset payload delivery did not request the final surface");
|
|
fixture.model.editor.surface = {
|
|
fixture.probe->generation, fixture.probe->id, true, true, {7, 1.5f, 3}};
|
|
fixture.frame();
|
|
check(fixture.ui.placing.empty() && fixture.previews.back().action == EditorPreviewAction::commit &&
|
|
fixture.previews.back().position == editor::Vec3{7, 1.5f, 3} &&
|
|
fixture.count(EditorPreviewAction::begin_place) == dragdrop_creates + 1,
|
|
"Asset drop respawned the preview or lost its final position");
|
|
fixture.frame();
|
|
const auto directory = std::filesystem::absolute(argc > 1 ? argv[1] : "build/editor-preview");
|
|
std::filesystem::create_directories(directory);
|
|
fixture.ui.selection = {1, 2};
|
|
fixture.ui.selected = 2;
|
|
fixture.frame();
|
|
io.AddKeyEvent(ImGuiMod_Ctrl, true);
|
|
io.AddKeyEvent(ImGuiKey_C, true);
|
|
fixture.frame();
|
|
check(fixture.ui.clipboard.size() == 2 && fixture.pastes.empty(),
|
|
"Ctrl+C did not copy the selection without creating objects");
|
|
io.AddKeyEvent(ImGuiKey_C, false);
|
|
fixture.frame();
|
|
const auto copied = fixture.ui.clipboard;
|
|
fixture.model.editor.objects[0].position[0] += 12;
|
|
io.AddKeyEvent(ImGuiKey_V, true);
|
|
fixture.frame(false, true);
|
|
check(fixture.pastes.empty(), "Ctrl+V in a text field pasted game objects");
|
|
io.AddKeyEvent(ImGuiKey_V, false);
|
|
fixture.frame();
|
|
io.AddKeyEvent(ImGuiKey_V, true);
|
|
fixture.frame();
|
|
check(fixture.pastes.size() == 1 && fixture.pastes.back().objects.size() == 2,
|
|
"Ctrl+V did not paste the complete copied group");
|
|
const auto pasted = fixture.pastes.back().objects;
|
|
for (std::size_t i = 0; i < copied.size(); ++i)
|
|
check(pasted[i].item == copied[i].item && pasted[i].rotation == copied[i].rotation &&
|
|
pasted[i].scale == copied[i].scale &&
|
|
pasted[i].position ==
|
|
editor::add(copied[i].position, {fixture.ui.grid, 0, fixture.ui.grid}),
|
|
"Clipboard did not preserve the copied snapshot, spacing, or rotation");
|
|
for (unsigned i = 0; i < 30; ++i)
|
|
fixture.frame();
|
|
check(fixture.pastes.size() == 1, "Holding Ctrl+V repeatedly created objects");
|
|
io.AddKeyEvent(ImGuiKey_V, false);
|
|
io.AddKeyEvent(ImGuiMod_Ctrl, false);
|
|
for (auto object : pasted) {
|
|
object.id = 100 + fixture.model.editor.objects.size();
|
|
object.spawned = true;
|
|
fixture.model.editor.objects.push_back(object);
|
|
}
|
|
fixture.model.editor.busy = false;
|
|
++fixture.model.editor.revision;
|
|
fixture.frame();
|
|
check(fixture.ui.selection == std::vector<std::uint64_t>{102, 103},
|
|
"Completed paste did not select the newly created objects for group movement");
|
|
++fixture.model.editor.generation;
|
|
fixture.frame();
|
|
check(fixture.ui.clipboard.size() == 2 && fixture.ui.paste_expected.empty(),
|
|
"Level change lost the clipboard or retained a stale paste selection");
|
|
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<std::uint64_t>{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<std::vector<EditorObjectSet>>();
|
|
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])) {
|
|
const auto &items = content_cache::catalogs().items;
|
|
auto assets = std::make_shared<std::vector<EditorAsset>>();
|
|
for (const auto &[key, value] : items.items()) {
|
|
ParkPreviewImage image;
|
|
if (park_preview_image(key, image))
|
|
assets->push_back({key, value.value("title", key), "Build Kit"});
|
|
}
|
|
if (!assets->empty())
|
|
fixture.model.editor.assets = assets;
|
|
}
|
|
for (const ImVec2 size : std::array{ImVec2{1280, 720}, ImVec2{1920, 1080}, ImVec2{1024, 768}}) {
|
|
io.DisplaySize = size;
|
|
fixture.ui.selected = 1;
|
|
fixture.ui.position = {0, 0, 0};
|
|
fixture.ui.angles = {};
|
|
fixture.ui.mode = ParkTransformMode::move;
|
|
fixture.frame();
|
|
fixture.frame();
|
|
capture(directory / (std::to_string(static_cast<int>(size.x)) + ".ppm"), *ImGui::GetDrawData(),
|
|
atlas, atlas_width, atlas_height);
|
|
}
|
|
clear_park_previews();
|
|
ImGui::DestroyContext();
|
|
std::cout
|
|
<< "Park editor UI layout, input focus, transform drag and world-transition tests passed.\n";
|
|
return 0;
|
|
} catch (const std::exception &e) {
|
|
std::cerr << e.what() << '\n';
|
|
return 1;
|
|
}
|
|
}
|