#include "dc_level.h"
#include "dc_level_shell.h"
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/classes/world3d.hpp>
#include <godot_cpp/classes/mesh.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/packed_color_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <atomic>
#include <chrono>
#include <thread>
using namespace godot;
DCLevel::DCLevel() {}
DCLevel::~DCLevel() {
_free_collision();
_free_all_shapes();
}
void DCLevel::_ready() {
if (!mesh_node_) {
mesh_node_ = memnew(MeshInstance3D);
mesh_node_->set_name("LevelMesh");
add_child(mesh_node_);
}
// Named so map_dc_level.gd's material_override walk finds it the same way it
// finds LevelMesh — pockets need the level material too, or a carved wall
// renders untextured next to the exact geometry it continues.
if (!pocket_node_) {
pocket_node_ = memnew(MeshInstance3D);
pocket_node_->set_name("LevelPockets");
add_child(pocket_node_);
}
if (dirty_) rebuild();
}
void DCLevel::_exit_tree() {
_free_collision();
_free_all_shapes();
}
// Every shape RID the caches own. Called only at teardown — per-rebuild eviction
// is what keeps them bounded during play.
void DCLevel::_free_all_shapes() {
PhysicsServer3D *ps = PhysicsServer3D::get_singleton();
if (!ps) { shape_cache_.clear(); pocket_cache_.clear(); return; }
for (auto &kv : shape_cache_) if (kv.second.is_valid()) ps->free_rid(kv.second);
shape_cache_.clear();
for (PocketCache &pc : pocket_cache_) if (pc.shape.is_valid()) ps->free_rid(pc.shape);
pocket_cache_.clear();
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
int DCLevel::add_brush(const PackedFloat32Array &planes, int material_id,
const PackedInt32Array &plane_proj, int level_id, bool sky,
const String &key, bool clearance, const String &group) {
const int n = planes.size() / 4;
if (n < 4) return 0; // <4 half-spaces cannot bound a solid
std::vector<ConvexPlane> pl;
pl.reserve(n);
for (int i = 0; i < n; i++) {
pl.push_back({ planes[i*4 + 0], planes[i*4 + 1],
planes[i*4 + 2], planes[i*4 + 3] });
}
std::vector<uint32_t> proj;
proj.reserve(n);
for (int i = 0; i < n; i++)
proj.push_back(i < plane_proj.size() ? (uint32_t)plane_proj[i] : 0u);
const uint32_t id = store_.add_brush(pl, proj, (uint32_t)material_id,
(uint32_t)level_id, sky,
std::string(key.utf8().get_data()),
clearance,
std::string(group.utf8().get_data()));
if (id) dirty_ = true;
return (int)id;
}
bool DCLevel::scale_brush(int id, const Vector3 &scale, const Vector3 &pivot) {
const bool r = store_.scale_brush((uint32_t)id, scale.x, scale.y, scale.z,
pivot.x, pivot.y, pivot.z);
if (r) dirty_ = true;
return r;
}
int DCLevel::brush_by_key(const String &key) const {
return (int)store_.brush_by_key(std::string(key.utf8().get_data()));
}
String DCLevel::get_brush_key(int id) const {
const DCBrush *b = store_.get((uint32_t)id);
return b ? String(b->key.c_str()) : String();
}
PackedInt32Array DCLevel::get_sky_brush_ids() const {
PackedInt32Array out;
std::vector<CSG4Brush> view;
store_.csg4_view(view); // live brushes only — tombstoned/carved excluded
for (const CSG4Brush &b : view)
if (b.sky) out.push_back((int)b.id);
return out;
}
String DCLevel::get_brush_group(int id) const {
const DCBrush *b = store_.get((uint32_t)id);
return b ? String(b->group.c_str()) : String();
}
PackedInt32Array DCLevel::get_group_brush_ids(const String &group) const {
PackedInt32Array out;
const std::string g(group.utf8().get_data());
if (g.empty()) return out; // "" is "no group", not a group
for (const DCBrush &b : store_.brushes())
if (!b.tombstoned && b.group == g) out.push_back((int)b.id);
return out;
}
PackedStringArray DCLevel::get_group_keys() const {
PackedStringArray out;
std::vector<std::string> seen;
for (const DCBrush &b : store_.brushes()) {
if (b.tombstoned || b.group.empty()) continue;
if (std::find(seen.begin(), seen.end(), b.group) != seen.end()) continue;
seen.push_back(b.group);
out.push_back(String(b.group.c_str()));
}
return out;
}
PackedInt32Array DCLevel::get_live_brush_ids() const {
PackedInt32Array out;
for (const DCBrush &b : store_.brushes())
if (!b.tombstoned) out.push_back((int)b.id);
return out;
}
PackedInt32Array DCLevel::get_clearance_brush_ids() const {
PackedInt32Array out;
// shell_view, NOT csg4_view — the latter drops clearance brushes by design,
// so asking it for them would always answer none.
std::vector<CSG4Brush> view;
store_.shell_view(view);
for (const CSG4Brush &b : view)
if (b.clearance) out.push_back((int)b.id);
return out;
}
// ShellMesh -> Dictionary. Extracted so the synchronous and the threaded path
// cannot drift: both hand the SAME mesh to the same packing, and the float
// centroid arithmetic below is load-bearing (see the comment inside).
static Dictionary _pack_shell(const dc_level_shell::ShellMesh &sm) {
Dictionary out;
PackedVector3Array verts;
verts.resize(sm.vertex_count());
// ACCUMULATED IN Vector3, NOT DOUBLE, and that is deliberate.
//
// stamp_mesh_arrays computes its own centroid to re-centre the vertices on
// (dc_terrain.cpp:3178-3180), summing float32 Vector3s. The mesh is then
// stored relative to THAT and placed at whatever `position` we pass, so any
// disagreement translates the entire shell. Over 281752 vertices a double
// sum drifts ~0.02 m from a float one — sub-voxel, but a systematic offset of
// the whole subtract volume for no reason. Matching the consumer's arithmetic
// exactly makes the offset zero instead of small.
Vector3 centroid;
for (int i = 0; i < sm.vertex_count(); i++) {
const Vector3 v(sm.vertices[i*3+0], sm.vertices[i*3+1], sm.vertices[i*3+2]);
verts[i] = v;
centroid += v;
}
const int nv = sm.vertex_count() > 0 ? sm.vertex_count() : 1;
centroid /= (real_t)nv;
PackedInt32Array idx;
idx.resize((int)sm.indices.size());
for (size_t i = 0; i < sm.indices.size(); i++) idx[(int)i] = sm.indices[i];
out["vertices"] = verts;
out["indices"] = idx;
// The centroid stamp_mesh_arrays will compute for itself — handed back so the
// caller cannot pass a different origin and translate the shell.
out["center"] = centroid;
out["triangles"] = sm.triangle_count();
out["volume"] = sm.volume;
out["area"] = sm.area;
out["snapped"] = (int)sm.snapped;
out["unmoved"] = (int)sm.unmoved;
out["cells_interior"] = (int)sm.cells_interior;
// Proof, not reassurance. A leaked fill still yields a CLOSED surface — just
// the boundary of the solid, enclosing none of the rooms — so the caller
// needs both numbers to tell a good shell from a plausible-looking one.
out["boundary_edges"] = (int)sm.boundary_edges;
out["nonmanifold_edges"] = (int)sm.nonmanifold_edges;
out["solid_volume"] = sm.solid_volume;
out["interior_volume"] = sm.interior_volume;
out["sealed"] = sm.sealed();
return out;
}
Dictionary DCLevel::build_shell(float cell, float clamp_cells) const {
std::vector<CSG4Brush> view;
// shell_view: csg4's brushes PLUS the clearance volumes. The shell answers
// "what does this level claim", and a passage is part of that answer even
// though nothing renders it.
store_.shell_view(view); // live brushes only
if (view.empty()) return Dictionary();
dc_csg4::compute_bounds(view);
// CLEAN brushes, not the carve-split pieces the mesher uses. A carve is a
// local pocket inside a wall; it does not change what the level ENCLOSES,
// and splitting for it would multiply the brush count for no effect on the
// sealed volume.
// ISLANDS, not one grid over everything. Identical output on a connected
// level (byte-for-byte on START.MAP, islands=1) and 778x faster on two huts
// 400 m apart, because no grid ever spans the empty ground between buildings.
dc_level_shell::ShellMesh sm;
if (!dc_level_shell::build_islands(view, sm, cell, clamp_cells)) return Dictionary();
return _pack_shell(sm);
}
// ---- the same build, off the caller's thread ------------------------------
struct DCLevel::ShellJob {
std::thread th;
std::atomic<bool> done{false};
bool ok = false;
dc_level_shell::ShellMesh sm;
~ShellJob() { if (th.joinable()) th.join(); }
};
bool DCLevel::build_shell_start(float cell, float clamp_cells) {
if (shell_job_ && !shell_job_->done.load(std::memory_order_acquire))
return false; // one at a time; the caller re-arms
shell_job_.reset(); // joins a finished job nobody took
// THE SNAPSHOT, HERE, ON THIS THREAD. Everything below reads only `view`, so
// the store is free to be edited the moment this function returns — which is
// the entire reason for moving the flood off the frame.
auto view = std::make_shared<std::vector<CSG4Brush>>();
store_.shell_view(*view); // clearance included — see build_shell
if (view->empty()) return false;
dc_csg4::compute_bounds(*view);
auto job = std::make_unique<ShellJob>();
ShellJob *j = job.get();
// `view` is captured BY VALUE into the thread: the shared_ptr keeps the
// snapshot alive for exactly as long as the worker needs it, without the
// worker reaching back into this object for anything but its own job.
job->th = std::thread([j, view, cell, clamp_cells]() {
j->ok = dc_level_shell::build_islands(*view, j->sm, cell, clamp_cells);
// RELEASE, paired with the acquire in shell_ready/take: it publishes `sm`
// and `ok`, which are written before it and read only after it.
j->done.store(true, std::memory_order_release);
});
shell_job_ = std::move(job);
return true;
}
bool DCLevel::shell_building() const {
return shell_job_ && !shell_job_->done.load(std::memory_order_acquire);
}
bool DCLevel::shell_ready() const {
return shell_job_ && shell_job_->done.load(std::memory_order_acquire);
}
Dictionary DCLevel::build_shell_take() {
if (!shell_ready()) return Dictionary();
std::unique_ptr<ShellJob> job = std::move(shell_job_); // clears the slot
if (job->th.joinable()) job->th.join();
return job->ok ? _pack_shell(job->sm) : Dictionary();
}
bool DCLevel::remove_brush(int id) { const bool r = store_.remove_brush((uint32_t)id); if (r) dirty_ = true; return r; }
bool DCLevel::restore_brush(int id) { const bool r = store_.restore_brush((uint32_t)id); if (r) dirty_ = true; return r; }
int DCLevel::remove_level(int lid) { const uint32_t n = store_.remove_level((uint32_t)lid); if (n) dirty_ = true; return (int)n; }
int DCLevel::restore_level(int lid){ const uint32_t n = store_.restore_level((uint32_t)lid); if (n) dirty_ = true; return (int)n; }
void DCLevel::clear() {
store_ = DCBrushStore();
dirty_ = true;
rebuild();
}
// ---------------------------------------------------------------------------
// Transform + shape editing
// ---------------------------------------------------------------------------
bool DCLevel::move_brush_points(int id, const PackedVector3Array &from,
const Vector3 &delta, const String &key) {
std::vector<CSG4Vec3> pts;
pts.reserve((size_t)from.size());
for (int i = 0; i < from.size(); i++)
pts.push_back({ from[i].x, from[i].y, from[i].z });
const bool r = store_.move_brush_points((uint32_t)id, pts,
{ delta.x, delta.y, delta.z },
std::string(key.utf8().get_data()));
if (r) dirty_ = true;
return r;
}
// Flattened 4-per-plane, as add_brush takes them.
static std::vector<ConvexPlane> _planes_from(const PackedFloat32Array &f) {
std::vector<ConvexPlane> out;
out.reserve((size_t)(f.size() / 4));
for (int i = 0; i + 3 < f.size(); i += 4)
out.push_back({ f[i], f[i + 1], f[i + 2], f[i + 3] });
return out;
}
int DCLevel::subtract_brush(int id, const PackedFloat32Array &cutter, const String &key) {
std::vector<uint32_t> pieces;
const int n = store_.subtract_brush((uint32_t)id, _planes_from(cutter),
std::string(key.utf8().get_data()), pieces);
if (n >= 0) dirty_ = true;
return n;
}
bool DCLevel::intersect_brush(int id, const PackedFloat32Array &other, const String &key) {
const bool r = store_.intersect_brush((uint32_t)id, _planes_from(other),
std::string(key.utf8().get_data()));
if (r) dirty_ = true;
return r;
}
bool DCLevel::merge_brushes(const PackedInt32Array &ids, const String &key) {
std::vector<uint32_t> v;
v.reserve((size_t)ids.size());
for (int i = 0; i < ids.size(); i++) v.push_back((uint32_t)ids[i]);
const bool r = store_.merge_brushes(v, std::string(key.utf8().get_data()));
if (r) dirty_ = true;
return r;
}
bool DCLevel::translate_brush(int id, const Vector3 &d) {
const bool r = store_.translate_brush((uint32_t)id, d.x, d.y, d.z);
if (r) dirty_ = true;
return r;
}
bool DCLevel::set_face_plane(int id, int face, const Vector3 &n, float d) {
const bool r = store_.set_face_plane((uint32_t)id, (uint32_t)face, {n.x, n.y, n.z, d});
if (r) dirty_ = true;
return r;
}
int DCLevel::add_face(int id, const Vector3 &n, float d, int proj, const String &key) {
const uint32_t f = store_.add_face((uint32_t)id, {n.x, n.y, n.z, d}, (uint32_t)proj,
std::string(key.utf8().get_data()));
if (f != UINT32_MAX) dirty_ = true;
return (f == UINT32_MAX) ? -1 : (int)f;
}
// Cut a brush in two: the original keeps the front half (n.x <= d), a new brush
// named `key` takes the back. Returns the new brush's id, or 0 when the plane does
// not divide the solid — in which case nothing changed. This is what makes a
// doorway reachable: split, split, remove the middle.
int DCLevel::split_brush(int id, const Vector3 &n, float d, const String &key) {
const uint32_t made = store_.split_brush((uint32_t)id, {n.x, n.y, n.z, d},
std::string(key.utf8().get_data()));
if (made) dirty_ = true;
return (int)made;
}
bool DCLevel::remove_face(int id, int face) {
const bool r = store_.remove_face((uint32_t)id, (uint32_t)face);
if (r) dirty_ = true;
return r;
}
Dictionary DCLevel::get_brush(int id) const {
Dictionary out;
const DCBrush *b = store_.get((uint32_t)id);
if (!b) return out; // unknown id: empty, not a stub
// Planes flattened 4-per-face (nx, ny, nz, d) — the same shape add_brush
// takes, so a read-back round-trips without the caller reformatting.
PackedFloat32Array planes;
planes.resize((int)b->planes.size() * 4);
for (size_t i = 0; i < b->planes.size(); i++) {
planes.set((int)i * 4 + 0, b->planes[i].nx);
planes.set((int)i * 4 + 1, b->planes[i].ny);
planes.set((int)i * 4 + 2, b->planes[i].nz);
planes.set((int)i * 4 + 3, b->planes[i].d);
}
PackedInt32Array proj;
proj.resize((int)b->plane_proj.size());
for (size_t i = 0; i < b->plane_proj.size(); i++)
proj.set((int)i, (int)b->plane_proj[i]);
PackedStringArray keys;
keys.resize((int)b->plane_key.size());
for (size_t i = 0; i < b->plane_key.size(); i++)
keys.set((int)i, String(b->plane_key[i].c_str()));
out["planes"] = planes;
out["proj"] = proj; // parallel to planes, always
out["plane_keys"] = keys; // CRDT dots; survive renumbering
// Differs from planes.size() exactly when the brush carries dormant planes —
// planes that bound nothing right now but come back if pushed in again.
out["live_faces"] = (int)store_.live_face_count((uint32_t)id);
out["material_id"] = (int)b->material_id;
// Bounds, in the shape edit_terrain_convex wants (centre + bounding sphere).
// Their ABSENCE was a silent no-op: map_dc_world's wall feed read
// b.get("radius", 0.0), got the default for every brush, skipped all 592 and
// logged "0/592 brushes" — while the gate that was supposed to cover it used
// a default of 1.0 and happily reported 592/592, feeding bogus centres. A
// check whose fallback differs from production's cannot see production's bug.
if (b->bounds_valid) {
const Vector3 lo(b->lo.x, b->lo.y, b->lo.z);
const Vector3 hi(b->hi.x, b->hi.y, b->hi.z);
out["center"] = (lo + hi) * 0.5f;
out["radius"] = ((hi - lo) * 0.5f).length();
out["aabb_min"] = lo;
out["aabb_max"] = hi;
}
out["level_id"] = (int)b->level_id;
out["generation"] = (int)b->generation; // bumps on ANY plane-set change
out["sky"] = b->sky;
out["clearance"] = b->clearance;
out["group"] = String(b->group.c_str());
out["tombstoned"] = b->tombstoned;
return out;
}
int DCLevel::pick_face(int id, const Vector3 &p) const {
return store_.nearest_face((uint32_t)id, p.x, p.y, p.z);
}
Dictionary DCLevel::raycast(const Vector3 &from, const Vector3 &to,
bool include_clearance) const {
Dictionary out;
const Vector3 d = to - from;
dc_pick::Hit h;
if (!dc_pick::raycast(store_, {from.x, from.y, from.z}, {d.x, d.y, d.z},
(float)d.length(), h, 0.0f,
include_clearance)) return out; // miss: empty, not a stub
const DCBrush *b = store_.get(h.brush);
out["brush"] = (int)h.brush;
out["face"] = (int)h.face;
// The durable names, so a caller never has to round-trip through local ids to
// write a journal record or hold a selection across a re-fold.
out["brush_key"] = b ? String(b->key.c_str()) : String();
out["face_key"] = (b && h.face < b->plane_key.size())
? String(b->plane_key[h.face].c_str()) : String();
out["position"] = Vector3(h.point.x, h.point.y, h.point.z);
out["normal"] = Vector3(h.normal.x, h.normal.y, h.normal.z);
out["distance"] = h.t;
out["inside"] = h.inside;
return out;
}
int DCLevel::face_by_key(int id, const String &key) const {
return store_.face_by_key((uint32_t)id, std::string(key.utf8().get_data()));
}
bool DCLevel::face_is_live(int id, int face) const {
return store_.face_is_live((uint32_t)id, (uint32_t)face);
}
PackedVector3Array DCLevel::get_face_winding(int id, int face) const {
PackedVector3Array out;
std::vector<CSG4Vec3> pts;
if (!store_.face_winding((uint32_t)id, (uint32_t)face, pts)) return out;
out.resize((int)pts.size());
for (size_t i = 0; i < pts.size(); i++)
out.set((int)i, Vector3(pts[i].x, pts[i].y, pts[i].z));
return out;
}
// ---------------------------------------------------------------------------
// Carves
// ---------------------------------------------------------------------------
int DCLevel::apply_carve_sphere(const Vector3 &c, float radius, const String &key) {
TerrainEdit e;
e.shape = TerrainEdit::SPHERE;
e.op = TerrainEdit::OP_HARD;
e.x = c.x; e.y = c.y; e.z = c.z;
e.radius = radius;
e.strength = -1.0f;
const uint32_t id = store_.apply_carve(e, std::string(key.utf8().get_data()));
dirty_ = true;
return (int)id;
}
int DCLevel::apply_carve_box(const Vector3 &c, const Vector3 &h, const String &key) {
TerrainEdit e;
e.shape = TerrainEdit::BOX;
e.x = c.x; e.y = c.y; e.z = c.z;
e.half_ext[0] = h.x; e.half_ext[1] = h.y; e.half_ext[2] = h.z;
e.radius = Math::sqrt(h.x*h.x + h.y*h.y + h.z*h.z);
e.strength = -1.0f;
const uint32_t id = store_.apply_carve(e, std::string(key.utf8().get_data()));
dirty_ = true;
return (int)id;
}
bool DCLevel::undo_carve(int id) { const bool r = store_.undo_carve((uint32_t)id); if (r) dirty_ = true; return r; }
bool DCLevel::redo_carve(int id) { const bool r = store_.redo_carve((uint32_t)id); if (r) dirty_ = true; return r; }
bool DCLevel::undo_carve_by_key(const String &key) {
const bool r = store_.undo_carve_by_key(std::string(key.utf8().get_data()));
if (r) dirty_ = true;
return r;
}
bool DCLevel::redo_carve_by_key(const String &key) {
const bool r = store_.redo_carve_by_key(std::string(key.utf8().get_data()));
if (r) dirty_ = true;
return r;
}
// ---------------------------------------------------------------------------
// The journal
// ---------------------------------------------------------------------------
static dc_journal::Kind _kind_from(const String &s) {
if (s == "brush.create") return dc_journal::Kind::BrushCreate;
if (s == "brush.remove") return dc_journal::Kind::BrushRemove;
if (s == "brush.split") return dc_journal::Kind::BrushSplit;
if (s == "brush.translate") return dc_journal::Kind::BrushTranslate;
if (s == "brush.scale") return dc_journal::Kind::BrushScale;
if (s == "brush.reshape") return dc_journal::Kind::BrushReshape;
if (s == "brush.subtract") return dc_journal::Kind::BrushSubtract;
if (s == "brush.intersect") return dc_journal::Kind::BrushIntersect;
if (s == "brush.merge") return dc_journal::Kind::BrushMerge;
if (s == "face.set") return dc_journal::Kind::FaceSet;
if (s == "face.add") return dc_journal::Kind::FaceAdd;
if (s == "face.remove") return dc_journal::Kind::FaceRemove;
if (s == "carve") return dc_journal::Kind::Carve;
return dc_journal::Kind::Unknown;
}
// Dictionary -> Record. An unreadable record becomes Kind::Unknown rather than
// throwing: a peer running an older build must be able to ignore a kind it does
// not know without losing the rest of the journal.
static dc_journal::Record _record_from(const Dictionary &d) {
dc_journal::Record r;
r.author = std::string(String(d.get("author", "")).utf8().get_data());
r.seq = (uint64_t)(int64_t)d.get("seq", 0);
r.kind = _kind_from(String(d.get("kind", "")));
r.undone = (bool)d.get("undone", false);
r.brush_key = std::string(String(d.get("brush_key", "")).utf8().get_data());
r.face_key = std::string(String(d.get("face_key", "")).utf8().get_data());
if (r.kind == dc_journal::Kind::BrushCreate) {
const PackedFloat32Array pl = d.get("planes", PackedFloat32Array());
const PackedInt32Array pj = d.get("proj", PackedInt32Array());
const int n = pl.size() / 4;
for (int i = 0; i < n; i++)
r.planes.push_back({ pl[i*4 + 0], pl[i*4 + 1], pl[i*4 + 2], pl[i*4 + 3] });
for (int i = 0; i < n; i++)
r.proj.push_back(i < pj.size() ? (uint32_t)pj[i] : 0u);
r.material_id = (uint32_t)(int)d.get("material_id", 0);
r.level_id = (uint32_t)(int)d.get("level_id", 0);
r.sky = (bool)d.get("sky", false);
// Absent on every record written before clearance existed, and the
// default is the right answer for all of them: nothing authored then was
// air the level owned.
r.clearance = (bool)d.get("clearance", false);
r.group = std::string(String(d.get("group", "")).utf8().get_data());
} else if (r.kind == dc_journal::Kind::FaceSet || r.kind == dc_journal::Kind::FaceAdd
|| r.kind == dc_journal::Kind::BrushSplit) {
const Vector3 n = d.get("n", Vector3());
r.plane = { n.x, n.y, n.z, (float)(double)d.get("d", 0.0) };
r.face_proj = (uint32_t)(int)d.get("face_proj", 0);
} else if (r.kind == dc_journal::Kind::BrushTranslate) {
const Vector3 v = d.get("delta", Vector3());
r.dx = v.x; r.dy = v.y; r.dz = v.z;
} else if (r.kind == dc_journal::Kind::BrushScale) {
const Vector3 sc = d.get("scale", Vector3(1, 1, 1));
const Vector3 pv = d.get("pivot", Vector3());
r.sx = sc.x; r.sy = sc.y; r.sz = sc.z;
r.px = pv.x; r.py = pv.y; r.pz = pv.z;
} else if (r.kind == dc_journal::Kind::BrushSubtract
|| r.kind == dc_journal::Kind::BrushIntersect) {
const PackedFloat32Array pl = d.get("planes", PackedFloat32Array());
for (int i = 0; i + 3 < pl.size(); i += 4)
r.planes.push_back({ pl[i], pl[i + 1], pl[i + 2], pl[i + 3] });
} else if (r.kind == dc_journal::Kind::BrushMerge) {
const PackedStringArray ks = d.get("brush_keys", PackedStringArray());
for (int i = 0; i < ks.size(); i++)
r.brush_keys.push_back(std::string(ks[i].utf8().get_data()));
} else if (r.kind == dc_journal::Kind::BrushReshape) {
const Vector3 v = d.get("delta", Vector3());
r.dx = v.x; r.dy = v.y; r.dz = v.z;
const PackedVector3Array pts = d.get("points", PackedVector3Array());
for (int i = 0; i < pts.size(); i++)
r.points.push_back({ pts[i].x, pts[i].y, pts[i].z });
} else if (r.kind == dc_journal::Kind::Carve) {
const Dictionary c = d.get("carve", Dictionary());
const Vector3 p = c.get("pos", Vector3());
r.carve.x = p.x; r.carve.y = p.y; r.carve.z = p.z;
r.carve.strength = -1.0f;
if (String(c.get("type", "sphere")) == "box") {
const Vector3 h = c.get("half", Vector3());
r.carve.shape = TerrainEdit::BOX;
r.carve.half_ext[0] = h.x; r.carve.half_ext[1] = h.y; r.carve.half_ext[2] = h.z;
r.carve.radius = Math::sqrt(h.x*h.x + h.y*h.y + h.z*h.z);
} else {
r.carve.shape = TerrainEdit::SPHERE;
r.carve.op = TerrainEdit::OP_HARD;
r.carve.radius = (float)(double)c.get("radius", 0.6);
}
}
return r;
}
Dictionary DCLevel::apply_journal(const Array &records) {
std::vector<dc_journal::Record> recs;
recs.reserve((size_t)records.size());
for (int i = 0; i < records.size(); i++) {
const Variant v = records[i];
if (v.get_type() != Variant::DICTIONARY) continue;
recs.push_back(_record_from(v));
}
const dc_journal::FoldStats st = dc_journal::fold(recs, store_);
dirty_ = true;
Dictionary out;
out["applied"] = (int)st.applied;
// NOT noise: a live record whose target is gone, or that the store refused
// (an edit that would empty a brush). It means the journal and this peer's
// geometry disagree, which is worth surfacing rather than swallowing.
out["skipped"] = (int)st.skipped;
out["undone"] = (int)st.undone;
out["records"] = (int)recs.size();
return out;
}
int64_t DCLevel::fingerprint() const { return (int64_t)store_.fingerprint(); }
// ---------------------------------------------------------------------------
// Derive
// ---------------------------------------------------------------------------
void DCLevel::rebuild() {
const auto t0 = std::chrono::high_resolution_clock::now();
last_split_pieces_ = 0;
last_split_swallowed_ = 0;
std::vector<CSG4Brush> view;
store_.csg4_view(view); // CLEAN brushes only
// Split every CARVED brush around its component's carve box, so the exact
// mesh keeps everything OUTSIDE the edit and only the edit-sized remainder
// voxelizes. Before this a carve voxelized the whole component: a 0.6m sphere
// in a wall cost 34,520 pocket triangles because the wall was 20m long.
_compute_pocket_boxes();
std::vector<dc_brush_split::SplitResult> splits;
splits.reserve(pocket_comps_.size() * 8);
for (size_t ci = 0; ci < pocket_comps_.size(); ci++) {
for (uint32_t bid : pocket_comps_[ci]) {
const DCBrush *b = store_.get(bid);
if (!b || b->tombstoned) continue;
dc_brush_split::SplitResult r;
if (!dc_brush_split::split_by_aabb(*b, pocket_boxes_[ci].first,
pocket_boxes_[ci].second, r)) continue;
// Counted so "the split often swallows a whole brush" is a NUMBER.
// Zero pieces means the carve box covered the brush outright, which
// is legitimate for a small brush and a symptom for a large one —
// and those two cases are indistinguishable without the count.
last_split_pieces_ += (uint32_t)r.pieces.size();
if (r.pieces.empty()) last_split_swallowed_++;
splits.push_back(std::move(r));
}
}
// Views are built in a SECOND pass, after `splits` has stopped growing:
// CSG4Brush holds non-owning pointers into the piece vectors.
for (const dc_brush_split::SplitResult &r : splits) {
for (const dc_brush_split::Piece &p : r.pieces) view.push_back(_piece_view(p));
if (r.has_remainder) view.push_back(_piece_view(r.remainder));
}
dc_csg4::compute_bounds(view);
const auto t_split = std::chrono::high_resolution_clock::now();
std::vector<CSG4Face> faces;
CSG4Stats st;
dc_csg4::clip_all(view, faces, &st);
// ---- HIDING, APPLIED HERE AND NOWHERE EARLIER (hyper-games #28) --------
//
// AFTER the clip, deliberately. csg4 has already seen the level exactly as it
// is, so a hidden brush still buries its neighbours' faces the way it did
// when it was visible — which is what makes unhiding restore the previous
// mesh to the digit rather than approximately. Filtering csg4's INPUT instead
// would re-clip every neighbour of the thing you hid, so hiding a wall would
// reveal the faces behind it and each toggle would cost a full re-clip of the
// level. The gate's control is a tombstone of the same brush, which does
// exactly that and must NOT produce the same answer as hiding.
//
// The counts are corrected by the DIFFERENCE rather than recomputed, so with
// nothing hidden they are bit-identical to what clip_all reported and the
// acceptance numbers in dc_level_gate keep meaning what they meant.
if (!hidden_.empty()) {
std::vector<CSG4Face> vis;
vis.reserve(faces.size());
double hidden_area = 0.0;
for (const CSG4Face &f : faces) {
if (hidden_.count(f.brush_id)) { hidden_area += dc_csg4::face_area(f); continue; }
vis.push_back(f);
}
st.out_faces -= (uint32_t)(faces.size() - vis.size());
st.out_area -= hidden_area;
faces.swap(vis);
}
const auto t_clip = std::chrono::high_resolution_clock::now();
DCLevelMeshData m;
dc_level_mesh::build(faces, m);
const auto t_mesh = std::chrono::high_resolution_clock::now();
last_faces_ = st.out_faces;
last_removed_ = st.removed;
last_fragmented_ = st.fragmented;
last_area_ = st.out_area;
last_tris_ = m.triangle_count();
last_verts_ = m.vertex_count();
last_normal_dev_ = dc_level_mesh::max_normal_deviation(m);
_upload_mesh(m);
const auto t_upload = std::chrono::high_resolution_clock::now();
if (build_collision_) {
std::vector<DCConvexCollider> cols;
if (hidden_.empty()) {
dc_level_mesh::build_colliders(view, cols);
} else {
// YOU ASKED TO FLY THROUGH WHAT YOU HID, so the collider goes with
// the surface. Filtered from a COPY: `view` is what csg4 was handed
// and what the split pieces point into, and shrinking it here would
// make the collider list disagree with the mesh it was clipped from.
std::vector<CSG4Brush> solid;
solid.reserve(view.size());
for (const CSG4Brush &b : view)
if (!hidden_.count(b.id)) solid.push_back(b);
dc_level_mesh::build_colliders(solid, cols);
}
_upload_collision(cols);
} else {
_free_collision();
}
const auto t_collide = std::chrono::high_resolution_clock::now();
// AFTER collision: pocket trimeshes go into the body _upload_collision just
// created, so this cannot run before it.
//
// NOT FILTERED BY `hidden_`, and that is a known gap rather than an oversight:
// a brush that is BOTH carved and hidden keeps its pocket mesh and its pocket
// trimesh, so it stays visible and solid where it was carved. Pockets are
// clustered by connected component (dc_brush_store pockets()), so excluding
// one brush means re-deriving its whole cluster, which is the expensive path
// hiding exists to avoid. Left until someone hides a carved brush and minds.
_build_pockets();
const auto t_pocket = std::chrono::high_resolution_clock::now();
// PER-PHASE, because "a carve hitches" has at least three candidate causes
// with completely different fixes — the full re-clip of 1183 brushes, the
// teardown and recreation of 1164 PhysicsServer shapes, or DC meshing the
// pocket — and one total cannot tell them apart. Measured in ms.
using ms = std::chrono::duration<double, std::milli>;
last_ms_split_ = ms(t_split - t0).count();
last_ms_clip_ = ms(t_clip - t_split).count();
last_ms_mesh_ = ms(t_mesh - t_clip).count();
last_ms_upload_ = ms(t_upload - t_mesh).count();
last_ms_collide_ = ms(t_collide - t_upload).count();
last_ms_pocket_ = ms(t_pocket - t_collide).count();
const auto t1 = std::chrono::high_resolution_clock::now();
last_build_ms_ = std::chrono::duration<double, std::milli>(t1 - t0).count();
dirty_ = false;
}
void DCLevel::_upload_mesh(const DCLevelMeshData &m) {
if (!mesh_node_) { dirty_ = true; return; } // not in the tree yet; _ready rebuilds
mesh_.instantiate();
if (m.triangle_count() == 0) {
mesh_node_->set_mesh(mesh_);
return;
}
const int vc = (int)m.vertex_count();
PackedVector3Array verts; verts.resize(vc);
PackedVector3Array norms; norms.resize(vc);
PackedColorArray colors; colors.resize(vc);
PackedFloat32Array proj; proj.resize(vc * 2);
PackedInt32Array idx; idx.resize((int)m.indices.size());
Vector3 *vp = verts.ptrw();
Vector3 *np = norms.ptrw();
Color *cp = colors.ptrw();
float *pp = proj.ptrw();
for (int i = 0; i < vc; i++) {
vp[i] = Vector3(m.positions[i*3], m.positions[i*3+1], m.positions[i*3+2]);
np[i] = Vector3(m.normals[i*3], m.normals[i*3+1], m.normals[i*3+2]);
// Route ② — identical encoding to DCTerrain's chunk meshes so the SAME
// shader works: R = material_id/255, A = 1 on a textured brush face.
const float mat = (float)m.material[i];
cp[i] = Color(mat / 255.0f, 0.0f, 0.0f, mat > 0.5f ? 1.0f : 0.0f);
// Both projection candidates are the SAME face here. On the voxel mesh
// these differ and the fragment shader picks by normal; on exact
// geometry the face is unambiguous, so the pick degenerates to a no-op.
pp[i*2 + 0] = (float)m.proj[i];
pp[i*2 + 1] = (float)m.proj[i];
}
int32_t *ip = idx.ptrw();
for (size_t i = 0; i < m.indices.size(); i++) ip[i] = m.indices[i];
Array arrays;
arrays.resize(Mesh::ARRAY_MAX);
arrays[Mesh::ARRAY_VERTEX] = verts;
arrays[Mesh::ARRAY_NORMAL] = norms;
arrays[Mesh::ARRAY_COLOR] = colors;
arrays[Mesh::ARRAY_CUSTOM0] = proj;
arrays[Mesh::ARRAY_INDEX] = idx;
const uint64_t flags =
(uint64_t)Mesh::ARRAY_CUSTOM_RG_FLOAT << Mesh::ARRAY_FORMAT_CUSTOM0_SHIFT;
mesh_->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays,
Array(), Dictionary(), flags);
mesh_node_->set_mesh(mesh_);
}
void DCLevel::_upload_collision(const std::vector<DCConvexCollider> &cols) {
PhysicsServer3D *ps = PhysicsServer3D::get_singleton();
if (!ps || !is_inside_tree()) return;
_free_collision();
Ref<World3D> w = get_world_3d();
if (w.is_null()) return;
body_ = ps->body_create();
ps->body_set_mode(body_, PhysicsServer3D::BODY_MODE_STATIC);
ps->body_set_space(body_, w->get_space());
ps->body_set_state(body_, PhysicsServer3D::BODY_STATE_TRANSFORM,
get_global_transform());
// Without this a raycast result has collider == null and collider_id == 0:
// the hit is detected but has no OWNER, so the editor cannot get from a
// click to a brush (§10). Attaching our instance id makes the ray result
// point back at this node; `shape` then resolves the brush via
// get_brush_id_from_shape().
ps->body_attach_object_instance_id(body_, get_instance_id());
// One convex shape per brush. Exact AND cheaper than a trimesh — the brush
// plane set IS a convex hull, so there is nothing to approximate (§10).
shapes_.reserve(cols.size());
std::unordered_map<uint64_t, RID> used;
used.reserve(cols.size());
for (const DCConvexCollider &c : cols) {
const uint32_t n = c.point_count();
if (n < 4) continue;
// A convex shape whose POINTS are unchanged is the same shape. On a carve
// ~1170 of ~1172 brushes are untouched, so creating them all again was
// ~2300 PhysicsServer calls per keypress for geometry that did not move.
// Keyed on the points rather than brush_id, because split pieces share
// their parent's id while having different plane sets.
uint64_t key = 1469598103934665603ull;
auto mix = [&key](uint32_t v) { key ^= v; key *= 1099511628211ull; };
mix(n);
for (uint32_t i = 0; i < n * 3; i++) {
uint32_t b; memcpy(&b, &c.points[i], 4); mix(b);
}
RID s;
auto it = shape_cache_.find(key);
if (it != shape_cache_.end()) {
s = it->second;
} else {
PackedVector3Array pts;
pts.resize((int)n);
Vector3 *p = pts.ptrw();
for (uint32_t i = 0; i < n; i++)
p[i] = Vector3(c.points[i*3], c.points[i*3+1], c.points[i*3+2]);
s = ps->convex_polygon_shape_create();
ps->shape_set_data(s, pts);
shape_cache_[key] = s;
}
used[key] = s;
ps->body_add_shape(body_, s, Transform3D());
shapes_.push_back(s);
// Recorded in LOCKSTEP with the shape, not derived from the loop index:
// the `n < 4` continue above means a brush can contribute no shape, so
// shape index and brush index drift apart.
shape_brush_ids_.push_back(c.brush_id);
}
// Drop shapes no longer referenced, or the cache grows without bound as
// pieces come and go across carves.
for (auto it = shape_cache_.begin(); it != shape_cache_.end(); ) {
if (used.find(it->first) == used.end()) {
if (it->second.is_valid()) ps->free_rid(it->second);
it = shape_cache_.erase(it);
} else {
++it;
}
}
}
// ---------------------------------------------------------------------------
// Pockets — the carved half of the level (docs §7, §12 step 5)
//
// A carved brush leaves csg4_view (dc_brush_store.h:227) because a carved solid
// is non-convex and the winding clip cannot represent it. Before this, nothing
// meshed what left, so a carve DELETED the wall instead of carving it — and took
// its collider with it. Proven and pinned in tests/test_pocket.cpp PART 1.
// ---------------------------------------------------------------------------
// A CSG4Brush view over a split piece. Non-owning — the SplitResult must outlive
// the view, which is why rebuild() builds every view in a second pass.
CSG4Brush DCLevel::_piece_view(const dc_brush_split::Piece &p) {
CSG4Brush v;
v.planes = p.planes.data();
v.plane_count = (uint32_t)p.planes.size();
v.plane_proj = p.plane_proj.data();
v.plane_internal = p.plane_internal.data();
v.material_id = p.material_id;
v.id = p.brush_id; // provenance stays the ORIGINAL brush
v.sky = p.sky;
v.clip_only = p.clip_only; // remainder: clips, draws nothing, NO collider
return v;
}
// One box per CLUSTER OF CARVES, not per connected component of brushes.
//
// It was per component, to guarantee two pockets could never mesh the same space
// (that pocket/pocket boundary is an internal seam with nothing to anchor it).
// The guarantee was right; the grouping was too coarse. A component accumulates
// every carve ever made in it, so its box grew to span all of them and the pocket
// remeshed the untouched solid in between. Measured: 15 same-sized carves drove
// pocket_triangles 920 -> 52,710 and ms_pocket 5.9 -> 422.
//
// Carves that do not overlap cannot share a surface, so they do not need to share
// a pocket. Clustering by AABB overlap keeps the no-overlap guarantee exactly
// where it is load-bearing and lets distant carves stay small and independent.
// Clusters are then merged until no two BOXES overlap, because a cluster's box is
// the bounding box of a connected chain and two such boxes can still intersect
// without any individual carves doing so.
void DCLevel::_compute_pocket_boxes() {
pocket_comps_.clear();
pocket_boxes_.clear();
struct Cluster { CSG4Vec3 lo, hi; std::vector<uint32_t> carve_ids; };
std::vector<Cluster> cl;
for (const DCCarve &c : store_.carves()) {
if (c.undone) continue;
Cluster k;
dc_brush_split::carve_bounds(c.shape, k.lo, k.hi);
k.carve_ids.push_back(c.id);
cl.push_back(std::move(k));
}
if (cl.empty()) return;
// Merge to a fixpoint on box overlap.
bool merged = true;
while (merged) {
merged = false;
for (size_t i = 0; i < cl.size() && !merged; i++) {
for (size_t j = i + 1; j < cl.size() && !merged; j++) {
const bool overlap =
!(cl[i].hi.x < cl[j].lo.x || cl[i].lo.x > cl[j].hi.x ||
cl[i].hi.y < cl[j].lo.y || cl[i].lo.y > cl[j].hi.y ||
cl[i].hi.z < cl[j].lo.z || cl[i].lo.z > cl[j].hi.z);
if (!overlap) continue;
dc_brush_split::union_aabb(cl[i].lo, cl[i].hi, cl[j].lo, cl[j].hi);
cl[i].carve_ids.insert(cl[i].carve_ids.end(),
cl[j].carve_ids.begin(), cl[j].carve_ids.end());
cl.erase(cl.begin() + (long)j);
merged = true;
}
}
}
// Each cluster owns the brushes its carves touched.
for (Cluster &k : cl) {
std::vector<uint32_t> brushes;
for (const DCCarve &c : store_.carves()) {
if (c.undone) continue;
if (std::find(k.carve_ids.begin(), k.carve_ids.end(), c.id) == k.carve_ids.end())
continue;
for (uint32_t t : c.touched) {
const DCBrush *b = store_.get(t);
if (!b || b->tombstoned) continue;
if (std::find(brushes.begin(), brushes.end(), t) == brushes.end())
brushes.push_back(t);
}
}
if (brushes.empty()) continue;
std::sort(brushes.begin(), brushes.end());
if (POCKET_INFLATE_VOXELS > 0.0f)
dc_brush_split::inflate(k.lo, k.hi, pocket_voxel_size_ * POCKET_INFLATE_VOXELS);
pocket_comps_.push_back(std::move(brushes));
pocket_boxes_.push_back({k.lo, k.hi});
}
}
// Identity of a pocket's INPUTS. If this is unchanged the mesh is unchanged, so
// the DC pass can be skipped entirely — which is the whole point: one carve
// changes one cluster, and re-meshing the others was costing hundreds of ms.
uint64_t DCLevel::_pocket_key(size_t ci) const {
uint64_t h = 1469598103934665603ull;
auto mix = [&h](uint64_t v) { h ^= v; h *= 1099511628211ull; };
auto mixf = [&](float f) { uint32_t b; memcpy(&b, &f, 4); mix(b); };
const CSG4Vec3 &lo = pocket_boxes_[ci].first;
const CSG4Vec3 &hi = pocket_boxes_[ci].second;
mixf(lo.x); mixf(lo.y); mixf(lo.z);
mixf(hi.x); mixf(hi.y); mixf(hi.z);
mixf(pocket_voxel_size_);
// Brush GENERATION as well as id: a shape edit changes the solid without
// changing which brushes are involved, and a stale pocket would then show
// geometry the brush no longer has.
for (uint32_t bid : pocket_comps_[ci]) {
mix(bid);
const DCBrush *b = store_.get(bid);
mix(b ? b->generation : 0u);
}
// The active carves THIS POCKET DEPENDS ON — by shape, and only those that can
// reach its box. Both halves of that changed, and both matter now that undo is
// a journal re-fold rather than a flag flip.
//
// BY SHAPE, NOT BY ID. The carve id used to be mixed in, which was harmless
// while carves only accumulated. The fold assigns ids in journal order, so
// undoing the 3rd of 15 renumbers the other 12 and every key changes. Nothing
// is lost by dropping it: the generator consumes the SHAPE, and two carves with
// the same shape subtract the same volume, so they are interchangeable to a
// pocket by definition.
//
// ONLY THE OVERLAPPING ONES. Mixing every active carve made each pocket depend
// on all of them, so undoing any carve anywhere invalidated every pocket — a
// full DC pass over the whole level for an edit that moved one cluster. That is
// the 5.9ms -> 422ms cost this cache exists to avoid, arriving on the one
// interaction that has to feel instant.
//
// The inflation is what keeps that sound. dc_pocket::build_bounded samples a
// grid whose origin is pulled back by cfg.padding voxels, so a carve sitting
// just OUTSIDE the box still moves sample values in the padding ring and can
// shift the DC surface inside it. And when a cluster exceeds max_grid the voxel
// is recomputed coarser (ext/usable, usable >= 4), which widens that ring — so
// the margin is max(3 voxels, half the box extent), which covers every path
// build_bounded can take. A stale pocket is exactly the failure mode not worth
// being clever about.
const float ext = std::max(std::max(hi.x - lo.x, hi.y - lo.y), hi.z - lo.z);
const float margin = std::max(3.0f * pocket_voxel_size_, 0.5f * ext);
const CSG4Vec3 dep_lo{lo.x - margin, lo.y - margin, lo.z - margin};
const CSG4Vec3 dep_hi{hi.x + margin, hi.y + margin, hi.z + margin};
for (const DCCarve &c : store_.carves()) {
if (c.undone) continue;
float ex, ey, ez;
if (c.shape.shape == TerrainEdit::BOX) {
ex = c.shape.half_ext[0]; ey = c.shape.half_ext[1]; ez = c.shape.half_ext[2];
} else {
ex = ey = ez = c.shape.radius; // sphere / mesh / convex: bounding sphere
}
if (c.shape.x + ex < dep_lo.x || c.shape.x - ex > dep_hi.x ||
c.shape.y + ey < dep_lo.y || c.shape.y - ey > dep_hi.y ||
c.shape.z + ez < dep_lo.z || c.shape.z - ez > dep_hi.z) continue;
mixf(c.shape.x); mixf(c.shape.y); mixf(c.shape.z); mixf(c.shape.radius);
}
return h;
}
void DCLevel::_build_pockets() {
last_pockets_ = 0;
last_pocket_tris_ = 0;
pocket_mesh_.instantiate();
if (!pocket_node_) { dirty_ = true; return; }
// Reuses the boxes rebuild() already computed — recomputing here would let
// the pocket and the split drift apart, and they MUST agree or the exact
// pieces and the voxel surface stop meeting at the same plane.
const std::vector<std::vector<uint32_t>> &comps = pocket_comps_;
last_pockets_ = (uint32_t)comps.size();
if (comps.empty()) {
pocket_node_->set_mesh(pocket_mesh_); // clears any previous pocket
return;
}
PhysicsServer3D *ps = PhysicsServer3D::get_singleton();
dc_pocket::PocketConfig cfg;
cfg.voxel_size = pocket_voxel_size_;
std::vector<PocketCache> next_cache;
next_cache.reserve(comps.size());
last_pockets_rebuilt_ = 0;
for (size_t ci = 0; ci < comps.size(); ci++) {
const std::vector<uint32_t> &comp = comps[ci];
// REUSE if nothing this pocket depends on changed. One carve dirties one
// cluster; without this every rebuild re-ran DC over every pocket ever
// made, which is what drove ms_pocket to 422ms by the 15th carve.
const uint64_t key = _pocket_key(ci);
bool reused = false;
for (const PocketCache &pc : pocket_cache_) {
if (pc.key != key || pc.arrays.is_empty()) continue;
pocket_mesh_->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, pc.arrays,
Array(), Dictionary(),
(uint64_t)Mesh::ARRAY_CUSTOM_RG_FLOAT
<< Mesh::ARRAY_FORMAT_CUSTOM0_SHIFT);
last_pocket_tris_ += pc.tris;
PocketCache reuse = pc;
_add_pocket_collision(reuse, comp);
next_cache.push_back(reuse);
reused = true;
break;
}
if (reused) continue;
last_pockets_rebuilt_++;
dc_pocket::PocketResult r;
// The SPLIT's box, not the component's AABB. This is the whole point:
// sample the edit, not the wall.
const CSG4Vec3 &blo = pocket_boxes_[ci].first;
const CSG4Vec3 &bhi = pocket_boxes_[ci].second;
const float plo[3] = {blo.x, blo.y, blo.z};
const float phi[3] = {bhi.x, bhi.y, bhi.z};
if (!dc_pocket::build_bounded(store_, comp, cfg, plo, phi, r)) continue;
const int icount = (int)r.mesh.indices.size();
const int vcount = (int)(r.mesh.positions.size() / 3);
if (icount < 3 || vcount < 3) continue; // carve removed the brush entirely
PackedVector3Array verts; verts.resize(vcount);
PackedVector3Array norms; norms.resize(vcount);
PackedColorArray colors; colors.resize(vcount);
PackedFloat32Array proj; proj.resize(vcount * 2);
PackedInt32Array idx; idx.resize(icount);
Vector3 *vp = verts.ptrw();
Vector3 *np = norms.ptrw();
Color *cp = colors.ptrw();
float *pp = proj.ptrw();
for (int i = 0; i < vcount; i++) {
const float x = r.mesh.positions[i*3 + 0];
const float y = r.mesh.positions[i*3 + 1];
const float z = r.mesh.positions[i*3 + 2];
vp[i] = Vector3(x, y, z);
const Vector3 n(r.mesh.normals[i*3 + 0],
r.mesh.normals[i*3 + 1],
r.mesh.normals[i*3 + 2]);
np[i] = n;
// Material by POINT LOCATION — "which brush contains this vertex" —
// not by nearest plane. tests/test_material_approaches.cpp measured
// both: extent/point-location is 0% error on 3-way corners and on
// coplanar splits, while plane-distance and 3rd-candidate sit at
// 8-10% and the doc says outright "do not build them". The candidate
// set here is one connected component, so this stays cheap.
const DCBrush *owner = nullptr;
float best_inside = 1e30f;
for (uint32_t id : comp) {
const DCBrush *b = store_.get(id);
if (!b || b->tombstoned) continue;
float f = -1e30f; // max_i(n·p - d): <=0 inside
for (const ConvexPlane &pl : b->planes) {
const float dp = pl.nx*x + pl.ny*y + pl.nz*z - pl.d;
if (dp > f) f = dp;
}
if (f < best_inside) { best_inside = f; owner = b; }
}
uint32_t mat = 0, pr = 0;
if (owner) {
mat = owner->material_id;
// Which FACE of that brush this vertex belongs to: the one whose
// plane normal best matches the surface normal DC produced. On a
// carved surface no face matches well, which is correct — those
// fragments get the owner's material and a projection that is
// merely the closest available. Per-fragment selection for
// pockets is the known follow-up (docs §11).
float best_dot = -2.0f;
for (size_t f = 0; f < owner->planes.size(); f++) {
const ConvexPlane &pl = owner->planes[f];
const float d = pl.nx*n.x + pl.ny*n.y + pl.nz*n.z;
if (d > best_dot) { best_dot = d; pr = owner->plane_proj[f]; }
}
}
// Same encoding as the exact mesh and DCTerrain's chunks, so the SAME
// shader works with no branch for pockets.
cp[i] = Color((float)mat / 255.0f, 0.0f, 0.0f, mat > 0 ? 1.0f : 0.0f);
pp[i*2 + 0] = (float)pr;
pp[i*2 + 1] = (float)pr;
}
int32_t *ip = idx.ptrw();
for (int i = 0; i < icount; i++) ip[i] = r.mesh.indices[i];
Array arrays;
arrays.resize(Mesh::ARRAY_MAX);
arrays[Mesh::ARRAY_VERTEX] = verts;
arrays[Mesh::ARRAY_NORMAL] = norms;
arrays[Mesh::ARRAY_COLOR] = colors;
arrays[Mesh::ARRAY_CUSTOM0] = proj;
arrays[Mesh::ARRAY_INDEX] = idx;
const uint64_t flags =
(uint64_t)Mesh::ARRAY_CUSTOM_RG_FLOAT << Mesh::ARRAY_FORMAT_CUSTOM0_SHIFT;
pocket_mesh_->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays,
Array(), Dictionary(), flags);
last_pocket_tris_ += (uint32_t)(icount / 3);
PocketCache pc;
pc.key = key;
pc.arrays = arrays;
pc.tris = (uint32_t)(icount / 3);
// Collision. A pocket is concave, so it cannot be a convex shape the way
// a brush is — this is the one place the level uses a trimesh, and the
// §10 caveat applies: a trimesh is a SURFACE, so fast movers can tunnel
// it. Acceptable here because the pocket sits inside the volume its
// brush used to occupy, not at the level boundary.
const std::vector<int> &ci_idx = r.mesh.collision_indices.empty()
? r.mesh.indices : r.mesh.collision_indices;
if (ci_idx.size() >= 3) {
pc.faces.resize((int)ci_idx.size());
Vector3 *fp = pc.faces.ptrw();
for (size_t i = 0; i < ci_idx.size(); i++) {
const int v = ci_idx[i];
if (v < 0 || v >= vcount) { fp[i] = Vector3(); continue; }
fp[i] = Vector3(r.mesh.positions[v*3 + 0],
r.mesh.positions[v*3 + 1],
r.mesh.positions[v*3 + 2]);
}
_add_pocket_collision(pc, comp);
}
next_cache.push_back(std::move(pc));
}
// Entries for clusters that no longer exist are dropped here rather than
// accumulating — an undone carve must not leave its pocket cached forever.
// Free shapes for pockets that no longer exist before dropping their entries.
if (PhysicsServer3D *ps2 = PhysicsServer3D::get_singleton()) {
for (PocketCache &old : pocket_cache_) {
bool still = false;
for (const PocketCache &nw : next_cache)
if (nw.shape == old.shape) { still = true; break; }
if (!still && old.shape.is_valid()) ps2->free_rid(old.shape);
}
}
pocket_cache_.swap(next_cache);
pocket_node_->set_mesh(pocket_mesh_);
}
void DCLevel::_add_pocket_collision(PocketCache &pc,
const std::vector<uint32_t> &comp) {
PhysicsServer3D *ps = PhysicsServer3D::get_singleton();
if (!build_collision_ || !ps || !body_.is_valid() || pc.faces.size() < 3) return;
// Built once per pocket and carried in the cache: a reused pocket mesh has a
// reused collision shape, so a carve costs one trimesh, not one per pocket.
if (!pc.shape.is_valid()) {
pc.shape = ps->concave_polygon_shape_create();
Dictionary d;
d["faces"] = pc.faces;
ps->shape_set_data(pc.shape, d);
}
ps->body_add_shape(body_, pc.shape, Transform3D());
shapes_.push_back(pc.shape);
// Kept in lockstep with shapes_ so a pick on a pocket still resolves to a
// real brush id rather than shifting every later shape's mapping by one.
shape_brush_ids_.push_back(comp.empty() ? 0u : comp[0]);
}
void DCLevel::set_pocket_voxel_size(float v) {
pocket_voxel_size_ = v > 1e-4f ? v : 0.224f;
}
float DCLevel::get_pocket_voxel_size() const { return pocket_voxel_size_; }
void DCLevel::_free_collision() {
PhysicsServer3D *ps = PhysicsServer3D::get_singleton();
if (!ps) { shapes_.clear(); shape_brush_ids_.clear(); body_ = RID(); return; }
if (body_.is_valid()) { ps->free_rid(body_); body_ = RID(); }
// shapes_ is only a per-rebuild ORDERING of RIDs owned by shape_cache_ and
// pocket_cache_. Freeing them here would destroy the cache the next rebuild
// is about to reuse — they are released when their cache entry is evicted,
// and wholesale in _free_all_shapes() at teardown.
shapes_.clear();
// Must die with the shapes it indexes — a stale mapping after a rebuild
// would resolve picks to the WRONG brush, which is worse than failing.
shape_brush_ids_.clear();
}
void DCLevel::set_build_collision(bool v) { build_collision_ = v; }
bool DCLevel::get_build_collision() const { return build_collision_; }
// ---------------------------------------------------------------------------
// Introspection
// ---------------------------------------------------------------------------
static PackedVector3Array _to_packed(const std::vector<CSG4Vec3> &v) {
PackedVector3Array out;
out.resize((int)v.size());
for (size_t i = 0; i < v.size(); i++)
out.set((int)i, Vector3(v[i].x, v[i].y, v[i].z));
return out;
}
PackedVector3Array DCLevel::get_brush_edges() const {
std::vector<CSG4Vec3> e;
store_.brush_edges(e);
return _to_packed(e);
}
void DCLevel::set_brush_hidden(int id, bool hidden) {
const uint32_t u = (uint32_t)id;
const bool had = hidden_.count(u) != 0;
if (had == hidden) return; // no state change, no rebuild
if (hidden) hidden_.insert(u); else hidden_.erase(u);
dirty_ = true;
}
bool DCLevel::is_brush_hidden(int id) const {
return hidden_.count((uint32_t)id) != 0;
}
PackedInt32Array DCLevel::get_hidden_brush_ids() const {
PackedInt32Array out;
// Store order, not set order: an unordered_set iterates differently between
// runs, and a panel that redraws its rows in a new order every refresh is
// unusable. Store order is import order, which is stable everywhere.
for (const DCBrush &b : store_.brushes())
if (hidden_.count(b.id)) out.push_back((int)b.id);
return out;
}
void DCLevel::clear_hidden() {
if (hidden_.empty()) return;
hidden_.clear();
dirty_ = true;
}
PackedVector3Array DCLevel::get_clearance_edges() const {
std::vector<CSG4Vec3> e;
store_.clearance_edges(e);
return _to_packed(e);
}
PackedVector3Array DCLevel::get_brush_edges_of(int id) const {
std::vector<CSG4Vec3> e;
store_.brush_edges_of((uint32_t)id, e);
return _to_packed(e);
}
PackedVector3Array DCLevel::get_brush_vertices_of(int id) const {
std::vector<CSG4Vec3> v;
store_.brush_vertices_of((uint32_t)id, v);
return _to_packed(v);
}
PackedVector3Array DCLevel::get_cut_preview(int id, const Vector3 &n, float d) const {
std::vector<CSG4Vec3> poly;
if (!store_.cut_preview((uint32_t)id, {n.x, n.y, n.z, d}, poly))
return PackedVector3Array();
return _to_packed(poly);
}
int DCLevel::get_brush_count() const {
int n = 0;
for (const DCBrush &b : store_.brushes()) if (!b.tombstoned) n++;
return n;
}
int DCLevel::get_triangle_count() const { return (int)last_tris_; }
int DCLevel::get_vertex_count() const { return (int)last_verts_; }
int DCLevel::get_collider_count() const { return (int)shapes_.size(); }
int DCLevel::get_brush_id_from_shape(int shape_index) const {
if (shape_index < 0 || shape_index >= (int)shape_brush_ids_.size()) return -1;
return (int)shape_brush_ids_[shape_index];
}
int DCLevel::get_carve_count() const {
int n = 0;
for (const DCCarve &c : store_.carves()) if (!c.undone) n++;
return n;
}
int DCLevel::get_pocket_count() const {
std::vector<std::vector<uint32_t>> p;
store_.pockets(p);
return (int)p.size();
}
Dictionary DCLevel::get_stats() const {
Dictionary d;
d["brushes"] = get_brush_count();
d["faces"] = (int)last_faces_;
d["triangles"] = (int)last_tris_;
d["vertices"] = (int)last_verts_;
d["faces_removed"] = (int)last_removed_;
d["faces_fragmented"] = (int)last_fragmented_;
d["surface_area"] = last_area_;
d["max_normal_deviation"] = last_normal_dev_;
d["colliders"] = get_collider_count();
d["sky_brushes"] = get_sky_brush_ids().size();
// Counted separately because `brushes` above includes them and they are not
// geometry — a level reporting 1190 brushes and 12747 triangles has not lost
// seven of them, it has seven passages.
d["clearance_brushes"] = get_clearance_brush_ids().size();
d["hidden_brushes"] = (int)hidden_.size();
d["carves"] = get_carve_count();
d["pockets"] = get_pocket_count();
// Reported separately from `triangles` on purpose: the exact mesh and the
// voxel pockets have completely different cost curves, and one number hides
// a pocket that has quietly become most of the frame.
d["pocket_triangles"] = (int)last_pocket_tris_;
d["pocket_voxel_size"] = pocket_voxel_size_;
d["ms_split"] = last_ms_split_;
d["ms_clip"] = last_ms_clip_;
d["ms_mesh"] = last_ms_mesh_;
d["ms_upload"] = last_ms_upload_;
d["ms_collide"] = last_ms_collide_;
d["ms_pocket"] = last_ms_pocket_;
d["pockets_rebuilt"] = (int)last_pockets_rebuilt_; // rest came from cache
d["split_pieces"] = (int)last_split_pieces_;
d["split_swallowed"] = (int)last_split_swallowed_; // carved brushes with NO exact remainder
d["build_ms"] = last_build_ms_;
return d;
}
// ---------------------------------------------------------------------------
void DCLevel::_bind_methods() {
ClassDB::bind_method(D_METHOD("add_brush", "planes", "material_id", "plane_proj", "level_id", "sky", "key", "clearance", "group"),
&DCLevel::add_brush, DEFVAL(PackedInt32Array()), DEFVAL(0), DEFVAL(false),
DEFVAL(String()), // "" = an import, named i:<level_id>:<n>
DEFVAL(false), // air the level owns; see DCBrush::clearance
DEFVAL(String())); // the operator invocation; see DCBrush::group
ClassDB::bind_method(D_METHOD("get_brush_group", "brush_id"), &DCLevel::get_brush_group);
ClassDB::bind_method(D_METHOD("get_group_brush_ids", "group"), &DCLevel::get_group_brush_ids);
ClassDB::bind_method(D_METHOD("get_group_keys"), &DCLevel::get_group_keys);
ClassDB::bind_method(D_METHOD("get_sky_brush_ids"), &DCLevel::get_sky_brush_ids);
ClassDB::bind_method(D_METHOD("get_clearance_brush_ids"), &DCLevel::get_clearance_brush_ids);
ClassDB::bind_method(D_METHOD("get_live_brush_ids"), &DCLevel::get_live_brush_ids);
ClassDB::bind_method(D_METHOD("get_clearance_edges"), &DCLevel::get_clearance_edges);
ClassDB::bind_method(D_METHOD("set_brush_hidden", "brush_id", "hidden"), &DCLevel::set_brush_hidden);
ClassDB::bind_method(D_METHOD("is_brush_hidden", "brush_id"), &DCLevel::is_brush_hidden);
ClassDB::bind_method(D_METHOD("get_hidden_brush_ids"), &DCLevel::get_hidden_brush_ids);
ClassDB::bind_method(D_METHOD("clear_hidden"), &DCLevel::clear_hidden);
ClassDB::bind_method(D_METHOD("build_shell", "cell", "clamp_cells"), &DCLevel::build_shell,
DEFVAL(0.25f), DEFVAL(0.5f));
ClassDB::bind_method(D_METHOD("build_shell_start", "cell", "clamp_cells"),
&DCLevel::build_shell_start, DEFVAL(0.25f), DEFVAL(0.5f));
ClassDB::bind_method(D_METHOD("shell_building"), &DCLevel::shell_building);
ClassDB::bind_method(D_METHOD("shell_ready"), &DCLevel::shell_ready);
ClassDB::bind_method(D_METHOD("build_shell_take"), &DCLevel::build_shell_take);
ClassDB::bind_method(D_METHOD("remove_brush", "brush_id"), &DCLevel::remove_brush);
ClassDB::bind_method(D_METHOD("restore_brush", "brush_id"), &DCLevel::restore_brush);
ClassDB::bind_method(D_METHOD("remove_level", "level_id"), &DCLevel::remove_level);
ClassDB::bind_method(D_METHOD("restore_level", "level_id"), &DCLevel::restore_level);
ClassDB::bind_method(D_METHOD("clear"), &DCLevel::clear);
ClassDB::bind_method(D_METHOD("translate_brush", "brush_id", "delta"), &DCLevel::translate_brush);
ClassDB::bind_method(D_METHOD("scale_brush", "brush_id", "scale", "pivot"), &DCLevel::scale_brush);
ClassDB::bind_method(D_METHOD("move_brush_points", "brush_id", "from", "delta", "key"),
&DCLevel::move_brush_points);
ClassDB::bind_method(D_METHOD("subtract_brush", "brush_id", "cutter", "key"),
&DCLevel::subtract_brush);
ClassDB::bind_method(D_METHOD("intersect_brush", "brush_id", "other", "key"),
&DCLevel::intersect_brush);
ClassDB::bind_method(D_METHOD("merge_brushes", "brush_ids", "key"),
&DCLevel::merge_brushes);
ClassDB::bind_method(D_METHOD("set_face_plane", "brush_id", "face", "normal", "d"), &DCLevel::set_face_plane);
ClassDB::bind_method(D_METHOD("add_face", "brush_id", "normal", "d", "proj", "key"), &DCLevel::add_face);
ClassDB::bind_method(D_METHOD("remove_face", "brush_id", "face"), &DCLevel::remove_face);
ClassDB::bind_method(D_METHOD("split_brush", "brush_id", "normal", "d", "key"),
&DCLevel::split_brush);
ClassDB::bind_method(D_METHOD("get_brush", "brush_id"), &DCLevel::get_brush);
ClassDB::bind_method(D_METHOD("pick_face", "brush_id", "point"), &DCLevel::pick_face);
// Engine-free picking — no physics server needed (hyper-games #22 §5 item 3).
ClassDB::bind_method(D_METHOD("raycast", "from", "to", "include_clearance"),
&DCLevel::raycast, DEFVAL(false));
ClassDB::bind_method(D_METHOD("face_by_key", "brush_id", "key"), &DCLevel::face_by_key);
ClassDB::bind_method(D_METHOD("get_face_winding", "brush_id", "face"), &DCLevel::get_face_winding);
ClassDB::bind_method(D_METHOD("face_is_live", "brush_id", "face"), &DCLevel::face_is_live);
ClassDB::bind_method(D_METHOD("apply_carve_sphere", "center", "radius", "key"),
&DCLevel::apply_carve_sphere, DEFVAL(String()));
ClassDB::bind_method(D_METHOD("apply_carve_box", "center", "half_extents", "key"),
&DCLevel::apply_carve_box, DEFVAL(String()));
ClassDB::bind_method(D_METHOD("undo_carve", "carve_id"), &DCLevel::undo_carve);
// The cross-peer forms — a journal replay MUST use these; the id-taking pair
// above is local-only (hyper-games #22 §5 item 4).
ClassDB::bind_method(D_METHOD("undo_carve_by_key", "key"), &DCLevel::undo_carve_by_key);
ClassDB::bind_method(D_METHOD("redo_carve_by_key", "key"), &DCLevel::redo_carve_by_key);
ClassDB::bind_method(D_METHOD("brush_by_key", "key"), &DCLevel::brush_by_key);
ClassDB::bind_method(D_METHOD("get_brush_key", "brush_id"), &DCLevel::get_brush_key);
ClassDB::bind_method(D_METHOD("apply_journal", "records"), &DCLevel::apply_journal);
ClassDB::bind_method(D_METHOD("fingerprint"), &DCLevel::fingerprint);
ClassDB::bind_method(D_METHOD("redo_carve", "carve_id"), &DCLevel::redo_carve);
ClassDB::bind_method(D_METHOD("rebuild"), &DCLevel::rebuild);
ClassDB::bind_method(D_METHOD("set_build_collision", "value"), &DCLevel::set_build_collision);
ClassDB::bind_method(D_METHOD("get_build_collision"), &DCLevel::get_build_collision);
ClassDB::bind_method(D_METHOD("set_pocket_voxel_size", "value"), &DCLevel::set_pocket_voxel_size);
ClassDB::bind_method(D_METHOD("get_pocket_voxel_size"), &DCLevel::get_pocket_voxel_size);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "build_collision"),
"set_build_collision", "get_build_collision");
ClassDB::bind_method(D_METHOD("get_brush_edges"), &DCLevel::get_brush_edges);
ClassDB::bind_method(D_METHOD("get_brush_edges_of", "brush_id"), &DCLevel::get_brush_edges_of);
ClassDB::bind_method(D_METHOD("get_brush_vertices_of", "brush_id"),
&DCLevel::get_brush_vertices_of);
ClassDB::bind_method(D_METHOD("get_cut_preview", "brush_id", "normal", "d"),
&DCLevel::get_cut_preview);
ClassDB::bind_method(D_METHOD("get_brush_count"), &DCLevel::get_brush_count);
ClassDB::bind_method(D_METHOD("get_triangle_count"), &DCLevel::get_triangle_count);
ClassDB::bind_method(D_METHOD("get_vertex_count"), &DCLevel::get_vertex_count);
ClassDB::bind_method(D_METHOD("get_collider_count"), &DCLevel::get_collider_count);
ClassDB::bind_method(D_METHOD("get_brush_id_from_shape", "shape_index"),
&DCLevel::get_brush_id_from_shape);
ClassDB::bind_method(D_METHOD("get_carve_count"), &DCLevel::get_carve_count);
ClassDB::bind_method(D_METHOD("get_pocket_count"), &DCLevel::get_pocket_count);
ClassDB::bind_method(D_METHOD("get_stats"), &DCLevel::get_stats);
}