Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Rose
6550771d65 add obj format support 2026-05-31 22:14:45 -07:00
11 changed files with 1037 additions and 40 deletions

View File

@@ -20,6 +20,7 @@ Note that since we don't clearly distinguish between a public and private interf
- Add axis param to camera spin/rock animation
- Fix SSAO half/quarter resolution textures for multi-scale
- Non-covalent interactions: water bridge support
- Add OBJ format support
## [v5.9.0] - 2026-05-03
- Fix edge case when `PluginSpec.animations` is empty

View File

@@ -15,7 +15,7 @@ import { GraphicsMode, MesoscaleGroup, MesoscaleState, getGraphicsModeProps, get
import { ColorNames } from '../../../../mol-util/color/names';
import { ShapeRepresentation3D, StructureRepresentation3D } from '../../../../mol-plugin-state/transforms/representation';
import { ParseCif, ParsePly, ReadFile } from '../../../../mol-plugin-state/transforms/data';
import { ModelFromTrajectory, ShapeFromPly, TrajectoryFromGRO, TrajectoryFromMOL, TrajectoryFromMOL2, TrajectoryFromMmCif, TrajectoryFromPDB, TrajectoryFromSDF, TrajectoryFromXYZ } from '../../../../mol-plugin-state/transforms/model';
import { ModelFromTrajectory, TrajectoryFromGRO, TrajectoryFromMOL, TrajectoryFromMOL2, TrajectoryFromMmCif, TrajectoryFromPDB, TrajectoryFromSDF, TrajectoryFromXYZ } from '../../../../mol-plugin-state/transforms/model';
import { Euler } from '../../../../mol-math/linear-algebra/3d/euler';
import { Asset } from '../../../../mol-util/assets';
import { Clip } from '../../../../mol-util/clip';
@@ -24,6 +24,7 @@ import { getFileNameInfo } from '../../../../mol-util/file-info';
import { NumberArray } from '../../../../mol-util/type-helpers';
import { BaseGeometry } from '../../../../mol-geo/geometry/base';
import { ParamDefinition as PD } from '../../../../mol-util/param-definition';
import { ShapeFromPly } from '../../../../mol-plugin-state/transforms/shape';
function getSpacefillParams(color: Color, sizeFactor: number, graphics: GraphicsMode, clipVariant: Clip.Variant) {
const gmp = getGraphicsModeProps(graphics === 'custom' ? 'quality' : graphics);

View File

@@ -0,0 +1,385 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { parseObj } from '../obj/parser';
// Simple triangle
const objTriangle = `# simple triangle
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
f 1 2 3
`;
// Quad that gets fan-triangulated into 2 triangles
const objQuad = `# quad fan-triangulated
v -1.0 -1.0 0.0
v 1.0 -1.0 0.0
v 1.0 1.0 0.0
v -1.0 1.0 0.0
f 1 2 3 4
`;
// Vertex normals
const objWithNormals = `# vertex normals
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
vn 0.0 0.0 1.0
vn 0.0 0.0 1.0
vn 0.0 0.0 1.0
f 1//1 2//2 3//3
`;
// v/vt/vn format (texture coords are ignored but should not break parsing)
const objWithTexture = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
vn 0.0 0.0 1.0
f 1/1/1 2/2/1 3/3/1
`;
// Multiple materials / usemtl groups
const objMultiMaterial = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
v 2.0 0.0 0.0
v 2.5 1.0 0.0
usemtl red
f 1 2 3
usemtl green
f 2 4 5
`;
// Negative indices (relative addressing)
const objNegativeIndices = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
f -3 -2 -1
`;
// Comments and blank lines should be ignored
const objWithComments = `# header comment
# another comment
v 0.0 0.0 0.0
# inline comment after data
v 1.0 0.0 0.0
v 0.5 1.0 0.0
f 1 2 3
`;
// Unsupported directives (g, o, s, mtllib, vt, vp) should be silently skipped
const objUnsupportedDirectives = `mtllib material.mtl
o MyObject
g mygroup
s 1
v 0.0 0.0 0.0
vt 0.0 0.0
vp 0.0 1.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
f 1 2 3
`;
// Cube (6 faces × 2 triangles = 12 triangles)
const objCube = `# unit cube
v -1 -1 -1
v 1 -1 -1
v 1 1 -1
v -1 1 -1
v -1 -1 1
v 1 -1 1
v 1 1 1
v -1 1 1
# bottom (-z)
f 1 2 3
f 1 3 4
# top (+z)
f 5 6 7
f 5 7 8
# front (+x)
f 2 6 7
f 2 7 3
# back (-x)
f 5 1 4
f 5 4 8
# left (-y)
f 1 5 6
f 1 6 2
# right (+y)
f 4 3 7
f 4 7 8
`;
// CRLF line endings
const objCRLF = '# crlf triangle\r\nv 0.0 0.0 0.0\r\nv 1.0 0.0 0.0\r\nv 0.5 1.0 0.0\r\nf 1 2 3\r\n';
// Tabs and leading whitespace before keywords
const objLeadingWhitespace = '\tv 0.0 0.0 0.0\n v 1.0 0.0 0.0\n\t v 0.5 1.0 0.0\n\tf 1 2 3\n';
// Degenerate face (fewer than 3 vertices) should be skipped with a warning
const objDegenerateFace = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
f 1 2
f 1 2 3
`;
// Mixed face-vertices: some reference a normal, some do not, within one mesh
const objMixedNormals = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
vn 0.0 0.0 1.0
f 1//1 2 3//1
`;
// Negative normal indices (relative addressing for normals)
const objNegativeNormalIndices = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
vn 0.0 0.0 1.0
f 1//-1 2//-1 3//-1
`;
// usemtl reuse: a material name is referenced again after another material
const objReusedMaterial = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
v 2.0 0.0 0.0
v 2.5 1.0 0.0
usemtl red
f 1 2 3
usemtl green
f 2 4 5
usemtl red
f 1 3 4
`;
// Empty file
const objEmpty = '';
// File with vertices but no faces
const objNoFaces = `v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
`;
describe('obj reader', () => {
it('parses a simple triangle', async () => {
const parsed = await parseObj(objTriangle).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(1);
// First vertex
expect(obj.positions[0]).toBeCloseTo(0.0);
expect(obj.positions[1]).toBeCloseTo(0.0);
expect(obj.positions[2]).toBeCloseTo(0.0);
// Triangle indices (0-based)
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
});
it('fan-triangulates a quad into two triangles', async () => {
const parsed = await parseObj(objQuad).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(4);
expect(obj.triangleCount).toBe(2);
// Fan from vertex 0: (0,1,2) and (0,2,3)
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2, 0, 2, 3]);
});
it('parses vertex normals with v//vn format', async () => {
const parsed = await parseObj(objWithNormals).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.normalCount).toBe(3);
expect(obj.triangleCount).toBe(1);
expect(Array.from(obj.normalIndices)).toEqual([0, 1, 2]);
// Normal z component of first normal
expect(obj.normals[2]).toBeCloseTo(1.0);
});
it('parses v/vt/vn format (texture coords ignored)', async () => {
const parsed = await parseObj(objWithTexture).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.normalCount).toBe(1);
expect(obj.triangleCount).toBe(1);
expect(Array.from(obj.normalIndices)).toEqual([0, 0, 0]);
});
it('assigns material groups via usemtl', async () => {
const parsed = await parseObj(objMultiMaterial).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.triangleCount).toBe(2);
// 'default' is always first; 'red' and 'green' added on use
expect(obj.groups[1]).toBe('red');
expect(obj.groups[2]).toBe('green');
// First triangle belongs to 'red' (index 1), second to 'green' (index 2)
expect(obj.groupIndices[0]).toBe(1);
expect(obj.groupIndices[1]).toBe(2);
});
it('handles negative (relative) vertex indices', async () => {
const parsed = await parseObj(objNegativeIndices).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.triangleCount).toBe(1);
// -3, -2, -1 with posCount=3 → 0, 1, 2
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
});
it('ignores comments and blank lines', async () => {
const parsed = await parseObj(objWithComments).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(1);
});
it('silently skips unsupported directives', async () => {
const parsed = await parseObj(objUnsupportedDirectives).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(1);
});
it('parses a cube (12 triangles, 8 vertices)', async () => {
const parsed = await parseObj(objCube).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(8);
expect(obj.triangleCount).toBe(12);
expect(obj.positionIndices.length).toBe(36); // 12 * 3
});
it('returns no normals when none are defined', async () => {
const parsed = await parseObj(objTriangle).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.normalCount).toBe(0);
// All normal indices should be -1
expect(Array.from(obj.normalIndices)).toEqual([-1, -1, -1]);
});
it('default group is always present', async () => {
const parsed = await parseObj(objTriangle).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.groups[0]).toBe('default');
expect(obj.groupIndices[0]).toBe(0);
});
it('parses CRLF line endings', async () => {
const parsed = await parseObj(objCRLF).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(1);
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
});
it('handles tabs and leading whitespace before keywords', async () => {
const parsed = await parseObj(objLeadingWhitespace).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(1);
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
});
it('skips a degenerate face and emits a warning', async () => {
const parsed = await parseObj(objDegenerateFace).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
// Only the valid triangle survives
expect(obj.triangleCount).toBe(1);
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
// A warning was recorded for the degenerate face
expect(parsed.warnings.length).toBeGreaterThan(0);
expect(parsed.warnings.some(w => w.includes('degenerate'))).toBe(true);
});
it('parses faces with mixed normal/no-normal vertices', async () => {
const parsed = await parseObj(objMixedNormals).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.normalCount).toBe(1);
expect(obj.triangleCount).toBe(1);
// First and third vertices reference normal 0; the middle has none (-1)
expect(Array.from(obj.normalIndices)).toEqual([0, -1, 0]);
});
it('handles negative (relative) normal indices', async () => {
const parsed = await parseObj(objNegativeNormalIndices).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.normalCount).toBe(1);
expect(obj.triangleCount).toBe(1);
// -1 with normCount=1 → 0
expect(Array.from(obj.normalIndices)).toEqual([0, 0, 0]);
});
it('reuses an already-seen usemtl material name', async () => {
const parsed = await parseObj(objReusedMaterial).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.triangleCount).toBe(3);
// 'red' (1) and 'green' (2) are the only added groups; no duplicate 'red'
expect(obj.groups[1]).toBe('red');
expect(obj.groups[2]).toBe('green');
expect(obj.groups.indexOf('red')).toBe(1);
expect(obj.groups.lastIndexOf('red')).toBe(1);
// Third face reuses 'red' (index 1)
expect(Array.from(obj.groupIndices)).toEqual([1, 2, 1]);
});
it('parses an empty file', async () => {
const parsed = await parseObj(objEmpty).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(0);
expect(obj.normalCount).toBe(0);
expect(obj.triangleCount).toBe(0);
expect(obj.groups[0]).toBe('default');
});
it('parses a file with vertices but no faces', async () => {
const parsed = await parseObj(objNoFaces).run();
if (parsed.isError) throw new Error(parsed.message);
const obj = parsed.result;
expect(obj.positionCount).toBe(3);
expect(obj.triangleCount).toBe(0);
expect(obj.positionIndices.length).toBe(0);
});
});

View File

@@ -1,22 +1,323 @@
/**
* Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { ReaderResult as Result } from '../result';
import { Task, RuntimeContext } from '../../../mol-task';
import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
import { ChunkedArray } from '../../../mol-data/util';
import { ObjFile } from './schema';
import { StringLike } from '../../common/string-like';
import { Tokenizer } from '../common/text/tokenizer';
import { parseInt, parseFloat } from '../common/text/number-parser';
async function parseInternal(data: string, ctx: RuntimeContext): Promise<Result<Mesh>> {
// TODO
const mesh: Mesh = Mesh.createEmpty();
// Mesh.computeNormalsImmediate(mesh)
return Result.success(mesh);
// OBJ file format specification: http://www.martinreddy.net/gfx/3d/OBJ.spec
interface State {
tokenizer: Tokenizer
positions: ChunkedArray<number, 3>
normals: ChunkedArray<number, 3>
positionIndices: ChunkedArray<number, 3>
normalIndices: ChunkedArray<number, 3>
groupIndices: ChunkedArray<number, 1>
groups: string[]
currentGroupIdx: number
warnings: string[]
}
export function parse(data: string) {
return Task.create<Result<Mesh>>('Parse OBJ', async ctx => {
function State(data: StringLike): State {
return {
tokenizer: Tokenizer(data),
positions: ChunkedArray.create(Float32Array, 3, 1024),
normals: ChunkedArray.create(Float32Array, 3, 1024),
positionIndices: ChunkedArray.create(Int32Array, 3, 1024),
normalIndices: ChunkedArray.create(Int32Array, 3, 1024),
groupIndices: ChunkedArray.create(Uint32Array, 1, 1024),
groups: ['default'],
currentGroupIdx: 0,
warnings: []
};
}
// Character codes used for keyword identification without materializing strings
const CC_v = 118; // 'v'
const CC_n = 110; // 'n'
const CC_f = 102; // 'f'
const CC_u = 117; // 'u'
const CC_s = 115; // 's'
const CC_e = 101; // 'e'
const CC_m = 109; // 'm'
const CC_t = 116; // 't'
const CC_l = 108; // 'l'
const CC_HASH = 35; // '#'
const CC_NEWLINE = 10; // '\n'
const CC_CR = 13; // '\r'
const CC_SPACE = 32; // ' '
const CC_TAB = 9; // '\t'
const CC_SLASH = 47; // '/'
/** Skip to the end of the current line without returning it. */
function skipLine(tokenizer: Tokenizer): void {
const { data } = tokenizer;
while (tokenizer.position < tokenizer.length) {
const c = data.charCodeAt(tokenizer.position);
if (c === CC_NEWLINE) { ++tokenizer.position; ++tokenizer.lineNumber; return; }
if (c === CC_CR) {
++tokenizer.position;
++tokenizer.lineNumber;
if (tokenizer.position < tokenizer.length && data.charCodeAt(tokenizer.position) === CC_NEWLINE) ++tokenizer.position;
return;
}
++tokenizer.position;
}
}
/** Skip inline whitespace (space/tab only — does not cross newlines). */
function skipInlineWS(tokenizer: Tokenizer): void {
const { data } = tokenizer;
while (tokenizer.position < tokenizer.length) {
const c = data.charCodeAt(tokenizer.position);
if (c !== CC_SPACE && c !== CC_TAB) return;
++tokenizer.position;
}
}
/**
* Read one whitespace-delimited token on the current line.
* Returns false when end-of-line / end-of-file is reached before any character.
* Leaves tokenizer.tokenStart/tokenEnd set to the token boundaries.
*/
function readInlineToken(tokenizer: Tokenizer): boolean {
skipInlineWS(tokenizer);
const { data } = tokenizer;
if (tokenizer.position >= tokenizer.length) return false;
const c = data.charCodeAt(tokenizer.position);
if (c === CC_NEWLINE || c === CC_CR || c === CC_HASH) return false;
tokenizer.tokenStart = tokenizer.position;
while (tokenizer.position < tokenizer.length) {
const ch = data.charCodeAt(tokenizer.position);
if (ch === CC_SPACE || ch === CC_TAB || ch === CC_NEWLINE || ch === CC_CR || ch === CC_HASH) break;
++tokenizer.position;
}
tokenizer.tokenEnd = tokenizer.position;
return tokenizer.tokenEnd > tokenizer.tokenStart;
}
/**
* Read up to `maxCount` face-vertex tokens from the current line into `facePos` / `faceNorm`.
* Returns the number of tokens read.
* Face vertex format: posIdx[/[texIdx][/normIdx]] (all 1-based, may be negative).
*/
function readFaceTokens(
tokenizer: Tokenizer,
facePos: Int32Array, faceNorm: Int32Array,
maxCount: number,
posCount: number, normCount: number
): number {
const { data } = tokenizer;
let count = 0;
while (count < maxCount && readInlineToken(tokenizer)) {
const start = tokenizer.tokenStart;
const end = tokenizer.tokenEnd;
// Find first slash within [start, end)
let slash1 = -1;
for (let i = start; i < end; ++i) {
if (data.charCodeAt(i) === CC_SLASH) { slash1 = i; break; }
}
let posIdx: number;
let normIdx = -1;
if (slash1 === -1) {
// "v"
const p = parseInt(data, start, end);
posIdx = p < 0 ? posCount + p : p - 1;
} else {
const p = parseInt(data, start, slash1);
posIdx = p < 0 ? posCount + p : p - 1;
// Find second slash
let slash2 = -1;
for (let i = slash1 + 1; i < end; ++i) {
if (data.charCodeAt(i) === CC_SLASH) { slash2 = i; break; }
}
if (slash2 !== -1 && slash2 + 1 < end) {
// "v/vt/vn" or "v//vn"
const n = parseInt(data, slash2 + 1, end);
normIdx = n < 0 ? normCount + n : n - 1;
}
// else "v/vt" — no normal
}
facePos[count] = posIdx;
faceNorm[count] = normIdx;
++count;
}
return count;
}
// Reusable scratch buffers for face vertex data (polygons up to MAX_FACE_VERTICES vertices)
const MAX_FACE_VERTICES = 256;
const _facePos = new Int32Array(MAX_FACE_VERTICES);
const _faceNorm = new Int32Array(MAX_FACE_VERTICES);
function handleVertex(state: State): void {
const { tokenizer } = state;
let x = 0, y = 0, z = 0;
if (readInlineToken(tokenizer)) x = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
if (readInlineToken(tokenizer)) y = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
if (readInlineToken(tokenizer)) z = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
ChunkedArray.add3(state.positions, x, y, z);
skipLine(tokenizer);
}
function handleNormal(state: State): void {
const { tokenizer } = state;
let x = 0, y = 0, z = 0;
if (readInlineToken(tokenizer)) x = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
if (readInlineToken(tokenizer)) y = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
if (readInlineToken(tokenizer)) z = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
ChunkedArray.add3(state.normals, x, y, z);
skipLine(tokenizer);
}
function handleFace(state: State): void {
const { tokenizer } = state;
const posCount = state.positions.elementCount;
const normCount = state.normals.elementCount;
const n = readFaceTokens(tokenizer, _facePos, _faceNorm, MAX_FACE_VERTICES, posCount, normCount);
if (n < 3) {
state.warnings.push(`Line ${tokenizer.lineNumber}: degenerate face with ${n} vertices, skipped`);
skipLine(tokenizer);
return;
}
// Warn if the polygon exceeded the scratch buffer capacity and was truncated.
if (n === MAX_FACE_VERTICES && readInlineToken(tokenizer)) {
state.warnings.push(`Line ${tokenizer.lineNumber}: face with more than ${MAX_FACE_VERTICES} vertices truncated`);
}
// Fan-triangulate: (0,1,2), (0,2,3), ...
const p0 = _facePos[0], n0 = _faceNorm[0];
for (let i = 1; i < n - 1; ++i) {
ChunkedArray.add3(state.positionIndices, p0, _facePos[i], _facePos[i + 1]);
ChunkedArray.add3(state.normalIndices, n0, _faceNorm[i], _faceNorm[i + 1]);
ChunkedArray.add(state.groupIndices, state.currentGroupIdx);
}
skipLine(tokenizer);
}
function handleUsemtl(state: State): void {
const { tokenizer } = state;
// Read the rest of the line as the material name (trimmed)
skipInlineWS(tokenizer);
tokenizer.tokenStart = tokenizer.position;
const { data } = tokenizer;
while (tokenizer.position < tokenizer.length) {
const c = data.charCodeAt(tokenizer.position);
if (c === CC_NEWLINE || c === CC_CR) break;
++tokenizer.position;
}
// Trim trailing whitespace
let end = tokenizer.position;
while (end > tokenizer.tokenStart && (data.charCodeAt(end - 1) === CC_SPACE || data.charCodeAt(end - 1) === CC_TAB)) --end;
const name = end > tokenizer.tokenStart ? data.substring(tokenizer.tokenStart, end) : 'default';
const existing = state.groups.indexOf(name);
if (existing !== -1) {
state.currentGroupIdx = existing;
} else {
state.currentGroupIdx = state.groups.length;
state.groups.push(name);
}
skipLine(tokenizer);
}
async function parseInternal(data: StringLike, ctx: RuntimeContext): Promise<Result<ObjFile>> {
const state = State(data);
const { tokenizer } = state;
const updateChunk = 100000;
while (tokenizer.position < tokenizer.length) {
// Skip full-line whitespace and newlines between lines
const c0 = tokenizer.data.charCodeAt(tokenizer.position);
if (c0 === CC_NEWLINE) { ++tokenizer.position; ++tokenizer.lineNumber; continue; }
if (c0 === CC_CR) {
++tokenizer.position; ++tokenizer.lineNumber;
if (tokenizer.position < tokenizer.length && tokenizer.data.charCodeAt(tokenizer.position) === CC_NEWLINE) ++tokenizer.position;
continue;
}
if (c0 === CC_SPACE || c0 === CC_TAB) { skipInlineWS(tokenizer); continue; }
if (c0 === CC_HASH) { skipLine(tokenizer); continue; }
// Identify keyword by inspecting character codes — no string allocation
const c1 = tokenizer.position + 1 < tokenizer.length ? tokenizer.data.charCodeAt(tokenizer.position + 1) : -1;
if (c0 === CC_f && (c1 === CC_SPACE || c1 === CC_TAB)) {
// "f " — face
tokenizer.position += 2;
handleFace(state);
} else if (c0 === CC_v) {
if (c1 === CC_SPACE || c1 === CC_TAB) {
// "v " — vertex position
tokenizer.position += 2;
handleVertex(state);
} else if (c1 === CC_n) {
// "vn" — vertex normal
tokenizer.position += 2;
handleNormal(state);
} else {
// "vt", "vp", etc — skip
skipLine(tokenizer);
}
} else if (
c0 === CC_u &&
tokenizer.position + 6 < tokenizer.length &&
tokenizer.data.charCodeAt(tokenizer.position + 1) === CC_s &&
tokenizer.data.charCodeAt(tokenizer.position + 2) === CC_e &&
tokenizer.data.charCodeAt(tokenizer.position + 3) === CC_m &&
tokenizer.data.charCodeAt(tokenizer.position + 4) === CC_t &&
tokenizer.data.charCodeAt(tokenizer.position + 5) === CC_l
) {
// "usemtl"
tokenizer.position += 6;
handleUsemtl(state);
} else {
// "o", "g", "s", "mtllib", "call", etc. — skip entire line
skipLine(tokenizer);
}
if (ctx.shouldUpdate && tokenizer.lineNumber % updateChunk === 0) {
await ctx.update({ message: 'Parsing OBJ', current: tokenizer.position, max: tokenizer.length });
}
}
const posArr = ChunkedArray.compact(state.positions) as Float32Array;
const normArr = ChunkedArray.compact(state.normals) as Float32Array;
const posIdxArr = ChunkedArray.compact(state.positionIndices) as Int32Array;
const normIdxArr = ChunkedArray.compact(state.normalIndices) as Int32Array;
const grpIdxArr = ChunkedArray.compact(state.groupIndices) as Uint32Array;
const result: ObjFile = {
positions: posArr,
normals: normArr,
positionIndices: posIdxArr,
normalIndices: normIdxArr,
groupIndices: grpIdxArr,
groups: state.groups,
positionCount: state.positions.elementCount,
normalCount: state.normals.elementCount,
triangleCount: posIdxArr.length / 3
};
return Result.success(result, state.warnings);
}
export function parseObj(data: StringLike) {
return Task.create<Result<ObjFile>>('Parse OBJ', async ctx => {
return await parseInternal(data, ctx);
});
}

View File

@@ -0,0 +1,45 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
/**
* Intermediate representation of a parsed OBJ file.
*
* Positions and normals are stored as raw arrays from the file.
* Faces have been triangulated (fan-triangulation) and are stored as
* separate flat arrays of per-triangle-vertex position and normal indices.
* Normal indices of -1 indicate that the vertex has no explicit normal.
*/
export interface ObjFile {
/** Raw position data from `v` lines, interleaved [x0,y0,z0, x1,y1,z1, ...] */
readonly positions: Float32Array
/** Raw normal data from `vn` lines, interleaved [nx0,ny0,nz0, ...]. Length 0 if no normals. */
readonly normals: Float32Array
/**
* Per-face-vertex position index (0-based), length = triangleCount * 3.
* Three consecutive values define one triangle: [p0, p1, p2, p3, p4, p5, ...]
*/
readonly positionIndices: Int32Array
/**
* Per-face-vertex normal index (0-based), length = triangleCount * 3.
* -1 means no explicit normal for that face-vertex.
*/
readonly normalIndices: Int32Array
/**
* Material/group index per triangle, length = triangleCount.
* Index into the `groups` array.
*/
readonly groupIndices: Uint32Array
/** Ordered list of material group names, populated from `usemtl` directives. */
readonly groups: ReadonlyArray<string>
readonly positionCount: number
readonly normalCount: number
readonly triangleCount: number
}

View File

@@ -0,0 +1,194 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { RuntimeContext, Task } from '../../mol-task';
import { ShapeProvider } from '../../mol-model/shape/provider';
import { Color } from '../../mol-util/color';
import { ObjFile } from '../../mol-io/reader/obj/schema';
import { MeshBuilder } from '../../mol-geo/geometry/mesh/mesh-builder';
import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
import { Shape } from '../../mol-model/shape';
import { ChunkedArray } from '../../mol-data/util';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { ColorNames } from '../../mol-util/color/names';
import { deepClone } from '../../mol-util/object';
import { Mat4 } from '../../mol-math/linear-algebra/3d/mat4';
import { distinctColors } from '../../mol-util/color/distinct';
import { ValueCell } from '../../mol-util/value-cell';
export type ObjData = {
source: ObjFile,
transforms?: Mat4[],
}
export const ObjShapeParams = {
...Mesh.Params,
coloring: PD.MappedStatic('group', {
group: PD.Group({}),
uniform: PD.Group({
color: PD.Color(ColorNames.grey),
}, { isFlat: true })
}),
};
export type ObjShapeParams = typeof ObjShapeParams
/**
* Build an expanded mesh from indexed OBJ data.
*
* OBJ stores positions and normals as indexed arrays (each face-vertex
* references a position index and an optional normal index). Mesh.create
* requires a flat vertex array, so we expand the indexed data.
*
* A vertex key encodes (posIdx, normIdx, groupIdx) so that a position
* shared between faces of different material groups or with different normals
* gets a distinct mesh vertex — ensuring each vertex has one unambiguous
* group ID and normal.
*/
async function getMesh(ctx: RuntimeContext, obj: ObjFile, mesh?: Mesh): Promise<Mesh> {
const { positions, normals, positionIndices, normalIndices, groupIndices, triangleCount } = obj;
const hasNormals = obj.normalCount > 0;
const builderState = MeshBuilder.createState(triangleCount * 3, triangleCount, mesh);
const { vertices, normals: normBuf, indices, groups } = builderState;
// Group count for the composite dedup key; groups always contains at least 'default'.
const groupCount = Math.max(1, obj.groups.length);
// Map from position index to a map of (normal index, group) composite keys to expanded vertex index.
// Avoids allocating a string key per face-vertex on the hot path.
const vertexMap = new Map<number, Map<number, number>>();
const getVertex = (pi: number, ni: number, groupId: number): number => {
let inner = vertexMap.get(pi);
if (inner === undefined) {
inner = new Map<number, number>();
vertexMap.set(pi, inner);
}
const subKey = (ni + 1) * groupCount + groupId;
let idx = inner.get(subKey);
if (idx === undefined) {
idx = vertices.elementCount;
inner.set(subKey, idx);
const po = pi * 3;
ChunkedArray.add3(vertices, positions[po], positions[po + 1], positions[po + 2]);
if (hasNormals && ni >= 0) {
const no = ni * 3;
ChunkedArray.add3(normBuf, normals[no], normals[no + 1], normals[no + 2]);
} else {
ChunkedArray.add3(normBuf, 0, 0, 0);
}
ChunkedArray.add(groups, groupId);
}
return idx;
};
const updateChunk = 50000;
for (let t = 0; t < triangleCount; ++t) {
const triOffset = t * 3;
const groupId = groupIndices[t];
const i0 = getVertex(positionIndices[triOffset], hasNormals ? normalIndices[triOffset] : -1, groupId);
const i1 = getVertex(positionIndices[triOffset + 1], hasNormals ? normalIndices[triOffset + 1] : -1, groupId);
const i2 = getVertex(positionIndices[triOffset + 2], hasNormals ? normalIndices[triOffset + 2] : -1, groupId);
ChunkedArray.add3(indices, i0, i1, i2);
if (t % updateChunk === 0 && ctx.shouldUpdate) {
await ctx.update({ message: 'Building OBJ mesh', current: t, max: triangleCount });
}
}
const m = MeshBuilder.getMesh(builderState);
if (!hasNormals) Mesh.computeNormals(m);
ValueCell.updateIfChanged(m.varyingGroup, true);
return m;
}
type GroupColors = { kind: 'group', colors: Color[] } | { kind: 'uniform', color: Color }
function getColoring(obj: ObjFile, props: PD.Values<ObjShapeParams>): GroupColors {
const { coloring } = props;
if (coloring.name === 'uniform') {
return { kind: 'uniform', color: coloring.params.color };
}
// Generate distinct colors for each group
const n = Math.max(1, obj.groups.length);
const colors = distinctColors(n);
return { kind: 'group', colors };
}
function createShape(objData: ObjData, mesh: Mesh, groupColors: GroupColors) {
const { source, transforms } = objData;
return Shape.create(
'obj-mesh', source, mesh,
(groupId: number) => {
if (groupColors.kind === 'uniform') return groupColors.color;
const idx = Math.min(groupId, groupColors.colors.length - 1);
return groupColors.colors[idx];
},
() => 1,
(groupId: number) => {
const name = source.groups[groupId] ?? `Group ${groupId}`;
return name;
},
transforms
);
}
function makeShapeGetter() {
let _objData: ObjData | undefined;
let _props: PD.Values<ObjShapeParams> | undefined;
let _shape: Shape<Mesh>;
let _mesh: Mesh;
let _groupColors: GroupColors;
const getShape = async (ctx: RuntimeContext, objData: ObjData, props: PD.Values<ObjShapeParams>, shape?: Shape<Mesh>) => {
let newMesh = false;
let newColor = false;
if (!_objData || _objData !== objData) {
newMesh = true;
}
if (!_props || !PD.isParamEqual(ObjShapeParams.coloring, _props.coloring, props.coloring)) {
newColor = true;
}
if (newMesh) {
_mesh = await getMesh(ctx, objData.source, shape && shape.geometry);
_groupColors = getColoring(objData.source, props);
_shape = createShape(objData, _mesh, _groupColors);
} else if (newColor) {
_groupColors = getColoring(objData.source, props);
_shape = createShape(objData, _mesh, _groupColors);
}
_objData = objData;
_props = deepClone(props);
return _shape;
};
return getShape;
}
export function shapeFromObj(source: ObjFile, params?: { transforms?: Mat4[] }) {
return Task.create<ShapeProvider<ObjData, Mesh, ObjShapeParams>>('Shape Provider', async _ctx => {
return {
label: 'Mesh',
data: { source, transforms: params?.transforms },
params: ObjShapeParams,
getShape: makeShapeGetter(),
geometryUtils: Mesh.Utils
};
});
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -23,7 +23,31 @@ export const PlyProvider = DataFormatProvider({
.to(data)
.apply(StateTransforms.Data.ParsePly, {}, { state: { isGhost: true } });
const shape = format.apply(StateTransforms.Model.ShapeFromPly);
const shape = format.apply(StateTransforms.Shape.ShapeFromPly);
await format.commit();
return { format: format.selector, shape: shape.selector };
},
visuals(plugin: PluginContext, data: { shape: StateObjectRef<PluginStateObject.Shape.Provider> }) {
const repr = plugin.state.data.build()
.to(data.shape)
.apply(StateTransforms.Representation.ShapeRepresentation3D);
return repr.commit();
}
});
export const ObjProvider = DataFormatProvider({
label: 'OBJ',
description: 'OBJ',
category: ShapeFormatCategory,
stringExtensions: ['obj'],
parse: async (plugin, data) => {
const format = plugin.state.data.build()
.to(data)
.apply(StateTransforms.Data.ParseObj, {}, { state: { isGhost: true } });
const shape = format.apply(StateTransforms.Shape.ShapeFromObj);
await format.commit();
@@ -39,6 +63,7 @@ export const PlyProvider = DataFormatProvider({
export const BuiltInShapeFormats = [
['ply', PlyProvider] as const,
['obj', ObjProvider] as const,
] as const;
export type BuildInShapeFormat = (typeof BuiltInShapeFormats)[number][0]

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -10,6 +10,7 @@ import { CifFile } from '../mol-io/reader/cif';
import { DcdFile } from '../mol-io/reader/dcd/parser';
import { Dsn6File } from '../mol-io/reader/dsn6/schema';
import { PlyFile } from '../mol-io/reader/ply/schema';
import { ObjFile } from '../mol-io/reader/obj/schema';
import { PsfFile } from '../mol-io/reader/psf/parser';
import { ShapeProvider } from '../mol-model/shape/provider';
import { Coordinates as _Coordinates, Model as _Model, Structure as _Structure, Trajectory as _Trajectory, StructureElement, Topology as _Topology } from '../mol-model/structure';
@@ -79,6 +80,7 @@ export namespace PluginStateObject {
export class Prmtop extends Create<PrmtopFile>({ name: 'PRMTOP File', typeClass: 'Data' }) { }
export class Top extends Create<TopFile>({ name: 'TOP File', typeClass: 'Data' }) { }
export class Ply extends Create<PlyFile>({ name: 'PLY File', typeClass: 'Data' }) { }
export class Obj extends Create<ObjFile>({ name: 'OBJ File', typeClass: 'Data' }) { }
export class Ccp4 extends Create<Ccp4File>({ name: 'CCP4/MRC/MAP File', typeClass: 'Data' }) { }
export class Dsn6 extends Create<Dsn6File>({ name: 'DSN6/BRIX File', typeClass: 'Data' }) { }
export class Dx extends Create<DxFile>({ name: 'DX File', typeClass: 'Data' }) { }

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -10,6 +10,7 @@ import * as CCP4 from '../../mol-io/reader/ccp4/parser';
import { CIF } from '../../mol-io/reader/cif';
import * as DSN6 from '../../mol-io/reader/dsn6/parser';
import * as PLY from '../../mol-io/reader/ply/parser';
import * as OBJ from '../../mol-io/reader/obj/parser';
import { parsePsf } from '../../mol-io/reader/psf/parser';
import { PluginContext } from '../../mol-plugin/context';
import { StateObject, StateTransformer } from '../../mol-state';
@@ -41,6 +42,7 @@ export { ParsePsf };
export { ParsePrmtop };
export { ParseTop };
export { ParsePly };
export { ParseObj };
export { ParseCcp4 };
export { ParseDsn6 };
export { ParseDx };
@@ -403,6 +405,22 @@ const ParsePly = PluginStateTransform.BuiltIn({
}
});
type ParseObj = typeof ParseObj
const ParseObj = PluginStateTransform.BuiltIn({
name: 'parse-obj',
display: { name: 'Parse OBJ', description: 'Parse OBJ from String data' },
from: [SO.Data.String],
to: SO.Format.Obj
})({
apply({ a }) {
return Task.create('Parse OBJ', async ctx => {
const parsed = await OBJ.parseObj(a.data).runInContext(ctx);
if (parsed.isError) throw new Error(parsed.message);
return new SO.Format.Obj(parsed.result, { label: 'OBJ Data' });
});
}
});
type ParseCcp4 = typeof ParseCcp4
const ParseCcp4 = PluginStateTransform.BuiltIn({
name: 'parse-ccp4',

View File

@@ -10,8 +10,7 @@
import { parseDcd } from '../../mol-io/reader/dcd/parser';
import { parseGRO } from '../../mol-io/reader/gro/parser';
import { parsePDB } from '../../mol-io/reader/pdb/parser';
import { Mat4, Vec3 } from '../../mol-math/linear-algebra';
import { shapeFromPly } from '../../mol-model-formats/shape/ply';
import { Vec3 } from '../../mol-math/linear-algebra';
import { coordinatesFromDcd } from '../../mol-model-formats/structure/dcd';
import { trajectoryFromGRO } from '../../mol-model-formats/structure/gro';
import { trajectoryFromCCD, trajectoryFromMmCIF } from '../../mol-model-formats/structure/mmcif';
@@ -93,7 +92,6 @@ export { StructureComplexElement };
export { StructureComponent };
export { CustomModelProperties };
export { CustomStructureProperties };
export { ShapeFromPly };
type CoordinatesFromDcd = typeof CoordinatesFromDcd
const CoordinatesFromDcd = PluginStateTransform.BuiltIn({
@@ -1295,25 +1293,3 @@ async function attachStructureProps(structure: Structure, ctx: PluginContext, ta
}
}
}
type ShapeFromPly = typeof ShapeFromPly
const ShapeFromPly = PluginStateTransform.BuiltIn({
name: 'shape-from-ply',
display: { name: 'Shape from PLY', description: 'Create Shape from PLY data' },
from: SO.Format.Ply,
to: SO.Shape.Provider,
params(a) {
return {
transforms: PD.Optional(PD.Value([Mat4.identity()], { isHidden: true })),
label: PD.Optional(PD.Text('', { isHidden: true }))
};
}
})({
apply({ a, params }) {
return Task.create('Create shape from PLY', async ctx => {
const shape = await shapeFromPly(a.data, params).runInContext(ctx);
const props = { label: params.label || 'Shape' };
return new SO.Shape.Provider(shape, props);
});
}
});

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2021-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
@@ -9,6 +10,8 @@ import { MeshBuilder } from '../../mol-geo/geometry/mesh/mesh-builder';
import { BoxCage } from '../../mol-geo/primitive/box';
import { Box3D, Sphere3D } from '../../mol-math/geometry';
import { Mat4, Vec3 } from '../../mol-math/linear-algebra';
import { shapeFromObj } from '../../mol-model-formats/shape/obj';
import { shapeFromPly } from '../../mol-model-formats/shape/ply';
import { Shape } from '../../mol-model/shape';
import { Task } from '../../mol-task';
import { ColorNames } from '../../mol-util/color/names';
@@ -67,3 +70,49 @@ export function getBoxMesh(box: Box3D, radius: number, oldMesh?: Mesh) {
return mesh;
}
export { ShapeFromPly };
type ShapeFromPly = typeof ShapeFromPly
const ShapeFromPly = PluginStateTransform.BuiltIn({
name: 'shape-from-ply',
display: { name: 'Shape from PLY', description: 'Create Shape from PLY data' },
from: SO.Format.Ply,
to: SO.Shape.Provider,
params(a) {
return {
transforms: PD.Optional(PD.Value([Mat4.identity()], { isHidden: true })),
label: PD.Optional(PD.Text('', { isHidden: true }))
};
}
})({
apply({ a, params }) {
return Task.create('Create shape from PLY', async ctx => {
const shape = await shapeFromPly(a.data, params).runInContext(ctx);
const props = { label: params.label || 'Shape' };
return new SO.Shape.Provider(shape, props);
});
}
});
export { ShapeFromObj };
type ShapeFromObj = typeof ShapeFromObj
const ShapeFromObj = PluginStateTransform.BuiltIn({
name: 'shape-from-obj',
display: { name: 'Shape from OBJ', description: 'Create Shape from OBJ data' },
from: SO.Format.Obj,
to: SO.Shape.Provider,
params(a) {
return {
transforms: PD.Optional(PD.Value([Mat4.identity()], { isHidden: true })),
label: PD.Optional(PD.Text('', { isHidden: true }))
};
}
})({
apply({ a, params }) {
return Task.create('Create shape from OBJ', async ctx => {
const shape = await shapeFromObj(a.data, params).runInContext(ctx);
const props = { label: params.label || 'Shape' };
return new SO.Shape.Provider(shape, props);
});
}
});