OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / tests / test_pick_raycast.cpp

149 lines · cpp ref:
// ============================================================================
// PICKING WITHOUT AN ENGINE — resolve a ray to (brush, face) with no physics.
//
// hyper-games #22 §5 item 3. Picking is engine-coupled today: pick_face needs a
// brush id, and the only way to get one is get_brush_id_from_shape(), which takes
// the index of a Godot collision shape. That makes the editor's most basic query
// depend on the physics server having been fed, stepped and queried — so
// blender-bridge, which has no physics server at all, cannot pick anything.
//
// A brush is an intersection of half-spaces, so the ray query is the standard
// convex slab clip and needs nothing but the plane set:
//
//     for each plane (n, d):  t = (d - n.o) / (n.dir)
//     n.dir < 0 -> the ray is ENTERING that half-space  -> t_enter = max(t_enter, t)
//     n.dir > 0 -> it is LEAVING                        -> t_exit  = min(t_exit,  t)
//     hit iff t_enter <= t_exit and t_exit >= 0
//
// and the face is simply the plane that produced t_enter — the one the ray crossed
// last on the way in. Exact, not sampled: no epsilon-marching, no collision mesh,
// and it gives the same answer on a machine with no renderer.
//
// TWO DELIBERATE DIFFERENCES FROM THE PHYSICS PATH, both tested below:
//   - SKY BRUSHES ARE PICKABLE. They render and clip but carry no collider, so a
//     physics ray passes straight through them and the author cannot select the
//     boundary they marked. Here they are ordinary brushes.
//   - CARVED BRUSHES ARE STILL HIT AT THEIR HULL. The pocket is a derived voxel
//     cache; the BRUSH is the editable thing, and that is what a pick should
//     resolve to.
//
// Build: g++ -O2 -std=c++17 -I libdc/include -o /tmp/pick \
//          tests/test_pick_raycast.cpp libdc/src/dc_csg4.cpp
// ============================================================================
#include "dc_brush_store.h"
#include "dc_pick.h"
#include "dc_csg4.h"
#include <cstdio>
#include <cmath>
#include <vector>
static int fails=0;
static void ok(const char*w,bool c){ if(!c)fails++; printf("  %-58s %s\n",w,c?"OK":"**FAIL**"); }
static void eqi(const char*w,long g,long v){ bool c=g==v; if(!c)fails++; printf("  %-58s %-6ld want %-6ld %s\n",w,g,v,c?"OK":"**FAIL**"); }
static void eqf(const char*w,float g,float v){ bool c=std::fabs(g-v)<1e-3f; if(!c)fails++; printf("  %-58s %-9.4f want %-9.4f %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}};
}
static const std::vector<uint32_t> P6{0,0,0,0,0,0};

int main(){
  printf("=== Ray -> (brush, face), with no physics server ===\n\n");

  DCBrushStore s;
  const uint32_t a = s.add_brush(box(0,0,0,2,2,2),P6,1,0);          // x 0..2
  const uint32_t b = s.add_brush(box(6,0,0,8,2,2),P6,2,0);          // x 6..8

  printf("-- a ray hits the nearest brush, on the face it crossed --\n");
  dc_pick::Hit h;
  ok ("hit",dc_pick::raycast(s,{-5,1,1},{1,0,0},100.0f,h));
  eqi("the near brush, not the far one",(long)h.brush,(long)a);
  eqi("the -x face",(long)h.face,0);
  eqf("at t",h.t,5.0f);
  eqf("the point is on the surface",h.point.x,0.0f);
  eqf("and the normal is that face's",h.normal.x,-1.0f);

  printf("\n-- from the other side it is the other face, same brush --\n");
  ok ("hit",dc_pick::raycast(s,{5,1,1},{-1,0,0},100.0f,h));
  eqi("still the near brush",(long)h.brush,(long)a);
  eqi("the +x face",(long)h.face,1);
  eqf("at t",h.t,3.0f);

  printf("\n-- all six faces, so nothing is special-cased by axis --\n");
  struct Probe { float ox,oy,oz,dx,dy,dz; int face; };
  const Probe probes[6] = {
    {-5, 1, 1,  1, 0, 0, 0}, { 5, 1, 1, -1, 0, 0, 1},
    { 1,-5, 1,  0, 1, 0, 2}, { 1, 5, 1,  0,-1, 0, 3},
    { 1, 1,-5,  0, 0, 1, 4}, { 1, 1, 5,  0, 0,-1, 5},
  };
  for (const Probe& p : probes) {
    dc_pick::Hit q;
    const bool got = dc_pick::raycast(s,{p.ox,p.oy,p.oz},{p.dx,p.dy,p.dz},100.0f,q);
    char w[64]; snprintf(w,sizeof(w),"face %d",p.face);
    ok (w, got && (int)q.face==p.face && q.brush==a);
  }

  printf("\n-- misses are misses --\n");
  ok("a ray that passes beside it",!dc_pick::raycast(s,{-5,9,9},{1,0,0},100.0f,h));
  ok("a ray pointing away",!dc_pick::raycast(s,{-5,1,1},{-1,0,0},100.0f,h));
  ok("a ray that stops short (max_t)",!dc_pick::raycast(s,{-5,1,1},{1,0,0},3.0f,h));
  ok("a zero direction",!dc_pick::raycast(s,{-5,1,1},{0,0,0},100.0f,h));

  printf("\n-- the FAR brush when the near one is not in the way --\n");
  ok ("hit",dc_pick::raycast(s,{4,1,1},{1,0,0},100.0f,h));
  eqi("brush b",(long)h.brush,(long)b);
  eqf("at t",h.t,2.0f);

  printf("\n-- a tombstoned brush is not there to be hit --\n");
  ok ("tombstone the near brush",s.remove_brush(a));
  ok ("hit",dc_pick::raycast(s,{-5,1,1},{1,0,0},100.0f,h));
  eqi("the ray reaches the far brush",(long)h.brush,(long)b);
  eqf("at t",h.t,11.0f);
  ok ("restore",s.restore_brush(a));

  printf("\n-- an origin INSIDE a brush reports the face it is looking at --\n");
  // A camera can be inside a wall (clipping, a carved pocket, spectating). The
  // entry is behind us, so the meaningful answer is the surface ahead: the exit.
  ok ("hit",dc_pick::raycast(s,{1,1,1},{1,0,0},100.0f,h));
  eqi("the brush we are inside",(long)h.brush,(long)a);
  eqi("the face ahead",(long)h.face,1);
  ok ("flagged as an inside hit",h.inside);
  eqf("at t",h.t,1.0f);

  printf("\n-- a DORMANT plane never wins the pick --\n");
  // A plane that bounds nothing stays in the set (that is what makes topology
  // change reversible), but it is not a surface: picking it would hand the editor
  // a face that cannot be seen or dragged. nearest_face already skips these.
  DCBrushStore d;
  const uint32_t c = d.add_brush(box(0,0,0,2,2,2),P6,1,0);
  const uint32_t bev = d.add_face(c,{1,0,0,10.0f},7,"alice:1");   // far away: dormant
  ok ("the plane exists",bev!=UINT32_MAX);
  ok ("but bounds nothing",!d.face_is_live(c,bev));
  dc_pick::Hit e;
  ok ("hit",dc_pick::raycast(d,{-5,1,1},{1,0,0},100.0f,e));
  eqi("the live -x face, not the dormant plane",(long)e.face,0);
  ok ("and the face it reports IS live",d.face_is_live(c,e.face));

  printf("\n-- it agrees with nearest_face, which is the physics path's query --\n");
  // get_brush_id_from_shape + pick_face is what the engine does today; the second
  // half of that is nearest_face on the hit POINT. Given the same point, the two
  // must name the same face or the editor would behave differently depending on
  // which way the pick was routed.
  for (const Probe& p : probes) {
    dc_pick::Hit q;
    if (!dc_pick::raycast(s,{p.ox,p.oy,p.oz},{p.dx,p.dy,p.dz},100.0f,q)) { fails++; continue; }
    const int nf = s.nearest_face(q.brush,q.point.x,q.point.y,q.point.z);
    char w[64]; snprintf(w,sizeof(w),"face %d matches nearest_face",p.face);
    ok (w, nf==(int)q.face);
  }

  printf("\n-- a sky brush IS pickable, unlike through the physics path --\n");
  DCBrushStore sk;
  sk.add_brush(box(0,0,0,2,2,2),P6,3,0,true);      // sky: renders, clips, no collider
  dc_pick::Hit y;
  ok("the author can select the boundary they marked",
     dc_pick::raycast(sk,{-5,1,1},{1,0,0},100.0f,y));

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