OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / libdc / include / dc_brush_store.h

1382 lines · c ref:
#ifndef DC_BRUSH_STORE_H
#define DC_BRUSH_STORE_H

// ---------------------------------------------------------------------------
// DCBrushStore — the DURABLE state of a level. Brushes + a carve journal.
//
// Implements docs/level-mesh-implementation.md §3.1, §4, §9, §9a.
// Decision it encodes (level-mesh-architecture.md, hyper-games #5, 2026-07-27):
//
//   The level is mostly static; EVERY BRUSH IS EDITABLE via localized carving.
//   Carving is DERIVED-AND-REVERSIBLE: the BRUSH IS AUTHORITATIVE. The carve
//   list is the journal; a voxel pocket is a derived CACHE of (brush - carves).
//
// The load-bearing invariant, and the thing the gate test exists to prove:
//
//   *** A CARVE NEVER MUTATES A BRUSH. ***
//
// Everything else follows. Undo is popping a carve, not re-fitting planes, so
// edits stay exact indefinitely with no error accumulation. There is no
// model->brush conversion because a carved solid is non-convex and cannot be a
// brush.
//
// Engine-free (libdc rule) so it is testable from a bare main().
//
// DELETION IS A TOMBSTONE, never a removal. Two independent reasons:
//   - reversibility: deleting a brush should be as undoable as undoing a carve;
//   - mechanism: GameState.erase_state() clears only the local mirrors and never
//     calls _send_durable, so there is NO durable delete path. An erase would
//     vanish on the next load_room_state(). Verified in autoloads/game_state.gd.
// Tombstoning also keeps ids stable, which the journal references.
// ---------------------------------------------------------------------------

#include <cstdint>
#include <vector>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <string>
#include <unordered_map>

#include "dc_edit.h"    // ConvexPlane, TerrainEdit
#include "dc_csg4.h"
#include "dc_brush_hull.h"    // CSG4Brush, CSG4Vec3, compute_bounds

struct DCBrush {
    std::vector<ConvexPlane> planes;
    std::vector<uint32_t>    plane_proj;   // parallel to planes; 0 = none
    // Parallel to planes. STABLE IDENTITY, assigned once and never reused.
    //
    // remove_face renumbers every face after it, and an INDEX is a position, not
    // a thing. Two callers need the difference and both fail silently without it:
    // an editor selection would point at a different face after a removal, and a
    // replicated op-log keyed by index would stop meaning what it said. Neither
    // errors — they just move the wrong geometry.
    //
    // THE KEY IS A CRDT DOT — "<replica>:<counter>" — not a local counter, and
    // that choice is load-bearing. A local counter is only deterministic if every
    // peer applies the same ops in the same order, and this stack does NOT hand
    // the game layer an ordered op stream: the durable view is a last-write-wins
    // KV projection of the autobase, so ops arrive per-key in whatever order they
    // land. A dot needs no order at all — it is unique because the replica that
    // minted it is unique. That is the standard CRDT construction (dotted version
    // vectors, OR-Set), and it costs nothing here because the journal key
    // `world:level:<memberId>:face:<seq>` IS already such a pair.
    //
    // Imported faces take the reserved "i:<index>" dot: every peer derives them
    // from the same .MAP in the same deterministic order, so no allocation is
    // needed and no replica has to have been involved.
    std::vector<std::string> plane_key;
    uint32_t material_id = 0;
    uint32_t id = 0;                       // stable; assigned at add, NEVER reused
    // THE DURABLE NAME. `id` is the LOCAL handle — an integer because the hot
    // loops (is_carved, pockets, csg4_view, fingerprint) index by it — and it is
    // meaningless to another peer: it comes from a local counter, so two peers
    // creating a brush each allocate the SAME id for DIFFERENT geometry. An
    // op-log keyed by id therefore moves the wrong brush, silently. That is what
    // blocked any second writer, and it is exactly the problem plane_key above
    // already solved for faces.
    //
    // So a brush is named by the same construction: the DOT of the op that
    // created it, "<replica>:<counter>", unique without needing any agreed order.
    // Imported brushes need no allocation — every peer derives them from the same
    // .MAP in the same deterministic order — so they take the reserved form
    // "i:<level_id>:<n>", per level so two maps in one room cannot collide.
    std::string key;
    // THE PLANE SET AS CREATED — the base the journal fold rewinds to.
    //
    // The journal is the state and this store is a derived cache, so a re-fold has
    // to start from something. For an IMPORTED brush that something is the .MAP;
    // for a CREATED one it is the plane set its create record carried. Keeping
    // both here means a fold never re-parses the .MAP, and never needs the record
    // that created a brush in order to rewind later edits to it.
    //
    // ~111 KB for START.MAP's 6930 planes, doubled. That is the entire cost of
    // making undo a property of the journal instead of a stack of inverse ops.
    std::vector<ConvexPlane> base_planes;
    std::vector<uint32_t>    base_proj;
    std::vector<std::string> base_plane_key;
    // IMPORTED (from the .MAP, derived identically by every peer) or CREATED by a
    // journalled op? reset_to_base keeps imports live and hides creations, because
    // a creation exists only while its record does.
    bool from_import = false;
    uint32_t level_id = 0;                 // which imported level this belongs to
    // ++ on ANY plane-set change, and reset to 0 by reset_to_base — so after a
    // fold it counts the live ops that touched this brush and is therefore a
    // FUNCTION OF THE JOURNAL, identical on every peer and across re-folds. That
    // is what lets the pocket cache keep using it: an unchanged journal reproduces
    // the same generation, so an undo does not invalidate every pocket.
    uint32_t generation = 0;
    bool     tombstoned = false;
    // A Quake sky brush. Renders and CLIPS like any other brush — it is real
    // geometry — but carries no collider, so the player passes through it. It is
    // also the level's authored OUTSIDE-WALL marker: the map author already told
    // us where the boundary is by texturing it sky, which is stronger than any
    // heuristic level-mesh-implementation.md §5a proposed for that question
    // ("visible-skin membership is not sufficient... undecided, and it is the
    // crux"). Hence a semantic `sky` rather than a bare `no_collision`.
    bool     sky = false;
    // AIR THE LEVEL OWNS — hyper-games #25 §2. The headroom over a flight of
    // stairs, the bore of an archway: volume the terrain must be subtracted from
    // even though no brush fills it. It exists ONLY for dc_level_shell, where it
    // blocks the exterior flood so the sealed volume runs through the passage,
    // and it is counted as interior rather than as wall.
    //
    // It is NOT a shade of `sky`, and the difference is the whole point. A sky
    // brush is real geometry — it renders, it clips, it is the authored boundary.
    // A clearance brush renders nothing, collides with nothing and CLIPS NOTHING:
    // csg4_view drops it, because it encloses the stairs it belongs to and
    // clipping them against it would remove the treads it exists to expose.
    // shell_view is the one view that carries it.
    bool     clearance = false;
    // THE GROUP THIS BRUSH WAS MADE WITH — hyper-games #28. One operator
    // invocation produces many brushes: an arch is eight voussoirs and a
    // clearance volume, a flight is one solid per step. They are one THING, and
    // until now nothing said so — moving the ring left its clearance behind, and
    // the terrain kept a passage where the arch no longer was.
    //
    // The value is the DOT of the create that made the first piece, so it is a
    // durable name minted the way every other name here is, and it needs no
    // record kind of its own: it rides the brush.create that was already being
    // written.
    //
    // DERIVED ROWS, NOT A SECOND STORE. The outliner's "arch" row is every brush
    // carrying this key — computed, never stored twice — so a collection cannot
    // drift from the geometry it describes, which is the failure mode of keeping
    // membership in a list beside the brushes.
    std::string group;

    CSG4Vec3 lo{0,0,0}, hi{0,0,0};
    bool     bounds_valid = false;
};

