// Copied from https://webgl2fundamentals.org/webgl/lessons/webgl-load-obj.html
/** Parse OBJ text into nonindexed triangle geometry arrays. */
function parseOBJ(text)
{
// because indices are base 1 let's just fill in the 0th data
const objPositions = [[0, 0, 0]];
const objTexcoords = [[0, 0]];
const objNormals = [[0, 0, 0]];
const objColors = [[0, 0, 0]];
// same order as `f` indices
const objVertexData = [
objPositions,
objTexcoords,
objNormals,
objColors,
];
// same order as `f` indices
let webglVertexData = [
[], // positions
[], // texcoords
[], // normals
[], // colors
];
const materialLibs = [];
const geometries = [];
let geometry;
let groups = ['default'];
let material = 'default';
let object = 'default';
const noop = () => {};
function newGeometry() {
// If there is an existing geometry and it's
// not empty then start a new one.
if (geometry && geometry.data.position.length) {
geometry = undefined;
}
}
function setGeometry() {
if (!geometry) {
const position = [];
const texcoord = [];
const normal = [];
const color = [];
webglVertexData = [
position,
texcoord,
normal,
color,
];
geometry = {
object,
groups,
material,
data: {
position,
texcoord,
normal,
color,
},
};
geometries.push(geometry);
}
}
function addVertex(vert) {
const ptn = vert.split('/');
ptn.forEach((objIndexStr, i) => {
if (!objIndexStr) {
return;
}
const objIndex = parseInt(objIndexStr);
const index = objIndex + (objIndex >= 0 ? 0 : objVertexData[i].length);
webglVertexData[i].push(...objVertexData[i][index]);
// if this is the position index (index 0) and we parsed
// vertex colors then copy the vertex colors to the webgl vertex color data
if (i === 0 && objColors.length > 1) {
geometry.data.color.push(...objColors[index]);
}
});
}
const keywords = {
v(parts) {
// if there are more than 3 values here they are vertex colors
if (parts.length > 3) {
objPositions.push(parts.slice(0, 3).map(parseFloat));
objColors.push(parts.slice(3).map(parseFloat));
} else {
objPositions.push(parts.map(parseFloat));
}
},
vn(parts) {
objNormals.push(parts.map(parseFloat));
},
vt(parts) {
// should check for missing v and extra w?
objTexcoords.push(parts.map(parseFloat));
},
f(parts) {
setGeometry();
const numTriangles = parts.length - 2;
for (let tri = 0; tri < numTriangles; ++tri) {
addVertex(parts[0]);
addVertex(parts[tri + 1]);
addVertex(parts[tri + 2]);
}
},
s: noop, // smoothing group
mtllib(parts, unparsedArgs) {
// the spec says there can be multiple filenames here
// but many exist with spaces in a single filename
materialLibs.push(unparsedArgs);
},
usemtl(parts, unparsedArgs) {
material = unparsedArgs;
newGeometry();
},
g(parts) {
groups = parts;
newGeometry();
},
o(parts, unparsedArgs) {
object = unparsedArgs;
newGeometry();
},
};
const keywordRE = /(\w*)(?: )*(.*)/;
const lines = text.split('\n');
for (let lineNo = 0; lineNo < lines.length; ++lineNo) {
const line = lines[lineNo].trim();
if (line === '' || line.startsWith('#')) {
continue;
}
const m = keywordRE.exec(line);
if (!m) {
continue;
}
const [, keyword, unparsedArgs] = m;
const parts = line.split(/\s+/).slice(1);
const handler = keywords[keyword];
if (!handler) {
console.warn('unhandled keyword:', keyword); // eslint-disable-line no-console
continue;
}
handler(parts, unparsedArgs);
}
// remove any arrays that have no entries.
for (const geometry of geometries) {
geometry.data = Object.fromEntries(
Object.entries(geometry.data).filter(([, array]) => array.length > 0));
}
return {
geometries,
materialLibs,
};
}
/** Model-space rules for extracting the front from the atlas-mapped OBJ. */
const CARD_MESH_LAYOUT = Object.freeze({
front_normal: Object.freeze([0.0, 1.0, 0.0]),
front_normal_threshold: 0.999,
front_uv_min: Object.freeze([0.5, 0.3]),
front_uv_max: Object.freeze([1.0, 1.0]),
uv_tolerance: 0.001,
});
/** Return a validated normalized three-component vector. */
function normalizedLayoutVector(value, name)
{
if(!Array.isArray(value) || value.length != 3
|| !value.every(Number.isFinite))
{
throw(new Error(`Invalid ${name}.`));
}
const length = Math.hypot(...value);
if(length < 1e-12)
{
throw(new Error(`Invalid ${name}: vector has zero length.`));
}
return [value[0] / length, value[1] / length, value[2] / length];
}
/** Return a geometry with empty arrays matching the source attributes. */
function emptyPartitionGeometry(geometry, suffix, material)
{
const data = {};
for(const name of Object.keys(geometry.data))
{
data[name] = [];
}
return {
object: `${geometry.object}/${suffix}`,
groups: geometry.groups,
material,
data,
};
}
/** Validate geometry arrays and return their components per vertex. */
function geometryComponents(geometry)
{
if(geometry == null || geometry.data == null
|| !Array.isArray(geometry.data.position)
|| !Array.isArray(geometry.data.texcoord))
{
throw(new Error("Card geometry requires positions and texcoords."));
}
const vertex_count = geometry.data.position.length / 3;
if(vertex_count == 0 || !Number.isInteger(vertex_count)
|| vertex_count % 3 != 0)
{
throw(new Error(
`Card geometry ${geometry.object} has incomplete triangles.`));
}
const components = {};
for(const [name, data] of Object.entries(geometry.data))
{
if(!Array.isArray(data) || data.length == 0
|| !data.every(Number.isFinite)
|| data.length % vertex_count != 0)
{
throw(new Error(
`Card geometry ${geometry.object} has invalid ${name} data.`));
}
components[name] = data.length / vertex_count;
}
if(components.position != 3 || components.texcoord != 2
|| (components.normal != null && components.normal != 3))
{
throw(new Error(
`Card geometry ${geometry.object} has invalid attribute sizes.`));
}
return components;
}
/** Remap one validated card-front coordinate into the unit square. */
function remapCardFrontUv(u, v, layout, geometry_name, triangle)
{
const values = [u, v];
const remapped = [];
for(let axis = 0; axis < 2; ++axis)
{
const minimum = layout.front_uv_min[axis];
const maximum = layout.front_uv_max[axis];
if(values[axis] < minimum - layout.uv_tolerance
|| values[axis] > maximum + layout.uv_tolerance)
{
const coordinate = axis == 0 ? "u" : "v";
throw(new Error(
`Front UV outside atlas region in ${geometry_name} triangle `
+ `${triangle}: ${coordinate}=${values[axis]}, expected `
+ `${minimum}..${maximum} (tolerance `
+ `${layout.uv_tolerance}).`));
}
const value = (values[axis] - minimum) / (maximum - minimum);
remapped.push(Math.min(Math.max(value, 0.0), 1.0));
}
return remapped;
}
/** Append one source triangle, optionally remapping its texture coordinates. */
function appendPartitionTriangle(source, destination, components,
triangle, layout)
{
const first_vertex = triangle * 3;
for(const [name, component_count] of Object.entries(components))
{
const first = first_vertex * component_count;
const last = first + 3 * component_count;
if(name != "texcoord" || layout == null)
{
destination.data[name].push(
...source.data[name].slice(first, last));
continue;
}
for(let vertex = 0; vertex < 3; ++vertex)
{
const offset = first + 2 * vertex;
destination.data.texcoord.push(...remapCardFrontUv(
source.data.texcoord[offset],
source.data.texcoord[offset + 1], layout,
source.object, triangle));
}
}
}
/** Partition and remap an atlas-mapped card into front and shell geometry. */
function partitionCardGeometry(geometry, layout)
{
const components = geometryComponents(geometry);
if(layout == null || !Number.isFinite(layout.front_normal_threshold)
|| layout.front_normal_threshold < -1.0
|| layout.front_normal_threshold > 1.0
|| !Number.isFinite(layout.uv_tolerance)
|| layout.uv_tolerance < 0.0)
{
throw(new Error("Invalid card mesh layout."));
}
const front_normal = normalizedLayoutVector(
layout.front_normal, "card front normal");
const uv_min = layout.front_uv_min;
const uv_max = layout.front_uv_max;
if(!Array.isArray(uv_min) || !Array.isArray(uv_max)
|| uv_min.length != 2 || uv_max.length != 2
|| !uv_min.every(Number.isFinite) || !uv_max.every(Number.isFinite)
|| uv_min[0] >= uv_max[0] || uv_min[1] >= uv_max[1])
{
throw(new Error("Invalid card front UV rectangle."));
}
const front = emptyPartitionGeometry(geometry, "front", "front");
const shell = emptyPartitionGeometry(geometry, "shell", "shell");
const positions = geometry.data.position;
const texcoords = geometry.data.texcoord;
const triangle_count = positions.length / 9;
for(let triangle = 0; triangle < triangle_count; ++triangle)
{
const offset = triangle * 9;
const edge_1 = [
positions[offset + 3] - positions[offset],
positions[offset + 4] - positions[offset + 1],
positions[offset + 5] - positions[offset + 2],
];
const edge_2 = [
positions[offset + 6] - positions[offset],
positions[offset + 7] - positions[offset + 1],
positions[offset + 8] - positions[offset + 2],
];
const face_normal = [
edge_1[1] * edge_2[2] - edge_1[2] * edge_2[1],
edge_1[2] * edge_2[0] - edge_1[0] * edge_2[2],
edge_1[0] * edge_2[1] - edge_1[1] * edge_2[0],
];
const normal_length = Math.hypot(...face_normal);
if(normal_length < 1e-12)
{
throw(new Error(
`Degenerate triangle ${triangle} in ${geometry.object}.`));
}
const facing = (face_normal[0] * front_normal[0]
+ face_normal[1] * front_normal[1]
+ face_normal[2] * front_normal[2]) / normal_length;
if(facing < layout.front_normal_threshold)
{
appendPartitionTriangle(
geometry, shell, components, triangle, null);
continue;
}
appendPartitionTriangle(
geometry, front, components, triangle, layout);
}
for(const partition of [front, shell])
{
for(const [name, data] of Object.entries(partition.data))
{
if(data.length == 0)
{
delete partition.data[name];
}
}
}
return {front, shell};
}
if(typeof module != "undefined")
{
module.exports = {
CARD_MESH_LAYOUT,
parseOBJ,
partitionCardGeometry,
};
}