Files
tsullivan13andClaude Opus 5.5 d9bac524db Park editor: saved object sets; duplicate selections; group local axes
Sets: save a selection under a name and place it again anywhere, on any map.

- The library gets an OBJECTS / SETS switch. SETS saves the selected
  objects as a set and lists the saved sets to place or delete (Delete asks
  for a second press).
- A set is a park file in <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>
2026-10-13 09:02:41 -07:00

313 lines
14 KiB
C++

#include "park_editor_runtime.h"
#include "park_object_sets.h"
#include "park_editor_internal.h"
#include "Extension/Objects/local_placements_runtime.h"
namespace dingosdk::profile_runtime::park_editor_detail {
void update_preview_target(Transaction &tx) {
if (tx.steps.empty())
return;
auto &preview = *tx.preview;
auto &step = tx.steps.front();
if (step.kind == Kind::create) {
if (!tx.sent) {
if (preview.cancelled)
tx.steps.clear();
else
step.object = preview.target;
}
return; // A sent create must acknowledge its original pose first.
}
if (preview.cancelled && preview.placing) {
if (step.kind != Kind::erase) {
step.kind = Kind::erase;
step.acknowledged = false;
tx.sent = false;
tx.waiting_since = GetTickCount64();
}
} else {
for (auto &move : tx.steps) {
const auto *target = find_object(tx.change.after, move.object.id);
if (target && !placement_same_pose(move.object, *target)) {
move.object = *target;
move.acknowledged = false;
move.live_dirty = true;
}
}
}
}
void cancel_preview(Transaction &tx) {
auto &preview = *tx.preview;
preview.ending = preview.cancelled = true;
tx.change.after = tx.change.before;
if (!preview.placing)
preview.target = *find_object(tx.change.before, preview.target.id);
update_preview_target(tx);
editor_state().status = "Restoring the park after cancelling the preview...";
}
void expire_preview() {
auto &e = editor_state();
if (e.transaction && e.transaction->preview && !e.transaction->preview->ending &&
GetTickCount64() > e.transaction->preview->last_sample + 2000)
cancel_preview(*e.transaction);
}
} // namespace dingosdk::profile_runtime::park_editor_detail
namespace dingosdk {
using namespace profile_runtime;
using namespace profile_runtime::park_editor_detail;
bool queue_local_park_paste(const EditorPasteRequest &request) {
std::lock_guard lock(local_runtime().native_mutex);
auto &e = editor_state();
auto &r = placements_runtime();
try {
if (!local_runtime().active || !placement_session_ready() || !idle() ||
request.generation != e.generation || request.revision != e.revision || request.objects.empty() ||
request.objects.size() > 1024 || !e.assets)
return false;
auto target = r.document.maps[r.map];
if (target.size() + request.objects.size() > 1024 || r.next_id > UINT64_MAX - request.objects.size())
return false;
// The session's own limit on each player: copies that would pass it are not made.
if (const auto limit = lobby_object_limit(); limit && target.size() + request.objects.size() > limit) {
e.status = object_limit_notice;
return false;
}
auto next_id = r.next_id;
for (const auto &copy : request.objects) {
if (std::none_of(e.assets->begin(), e.assets->end(),
[&](const auto &asset) { return asset.key == copy.item; }))
return false;
profile::PlacedObject object{next_id++, copy.item, copy.position, copy.rotation, copy.scale};
if (!profile::valid_placed_object(object))
return false;
target.push_back(std::move(object));
}
// One validated layout change gives the entire pasted group one undo step.
if (!begin(std::move(target), 0))
return false;
r.next_id = next_id;
return true;
} catch (...) {
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<std::uint64_t> 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();
auto &r = placements_runtime();
try {
if (!lobby_object_placement_allowed() && request.action != EditorPreviewAction::cancel) return false;
if (!local_runtime().active || !placement_session_ready() || request.generation != e.generation ||
!request.token || !request.sequence)
return false;
const bool placing = request.action == EditorPreviewAction::begin_place;
const bool starting = placing || request.action == EditorPreviewAction::begin_move;
if (starting) {
if (!idle() || request.revision != e.revision || request.token <= e.last_preview_token)
return false;
const auto &layout = r.document.maps[r.map];
profile::PlacedObject object;
std::uint64_t entity{};
if (placing) {
if (const auto limit = lobby_object_limit(); limit && layout.size() >= limit) {
e.status = object_limit_notice;
return false;
}
if (layout.size() >= 1024 || r.next_id == UINT64_MAX || !e.assets ||
std::none_of(e.assets->begin(), e.assets->end(),
[&](const auto &asset) { return asset.key == request.item; }))
return false;
object.id = r.next_id;
object.item = request.item;
} else {
const auto *saved = find_object(layout, request.object);
const auto row = std::find_if(r.rows.begin(), r.rows.end(), [&](const auto &candidate) {
return candidate.saved_id == request.object && candidate.spawned;
});
if (!saved || row == r.rows.end())
return false;
object = *saved;
entity = row->entity;
}
object.position = request.position;
object.rotation = request.rotation;
object.scale = request.scale;
if (!profile::valid_placed_object(object))
return false;
Transaction tx;
tx.change = {layout, layout};
if (placing) {
tx.change.after.push_back(object);
++r.next_id;
} else {
for (auto &row : tx.change.after)
if (row.id == object.id)
row = object;
}
tx.steps.push_back({placing ? Kind::create : Kind::move, object, entity});
if (!placing && !request.transforms.empty()) {
if (request.transforms.size() > 1024)
return false;
tx.steps.clear();
tx.change.after = layout;
std::set<std::uint64_t> seen;
for (const auto &transform : request.transforms) {
const auto *saved = find_object(layout, transform.id);
const auto row = std::find_if(r.rows.begin(), r.rows.end(), [&](const auto &candidate) {
return candidate.saved_id == transform.id && candidate.spawned;
});
if (!saved || row == r.rows.end() || !seen.insert(transform.id).second)
return false;
auto next = *saved;
next.position = transform.position;
next.rotation = transform.rotation;
next.scale = transform.scale;
if (!profile::valid_placed_object(next))
return false;
for (auto &value : tx.change.after)
if (value.id == next.id)
value = next;
tx.steps.push_back({Kind::move, next, row->entity});
}
if (!seen.contains(request.object))
return false;
}
tx.waiting_since = GetTickCount64();
tx.preview =
Preview{request.token, request.revision, request.sequence, GetTickCount64(), object, placing};
for (const auto &step : tx.steps)
tx.preview->selected.push_back(step.object.id);
e.transaction = std::move(tx);
e.last_preview_token = request.token;
e.status = placing ? "Placing object. Release or click to keep; Escape cancels."
: "Moving object. Release to keep; Escape restores.";
++e.revision;
return true;
}
if (!e.transaction || !e.transaction->preview)
return false;
auto &tx = *e.transaction;
auto &preview = *tx.preview;
if (preview.token != request.token || preview.revision != request.revision || preview.ending ||
request.sequence <= preview.sequence)
return false;
if (request.action == EditorPreviewAction::cancel)
cancel_preview(tx);
else if (request.action == EditorPreviewAction::update ||
request.action == EditorPreviewAction::commit) {
auto after = tx.change.after;
auto target = preview.target;
if (!preview.placing && !request.transforms.empty()) {
if (request.transforms.size() != preview.selected.size())
return false;
std::set<std::uint64_t> seen;
for (const auto &transform : request.transforms) {
if (std::find(preview.selected.begin(), preview.selected.end(), transform.id) ==
preview.selected.end() ||
!seen.insert(transform.id).second)
return false;
for (auto &object : after) {
if (object.id != transform.id)
continue;
object.position = transform.position;
object.rotation = transform.rotation;
object.scale = transform.scale;
if (!profile::valid_placed_object(object))
return false;
}
}
target = *find_object(after, target.id);
} else {
if (preview.selected.size() != 1)
return false;
target.position = request.position;
target.rotation = request.rotation;
target.scale = request.scale;
if (!profile::valid_placed_object(target))
return false;
for (auto &object : after)
if (object.id == target.id)
object = target;
}
preview.target = target;
preview.ending = request.action == EditorPreviewAction::commit;
tx.change.after = std::move(after);
update_preview_target(tx);
} else
return false;
preview.sequence = request.sequence;
preview.last_sample = GetTickCount64();
return true;
} catch (...) {
return false;
}
}
bool queue_local_park_selection(const EditorSelectionRequest &request) {
std::lock_guard lock(local_runtime().native_mutex);
auto &e = editor_state();
if ((!lobby_object_placement_allowed() && !request.objects.empty()) || !local_runtime().active || request.generation != e.generation || request.objects.size() > 1024)
return false;
e.selection = request.objects;
e.selection_time = GetTickCount64();
return true;
}
bool queue_local_park_surface(const EditorSurfaceRequest &request) {
std::lock_guard lock(local_runtime().native_mutex);
auto &e = editor_state();
if (!lobby_object_placement_allowed() || !local_runtime().active || request.generation != e.generation || !request.id)
return false;
e.probe = request; // Latest cursor sample replaces older, unprocessed samples.
return true;
}
} // namespace dingosdk