struct DCCarve {
    uint32_t id = 0;
    // The DURABLE name, for the same reason DCBrush::key exists — and here it was
    // not a latent risk but a shipped defect: a carve was persisted with its
    // author's LOCAL id, and a replaying peer mints its own ids as it applies, so
    // that number named a different carve there. Undo rolled back the wrong one,
    // silently. The journal key `world:level:<author>:op:<seq>` already IS a
    // (replica, counter) dot, so the record's own key carries the identity.
    std::string key;
    TerrainEdit shape;
    std::vector<uint32_t> touched;   // brush ids whose AABB the carve intersects
    bool undone = false;
};

class DCBrushStore {
public:
    // ---- import / lifecycle -------------------------------------------------

    // `key` is the DURABLE name (see DCBrush::key). Pass the dot of the op that
    // created this brush; pass "" for an IMPORT, which is named deterministically
    // as "i:<level_id>:<n>" and needs no replica to have been involved.
    //
    // IDEMPOTENT on a key the store already holds: returns the existing id and
    // changes nothing. Replay is not once-only — state_changed fires on bulk load
    // AND on every live update, including a peer's own writes coming back
    // confirmed — so a create that duplicated would grow a brush per round trip.
    // Same reasoning add_face carries, and the same reason it refuses rather than
    // reshapes: the creating record is authoritative for a brush's shape only at
    // creation, and every later edit carries its own record.
    //
    // Returns 0 (= none) when the plane set ENCLOSES NOTHING. set_face_plane and
    // add_face both validate; add_brush did not, so the caller had to create,
    // rebuild, count live faces and tombstone after the fact. A journalled create
    // must not be able to mint invisible geometry.
    uint32_t add_brush(const std::vector<ConvexPlane>& planes,
                       const std::vector<uint32_t>& proj,
                       uint32_t material_id, uint32_t level_id = 0,
                       bool sky = false, const std::string& key = std::string(),
                       bool clearance = false,
                       const std::string& group = std::string()) {
        if (!key.empty()) {
            auto it = by_key_.find(key);
            if (it != by_key_.end()) return it->second;   // already applied
        }
        DCBrush b;
        b.planes = planes;
        b.plane_proj = proj;
        b.plane_proj.resize(planes.size(), 0u);   // keep parallel, always
        b.plane_key.resize(planes.size());
        for (size_t i = 0; i < planes.size(); i++)
            b.plane_key[i] = "i:" + std::to_string(i);   // deterministic on every peer
        if (!encloses_volume(b)) return 0;
        b.material_id = material_id;
        b.level_id = level_id;
        b.sky = sky;
        b.clearance = clearance;
        b.group = group;
        b.id = next_brush_id_++;
        b.from_import = key.empty();
        b.key = b.from_import
              ? ("i:" + std::to_string(level_id) + ":" + std::to_string(import_seq_[level_id]++))
              : key;
        b.base_planes = b.planes;
        b.base_proj = b.plane_proj;
        b.base_plane_key = b.plane_key;
        by_key_[b.key] = b.id;
        brushes_.push_back(std::move(b));
        refresh_bounds(brushes_.back());
        return brushes_.back().id;
    }

    // Durable name -> local handle. 0 when this store has never seen that name,
    // matching add_brush's "0 = none". A tombstoned brush still resolves: ids are
    // never reused, so the mapping outlives the brush being hidden.
    uint32_t brush_by_key(const std::string& key) const {
        if (key.empty()) return 0;
        auto it = by_key_.find(key);
        return it == by_key_.end() ? 0u : it->second;
    }

    bool remove_brush(uint32_t id) {                 // tombstone
        DCBrush* b = find(id);
        if (!b || b->tombstoned) return false;
        b->tombstoned = true;
        return true;
    }
    bool restore_brush(uint32_t id) {
        DCBrush* b = find(id);
        if (!b || !b->tombstoned) return false;
        b->tombstoned = false;
        return true;
    }

    // "Delete the map" — one op so it stays one undo step.
    uint32_t remove_level(uint32_t level_id) {
        uint32_t n = 0;
        for (DCBrush& b : brushes_)
            if (b.level_id == level_id && !b.tombstoned) { b.tombstoned = true; n++; }
        return n;
    }
    uint32_t restore_level(uint32_t level_id) {
        uint32_t n = 0;
        for (DCBrush& b : brushes_)
            if (b.level_id == level_id && b.tombstoned) { b.tombstoned = false; n++; }
        return n;
    }

    // ---- transform + SHAPE editing (stays a brush; no model involved) -------
    // Any set of half-spaces intersects to a convex solid, so every op below
    // yields a valid brush by construction.

    bool translate_brush(uint32_t id, float dx, float dy, float dz) {
        DCBrush* b = find(id);
        if (!b) return false;
        for (ConvexPlane& p : b->planes) p.d += p.nx*dx + p.ny*dy + p.nz*dz;
        touch(*b);
        return true;
    }

    // AXIS-ALIGNED SCALE about a pivot — hyper-games #28, "if the length changes
    // the cut should change with it".
    //
    // EXACT ON HALF-SPACES, which is why this is a plane op and not a vertex one.
    // A point x on the plane satisfies n·x = d. Under a diagonal scale S the
    // image is x' = Sx, and taking n' = S^-1 n with d' unchanged gives
    // n'·x' = (S^-1 n)·(S x) = n·x = d. So each component is divided by its
    // factor and the plane is then renormalized. No re-derivation of geometry, no
    // vertex matching, no accumulated error — the same argument that makes
    // translate_brush a `d += n·delta`.
    //
    // The whole point is that a GROUP scales together: an arch's clearance volume
    // is scaled by the identical factor about the identical pivot, so the cut
    // cannot drift out of step with the ring it belongs to. Making the clearance
    // re-derive itself from the solids instead would need a stored shape
    // descriptor and a rule per shape, and would still have to guess what the
    // envelope of an arbitrarily dragged arch is.
    //
    // REJECTED, NOT APPLIED, if it would empty the brush — same contract as
    // set_face_plane, and for the same reason: a brush with valid planes and no
    // volume is unrecoverable by remove/restore.
    bool scale_brush (uint32_t id, float sx, float sy, float sz,
                      float px, float py, float pz) {
        DCBrush* b = find(id);
        if (!b) return false;
        // A zero factor collapses the solid to a plane and cannot be undone by
        // scaling back, so it is refused rather than clamped to something small
        // that silently changes the shape.
        if (std::fabs(sx) < 1e-6f || std::fabs(sy) < 1e-6f || std::fabs(sz) < 1e-6f)
            return false;
        const std::vector<ConvexPlane> prev = b->planes;
        for (ConvexPlane& p : b->planes) {
            // to the pivot, scale, back again
            float d = p.d - (p.nx * px + p.ny * py + p.nz * pz);
            float nx = p.nx / sx, ny = p.ny / sy, nz = p.nz / sz;
            const float len = std::sqrt(nx*nx + ny*ny + nz*nz);
            if (len < 1e-12f) { b->planes = prev; return false; }
            nx /= len; ny /= len; nz /= len; d /= len;
            p.nx = nx; p.ny = ny; p.nz = nz;
            p.d = d + (nx * px + ny * py + nz * pz);
        }
        if (!encloses_volume(*b)) { b->planes = prev; return false; }
        touch(*b);
        return true;
    }

