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}