nx_stl.nx source
↩ module page · 246 lines · 9260 B
1// nx_stl.nx -- binary STL mesh reader (1980s 3D Systems format,
2// public domain).
3//
4// Per cardinal NISHI_3D_PRINT_ROADMAP scope: STL spec is public
5// domain since 1980s; reading it is not a license entanglement
6// (Cardinal 0). Composes nx_le + nx_fp32_q14 + nx_hash + nx_mesh.
7//
8// Binary STL on-disk layout:
9// bytes 0..79 header (ignored; ASCII STL detection: starts
10// with "solid"; we refuse ASCII for P0.2a)
11// bytes 80..83 little-endian uint32 = n_tris
12// bytes 84.. per-triangle records, 50 bytes each:
13// 0..11 normal vector (3 × f32) IGNORED -- we trust mesh
14// winding, normals are often
15// wrong in user-exported STL
16// 12..47 3 vertices × 3 × f32 = 9 floats
17// 48..49 attribute byte count (IGNORED)
18//
19// Total file size = 84 + 50 * n_tris.
20//
21// Vertex dedup strategy:
22// Raw STL has 3 verts per tri with massive duplication (closed
23// mesh of n tris has ~n/2 unique vertices, ~50% saving). We
24// hash each (x_q14, y_q14, z_q14) triple via nx_hash_fnv1a_bytes
25// into nx_hash; collisions are resolved by direct (x,y,z)
26// verification against the existing mesh vertex. False matches
27// from hash collisions allocate a new vertex (slight over-alloc;
28// correctness preserved).
29//
30// Capacity heuristic:
31// Worst case (no dedup): 3 * n_tris vertices. We size nx_hash
32// to the next power of 2 above n_tris * 4 to keep load factor
33// < 0.75.
34//
35// Manifold-check expectation: a well-formed closed STL produces a
36// mesh that nx_mesh_is_manifold accepts. Real-world STL from
37// scanners (Christus from Scan The World) sometimes has minor
38// non-manifold artefacts (T-junctions, near-duplicate verts at
39// quantization boundaries); those are the slicer's problem, not
40// the parser's.
41//
42// license_tier: ORIGINAL
43
44import "nx_syscalls.nx"
45import "nx_le.nx"
46import "nx_hash.nx"
47import "nx_mesh.nx"
48import "nx_fp32_q14.nx"
49
50// ===== verdicts ===================================================
51
52const NX_STL_OK: i64 = 0
53const NX_STL_ERR_TOO_SHORT: i64 = 1
54const NX_STL_ERR_TRUNCATED: i64 = 2
55const NX_STL_ERR_BAD_FLOAT: i64 = 3 // Inf/NaN coord
56const NX_STL_ERR_ASCII_REFUSED: i64 = 4 // ASCII STL parser deferred
57const NX_STL_ERR_ALLOC_FAILED: i64 = 5
58const NX_STL_ERR_DEDUP_OVERFLOW: i64 = 6
59// Four-pillar fix (2026-05-20) for the empty-STL ALLOC_FAILED bug
60// surfaced by Phase A1 adversarial test. Legitimately-empty STL
61// (header + n_tris=0) now returns NX_STL_ERR_EMPTY -- distinguishable
62// from real OOM (ALLOC_FAILED) so caller can act on it.
63const NX_STL_ERR_EMPTY: i64 = 7
64
65// ===== parsed result =============================================
66
67struct NxStlResult {
68 mesh: *NxMesh,
69 verdict: i64,
70 n_tris_header: i64, // claim from STL header
71 n_verts_unique: i64, // after dedup
72 bad_tri_idx: i64, // first triangle with bad float, or -1
73}
74
75const NX_STL_RES_BYTES: i64 = 40
76
77// ===== ASCII detection ===========================================
78
79func nx_stl_is_ascii(buf: *u8, buf_len: i64) -> i64 {
80 if buf_len < 5 { return 0 }
81 if buf[0] != 115 { return 0 } // 's'
82 if buf[1] != 111 { return 0 } // 'o'
83 if buf[2] != 108 { return 0 } // 'l'
84 if buf[3] != 105 { return 0 } // 'i'
85 if buf[4] != 100 { return 0 } // 'd'
86 return 1
87}
88
89// ===== dedup key ===================================================
90//
91// Packs (x_q14, y_q14, z_q14) i64 triple into 24 bytes for FNV-1a
92// hashing. The hash table maps the FNV-1a digest (i64) to the
93// vertex index already inserted into the mesh. On hit, the caller
94// verifies the candidate vertex's (x,y,z) actually matches before
95// reusing -- defends against the ~1-in-4-billion hash collision.
96
97func nx_stl_pack_xyz(x: i64, y: i64, z: i64, scratch: *u8) -> i64 {
98 nx_le_write_u64(scratch, 0, x)
99 nx_le_write_u64(scratch, 8, y)
100 nx_le_write_u64(scratch, 16, z)
101 return 0
102}
103
104// Resolve a vertex via hash dedup. Returns the (possibly newly
105// inserted) vertex index in `m`, or -1 on hash table overflow.
106func nx_stl_intern_vertex(m: *NxMesh, dedup: *NxHash, scratch: *u8,
107 x: i64, y: i64, z: i64,
108 next_vert_idx: *i64) -> i64 {
109 nx_stl_pack_xyz(x, y, z, scratch)
110 let key: i64 = nx_hash_fnv1a_bytes(scratch, 24)
111
112 if nx_hash_has(dedup, key) == 1 {
113 let cand: i64 = nx_hash_get(dedup, key)
114 // Verify: hash collision check against actual coords.
115 if nx_mesh_get_vertex_x(m, cand) == x {
116 if nx_mesh_get_vertex_y(m, cand) == y {
117 if nx_mesh_get_vertex_z(m, cand) == z { return cand }
118 }
119 }
120 // Collision -- fall through and allocate a new vertex; the
121 // hash key still maps to the original, leaving the collider
122 // un-dedup'd. Acceptable rate (~5e-6 over 200K tris).
123 }
124
125 let vidx: i64 = next_vert_idx[0]
126 if vidx >= m.n_verts { return -1 } // capacity hit
127 nx_mesh_set_vertex(m, vidx, x, y, z, 0)
128 next_vert_idx[0] = vidx + 1
129 if nx_hash_put(dedup, key, vidx) != 0 { return -1 }
130 return vidx
131}
132
133// ===== next-power-of-2 helper ====================================
134
135func nx_stl_next_pow2(n: i64) -> i64 {
136 var p: i64 = 1
137 while p < n { p = p * 2 }
138 return p
139}
140
141// ===== main entry =================================================
142//
143// Parse binary STL from buf[0..buf_len]. Returns *NxStlResult
144// (always non-null); inspect .verdict for status. On OK, .mesh
145// is the loaded mesh with n_tris_header triangles and dedup'd
146// vertex count in n_verts_unique.
147//
148// Capacity policy: allocates 3*n_tris vertex slots as the worst-
149// case ceiling; real dedup'd count is typically n_tris/2. The
150// unused slots stay zero-init'd -- minor memory waste, but lets
151// us avoid two-pass parsing (count-then-load).
152
153func nx_stl_load_binary(buf: *u8, buf_len: i64) -> *NxStlResult {
154 let r: *NxStlResult = (sys_mmap(NX_STL_RES_BYTES)) as *NxStlResult
155 r.mesh = 0 as *NxMesh
156 r.verdict = NX_STL_OK
157 r.n_tris_header = 0
158 r.n_verts_unique = 0
159 r.bad_tri_idx = -1
160
161 if buf_len < 84 {
162 r.verdict = NX_STL_ERR_TOO_SHORT
163 return r
164 }
165 if nx_stl_is_ascii(buf, buf_len) == 1 {
166 r.verdict = NX_STL_ERR_ASCII_REFUSED
167 return r
168 }
169
170 let n_tris: i64 = nx_le_read_u32(buf, 80)
171 r.n_tris_header = n_tris
172 if buf_len < 84 + 50 * n_tris {
173 r.verdict = NX_STL_ERR_TRUNCATED
174 return r
175 }
176
177 // Four-pillar PREVENT pillar (2026-05-20): emit a distinct
178 // verdict for legitimately-empty STLs instead of ALLOC_FAILED.
179 // Caller can disambiguate "no triangles" from real OOM.
180 if n_tris <= 0 {
181 r.verdict = NX_STL_ERR_EMPTY
182 return r
183 }
184
185 let max_verts: i64 = n_tris * 3
186 let m: *NxMesh = nx_mesh_alloc(max_verts, n_tris, 0)
187 if (m as i64) == 0 {
188 r.verdict = NX_STL_ERR_ALLOC_FAILED
189 return r
190 }
191
192 let hash_cap: i64 = nx_stl_next_pow2(max_verts * 2)
193 let dedup: *NxHash = nx_hash_new(hash_cap)
194 let scratch: *u8 = sys_mmap(24)
195 let next_v_p: *i64 = (sys_mmap(8)) as *i64
196 next_v_p[0] = 0
197
198 var ti: i64 = 0
199 while ti < n_tris {
200 let tri_off: i64 = 84 + ti * 50
201 let v_block_off: i64 = tri_off + 12 // skip 12-byte normal
202
203 // Convert 9 floats (3 verts × 3 components) in one shot.
204 let q14_buf_p: *i64 = (sys_mmap(9 * 8)) as *i64
205 let n_conv: i64 = nx_fp32_bytes_to_q14(buf, v_block_off, 9, q14_buf_p)
206 if n_conv != 9 {
207 r.verdict = NX_STL_ERR_BAD_FLOAT
208 r.bad_tri_idx = ti
209 r.mesh = m
210 return r
211 }
212
213 // Intern + record vertex indices for this triangle.
214 let i0: i64 = nx_stl_intern_vertex(m, dedup, scratch,
215 q14_buf_p[0], q14_buf_p[1], q14_buf_p[2],
216 next_v_p)
217 let i1: i64 = nx_stl_intern_vertex(m, dedup, scratch,
218 q14_buf_p[3], q14_buf_p[4], q14_buf_p[5],
219 next_v_p)
220 let i2: i64 = nx_stl_intern_vertex(m, dedup, scratch,
221 q14_buf_p[6], q14_buf_p[7], q14_buf_p[8],
222 next_v_p)
223 if i0 < 0 {
224 r.verdict = NX_STL_ERR_DEDUP_OVERFLOW
225 r.bad_tri_idx = ti
226 r.mesh = m
227 return r
228 }
229 if i1 < 0 { r.verdict = NX_STL_ERR_DEDUP_OVERFLOW; r.bad_tri_idx = ti; r.mesh = m; return r }
230 if i2 < 0 { r.verdict = NX_STL_ERR_DEDUP_OVERFLOW; r.bad_tri_idx = ti; r.mesh = m; return r }
231
232 nx_mesh_set_triangle(m, ti, i0, i1, i2)
233 ti = ti + 1
234 }
235
236 // Trim m.n_verts to actual unique count. The original
237 // nx_mesh_alloc set n_verts = max_verts; we update to reflect
238 // dedup. Downstream consumers (slicer, BVH) iterate
239 // [0, n_verts), so unused tail slots are invisible.
240 m.n_verts = next_v_p[0]
241
242 r.mesh = m
243 r.n_verts_unique = next_v_p[0]
244 r.verdict = NX_STL_OK
245 return r
246}