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}