OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / tests / test_brush_topology.cpp

114 lines · cpp ref:
// ============================================================================
// TOPOLOGY GATE — does the half-space representation change topology, and is
// that change EXACT and REVERSIBLE?
//
// The question this answers: level-mesh-architecture.md picks face-level editing
// as "the tractable subset", which reads as if topology change were out of scope.
// It is not. For a CONVEX brush, topology is not an independent thing to support
// — it is a CONSEQUENCE of the half-space set. Adding a plane adds a face and
// vertices; pushing a plane until it bounds nothing takes them away. So the
// existing add_face/remove_face/set_face_plane already span every reachable
// topology, and this file proves it rather than asserting it.
//
// The property worth protecting is the one in the last section. TrenchBroom
// changes topology by rebuilding a polyhedron and RE-DERIVING each plane from
// three of its new vertices (BrushFace::updatePointsFromVertices), then matching
// old faces to new ones heuristically (PolyhedronMatcher) to carry attributes
// across. That destroys information: once vertices merge, the plane is gone and
// the matcher has to guess. It is the topological naming problem, and it is why
// they need a matcher at all.
//
// Here a degenerated face keeps its plane AND its dot in the set, so it is
// dormant rather than deleted — push it back and the geometry returns
// bit-for-bit. No rebuild, no re-fitting, no matcher, no guessing.
//
// WHAT THIS DOES NOT COVER: concavity. An intersection of half-spaces is convex,
// always. Holes, genus and concave shapes need multiple brushes plus CSG (which
// is what Quake and TrenchBroom do) or a carve pocket. No tweak to this
// representation reaches them.
//
// Build: g++ -O2 -std=c++17 -I libdc/include -o /tmp/topo \
//          tests/test_brush_topology.cpp libdc/src/dc_csg4.cpp
// ============================================================================
#include "dc_brush_store.h"
#include "dc_csg4.h"
#include <cstdio>
#include <vector>
static int fails=0;
static void ok(const char*w,bool c){ if(!c)fails++; printf("  %-56s %s\n",w,c?"OK":"**FAIL**"); }
static void eqi(const char*w,long g,long v){ bool c=g==v; if(!c)fails++; printf("  %-56s %-6ld want %-6ld %s\n",w,g,v,c?"OK":"**FAIL**"); }

static std::vector<ConvexPlane> box(float a,float b,float c,float d,float e,float f){
  return {{-1,0,0,-a},{1,0,0,d},{0,-1,0,-b},{0,1,0,e},{0,0,-1,-c},{0,0,1,f}};
}
// How many faces actually BOUND something, and how many distinct vertices exist.
static void shape(const DCBrushStore&s,uint32_t id,int&live,int&verts){
  const DCBrush*br=s.get(id); live=0; std::vector<CSG4Vec3> all;
  CSG4Brush cb; cb.planes=br->planes.data(); cb.plane_count=(uint32_t)br->planes.size();
  for(uint32_t i=0;i<cb.plane_count;i++){ std::vector<CSG4Vec3> p;
    if(dc_csg4::face_winding(cb,i,p)&&p.size()>=3){ live++; for(auto&q:p) all.push_back(q); } }
  verts=0;
  for(size_t i=0;i<all.size();i++){ bool dup=false;
    for(size_t j=0;j<i;j++){ CSG4Vec3&x=all[i];CSG4Vec3&y=all[j];
      if(std::fabs(x.x-y.x)<1e-3f&&std::fabs(x.y-y.y)<1e-3f&&std::fabs(x.z-y.z)<1e-3f){dup=true;break;} }
    if(!dup) verts++; }
}
int main(){
  printf("=== Topology change in a half-space brush ===\n\n");
  DCBrushStore s;
  uint32_t id=s.add_brush(box(0,0,0,2,2,2),{0,0,0,0,0,0},0);
  int live,verts; shape(s,id,live,verts);
  printf("-- a cube --\n"); eqi("faces that bound something",live,6); eqi("vertices",verts,8);

  // Record the EXACT plane set so we can compare after a round trip.
  const std::vector<ConvexPlane> before=s.get(id)->planes;

  printf("\n-- ADD A VERTEX: bevel a corner (topology +1 face, +2 verts) --\n");
  uint32_t bev=s.add_face(id,{0.5774f,0.5774f,0.5774f,2.6f},7,"alice:1");
  ok("bevel accepted",bev!=UINT32_MAX);
  shape(s,id,live,verts); eqi("faces now",live,7); ok("vertex count grew",verts>8);
  printf("     (faces=%d verts=%d)\n",live,verts);

  printf("\n-- DEGENERATE IT: push the bevel out until it bounds nothing --\n");
  ok("push out",s.set_face_plane(id,bev,{0.5774f,0.5774f,0.5774f,10.0f}));
  shape(s,id,live,verts);
  eqi("back to 6 live faces",live,6); eqi("back to 8 vertices",verts,8);
  ok("the dead plane is STILL in the set",s.get(id)->planes.size()==7);
  ok("and it still has its identity",s.face_by_key(id,"alice:1")>=0);

  printf("\n-- REVERSE IT: push it back --\n");
  ok("push back",s.set_face_plane(id,bev,{0.5774f,0.5774f,0.5774f,2.6f}));
  shape(s,id,live,verts); eqi("bevel returns",live,7);

  printf("\n-- EXACTNESS: remove the bevel, compare to the original --\n");
  ok("remove_face",s.remove_face(id,(uint32_t)s.face_by_key(id,"alice:1")));
  const std::vector<ConvexPlane>&after=s.get(id)->planes;
  eqi("plane count restored",(long)after.size(),(long)before.size());
  bool bitident=after.size()==before.size();
  for(size_t i=0;i<after.size()&&bitident;i++)
    bitident = after[i].nx==before[i].nx&&after[i].ny==before[i].ny
            && after[i].nz==before[i].nz&&after[i].d ==before[i].d;
  ok("planes are BIT-IDENTICAL to the original",bitident);
  shape(s,id,live,verts); eqi("and the shape is the cube again",live,6); eqi("vertices",verts,8);

  printf("\n-- a dormant plane is distinguishable from a face --\n");
  // The editor consequence of keeping dead planes: "face" and "plane" stop being
  // the same thing, and you cannot click something that bounds nothing.
  uint32_t b2=s.add_face(id,{0.5774f,0.5774f,0.5774f,2.6f},7,"alice:2");
  ok("bevel back",b2!=UINT32_MAX);
  ok("it is live",s.face_is_live(id,b2));
  eqi("live faces",(long)s.live_face_count(id),7);
  eqi("planes",(long)s.get(id)->planes.size(),7);

  ok("push it dormant",s.set_face_plane(id,b2,{0.5774f,0.5774f,0.5774f,10.0f}));
  ok("now it is NOT live",!s.face_is_live(id,b2));
  eqi("live faces drop",(long)s.live_face_count(id),6);
  eqi("but the plane is still there",(long)s.get(id)->planes.size(),7);
  ok("and still identified",s.face_by_key(id,"alice:2")>=0);
  ok("a dormant plane cannot be picked",s.nearest_face(id,10.0f,10.0f,10.0f)!=(int)b2);
  ok("out-of-range face is not live",!s.face_is_live(id,99));

  printf("\n%s\n",fails?"FAILED":"PASS");
  return fails?1:0;
}