code wiki / (root) / nx_stl.nx

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}