OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / src / dc_level.cpp

1527 lines · cpp ref:
#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);
}