    // AN EDIT THAT WOULD EMPTY THE BRUSH IS REJECTED, NOT APPLIED.
    //
    // The architecture note says shape editing is "safe by construction" because
    // any set of half-spaces intersects to a convex solid. True, and incomplete:
    // that solid can be EMPTY. Push a face past its opposite and the brush still
    // has six perfectly valid planes and encloses nothing — set_face_plane
    // returned true, generation incremented, and a wall ceased to exist with no
    // error anywhere. Worse, it is not recoverable by remove/restore_brush,
    // because those tombstone a brush rather than its plane-set: restore brings
    // back the emptied brush.
    //
    // So both mutators that can SHRINK a brush validate and revert. The two that
    // cannot are deliberately left alone: remove_face only ever enlarges the
    // solid, and translate_brush is rigid.
    //
    // Emptiness is decided by the MESHER (dc_csg4::face_winding), not a bespoke
    // predicate, so the validator and the thing that builds geometry cannot
    // disagree about what exists. Cost is one winding pass over a brush's own
    // planes — brushes carry a handful of faces, and this runs per edit, not per
    // frame of a rebuild.
    bool set_face_plane(uint32_t id, uint32_t face, const ConvexPlane& p) {
        DCBrush* b = find(id);
        if (!b || face >= b->planes.size()) return false;
        const ConvexPlane prev = b->planes[face];
        b->planes[face] = p;
        if (!encloses_volume(*b)) { b->planes[face] = prev; return false; }
        touch(*b);
        return true;
    }

    // Returns the new face index, or UINT32_MAX on failure.
    // NOTE: appends, so existing face indices are stable.
    // `key` is the dot of the op creating this face. Defaults to "" only for
    // callers that never replicate (tests, one-shot local geometry); anything
    // journalled MUST pass one, or two peers cannot agree which face it is.
    //
    // IDEMPOTENT: re-adding a dot this brush already carries is refused rather
    // than duplicated. Replay is not once-only — state_changed fires on bulk load
    // AND on every live update, including a peer's own writes coming back
    // confirmed — so an add that duplicated on replay would grow a bevel per
    // round trip.
    uint32_t add_face(uint32_t id, const ConvexPlane& p, uint32_t proj,
                      const std::string& key = std::string()) {
        DCBrush* b = find(id);
        if (!b) return UINT32_MAX;
        if (!key.empty()) {
            for (const std::string& k : b->plane_key)
                if (k == key) return UINT32_MAX;   // already applied
        }
        b->planes.push_back(p);
        b->plane_proj.push_back(proj);
        b->plane_key.push_back(key);
        if (!encloses_volume(*b)) {
            b->planes.pop_back();
            b->plane_proj.pop_back();   // parallel on the failure path too
            b->plane_key.pop_back();
            return UINT32_MAX;
        }
        touch(*b);
        return (uint32_t)b->planes.size() - 1u;
    }

    // Which face of this brush does `p` lie on? Returns the face index, or -1 if
    // the brush is unknown or bounds nothing.
    //
    // get_brush_id_from_shape resolves a raycast to a BRUSH; face-level editing
    // needs the FACE, and what a ray gives you is a point on the surface. So the
    // pick is "which plane is this point on", by |n·p - d| — a hit is never
    // exactly on the plane in floating point, so nearest wins rather than equal.
    //
    // FACES THAT BOUND NOTHING ARE SKIPPED. A brush can carry a plane that
    // touches the solid at a single point or not at all (a bevel that no longer
    // cuts, a plane left after neighbouring faces moved). Such a plane is not a
    // surface the player can see or click, and letting it win a distance test
    // would hand the editor a face that cannot be dragged meaningfully.
    int nearest_face(uint32_t id, float px, float py, float pz) const {
        const DCBrush* b = get(id);
        if (!b) return -1;
        CSG4Brush cb;                       // non-owning view
        cb.planes = b->planes.data();
        cb.plane_count = (uint32_t)b->planes.size();
        int best = -1;
        float best_d = 0.0f;
        std::vector<CSG4Vec3> pts;
        for (uint32_t i = 0; i < cb.plane_count; i++) {
            pts.clear();
            if (!dc_csg4::face_winding(cb, i, pts) || pts.size() < 3) continue;
            const ConvexPlane& pl = b->planes[i];
            const float dist = std::fabs(pl.nx*px + pl.ny*py + pl.nz*pz - pl.d);
            if (best < 0 || dist < best_d) { best = (int)i; best_d = dist; }
        }
        return best;
    }

    // The polygon of one face, in world space. False if the brush or face is
    // unknown, or the face bounds nothing.
    //
    // BRUSH-LOCAL, not clipped against neighbours: this is the face
    // set_face_plane moves, so it is the face an editor should draw. The clipped
    // remnant is what gets RENDERED, and showing that as the selection would
    // highlight a fragment of the thing being edited rather than the thing.
    bool face_winding(uint32_t id, uint32_t face, std::vector<CSG4Vec3>& out) const {
        out.clear();
        const DCBrush* b = get(id);
        if (!b || face >= b->planes.size()) return false;
        CSG4Brush cb;                       // non-owning view
        cb.planes = b->planes.data();
        cb.plane_count = (uint32_t)b->planes.size();
        return dc_csg4::face_winding(cb, face, out) && out.size() >= 3;
    }

    // Is this face a SURFACE, or a dormant plane?
    //
    // A plane that currently bounds nothing still sits in the set — that is
    // deliberate and it is what makes topology change reversible: push it back
    // and the face returns bit-for-bit, with its dot intact, because nothing was
    // ever thrown away. TrenchBroom cannot do that; it rebuilds the polyhedron
    // and the plane is gone.
    //
    // But it means "face" and "plane" are no longer the same thing, and an editor
    // must be able to tell them apart: you cannot click a face that bounds
    // nothing, and rendering a dormant plane as a selectable surface would be a
    // lie. nearest_face already skips these; this makes the distinction askable.
    bool face_is_live(uint32_t id, uint32_t face) const {
        std::vector<CSG4Vec3> pts;
        return face_winding(id, face, pts);
    }

    // Live faces of a brush that is NOT in the store yet — split_brush validates
    // both candidate halves before committing either, so it needs to ask about a
    // DCBrush it is still holding rather than about an id.
    static uint32_t live_faces_of(const DCBrush& b) {
        CSG4Brush cb;
        cb.planes = b.planes.data();
        cb.plane_count = (uint32_t)b.planes.size();
        uint32_t n = 0;
        std::vector<CSG4Vec3> pts;
        for (uint32_t i = 0; i < cb.plane_count; i++)
            if (dc_csg4::face_winding(cb, i, pts) && pts.size() >= 3) n++;
        return n;
    }

    // How many of this brush's planes actually bound a surface. Differs from
    // planes.size() exactly when the brush carries dormant planes.
    uint32_t live_face_count(uint32_t id) const {
        const DCBrush* b = get(id);
        if (!b) return 0;
        uint32_t n = 0;
        std::vector<CSG4Vec3> pts;
        for (uint32_t i = 0; i < (uint32_t)b->planes.size(); i++)
            if (face_winding(id, i, pts)) n++;
        return n;
    }

    // Does this brush enclose anything? A brush with no face that produces a
    // winding encloses nothing, whatever its plane count says.
    static bool encloses_volume(const DCBrush& b) {
        CSG4Brush cb;                       // non-owning view
        cb.planes = b.planes.data();
        cb.plane_count = (uint32_t)b.planes.size();
        for (uint32_t i = 0; i < cb.plane_count; i++) {
            std::vector<CSG4Vec3> pts;
            if (dc_csg4::face_winding(cb, i, pts) && pts.size() >= 3) return true;
        }
        return false;
    }

    // ⚠ RENUMBERS every face after `face`. Callers holding face indices across
    // this call (an editor selection, a pending set_face_plane) must re-resolve.
    // docs §9a.4 risk 3. plane_proj is kept in sync here so textures cannot
    // silently shift — that is the easiest thing in this design to get wrong.
    bool remove_face(uint32_t id, uint32_t face) {
        DCBrush* b = find(id);
        if (!b || face >= b->planes.size()) return false;
        if (b->planes.size() <= 4) return false;   // <4 half-spaces = unbounded
        b->planes.erase(b->planes.begin() + face);
        b->plane_proj.erase(b->plane_proj.begin() + face);
        b->plane_key.erase(b->plane_key.begin() + face);
        touch(*b);
        return true;
    }

