code wiki / (root) / nx_vnormals_lib.nx

nx_vnormals_lib.nx source

↩ module page · 121 lines · 7558 B

1// nx_vnormals_lib.nx -- ONE OWNER for AREA-WEIGHTED smooth per-vertex normals over an AoS vertex buffer 2// and a flat triangle-index buffer. Retires the copy in nx_gltf_export.gl_vnormals and the copy that was 3// INLINE inside nx_obj_export.write_obj. 4// 5// WHY THIS FILE EXISTS AND WHY IT IS NOT THE OBVIOUS MERGE. The estate had THREE per-vertex-normal 6// computations. Two of them are the same computation and are retired here. The THIRD IS NOT THE SAME 7// COMPUTATION AND IS DELIBERATELY LEFT WHERE IT IS -- merging it would have silently changed shipped 8// geometry, which is how this estate once deleted live capability by consolidating things that merely 9// looked alike: 10// 11// sc_vertex_normals (nx_shellclose.nx) NORMALISES EACH FACE NORMAL TO UNIT LENGTH BEFORE ACCUMULATING 12// it -- `nrm[vi] = nrm[vi] + (nx * SC_Q) / nl` -- so every incident face contributes EQUALLY regardless 13// of its area. That is UNIFORM (equal-weight) smoothing. 14// 15// The two copies retired here accumulate the RAW cross product, whose magnitude is twice the triangle's 16// area, so a large face dominates a small one. That is AREA-WEIGHTED smoothing. 17// 18// They agree only on a mesh whose incident triangles all have equal area, and they diverge without bound 19// otherwise. nx_vnormals_gate MEASURES that divergence against the real shipped sc_vertex_normals rather 20// than asserting it: on two triangles meeting at a vertex with a 100:1 area ratio, area weighting yields a 21// normal 99.5 percent of the way toward the large face while uniform weighting sits at exactly 45 degrees 22// between them, and the gate proves the two results are not even PARALLEL. Three further differences are 23// recorded for the same reason -- a future reader will be tempted by this merge again: 24// * output scale 1000 here versus SC_Q = 256 there, 25// * a vertex with no usable incident face becomes +Y up here and stays the ZERO vector there, 26// * sc_isqrt is a fixed 24-iteration Newton with its seed clamped to 65536, which for a non-perfect 27// square can settle on either side of the true root, where vm_isqrt iterates to convergence and is an 28// exact floor. Two rulers, not one. 29// 30// THE OUTPUT SCALE IS NOT A CHOICE MADE HERE. VN_SCALE is 1000 because that is the scale BOTH retired 31// copies already emitted and both of their consumers already expect. Changing it here would be a silent 32// format change to two shipped exporters, so it is named as inherited rather than picked. 33// 34// CORRECTION 2026-08-25 -- THE HALF OF THAT SENTENCE THAT WAS A COMMENT, NOT A MEASUREMENT. This header 35// used to justify the scale with "glTF renderers re-normalise after the node's inverse-transpose". That is 36// FALSE as a statement about the FORMAT: glTF 2.0 requires the NORMAL accessor to carry unit vectors, and 37// KhronosGroup/glTF-Validator ranks a non-unit one an Error, not a warning -- 33,656 of them across the 12 38// .glb files nx_gltf_export had published. The OBJ half of the sentence stands (importers do rescale), and 39// THAT is why the scale is still right here and the defect was never in this function: it was at the glTF 40// write site, and it is fixed there, in nx_glbnorm_lib.gn_unit3. This file is deliberately unchanged in 41// behaviour -- a comment-only edit, so its artifact rebuilds byte-identical and its gate stays 18/18. 42// 43// 100 percent sovereign. No hardware writes (Rule 26). license_tier: ORIGINAL 44import "nx_vecmath.nx" 45 46// INHERITED, NOT CHOSEN: the scale both retired copies already emitted. See the header. 47const VN_SCALE: i64 = 1000 48const VN_V3: i64 = 3 // components per vertex in the estate's AoS vertex buffers 49const VN_TRI_I: i64 = 3 // vertex indices per triangle in the estate's flat index buffers 50const VN_X: i64 = 0 51const VN_Y: i64 = 1 52const VN_Z: i64 = 2 53 54// A vertex touched by no usable face has NO defined normal. Both retired copies answered +Y up rather than 55// leaving a zero vector, because a zero normal makes a renderer produce a black or NaN-shaded fragment. 56// That answer is inherited unchanged, and it is named here so it reads as a decision rather than an 57// accident: it is a FALLBACK, and a caller that needs to know it fired should compare against this value. 58const VN_FALLBACK_X: i64 = 0 59const VN_FALLBACK_Y: i64 = 1000 // == VN_SCALE, and deliberately spelled out beside its siblings 60const VN_FALLBACK_Z: i64 = 0 61 62// The length below which a summed normal is treated as absent. INHERITED: both retired copies used `l < 1`, 63// i.e. an integer length that floored to zero. Named rather than left as a bare 1 in the comparison. 64const VN_MIN_LEN: i64 = 1 65 66const VN_OK: i64 = 0 67const VN_E_ARGS: i64 = 0 - 48 // clear of nx_editstack_lib (-1..-10), nx_vpick_lib (-16..-26) 68 // and nx_editor_canvas (-33..-37), so a propagated code still names 69 // which layer refused. 70 71func vn_code_name(c: i64) -> *u8 { 72 if c == VN_OK { return "OK" as *u8 } 73 if c == VN_E_ARGS { return "REFUSED-BAD-ARGUMENTS" as *u8 } 74 return "REFUSED-UNCLASSIFIED" as *u8 75} 76 77// AREA-WEIGHTED smooth per-vertex normals into nrm[nv*3], scaled to VN_SCALE. 78// 79// The accumulation is the RAW cross product of the two triangle edges. Its magnitude is twice the 80// triangle's area, so this is area weighting with no extra multiply -- the weighting is a property of NOT 81// normalising, which is exactly the line sc_vertex_normals does differently and the reason these two 82// functions are not interchangeable. 83// 84// nv <= 0 or nf < 0 REFUSES rather than looping on a nonsense bound. nf == 0 is legal and is NOT an error: 85// a point cloud has no faces, and every vertex correctly comes back as the named fallback. 86func vn_smooth_area(vbuf: *i64, fbuf: *i64, nv: i64, nf: i64, nrm: *i64) -> i64 { 87 if nv <= 0 { return VN_E_ARGS } 88 if nf < 0 { return VN_E_ARGS } 89 var i: i64 = 0 90 while i < nv * VN_V3 { nrm[i] = 0; i = i + 1 } 91 var t: i64 = 0 92 while t < nf { 93 let a: i64 = fbuf[t*VN_TRI_I] 94 let b: i64 = fbuf[t*VN_TRI_I+1] 95 let c: i64 = fbuf[t*VN_TRI_I+2] 96 let e1x: i64 = vbuf[b*VN_V3+VN_X]-vbuf[a*VN_V3+VN_X] 97 let e1y: i64 = vbuf[b*VN_V3+VN_Y]-vbuf[a*VN_V3+VN_Y] 98 let e1z: i64 = vbuf[b*VN_V3+VN_Z]-vbuf[a*VN_V3+VN_Z] 99 let e2x: i64 = vbuf[c*VN_V3+VN_X]-vbuf[a*VN_V3+VN_X] 100 let e2y: i64 = vbuf[c*VN_V3+VN_Y]-vbuf[a*VN_V3+VN_Y] 101 let e2z: i64 = vbuf[c*VN_V3+VN_Z]-vbuf[a*VN_V3+VN_Z] 102 let fnx: i64 = e1y*e2z - e1z*e2y 103 let fny: i64 = e1z*e2x - e1x*e2z 104 let fnz: i64 = e1x*e2y - e1y*e2x 105 nrm[a*VN_V3+VN_X]=nrm[a*VN_V3+VN_X]+fnx; nrm[a*VN_V3+VN_Y]=nrm[a*VN_V3+VN_Y]+fny; nrm[a*VN_V3+VN_Z]=nrm[a*VN_V3+VN_Z]+fnz 106 nrm[b*VN_V3+VN_X]=nrm[b*VN_V3+VN_X]+fnx; nrm[b*VN_V3+VN_Y]=nrm[b*VN_V3+VN_Y]+fny; nrm[b*VN_V3+VN_Z]=nrm[b*VN_V3+VN_Z]+fnz 107 nrm[c*VN_V3+VN_X]=nrm[c*VN_V3+VN_X]+fnx; nrm[c*VN_V3+VN_Y]=nrm[c*VN_V3+VN_Y]+fny; nrm[c*VN_V3+VN_Z]=nrm[c*VN_V3+VN_Z]+fnz 108 t = t + 1 109 } 110 i = 0 111 while i < nv { 112 let l: i64 = vm_isqrt(nrm[i*VN_V3+VN_X]*nrm[i*VN_V3+VN_X] + nrm[i*VN_V3+VN_Y]*nrm[i*VN_V3+VN_Y] + nrm[i*VN_V3+VN_Z]*nrm[i*VN_V3+VN_Z]) 113 if l < VN_MIN_LEN { 114 nrm[i*VN_V3+VN_X]=VN_FALLBACK_X; nrm[i*VN_V3+VN_Y]=VN_FALLBACK_Y; nrm[i*VN_V3+VN_Z]=VN_FALLBACK_Z 115 } else { 116 nrm[i*VN_V3+VN_X]=nrm[i*VN_V3+VN_X]*VN_SCALE/l; nrm[i*VN_V3+VN_Y]=nrm[i*VN_V3+VN_Y]*VN_SCALE/l; nrm[i*VN_V3+VN_Z]=nrm[i*VN_V3+VN_Z]*VN_SCALE/l 117 } 118 i = i + 1 119 } 120 return VN_OK 121}