Fixed parsing and added basic testing

This commit is contained in:
Lucas Dower 2022-03-01 22:34:42 +00:00
parent c845ee947d
commit a99fa2b452
7 changed files with 321 additions and 53 deletions

7
jestconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"
},
"testRegex": "(/test/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
}

View File

@ -10,6 +10,7 @@
"lint": "eslint --fix ./src/**/*.ts && eslint --fix ./tools/**/*.ts",
"debug": "tsc && electron ./dist/main.js --enable-logging",
"build": "npm run lint && tsc",
"test": "jest --config jestconfig.json",
"start": "npm run build && electron ./dist/main.js --enable-logging",
"atlas": "npx ts-node tools/build-atlas.ts",
"palette": "npx ts-node tools/build-palette.ts",
@ -26,6 +27,7 @@
},
"homepage": "https://github.com/LucasDower/ObjToSchematic#readme",
"devDependencies": {
"@types/jest": "^27.4.1",
"@types/jquery": "^3.5.6",
"@types/obj-file-parser": "^0.5.0",
"@types/pngjs": "^6.0.1",
@ -39,7 +41,9 @@
"electron-packager": "^15.2.0",
"eslint": "^8.7.0",
"images": "^3.2.3",
"jest": "^27.5.1",
"prompt": "^1.2.1",
"ts-jest": "^27.1.3",
"ts-node": "^10.1.0",
"typescript": "^4.3.5"
},

View File