    // CUT THIS BRUSH IN TWO. The original keeps the FRONT half (n.x <= d); a new
    // brush, named `key`, gets the back half. Returns the new brush's id, or 0 if
    // the plane does not actually divide the solid — in which case NOTHING changes.
    //
    // This is the operation that turns "adjust a level" into "author a level".
    // add_face can only ever shrink a convex solid, so a doorway — material removed
    // from the middle of a wall — was unreachable. Now it is: split, split,
    // tombstone the middle piece. Three ops, all exact, all undoable.
    //
    // ALMOST FREE ON THIS REPRESENTATION, and the contrast is the point. A brush is
    // an intersection of half-spaces, so each half is just the original plane set
    // plus one plane. Nothing is rebuilt, so every surviving face keeps its own
    // plane AND ITS DOT. TrenchBroom's clip rebuilds a polyhedron, re-derives each
    // plane from new vertices, and then has to GUESS which new face corresponds to
    // which old one (Polyhedron_Matcher.h exists for that guess). We have nothing
    // to guess about.
    //
    // The cut face takes `key` as its dot on BOTH halves — dots are per-brush, so
    // there is no collision, and it means an edit to "the cut face" is nameable on
    // either piece. It gets no texture projection (0): a freshly cut face has no
    // material of its own, which is also what TrenchBroom does with new faces.
    //
    // IDEMPOTENT on a key already held, like every other create — replay is not
    // once-only.
    uint32_t split_brush(uint32_t id, const ConvexPlane& p, const std::string& key) {
        DCBrush* b = find(id);
        if (!b || key.empty()) return 0;

        auto known = by_key_.find(key);
        if (known != by_key_.end()) {
            // Re-applied: make sure both halves are present rather than assuming.
            // After a journal rewind the new half is tombstoned and the original has
            // lost its cut face, so this path has real work to do.
            restore_brush(known->second);
            add_face(id, p, 0u, key);            // no-op if it is already there
            return known->second;
        }

        // BUILD BOTH HALVES AND VALIDATE BEFORE COMMITTING TO EITHER. A plane that
        // misses the solid, or grazes a face, leaves one side enclosing nothing —
        // and a half-applied split (one brush shrunk, no second brush) would be a
        // silent hole in the level.
        DCBrush front = *b;
        front.planes.push_back(p);
        front.plane_proj.push_back(0u);
        front.plane_key.push_back(key);

        DCBrush back = *b;
        back.planes.push_back({ -p.nx, -p.ny, -p.nz, -p.d });
        back.plane_proj.push_back(0u);
        back.plane_key.push_back(key);

        if (!encloses_volume(front) || !encloses_volume(back)) return 0;
        // Both sides must be REAL, not a slice thinner than the mesher can see: a
        // degenerate sliver is geometry nobody can select or delete.
        if (live_faces_of(front) < 4 || live_faces_of(back) < 4) return 0;

        back.id = next_brush_id_++;
        back.key = key;
        back.from_import = false;        // a created brush: rewinding hides it again
        back.base_planes = back.planes;
        back.base_proj = back.plane_proj;
        back.base_plane_key = back.plane_key;
        by_key_[key] = back.id;

        *b = front;                      // commit the front half in place
        touch(*b);
        brushes_.push_back(std::move(back));
        refresh_bounds(brushes_.back());
        return brushes_.back().id;
    }

    // MOVE ACTUAL CORNERS — the one operation that goes backwards, from points to
    // planes, and the only way a vertex moves without taking its neighbours.
    //
    // Everything else here moves PLANES, which is what keeps it exact: a corner is
    // the meet of three of them, so dragging it means moving all three, and each one
    // carries its whole face and therefore every other corner on it. Correct for the
    // representation, and not what a person dragging a corner means. Reported four
    // times as "vertex mode moves the whole brush".
    //
    // So for the duration of this call the brush stops being a plane set: take its
    // vertices, move the named ones, hull the result, and derive planes back out.
    // That is TrenchBroom's model and it costs what theirs costs — the plane set is
    // re-derived, so face identity has to be re-matched rather than carried
    // (Polyhedron_Matcher.h exists for exactly that). Faces that survive keep their
    // dots; genuinely new ones are named from this record's key, so a later edit to
    // "the face I just made" has something to hold.
    //
    // `from` names corners by POSITION rather than by index, because there is no
    // stable index for a vertex — it is derived, and the derivation order changes
    // the moment the plane set does. A position that is not a corner of this brush is
    // REFUSED, not ignored: the caller is describing geometry that is not there, and
    // silently doing nothing is how an editor loses an edit without saying so.
    //
    // Validated before committing, like split_brush: a drag that flattens the solid
    // leaves the brush exactly as it was.
    bool move_brush_points(uint32_t id, const std::vector<CSG4Vec3>& from,
                           const CSG4Vec3& delta, const std::string& key) {
        DCBrush* b = find(id);
        if (!b || b->tombstoned || from.empty() || key.empty()) return false;

        std::vector<CSG4Vec3> pts;
        brush_vertices_of(id, pts);
        if (pts.size() < 4) return false;

        // Same epsilon brush_vertices_of dedups with, so "a corner" means the same
        // thing to the caller as it does to the enumerator.
        const float GRAB = 1e-3f;
        for (const CSG4Vec3& f : from) {
            int best = -1;
            float best_d = GRAB;
            for (size_t i = 0; i < pts.size(); i++) {
                const float dx = pts[i].x - f.x;
                const float dy = pts[i].y - f.y;
                const float dz = pts[i].z - f.z;
                const float d = std::sqrt(dx * dx + dy * dy + dz * dz);
                if (d < best_d) { best_d = d; best = (int)i; }
            }
            if (best < 0) return false;              // not a corner of this brush
            pts[(size_t)best].x += delta.x;
            pts[(size_t)best].y += delta.y;
            pts[(size_t)best].z += delta.z;
        }

        std::vector<ConvexPlane> hull;
        if (!dc_hull::hull_planes(pts, hull)) return false;   // flattened: refuse

        DCBrush cand = *b;
        cand.planes = hull;
        cand.plane_proj.assign(hull.size(), 0u);
        cand.plane_key.assign(hull.size(), std::string());

        // INHERIT IDENTITY where the face survived. Matched by normal only: the
        // offset moves under any drag that touches the face, but a face that is
        // still facing the same way is still that face. One old plane may be claimed
        // once, and ties go to the lowest index — both so that two peers replaying
        // this record land on identical keys.
        // IDENTITY AND TEXTURE ARE MATCHED SEPARATELY, and they need different
        // thresholds, which is not fussiness — it is the difference between "is this
        // the same face" and "what should this face look like".
        //
        // A dot is an identity claim: only a face still pointing the same way is the
        // same face, so it takes a strict match and one old plane may be claimed once.
        // Get that wrong and an edit to "the face I moved" lands on a different face.
        //
        // A projection is not a claim about anything. plane_proj INDEXES the import's
        // projection table, which is built once from the .MAP and uploaded as a
        // texture — so a brand new face has no entry it could point at, and leaving it
        // 0 means no projection at all. That is the untextured face on a reshaped
        // brush. The honest answer is that a face created by dragging a corner is a
        // continuation of the surface around it, so it inherits the texture of the
        // old face it most nearly resembles, however loosely — a plausible texture
        // beats none, and the alternative needs a projection table that can grow at
        // runtime.
        std::vector<bool> claimed(b->planes.size(), false);
        for (size_t i = 0; i < hull.size(); i++) {
            int best = -1;             // strict: same face, inherits the dot
            float best_dot = 0.999f;
            int nearest = -1;          // loose: most similar face, lends its texture
            float nearest_dot = -2.0f;
            for (size_t j = 0; j < b->planes.size(); j++) {
                const float dot = hull[i].nx * b->planes[j].nx +
                                  hull[i].ny * b->planes[j].ny +
                                  hull[i].nz * b->planes[j].nz;
                if (dot > nearest_dot) { nearest_dot = dot; nearest = (int)j; }
                if (claimed[j]) continue;
                if (dot > best_dot) { best_dot = dot; best = (int)j; }
            }
            if (best >= 0) {
                claimed[(size_t)best] = true;
                cand.plane_key[i]  = b->plane_key[(size_t)best];
                cand.plane_proj[i] = b->plane_proj[(size_t)best];
            } else {
                cand.plane_key[i] = key + ":" + std::to_string(i);
                cand.plane_proj[i] = nearest >= 0 ? b->plane_proj[(size_t)nearest] : 0u;
            }
        }

        if (!encloses_volume(cand)) return false;
        if (live_faces_of(cand) < 4) return false;

        *b = cand;
        touch(*b);
        refresh_bounds(*b);
        return true;
    }

