code wiki / (root) / nx_vpick_lib.nx

nx_vpick_lib.nx source

↩ module page · 788 lines · 45716 B

1// nx_vpick_lib.nx -- DC4 of the /compare/dcc board: RAY-PICK and a TRANSFORM GIZMO for the sovereign 2// browser viewport. nx_meshview_wasm already RENDERS and ORBITS a real indexed mesh through our own 3// software rasteriser compiled to WASM (zero WebGL, zero Emscripten, zero third-party code). Nothing in 4// that viewport can be SELECTED or DRAGGED, so it is a viewer and not an editor. This library is the 5// missing half: turn a click into a triangle, turn a drag into an axis-constrained translation, and hand 6// that translation to the operation stack as an OP instead of writing it into the vertex buffer. 7// 8// THE GIZMO NEVER MUTATES GEOMETRY. vp_gizmo_delta takes no mesh pointer at all -- it cannot write a 9// vertex even by accident -- and vp_apply_drag pushes ES_OP_ADD / ES_OP_RADD onto an nx_editstack_lib 10// stack. That is the whole point of the rung: a drag becomes an entry in the log, so it is undoable, 11// redoable, replayable bit-exactly, and re-evaluable at depth, exactly like every other edit. A gizmo 12// that wrote vertices directly would be a gizmo whose work cannot be undone without a snapshot. 13// 14// CHECK-BEFORE-BUILD. nx_capsearch was asked first (corpus_complete=1 over 6892 tools and 5517 libs) and 15// nx_vpick is absent from the tree (nx_shelltool find, corpus_complete=1 over 61581 files). THREE 16// incumbents were then READ, not guessed, and each is named with the measured reason it is not the answer: 17// * mtk_ray_tri (nx_meshthick.nx) IS a Moller-Trumbore ray/triangle test and is the closest thing the 18// estate had. It works in fx256, it REQUIRES a unit-length direction, it returns ONLY a scalar 19// distance, and it collapses parallel, behind, outside-the-triangle and no-intersection into a single 20// 0-1 return -- a negative answer that cannot say which negative it is. A picker needs the barycentric 21// coordinates (to place a manipulator on the hit), needs to tell a DEGENERATE triangle from a parallel 22// ray, and must not be forced to normalise a screen ray: normalisation costs an isqrt and discards the 23// sub-unit precision a pixel-accurate pick depends on. Its own consumer nx_castview.nx already carries 24// a standing warning about converting Q14 rays into its fx256 scale. 25// * r3_ray (nx_recon3d.nx) back-projects a pixel to a ray, but from a 3x3 BASIS plus a focal length, and 26// normalises the result. The viewport camera is not that model: it is yaw + orbit distance + half-fov 27// through rc_perspective_cs. Feeding one convention's rays to the other camera is how a pick lands on 28// the wrong triangle. 29// * nx_raycast_voxel.nx is DDA traversal of a voxel grid, not an indexed triangle mesh. 30// 31// THE CAMERA CONVENTION IS COPIED, NOT INVENTED. Everything below is the exact inverse of what 32// mv_render_loaded_cam and mv_proj1 do in nx_meshview_wasm: 33// model -> eye eye = T(0,0,-dist) * Ry(yaw) * model (roty exactly as the renderer builds it) 34// eye -> clip rc_perspective_cs(cos(fovh), sin(fovh), w*Q/h, Q, far) 35// clip -> screen sx = (ndcx + 1) * w / 2 , sy = (1 - ndcy) * h / 2 36// so screen -> model is: undo the viewport map, divide out f = cot(fovh) and the aspect, set eye-space z 37// to -1, then apply Ry(yaw) TRANSPOSED (a rotation's transpose is its inverse) and place the eye at 38// Ry(yaw)^T * (0,0,dist). The trig is rc_sin_q14 / rc_cos_q14 -- the renderer's OWN Bhaskara 39// approximation, COMPOSED rather than re-implemented, so a pick can never drift from the picture it was 40// taken on. The scale unit is RC_Q, imported from nx_render_core for the same reason: one Q in the estate. 41// 42// ALLOCATION-FREE ON PURPOSE. Every vp_ function takes caller-supplied buffers and calls no syscall, which 43// is the discipline nx_render_core already declares, and it exists for a concrete reason: this capability 44// is FOR the wasm viewport, and wasm has no sys_mmap -- a wasm consumer backs these buffers with fixed 45// linear-memory offsets exactly as nx_meshview_wasm already does for its framebuffer. The one allocating 46// entry point in the whole chain is es_new, in the imported stack lib; a wasm consumer replaces that with 47// a fixed offset. Named here rather than left for the next reader to discover. 48// 49// ===================================================================================================== 50// THE OVERFLOW PROOF -- the guard is DERIVED from i64 headroom, not picked. 51// ===================================================================================================== 52// Let M_v be the bound on any vertex component, M_o the bound on a ray-origin component, and M_d the bound 53// on a ray-direction component, all Q14. Then in Moller-Trumbore: 54// e = v1-v0 or v2-v0 so |e| <= E = 2*M_v 55// s = origin - v0 so |s| <= S = M_o + M_v 56// h = (dir x e2) / Q so |h| <= 2*M_d*E / Q 57// det= e1 . h so |det| <= 3*E * 2*M_d*E/Q = 6*M_d*E*E/Q 58// un = s . h so |un| <= 6*S*M_d*E/Q 59// q = (s x e1) / Q so |q| <= 2*S*E/Q 60// vn = dir . q so |vn| <= 6*M_d*S*E/Q 61// tn = e2 . q so |tn| <= 6*S*E*E/Q 62// The WIDEST value in the whole routine is tn*Q (the numerator of the ray parameter), bounded by 6*S*E*E. 63// The division by Q inside h and q is what makes this fit at all: without it every product above is Q 64// times larger and the bound collapses to about 1.4 world units, which is smaller than the meshes the 65// estate already emits. 66// 67// Choosing the three bounds from the estate's OWN shipped constants rather than from taste: 68// M_o = 524288 = 32.0 in Q14. This is DV_DIST_MAX in nx_meshview_wasm -- the farthest the estate's own 69// orbit camera is ever allowed to sit from the model, so it is exactly the largest ray 70// origin the viewport can produce. 71// M_v = 262144 = 16.0 in Q14. The shipped scenes use half-extents around 9830 (0.6) and the dance viewer 72// zooms in to DV_DIST_MIN = 1.0, so 16.0 is roughly 26x the largest thing rendered. 73// M_d = 262144 = 16.0 in Q14. A screen ray built below has |dir| near 1.0-2.0 for any half-fov under 60 74// degrees, so this is an order of magnitude of headroom for a hand-built ray. 75// Then E = 524288, S = 786432 and the widest intermediate is 76// 6 * 786432 * 524288 * 524288 = 1.30e18 against i64 max 9.22e18 -- a 7.1x margin. 77// The second widest is un*Q = 6*S*M_d*E = 6.49e17, a 14x margin. Everything else is smaller by 78// construction. Any input outside these bounds is REFUSED BY NAME (VP_E_BOUND), never clamped and never 79// silently wrapped: a clamp is how an out-of-range read becomes a plausible wrong answer. 80// 81// THE IMPRECISION I CHOSE TO LIVE WITH, stated rather than left to be discovered. Dividing the two cross 82// products by Q truncates about one part in 2*|edge|/Q. For the smallest triangle the estate plausibly 83// emits (edge around 0.01 world units, 164 in Q14) that is roughly 3 parts per thousand of a barycentric 84// coordinate -- so a click landing within 3 permil of a shared edge may resolve to the neighbouring 85// triangle. That is the price of keeping the widest intermediate inside i64, and it is paid deliberately. 86// 87// SECTION 6 IS THE MEASUREMENT HALF, added 2026-08-25, and it obeys the same two disciplines as everything 88// above it: caller-supplied buffers, and NO CLOCK. It exists because an interaction claim with no triangle 89// count is opinion wearing a number, which is the rung's own done-rule and was the half this file did not 90// meet. What a pick COSTS is as much a property of the picker as which triangle it returns. 91// 92// 100% sovereign. No hardware writes (Rule 26). license_tier: ORIGINAL 93import "nx_render_core.nx" 94import "nx_editstack_lib.nx" 95 96// ---- ray record, 6 words: origin then direction, both Q14 model space ---- 97const VP_RAY_OX: i64 = 0 98const VP_RAY_OY: i64 = 1 99const VP_RAY_OZ: i64 = 2 100const VP_RAY_DX: i64 = 3 101const VP_RAY_DY: i64 = 4 102const VP_RAY_DZ: i64 = 5 103const VP_RAY_WORDS: i64 = 6 104 105// ---- camera record, 8 words. The first five are exactly the arguments mv_render_loaded_cam already 106// takes or derives; the last three are the gizmo pivot (the point the drag plane passes through). 107const VP_CAM_W: i64 = 0 108const VP_CAM_H: i64 = 1 109const VP_CAM_YAW: i64 = 2 // degrees, fed straight to rc_sin_q14 like the renderer does 110const VP_CAM_DIST: i64 = 3 // Q14 orbit distance, the renderer's `dist` 111const VP_CAM_FOVH: i64 = 4 // half field-of-view in degrees, the renderer's FOVH 112const VP_CAM_PX: i64 = 5 113const VP_CAM_PY: i64 = 6 114const VP_CAM_PZ: i64 = 7 115const VP_CAM_WORDS: i64 = 8 116 117// ---- pick result, 7 words ---- 118const VP_R_T: i64 = 0 // ray parameter, Q14: hit = origin + t*dir/Q 119const VP_R_U: i64 = 1 // barycentric weight of v1, Q14 120const VP_R_V: i64 = 2 // barycentric weight of v2, Q14 121const VP_R_W: i64 = 3 // barycentric weight of v0, Q14 (Q - u - v) 122const VP_R_HX: i64 = 4 123const VP_R_HY: i64 = 5 124const VP_R_HZ: i64 = 6 125const VP_R_WORDS: i64 = 7 126 127// ---- gizmo result, 4 words ---- 128const VP_G_DX: i64 = 0 129const VP_G_DY: i64 = 1 130const VP_G_DZ: i64 = 2 131const VP_G_SCALAR: i64 = 3 // signed magnitude along the chosen axis; equals the one nonzero component 132const VP_G_WORDS: i64 = 4 133 134// ---- drag record, 4 words: screen point A then screen point B ---- 135const VP_DRAG_AX: i64 = 0 136const VP_DRAG_AY: i64 = 1 137const VP_DRAG_BX: i64 = 2 138const VP_DRAG_BY: i64 = 3 139const VP_DRAG_WORDS: i64 = 4 140 141// ---- scratch layout for vp_gizmo_delta / vp_apply_drag. SIZED FROM WHAT IT HOLDS, not guessed: 142// two rays (2 * VP_RAY_WORDS = 12 words) plus two plane hit points (2 * 3 = 6 words) = 18. 143const VP_SC_RAY_A: i64 = 0 144const VP_SC_RAY_B: i64 = 6 145const VP_SC_HIT_A: i64 = 12 146const VP_SC_HIT_B: i64 = 15 147const VP_SCRATCH_WORDS: i64 = 18 148 149const VP_AXIS_X: i64 = 0 150const VP_AXIS_Y: i64 = 1 151const VP_AXIS_Z: i64 = 2 152 153// ---- per-triangle classification. A SEPARATE CODE SPACE from the pick/refusal codes below, so that a 154// caller which mistakenly propagates one of these can never be mistaken for a whole-mesh miss. 155const VP_HIT: i64 = 1 156const VP_NOHIT_DEGENERATE: i64 = 0 - 1 // zero-area triangle: e1 x e2 is exactly the zero vector 157const VP_NOHIT_PARALLEL: i64 = 0 - 2 // ray parallel to a genuinely non-degenerate triangle 158const VP_NOHIT_OUTSIDE: i64 = 0 - 3 // plane hit, but the barycentric point is outside the triangle 159const VP_NOHIT_BEHIND: i64 = 0 - 4 // intersection is behind the ray origin 160 161// ---- pick + refusal codes. They start at -16 for two reasons: they stay clear of the per-triangle codes 162// above, AND they stay clear of nx_editstack_lib's ES_E_* codes, which occupy -1 .. -9. vp_apply_drag 163// propagates a stack refusal unchanged, so a caller can tell "the stack refused" from "the pick refused" 164// by the code alone rather than by remembering which call it came from. 165const VP_OK: i64 = 0 166const VP_MISS_NONE: i64 = 0 - 16 // NO triangle was intersected. Deliberately not 0 and not -1. 167const VP_E_TRI_IDX: i64 = 0 - 17 168const VP_E_ZERO_DIR: i64 = 0 - 18 169const VP_E_SCREEN: i64 = 0 - 19 170const VP_E_BOUND: i64 = 0 - 20 171const VP_E_VIEWPORT: i64 = 0 - 21 172const VP_E_AXIS: i64 = 0 - 22 173const VP_E_ARGS: i64 = 0 - 23 174const VP_E_CELL: i64 = 0 - 24 175const VP_E_FOV: i64 = 0 - 25 176const VP_E_PLANE: i64 = 0 - 26 177 178// ---- derived magnitude guards. Their derivation is the OVERFLOW PROOF block at the top of this file. 179const VP_VERT_MAX: i64 = 262144 // 16.0 in Q14 180const VP_ORIGIN_MAX: i64 = 524288 // 32.0 in Q14 == DV_DIST_MAX in nx_meshview_wasm 181const VP_DIR_MAX: i64 = 262144 // 16.0 in Q14 182// The largest world point any stage here may name: an origin plus a vertex offset. Used to reject a plane 183// intersection that has run off to somewhere no scene can contain. 184const VP_WORLD_MAX: i64 = 786432 // VP_ORIGIN_MAX + VP_VERT_MAX 185// Largest ray parameter a plane hit may carry. DERIVED so that t*dir/Q cannot overflow: 186// VP_RAYT_MAX * VP_DIR_MAX = 2^40 * 2^18 = 2^58, comfortably inside i64. As a distance it is 2^26 world 187// units, which is six orders of magnitude beyond anything the estate renders. 188const VP_RAYT_MAX: i64 = 1099511627776 189 190const VP_THREE: i64 = 3 // components per vertex in the estate's AoS mesh buffers 191const VP_TWO: i64 = 2 192 193// VP_THREE is components per VERTEX, and it was also being used as the stride of the INDEX buffer -- one 194// constant standing for two unrelated quantities, which is a constant that can never be tuned for either. 195// The index stride gets its own name and its own field offsets. 196const VP_TRI_I: i64 = 3 // vertex indices per triangle in the estate's index buffers 197const VP_TI_A: i64 = 0 198const VP_TI_B: i64 = 1 199const VP_TI_C: i64 = 2 200 201// The component offsets within a 3-word point are VP_AXIS_X/Y/Z, already declared above: an axis selector 202// and a component offset are the SAME number here, for the reason vp_gizmo_delta states in its own comment 203// -- the three axes are the unit basis vectors, so the component along an axis IS the word at that offset. 204// Every point read below is addressed through those names rather than through a hand-written 0, 1 or 2. 205 206func vp_abs(a: i64) -> i64 { if a < 0 { return 0 - a } return a } 207 208func vp_within(v: i64, m: i64) -> i64 { 209 if v > m { return 0 } 210 if v < (0 - m) { return 0 } 211 return 1 212} 213 214// EVERY REFUSAL IS NAMED. A guard that returns a bare negative for nine different causes sends every 215// reader at the wrong one. 216func vp_code_name(c: i64) -> *u8 { 217 if c == VP_OK { return "OK" as *u8 } 218 if c == VP_HIT { return "HIT" as *u8 } 219 if c == VP_NOHIT_DEGENERATE{ return "NOHIT-TRIANGLE-IS-DEGENERATE" as *u8 } 220 if c == VP_NOHIT_PARALLEL { return "NOHIT-RAY-PARALLEL-TO-TRIANGLE" as *u8 } 221 if c == VP_NOHIT_OUTSIDE { return "NOHIT-POINT-OUTSIDE-TRIANGLE" as *u8 } 222 if c == VP_NOHIT_BEHIND { return "NOHIT-INTERSECTION-BEHIND-ORIGIN" as *u8 } 223 if c == VP_MISS_NONE { return "MISS-NO-TRIANGLE-INTERSECTED" as *u8 } 224 if c == VP_E_TRI_IDX { return "REFUSED-TRIANGLE-INDEX-OUT-OF-RANGE" as *u8 } 225 if c == VP_E_ZERO_DIR { return "REFUSED-RAY-DIRECTION-ZERO-LENGTH" as *u8 } 226 if c == VP_E_SCREEN { return "REFUSED-SCREEN-POINT-OUTSIDE-VIEWPORT" as *u8 } 227 if c == VP_E_BOUND { return "REFUSED-COORDINATE-OUTSIDE-DERIVED-RANGE" as *u8 } 228 if c == VP_E_VIEWPORT { return "REFUSED-VIEWPORT-DIMENSION-INVALID" as *u8 } 229 if c == VP_E_AXIS { return "REFUSED-UNKNOWN-GIZMO-AXIS" as *u8 } 230 if c == VP_E_ARGS { return "REFUSED-BAD-ARGUMENTS" as *u8 } 231 if c == VP_E_CELL { return "REFUSED-SELECTION-OUTSIDE-MESH" as *u8 } 232 if c == VP_E_FOV { return "REFUSED-HALF-FOV-NOT-IN-OPEN-0-90" as *u8 } 233 if c == VP_E_PLANE { return "REFUSED-DRAG-RAY-PARALLEL-TO-PIVOT-PLANE" as *u8 } 234 return "REFUSED-UNCLASSIFIED" as *u8 235} 236 237func vp_axis_ok(axis: i64) -> i64 { 238 if axis == VP_AXIS_X { return 1 } 239 if axis == VP_AXIS_Y { return 1 } 240 if axis == VP_AXIS_Z { return 1 } 241 return 0 242} 243 244func vp_dir_is_zero(ray: *i64, rbase: i64) -> i64 { 245 if ray[rbase + VP_RAY_DX] != 0 { return 0 } 246 if ray[rbase + VP_RAY_DY] != 0 { return 0 } 247 if ray[rbase + VP_RAY_DZ] != 0 { return 0 } 248 return 1 249} 250 251func vp_ray_within_bounds(ray: *i64, rbase: i64) -> i64 { 252 if vp_within(ray[rbase + VP_RAY_OX], VP_ORIGIN_MAX) == 0 { return 0 } 253 if vp_within(ray[rbase + VP_RAY_OY], VP_ORIGIN_MAX) == 0 { return 0 } 254 if vp_within(ray[rbase + VP_RAY_OZ], VP_ORIGIN_MAX) == 0 { return 0 } 255 if vp_within(ray[rbase + VP_RAY_DX], VP_DIR_MAX) == 0 { return 0 } 256 if vp_within(ray[rbase + VP_RAY_DY], VP_DIR_MAX) == 0 { return 0 } 257 if vp_within(ray[rbase + VP_RAY_DZ], VP_DIR_MAX) == 0 { return 0 } 258 return 1 259} 260 261func vp_vert_within_bounds(mesh: *i64, v: i64) -> i64 { 262 if vp_within(mesh[v * VP_THREE + VP_AXIS_X], VP_VERT_MAX) == 0 { return 0 } 263 if vp_within(mesh[v * VP_THREE + VP_AXIS_Y], VP_VERT_MAX) == 0 { return 0 } 264 if vp_within(mesh[v * VP_THREE + VP_AXIS_Z], VP_VERT_MAX) == 0 { return 0 } 265 return 1 266} 267 268// ===================================================================================================== 269// 1. SCREEN POINT -> RAY, in the renderer's own convention. 270// ===================================================================================================== 271// Writes 6 words at ray[base ..]. Returns VP_OK or a named refusal. The refusals are deliberate: a caller 272// that hands this a click from outside the canvas is asking a question with no answer, and inventing one 273// would put a manipulator somewhere the user never pointed. 274func vp_ray_from_screen(cam: *i64, px: i64, py: i64, ray: *i64, base: i64) -> i64 { 275 let vw: i64 = cam[VP_CAM_W] 276 let vh: i64 = cam[VP_CAM_H] 277 if vw <= 0 { return VP_E_VIEWPORT } 278 if vh <= 0 { return VP_E_VIEWPORT } 279 if px < 0 { return VP_E_SCREEN } 280 if py < 0 { return VP_E_SCREEN } 281 if px >= vw { return VP_E_SCREEN } 282 if py >= vh { return VP_E_SCREEN } 283 let dist: i64 = cam[VP_CAM_DIST] 284 if vp_within(dist, VP_ORIGIN_MAX) == 0 { return VP_E_BOUND } 285 // A half-fov outside the open interval 0..90 has no forward-facing frustum, and f = cot(fovh) would 286 // be zero or negative. rc_perspective_cs silently falls back to the identity there; a picker must not, 287 // because the identity camera would answer confidently about a picture nobody is looking at. 288 let sin_h: i64 = rc_sin_q14(cam[VP_CAM_FOVH]) 289 let cos_h: i64 = rc_cos_q14(cam[VP_CAM_FOVH]) 290 if sin_h <= 0 { return VP_E_FOV } 291 if cos_h <= 0 { return VP_E_FOV } 292 let f_q: i64 = (cos_h * RC_Q) / sin_h // cot(half-fov), IDENTICAL to rc_perspective_cs's f 293 if f_q <= 0 { return VP_E_FOV } 294 let aspect_q: i64 = (vw * RC_Q) / vh // IDENTICAL to the renderer's MV_W*VQ/MV_H 295 if aspect_q <= 0 { return VP_E_VIEWPORT } 296 // Undo mv_proj1's viewport map: sx = (ndcx+1)*w/2 => ndcx = 2*sx/w - 1 297 // sy = (1-ndcy)*h/2 => ndcy = 1 - 2*sy/h 298 let ndcx: i64 = (VP_TWO * px * RC_Q) / vw - RC_Q 299 let ndcy: i64 = RC_Q - (VP_TWO * py * RC_Q) / vh 300 // Undo the projection at eye-space z = -1: clipx = (f/aspect)*ex and clipw = -ez, so ndcx = ex*f/aspect. 301 let ex: i64 = (ndcx * aspect_q) / f_q 302 let ey: i64 = (ndcy * RC_Q) / f_q 303 let ez: i64 = 0 - RC_Q 304 // Ry(yaw) as the renderer builds roty is [cc 0 ss ; 0 1 0 ; -ss 0 cc]; its transpose is its inverse. 305 let cc: i64 = rc_cos_q14(cam[VP_CAM_YAW]) 306 let ss: i64 = rc_sin_q14(cam[VP_CAM_YAW]) 307 let dx: i64 = (cc * ex - ss * ez) / RC_Q 308 let dy: i64 = ey 309 let dz: i64 = (ss * ex + cc * ez) / RC_Q 310 // The eye sits at the origin in eye space; the view translation is (0,0,-dist), so in model space the 311 // camera is Ry(yaw)^T * (0,0,dist). 312 let ox: i64 = (0 - ss * dist) / RC_Q 313 let oy: i64 = 0 314 let oz: i64 = (cc * dist) / RC_Q 315 if dx == 0 { if dy == 0 { if dz == 0 { return VP_E_ZERO_DIR } } } 316 if vp_within(dx, VP_DIR_MAX) == 0 { return VP_E_BOUND } 317 if vp_within(dy, VP_DIR_MAX) == 0 { return VP_E_BOUND } 318 if vp_within(dz, VP_DIR_MAX) == 0 { return VP_E_BOUND } 319 ray[base + VP_RAY_OX] = ox 320 ray[base + VP_RAY_OY] = oy 321 ray[base + VP_RAY_OZ] = oz 322 ray[base + VP_RAY_DX] = dx 323 ray[base + VP_RAY_DY] = dy 324 ray[base + VP_RAY_DZ] = dz 325 return VP_OK 326} 327 328// ===================================================================================================== 329// 2. MOLLER-TRUMBORE against ONE triangle of an indexed mesh. 330// ===================================================================================================== 331// mesh is the estate's ordinary AoS vertex buffer (mesh[v*3+c]), unchanged and never written. 332// Returns VP_HIT and fills `out`, or one of the four NAMED non-hit classifications, or a named refusal. 333// DEGENERACY IS TESTED FIRST AND ON ITS OWN TERMS: e1 x e2 == 0 exactly means zero area, and it is a 334// property of the TRIANGLE, not of this ray. Folding it into the determinant test (as the naive form does) 335// would report a zero-area triangle as merely "parallel", which is true of every ray and therefore tells 336// the caller nothing about the mesh it is holding. 337func vp_tri_hit(mesh: *i64, nverts: i64, i0: i64, i1: i64, i2: i64, ray: *i64, rbase: i64, out: *i64) -> i64 { 338 if nverts <= 0 { return VP_E_ARGS } 339 if i0 < 0 { return VP_E_TRI_IDX } 340 if i1 < 0 { return VP_E_TRI_IDX } 341 if i2 < 0 { return VP_E_TRI_IDX } 342 if i0 >= nverts { return VP_E_TRI_IDX } 343 if i1 >= nverts { return VP_E_TRI_IDX } 344 if i2 >= nverts { return VP_E_TRI_IDX } 345 if vp_dir_is_zero(ray, rbase) == 1 { return VP_E_ZERO_DIR } 346 if vp_ray_within_bounds(ray, rbase) == 0 { return VP_E_BOUND } 347 if vp_vert_within_bounds(mesh, i0) == 0 { return VP_E_BOUND } 348 if vp_vert_within_bounds(mesh, i1) == 0 { return VP_E_BOUND } 349 if vp_vert_within_bounds(mesh, i2) == 0 { return VP_E_BOUND } 350 let v0x: i64 = mesh[i0 * VP_THREE + VP_AXIS_X] 351 let v0y: i64 = mesh[i0 * VP_THREE + VP_AXIS_Y] 352 let v0z: i64 = mesh[i0 * VP_THREE + VP_AXIS_Z] 353 let e1x: i64 = mesh[i1 * VP_THREE + VP_AXIS_X] - v0x 354 let e1y: i64 = mesh[i1 * VP_THREE + VP_AXIS_Y] - v0y 355 let e1z: i64 = mesh[i1 * VP_THREE + VP_AXIS_Z] - v0z 356 let e2x: i64 = mesh[i2 * VP_THREE + VP_AXIS_X] - v0x 357 let e2y: i64 = mesh[i2 * VP_THREE + VP_AXIS_Y] - v0y 358 let e2z: i64 = mesh[i2 * VP_THREE + VP_AXIS_Z] - v0z 359 // Zero area. Computed RAW (no divide by Q) so it is exact: |e1 x e2| components are at most 2*E*E = 360 // 5.5e11, nowhere near i64. An exact zero here is the only honest test for a degenerate triangle. 361 let crx: i64 = e1y * e2z - e1z * e2y 362 let cry: i64 = e1z * e2x - e1x * e2z 363 let crz: i64 = e1x * e2y - e1y * e2x 364 if crx == 0 { if cry == 0 { if crz == 0 { return VP_NOHIT_DEGENERATE } } } 365 let ox: i64 = ray[rbase + VP_RAY_OX] 366 let oy: i64 = ray[rbase + VP_RAY_OY] 367 let oz: i64 = ray[rbase + VP_RAY_OZ] 368 let dx: i64 = ray[rbase + VP_RAY_DX] 369 let dy: i64 = ray[rbase + VP_RAY_DY] 370 let dz: i64 = ray[rbase + VP_RAY_DZ] 371 let hx: i64 = (dy * e2z - dz * e2y) / RC_Q 372 let hy: i64 = (dz * e2x - dx * e2z) / RC_Q 373 let hz: i64 = (dx * e2y - dy * e2x) / RC_Q 374 var det: i64 = e1x * hx + e1y * hy + e1z * hz 375 // The triangle is known non-degenerate by now, so a zero determinant can only mean the ray lies in 376 // the triangle's plane. 377 if det == 0 { return VP_NOHIT_PARALLEL } 378 let sx: i64 = ox - v0x 379 let sy: i64 = oy - v0y 380 let sz: i64 = oz - v0z 381 var un: i64 = sx * hx + sy * hy + sz * hz 382 let qx: i64 = (sy * e1z - sz * e1y) / RC_Q 383 let qy: i64 = (sz * e1x - sx * e1z) / RC_Q 384 let qz: i64 = (sx * e1y - sy * e1x) / RC_Q 385 var vn: i64 = dx * qx + dy * qy + dz * qz 386 var tn: i64 = e2x * qx + e2y * qy + e2z * qz 387 // Two-sided (non-culling) form: normalise the sign of the determinant and carry the numerators with 388 // it, so the inside test is four plain comparisons and a back-facing triangle is still pickable -- 389 // which is what an editor wants, because you routinely select geometry you are looking at from behind. 390 if det < 0 { 391 det = 0 - det 392 un = 0 - un 393 vn = 0 - vn 394 tn = 0 - tn 395 } 396 if un < 0 { return VP_NOHIT_OUTSIDE } 397 if un > det { return VP_NOHIT_OUTSIDE } 398 if vn < 0 { return VP_NOHIT_OUTSIDE } 399 if un + vn > det { return VP_NOHIT_OUTSIDE } 400 if tn <= 0 { return VP_NOHIT_BEHIND } 401 let uq: i64 = (un * RC_Q) / det 402 let vq: i64 = (vn * RC_Q) / det 403 let tq: i64 = (tn * RC_Q) / det 404 out[VP_R_T] = tq 405 out[VP_R_U] = uq 406 out[VP_R_V] = vq 407 out[VP_R_W] = RC_Q - uq - vq 408 // THE HIT POINT COMES FROM THE BARYCENTRICS, NOT FROM origin + t*dir. Both are correct mathematically, 409 // but the barycentric form is bounded by the triangle itself by construction, so it cannot overflow 410 // however small the determinant gets, and it does not depend on the length of the direction vector. 411 out[VP_R_HX] = v0x + (uq * e1x + vq * e2x) / RC_Q 412 out[VP_R_HY] = v0y + (uq * e1y + vq * e2y) / RC_Q 413 out[VP_R_HZ] = v0z + (uq * e1z + vq * e2z) / RC_Q 414 return VP_HIT 415} 416 417// Same test addressed by triangle number through an index buffer. ONE ruler: vp_pick and every caller go 418// through here, so the index-range refusal cannot be right in one place and missing in another. 419func vp_tri_hit_at(mesh: *i64, nverts: i64, idx: *i64, ntris: i64, tri: i64, ray: *i64, rbase: i64, out: *i64) -> i64 { 420 if ntris < 0 { return VP_E_ARGS } 421 if tri < 0 { return VP_E_TRI_IDX } 422 if tri >= ntris { return VP_E_TRI_IDX } 423 return vp_tri_hit(mesh, nverts, idx[tri * VP_TRI_I + VP_TI_A], idx[tri * VP_TRI_I + VP_TI_B], idx[tri * VP_TRI_I + VP_TI_C], ray, rbase, out) 424} 425 426// ===================================================================================================== 427// 3. NEAREST HIT over the whole indexed mesh. 428// ===================================================================================================== 429// Returns the triangle INDEX (>= 0) of the nearest intersection, or VP_MISS_NONE, or a named refusal. 430// A miss is NEVER 0: returning the first triangle when nothing was hit is the off-by-default failure this 431// whole routine exists to avoid, and it is invisible in a viewport because triangle 0 is a real triangle. 432// 433// TWO PASSES, ZERO ALLOCATION. Pass one uses the caller's own result buffer as its scratch and keeps only 434// the winning index and its ray parameter; pass two re-runs the SAME test on the winner so `out` ends up 435// describing the triangle actually returned. The alternative -- a private scratch buffer -- would mean a 436// syscall, and this library has to compile for wasm where there is none. 437func vp_pick(mesh: *i64, nverts: i64, idx: *i64, ntris: i64, ray: *i64, rbase: i64, out: *i64) -> i64 { 438 if nverts <= 0 { return VP_E_ARGS } 439 if ntris < 0 { return VP_E_ARGS } 440 if vp_dir_is_zero(ray, rbase) == 1 { return VP_E_ZERO_DIR } 441 if vp_ray_within_bounds(ray, rbase) == 0 { return VP_E_BOUND } 442 var best: i64 = VP_MISS_NONE 443 var best_t: i64 = 0 444 var seen: i64 = 0 445 var tri: i64 = 0 446 while tri < ntris { 447 let rc: i64 = vp_tri_hit_at(mesh, nverts, idx, ntris, tri, ray, rbase, out) 448 // A REFUSAL IS NOT A MISS. A corrupt index buffer or an out-of-range vertex must stop the pick and 449 // say so, not quietly reduce the number of candidates and return a confident wrong triangle. 450 if rc == VP_E_TRI_IDX { return VP_E_TRI_IDX } 451 if rc == VP_E_BOUND { return VP_E_BOUND } 452 if rc == VP_E_ZERO_DIR { return VP_E_ZERO_DIR } 453 if rc == VP_E_ARGS { return VP_E_ARGS } 454 if rc == VP_HIT { 455 var take: i64 = 0 456 if seen == 0 { take = 1 } 457 if out[VP_R_T] < best_t { take = 1 } 458 if take == 1 { 459 best = tri 460 best_t = out[VP_R_T] 461 seen = 1 462 } 463 } 464 tri = tri + 1 465 } 466 if best == VP_MISS_NONE { return VP_MISS_NONE } 467 vp_tri_hit_at(mesh, nverts, idx, ntris, best, ray, rbase, out) 468 return best 469} 470 471// Click -> triangle in one call. scratch needs VP_RAY_WORDS words (VP_SCRATCH_WORDS covers it). 472func vp_pick_screen(cam: *i64, px: i64, py: i64, mesh: *i64, nverts: i64, idx: *i64, ntris: i64, scratch: *i64, out: *i64) -> i64 { 473 let rc: i64 = vp_ray_from_screen(cam, px, py, scratch, VP_SC_RAY_A) 474 if rc != VP_OK { return rc } 475 return vp_pick(mesh, nverts, idx, ntris, scratch, VP_SC_RAY_A, out) 476} 477 478// ===================================================================================================== 479// 4. THE GIZMO: a screen drag -> an axis-constrained world translation. IT TOUCHES NO GEOMETRY. 480// ===================================================================================================== 481// Where a drag lands in the world is only defined once you say ON WHAT SURFACE. This uses the plane 482// through the gizmo pivot whose normal is the camera's forward direction -- the screen-parallel plane -- 483// which is what a direct-manipulation gizmo means by "follow the cursor": the manipulator stays under the 484// pointer at the depth of the thing being moved. The alternative (projecting onto the axis line in screen 485// space) is the same construction with a different denominator and behaves badly when the axis points at 486// the camera; this one degrades to a NAMED refusal there instead (VP_E_PLANE). 487// Writes 3 words at out[obase ..]: the world point where the ray through (px,py) meets that plane. 488func vp_plane_hit(cam: *i64, px: i64, py: i64, scratch: *i64, rbase: i64, out: *i64, obase: i64) -> i64 { 489 if vp_within(cam[VP_CAM_PX], VP_VERT_MAX) == 0 { return VP_E_BOUND } 490 if vp_within(cam[VP_CAM_PY], VP_VERT_MAX) == 0 { return VP_E_BOUND } 491 if vp_within(cam[VP_CAM_PZ], VP_VERT_MAX) == 0 { return VP_E_BOUND } 492 let rc: i64 = vp_ray_from_screen(cam, px, py, scratch, rbase) 493 if rc != VP_OK { return rc } 494 // Camera forward in model space is Ry(yaw)^T * (0,0,-1) = (sin yaw, 0, -cos yaw), Q14 and unit length. 495 let cc: i64 = rc_cos_q14(cam[VP_CAM_YAW]) 496 let ss: i64 = rc_sin_q14(cam[VP_CAM_YAW]) 497 let nx0: i64 = ss 498 let ny0: i64 = 0 499 let nz0: i64 = 0 - cc 500 let ox: i64 = scratch[rbase + VP_RAY_OX] 501 let oy: i64 = scratch[rbase + VP_RAY_OY] 502 let oz: i64 = scratch[rbase + VP_RAY_OZ] 503 let dx: i64 = scratch[rbase + VP_RAY_DX] 504 let dy: i64 = scratch[rbase + VP_RAY_DY] 505 let dz: i64 = scratch[rbase + VP_RAY_DZ] 506 let den: i64 = dx * nx0 + dy * ny0 + dz * nz0 507 if den == 0 { return VP_E_PLANE } 508 let num: i64 = (cam[VP_CAM_PX] - ox) * nx0 + (cam[VP_CAM_PY] - oy) * ny0 + (cam[VP_CAM_PZ] - oz) * nz0 509 let tq: i64 = (num * RC_Q) / den 510 if vp_within(tq, VP_RAYT_MAX) == 0 { return VP_E_BOUND } 511 let hx: i64 = ox + (tq * dx) / RC_Q 512 let hy: i64 = oy + (tq * dy) / RC_Q 513 let hz: i64 = oz + (tq * dz) / RC_Q 514 if vp_within(hx, VP_WORLD_MAX) == 0 { return VP_E_BOUND } 515 if vp_within(hy, VP_WORLD_MAX) == 0 { return VP_E_BOUND } 516 if vp_within(hz, VP_WORLD_MAX) == 0 { return VP_E_BOUND } 517 out[obase + VP_AXIS_X] = hx 518 out[obase + VP_AXIS_Y] = hy 519 out[obase + VP_AXIS_Z] = hz 520 return VP_OK 521} 522 523// The gizmo proper. NO MESH POINTER: this function is structurally incapable of moving a vertex. It hands 524// back a delta and the caller decides what to do with it -- and vp_apply_drag below decides to make it an 525// operation on the stack. 526func vp_gizmo_delta(cam: *i64, drag: *i64, axis: i64, scratch: *i64, out: *i64) -> i64 { 527 if vp_axis_ok(axis) == 0 { return VP_E_AXIS } 528 let ra: i64 = vp_plane_hit(cam, drag[VP_DRAG_AX], drag[VP_DRAG_AY], scratch, VP_SC_RAY_A, scratch, VP_SC_HIT_A) 529 if ra != VP_OK { return ra } 530 let rb: i64 = vp_plane_hit(cam, drag[VP_DRAG_BX], drag[VP_DRAG_BY], scratch, VP_SC_RAY_B, scratch, VP_SC_HIT_B) 531 if rb != VP_OK { return rb } 532 let wx: i64 = scratch[VP_SC_HIT_B + VP_AXIS_X] - scratch[VP_SC_HIT_A + VP_AXIS_X] 533 let wy: i64 = scratch[VP_SC_HIT_B + VP_AXIS_Y] - scratch[VP_SC_HIT_A + VP_AXIS_Y] 534 let wz: i64 = scratch[VP_SC_HIT_B + VP_AXIS_Z] - scratch[VP_SC_HIT_A + VP_AXIS_Z] 535 // The three axes are the unit basis vectors, so the dot product with the axis IS the component. Written 536 // as a selection rather than a dot product because a dot product here would only re-derive the same 537 // number through a multiply and a divide that can only lose bits. 538 var s: i64 = 0 539 if axis == VP_AXIS_X { s = wx } 540 if axis == VP_AXIS_Y { s = wy } 541 if axis == VP_AXIS_Z { s = wz } 542 out[VP_G_DX] = 0 543 out[VP_G_DY] = 0 544 out[VP_G_DZ] = 0 545 if axis == VP_AXIS_X { out[VP_G_DX] = s } 546 if axis == VP_AXIS_Y { out[VP_G_DY] = s } 547 if axis == VP_AXIS_Z { out[VP_G_DZ] = s } 548 out[VP_G_SCALAR] = s 549 return VP_OK 550} 551 552// ===================================================================================================== 553// 5. COMPOSING THE EDIT STACK -- the point of the rung. 554// ===================================================================================================== 555// THE CELL LAYOUT IS COMPONENT-MAJOR, AND THAT IS A DELIBERATE CHOICE WITH A REASON. An axis-constrained 556// drag over a selection of N vertices changes exactly ONE component of each, so under the estate's usual 557// interleaved (AoS) layout those cells are N separate non-adjacent cells and the drag would cost N ops. 558// Laid out component-major they are one contiguous run, so the same drag is a single ES_OP_RADD: one 559// gesture, one entry in the log, one undo. The PICK path still reads the estate's ordinary AoS vertex 560// buffer unchanged -- vp_seed_stack is the single place the two layouts meet. 561func vp_cell(axis: i64, vert: i64, nverts: i64) -> i64 { return axis * nverts + vert } 562func vp_cells_needed(nverts: i64) -> i64 { return VP_THREE * nverts } 563 564// Seed a stack's base state from an AoS mesh. The stack must have been created with exactly 565// vp_cells_needed(nverts) cells; a mismatch is refused rather than partially written. 566func vp_seed_stack(st: *i64, mesh: *i64, nverts: i64) -> i64 { 567 if nverts <= 0 { return VP_E_ARGS } 568 if es_ncells(st) != vp_cells_needed(nverts) { return VP_E_CELL } 569 var v: i64 = 0 570 while v < nverts { 571 var a: i64 = 0 572 while a < VP_THREE { 573 es_base_set(st, vp_cell(a, v, nverts), mesh[v * VP_THREE + a]) 574 a = a + 1 575 } 576 v = v + 1 577 } 578 return VP_OK 579} 580 581func vp_vert_component(st: *i64, vert: i64, axis: i64, nverts: i64) -> i64 { 582 return es_cell(st, vp_cell(axis, vert, nverts)) 583} 584 585// THE WHOLE RUNG IN ONE CALL: a drag on screen becomes ONE operation on the log. 586// A single-vertex selection records ES_OP_ADD; a multi-vertex selection records ES_OP_RADD. Either way it 587// is exactly one op, so undo after a drag restores exactly the state before it. 588// A ZERO-MAGNITUDE DRAG STILL RECORDS AN OP. That is not an oversight: the number of entries in the log 589// must equal the number of gestures the user made, or undo starts skipping clicks. The op is a genuine 590// no-op and the state digest is unchanged, which the gate asserts. 591func vp_apply_drag(st: *i64, cam: *i64, drag: *i64, axis: i64, first_vert: i64, count: i64, nverts: i64, scratch: *i64, out: *i64) -> i64 { 592 if nverts <= 0 { return VP_E_ARGS } 593 if count <= 0 { return VP_E_ARGS } 594 if first_vert < 0 { return VP_E_CELL } 595 if first_vert + count > nverts { return VP_E_CELL } 596 if es_ncells(st) != vp_cells_needed(nverts) { return VP_E_CELL } 597 let g: i64 = vp_gizmo_delta(cam, drag, axis, scratch, out) 598 // A refused gizmo pushes NOTHING. The stack must not carry an entry for a gesture that produced no 599 // answer, or a later replay would apply a delta nobody computed. 600 if g != VP_OK { return g } 601 let dq: i64 = out[VP_G_SCALAR] 602 let lo: i64 = vp_cell(axis, first_vert, nverts) 603 if count == 1 { 604 let r1: i64 = es_push(st, ES_OP_ADD, lo, dq, 0, 0) 605 if r1 != ES_OK { return r1 } 606 return VP_OK 607 } 608 let r2: i64 = es_push(st, ES_OP_RADD, lo, lo + count, dq, 0) 609 if r2 != ES_OK { return r2 } 610 return VP_OK 611} 612 613// ===================================================================================================== 614// 6. THE MEASUREMENT HALF OF THE RUNG: WHAT A PICK COSTS, AT A STATED TRIANGLE COUNT. 615// ===================================================================================================== 616// dcc.plan's own unit line says it: interaction claims are in milliseconds per operation AT A STATED 617// TRIANGLE COUNT, and a latency claim with no triangle count is opinion wearing a number. Everything above 618// proves the pick is CORRECT; nothing above says what it COSTS, so the viewport rung was only half met. 619// This section is the other half: a procedural mesh at a stated size, a timed region that runs a DECLARED 620// number of complete picks, and the arithmetic that turns an elapsed total into a per-operation cost, a 621// fraction of a frame, and a verdict on how that cost GROWS. 622// 623// THE CLOCK IS DELIBERATELY NOT IN HERE. Everything below is pure integer arithmetic over caller-supplied 624// buffers, exactly like the rest of this file, because the whole point of the library is that it compiles 625// for wasm where there is no sys_clock_now_us -- a browser consumer brackets vp_bench_run with its own 626// clock and gets a figure in the same units. Pulling a syscall in here would take the capability away from 627// the only consumer it was written for. The organ that owns the clock is the GATE, which is native. 628// 629// WHY A PROCEDURAL GRID AND NOT AN ASSET. A published figure has to be reproducible by whoever doubts it. 630// A grid is generated from ONE integer, needs no file, and its triangle count is a closed form the caller 631// checks against what the generator actually wrote -- so the N beside the microseconds is verified rather 632// than asserted. It is also the honest shape of the work: a pick tests EVERY triangle in the index buffer 633// and exactly one of them wins, which is what a click on a real mesh does. 634 635const VP_NS_PER_US: i64 = 1000 // nanoseconds in a microsecond: a unit conversion, never a tunable 636const VP_PERMIL: i64 = 1000 // parts per thousand 637 638// (g+1)^2 vertices and 2*g^2 triangles for a g-by-g cell grid. CLOSED FORMS, exported so a caller can size 639// its buffers and check the generator against arithmetic instead of trusting either one. 640func vp_bench_verts(g: i64) -> i64 { return (g + 1) * (g + 1) } 641func vp_bench_tris(g: i64) -> i64 { return VP_TWO * g * g } 642 643// Write a g-by-g triangulated grid spanning [-1,+1] in x and y on the z = 0 plane, Q14. `mesh` needs 644// VP_THREE * vp_bench_verts(g) words, `idx` needs VP_TRI_I * vp_bench_tris(g). Returns the number of 645// triangles actually written, or a named refusal. 646// THE EXTENT IS DERIVED, NOT PICKED: one RC_Q either side of the origin is the estate's own unit and the 647// same scale every other fixture in this pair uses, which puts every vertex 16x inside VP_VERT_MAX -- so a 648// bench mesh can never trip the overflow guard and turn a timing run into a refusal. 649// NO TRIANGLE HERE CAN BE DEGENERATE, and that is load-bearing rather than incidental: x depends only on 650// the column and y only on the row, and the step (2*RC_Q)/g is at least 1 for any g inside the guard below, 651// so no two grid lines can truncate onto each other. A degenerate triangle exits vp_tri_hit EARLY, which 652// would silently make the bench time a cheaper loop than the one it claims to be timing. 653func vp_bench_grid(mesh: *i64, idx: *i64, g: i64) -> i64 { 654 if g <= 0 { return VP_E_ARGS } 655 let span: i64 = VP_TWO * RC_Q 656 if g > span { return VP_E_ARGS } 657 var j: i64 = 0 658 while j <= g { 659 var i: i64 = 0 660 while i <= g { 661 let v: i64 = j * (g + 1) + i 662 mesh[v * VP_THREE + VP_AXIS_X] = (span * i) / g - RC_Q 663 mesh[v * VP_THREE + VP_AXIS_Y] = (span * j) / g - RC_Q 664 mesh[v * VP_THREE + VP_AXIS_Z] = 0 665 i = i + 1 666 } 667 j = j + 1 668 } 669 var t: i64 = 0 670 var cj: i64 = 0 671 while cj < g { 672 var ci: i64 = 0 673 while ci < g { 674 let a: i64 = cj * (g + 1) + ci 675 let b: i64 = a + 1 676 let c: i64 = a + g + 1 677 let d: i64 = c + 1 678 idx[t * VP_TRI_I + VP_TI_A] = a 679 idx[t * VP_TRI_I + VP_TI_B] = b 680 idx[t * VP_TRI_I + VP_TI_C] = d 681 t = t + 1 682 idx[t * VP_TRI_I + VP_TI_A] = a 683 idx[t * VP_TRI_I + VP_TI_B] = d 684 idx[t * VP_TRI_I + VP_TI_C] = c 685 t = t + 1 686 ci = ci + 1 687 } 688 cj = cj + 1 689 } 690 return t 691} 692 693// THE TIMED REGION, and nothing else is in it. Runs `reps` COMPLETE picks -- ray construction from a screen 694// point plus Moller-Trumbore over the whole index buffer -- and returns the accumulated triangle indices. 695// THE ACCUMULATOR IS NOT DECORATION. A loop whose result nobody reads is a loop a compiler may delete, and 696// a deleted loop times as free -- the measurement would then be of an empty loop and would look like a very 697// fast picker. The caller checks this sum against reps times what one pick returns, which a deleted loop 698// cannot produce. ONE IS ADDED TO EACH INDEX because triangle 0 is a legitimate winner and a sum of zeros 699// is exactly what a deleted loop also produces. 700// A REFUSAL OR A MISS STOPS THE RUN AND IS RETURNED UNCHANGED, never averaged into a cost: a bench that 701// timed a picker which was refusing on every repetition would publish the cost of the guard, not the pick. 702func vp_bench_run(cam: *i64, px: i64, py: i64, mesh: *i64, nverts: i64, idx: *i64, ntris: i64, scratch: *i64, out: *i64, reps: i64) -> i64 { 703 if reps <= 0 { return VP_E_ARGS } 704 var acc: i64 = 0 705 var r: i64 = 0 706 while r < reps { 707 let p: i64 = vp_pick_screen(cam, px, py, mesh, nverts, idx, ntris, scratch, out) 708 if p < 0 { return p } 709 acc = acc + p + 1 710 r = r + 1 711 } 712 return acc 713} 714 715// DIVIDE ONCE, AT THE END, AND IN NANOSECONDS. Microseconds per operation would truncate any pick faster 716// than a microsecond to zero, and a cost reported as 0 reads as free, which is never true. The division 717// happens exactly once per measurement, on the total, so no per-repetition truncation can accumulate. 718func vp_ns_per_op(elapsed_us: i64, reps: i64) -> i64 { 719 if reps <= 0 { return VP_E_ARGS } 720 if elapsed_us < 0 { return VP_E_ARGS } 721 return (elapsed_us * VP_NS_PER_US) / reps 722} 723 724// PICOSECONDS PER TRIANGLE: the per-operation cost over the triangle count it was measured at. This is the 725// quantity the growth test compares, and it is SCALED by VP_NS_PER_US rather than divided straight so that 726// a cost of tens of nanoseconds per triangle keeps its significant digits through an integer divide instead 727// of collapsing onto a handful of whole numbers. 728func vp_cost_per_tri(ns_per_op: i64, ntris: i64) -> i64 { 729 if ntris <= 0 { return VP_E_ARGS } 730 if ns_per_op < 0 { return VP_E_ARGS } 731 return (ns_per_op * VP_NS_PER_US) / ntris 732} 733 734// WHAT FRACTION OF ONE FRAME A PICK COSTS, in parts per thousand. The two thousands cancel exactly -- 735// (ns / 1000) microseconds over frame_us microseconds, times 1000 -- so this is ONE division and not two, 736// and there is no intermediate left to truncate. 737func vp_frame_permil(ns_per_op: i64, frame_us: i64) -> i64 { 738 if frame_us <= 0 { return VP_E_ARGS } 739 if ns_per_op < 0 { return VP_E_ARGS } 740 return ns_per_op / frame_us 741} 742 743// The same fraction in parts per million, because a pick cheap enough to be worth having is also cheap 744// enough that per-thousand truncates it to zero -- and a fraction printed as 0 for want of a finer unit 745// reads as free. Reported BESIDE the per-mille figure, never instead of it. 746func vp_frame_ppm(ns_per_op: i64, frame_us: i64) -> i64 { 747 if frame_us <= 0 { return VP_E_ARGS } 748 if ns_per_op < 0 { return VP_E_ARGS } 749 return (ns_per_op * VP_PERMIL) / frame_us 750} 751 752// IS THE COST GROWING NO WORSE THAN LINEARLY IN TRIANGLE COUNT? 753// A single triangle count cannot answer this, and an accidentally quadratic picker -- one that rebuilt the 754// ray per triangle, or rescanned the index buffer per candidate -- is exactly the defect one N hides, 755// because at a single size a quadratic curve and a linear one are the same number. 756// 757// THE BAR IS DERIVED FROM THE TWO HYPOTHESES, NOT PICKED, so there is no tolerance constant here to tune. 758// Let R = n_hi/n_lo. Measured in PICOSECONDS PER TRIANGLE a LINEAR picker costs the same at both sizes 759// (k_hi = k_lo), and a QUADRATIC one costs R times more per triangle at the larger size (k_hi = R*k_lo). 760// The value equally far from those two on a log scale is their geometric mean, k_lo*sqrt(R) -- the 761// maximum-margin separator between the two hypotheses actually being distinguished. Squaring both sides 762// removes the fractional power and keeps the whole test in integers, and it is CROSS-MULTIPLIED so no 763// division can truncate a failure into a pass: 764// k_hi <= k_lo * sqrt(n_hi/n_lo) is k_hi*k_hi*n_lo <= k_lo*k_lo*n_hi 765// THE IMPRECISION I CHOSE TO LIVE WITH, stated rather than left to be discovered: this is a ONE-SIDED test 766// and it is deliberately generous -- across a 64x range sqrt(R) is 8, so the per-triangle cost may grow 767// eightfold before it fires. That is the price of a bar with no tuned constant in it, and it is why 768// vp_growth_permil exists: the caller PRINTS the measured ratio beside the verdict so a reader sees the 769// real figure rather than only that it passed. 770// Returns 1 = linear or better, 0 = worse than linear, VP_E_ARGS for a pair that cannot be compared. 771func vp_growth_ok(n_lo: i64, k_lo: i64, n_hi: i64, k_hi: i64) -> i64 { 772 if n_lo <= 0 { return VP_E_ARGS } 773 if n_hi <= n_lo { return VP_E_ARGS } 774 if k_lo <= 0 { return VP_E_ARGS } 775 if k_hi < 0 { return VP_E_ARGS } 776 if k_hi * k_hi * n_lo <= k_lo * k_lo * n_hi { return 1 } 777 return 0 778} 779 780// The MEASURED growth in per-triangle cost between two sizes, in parts per thousand. VP_PERMIL means the 781// cost per triangle did not change at all (perfectly linear); the size ratio times VP_PERMIL means it grew 782// quadratically. Published beside every growth verdict, because a verdict without its number cannot be 783// re-judged by anyone who disagrees with the bar. 784func vp_growth_permil(k_lo: i64, k_hi: i64) -> i64 { 785 if k_lo <= 0 { return VP_E_ARGS } 786 if k_hi < 0 { return VP_E_ARGS } 787 return (k_hi * VP_PERMIL) / k_lo 788}