mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 21:25: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>
372 lines
21 KiB
C++
372 lines
21 KiB
C++
#include "Extension/Objects/ParkEditor/editor_math.h"
|
|
#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 <Windows.h>
|
|
#include <cmath>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <limits>
|
|
#include <stdexcept>
|
|
|
|
namespace {
|
|
void check(bool value, const char *message) {
|
|
if (!value)
|
|
throw std::runtime_error(message);
|
|
}
|
|
bool close_float(float a, float b) {
|
|
return std::abs(a - b) < .001f;
|
|
}
|
|
template <class F> void rejects(F &&fn) {
|
|
bool rejected = false;
|
|
try {
|
|
fn();
|
|
} catch (...) {
|
|
rejected = true;
|
|
}
|
|
check(rejected, "Invalid input accepted");
|
|
}
|
|
namespace surface_fixture {
|
|
using namespace dingosdk;
|
|
using namespace dingosdk::editor;
|
|
std::array<float, 2> fractions{};
|
|
std::array<std::uintptr_t, 7> data{};
|
|
unsigned calls{}, released{}, scopes{};
|
|
unsigned expected_bodies{};
|
|
bool miss{}, corrupt{};
|
|
void pop_scope(std::uintptr_t, void *) {
|
|
++scopes;
|
|
}
|
|
std::array<std::uintptr_t, 2> allocator_vtable{0, reinterpret_cast<std::uintptr_t>(&pop_scope)};
|
|
std::uintptr_t allocator = reinterpret_cast<std::uintptr_t>(allocator_vtable.data());
|
|
NativeSurfaceApi api{
|
|
[](std::uintptr_t) -> std::uintptr_t { return 1234; },
|
|
[](std::uintptr_t world, NativeSurfaceResult *out, const NativeSurfaceRay *ray, const char *) -> void * {
|
|
++calls;
|
|
check(ray->filter == 0x40080303 &&
|
|
(ray->ignored_bodies[1] - ray->ignored_bodies[0]) / 16 == expected_bodies,
|
|
"Surface query used the wrong native filter or exclude-vector layout");
|
|
// Independent sloping surface y = .3*x + 2, with unsorted hits.
|
|
const float start = ray->start[1] - .3f * ray->start[0] - 2;
|
|
const float end = ray->end[1] - .3f * ray->end[0] - 2;
|
|
const float t = start / (start - end);
|
|
fractions = {std::min(1.0f, t + .1f), t};
|
|
data[1] = reinterpret_cast<std::uintptr_t>(fractions.data());
|
|
out->world = world;
|
|
out->data = reinterpret_cast<std::uintptr_t>(data.data());
|
|
out->allocator = 1;
|
|
out->count = corrupt ? 9999U : (miss ? 0U : 2U);
|
|
out->scope[2] = 1;
|
|
return out;
|
|
},
|
|
[]() -> std::uintptr_t { return reinterpret_cast<std::uintptr_t>(&allocator); },
|
|
[](void *, std::uintptr_t) { ++released; }, true};
|
|
void test() {
|
|
EditorSurfaceRequest request{3, 7, {1.3f, 10, 2.4f}, {0, -1, 0}, 1};
|
|
const auto result = probe_surface(api, 9, request);
|
|
check(result.available && result.hit && result.id == 7 && close_float(result.position[0], 1) &&
|
|
close_float(result.position[1], 2.3f) && close_float(result.position[2], 2),
|
|
"Surface snapping used a fake plane, rounded terrain height or selected the farther hit");
|
|
check(calls == 2 && released == calls && scopes == calls, "Native ray results leaked allocation scopes");
|
|
const SurfaceOwner owner = [](std::uintptr_t, std::uint32_t index, std::uintptr_t) -> std::uint64_t {
|
|
return index == 1 ? 55 : 77;
|
|
};
|
|
request.grid = 0;
|
|
const auto picked = probe_surface(api, 9, request, owner);
|
|
check(picked.entity == 55, "Collision picking selected a farther body");
|
|
const std::array<std::uint64_t, 1> ignored{55};
|
|
check(probe_surface(api, 9, request, owner, ignored).entity == 77,
|
|
"Placement ray hit the dragged object itself");
|
|
const std::array<std::uint64_t, 2> group{55, 77};
|
|
check(!probe_surface(api, 9, request, owner, group).hit,
|
|
"Group drag failed to exclude every selected body");
|
|
const SurfaceOwner occluded = [](std::uintptr_t, std::uint32_t index, std::uintptr_t) -> std::uint64_t {
|
|
return index == 1 ? 0 : 77;
|
|
};
|
|
check(probe_surface(api, 9, request, occluded).entity == 0,
|
|
"Picking selected through static world collision");
|
|
expected_bodies = 2;
|
|
const std::array<SurfaceBody, 3> bodies{{{1234, 9}, {1234, 10}, {9999, 11}}};
|
|
check(probe_surface(api, 9, request, nullptr, {}, bodies).hit,
|
|
"Native ignore-body array did not survive the surface query");
|
|
expected_bodies = 0;
|
|
miss = true;
|
|
check(!probe_surface(api, 9, request).hit, "A ray miss fell back to a fake ground plane");
|
|
miss = false;
|
|
corrupt = true;
|
|
check(!probe_surface(api, 9, request).hit && released == calls && scopes == calls,
|
|
"Invalid native ray results were used or not released");
|
|
request.direction = {};
|
|
const auto before = calls;
|
|
check(!probe_surface(api, 9, request).hit && before == calls, "Invalid ray reached native physics");
|
|
}
|
|
} // namespace surface_fixture
|
|
namespace closest_surface_fixture {
|
|
using namespace dingosdk;
|
|
using namespace dingosdk::editor;
|
|
unsigned calls{}, released{}, current_body{};
|
|
bool ignore_broken{};
|
|
constexpr std::uint64_t generation = std::uint64_t{7} << 32;
|
|
NativeSurfaceApi api{
|
|
[](std::uintptr_t) -> std::uintptr_t { return 1234; },
|
|
[](std::uintptr_t world, NativeSurfaceResult *out, const NativeSurfaceRay *ray, const char *) -> void * {
|
|
++calls;
|
|
current_body = 0;
|
|
const auto *begin = reinterpret_cast<const SurfaceBody *>(ray->ignored_bodies[0]);
|
|
const auto *end = reinterpret_cast<const SurfaceBody *>(ray->ignored_bodies[1]);
|
|
if (!ignore_broken)
|
|
while (current_body < 3 && begin &&
|
|
std::find(begin, end, SurfaceBody{world, generation | current_body}) != end)
|
|
++current_body;
|
|
// Two children of the dragged object, another selected object, then terrain.
|
|
const float height = 8.0f - static_cast<float>(current_body) * 2;
|
|
static float fraction{};
|
|
fraction = (ray->start[1] - height) / (ray->start[1] - ray->end[1]);
|
|
static std::array<std::uintptr_t, 7> data{0, reinterpret_cast<std::uintptr_t>(&fraction)};
|
|
out->world = world;
|
|
out->data = reinterpret_cast<std::uintptr_t>(data.data());
|
|
out->count = 1; // A closest-only query hides the ground behind self collision.
|
|
out->allocator = 1;
|
|
return out;
|
|
},
|
|
nullptr, [](void *, std::uintptr_t) { ++released; }, true};
|
|
const SurfaceOwner owner = [](std::uintptr_t, std::uint32_t, std::uintptr_t) -> std::uint64_t {
|
|
return current_body < 2 ? 55 : (current_body == 2 ? 77 : 0);
|
|
};
|
|
const SurfaceBodyReader reader = [](std::uintptr_t world, std::uint32_t, std::uintptr_t, SurfaceBody &out) {
|
|
out = {world, generation | current_body};
|
|
return true;
|
|
};
|
|
void test() {
|
|
EditorSurfaceRequest request{3, 7, {1.3f, 12, 2.4f}, {0, -1, 0}, 1};
|
|
const std::array<std::uint64_t, 2> group{55, 77};
|
|
const auto ground = probe_surface(api, 9, request, owner, group, {}, reader);
|
|
check(ground.hit && ground.entity == 0 && ground.position == Vec3{1, 2, 2} && calls == 8,
|
|
"Closest-only self hits hid the ground or snapping failed to exclude every collision child");
|
|
check(released == calls, "Retried self-collision queries leaked native results");
|
|
request.grid = 0;
|
|
const std::array<std::uint64_t, 1> single{55};
|
|
const auto ramp = probe_surface(api, 9, request, owner, single, {}, reader);
|
|
check(ramp.hit && ramp.entity == 77 && close_float(ramp.position[1], 4),
|
|
"Self exclusion skipped a nonselected ramp");
|
|
const auto picked = probe_surface(api, 9, request, owner, {}, {}, reader);
|
|
check(picked.hit && picked.entity == 55 && close_float(picked.position[1], 8),
|
|
"Picking excluded the object it should select");
|
|
ignore_broken = true;
|
|
const auto before = calls;
|
|
check(!probe_surface(api, 9, request, owner, group, {}, reader).hit && calls == before + 2,
|
|
"A native query ignoring its exclusion list looped or snapped onto self collision");
|
|
check(released == calls, "Failed exclusions leaked native results");
|
|
}
|
|
} // namespace closest_surface_fixture
|
|
namespace highlight_fixture {
|
|
using namespace dingosdk::editor;
|
|
std::array<std::uint8_t, 24> component{};
|
|
unsigned finished{}, notified{};
|
|
NativeHighlightApi api{[](const void *, NativeHighlightScope *scope) -> void * {
|
|
scope->type = 1;
|
|
scope->component = reinterpret_cast<std::uintptr_t>(component.data());
|
|
return scope;
|
|
},
|
|
[](NativeHighlightScope *scope) {
|
|
++finished;
|
|
notified += scope->dirty ? 1U : 0U;
|
|
},
|
|
nullptr, true};
|
|
void test() {
|
|
const HighlightValue selected{7, 1};
|
|
const auto old = highlight_value(api, nullptr, selected);
|
|
check(old && *old == HighlightValue{0, 0} && finished == 1 && notified == 1,
|
|
"Selection highlight failed to publish its component update");
|
|
check(highlight_value(api, nullptr) == selected, "Selection highlight did not apply the outline color");
|
|
highlight_value(api, nullptr, old, selected);
|
|
check(highlight_value(api, nullptr) == old, "Deselection did not restore the original highlight");
|
|
highlight_value(api, nullptr, HighlightValue{9, 1});
|
|
highlight_value(api, nullptr, old, selected);
|
|
check(highlight_value(api, nullptr) == HighlightValue{9, 1},
|
|
"Highlight restore overwrote another system's change");
|
|
check(finished == 7 && notified == 3, "Highlight scope notified on reads or leaked a native scope");
|
|
}
|
|
} // namespace highlight_fixture
|
|
} // namespace
|
|
int main(int argc, char **argv) {
|
|
try {
|
|
using namespace dingosdk;
|
|
using namespace editor;
|
|
surface_fixture::test();
|
|
closest_surface_fixture::test();
|
|
highlight_fixture::test();
|
|
const Camera camera{{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 3, 12, 1}, 60, 1600, 900};
|
|
const auto center = camera.project({10, 3, 0});
|
|
check(center && close_float((*center)[0], 800) && close_float((*center)[1], 450),
|
|
"Camera origin/projection differs");
|
|
check(!camera.project({10, 3, 13}), "Points behind the camera are pickable");
|
|
const Vec3 target{12, 1, 0};
|
|
const auto screen = camera.project(target);
|
|
check(screen.has_value(), "Front point rejected");
|
|
const auto hit =
|
|
plane_hit(camera.origin(), camera.ray((*screen)[0], (*screen)[1]), target, {0, 1, 0});
|
|
check(hit && close_float((*hit)[0], target[0]) && close_float((*hit)[1], target[1]) &&
|
|
close_float((*hit)[2], target[2]),
|
|
"Projection/ray/plane round trip differs");
|
|
check(!plane_hit({0, 1, 0}, {1, 0, 0}, {0, 0, 0}, {0, 1, 0}), "Parallel ray hit plane");
|
|
check(!plane_hit({0, 1, 0}, {0, 1, 0}, {0, 0, 0}, {0, 1, 0}), "Backward ray hit plane");
|
|
const auto parameter = axis_parameter({0, 1, 5}, normalized({2, -1, -5}), {0, 0, 0}, {1, 0, 0});
|
|
check(parameter && close_float(*parameter, 2), "Axis drag does not follow cursor ray");
|
|
check(!axis_parameter({0, 0, 0}, {1, 0, 0}, {1, 0, 0}, {1, 0, 0}), "Parallel axis drag is unstable");
|
|
check(close_float(snapped(-1.26f, .5f), -1.5f) && close_float(snapped(1.26f, .5f), 1.5f),
|
|
"Negative snapping differs");
|
|
const auto q = rotation({25, 41, -73});
|
|
const auto roundtrip = rotation(angles(q));
|
|
float product = 0;
|
|
for (unsigned i = 0; i < 4; ++i)
|
|
product += q[i] * roundtrip[i];
|
|
check(std::abs(product) > .99999f, "Rotation round trip differs");
|
|
const auto turned = rotated({1, 0, 0}, rotation({0, 90, 0}));
|
|
check(close_float(turned[0], 0) && close_float(turned[2], -1), "Local axis rotation differs");
|
|
for (const auto name : {"", "../park", "A/B", "park.json", "CON", "nul", "LPT2", " trailing ", "x:y"})
|
|
check(!valid_park_name(name), "Unsafe park filename accepted");
|
|
check(valid_park_name("Warehouse - Session 2"), "Normal park name rejected");
|
|
ParkDocument park{"bam",
|
|
{{1, "own_bk_ramp", {2, 3, 4}, q, 2.5f},
|
|
{2, "own_bk_rail", {5, 6, 7}, {0, 0, 0, 1}}}};
|
|
const auto encoded = encode_park(park);
|
|
const auto decoded = decode_park(encoded);
|
|
check(encoded.find("object_scales") != std::string::npos && decoded.map == park.map &&
|
|
decoded.objects == park.objects,
|
|
"Park save round trip lost uniform object scale");
|
|
auto applied = park.objects[0];
|
|
applied.id = 1;
|
|
applied.scale = 1;
|
|
check(profile_runtime::placement_matches_applied(park.objects[0], applied) &&
|
|
applied.id == park.objects[0].id && applied.scale == park.objects[0].scale,
|
|
"A scaled restore acknowledgement did not recover its requested scale/identity");
|
|
auto unrelated = applied;
|
|
unrelated.id = 1;
|
|
unrelated.scale = 1;
|
|
unrelated.position[0] += 1;
|
|
check(!profile_runtime::placement_matches_applied(park.objects[0], unrelated) &&
|
|
unrelated.id == 1 && unrelated.scale == 1,
|
|
"A nonmatching placement was mutated or claimed as a restore acknowledgement");
|
|
auto bad = park;
|
|
bad.objects[1].id = 1;
|
|
rejects([&] { encode_park(bad); });
|
|
bad = park;
|
|
bad.objects[0].rotation = {0, 0, 0, 0};
|
|
rejects([&] { encode_park(bad); });
|
|
bad = park;
|
|
bad.objects[0].position[0] = std::numeric_limits<float>::quiet_NaN();
|
|
rejects([&] { encode_park(bad); });
|
|
bad = park;
|
|
bad.objects[0].scale = 0;
|
|
rejects([&] { encode_park(bad); });
|
|
auto unknown_scale = Json::parse(encoded);
|
|
unknown_scale["object_scales"]["bam"]["999"] = 2;
|
|
rejects([&] { decode_park(unknown_scale.dump()); });
|
|
rejects([&] { decode_park("{\"schema_version\":99}"); });
|
|
rejects([&] { decode_park(std::string(2 * 1024 * 1024 + 1, ' ')); });
|
|
const auto directory = std::filesystem::absolute(argc > 1 ? argv[1] : "build/editor-fixtures");
|
|
const auto name = "Round trip " + std::to_string(GetCurrentProcessId());
|
|
save_park(directory, name, park);
|
|
check(load_park(directory, name).objects == park.objects, "On-disk park save differs");
|
|
bad = park;
|
|
bad.objects[0].rotation = {};
|
|
rejects([&] { save_park(directory, name, bad); });
|
|
check(load_park(directory, name).objects == park.objects, "Rejected save overwrote previous park");
|
|
const auto stray = directory / L"\U0001F6F9 notes.txt"; // No ANSI code page can hold this name.
|
|
std::ofstream(stray).put('x');
|
|
const auto names = list_parks(directory);
|
|
check(std::find(names.begin(), names.end(), name) != names.end(), "Saved park missing from browser");
|
|
std::filesystem::remove(stray);
|
|
std::filesystem::remove(directory / (name + ".park.json"));
|
|
// Park mods: <data>/Mods/<folder>/manifest.json + parks/<map>.park.json.
|
|
{
|
|
const auto data_root = directory / ("mods-" + std::to_string(GetCurrentProcessId()));
|
|
const auto mods_root = data_root / "Mods";
|
|
std::filesystem::remove_all(data_root);
|
|
const auto read_json = [](const std::filesystem::path &path) {
|
|
std::ifstream input(path, std::ios::binary);
|
|
return Json::parse(std::string(std::istreambuf_iterator<char>(input), {}));
|
|
};
|
|
rejects([&] { create_park_mod(mods_root, {"", "Zee", "1.0", ""}); });
|
|
rejects([&] { create_park_mod(mods_root, {"Park", "", "", std::string(513, 'x')}); });
|
|
check(!std::filesystem::exists(mods_root / "Park"), "A rejected park mod left a folder behind");
|
|
const auto folder = create_park_mod(mods_root, {"Pier Park!", "Zee", "1.0.0", "Lines by the water."});
|
|
check(folder == "Pier Park_", "Park mod folder was not made safe");
|
|
check(create_park_mod(mods_root, {"Pier Park!", "", "", ""}) == "Pier Park_ 2", "Duplicate mod names collided");
|
|
const auto manifest = read_json(mods_root / folder / "manifest.json");
|
|
check(manifest.value("name", std::string{}) == "Pier Park!" && manifest.value("author", std::string{}) == "Zee" &&
|
|
manifest.value("version_number", std::string{}) == "1.0.0" &&
|
|
manifest.value("description", std::string{}) == "Lines by the water.",
|
|
"manifest.json lacks name, author, version_number or description");
|
|
save_mod_park(mods_root, folder, park);
|
|
check(load_mod_park(mods_root, folder, "bam").objects == park.objects, "Park mod round trip differs");
|
|
rejects([&] { load_mod_park(mods_root, folder, "grom"); });
|
|
rejects([&] { load_mod_park(mods_root, folder, "../bam"); });
|
|
rejects([&] { save_mod_park(mods_root, "Not Installed", park); });
|
|
rejects([&] { load_mod_park(mods_root, "..", "bam"); });
|
|
auto extended = manifest;
|
|
extended["website_url"] = "https://example.com";
|
|
std::ofstream(mods_root / folder / "manifest.json", std::ios::binary | std::ios::trunc) << extended.dump();
|
|
save_park_mod_details(mods_root, folder, {"Pier Park", "Zee", "1.1.0", "Updated."});
|
|
check(read_json(mods_root / folder / "manifest.json").value("website_url", std::string{}) ==
|
|
"https://example.com",
|
|
"Saving details dropped other manifest fields");
|
|
const auto found_mods = list_park_mods(data_root);
|
|
const auto found = std::find_if(found_mods.begin(), found_mods.end(),
|
|
[&](const auto &mod) { return mod.folder == folder; });
|
|
check(found != found_mods.end() && found->details.title == "Pier Park" && found->details.version == "1.1.0" &&
|
|
found->maps == std::vector<std::string>{"bam"} && found->enabled,
|
|
"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) {
|
|
std::cerr << error.what() << '\n';
|
|
return 1;
|
|
}
|
|
}
|