    // A \ B — SUBTRACT A CONVEX CUTTER, leaving a set of convex brushes.
    //
    // Clip, CSG subtract and "cut a shape into a face" are one operation with
    // different cutters (docs/build-mode-operators.md 5b). Each takes an edit and has
    // to produce a set of convex brushes; they differ only in how many planes the
    // cutter carries. One plane is a clip and gives two pieces — which is split_brush,
    // and why this is built on the same idea rather than beside it.
    //
    //     piece_i = A INTERSECT outside(B.plane[i]) INTERSECT inside(B.plane[0..i-1])
    //
    // Every piece is an intersection of half-spaces, which is what a brush IS, so
    // nothing is hulled and no piece can come out invalid — the contrast with
    // move_brush_points, which has to hull precisely because it goes the other way.
    // The pieces are disjoint by construction (piece_i is outside plane i and every
    // later piece is inside it) and their union is exactly A minus B.
    //
    // The FIRST surviving piece takes A's place — its id and its durable key — so a
    // subtraction that merely shaves a brush leaves that brush recognisably itself.
    // The rest are new, named `key:i`. A cutter that swallows A tombstones it and
    // returns 0; a cutter that misses returns -1 and changes nothing, because an edit
    // that cannot do anything should say so rather than churn the geometry.
    //
    // IDEMPOTENT, like every other create here: replaying a record reuses the brushes
    // that record already named instead of minting a second set. The fold replays,
    // and a fold that doubled the geometry each time would be no fold at all.
    int subtract_brush(uint32_t id, const std::vector<ConvexPlane>& cutter,
                       const std::string& key, std::vector<uint32_t>& out_ids) {
        out_ids.clear();
        DCBrush* b = find(id);
        if (!b || b->tombstoned || cutter.empty() || key.empty()) return -1;

        // Nothing to do unless the two actually meet. Checked by asking whether the
        // intersection encloses anything, which is the same question the pieces are
        // validated with — one definition of "overlaps", not two.
        DCBrush both = *b;
        for (const ConvexPlane& p : cutter) {
            both.planes.push_back(p);
            both.plane_proj.push_back(0u);
            both.plane_key.push_back(std::string());
        }
        if (!encloses_volume(both)) return -1;          // misses: refuse

        // Build every piece first and commit nothing until they are all known good,
        // the same discipline split_brush uses: a half-applied subtraction is a hole
        // in the level that nobody asked for.
        std::vector<DCBrush> pieces;
        for (size_t i = 0; i < cutter.size(); i++) {
            DCBrush piece = *b;
            const ConvexPlane& c = cutter[i];
            piece.planes.push_back({ -c.nx, -c.ny, -c.nz, -c.d });   // outside plane i
            piece.plane_proj.push_back(0u);
            piece.plane_key.push_back(std::string());
            for (size_t j = 0; j < i; j++) {                        // inside 0..i-1
                piece.planes.push_back(cutter[j]);
                piece.plane_proj.push_back(0u);
                piece.plane_key.push_back(std::string());
            }
            if (!encloses_volume(piece)) continue;
            if (live_faces_of(piece) < 4) continue;
            pieces.push_back(std::move(piece));
        }

        // A NEW FACE IS A CUT FACE, and it inherits the texture of the face it most
        // nearly continues — the same reasoning as the hull rebuild: plane_proj
        // indexes a table fixed at import, so a new face has no entry of its own, and
        // no projection renders as no texture.
        for (DCBrush& piece : pieces) {
            for (size_t i = 0; i < piece.planes.size(); i++) {
                if (!piece.plane_key[i].empty()) continue;
                int nearest = -1;
                float nearest_dot = -2.0f;
                for (size_t j = 0; j < b->planes.size(); j++) {
                    const float dot = piece.planes[i].nx * b->planes[j].nx +
                                      piece.planes[i].ny * b->planes[j].ny +
                                      piece.planes[i].nz * b->planes[j].nz;
                    if (dot > nearest_dot) { nearest_dot = dot; nearest = (int)j; }
                }
                piece.plane_proj[i] = nearest >= 0 ? b->plane_proj[(size_t)nearest] : 0u;
                piece.plane_key[i] = key + ":f" + std::to_string(i);
            }
        }

        if (pieces.empty()) {                            // swallowed whole
            b->tombstoned = true;
            touch(*b);
            return 0;
        }

        // The first piece takes A's place, keeping its id and durable name.
        const std::string a_key = b->key;
        const bool a_import = b->from_import;
        {
            DCBrush first = pieces[0];
            first.id = b->id;
            first.key = a_key;
            first.from_import = a_import;
            *b = first;
            touch(*b);
            refresh_bounds(*b);
            out_ids.push_back(b->id);
        }

        for (size_t i = 1; i < pieces.size(); i++) {
            const std::string pkey = key + ":" + std::to_string(i);
            DCBrush& piece = pieces[i];
            piece.key = pkey;
            piece.from_import = false;   // a created brush: a rewind hides it again
            auto known = by_key_.find(pkey);
            if (known != by_key_.end()) {
                // REPLAY. The record already named this brush, so reuse it rather
                // than minting a second — otherwise every fold doubles the geometry.
                DCBrush* prev = find(known->second);
                if (prev) {
                    piece.id = prev->id;
                    piece.tombstoned = false;
                    *prev = piece;
                    prev->base_planes = prev->planes;
                    prev->base_proj = prev->plane_proj;
                    prev->base_plane_key = prev->plane_key;
                    touch(*prev);
                    refresh_bounds(*prev);
                    out_ids.push_back(prev->id);
                    continue;
                }
            }
            piece.id = next_brush_id_++;
            piece.base_planes = piece.planes;
            piece.base_proj = piece.plane_proj;
            piece.base_plane_key = piece.plane_key;
            by_key_[pkey] = piece.id;
            brushes_.push_back(std::move(piece));
            refresh_bounds(brushes_.back());
            out_ids.push_back(brushes_.back().id);
        }
        return (int)out_ids.size();
    }

    // A INTERSECT B — the union of the two plane sets, and nothing else.
    //
    // An intersection of half-spaces intersected with another intersection of
    // half-spaces is still an intersection of half-spaces. So this is the one CSG op
    // with no construction at all: concatenate, validate, keep. One brush comes back,
    // never pieces, because the intersection of two convex solids is convex.
    //
    // A and B not meeting is a REFUSAL, not an empty brush: an edit that would leave
    // nothing should say so and change nothing, exactly as subtract does when it
    // misses.
    bool intersect_brush(uint32_t id, const std::vector<ConvexPlane>& other,
                         const std::string& key) {
        DCBrush* b = find(id);
        if (!b || b->tombstoned || other.empty() || key.empty()) return false;

        DCBrush cand = *b;
        for (size_t i = 0; i < other.size(); i++) {
            cand.planes.push_back(other[i]);
            // A face arriving from B has no dot here and no projection here, so it
            // takes one from this record and borrows the texture of the face of A it
            // most nearly continues — the same rule the hull rebuild and subtract use,
            // for the same reason: plane_proj indexes a table fixed at import.
            int nearest = -1;
            float nearest_dot = -2.0f;
            for (size_t j = 0; j < b->planes.size(); j++) {
                const float dot = other[i].nx * b->planes[j].nx +
                                  other[i].ny * b->planes[j].ny +
                                  other[i].nz * b->planes[j].nz;
                if (dot > nearest_dot) { nearest_dot = dot; nearest = (int)j; }
            }
            cand.plane_proj.push_back(nearest >= 0 ? b->plane_proj[(size_t)nearest] : 0u);
            cand.plane_key.push_back(key + ":i" + std::to_string(i));
        }
        if (!encloses_volume(cand)) return false;
        if (live_faces_of(cand) < 4) return false;
        *b = cand;
        touch(*b);
        refresh_bounds(*b);
        return true;
    }

