stl: review fixes -- bytes after the last triangle still read as binary, keywords any case, solid_name headers

Some exporters pad binary STLs; three.js reads them, so the command line must
too. Tests pin padded binary (with and without a 'solid' header) and ASCII with
CRLF, exponents, upper case, a BOM and a solid_name header.
This commit is contained in:
matthewtrahan authored and Matthew Trahan committed 2026-09-30 14:33:13 -05:00
1 parent 4210035e54
commit dcce14a185
3 files changed
+34 -7

No files matched your search

+3 -1
View File
@@ -130,7 +130,9 @@ that aren't fin geometry, because the file format is equally part of the product
use it; the site parses with three.js's `STLLoader`):
- our own binary export **reads back** as the same triangles, and ASCII does too;
- binary is decided by **size**, not by a leading `solid`: CAD exporters (SolidWorks)
start binary headers with it, and reading one as text opens an empty part;
start binary headers with it, and reading one as text opens an empty part; bytes
left after the last triangle still read as binary, as three.js reads them;
- ASCII with CRLF, exponents, upper-case keywords and a BOM reads;
- a **truncated, empty or non-STL** file throws instead of opening blank.
**`step.test.js`** -- STEP import through the real vendored OpenCascade WASM (the
+20
View File
@@ -48,12 +48,32 @@ Deno.test('stl: a binary file whose header starts with "solid" is still binary',
assert(got.length === CUBE.length, `read ${got.length / 9} faces as ASCII instead of binary`);
});
Deno.test('stl: extra bytes after the last triangle still read as binary (three.js reads these)', async () => {
for (const name of ['part', 'solid part']) {
const bin = await bytesOf(triples(CUBE), name);
const padded = new Uint8Array(bin.length + 2);
padded.set(bin);
const got = readSTL(padded);
assert(got.length === CUBE.length, `header "${name}": read ${got.length / 9} faces`);
}
});
Deno.test('stl: ASCII reads the same triangles', () => {
const got = readSTL(ascii(CUBE));
assert(got.length === CUBE.length, `length ${got.length} vs ${CUBE.length}`);
for (let i = 0; i < CUBE.length; i++) assert(got[i] === CUBE[i], `value ${i}`);
});
Deno.test('stl: ASCII with CRLF, exponents, upper case, a BOM and solid_name', () => {
const text = new TextDecoder().decode(ascii(CUBE))
.replace('solid cube', '\uFEFFSOLID_cube').replaceAll('vertex', 'VERTEX')
.replace(/VERTEX (\S+)/g, (_, x) => `VERTEX ${Number(x).toExponential()}`)
.replaceAll('\n', '\r\n');
const got = readSTL(new TextEncoder().encode(text));
assert(got.length === CUBE.length, `length ${got.length} vs ${CUBE.length}`);
for (let i = 0; i < CUBE.length; i++) assert(got[i] === CUBE[i], `value ${i}`);
});
Deno.test('stl: truncated, empty and non-STL files are refused', async () => {
const bin = await bytesOf(triples(CUBE), 'cube');
throws(() => readSTL(bin.subarray(0, bin.length - 7)), /not an STL/, 'truncated binary');
+11 -6
View File
@@ -69,16 +69,22 @@ export function writeBinarySTL(tris, name = 'Support Fins') {
*
* Binary is decided by SIZE, not by the "solid" prefix: plenty of CAD exporters
* (SolidWorks among them) start a binary file's header with "solid", and reading
* one as text gives an empty part instead of an error.
* one as text gives an empty part instead of an error. Some exporters also leave
* bytes after the last triangle; three.js reads those files, so we do too: a file
* at least as long as its triangle count says is binary unless it reads as ASCII
* (a "solid" line and a "facet" soon after).
*
* @param bytes Uint8Array of the whole file
* @returns Float32Array
*/
export function readSTL(bytes) {
const head = new TextDecoder().decode(bytes.subarray(0, Math.min(bytes.length, 1024)));
const looksAscii = /^\uFEFF?\s*solid/i.test(head) && /\bfacet\b/i.test(head);
if (bytes.length >= HEADER + 4) {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const n = dv.getUint32(HEADER, true);
if (bytes.length === HEADER + 4 + n * PER_TRI) {
const need = HEADER + 4 + n * PER_TRI;
if (bytes.length === need || (n > 0 && bytes.length > need && !looksAscii)) {
if (n === 0) throw new Error('STL has no triangles');
const pos = new Float32Array(n * 9);
for (let f = 0; f < n; f++) {
@@ -88,16 +94,15 @@ export function readSTL(bytes) {
return pos;
}
}
const text = new TextDecoder().decode(bytes.subarray(0, Math.min(bytes.length, 1024)));
if (!/^\s*solid\b/.test(text)) {
throw new Error('not an STL: wrong size for binary and no "solid" line for ASCII (truncated file?)');
if (!/^\uFEFF?\s*solid/i.test(head)) {
throw new Error('not an STL: too short for binary and no "solid" line for ASCII (truncated file?)');
}
return readAsciiSTL(new TextDecoder().decode(bytes));
}
function readAsciiSTL(text) {
const out = [];
const re = /\bvertex\s+(\S+)\s+(\S+)\s+(\S+)/g;
const re = /\bvertex\s+(\S+)\s+(\S+)\s+(\S+)/gi;
for (let m; (m = re.exec(text));) {
for (let k = 1; k <= 3; k++) {
const v = Number(m[k]);