mirror of
https://github.com/molstar/molstar.git
synced 2026-06-07 07:04:22 +08:00
Compare commits
2 Commits
app-load-u
...
obj-format
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee2556421 | ||
|
|
6550771d65 |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
403
src/mol-io/reader/_spec/obj.spec.ts
Normal file
403
src/mol-io/reader/_spec/obj.spec.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* 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 — should be silently skipped
|
||||
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 (s, mtllib, vt, vp, g, o, usemtl) 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: silently skipped like any other usemtl
|
||||
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
|
||||
`;
|
||||
|
||||
// combined: object + group + material on the same triangles — all directives silently skipped
|
||||
const objAllThree = `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
|
||||
o MyObj
|
||||
g MyGroup
|
||||
usemtl MyMtl
|
||||
f 1 2 3
|
||||
f 2 4 5
|
||||
`;
|
||||
|
||||
// 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('tracks usemtl directives into materialNames and faceGroups', async () => {
|
||||
const parsed = await parseObj(objMultiMaterial).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.triangleCount).toBe(2);
|
||||
expect(obj.materialNames).toEqual(['red', 'green']);
|
||||
// triangle 0 → red (0), triangle 1 → green (1)
|
||||
expect(Array.from(obj.faceGroups)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
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 arrays are present', 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);
|
||||
});
|
||||
|
||||
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('deduplicates reused usemtl material names and maps faceGroups correctly', 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" and "green" each appear once in materialNames
|
||||
expect(obj.materialNames).toEqual(['red', 'green']);
|
||||
// triangle 0 → red (0), triangle 1 → green (1), triangle 2 → red (0) again
|
||||
expect(Array.from(obj.faceGroups)).toEqual([0, 1, 0]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('silently skips g and o directives; tracks usemtl into materialNames and faceGroups', async () => {
|
||||
const parsed = await parseObj(objAllThree).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.triangleCount).toBe(2);
|
||||
expect(obj.materialNames).toEqual(['MyMtl']);
|
||||
// both triangles belong to the single material
|
||||
expect(Array.from(obj.faceGroups)).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,319 @@
|
||||
/**
|
||||
* 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>
|
||||
faceGroups: ChunkedArray<number, 1>
|
||||
materialNames: string[]
|
||||
materialMap: Map<string, number>
|
||||
currentMaterialIdx: 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),
|
||||
faceGroups: ChunkedArray.create(Int32Array, 1, 1024),
|
||||
materialNames: [],
|
||||
materialMap: new Map(),
|
||||
currentMaterialIdx: 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 handleUseMtl(state: State): void {
|
||||
const { tokenizer } = state;
|
||||
if (readInlineToken(tokenizer)) {
|
||||
const name = tokenizer.data.substring(tokenizer.tokenStart, tokenizer.tokenEnd);
|
||||
if (!state.materialMap.has(name)) {
|
||||
const idx = state.materialNames.length;
|
||||
state.materialMap.set(name, idx);
|
||||
state.materialNames.push(name);
|
||||
}
|
||||
state.currentMaterialIdx = state.materialMap.get(name)!;
|
||||
}
|
||||
skipLine(tokenizer);
|
||||
}
|
||||
|
||||
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];
|
||||
const group = state.currentMaterialIdx;
|
||||
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.faceGroups, group);
|
||||
}
|
||||
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_u) {
|
||||
// Check for "usemtl "
|
||||
const p = tokenizer.position;
|
||||
if (
|
||||
p + 6 < tokenizer.length &&
|
||||
tokenizer.data.charCodeAt(p + 1) === CC_s &&
|
||||
tokenizer.data.charCodeAt(p + 2) === CC_e &&
|
||||
tokenizer.data.charCodeAt(p + 3) === CC_m &&
|
||||
tokenizer.data.charCodeAt(p + 4) === CC_t &&
|
||||
tokenizer.data.charCodeAt(p + 5) === CC_l &&
|
||||
(tokenizer.data.charCodeAt(p + 6) === CC_SPACE || tokenizer.data.charCodeAt(p + 6) === CC_TAB)
|
||||
) {
|
||||
tokenizer.position += 7;
|
||||
handleUseMtl(state);
|
||||
} else {
|
||||
skipLine(tokenizer);
|
||||
}
|
||||
} 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 {
|
||||
// "g", "o", "s", "usemtl", "mtllib", 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 faceGroupsArr = ChunkedArray.compact(state.faceGroups) as Int32Array;
|
||||
|
||||
const result: ObjFile = {
|
||||
positions: posArr,
|
||||
normals: normArr,
|
||||
positionIndices: posIdxArr,
|
||||
normalIndices: normIdxArr,
|
||||
positionCount: state.positions.elementCount,
|
||||
normalCount: state.normals.elementCount,
|
||||
triangleCount: posIdxArr.length / 3,
|
||||
materialNames: state.materialNames,
|
||||
faceGroups: faceGroupsArr
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
48
src/mol-io/reader/obj/schema.ts
Normal file
48
src/mol-io/reader/obj/schema.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
readonly positionCount: number
|
||||
readonly normalCount: number
|
||||
readonly triangleCount: number
|
||||
|
||||
/**
|
||||
* Unique material names encountered via `usemtl` directives, in encounter order.
|
||||
* Empty when no `usemtl` directives are present.
|
||||
*/
|
||||
readonly materialNames: readonly string[]
|
||||
|
||||
/**
|
||||
* Per-triangle material index (0-based, indexes into materialNames), length = triangleCount.
|
||||
* 0 for faces before the first `usemtl` or when no materials are defined.
|
||||
*/
|
||||
readonly faceGroups: Int32Array
|
||||
}
|
||||
176
src/mol-model-formats/shape/obj.ts
Normal file
176
src/mol-model-formats/shape/obj.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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 { Mat4 } from '../../mol-math/linear-algebra/3d/mat4';
|
||||
import { distinctColors } from '../../mol-util/color/distinct';
|
||||
|
||||
export type ObjData = {
|
||||
source: ObjFile,
|
||||
transforms?: Mat4[],
|
||||
}
|
||||
|
||||
function createObjShapeParams(objFile?: ObjFile) {
|
||||
const materialNames = objFile?.materialNames ?? [];
|
||||
const hasMaterials = materialNames.length > 0;
|
||||
|
||||
const defaultColors = materialNames.length > 1
|
||||
? distinctColors(materialNames.length)
|
||||
: materialNames.length === 1 ? [ColorNames.grey] : [];
|
||||
|
||||
const materialColorParams: Record<string, PD.Color> = {};
|
||||
for (let i = 0; i < materialNames.length; ++i) {
|
||||
materialColorParams[materialNames[i]] = PD.Color(defaultColors[i]);
|
||||
}
|
||||
|
||||
return {
|
||||
...Mesh.Params,
|
||||
coloring: PD.MappedStatic(hasMaterials ? 'material' : 'uniform', {
|
||||
uniform: PD.Group({
|
||||
color: PD.Color(ColorNames.grey),
|
||||
}, { isFlat: true }),
|
||||
material: PD.Group(materialColorParams, { isFlat: false }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const ObjShapeParams = createObjShapeParams();
|
||||
export type ObjShapeParams = typeof ObjShapeParams
|
||||
|
||||
/**
|
||||
* Resolve one Color per material group from the current params.
|
||||
* The returned array has length = max(1, materialNames.length).
|
||||
*/
|
||||
function getMaterialColors(materialNames: readonly string[], props: PD.Values<ObjShapeParams>): Color[] {
|
||||
const count = Math.max(1, materialNames.length);
|
||||
const { coloring } = props;
|
||||
if (coloring.name === 'uniform') {
|
||||
return Array<Color>(count).fill(coloring.params.color);
|
||||
} else {
|
||||
// material: read one color per material name from dynamic params
|
||||
if (materialNames.length === 0) return [ColorNames.grey];
|
||||
const params = coloring.params as Record<string, Color>;
|
||||
return materialNames.map(name => params[name] ?? ColorNames.grey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 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) so that a shared position with
|
||||
* different normals gets a distinct mesh vertex.
|
||||
*/
|
||||
async function getMesh(ctx: RuntimeContext, obj: ObjFile, mesh?: Mesh): Promise<Mesh> {
|
||||
const { positions, normals, positionIndices, normalIndices, triangleCount, faceGroups } = obj;
|
||||
const hasNormals = obj.normalCount > 0;
|
||||
|
||||
const builderState = MeshBuilder.createState(triangleCount * 3, triangleCount, mesh);
|
||||
const { vertices, normals: normBuf, indices, groups } = builderState;
|
||||
|
||||
const updateChunk = 50000;
|
||||
|
||||
for (let t = 0; t < triangleCount; ++t) {
|
||||
const triOffset = t * 3;
|
||||
const base = t * 3;
|
||||
|
||||
for (let v = 0; v < 3; ++v) {
|
||||
const pi = positionIndices[triOffset + v];
|
||||
const po = pi * 3;
|
||||
ChunkedArray.add3(vertices, positions[po], positions[po + 1], positions[po + 2]);
|
||||
|
||||
const ni = hasNormals ? normalIndices[triOffset + v] : -1;
|
||||
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, faceGroups[t]);
|
||||
}
|
||||
|
||||
ChunkedArray.add3(indices, base, base + 1, base + 2);
|
||||
|
||||
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);
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
function createShape(objData: ObjData, mesh: Mesh, colors: Color[]) {
|
||||
const { source, transforms } = objData;
|
||||
const { materialNames } = source;
|
||||
return Shape.create(
|
||||
'obj-mesh', source, mesh,
|
||||
(groupId: number) => colors[Math.min(groupId, colors.length - 1)],
|
||||
() => 1,
|
||||
(groupId: number) => materialNames.length > 0 ? (materialNames[groupId] ?? 'OBJ Mesh') : 'OBJ Mesh',
|
||||
transforms,
|
||||
colors.length
|
||||
);
|
||||
}
|
||||
|
||||
function makeShapeGetter() {
|
||||
let _objData: ObjData | undefined;
|
||||
let _colors: Color[] | undefined;
|
||||
|
||||
let _shape: Shape<Mesh>;
|
||||
let _mesh: Mesh;
|
||||
|
||||
const getShape = async (ctx: RuntimeContext, objData: ObjData, props: PD.Values<ObjShapeParams>, shape?: Shape<Mesh>) => {
|
||||
const newMesh = !_objData || _objData !== objData;
|
||||
|
||||
const nextColors = getMaterialColors(objData.source.materialNames, props);
|
||||
const newColor = !_colors
|
||||
|| nextColors.length !== _colors.length
|
||||
|| nextColors.some((c, i) => c !== _colors![i]);
|
||||
|
||||
if (newMesh) {
|
||||
_colors = nextColors;
|
||||
_mesh = await getMesh(ctx, objData.source, shape && shape.geometry);
|
||||
_shape = createShape(objData, _mesh, _colors);
|
||||
} else if (newColor) {
|
||||
_colors = nextColors;
|
||||
_shape = createShape(objData, _mesh, _colors);
|
||||
}
|
||||
|
||||
_objData = objData;
|
||||
|
||||
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: createObjShapeParams(source),
|
||||
getShape: makeShapeGetter(),
|
||||
geometryUtils: Mesh.Utils,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
@@ -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' }) { }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user