    // CONVEX MERGE — the hull of every brush's vertices, as ONE brush.
    //
    // NOT a union, and the name says so on purpose. A union of two convex solids is
    // not convex and therefore is not a brush at all; what every brush editor offers
    // instead is the convex hull of the combined points. TrenchBroom's csgConvexMerge
    // is the same operation and is named the same way for the same reason
    // (Map_Geometry.cpp:607 collects the selected brushes' vertices and builds a
    // Polyhedron3 from them).
    //
    // So merging two separated boxes FILLS THE GAP between them. That is the correct
    // result, and it is why the operation must never be labelled "union" in the UI:
    // someone reaching for union and getting this would rightly call it a bug.
    //
    // The first brush survives, keeping its id and durable key; the rest are
    // tombstoned. Faces are matched to the survivor's planes for identity and texture
    // exactly as move_brush_points does — this is the same backwards trip, from points
    // to planes, and has the same cost.
    bool merge_brushes(const std::vector<uint32_t>& ids, const std::string& key) {
        if (ids.size() < 2 || key.empty()) return false;

        std::vector<CSG4Vec3> pts;
        for (uint32_t id : ids) {
            const DCBrush* b = get(id);
            if (!b || b->tombstoned) return false;
            std::vector<CSG4Vec3> v;
            brush_vertices_of(id, v);
            pts.insert(pts.end(), v.begin(), v.end());
        }

        std::vector<ConvexPlane> hull;
        if (!dc_hull::hull_planes(pts, hull)) return false;

        DCBrush* keep = find(ids[0]);
        if (!keep) return false;

        DCBrush cand = *keep;
        cand.planes = hull;
        cand.plane_proj.assign(hull.size(), 0u);
        cand.plane_key.assign(hull.size(), std::string());
        std::vector<bool> claimed(keep->planes.size(), false);
        for (size_t i = 0; i < hull.size(); i++) {
            int best = -1;
            float best_dot = 0.999f;
            int nearest = -1;
            float nearest_dot = -2.0f;
            for (size_t j = 0; j < keep->planes.size(); j++) {
                const float dot = hull[i].nx * keep->planes[j].nx +
                                  hull[i].ny * keep->planes[j].ny +
                                  hull[i].nz * keep->planes[j].nz;
                if (dot > nearest_dot) { nearest_dot = dot; nearest = (int)j; }
                if (claimed[j]) continue;
                if (dot > best_dot) { best_dot = dot; best = (int)j; }
            }
            if (best >= 0) {
                claimed[(size_t)best] = true;
                cand.plane_key[i]  = keep->plane_key[(size_t)best];
                cand.plane_proj[i] = keep->plane_proj[(size_t)best];
            } else {
                cand.plane_key[i] = key + ":" + std::to_string(i);
                cand.plane_proj[i] = nearest >= 0 ? keep->plane_proj[(size_t)nearest] : 0u;
            }
        }
        if (!encloses_volume(cand)) return false;
        if (live_faces_of(cand) < 4) return false;

        *keep = cand;
        touch(*keep);
        refresh_bounds(*keep);
        for (size_t i = 1; i < ids.size(); i++) {
            DCBrush* gone = find(ids[i]);
            if (gone) { gone->tombstoned = true; touch(*gone); }
        }
        return true;
    }

    // Where does the face with this dot live NOW? -1 if it is gone.
    // This is what a selection or an op-log holds instead of an index, so a
    // removal elsewhere in the brush cannot silently rebind it to another face.
    int face_by_key(uint32_t id, const std::string& key) const {
        const DCBrush* b = get(id);
        if (!b || key.empty()) return -1;
        for (size_t i = 0; i < b->plane_key.size(); i++)
            if (b->plane_key[i] == key) return (int)i;
        return -1;
    }

    // ---- operator previews ---------------------------------------------------

    // The polygon where plane `p` would cut brush `id` — EXACTLY the face a split
    // would create. Empty if the plane does not divide the solid, which is also the
    // condition split_brush refuses on, so a preview that shows nothing is telling
    // the truth: that cut is not available.
    //
    // Built by constructing the candidate half and asking for its new face's
    // winding, rather than by intersecting the plane with the hull independently.
    // Two ways of computing the same polygon would drift, and the one that matters
    // is the one the split will actually produce.
    bool cut_preview(uint32_t id, const ConvexPlane& p,
                     std::vector<CSG4Vec3>& out) const {
        out.clear();
        const DCBrush* b = get(id);
        if (!b || b->tombstoned) return false;
        DCBrush front = *b;
        front.planes.push_back(p);
        front.plane_proj.push_back(0u);
        front.plane_key.push_back(std::string());
        DCBrush back = *b;
        back.planes.push_back({ -p.nx, -p.ny, -p.nz, -p.d });
        back.plane_proj.push_back(0u);
        back.plane_key.push_back(std::string());
        // The same refusal split_brush applies, so the preview cannot promise a cut
        // that would then be rejected.
        if (!encloses_volume(front) || !encloses_volume(back)) return false;
        if (live_faces_of(front) < 4 || live_faces_of(back) < 4) return false;

        CSG4Brush cb;
        cb.planes = front.planes.data();
        cb.plane_count = (uint32_t)front.planes.size();
        return dc_csg4::face_winding(cb, cb.plane_count - 1, out) && out.size() >= 3;
    }

    // ---- the wireframe -------------------------------------------------------

    // Every live face of one brush as LINE PAIRS — the outline of the solid.
    //
    // The level renders as one merged surface, so the structure being edited is
    // invisible: which brush is which, where one ends and the next begins, whether
    // the thing under the crosshair is one wall or three. A split makes that acute —
    // two halves tile the original exactly, so the rendered mesh is byte-identical
    // and a correct cut looks like nothing happened.
    //
    // DORMANT PLANES ARE SKIPPED. A plane that bounds nothing stays in the set (that
    // is what makes topology change reversible), but drawing it would show an edge
    // that is not there.
    //
    // Each edge is emitted TWICE, once per adjoining face. Deduplicating would cost
    // a hash of every segment to save vertices that are free to draw — the duplicate
    // is exactly coincident, so it is invisible.
    void brush_edges_of(uint32_t id, std::vector<CSG4Vec3>& out) const {
        out.clear();
        const DCBrush* b = get(id);
        if (!b || b->tombstoned) return;
        std::vector<CSG4Vec3> pts;
        for (uint32_t i = 0; i < (uint32_t)b->planes.size(); i++) {
            if (!face_winding(id, i, pts)) continue;      // skips dormant planes
            for (size_t k = 0; k < pts.size(); k++) {
                out.push_back(pts[k]);
                out.push_back(pts[(k + 1) % pts.size()]); // closed loop
            }
        }
    }

