#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