@ -1,7 +1,7 @@
import { IImporter } from '../importer';
import { MaterialType, Mesh, SolidMaterial, TexturedMaterial, Tri } from '../mesh';
import { Vector3 } from '../vector';
import { UV, ASSERT, RGB, CustomError, LOG } from '../util';
import { UV, ASSERT, RGB, CustomError, LOG, REGEX_NUMBER, RegExpBuilder, REGEX_NZ_ANY, LOG_ERROR } from '../util';
import { UI } from '../ui/layout';
import { checkFractional, checkNaN } from '../math';
@ -20,19 +20,31 @@ export class ObjImporter extends IImporter {
private _objPath?: path.ParsedPath;
private _objParsers = [
{
regex: /mtllib (?<path>.*\.mtl)/,
// e.g. 'mtllib my_file.mtl'
regex: new RegExpBuilder().add(/mtllib/).add(/ /).add(REGEX_NZ_ANY, 'path').toRegExp(),
delegate: (match: { [key: string]: string }) => {
this._mtlLibs.push(match.path);
this._mtlLibs.push(match.path.trim());
},
},
{
regex: /usemtl (?<name>.*)/,
// e.g. 'usemtl my_material'
regex: new RegExpBuilder().add(/usemtl/).add(/ /).add(REGEX_NZ_ANY, 'name').toRegExp(),
delegate: (match: { [key: string]: string }) => {
this._currentMaterialName = match.name;
this._currentMaterialName = match.name.trim();
ASSERT(this._currentMaterialName);
},
},
{
regex: /v (?<x>.*) (?<y>.*) (?<z>.*)/,
// e.g. 'v 0.123 0.456 0.789'
regex: new RegExpBuilder()
.add(/v/)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'x')
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'y')
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'z')
.toRegExp(),
delegate: (match: { [key: string]: string }) => {
const x = parseFloat(match.x);
const y = parseFloat(match.y);
@ -42,7 +54,14 @@ export class ObjImporter extends IImporter {
},
},
{
regex: /vt (?<u>.*) (?<v>.*)/,
// e.g. 'vt 0.123 0.456'
regex: new RegExpBuilder()
.add(/vt/)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'u')
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'v')
.toRegExp(),
delegate: (match: { [key: string]: string }) => {
const u = parseFloat(match.u);
const v = parseFloat(match.v);
@ -51,16 +70,27 @@ export class ObjImporter extends IImporter {
},
},
{
regex: /f (?<ix>.*)\/(?<iuvx>.*)\/.* (?<iy>.*)\/(?<iuvy>.*)\/.* (?<iz>.*)\/(?<iuvz>.*)\/.* (?<iw>.*)\/(?<iuvw>.*)\//,
// e.g. 'f 1/2/3 4/5/6 7/8/9 10/11/12' or 'f 1/2 3/4 5/6 7/8'
regex: new RegExpBuilder()
.add(/f/)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'xIndex').addMany(['/'], true).add(REGEX_NUMBER, 'xtIndex', true).addMany(['/', REGEX_NUMBER], true)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'yIndex').addMany(['/'], true).add(REGEX_NUMBER, 'ytIndex', true).addMany(['/', REGEX_NUMBER], true)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'zIndex').addMany(['/'], true).add(REGEX_NUMBER, 'ztIndex', true).addMany(['/', REGEX_NUMBER], true)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'wIndex').addMany(['/'], true).add(REGEX_NUMBER, 'wtIndex', true).addMany(['/', REGEX_NUMBER], true)
.toRegExp(),
delegate: (match: { [key: string]: string }) => {
const iX = parseInt(match.ix) - 1;
const iY = parseInt(match.iy) - 1;
const iZ = parseInt(match.iz) - 1;
const iW = parseInt(match.iw) - 1;
const iUVx = parseInt(match.iuvx) - 1;
const iUVy = parseInt(match.iuvy) - 1;
const iUVz = parseInt(match.iuvz) - 1;
const iUVw = parseInt(match.iuvw) - 1;
const iX = parseInt(match.xIndex) - 1;
const iY = parseInt(match.yIndex) - 1;
const iZ = parseInt(match.zIndex) - 1;
const iW = parseInt(match.wIndex) - 1;
const iUVx = parseInt(match.xtIndex) - 1;
const iUVy = parseInt(match.ytIndex) - 1;
const iUVz = parseInt(match.ztIndex) - 1;
const iUVw = parseInt(match.wtIndex) - 1;
checkNaN(iX, iY, iZ, iW);
ASSERT(this._currentMaterialName);
this._tris.push({
@ -84,14 +114,23 @@ export class ObjImporter extends IImporter {
},
},
{
regex: /f (?<ix>.*)\/(?<iuvx>.*)\/.* (?<iy>.*)\/(?<iuvy>.*)\/.* (?<iz>.*)\/(?<iuvz>.*)\//,
// e.g. f 1/2/3 4/5/6 7/8/9 or 1/2 3/4 5/6
regex: new RegExpBuilder()
.add(/f/)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'xIndex').addMany(['/'], true).add(REGEX_NUMBER, 'xtIndex', true).addMany(['/', REGEX_NUMBER], true)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'yIndex').addMany(['/'], true).add(REGEX_NUMBER, 'ytIndex', true).addMany(['/', REGEX_NUMBER], true)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'zIndex').addMany(['/'], true).add(REGEX_NUMBER, 'ztIndex', true).addMany(['/', REGEX_NUMBER], true)
.toRegExp(),
delegate: (match: { [key: string]: string }) => {
const iX = parseInt(match.ix) - 1;
const iY = parseInt(match.iy) - 1;
const iZ = parseInt(match.iz) - 1;
const iUVx = parseInt(match.iuvx) - 1;
const iUVy = parseInt(match.iuvy) - 1;
const iUVz = parseInt(match.iuvz) - 1;
const iX = parseInt(match.xIndex) - 1;
const iY = parseInt(match.yIndex) - 1;
const iZ = parseInt(match.zIndex) - 1;
const iUVx = parseInt(match.xtIndex) - 1;
const iUVy = parseInt(match.ytIndex) - 1;
const iUVz = parseInt(match.ztIndex) - 1;
checkNaN(iX, iY, iZ);
ASSERT(this._currentMaterialName);
this._tris.push({
@ -105,25 +144,6 @@ export class ObjImporter extends IImporter {
});
},
},
{
regex: /f (?<ix>.*) (?<iy>.*) (?<iz>.*)/,
delegate: (match: { [key: string]: string }) => {
const iX = parseInt(match.ix) - 1;
const iY = parseInt(match.iy) - 1;
const iZ = parseInt(match.iz) - 1;
checkNaN(iX, iY, iZ);
ASSERT(this._currentMaterialName);
this._tris.push({
iX: iX,
iY: iY,
iZ: iZ,
iXUV: iX,
iYUV: iY,
iZUV: iZ,
material: this._currentMaterialName,
});
},
},
];
private _currentColour: RGB = RGB.black;
@ -131,16 +151,26 @@ export class ObjImporter extends IImporter {
private _materialReady: boolean = false;
private _mtlParsers = [
{
regex: /newmtl (?<name>.*)/,
// e.g. 'newmtl my_material'
regex: new RegExpBuilder().add(/newmtl/).add(REGEX_NZ_ANY, 'name').toRegExp(),
delegate: (match: { [key: string]: string }) => {
this._addCurrentMaterial();
this._currentMaterialName = match.name;
this._currentMaterialName = match.name.trim();
this._currentTexture = '';
this._materialReady = false;
},
},
{
regex: /Kd (?<r>.*) (?<g>.*) (?<b>.*)/,
// e.g. 'Kd 0.123 0.456 0.789'
regex: new RegExpBuilder()
.add(/Kd/)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'r')
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'g')
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'b')
.toRegExp(),
delegate: (match: { [key: string]: string }) => {
const r = parseFloat(match.r);
const g = parseFloat(match.g);
@ -152,9 +182,10 @@ export class ObjImporter extends IImporter {
},
},
{
regex: /map_Kd (?<path>.*)/,
// e.g. 'map_Kd my/path/to/file.png'
regex: new RegExpBuilder().add(/map_Kd/).add(REGEX_NZ_ANY, 'path').toRegExp(),
delegate: (match: { [key: string]: string }) => {
let mtlPath = match.path;
let mtlPath = match.path.trim();
if (!path.isAbsolute(mtlPath)) {
ASSERT(this._objPath);
mtlPath = path.join(this._objPath.dir, mtlPath);
@ -208,7 +239,7 @@ export class ObjImporter extends IImporter {
}
private _parseOBJLine(line: string) {
const essentialTokens = ['mtllib ', 'uselib ', 'v ', 'vt ', 'f '];
const essentialTokens = ['mtllib ', 'usemtl ', 'v ', 'vt ', 'f '];
for (const parser of this._objParsers) {
const match = parser.regex.exec(line);
@ -216,6 +247,7 @@ export class ObjImporter extends IImporter {
try {
parser.delegate(match.groups);
} catch (error) {
LOG_ERROR('Caught', error);
if (error instanceof CustomError) {
throw new CustomError(`Failed attempt to parse '${line}', because '${error.message}'`);
}
@ -228,7 +260,7 @@ export class ObjImporter extends IImporter {
return line.startsWith(token);
});
if (beginsWithEssentialToken) {
throw new CustomError(`Failed to parse essential token for ${line}`);
throw new CustomError(`Failed to parse essential token for <b>${line}</b>`);
}
}

View File

@ -58,6 +58,16 @@ export class Mesh {
}
private _checkMesh() {
// TODO: Check indices exist
if (this.vertices.length === 0) {
throw new CustomError('Loaded mesh has no vertices');
}
if (this.tris.length === 0) {
throw new CustomError('Loaded mesh has no triangles');
}
// Check UVs are inside [0, 1]
for (const uv of this.uvs) {
if (uv.u < 0.0 || uv.u > 1.0) {
@ -70,6 +80,10 @@ export class Mesh {
}
private _checkMaterials() {
if (Object.keys(this.materials).length === 0) {
throw new CustomError('Loaded mesh has no materials');
}
// Check used materials exist
let wasRemapped = false;
let debugName = (Math.random() + 1).toString(36).substring(7);
@ -77,13 +91,16 @@ export class Mesh {
debugName = (Math.random() + 1).toString(36).substring(7);
}
const missingMaterials = new Set<string>();
for (const tri of this.tris) {
if (!(tri.material in this.materials)) {
missingMaterials.add(tri.material);
wasRemapped = true;
tri.material = debugName;
}
}
if (wasRemapped) {
LOG_WARN('Triangles use these materials but they were not found', missingMaterials);
AppContext.Get.addWarning('Some materials were not loaded correctly');
this.materials[debugName] = {
type: MaterialType.solid,

View File

@ -64,13 +64,13 @@ export class Texture {
const y = uv.v * this._image.height;
const xL = Math.floor(x);
const xU = Math.ceil(x);
const xU = xL + 1;
const yL = Math.floor(y);
const yU = Math.ceil(y);
const yU = yL + 1;
const u = wayThrough(x, xL, xU);
const v = wayThrough(y, yL, yU);
ASSERT(u >= 0.0 && u <= 1.0 && v >= 0.0 && v <= 1.0);
ASSERT(u >= 0.0 && u <= 1.0 && v >= 0.0 && v <= 1.0, `UV out of range (${u}, ${v})`);
const A = this._getFromXY(xL, yU).toVector3();
const B = this._getFromXY(xU, yU).toVector3();

View File

@ -129,9 +129,33 @@ export function ASSERT(condition: any, errorMessage = 'Assertion Failed'): asser
export const LOG = console.log;
export const LOG_WARN = console.warn;
export const LOG_ERROR = console.error;
/* eslint-enable */
/** Regex for non-zero whitespace */
export const REGEX_NZ_WS = /[ \t]+/;
/** Regex for number */
export const REGEX_NUMBER = /[0-9\.\-]+/;
export const REGEX_NZ_ANY = /.+/;
export function regexCapture(identifier: string, regex: RegExp) {
return new RegExp(`(?<${identifier}>${regex.source}`);
}
export function regexOptional(regex: RegExp) {
return new RegExp(`(${regex})?`);
}
export function buildRegex(...args: (string | RegExp)[]) {
return new RegExp(args.map((r) => {
if (r instanceof RegExp) {
return r.source;
}
return r;
}).join(''));
}
export class CustomError extends Error {
constructor(msg: string) {
super(msg);
@ -149,3 +173,50 @@ export class CustomWarning extends Error {
export function fileExists(absolutePath: string) {
return fs.existsSync(absolutePath);
}
export class RegExpBuilder {
private _components: string[];
public constructor() {
this._components = [];
}
public add(item: string | RegExp, capture?: string, optional: boolean = false): RegExpBuilder {
let regex: string;
if (item instanceof RegExp) {
regex = item.source;
} else {
regex = item;
}
if (capture) {
regex = `(?<${capture}>${regex})`;
}
if (optional) {
regex = `(${regex})?`;
}
this._components.push(regex);
return this;
}
public addMany(items: (string | RegExp)[], optional: boolean = false): RegExpBuilder {
let toAdd: string = '';
for (const item of items) {
if (item instanceof RegExp) {
toAdd += item.source;
} else {
toAdd += item;
}
}
this._components.push(optional ? `(${toAdd})?` : toAdd);
return this;
}
public addNonzeroWhitespace(): RegExpBuilder {
this.add(REGEX_NZ_WS);
return this;
}
public toRegExp(): RegExp {
return new RegExp(this._components.join(''));
}
}

137
test/util.test.ts Normal file
View File

@ -0,0 +1,137 @@
import { ASSERT, RegExpBuilder, REGEX_NUMBER, REGEX_NZ_ANY } from '../src/util';
test('RegExpBuilder', () => {
const regex = new RegExpBuilder()
.add(/hello/)
.toRegExp();
expect(regex.test('hello')).toBe(true);
expect(regex.test('there')).toBe(false);
});
test('RegExpBuilder REGEX_NUMBER', () => {
const tests = [
{ f: '0', s: 0 },
{ f: '0.0', s: 0.0 },
{ f: '-0.0', s: -0.0 },
{ f: '1', s: 1 },
{ f: '1.0', s: 1.0 },
{ f: '-1.0', s: -1.0 },
];
for (const t of tests) {
const temp = REGEX_NUMBER.exec(t.f);
ASSERT(temp !== null);
expect(parseFloat(temp[0])).toEqual(t.s);
}
});
test('RegExpBuilder Required-whitespace', () => {
const regex = new RegExpBuilder()
.add(/hello/)
.addNonzeroWhitespace()
.add(/there/)
.toRegExp();
expect(regex.test('hello there')).toBe(true);
expect(regex.test('hello there')).toBe(true);
expect(regex.test('hellothere')).toBe(false);
});
test('RegExpBuilder Optional', () => {
const regex = new RegExpBuilder()
.add(/hello/)
.addNonzeroWhitespace()
.addMany([/there/], true)
.toRegExp();
expect(regex.test('hello there')).toBe(true);
expect(regex.test('hello there')).toBe(true);
expect(regex.test('hello ')).toBe(true);
expect(regex.test('hello')).toBe(false);
});
test('RegExpBuilder Capture', () => {
const regex = new RegExpBuilder()
.add(/[0-9]+/, 'myNumber')
.toRegExp();
const exec = regex.exec('1234');
expect(exec).toHaveProperty('groups');
if (exec !== null && exec.groups) {
expect(exec.groups).toHaveProperty('myNumber');
expect(exec.groups['myNumber']).toBe('1234');
}
});
test('RegExpBuilder Capture-multiple', () => {
const regex = new RegExpBuilder()
.add(/[0-9]+/, 'x')
.addNonzeroWhitespace()
.add(/[0-9]+/, 'y')
.addNonzeroWhitespace()
.add(/[0-9]+/, 'z')
.toRegExp();
const exec = regex.exec('123 456 789');
expect(exec).toHaveProperty('groups');
if (exec !== null && exec.groups) {
expect(exec.groups).toHaveProperty('x');
expect(exec.groups).toHaveProperty('y');
expect(exec.groups).toHaveProperty('z');
expect(exec.groups['x']).toBe('123');
expect(exec.groups['y']).toBe('456');
expect(exec.groups['z']).toBe('789');
}
});
test('RegExpBuilder Capture-multiple', () => {
const regex = new RegExpBuilder()
.add(/f/)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'xIndex').addMany(['/'], true).add(REGEX_NUMBER, 'xtIndex', true).addMany(['/', REGEX_NUMBER], true)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'yIndex').addMany(['/'], true).add(REGEX_NUMBER, 'ytIndex', true).addMany(['/', REGEX_NUMBER], true)
.addNonzeroWhitespace()
.add(REGEX_NUMBER, 'zIndex').addMany(['/'], true).add(REGEX_NUMBER, 'ztIndex', true).addMany(['/', REGEX_NUMBER], true)
.toRegExp();
let exec = regex.exec('f 1/2/3 4/5/6 7/8/9');
expect(exec).toHaveProperty('groups');
if (exec !== null && exec.groups) {
expect(exec.groups['xIndex']).toBe('1');
expect(exec.groups['xtIndex']).toBe('2');
expect(exec.groups['yIndex']).toBe('4');
expect(exec.groups['ytIndex']).toBe('5');
expect(exec.groups['zIndex']).toBe('7');
expect(exec.groups['ztIndex']).toBe('8');
}
exec = regex.exec('f 1//3 4//6 7//9');
expect(exec).toHaveProperty('groups');
if (exec !== null && exec.groups) {
expect(exec.groups['xIndex']).toBe('1');
expect(exec.groups['xtIndex']).toBeUndefined();
expect(exec.groups['yIndex']).toBe('4');
expect(exec.groups['ytIndex']).toBeUndefined();
expect(exec.groups['zIndex']).toBe('7');
expect(exec.groups['ztIndex']).toBeUndefined();
}
exec = regex.exec('f 1 4 7');
expect(exec).toHaveProperty('groups');
if (exec !== null && exec.groups) {
expect(exec.groups['xIndex']).toBe('1');
expect(exec.groups['yIndex']).toBe('4');
expect(exec.groups['zIndex']).toBe('7');
}
});
test('RegExpBuilder Capture-multiple', () => {
const regex = new RegExpBuilder()
.add(/usemtl/)
.add(/ /)
.add(REGEX_NZ_ANY, 'path')
.toRegExp();
const exec = regex.exec('usemtl hellothere.txt');
expect(exec).toHaveProperty('groups');
if (exec !== null && exec.groups) {
expect(exec.groups['path']).toBe('hellothere.txt');
}
});