    // The DISTINCT corners of one brush — the handles an editor lets you aim at.
    //
    // Every corner appears once per face that meets there (three times on a cube
    // corner), so the dedup IS the job. It lives here rather than in the UI so that
    // every consumer gets the same answer and there is one epsilon to be careful
    // about instead of one per caller.
    //
    // Quadratic in the point count, which is fine: a brush has a handful of faces
    // and this runs when the aimed brush CHANGES, not per frame.
    void brush_vertices_of(uint32_t id, std::vector<CSG4Vec3>& out) const {
        out.clear();
        const DCBrush* b = get(id);
        if (!b || b->tombstoned) return;
        std::vector<CSG4Vec3> pts;
        for (uint32_t i = 0; i < (uint32_t)b->planes.size(); i++) {
            if (!face_winding(id, i, pts)) continue;      // dormant planes have none
            for (const CSG4Vec3& p : pts) {
                bool dup = false;
                for (const CSG4Vec3& q : out) {
                    if (std::fabs(p.x - q.x) < 1e-3f && std::fabs(p.y - q.y) < 1e-3f &&
                        std::fabs(p.z - q.z) < 1e-3f) { dup = true; break; }
                }
                if (!dup) out.push_back(p);
            }
        }
    }

    // The whole level's wireframe: every live brush, tombstoned ones omitted.
    // CARVED brushes are included — the pocket is a derived cache and the brush is
    // still the thing you can select and edit, so its outline is still the truth.
    //
    // CLEARANCE brushes are NOT, and they get clearance_edges below instead of a
    // flag here. Drawn in this mesh they would be indistinguishable from the walls
    // — same colour, same weight — and a box outline hanging in the air where a
    // passage is reads as a wall that failed to render. They are a different KIND
    // of thing and want their own layer to say so.
    void brush_edges(std::vector<CSG4Vec3>& out) const {
        out.clear();
        std::vector<CSG4Vec3> one;
        for (const DCBrush& b : brushes_) {
            if (b.tombstoned || b.clearance) continue;
            brush_edges_of(b.id, one);
            out.insert(out.end(), one.begin(), one.end());
        }
    }

    // The clearance volumes' outlines, for the editor's clearance view. Nothing
    // else draws them: they render no surface, so without this the only evidence
    // a passage exists is the hole in the terrain around it.
    void clearance_edges(std::vector<CSG4Vec3>& out) const {
        out.clear();
        std::vector<CSG4Vec3> one;
        for (const DCBrush& b : brushes_) {
            if (b.tombstoned || !b.clearance) continue;
            brush_edges_of(b.id, one);
            out.insert(out.end(), one.begin(), one.end());
        }
    }

    // ---- carves (the journal) ----------------------------------------------

    // `key` is the dot of the op that made this carve; "" for a local-only carve
    // that is never journalled (tests, one-shot geometry). IDEMPOTENT on a key the
    // store already holds — replay is not once-only, so re-applying a record must
    // not stack a second carve.
    uint32_t apply_carve(const TerrainEdit& shape, const std::string& key = std::string()) {
        if (!key.empty()) {
            auto it = carve_by_key_.find(key);
            if (it != carve_by_key_.end()) return it->second;   // already applied
        }
        DCCarve c;
        c.id = next_carve_id_++;
        c.key = key;
        c.shape = shape;
        CSG4Vec3 lo, hi;
        shape_bounds(shape, lo, hi);
        // A CLEARANCE BRUSH IS NEVER TOUCHED. `touched` means "this brush now
        // feeds a pocket instead of the exact mesher", and a clearance volume
        // feeds neither — it is air, and there is nothing in it to carve. Left
        // in, the consequences are both silent and bad: is_carved() would drop it
        // from shell_view, the passage would stop being enclosed, and a player who
        // carved anywhere near a tunnel would watch the hillside grow back into
        // it. It would also be handed to the pocket builder as if it were solid.
        for (const DCBrush& b : brushes_)
            if (!b.tombstoned && !b.clearance && b.bounds_valid &&
                aabb_overlap(b.lo, b.hi, lo, hi))
                c.touched.push_back(b.id);
        carves_.push_back(std::move(c));
        if (!key.empty()) carve_by_key_[key] = carves_.back().id;
        return carves_.back().id;
    }

    // Undo = mark the carve undone. Brushes are untouched, so the level returns
    // to EXACTLY its prior geometry — not an approximation of it.
    bool undo_carve(uint32_t carve_id) {
        for (DCCarve& c : carves_)
            if (c.id == carve_id && !c.undone) { c.undone = true; return true; }
        return false;
    }
    bool redo_carve(uint32_t carve_id) {
        for (DCCarve& c : carves_)
            if (c.id == carve_id && c.undone) { c.undone = false; return true; }
        return false;
    }

    // THE CROSS-PEER FORMS — these are what a journal replay must use. The
    // id-taking pair above is local-only: a peer's ids are its own, so undoing by
    // the author's number undoes whichever carve happens to hold it here.
    uint32_t carve_by_key(const std::string& key) const {
        if (key.empty()) return 0;
        auto it = carve_by_key_.find(key);
        return it == carve_by_key_.end() ? 0u : it->second;
    }
    bool undo_carve_by_key(const std::string& key) {
        const uint32_t id = carve_by_key(key);
        return id ? undo_carve(id) : false;
    }
    bool redo_carve_by_key(const std::string& key) {
        const uint32_t id = carve_by_key(key);
        return id ? redo_carve(id) : false;
    }

    // ---- the fold's rewind --------------------------------------------------

    // Rewind to the state a journal fold starts from: every brush back to its
    // as-created plane set, every carve gone. Imported brushes stay LIVE (the .MAP
    // is the base); created ones become TOMBSTONED, because a created brush exists
    // only while the record that created it does — its create record un-tombstones
    // it during the fold, which is why the id and the key are kept rather than the
    // brush being erased.
    //
    // DETERMINISTIC, and that is load-bearing rather than tidy. Local handles
    // survive (by_key_ is never pruned), generation restarts at 0 so it counts the
    // journal's live ops, and carve ids restart at 1 so the same journal mints the
    // same ids. Without that, an undo would change every downstream id and the
    // pocket cache — keyed by the identity of its inputs — would miss on every
    // pocket, which is precisely the cost it exists to avoid.
    //
    // NOTHING IS DESTROYED: no brush is erased, no name is forgotten. This is a
    // rewind, not a delete.
    void reset_to_base() {
        for (DCBrush& b : brushes_) {
            b.planes = b.base_planes;
            b.plane_proj = b.base_proj;
            b.plane_key = b.base_plane_key;
            b.tombstoned = !b.from_import;
            b.generation = 0;
            refresh_bounds(b);
        }
        carves_.clear();
        carve_by_key_.clear();
        next_carve_id_ = 1;
    }

    // ---- queries ------------------------------------------------------------

    const DCBrush* get(uint32_t id) const {
        for (const DCBrush& b : brushes_) if (b.id == id) return &b;
        return nullptr;
    }
    const std::vector<DCBrush>& brushes() const { return brushes_; }
    const std::vector<DCCarve>& carves()  const { return carves_;  }

    bool is_carved(uint32_t brush_id) const {
        for (const DCCarve& c : carves_) {
            if (c.undone) continue;
            if (std::find(c.touched.begin(), c.touched.end(), brush_id) != c.touched.end())
                return true;
        }
        return false;
    }

    // CLEAN = live and untouched by any active carve -> feeds the exact mesher.
    void clean_brushes(std::vector<uint32_t>& out) const {
        out.clear();
        for (const DCBrush& b : brushes_)
            if (!b.tombstoned && !is_carved(b.id)) out.push_back(b.id);
    }
    // CARVED = live and touched -> feeds a pocket.
    void carved_brushes(std::vector<uint32_t>& out) const {
        out.clear();
        for (const DCBrush& b : brushes_)
            if (!b.tombstoned && is_carved(b.id)) out.push_back(b.id);
    }

