src/parsers/parseSTLIntoXKTModel.js
import {faceToVertexNormals} from "../lib/faceToVertexNormals.js";
import {math} from "../lib/math.js";
/**
* @desc Parses STL file data into an {@link XKTModel}.
*
* * Supports binary and ASCII STL formats.
* * Option to create a separate {@link XKTEntity} for each group of faces that share the same vertex colors.
* * Option to smooth face-aligned normals loaded from STL.
* * Option to reduce XKT file size by ignoring STL normals and relying on xeokit to auto-generate them.
*
* ## Usage
*
* In the example below we'll create an {@link XKTModel}, then load an STL model into it.
*
* ````javascript
* utils.loadArraybuffer("./models/stl/binary/spurGear.stl", async (data) => {
*
* const xktModel = new XKTModel();
*
* parseSTLIntoXKTModel({data, xktModel});
*
* xktModel.finalize();
* });
* ````
*
* @param {Object} params Parsing params.
* @param {ArrayBuffer|String} [params.data] STL file data. Can be binary or string.
* @param {Boolean} [params.autoNormals=false] When true, the parser will ignore the STL geometry normals, and the STL
* data will rely on the xeokit ````Viewer```` to automatically generate them. This has the limitation that the
* normals will be face-aligned, and therefore the ````Viewer```` will only be able to render a flat-shaded representation
* of the STL.
* Overrides ````smoothNormals```` when ````true````. This ignores the normals in the STL, and loads no
* normals from the STL into the {@link XKTModel}, resulting in the XKT file storing no normals for the STL model. The
* xeokit-sdk will then automatically generate the normals within its shaders. The disadvantages are that auto-normals
* may slow rendering down a little bit, and that the normals can only be face-aligned (and thus rendered using flat
* shading). The advantages, however, are a smaller XKT file size, and the ability to apply certain geometry optimizations
* during parsing, such as removing duplicated STL vertex positions, that are not possible when normals are loaded
* for the STL vertices.
* @param {Boolean} [params.smoothNormals=true] When true, automatically converts face-oriented STL normals to vertex normals, for a smooth appearance. Ignored if ````autoNormals```` is ````true````.
* @param {Number} [params.smoothNormalsAngleThreshold=20] This is the threshold angle between normals of adjacent triangles, below which their shared wireframe edge is not drawn.
* @param {Boolean} [params.splitMeshes=true] When true, creates a separate {@link XKTEntity} for each group of faces that share the same vertex colors. Only works with binary STL (ie. when ````data```` is an ArrayBuffer).
* @param {XKTModel} [params.xktModel] XKTModel to parse into.
* @param {Object} [params.stats] Collects statistics.
* @param {function} [params.log] Logging callback.
@returns {Promise} Resolves when STL has been parsed.
*/
async function parseSTLIntoXKTModel({
data,
splitMeshes,
autoNormals,
smoothNormals,
smoothNormalsAngleThreshold,
xktModel,
stats,
log
}) {
if (log) {
log("Using parser: parseSTLIntoXKTModel");
}
return new Promise(function (resolve, reject) {
if (!data) {
reject("Argument expected: data");
return;
}
if (!xktModel) {
reject("Argument expected: xktModel");
return;
}
const rootMetaObjectId = math.createUUID();
const rootMetaObject = xktModel.createMetaObject({
metaObjectId: rootMetaObjectId,
metaObjectType: "Model",
metaObjectName: "Model"
});
const ctx = {
data,
splitMeshes,
autoNormals,
smoothNormals,
smoothNormalsAngleThreshold,
xktModel,
rootMetaObject,
nextId: 0,
log: (log || function (msg) {
}),
stats: {
numObjects: 0,
numGeometries: 0,
numTriangles: 0,
numVertices: 0
}
};
const binData = ensureBinary(data);
if (isBinary(binData)) {
parseBinary(ctx, binData);
} else {
parseASCII(ctx, ensureString(data));
}
if (stats) {
stats.sourceFormat = "STL";
stats.schemaVersion = "";
stats.title = "";
stats.author = "";
stats.created = "";
stats.numMetaObjects = 2;
stats.numPropertySets = 0;
stats.numObjects = 1;
stats.numGeometries = 1;
stats.numTriangles = ctx.stats.numTriangles;
stats.numVertices = ctx.stats.numVertices;
}
resolve();
});
}
function isBinary(data) {
const reader = new DataView(data);
const numFaces = reader.getUint32(80, true);
const faceSize = (32 / 8 * 3) + ((32 / 8 * 3) * 3) + (16 / 8);
const numExpectedBytes = 80 + (32 / 8) + (numFaces * faceSize);
if (numExpectedBytes === reader.byteLength) {
return true;
}
const solid = [115, 111, 108, 105, 100];
for (let i = 0; i < 5; i++) {
if (solid[i] !== reader.getUint8(i, false)) {
return true;
}
}
return false;
}
function parseBinary(ctx, data) {
const reader = new DataView(data);
const faces = reader.getUint32(80, true);
let r;
let g;
let b;
let hasColors = false;
let colors;
let defaultR;
let defaultG;
let defaultB;
let lastR = null;
let lastG = null;
let lastB = null;
let newMesh = false;
let alpha;
for (let index = 0; index < 80 - 10; index++) {
if ((reader.getUint32(index, false) === 0x434F4C4F /*COLO*/) &&
(reader.getUint8(index + 4) === 0x52 /*'R'*/) &&
(reader.getUint8(index + 5) === 0x3D /*'='*/)) {
hasColors = true;
colors = [];
defaultR = reader.getUint8(index + 6) / 255;
defaultG = reader.getUint8(index + 7) / 255;
defaultB = reader.getUint8(index + 8) / 255;
alpha = reader.getUint8(index + 9) / 255;
}
}
let dataOffset = 84;
let faceLength = 12 * 4 + 2;
let positions = [];
let normals = [];
let splitMeshes = ctx.splitMeshes;
for (let face = 0; face < faces; face++) {
let start = dataOffset + face * faceLength;
let normalX = reader.getFloat32(start, true);
let normalY = reader.getFloat32(start + 4, true);
let normalZ = reader.getFloat32(start + 8, true);
if (hasColors) {
let packedColor = reader.getUint16(start + 48, true);
if ((packedColor & 0x8000) === 0) {
r = (packedColor & 0x1F) / 31;
g = ((packedColor >> 5) & 0x1F) / 31;
b = ((packedColor >> 10) & 0x1F) / 31;
} else {
r = defaultR;
g = defaultG;
b = defaultB;
}
if (splitMeshes && r !== lastR || g !== lastG || b !== lastB) {
if (lastR !== null) {
newMesh = true;
}
lastR = r;
lastG = g;
lastB = b;
}
}
for (let i = 1; i <= 3; i++) {
let vertexstart = start + i * 12;
positions.push(reader.getFloat32(vertexstart, true));
positions.push(reader.getFloat32(vertexstart + 4, true));
positions.push(reader.getFloat32(vertexstart + 8, true));
if (!ctx.autoNormals) {
normals.push(normalX, normalY, normalZ);
}
if (hasColors) {
colors.push(r, g, b, 1); // TODO: handle alpha
}
}
if (splitMeshes && newMesh) {
addMesh(ctx, positions, normals, colors);
positions = [];
normals = [];
colors = colors ? [] : null;
newMesh = false;
}
}
if (positions.length > 0) {
addMesh(ctx, positions, normals, colors);
}
}
function parseASCII(ctx, data) {
const faceRegex = /facet([\s\S]*?)endfacet/g;
let faceCounter = 0;
const floatRegex = /[\s]+([+-]?(?:\d+.\d+|\d+.|\d+|.\d+)(?:[eE][+-]?\d+)?)/.source;
const vertexRegex = new RegExp('vertex' + floatRegex + floatRegex + floatRegex, 'g');
const normalRegex = new RegExp('normal' + floatRegex + floatRegex + floatRegex, 'g');
const positions = [];
const normals = [];
const colors = null;
let normalx;
let normaly;
let normalz;
let result;
let verticesPerFace;
let normalsPerFace;
let text;
while ((result = faceRegex.exec(data)) !== null) {
verticesPerFace = 0;
normalsPerFace = 0;
text = result[0];
while ((result = normalRegex.exec(text)) !== null) {
normalx = parseFloat(result[1]);
normaly = parseFloat(result[2]);
normalz = parseFloat(result[3]);
normalsPerFace++;
}
while ((result = vertexRegex.exec(text)) !== null) {
positions.push(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]));
normals.push(normalx, normaly, normalz);
verticesPerFace++;
}
if (normalsPerFace !== 1) {
ctx.log("Error in normal of face " + faceCounter);
return -1;
}
if (verticesPerFace !== 3) {
ctx.log("Error in positions of face " + faceCounter);
return -1;
}
faceCounter++;
}
addMesh(ctx, positions, normals, colors);
}
let nextGeometryId = 0;
function addMesh(ctx, positions, normals, colors) {
const indices = new Int32Array(positions.length / 3);
for (let ni = 0, len = indices.length; ni < len; ni++) {
indices[ni] = ni;
}
normals = normals && normals.length > 0 ? normals : null;
colors = colors && colors.length > 0 ? colors : null;
if (!ctx.autoNormals && ctx.smoothNormals) {
faceToVertexNormals(positions, normals, {smoothNormalsAngleThreshold: ctx.smoothNormalsAngleThreshold});
}
const geometryId = "" + nextGeometryId++;
const meshId = "" + nextGeometryId++;
const entityId = "" + nextGeometryId++;
ctx.xktModel.createGeometry({
geometryId: geometryId,
primitiveType: "triangles",
positions: positions,
normals: (!ctx.autoNormals) ? normals : null,
colors: colors,
indices: indices
});
ctx.xktModel.createMesh({
meshId: meshId,
geometryId: geometryId,
color: colors ? null : [1, 1, 1],
metallic: 0.9,
roughness: 0.1
});
ctx.xktModel.createEntity({
entityId: entityId,
meshIds: [meshId]
});
ctx.xktModel.createMetaObject({
metaObjectId: entityId,
metaObjectType: "Default",
metaObjectName: "STL Mesh",
parentMetaObjectId: ctx.rootMetaObject.metaObjectId
});
ctx.stats.numGeometries++;
ctx.stats.numObjects++;
ctx.stats.numVertices += positions.length / 3;
ctx.stats.numTriangles += indices.length / 3;
}
function ensureString(buffer) {
if (typeof buffer !== 'string') {
return decodeText(new Uint8Array(buffer));
}
return buffer;
}
function ensureBinary(buffer) {
if (typeof buffer === 'string') {
const arrayBuffer = new Uint8Array(buffer.length);
for (let i = 0; i < buffer.length; i++) {
arrayBuffer[i] = buffer.charCodeAt(i) & 0xff; // implicitly assumes little-endian
}
return arrayBuffer.buffer || arrayBuffer;
} else {
return buffer;
}
}
function decodeText(array) {
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder().decode(array);
}
let s = '';
for (let i = 0, il = array.length; i < il; i++) {
s += String.fromCharCode(array[i]); // Implicitly assumes little-endian.
}
return decodeURIComponent(escape(s));
}
export {parseSTLIntoXKTModel};