585 lines
25 KiB
C++
585 lines
25 KiB
C++
#include "DistantUniverse.hpp"
|
|
|
|
#include <Tracy.hpp>
|
|
|
|
#include "Area.hpp"
|
|
#include "../contouring/Abstract.hpp"
|
|
#include "core/world/iterators.hpp"
|
|
#include "core/utils/logger.hpp"
|
|
#include "core/utils/io.hpp"
|
|
#include "Chunk.hpp"
|
|
#include <random>
|
|
#include <algorithm>
|
|
|
|
#undef LOG_PREFIX
|
|
#define LOG_PREFIX "Client: "
|
|
|
|
using namespace world::client;
|
|
|
|
DistantUniverse::DistantUniverse(const Universe::login& login, const connection& ct, const Universe::options& opt, const std::string& contouring):
|
|
Universe(contouring), options(opt), serverDistance(0),
|
|
peer(ct, [&](const memory::read_view& buf, net::PacketFlags flags){ return onPacket(buf, flags); })
|
|
{
|
|
{
|
|
auto packet = memory::write_buffer(net::client_packet_type::HELLO, sizeof(uint8_t) + (login.name.length()+1) + login.token.length());
|
|
packet.write<uint8_t>(login.token.empty() ? 0 : 64);
|
|
packet.write(login.name.c_str(), login.name.length()+1);
|
|
if (!login.token.empty())
|
|
packet.write(login.token.data(), login.token.size());
|
|
|
|
peer.send(packet.finish());
|
|
}
|
|
}
|
|
DistantUniverse::~DistantUniverse() { }
|
|
|
|
void DistantUniverse::update(voxel_pos pos, float deltaTime) {
|
|
{ //NOTE: triggers onPacket
|
|
ZoneScopedN("Pull");
|
|
peer.pull();
|
|
}
|
|
|
|
if (options.movePrediction) {
|
|
elements.hierarchy.for_each([&](Elements::hierarchy_entry &entry) {
|
|
//vel += acc * deltaTime
|
|
entry.relative.position += entry.vel.position * deltaTime;
|
|
entry.relative.rotation = glm::slerp(identity_pivot, entry.vel.rotation, deltaTime) * entry.relative.rotation;
|
|
|
|
const auto node = elements.nodes.directly_at(entry.self);
|
|
node->absolute = elements.computeAbsolute(entry.parent, entry.relative);
|
|
//TODO: full physics
|
|
});
|
|
}
|
|
|
|
const auto notifyTick = contouringNotifier.mustNotify(pos, deltaTime);
|
|
|
|
{ // Update alive areas
|
|
ZoneScopedN("World");
|
|
auto rng = std::mt19937(std::rand());
|
|
const auto contouringThreshold = UINT32_MAX / (1 + contouring->getQueueSize());
|
|
for (auto& ref: elements.areas) {
|
|
ZoneScopedN("Area");
|
|
const area_id id = elements.withFlag(ref).val;
|
|
const auto node = elements.findArea(id);
|
|
const chunk_pos areaOff = glm::divide((lpivot)glm::conjugate(node->absolute.rotation) * ((world_pos)pos - node->absolute.position));
|
|
auto &chunks = node->get()->setChunks();
|
|
if (glm::length2(areaOff) <= glm::pow2<glm::ll>(options.keepDistance + chunks.getRadius())) {
|
|
ZoneScopedN("Alive");
|
|
for (auto it_c = chunks.begin(); it_c != chunks.end();) {
|
|
const cell_pos chunkPos = node->absolute.computeChild(glm::multiply(it_c->first));
|
|
const auto chunkDist = glm::length2(glm::divide(chunkPos - pos));
|
|
if (chunkDist <= glm::pow2(options.keepDistance)) {
|
|
if (const auto neighbors = it_c->second->setEdits()->update(deltaTime, rng() < contouringThreshold)) {
|
|
contouring->onUpdate(area_chunk_pos{id.val, it_c->first}, chunkDist, chunks, neighbors.value());
|
|
} //MAYBE: if notifyTick
|
|
++it_c;
|
|
} else {
|
|
it_c = chunks.erase(it_c);
|
|
}
|
|
}
|
|
//MAYBE: if dist to unloaded chunk < X
|
|
if (mayQueryChunks && notifyTick) { // Request missing chunks
|
|
ZoneScopedN("Missing");
|
|
std::vector<chunk_pos> missingChunks;
|
|
std::vector<region_pos> missingRegions;
|
|
std::vector<long> missingDist;
|
|
const auto area = std::static_pointer_cast<Area>(node->get());
|
|
//TODO: use easy sphere fill
|
|
const auto queryDistance = getQueryDistance();
|
|
for (int x = -queryDistance; x <= queryDistance; x++) {
|
|
for (int y = -queryDistance; y <= queryDistance; y++) {
|
|
for (int z = -queryDistance; z <= queryDistance; z++) {
|
|
const auto dist2 = x * x + y * y + z * z;
|
|
const auto p = areaOff + chunk_pos(x, y, z);
|
|
if (dist2 <= glm::pow2(queryDistance) && chunks.inRange(p)) {
|
|
if (const auto it = chunks.find(p); it != chunks.end() &&
|
|
std::static_pointer_cast<Chunk>(it->second)->isTrusted(options.trustMajorant))
|
|
{
|
|
contouring->onNotify(area_chunk_pos{id.val, p}, dist2, chunks);
|
|
continue;
|
|
}
|
|
|
|
const auto rcPos = glm::split(p);
|
|
if(auto it_r = area->regionCache.find(rcPos.first); it_r != area->regionCache.end()) {
|
|
if (auto it_rc = it_r->second.find(rcPos.second); it_rc != it_r->second.end() && (it_rc->second.swap() || options.useAverages)) {
|
|
auto ck = std::make_shared<Chunk>(it_rc->second);
|
|
ck->setEdits().invalidate(geometry::Faces::All);
|
|
chunks.emplace(p, ck);
|
|
contouring->onNotify(area_chunk_pos{id.val, p}, dist2, chunks);
|
|
if (ck->isTrusted(options.trustMajorant))
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!area->regionCache.contains(rcPos.first) &&
|
|
std::find(missingRegions.begin(), missingRegions.end(), region_pos(rcPos.first)) == missingRegions.end())
|
|
{
|
|
missingRegions.push_back(rcPos.first);
|
|
}
|
|
if (missingDist.size() >= net::MAX_PENDING_CHUNK_COUNT) {
|
|
if (dist2 > missingDist.front())
|
|
continue;
|
|
missingDist.erase(missingDist.begin());
|
|
missingChunks.erase(missingChunks.begin());
|
|
}
|
|
|
|
auto it = missingDist.begin();
|
|
auto itv = missingChunks.begin();
|
|
while (it != missingDist.end()) {
|
|
if (dist2 > *it)
|
|
break;
|
|
++it;
|
|
++itv;
|
|
}
|
|
missingDist.insert(it, dist2);
|
|
missingChunks.insert(itv, p);
|
|
}
|
|
}}}
|
|
if(!missingChunks.empty()) {
|
|
LOG_T("Missing chunks " << missingChunks.size());
|
|
auto packet = memory::write_buffer(net::client_packet_type::REQUEST_CHUNKS, sizeof(node_id) + missingChunks.size() * sizeof(chunk_pos));
|
|
packet.write<node_id>(id.val);
|
|
packet.write(missingChunks.data(), missingChunks.size() * sizeof(chunk_pos));
|
|
peer.send(packet.finish());
|
|
mayQueryChunks = false;
|
|
}
|
|
if(!missingRegions.empty()) {
|
|
auto packet = memory::write_buffer(net::client_packet_type::REQUEST_REGIONS, sizeof(area_id) + missingRegions.size() * sizeof(region_pos));
|
|
packet.write<area_id>(id.val);
|
|
packet.write(missingRegions.data(), missingRegions.size() * sizeof(region_pos));
|
|
peer.send(packet.finish());
|
|
}
|
|
}
|
|
} else if(notifyTick) {
|
|
chunks.clear();
|
|
chunks.rehash(0);
|
|
auto& regions = std::static_pointer_cast<Area>(node->get())->regionCache;
|
|
regions.clear();
|
|
regions.rehash(0);
|
|
}
|
|
}
|
|
}
|
|
//TODO: remove out of range (instances, parts, null node)
|
|
if (notifyTick) {
|
|
const auto queryDist2 = glm::pow2(getQueryDistance());
|
|
for(const auto& ref: elements.parts) {
|
|
const part_id id = elements.withFlag(ref).val;
|
|
const auto node = elements.findPart(id);
|
|
const auto dist = glm::length2(glm::divide((cell_pos)node->absolute.position - pos));
|
|
if (dist <= glm::pow2(options.keepDistance)) {
|
|
const auto part = node->get();
|
|
if (part->loaded()) {
|
|
contouring->onNotify(id, dist, part->chunkSize(), part->chunks, false);
|
|
} else if (dist <= queryDist2) {
|
|
const auto fullCount = part->chunkCount();
|
|
const auto &chunks = part->chunks;
|
|
size_t size = 0;
|
|
for (size_t i = 0; i < fullCount; i++) {
|
|
if (i >= chunks.size() || chunks[i] == nullptr)
|
|
size++;
|
|
}
|
|
LOG_T("Missing chunks in part " << size);
|
|
auto packet = memory::write_buffer(net::client_packet_type::REQUEST_CHUNKS, sizeof(node_id) + size * sizeof(chunk_pos));
|
|
packet.write<node_id>(id.val);
|
|
for (size_t i = 0; i < fullCount; i++) {
|
|
if (i >= chunks.size() || chunks[i] == nullptr) {
|
|
packet.write<chunk_pos>(part->getPos(i));
|
|
}
|
|
}
|
|
peer.send(packet.finish());
|
|
}
|
|
} else {
|
|
auto &chunks = node->get()->chunks;
|
|
chunks.clear();
|
|
chunks.shrink_to_fit();
|
|
}
|
|
}
|
|
//TODO: remove temporary on last instance removal
|
|
elements.models.iter([&] (const model_id& id, const Model& model) {
|
|
if (!model.instances.empty()) {
|
|
io::vec_istream idata(model.dt);
|
|
std::istream iss(&idata);
|
|
contouring->onLoad(id, iss);
|
|
}
|
|
});
|
|
}
|
|
|
|
contouring->update(pos, elements);
|
|
}
|
|
|
|
bool DistantUniverse::onPacket(const memory::read_view& buf, net::PacketFlags) {
|
|
using namespace net;
|
|
|
|
//MAYBE: client::Serializer
|
|
|
|
auto packet = memory::read_buffer(buf, nullptr);
|
|
server_packet_type type;
|
|
if(!packet.read(type)) {
|
|
LOG_D("Empty packet");
|
|
return true;
|
|
}
|
|
switch (type) {
|
|
case server_packet_type::QUIT: {
|
|
uint16_t reason = 42;
|
|
packet.read(reason);
|
|
LOG_E("Disconnected from server (" << reason << ')');
|
|
peer.disconnect();
|
|
break;
|
|
}
|
|
|
|
case server_packet_type::CAPABILITIES: {
|
|
packet.read(serverDistance);
|
|
auto predictable = false;
|
|
packet.read(predictable);
|
|
if (predictable) {
|
|
packet.read(floodfillLimit);
|
|
} else {
|
|
options.editHandling = false;
|
|
options.editPrediction = false;
|
|
}
|
|
auto packet = memory::write_buffer(client_packet_type::CAPABILITIES, sizeof(bool));
|
|
packet.write(options.editHandling);
|
|
peer.send(packet.finish());
|
|
break;
|
|
}
|
|
|
|
case server_packet_type::COMPRESSION: {
|
|
const auto remain = packet.readRemaining();
|
|
dict.emplace(remain.data(), remain.size());
|
|
LOG_T("Compression dictionnary loaded");
|
|
mayQueryChunks = true;
|
|
break;
|
|
}
|
|
|
|
case server_packet_type::TELEPORT: {
|
|
relative_transform pos;
|
|
if (packet.read(playerId) && packet.read(pos)) {
|
|
if (!elements.withFlag(playerId)) {
|
|
LOG_W("FIXME: store if instance not reserved yet");
|
|
}
|
|
onTeleport(pos, getAbsolute(pos));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case server_packet_type::MESSAGE: {
|
|
const auto remain = packet.readRemaining();
|
|
onMessage(std::string((const char*)remain.data(), remain.size()));
|
|
break;
|
|
}
|
|
|
|
case server_packet_type::REGION: {
|
|
ZoneScopedN("Region");
|
|
area_region_ref pos(area_ref(), region_pos(0));
|
|
if(!packet.read(pos))
|
|
break;
|
|
|
|
const auto id = elements.nodes.with_flag(pos.area.val);
|
|
if (!Elements::Is<Elements::Type::Area>(id))
|
|
break;
|
|
|
|
const auto node = NodeOf<Area>::Make(elements.nodes.directly_at(id));
|
|
if (!node) {
|
|
LOG_W("Region area not found " << pos.area.val);
|
|
break;
|
|
}
|
|
auto ®ions = node->get()->regionCache;
|
|
auto it_r = regions.find(pos.region);
|
|
if (it_r == regions.end()) {
|
|
it_r = regions.emplace(pos.region, 0).first;
|
|
}
|
|
// MAYBE: create virtual Chunk without voxels of single material
|
|
while (!packet.isDone()) {
|
|
region_chunk_pos cpos;
|
|
Voxel voxel;
|
|
if (!packet.read(cpos) || !packet.read(voxel))
|
|
break;
|
|
|
|
it_r->second.insert_or_assign(cpos, voxel);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case server_packet_type::CHUNK: {
|
|
ZoneScopedN("Chunk");
|
|
if (!dict.has_value())
|
|
break;
|
|
|
|
if (packet.isDone()) {
|
|
mayQueryChunks = true;
|
|
break;
|
|
}
|
|
|
|
node_chunk_ref pos{node_ref(), chunk_pos(0)};
|
|
if(!packet.read(pos))
|
|
break;
|
|
|
|
const auto id = elements.nodes.with_flag(pos.node.val);
|
|
const auto node = elements.nodes.directly_at(id);
|
|
if (!node) {
|
|
LOG_W("node not found " << pos.node.val);
|
|
break;
|
|
}
|
|
|
|
const auto readChunk = [&] {
|
|
std::vector<char> buffer;
|
|
if (auto err = dict.value().decompress(packet.readRemaining(), buffer)) {
|
|
LOG_E("Corrupted chunk packet " << err.value());
|
|
return std::shared_ptr<Chunk>(nullptr);
|
|
}
|
|
io::vec_istream idata(buffer);
|
|
std::istream iss(&idata);
|
|
return std::make_shared<Chunk>(iss);
|
|
};
|
|
|
|
switch (Elements::GetType(id)) {
|
|
case Elements::Type::Area: {
|
|
const auto area = NodeOf<Area>::Make(node)->get();
|
|
if(!area->getChunks().inRange(pos.chunk)) {
|
|
LOG_W("Chunk out of area " << pos.node.val);
|
|
break;
|
|
}
|
|
if (auto ck = readChunk()) {
|
|
area->setChunks().insert_or_assign(pos.chunk, ck);
|
|
}
|
|
break;
|
|
}
|
|
case Elements::Type::Part: {
|
|
const auto part = NodeOf<Part>::Make(node)->get();
|
|
if(!part->inRange(pos.chunk)) {
|
|
LOG_W("Chunk out of part " << pos.node.val);
|
|
break;
|
|
}
|
|
if (auto ck = readChunk()) {
|
|
part->allocate();
|
|
part->emplace(pos.chunk, ck);
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
LOG_W("Not chunk holding node type");
|
|
break;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case server_packet_type::EDITS: {
|
|
ZoneScopedN("Edits");
|
|
if (!options.editHandling) {
|
|
LOG_W("Unhandled edit type");
|
|
break;
|
|
}
|
|
|
|
const auto fill = packet.read<action::FillShape>();
|
|
if (!fill)
|
|
break;
|
|
|
|
const auto anchor_id = elements.withFlag(fill->pos.node);
|
|
const auto node = elements.findNode(anchor_id);
|
|
if (!node)
|
|
break;
|
|
|
|
//MAYBE: make it void after air propagation setup
|
|
const auto cleanVoxel = Voxel(0, Voxel::DENSITY_MAX);
|
|
switch (Elements::GetType(anchor_id)) {
|
|
case Elements::Type::Area: {
|
|
const auto& chunks = NodeOf<Area>::Make(node)->get()->getChunks();
|
|
world::iterator::ApplyEraseSplitted<Chunk>(chunks, *fill, floodfillLimit, cleanVoxel,
|
|
[&](std::shared_ptr<Chunk>& ck, const chunk_pos&, chunk_voxel_idx idx, Voxel/*prev*/, Voxel next, float delay) {
|
|
return ck->setEdits().apply(ChunkEdits::Edit{next, delay, idx});
|
|
});
|
|
break;
|
|
}
|
|
/* TODO: case Elements::Type::Part: {
|
|
const auto& chunks = *NodeOf<Part>::Make(node)->get()->chunks;
|
|
world::iterator::ApplyEraseSplitted<Chunk>(chunks, *fill, floodfillLimit, cleanVoxel,
|
|
[&](std::shared_ptr<Chunk>& ck, const chunk_pos&, chunk_voxel_idx idx, Voxel prev, Voxel next, float delay) {
|
|
return ck->replace(ChunkEdits::Edit{next, delay, idx});
|
|
});
|
|
break;
|
|
}*/
|
|
default:
|
|
LOG_E("Unhandled fill target type " << (int)Elements::GetType(anchor_id));
|
|
break;
|
|
}
|
|
break;
|
|
}
|
|
case server_packet_type::RAW_EDITS: {
|
|
ZoneScopedN("Raw Edits");
|
|
node_ref ref;
|
|
if(!packet.read(ref))
|
|
break;
|
|
|
|
const auto id = elements.nodes.with_flag(ref.val);
|
|
const auto node = elements.nodes.directly_at(id);
|
|
if (!node) {
|
|
LOG_W("node not found " << ref.val);
|
|
break;
|
|
}
|
|
|
|
const auto readEdits = [&](std::function<std::shared_ptr<EdittableChunk>(const chunk_pos&)> findInRange) {
|
|
while(!packet.isDone()) {
|
|
chunk_pos pos = chunk_pos(INT_MAX);
|
|
packet.read(pos);
|
|
chunk_voxel_idx count = 0;
|
|
packet.read(count);
|
|
if (auto chunk = findInRange(pos)) {
|
|
const auto edits = chunk->setEdits();
|
|
for (auto i = 0; i < count && !packet.isDone(); i++) {
|
|
ChunkEdits::Edit edit;
|
|
packet.read(edit);
|
|
edits->apply(edit);
|
|
}
|
|
} else {
|
|
packet.skip(count * sizeof(ChunkEdits::Edit));
|
|
}
|
|
}
|
|
};
|
|
|
|
switch (Elements::GetType(id)) {
|
|
case Elements::Type::Area: {
|
|
const auto area = NodeOf<Area>::Make(node)->get();
|
|
const auto& chunks = area->setChunks();
|
|
readEdits([&](const world::chunk_pos &pos) {
|
|
return chunks.findInRange(pos);
|
|
});
|
|
break;
|
|
}
|
|
case Elements::Type::Part: {
|
|
const auto part = NodeOf<Part>::Make(node)->get();
|
|
readEdits([&](const world::chunk_pos &pos) {
|
|
return part->findInRange(pos);
|
|
});
|
|
break;
|
|
}
|
|
|
|
default:
|
|
LOG_W("Not chunk holding node type");
|
|
break;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case server_packet_type::ENTITIES: {
|
|
while(!packet.isDone()) {
|
|
node_id id;
|
|
if (!packet.read(id))
|
|
break;
|
|
|
|
const auto added = Elements::Has<Elements::Flag::Added>(id);
|
|
if (Elements::Has<Elements::Flag::Moved>(id) || added) {
|
|
MyElements::start_point state = MyElements::start_point(generational::id(), transform());
|
|
if (!packet.read(state))
|
|
break;
|
|
|
|
if (added) {
|
|
switch (Elements::GetType(id)) {
|
|
case Elements::Type::Area: {
|
|
Area::params params;
|
|
if (packet.read(params)) {
|
|
if (elements.withFlag(id)) {
|
|
LOG_D("Added entity already exists " << id.val.val);
|
|
break;
|
|
}
|
|
id = elements.createAt(id, state, std::make_shared<Area>(params));
|
|
elements.areas.push_back(id);
|
|
}
|
|
break;
|
|
}
|
|
case Elements::Type::Part: {
|
|
glm::usvec3 size;
|
|
if (packet.read(size)) {
|
|
if (elements.withFlag(id)) {
|
|
LOG_D("Added entity already exists " << id.val.val);
|
|
break;
|
|
}
|
|
id = elements.createAt(id, state, std::make_shared<Part>(size));
|
|
elements.parts.push_back(id);
|
|
}
|
|
break;
|
|
}
|
|
case Elements::Type::Instance: {
|
|
model_id mid;
|
|
if (packet.read(mid)) {
|
|
const auto model = elements.models.directly_at(mid);
|
|
if (!model) {
|
|
LOG_E("FIXME: must query model");
|
|
break;
|
|
}
|
|
if (elements.withFlag(id)) {
|
|
LOG_D("Added entity already exists " << id.val.val);
|
|
break;
|
|
}
|
|
id = elements.createAt(id, state, model->origin ? model->origin : std::make_shared<Instance>(mid));
|
|
model->instances.push_back(id.val.index());
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
LOG_W("Unhandled entity type " << id.val.val);
|
|
break;
|
|
}
|
|
} else if (elements.withFlag(id)) {
|
|
elements.move(id, state);
|
|
} else {
|
|
LOG_W("Must query content " << id.val.val);
|
|
}
|
|
} else if (Elements::Has<Elements::Flag::Removed>(id)) {
|
|
elements.remove(id);
|
|
} else {
|
|
LOG_W("Corrupted entity with id " << id.val.val);
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
LOG_W("Bad packet from server");
|
|
break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void DistantUniverse::emit(const world::action::packet &action) {
|
|
if(const auto move = std::get_if<world::action::Move>(&action)) {
|
|
peer.send(memory::read_buffer::Of(net::client_packet_type::MOVE, move->pos), net::client::queue::MOVE, 1);
|
|
} else if(const auto message = std::get_if<world::action::Message>(&action)) {
|
|
peer.send(memory::read_buffer::Of(net::client_packet_type::MESSAGE, message->text.data(), message->text.size()));
|
|
} else if(const auto fill = std::get_if<world::action::FillShape>(&action)) {
|
|
peer.send(memory::read_buffer::Of(net::client_packet_type::FILL_SHAPE, *fill));
|
|
if (options.editPrediction) {
|
|
ZoneScopedN("Fill");
|
|
const auto keepDelay = 5 + (peer.getRTT() / 20000.f); // 5s + 50RTT
|
|
const auto anchor_id = elements.withFlag(fill->pos.node);
|
|
const auto node = elements.findNode(anchor_id);
|
|
if (!node)
|
|
return;
|
|
|
|
//MAYBE: make it void after air propagation setup
|
|
const auto cleanVoxel = Voxel(0, Voxel::DENSITY_MAX);
|
|
switch (Elements::GetType(anchor_id)) {
|
|
case Elements::Type::Area: {
|
|
const auto& chunks = NodeOf<Area>::Make(node)->get()->getChunks();
|
|
world::iterator::ApplyEraseSplitted<Chunk>(chunks, *fill, floodfillLimit, cleanVoxel,
|
|
[&](std::shared_ptr<Chunk>& ck, const chunk_pos&, chunk_voxel_idx idx, Voxel/*prev*/, Voxel next, float delay) {
|
|
return ck->setEdits().addFutureEdit(ChunkEdits::Edit{next, keepDelay - delay * 2, idx}, delay);
|
|
});
|
|
break;
|
|
}
|
|
default:
|
|
LOG_E("Unhandled fill target type " << (int)Elements::GetType(anchor_id));
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
LOG_W("Bad action " << action.index());
|
|
}
|
|
}
|
|
|
|
DistantUniverse::MyElements::MyElements(): EdittableElements(true) { }
|
|
|
|
world::node_id DistantUniverse::MyElements::createAt(node_id id, const start_point& sp, const std::shared_ptr<world::Element>& el) {
|
|
return EdittableElements::createAt(id, sp, el);
|
|
} |