    // One brush as a non-owning view. Shared by the two views below so they
    // cannot drift in what they carry — they differ ONLY in which brushes they
    // admit, and a field added to one but not the other is exactly the kind of
    // divergence that shows up as a shell built from stale bounds.
    static CSG4Brush brush_view(const DCBrush& b) {
        CSG4Brush v;
        v.planes = b.planes.data();
        v.plane_count = (uint32_t)b.planes.size();
        v.plane_proj = b.plane_proj.data();
        v.material_id = b.material_id;
        v.id = b.id;
        // Carried through so sky brushes CLIP like everything else (they are
        // real geometry) while build_colliders can still skip them.
        v.sky = b.sky;
        v.clearance = b.clearance;
        v.lo = b.lo; v.hi = b.hi; v.bounds_valid = b.bounds_valid;
        return v;
    }

    // CLEAN brushes as csg4 input. The returned views point INTO this store —
    // do not outlive it or mutate the store while they are held.
    //
    // CLEARANCE BRUSHES ARE NOT HERE, and this is the load-bearing half of the
    // feature. A clearance solid encloses the geometry it belongs to — the
    // headroom box contains its own flight of stairs — so letting csg4 see it
    // would clip every tread away as interior to the union, and give the level a
    // collider filling the passage besides. It is not geometry; it is a statement
    // about air. shell_view below is the only view that carries it.
    void csg4_view(std::vector<CSG4Brush>& out) const {
        out.clear();
        for (const DCBrush& b : brushes_) {
            if (b.tombstoned || b.clearance || is_carved(b.id)) continue;
            out.push_back(brush_view(b));
        }
    }

    // csg4_view PLUS the clearance brushes — the input dc_level_shell wants, and
    // the only place they appear. The shell asks a different question from the
    // mesher ("what volume does this level claim", not "what surface does it
    // show"), and a passage is part of the first answer and none of the second.
    void shell_view(std::vector<CSG4Brush>& out) const {
        out.clear();
        for (const DCBrush& b : brushes_) {
            if (b.tombstoned || is_carved(b.id)) continue;
            out.push_back(brush_view(b));
        }
    }

    // Pocket granularity (docs §4): a pocket is a CONNECTED COMPONENT of carved
    // brushes, joined by sharing a carve or by overlapping AABBs. One carve
    // spanning three brushes yields ONE pocket — otherwise the pocket/pocket
    // boundaries become internal seams, which is what the design avoids.
    void pockets(std::vector<std::vector<uint32_t>>& out) const {
        out.clear();
        std::vector<uint32_t> carved;
        carved_brushes(carved);
        std::vector<bool> seen(carved.size(), false);
        for (size_t i = 0; i < carved.size(); i++) {
            if (seen[i]) continue;
            std::vector<uint32_t> comp;
            std::vector<size_t> stack{i};
            seen[i] = true;
            while (!stack.empty()) {
                const size_t k = stack.back(); stack.pop_back();
                comp.push_back(carved[k]);
                for (size_t j = 0; j < carved.size(); j++) {
                    if (seen[j]) continue;
                    if (connected(carved[k], carved[j])) { seen[j] = true; stack.push_back(j); }
                }
            }
            std::sort(comp.begin(), comp.end());
            out.push_back(std::move(comp));
        }
    }

    // Order-independent hash of all LIVE brush geometry. The gate test uses this
    // to assert carves leave the brush set bit-identical.
    //
    // Over DURABLE KEYS, not local ids, and sorted by key — so it compares two
    // PEERS, not just one store against its own past. Local ids are per-peer by
    // construction, so an id-based hash could only ever prove determinism; a
    // key-based one proves CONVERGENCE, which is the property the journal fold
    // actually has to have.
    uint64_t fingerprint() const {
        uint64_t h = 1469598103934665603ull;             // FNV-1a offset basis
        auto mix = [&h](uint64_t v) { h ^= v; h *= 1099511628211ull; };
        auto mixf = [&](float f) {
            uint32_t bits; std::memcpy(&bits, &f, 4); mix(bits);
        };
        auto mixs = [&](const std::string& s) {
            for (unsigned char c : s) mix((uint64_t)c);
            mix(0xffull);                                // terminator: "ab"+"c" != "a"+"bc"
        };
        std::vector<std::string> keys;
        for (const DCBrush& b : brushes_) if (!b.tombstoned) keys.push_back(b.key);
        std::sort(keys.begin(), keys.end());
        for (const std::string& k : keys) {
            const DCBrush* b = get(brush_by_key(k));
            mixs(b->key); mix(b->material_id); mix(b->level_id);
            mix((uint64_t)b->planes.size());
            for (size_t i = 0; i < b->planes.size(); i++) {
                mixf(b->planes[i].nx); mixf(b->planes[i].ny);
                mixf(b->planes[i].nz); mixf(b->planes[i].d);
                mix(b->plane_proj[i]);
            }
        }
        return h;
    }

    void refresh_all_bounds() { for (DCBrush& b : brushes_) refresh_bounds(b); }

private:
    std::vector<DCBrush> brushes_;
    std::vector<DCCarve> carves_;
    uint32_t next_brush_id_ = 1;   // 0 reserved for "none"
    uint32_t next_carve_id_ = 1;
    // Durable name -> local handle, for every brush this store has EVER held.
    // Never pruned: ids are never reused, and a re-applied create must resolve to
    // the handle it had before or a selection would rebind to different geometry.
    std::unordered_map<std::string, uint32_t> by_key_;
    // Per-level import counter, so "i:<level_id>:<n>" is dense and deterministic
    // per level rather than global — two maps in one room must not share a
    // numbering that depends on which loaded first.
    std::unordered_map<uint32_t, uint32_t> import_seq_;
    std::unordered_map<std::string, uint32_t> carve_by_key_;

    DCBrush* find(uint32_t id) {
        for (DCBrush& b : brushes_) if (b.id == id) return &b;
        return nullptr;
    }

    void touch(DCBrush& b) {
        b.generation++;
        b.plane_proj.resize(b.planes.size(), 0u);   // never let them desync
        refresh_bounds(b);
    }

    void refresh_bounds(DCBrush& b) {
        CSG4Brush v;
        v.planes = b.planes.data();
        v.plane_count = (uint32_t)b.planes.size();
        v.id = b.id;
        dc_csg4::compute_bounds(v);
        b.lo = v.lo; b.hi = v.hi; b.bounds_valid = v.bounds_valid;
    }

    bool connected(uint32_t a, uint32_t b) const {
        if (a == b) return false;
        for (const DCCarve& c : carves_) {         // share an active carve?
            if (c.undone) continue;
            const bool ha = std::find(c.touched.begin(), c.touched.end(), a) != c.touched.end();
            const bool hb = std::find(c.touched.begin(), c.touched.end(), b) != c.touched.end();
            if (ha && hb) return true;
        }
        const DCBrush* pa = get(a); const DCBrush* pb = get(b);
        if (!pa || !pb || !pa->bounds_valid || !pb->bounds_valid) return false;
        return aabb_overlap(pa->lo, pa->hi, pb->lo, pb->hi);
    }

    static bool aabb_overlap(const CSG4Vec3& alo, const CSG4Vec3& ahi,
                             const CSG4Vec3& blo, const CSG4Vec3& bhi) {
        return !(ahi.x < blo.x || alo.x > bhi.x ||
                 ahi.y < blo.y || alo.y > bhi.y ||
                 ahi.z < blo.z || alo.z > bhi.z);
    }

    static void shape_bounds(const TerrainEdit& e, CSG4Vec3& lo, CSG4Vec3& hi) {
        float ex, ey, ez;
        if (e.shape == TerrainEdit::BOX) {
            ex = e.half_ext[0]; ey = e.half_ext[1]; ez = e.half_ext[2];
        } else {
            ex = ey = ez = e.radius;   // sphere / mesh / convex: bounding sphere
        }
        lo = {e.x - ex, e.y - ey, e.z - ez};
        hi = {e.x + ex, e.y + ey, e.z + ez};
    }
};

#endif // DC_BRUSH_STORE_H