Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Rose
cee2556421 obj improvements
- per-material color param
- faster shape creation
2026-06-06 08:37:47 -07:00
Alexander Rose
6550771d65 add obj format support 2026-05-31 22:14:45 -07:00
27 changed files with 1041 additions and 3326 deletions

View File

@@ -14,15 +14,13 @@ Note that since we don't clearly distinguish between a public and private interf
- Add mesoscale representation preset
- Add presets option to `ObjectList` param definition
- Fix memory leak in `State.dispose()` not invoking transformer `dispose` callbacks for live cells
- Adds File/Open and drag-and-drop support for Kinemage files in the viewer app
- Fix bugs in ModelServer surroundingLigands endpoint, resulting in omitWater not honored
- Fix `Volume` and `Isosurface` getBoundingSphere ignoring instances
- Fix aromatic ring detection not accounting for hybridization
- Add axis param to camera spin/rock animation
- Fix SSAO half/quarter resolution textures for multi-scale
- Non-covalent interactions: water bridge support
- Download Structure From AlphaFoldDB allows IDs with version suffix (version is ignored)
- Add `loadUrl` method and GET params to Viewer app
- Add OBJ format support
## [v5.9.0] - 2026-05-03
- Fix edge case when `PluginSpec.animations` is empty

View File

@@ -125,8 +125,7 @@
"Kim Juho <juho_kim@outlook.com>",
"Victoria Doshchenko <doshchenko.victoria@gmail.com>",
"Diego del Alamo <diego.delalamo@gmail.com>",
"Tianzhen Lin (Tangent) <tangent@usa.net>",
"Russ Taylor <russ@reliasolve.com>"
"Tianzhen Lin (Tangent) <tangent@usa.net>"
],
"license": "MIT",
"devDependencies": {

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

@@ -14,7 +14,7 @@ import { MVSData } from '../../extensions/mvs/mvs-data';
import { StringLike } from '../../mol-io/common/string-like';
import { Structure, StructureElement } from '../../mol-model/structure';
import { Volume } from '../../mol-model/volume';
import { DownloadFile, OpenFiles } from '../../mol-plugin-state/actions/file';
import { OpenFiles } from '../../mol-plugin-state/actions/file';
import { DownloadStructure, PdbDownloadProvider } from '../../mol-plugin-state/actions/structure';
import { DownloadDensity } from '../../mol-plugin-state/actions/volume';
import { PresetTrajectoryHierarchy } from '../../mol-plugin-state/builder/structure/hierarchy-preset';
@@ -523,17 +523,6 @@ export class Viewer {
}
}
loadUrl(url: string, format: string, isBinary = false) {
return this.plugin.runTask(Task.create('Load URL', async taskCtx => {
await this.plugin.state.data.applyAction(DownloadFile, {
url: Asset.Url(url),
format,
isBinary,
visuals: true
}).runInContext(taskCtx);
}));
}
handleResize() {
this.plugin.layout.events.updated.next(void 0);
}

View File

@@ -4,7 +4,6 @@
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Adam Midlik <midlik@gmail.com>
* @author Russ Taylor <russ@reliasolve.com>
*/
import { ANVILMembraneOrientation } from '../../extensions/anvil/behavior';
@@ -29,7 +28,6 @@ import { ZenodoImport } from '../../extensions/zenodo';
import { PluginSpec } from '../../mol-plugin/spec';
import { MVSData } from '../../extensions/mvs/mvs-data';
import * as MVSUtil from '../../extensions/mvs/util';
import { KinemageExtension } from '../../extensions/kinemage/behavior';
export const ExtensionMap = {
// Mol* built-in extensions
@@ -41,7 +39,6 @@ export const ExtensionMap = {
'geo-export': PluginSpec.Behavior(GeometryExport),
'zenodo-import': PluginSpec.Behavior(ZenodoImport),
'wwpdb-chemical-component-dictionary': PluginSpec.Behavior(wwPDBChemicalComponentDictionary),
'kinemage': PluginSpec.Behavior(KinemageExtension),
// 3rd party extensions
'pdbe-structure-quality-report': PluginSpec.Behavior(PDBeStructureQualityReport),

View File

@@ -129,11 +129,6 @@
var modelArchive = getParam('model-archive', '[^&]+').trim();
if (modelArchive) viewer.loadModelArchive(modelArchive);
var url = getParam('url', '[^&]+').trim();
var urlFormat = getParam('url-format', '[^&]+').trim() || undefined;
var urlIsBinary = getParam('url-is-binary', '[^&]+').trim() === '1';
if (url && urlFormat) viewer.loadUrl(url, urlFormat, urlIsBinary);
window.addEventListener('unload', () => {
// to aid GC
viewer.dispose();

View File

@@ -1,28 +0,0 @@
# Kinemage extension
This extension adds support for the Kinemage molecular graphics format based on the
[kinemage format specification](http://kinemage.biochem.duke.edu/static/files/PDFs/format-kinemage.pdf).
It currently supports the following features:
- Drag-and-drop of Kinemage files into the display area
- Open File can open Kinemage files from the local filesystem
- Display of @ball, @sphere, @vector, @dot, @ribbon, and @triangle lists
- Coloring of objects by vertex color, or by a single color for the entire list
- Hovering over objects to see their labels (if present)
- When there are views defined, controls are added to the right panel; when selected, they shift the view
- When the view is changes, the projection is set to orthographic and the background is set to black to match Kinemage's default view
- Control panel names are based on the @pdbfile or @caption in the Kinemage file if there is one
- Lines are split in half, with each half colored by and labeled by the nearest vertex
- Master and submaster selections of visible objects
- Group and subgroup hierarchy with buttons to control visibility
- @pointmaster lists controlling visibility of points
- animate/2animate: First entry turned on to start, changing visibility of Animate button cycles through them
Currently unsupported features include:
- @label and @ring lists
- @hsvcolor keyword for coloring by hue, saturation, and value
- 'fore' and 'rear' keywords for different front and back colors
Current limitations include:
- Triangles are a single color, not colored by vertex (Mol* does not support per-vertex coloring for these primitives)
- Line segments in Mol* do not support end-caps for wide lines, so there are artifacts in highly-curved lines

View File

@@ -1,188 +0,0 @@
/**
* Copyright (c) 2025-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author ReliaSolve <russ@reliasolve.com>
*/
import { parseKin } from '../reader/parser';
const kinString = `@kinemage 1
@caption probe.2.26.021123, run Tue Apr 23 14:49:17 2024
command: C:\tmp\cctbx_phenix\build\probe\exe\probe.exe -kin -mc -het -once -wat2wat -onlybadout -stdbonds water all 1ssxFH.pdb
@group dominant {dots}
@subgroup dominant {once dots}
@master {bad overlap}
@pointmaster 'O' {Hets contacts}
@vectorlist {x} color=red master={bad overlap}
{ O HOH 319 A}hotpink P 'O' 31.146,32.100,-1.425 {"}hotpink 'O' 31.015,32.234,-1.324
{"}hotpink P 'O' 31.607,32.750,-1.156 {"}hotpink 'O' 31.410,32.784,-1.097
{"}hotpink P 'O' 31.263,32.074,-1.185 {"}hotpink 'O' 31.117,32.209,-1.122
{ O BHOH 338 A}hotpink P 'O' 32.540,45.631,10.833 {"}hotpink 'O' 32.430,45.771,10.977
{"}hotpink P 'O' 32.316,45.500,10.828 {"}hotpink 'O' 32.230,45.689,10.998
{"}hotpink P 'O' 32.068,45.424,10.824 {"}hotpink 'O' 32.034,45.604,10.975
{"}hotpink P 'O' 32.729,45.605,11.052 {"}hotpink 'O' 32.572,45.765,11.173
`;
// Complex kinemage with multiple features: animate groups, pointmasters, various list types
const kinComplexString = `@kinemage 1
@caption Complex test kinemage with multiple features
@text
This is a comprehensive test kinemage file that includes:
- Multiple groups with animate and 2animate
- Pointmasters with tags
- All list types: dots, vectors, balls, spheres, ribbons, triangles
@master {main} on
@master {secondary} off
@master {alternate}
@pointmaster 'ABC' {Primary atoms} on
@pointmaster 'XY' {Secondary atoms} off
@group {Structure} animate dominant
@subgroup {Backbone}
@vectorlist {CA trace} color=blue master={main}
{CA ALA 1}blue P 'A' 10.0,20.0,30.0 {CA ALA 2}blue 'A' 11.0,21.0,31.0
{"}blue 'A' 12.0,22.0,32.0 {CA ALA 3}blue 'A' 13.0,23.0,33.0
@dotlist {H-bonds} color=yellow master={main}
{HN ALA 2}yellow 'B' 10.5,20.5,30.5
{HN ALA 3}yellow 'B' 11.5,21.5,31.5
{HN ALA 4}yellow 'B' 12.5,22.5,32.5
@subgroup {Sidechains}
@balllist {CB atoms} color=green master={secondary} radius=0.5
{CB ARG 1}green r=0.5 'C' 9.0,19.0,29.0
{CB ARG 2}green r=0.6 'C' 10.0,20.0,30.0
{CB ARG 3}green r=0.55 'C' 11.0,21.0,31.0
@group {Alternate conformations} 2animate
@subgroup {Alt A}
@spherelist {Waters A} color=cyan master={alternate} radius=1.0
{HOH 101}cyan r=1.0 'X' 15.0,25.0,35.0
{HOH 102}cyan r=1.2 'X' 16.0,26.0,36.0
@subgroup {Alt B}
@spherelist {Waters B} color=magenta master={alternate} radius=1.0
{HOH 101}magenta r=1.0 'Y' 15.2,25.2,35.2
{HOH 102}magenta r=1.1 'Y' 16.1,26.1,36.1
@group {Surface} off
@subgroup {Ribbons}
@ribbonlist {Alpha helix} color=red master={main}
{ASP 5}red 14.0,24.0,34.0
{GLU 6}red 15.0,25.0,35.0
{LYS 7}red 16.0,26.0,36.0
{ARG 8}red 17.0,27.0,37.0
{THR 9}red P 18.0,28.0,38.0
{VAL 10}red 19.0,29.0,39.0
@subgroup {Triangles}
@trianglelist {Surface patch} color=sky master={secondary}
{Tri 1}sky 20.0,30.0,40.0
{Tri 1}sky 21.0,30.0,40.0
{Tri 1}sky 20.5,31.0,40.0
{Tri 2}sky X 22.0,32.0,42.0
{Tri 2}sky 23.0,32.0,42.0
{Tri 2}sky 22.5,33.0,42.0
@group {Contacts} animate
@subgroup {Clashes}
@vectorlist {Bad overlaps} color=hotpink master={main} width=4
{O HOH 319 A}hotpink P 31.146,32.100,-1.425 {O HOH 320 A}hotpink 31.015,32.234,-1.324
{"}hotpink P 31.607,32.750,-1.156 {"}hotpink 31.410,32.784,-1.097
`;
describe('kin reader', () => {
it('basic', async () => {
const parsed = await parseKin(kinString).run();
if (parsed.isError) {
console.error('Parse error:', parsed);
fail('Parse should not error');
}
if (parsed.result.length !== 1) {
fail(`Expected 1 kinemage, got ${parsed.result.length}`);
}
const kinemage = parsed.result[0];
const vectors = kinemage.vectorLists;
expect(vectors.length).toEqual(1);
const element = vectors[0];
expect(element.name).toEqual('x');
expect(element.position1Array.length).toEqual(7*3);
// Test that colors are parsed correctly
expect(element.color1Array.length).toEqual(7);
// Test masters are set up
expect(element.masterArray).toContain('bad overlap');
expect.assertions(5);
});
it('complex', async () => {
const parsed = await parseKin(kinComplexString).run();
if (parsed.isError) {
fail('Parse should not error');
}
expect(parsed.result.length).toBeGreaterThan(0);
const kinemage = parsed.result[0];
// Verify structure is valid
expect(kinemage.vectorLists).toBeDefined();
expect(kinemage.masterDict).toBeDefined();
expect(kinemage.groupDict).toBeDefined();
expect(kinemage.pointmasterDict).toBeDefined();
// Test animate groups
expect(kinemage.groupsAnimate.length).toEqual(2);
expect(kinemage.groupsAnimate).toContain('Structure');
expect(kinemage.groupsAnimate).toContain('Contacts');
expect(kinemage.activeAnimateGroup).toEqual(0);
// Test 2animate groups
expect(kinemage.groupsAnimate2.length).toEqual(1);
expect(kinemage.groupsAnimate2).toContain('Alternate conformations');
expect(kinemage.activeAnimateGroup2).toEqual(0);
// Test pointmasters
expect(Object.keys(kinemage.pointmasterDict).length).toBeGreaterThan(0);
expect(kinemage.pointmasterDict['A']).toEqual('Primary atoms');
expect(kinemage.pointmasterDict['B']).toEqual('Primary atoms');
expect(kinemage.pointmasterDict['X']).toEqual('Secondary atoms');
// Test masters
expect(kinemage.masterDict['main']).toBeDefined();
expect(kinemage.masterDict['main'].visible).toEqual(true);
expect(kinemage.masterDict['secondary']).toBeDefined();
expect(kinemage.masterDict['secondary'].visible).toEqual(false);
// Test list types
expect(kinemage.vectorLists.length).toEqual(2);
expect(kinemage.dotLists.length).toEqual(1);
expect(kinemage.ballLists.length).toEqual(3); // 1 balllist + 2 spherelists
expect(kinemage.ribbonLists.length).toEqual(2); // 1 ribbonlist + 1 trianglelist
// Test specific list properties
const caTrace = kinemage.vectorLists.find(v => v.name === 'CA trace');
expect(caTrace).toBeDefined();
expect(caTrace?.masterArray).toContain('main');
const hBonds = kinemage.dotLists[0];
expect(hBonds.name).toEqual('H-bonds');
expect(hBonds.positionArray.length).toEqual(9); // 3 dots * 3 coords
const cbAtoms = kinemage.ballLists.find(b => b.name === 'CB atoms');
expect(cbAtoms).toBeDefined();
expect(cbAtoms?.radiusArray.length).toEqual(3);
const helix = kinemage.ribbonLists.find(r => r.name === 'Alpha helix');
expect(helix).toBeDefined();
expect(helix?.pairTriangleNormals).toEqual(true); // ribbonlist
const surface = kinemage.ribbonLists.find(r => r.name === 'Surface patch');
expect(surface).toBeDefined();
expect(surface?.pairTriangleNormals).toEqual(false); // trianglelist
// Test groups
expect(Object.keys(kinemage.groupDict).length).toEqual(4);
expect(kinemage.groupDict['Structure'].animate).toEqual(true);
expect(kinemage.groupDict['Alternate conformations']['2animate']).toEqual(true);
expect(kinemage.groupDict['Surface'].off).toEqual(true);
expect.assertions(38);
});
});

View File

@@ -1,663 +0,0 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/** Based on the ../anvil extension. */
import { Vec3, Mat3 } from '../../mol-math/linear-algebra';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { KinemageDataProvider, KinemageData } from './prop';
import { StateTransformer, StateBuilder } from '../../mol-state';
import { Task } from '../../mol-task';
import { PluginBehavior } from '../../mol-plugin/behavior';
import { PluginDragAndDropHandler } from '../../mol-plugin-state/manager/drag-and-drop';
import { PluginStateObject } from '../../mol-plugin-state/objects';
import { PluginContext } from '../../mol-plugin/context';
import { DefaultQueryRuntimeTable } from '../../mol-script/runtime/query/compiler';
import { StateTransforms } from '../../mol-plugin-state/transforms';
import { shapePointsFromKin, shapeLinesFromKin, shapeMeshFromKin, shapeSpheresFromKin } from './kin';
import { Kinemage } from './reader/schema';
import { DataFormatProvider } from '../../mol-plugin-state/formats/provider';
import { Camera } from '../../mol-canvas3d/camera';
import { PluginCommands } from '../../mol-plugin/commands';
import { getPluginBoundingSphere } from '../../mol-plugin-state/manager/focus-camera/focus-object';
import { KinemageControls } from './ui';
import { StateObjectSelector } from '../../mol-state';
import { Color } from '../../mol-util/color';
const Tag = KinemageData.Tag;
const Transform = StateTransformer.builderFactory('sb-kinemage');
/**
* State object to hold parsed Kinemage data
*/
export class KinemageObject extends PluginStateObject.Create<KinemageData>({ name: 'Kinemage', typeClass: 'Object' }) { }
/**
* Visibility state for kinemage elements - stores which items are VISIBLE (not hidden)
*/
export interface KinemageVisibilityState {
/** Map of group name -> visibility (true = visible, false = hidden/off) */
groupVisibility: Map<string, boolean>;
/** Map of subgroup name -> visibility (true = visible, false = hidden/off) */
subgroupVisibility: Map<string, boolean>;
/** Map of master name -> visibility (true = visible, false = hidden) */
masterVisibility: Map<string, boolean>;
activeAnimateGroup: number;
activeAnimateGroup2: number;
}
/**
* Apply a saved snapshot object (from a view state node) to the plugin camera.
* Use PluginCommands.Camera.SetSnapshot so transitions and canvas props are handled properly.
*/
export async function applyViewSnapshot(plugin: PluginContext, snapshot: Partial<Camera.Snapshot>) {
if (!snapshot) return;
// Set background color to black
plugin.canvas3d?.setProps({
renderer: {
...plugin.canvas3d.props.renderer,
backgroundColor: Color(0x000000)
}
});
// If the snapshot provides a target, adjust the canvas `sceneRadiusFactor` so the scene isn't clipped
// when we switch camera.
if (snapshot.target) {
try {
const boundingSphere = getPluginBoundingSphere(plugin);
if (boundingSphere && boundingSphere.radius > 0) {
const offset = Vec3.distance(snapshot.target as Vec3, boundingSphere.center);
const sceneRadiusFactor = (boundingSphere.radius + offset) / boundingSphere.radius;
plugin.canvas3d?.setProps({ sceneRadiusFactor });
}
} catch (e) {
// fallback: ignore errors and continue to set the camera snapshot
console.warn('Failed to adjust sceneRadiusFactor for view snapshot', e);
}
}
await PluginCommands.Camera.SetSnapshot(plugin, { snapshot });
}
/**
* Transform to parse Kinemage data from string/data input
*/
export const ParseKinemage = Transform({
name: 'sb-kinemage-parse',
display: { name: 'Parse Kinemage' },
from: [PluginStateObject.Data.String],
to: KinemageObject,
params: {
label: PD.Optional(PD.Text('', { description: 'Label for the Kinemage data' }))
}
})({
apply({ a, params }) {
return Task.create('Parse Kinemage', async ctx => {
const input = a.data;
let data: KinemageData;
if (typeof input === 'string') {
// Parse from string content
const file = new File([input], 'input.kin', { type: 'text/plain' });
data = await KinemageData.open(file);
} else {
throw new Error('Unsupported input type for ParseKinemage');
}
// Precompute camera snapshots for all views in all kinemages
for (const kinData of data.kinemages) {
(kinData as any).viewSnapshots = (kinData as any).viewSnapshots || Object.create(null);
for (const [viewKey, viewObj] of Object.entries(kinData.viewDict)) {
const center = Vec3.create(0, 0, 0);
if (viewObj.center) {
Vec3.set(center, viewObj.center[0], viewObj.center[1], viewObj.center[2]);
}
const orientation: Mat3 = Mat3.identity();
if (viewObj.matrix) {
Mat3.fromArray(orientation, viewObj.matrix, 0);
Mat3.transpose(orientation, orientation);
}
const zAxis = Vec3.create(0, 0, 1);
Vec3.transformMat3(zAxis, zAxis, orientation);
const yAxis = Vec3.create(0, 1, 0);
Vec3.transformMat3(yAxis, yAxis, orientation);
let distance = 100;
if (viewObj.span) {
distance = viewObj.span;
}
Vec3.scale(zAxis, zAxis, distance);
const position = Vec3.create(0, 0, 100);
Vec3.add(position, center, zAxis);
let radius = 100;
if (viewObj.zslab) {
const scale = viewObj.zslab / 200;
radius = 0.5 * distance * scale;
}
const snap: Camera.Snapshot = {
mode: 'orthographic',
fov: Math.PI / 4,
position,
up: yAxis,
target: center,
radius,
radiusMax: 1e4,
fog: 0,
clipFar: true,
minNear: 1,
minFar: 1
};
(kinData as any).viewSnapshots[viewKey] = snap;
}
}
const label = params.label || data.kinemages[0]?.caption || 'Kinemage';
return new KinemageObject(data, { label, description: `Kinemage with ${data.kinemages.length} kinemage(s)` });
});
}
});
/**
* Transform to select a specific kinemage from parsed data
*/
export const SelectKinemage = Transform({
name: 'sb-kinemage-select',
display: { name: 'Select Kinemage' },
from: KinemageObject,
to: PluginStateObject.Format.Json,
params: (a) => {
const kinemages = a?.data?.kinemages || [];
const options = kinemages.map((k: Kinemage, i: number) => [i, k.pdbfile || k.caption || `Kinemage ${i}`] as const);
return {
index: PD.Select(0, options, { description: 'Which kinemage to use' })
};
}
})({
apply({ a, params }) {
return Task.create('Select Kinemage', async ctx => {
const kinData = a.data.kinemages[params.index];
if (!kinData) {
throw new Error(`No kinemage found at index ${params.index}`);
}
const label = kinData.pdbfile || kinData.caption || `Kinemage ${params.index}`;
// Store the kinemage data in a Format.Json node so downstream transforms can access it
return new PluginStateObject.Format.Json(
{ kinData },
{ label, description: kinData.text || '' }
);
});
}
});
/**
* Visibility Controller Transform - centralizes visibility state for all shape types
* Stores visibility as key-value pairs where key is the item name and value is boolean (true = visible)
*/
export const KinemageVisibilityController = Transform({
name: 'sb-kinemage-visibility-controller',
display: { name: 'Kinemage Visibility Controller' },
from: PluginStateObject.Format.Json,
to: PluginStateObject.Format.Json,
params: (a) => {
const kinData = (a?.data as any)?.kinData as Kinemage | undefined;
if (!kinData) {
return {
groupVisibility: PD.Value<{ [key: string]: boolean }>({}),
subgroupVisibility: PD.Value<{ [key: string]: boolean }>({}),
masterVisibility: PD.Value<{ [key: string]: boolean }>({}),
activeAnimateGroup: PD.Numeric(0, { min: 0, max: 0, step: 1 }, { description: 'Active animate group index' }),
activeAnimateGroup2: PD.Numeric(0, { min: 0, max: 0, step: 1 }, { description: 'Active animate2 group index' })
};
}
// Build initial visibility from parsed data
const groupVisibility: { [key: string]: boolean } = {};
const subgroupVisibility: { [key: string]: boolean } = {};
const masterVisibility: { [key: string]: boolean } = {};
for (const [groupKey, groupInfo] of Object.entries(kinData.groupDict)) {
groupVisibility[groupKey] = !(groupInfo as any).off;
}
for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict)) {
subgroupVisibility[subgroupKey] = !(subgroupInfo as any).off;
}
for (const [masterKey, masterInfo] of Object.entries(kinData.masterDict)) {
masterVisibility[masterKey] = !!(masterInfo as any).visible;
}
return {
groupVisibility: PD.Value(groupVisibility, { isHidden: true }),
subgroupVisibility: PD.Value(subgroupVisibility, { isHidden: true }),
masterVisibility: PD.Value(masterVisibility, { isHidden: true }),
activeAnimateGroup: PD.Numeric(0, { min: 0, max: Math.max(0, kinData.groupsAnimate.length - 1), step: 1 }, { description: 'Active animate group index', isHidden: true }),
activeAnimateGroup2: PD.Numeric(0, { min: 0, max: Math.max(0, kinData.groupsAnimate2.length - 1), step: 1 }, { description: 'Active animate2 group index', isHidden: true })
};
}
})({
apply({ a, params }) {
return Task.create('Kinemage Visibility Controller', async ctx => {
const kinData = (a.data as any).kinData as Kinemage;
if (!kinData) {
throw new Error('No kinData found in parent Format.Json node');
}
// Store visibility state alongside kinData
const visibilityState: KinemageVisibilityState = {
groupVisibility: new Map(Object.entries(params.groupVisibility)),
subgroupVisibility: new Map(Object.entries(params.subgroupVisibility)),
masterVisibility: new Map(Object.entries(params.masterVisibility)),
activeAnimateGroup: params.activeAnimateGroup,
activeAnimateGroup2: params.activeAnimateGroup2
};
return new PluginStateObject.Format.Json(
{ kinData, visibilityState },
{ label: a.label, description: a.description }
);
});
}
});
export const KinemageShapePointsProvider = Transform({
name: 'sb-kinemage-shape-points-provider',
display: { name: 'Kinemage Shape Points Provider' },
from: PluginStateObject.Format.Json,
to: PluginStateObject.Shape.Provider,
params: {}
})({
apply({ a }) {
return Task.create('Kinemage Points Shape Provider', async ctx => {
const kinData = (a.data as any).kinData as Kinemage;
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
if (!kinData) {
throw new Error('No kinData found in parent Format.Json node');
}
const provider = await shapePointsFromKin(kinData, visibilityState, { transforms: undefined }, 'Dots').runInContext(ctx);
return new PluginStateObject.Shape.Provider(provider as any, {
label: kinData.pdbfile || kinData.caption || 'Kinemage Points',
description: kinData.text || ''
});
});
}
});
export const KinemageShapeLinesProvider = Transform({
name: 'sb-kinemage-shape-lines-provider',
display: { name: 'Kinemage Shape Lines Provider' },
from: PluginStateObject.Format.Json,
to: PluginStateObject.Shape.Provider,
params: {}
})({
apply({ a }) {
return Task.create('Kinemage Lines Shape Provider', async ctx => {
const kinData = (a.data as any).kinData as Kinemage;
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
if (!kinData) {
throw new Error('No kinData found in parent Format.Json node');
}
const provider = await shapeLinesFromKin(kinData, visibilityState).runInContext(ctx);
return new PluginStateObject.Shape.Provider(provider as any, {
label: kinData.pdbfile || kinData.caption || 'Kinemage Lines',
description: kinData.text || ''
});
});
}
});
export const KinemageShapeMeshProvider = Transform({
name: 'sb-kinemage-shape-mesh-provider',
display: { name: 'Kinemage Shape Mesh Provider' },
from: PluginStateObject.Format.Json,
to: PluginStateObject.Shape.Provider,
params: {}
})({
apply({ a }) {
return Task.create('Kinemage Mesh Shape Provider', async ctx => {
const kinData = (a.data as any).kinData as Kinemage;
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
if (!kinData) {
throw new Error('No kinData found in parent Format.Json node');
}
const provider = await shapeMeshFromKin(kinData, visibilityState).runInContext(ctx);
return new PluginStateObject.Shape.Provider(provider as any, {
label: kinData.pdbfile || kinData.caption || 'Kinemage Meshes',
description: kinData.text || ''
});
});
}
});
export const KinemageShapeSpheresProvider = Transform({
name: 'sb-kinemage-shape-spheres-provider',
display: { name: 'Kinemage Shape Spheres Provider' },
from: PluginStateObject.Format.Json,
to: PluginStateObject.Shape.Provider,
params: {}
})({
apply({ a }) {
return Task.create('Kinemage Spheres Shape Provider', async ctx => {
const kinData = (a.data as any).kinData as Kinemage;
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
if (!kinData) {
throw new Error('No kinData found in parent Format.Json node');
}
const provider = await shapeSpheresFromKin(kinData, visibilityState).runInContext(ctx);
return new PluginStateObject.Shape.Provider(provider as any, {
label: kinData.pdbfile || kinData.caption || 'Kinemage Spheres',
description: kinData.text || ''
});
});
}
});
export const KinemageExtension = PluginBehavior.create<{ autoAttach: boolean }>({
name: 'kinemage-data-prop',
category: 'custom-props',
display: {
name: 'Kinemage data',
description: 'Data loaded from Kinemage.'
},
ctor: class extends PluginBehavior.Handler<{ autoAttach: boolean }> {
private provider = KinemageDataProvider;
register(): void {
DefaultQueryRuntimeTable.addCustomProp(this.provider.descriptor);
this.ctx.customStructureProperties.register(this.provider, this.params.autoAttach);
// Register right-panel controls for Kinemage (show in the right-hand inspector)
this.ctx.customStructureControls.set(Tag.Representation, KinemageControls as any);
// Some app hosts expose a global customControls registry; register there too so the card is visible
// even when no structure is loaded. Use `any` guards to avoid type errors if customControls isn't present.
if ((this.ctx as any).customControls && typeof (this.ctx as any).customControls.set === 'function') {
(this.ctx as any).customControls.set('kinemage', KinemageControls as any);
}
this.ctx.managers.dragAndDrop.addHandler(KinemageDragAndDropHandler.name, KinemageDragAndDropHandler.handle);
// Register .kin file handler so opening/dropping .kin is supported via the data formats system
this.ctx.dataFormats.add('KIN', KINFormatProvider);
}
update(p: { autoAttach: boolean }) {
const updated = this.params.autoAttach !== p.autoAttach;
this.params.autoAttach = p.autoAttach;
this.ctx.customStructureProperties.setDefaultAutoAttach(this.provider.descriptor.name, this.params.autoAttach);
return updated;
}
unregister() {
DefaultQueryRuntimeTable.removeCustomProp(this.provider.descriptor);
this.ctx.customStructureProperties.unregister(this.provider.descriptor.name);
this.ctx.genericRepresentationControls.delete(Tag.Representation);
this.ctx.managers.dragAndDrop.removeHandler(KinemageDragAndDropHandler.name);
// Unregister the .kin data format provider
this.ctx.dataFormats.remove('KIN');
// Remove right-panel controls
try { this.ctx.customStructureControls.delete(Tag.Representation); } catch { }
if ((this.ctx as any).customControls && typeof (this.ctx as any).customControls.delete === 'function') {
try { (this.ctx as any).customControls.delete('kinemage'); } catch { }
}
}
},
params: () => ({
autoAttach: PD.Boolean(false)
})
});
/** Registerable method for handling dragged-and-dropped files */
interface DragAndDropHandler {
name: string,
handle: PluginDragAndDropHandler,
}
/** Helper function to create all shapes for a kinemage via proper transform chain */
async function createShapesForKinemage(plugin: PluginContext, update: StateBuilder.Root, visControllerSelector: StateObjectSelector<PluginStateObject.Format.Json>) {
const visControllerCell = plugin.state.data.cells.get(visControllerSelector.ref);
if (!visControllerCell?.obj?.data) return;
const kinData = (visControllerCell.obj.data as any).kinData as Kinemage;
if (!kinData) return;
// Generate all shape types that have data, each as child of the visibility controller
if (kinData.dotLists.length > 0) {
await update
.to(visControllerSelector.ref)
.apply(KinemageShapePointsProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
if (kinData.vectorLists.length > 0) {
await update
.to(visControllerSelector.ref)
.apply(KinemageShapeLinesProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
if (kinData.ribbonLists.length > 0) {
await update
.to(visControllerSelector.ref)
.apply(KinemageShapeMeshProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D, { doubleSided: true });
}
if (kinData.ballLists.length > 0) {
await update
.to(visControllerSelector.ref)
.apply(KinemageShapeSpheresProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
}
/** Centralized helper to apply kinemage content into plugin state */
async function applyKinemageToState(plugin: PluginContext, data: string, label?: string) {
const update = plugin.state.data.build();
// Create String data node
const dataNode = update
.toRoot()
.apply(StateTransforms.Data.RawData, { data, label: label || 'Kinemage File' });
// Parse into KinemageObject
const parsedNode = dataNode
.apply(ParseKinemage, { label });
await update.commit();
// Get the parsed kinemage object to see how many kinemages it contains
const parsedCell = plugin.state.data.cells.get(parsedNode.ref);
const kinemageData = parsedCell?.obj?.data as KinemageData | undefined;
if (!kinemageData || !kinemageData.kinemages || kinemageData.kinemages.length === 0) {
console.warn('No kinemages found in parsed data');
return undefined;
}
// Create a separate visibility controller and shapes for EACH kinemage
const visControllerSelectors: StateObjectSelector<PluginStateObject.Format.Json>[] = [];
for (let i = 0; i < kinemageData.kinemages.length; i++) {
const kinUpdate = plugin.state.data.build();
// Select this specific kinemage
const selectedNode = kinUpdate
.to(parsedNode.ref)
.apply(SelectKinemage, { index: i });
// Add visibility controller for this kinemage
const visControllerNode = selectedNode
.apply(KinemageVisibilityController, {});
await kinUpdate.commit();
visControllerSelectors.push(visControllerNode.selector);
}
// Now create shapes for all kinemages
const shapeUpdate = plugin.state.data.build();
for (const visControllerSelector of visControllerSelectors) {
await createShapesForKinemage(plugin, shapeUpdate, visControllerSelector);
}
await shapeUpdate.commit();
// Wait for bounding sphere and focus camera
async function waitForNonEmptyBoundingSphere(plugin: PluginContext, timeoutMs = 2000, pollMs = 50) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const bs = getPluginBoundingSphere(plugin);
if (bs && bs.radius > 0) return bs;
} catch { /* ignore */ }
await new Promise<void>(r => setTimeout(r, pollMs));
}
return null;
}
try {
const bs = await waitForNonEmptyBoundingSphere(plugin);
if (bs && bs.radius > 0 && plugin.canvas3d) {
await PluginCommands.Camera.Focus(plugin, { center: bs.center, radius: bs.radius, durationMs: 250 });
plugin.canvas3d?.commit();
}
} catch (e) {
console.warn('Failed to apply initial kinemage view snapshot', e);
}
return visControllerSelectors[0]; // Return first for backward compatibility
}
/** Programmatic loader: load a single File (a .kin) into the plugin state.
* Returns the ref to the first visibility controller node.
*/
export async function loadKinemageFile(plugin: PluginContext, file: File): Promise<StateObjectSelector<PluginStateObject.Format.Json> | undefined> {
const content = await file.text();
return await applyKinemageToState(plugin, content, file.name);
}
/** DragAndDropHandler handler for `.kin` files */
const KinemageDragAndDropHandler: DragAndDropHandler = {
name: 'kin',
async handle(files: File[], plugin: PluginContext): Promise<boolean> {
let applied = false;
for (const file of files) {
if (file.name.toLowerCase().endsWith('.kin')) {
const ref = await loadKinemageFile(plugin, file);
applied = applied || !!ref;
}
}
return applied;
},
};
const KINFormatProvider: DataFormatProvider<{}, any, any> = DataFormatProvider({
label: 'KIN',
description: 'Kinemage',
category: 'Miscellaneous',
stringExtensions: ['kin', 'KIN'],
parse: async (plugin, data) => {
try {
// data is already a StateObjectRef to the raw data in the tree
// Build the transform chain from it
const builder = plugin.state.data.build()
.to(data)
.apply(ParseKinemage, {});
await builder.commit();
// Get the parsed data to see how many kinemages
const parsedRef = builder.selector.ref;
const parsedCell = plugin.state.data.cells.get(parsedRef);
const kinemageData = parsedCell?.obj?.data as KinemageData | undefined;
if (!kinemageData || !kinemageData.kinemages || kinemageData.kinemages.length === 0) {
console.warn('No kinemages found in parsed data');
return {};
}
// Create visibility controllers for all kinemages
const visControllers: StateObjectSelector<PluginStateObject.Format.Json>[] = [];
for (let i = 0; i < kinemageData.kinemages.length; i++) {
const kinBuilder = plugin.state.data.build();
const selectedKin = kinBuilder
.to(parsedRef)
.apply(SelectKinemage, { index: i });
const visController = selectedKin
.apply(KinemageVisibilityController, {});
await kinBuilder.commit();
visControllers.push(visController.selector);
}
// Return all visibility controllers
return { visControllers };
} catch (e) {
console.error('Failed to parse KIN file', e);
throw e;
}
},
visuals: async (plugin, data) => {
if (!data?.visControllers || !Array.isArray(data.visControllers)) {
console.warn('[Kinemage] visuals: no visControllers array provided');
return;
}
// Create shapes for all kinemages
const shapeBuilder = plugin.state.data.build();
for (const visController of data.visControllers) {
await createShapesForKinemage(plugin, shapeBuilder, visController);
}
await shapeBuilder.commit();
// Wait for bounding sphere and focus camera
async function waitForNonEmptyBoundingSphere(plugin: PluginContext, timeoutMs = 2000, pollMs = 50) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const bs = getPluginBoundingSphere(plugin);
if (bs && bs.radius > 0) return bs;
} catch { /* ignore */ }
await new Promise<void>(r => setTimeout(r, pollMs));
}
return null;
}
try {
const bs = await waitForNonEmptyBoundingSphere(plugin);
if (bs && bs.radius > 0 && plugin.canvas3d) {
await PluginCommands.Camera.Focus(plugin, { center: bs.center, radius: bs.radius, durationMs: 250 });
plugin.canvas3d?.commit();
}
} catch (e) {
console.warn('Failed to focus camera on kinemage', e);
}
return undefined;
}
});

View File

@@ -1,656 +0,0 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author ReliaSolve <russ@reliasolve.com>
*/
import { RuntimeContext, Task } from '../../mol-task';
import { ShapeProvider } from '../../mol-model/shape/provider';
import { Color } from '../../mol-util/color';
import { Kinemage, DotList, VectorList, RibbonObject, BallList } from './reader/schema';
import { Lines } from '../../mol-geo/geometry/lines/lines';
import { LinesBuilder } from '../../mol-geo/geometry/lines/lines-builder';
import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
import { MeshBuilder } from '../../mol-geo/geometry/mesh/mesh-builder';
import { Points } from '../../mol-geo/geometry/points/points';
import { PointsBuilder } from '../../mol-geo/geometry/points/points-builder';
import { Vec3 } from '../../mol-math/linear-algebra';
import { Spheres } from '../../mol-geo/geometry/spheres/spheres';
import { SpheresBuilder } from '../../mol-geo/geometry/spheres/spheres-builder';
import { Shape } from '../../mol-model/shape';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { Mat4 } from '../../mol-math/linear-algebra/3d/mat4';
import { KinemageVisibilityState } from './behavior';
export type KinData = {
source: Kinemage,
transforms?: Mat4[],
}
function createKinShapePointsParams(kinemage?: Kinemage) {
return {
...Points.Params,
};
}
export const KinShapePointsParams = createKinShapePointsParams();
export type KinShapePointsParams = typeof KinShapePointsParams
function createKinShapeLinesParams(kinemage?: Kinemage) {
return {
...Lines.Params,
};
}
export const KinShapeLinesParams = createKinShapeLinesParams();
export type KinShapeLinesParams = typeof KinShapeLinesParams
function createKinShapeMeshParams(kinemage?: Kinemage) {
return {
...Mesh.Params,
};
}
export const KinShapeMeshParams = createKinShapeMeshParams();
export type KinShapeMeshParams = typeof KinShapeMeshParams
function createKinShapeSpheresParams(kinemage?: Kinemage) {
return {
...Spheres.Params,
};
}
export const KinShapeSpheresParams = createKinShapeSpheresParams();
export type KinShapeSpheresParams = typeof KinShapeSpheresParams;
/**
* Check visibility using AND logic:
* - ALL masters must be visible
* - AND group must be visible
* - AND subgroup must be visible (and its parent group if it has one)
*/
function getVisibility(group: string, subGroup: string, masters: string[], kin: Kinemage, visibilityState?: KinemageVisibilityState) {
// If no visibility state provided, fall back to checking the original parsed data
if (!visibilityState) {
let visible = true;
// Check masters from parsed data
for (let m = 0; m < masters.length; m++) {
const masterName = masters[m];
const masterInfo = kin.masterDict[masterName];
if (masterInfo && !masterInfo.visible) {
visible = false;
break;
}
}
// Check group from parsed data
const groupInfo = kin.groupDict[group];
if (groupInfo && (groupInfo as any).off) {
visible = false;
}
// Check subgroup from parsed data
const subgroupInfo = kin.subgroupDict[subGroup];
if (subgroupInfo) {
if ((subgroupInfo as any).off) {
visible = false;
}
if ((subgroupInfo as any).group) {
const parentGroupInfo = kin.groupDict[(subgroupInfo as any).group];
if (parentGroupInfo && (parentGroupInfo as any).off) {
visible = false;
}
}
}
return visible;
}
// Use visibility state - all conditions must be true (AND logic)
// Check all masters - if ANY master is not visible, return false
for (let m = 0; m < masters.length; m++) {
const masterName = masters[m];
const masterVisible = visibilityState.masterVisibility.get(masterName);
if (masterVisible === false) {
return false;
}
}
// Check group visibility
const groupVisible = visibilityState.groupVisibility.get(group);
if (groupVisible === false) {
return false;
}
// Check subgroup visibility
if (subGroup) {
const subgroupVisible = visibilityState.subgroupVisibility.get(subGroup);
if (subgroupVisible === false) {
return false;
}
// Also check if subgroup's parent group is visible
const subgroupInfo = kin.subgroupDict[subGroup];
if (subgroupInfo && (subgroupInfo as any).group) {
const parentGroupVisible = visibilityState.groupVisibility.get((subgroupInfo as any).group);
if (parentGroupVisible === false) {
return false;
}
}
}
return true;
}
async function getPoints(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
const dotLists: DotList[] = kin.dotLists;
const builderState = PointsBuilder.create();
const colors: Color[] = [];
const labels: string[] = [];
// Every dot is in its own Molstar group because they may have colors and we look that up by group.
let index = 0;
for (let i = 0; i < dotLists.length; i++) {
const dotList = dotLists[i];
const positionArray = dotList.positionArray;
const colorArray = dotList.colorArray;
const labelArray = dotList.labelArray;
const masterArray = dotList.masterArray;
// Check the visibility of all of our masters and skip this dot list if any of them are not visible.
const visible = getVisibility(dotList.group, dotList.subgroup, masterArray, kin, visibilityState);
if (!visible) { continue; }
const numDots = positionArray.length / 3;
for (let j = 0; j < numDots; j++) {
// Skip this element if any master associated with any of its pointMasters are turned off.
const pointMasterNames = dotList.pointmasterArray[j];
let pmVisibility = true;
for (let pm = 0; pm < pointMasterNames.length; pm++) {
const pointMasterName = pointMasterNames[pm];
const masterName = kin.pointmasterDict[pointMasterName];
if (visibilityState) {
const masterVisible = visibilityState.masterVisibility.get(masterName);
if (masterVisible === false) {
pmVisibility = false;
break;
}
} else {
const masterInfo = kin.masterDict[masterName];
if (masterInfo && !masterInfo.visible) {
pmVisibility = false;
break;
}
}
}
if (!pmVisibility) { continue; }
const group = index++;
builderState.add(positionArray[3 * j + 0], positionArray[3 * j + 1], positionArray[3 * j + 2], group);
// colorArray may be undefined; push a default color when not provided
colors.push(colorArray && colorArray.length > j ? colorArray[j] : Color.fromRgb(255, 255, 255));
// labelArray may be undefined; push an empty string when not provided
labels.push(labelArray && labelArray.length > j ? labelArray[j] : '');
}
}
const points = builderState.getPoints();
return { points, colors, labels };
}
async function getLines(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
const vectorLists: VectorList[] = kin.vectorLists;
const builderState = LinesBuilder.create();
const widths: number[] = [];
const colors: Color[] = [];
const labels: string[] = [];
// Every line is in its own Molstar group because they may have individual widths and we look
// up the width based on the group is in the size function.
let index = 0;
for (let i = 0; i < vectorLists.length; i++) {
const vectorList = vectorLists[i];
const position1Array = vectorList.position1Array;
const position2Array = vectorList.position2Array;
const widthArray = vectorList.width;
const color1Array = vectorList.color1Array;
const color2Array = vectorList.color2Array;
const label1Array = vectorList.label1Array;
const label2Array = vectorList.label2Array;
const masterArray = vectorList.masterArray;
// Check the visibility of all of our masters and skip this vector list if any of them are not visible.
const visible = getVisibility(vectorList.group, vectorList.subgroup, masterArray, kin, visibilityState);
if (!visible) { continue; }
const numLines = position1Array.length / 3;
for (let j = 0; j < numLines; j++) {
// Skip this element if any master associated with any of its pointMasters are turned off.
const pointMasterNames = vectorList.pointmasterArray[j];
let pmVisibility = true;
for (let pm = 0; pm < pointMasterNames.length; pm++) {
const pointMasterName = pointMasterNames[pm];
const masterName = kin.pointmasterDict[pointMasterName];
if (visibilityState) {
const masterVisible = visibilityState.masterVisibility.get(masterName);
if (masterVisible === false) {
pmVisibility = false;
break;
}
} else {
const masterInfo = kin.masterDict[masterName];
if (masterInfo && !masterInfo.visible) {
pmVisibility = false;
break;
}
}
}
if (!pmVisibility) { continue; }
// Find the midpoint of the line because we're going to actually make
// two half-lines so that labels and selection work better.
const midX = (position1Array[3 * j + 0] + position2Array[3 * j + 0]) / 2;
const midY = (position1Array[3 * j + 1] + position2Array[3 * j + 1]) / 2;
const midZ = (position1Array[3 * j + 2] + position2Array[3 * j + 2]) / 2;
// Make the first half of the line from position1 to the midpoint, labeled and colored based on position1.
let group = index++;
builderState.add(position1Array[3 * j + 0], position1Array[3 * j + 1], position1Array[3 * j + 2],
midX, midY, midZ,
group);
// widthArray may be undefined; push NaN when width not provided
widths.push(widthArray && widthArray.length > j ? widthArray[j] : NaN);
// colorArray may be undefined; push a default color when not provided
colors.push(color1Array && color1Array.length > j ? color1Array[j] : Color.fromRgb(255, 255, 255));
// labelArray may be undefined; push an empty string when not provided
labels.push(label1Array && label1Array.length > j ? label1Array[j] : '');
// Make the second half of the line from the midpoint to position2, labeled and colored based on position2.
group = index++;
builderState.add(midX, midY, midZ,
position2Array[3 * j + 0], position2Array[3 * j + 1], position2Array[3 * j + 2],
group);
// widthArray may be undefined; push NaN when width not provided
widths.push(widthArray && widthArray.length > j ? widthArray[j] : NaN);
// colorArray may be undefined; push a default color when not provided
colors.push(color2Array && color2Array.length > j ? color2Array[j] : Color.fromRgb(255, 255, 255));
// labelArray may be undefined; push an empty string when not provided
labels.push(label2Array && label2Array.length > j ? label2Array[j] : '');
}
}
const lines = builderState.getLines();
return { lines, widths: new Float32Array(widths), colors, labels };
}
function addOffsetTriangle(builderState: MeshBuilder.State, a: Vec3, b: Vec3, c: Vec3, n: Vec3, offset: number) {
const aOffset = Vec3.add(Vec3(), a, Vec3.scale(Vec3(), n, offset));
const bOffset = Vec3.add(Vec3(), b, Vec3.scale(Vec3(), n, offset));
const cOffset = Vec3.add(Vec3(), c, Vec3.scale(Vec3(), n, offset));
MeshBuilder.addTriangleWithNormal(builderState, aOffset, bOffset, cOffset, n);
}
async function getMesh(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
const ribbonObjects: RibbonObject[] = kin.ribbonLists;
const builderState = MeshBuilder.createState();
const colors: Color[] = [];
const labels: string[] = [];
// Every triangle is in its own Molstar group because they may have individual colors and we look
// up the color based on the group is in the color function.
let index = 0;
for (let ri = 0; ri < ribbonObjects.length; ri++) {
const ribbonObject = ribbonObjects[ri];
const coords = ribbonObject.positionArray;
const colorArray = ribbonObject.colorArray;
const labelArray = ribbonObject.labelArray;
const masterArray = ribbonObject.masterArray;
const pointMasterArray = ribbonObject.pointmasterArray;
// Check the visibility of all of our masters and skip this ribbon object if any of them are not visible.
const visible = getVisibility(ribbonObject.group, ribbonObject.subgroup, masterArray, kin, visibilityState);
if (!visible) { continue; }
builderState.currentGroup = ri; // TODO: Base this on something in the file instead?
// The positionArray contains 3x as many entries as there are vertices since it's a catenation of x, y, z for each vertex.
// There are three vertices per triangle.
// TODO: Ribbon lighting is to be set up to make each pair of triangles look like a quad with the same normal.
const numTriangles = coords.length / 9;
let prevTriangleNormal: Vec3 | undefined = undefined;
for (let i = 0; i < numTriangles; i++) {
// Skip this element if any master associated with any of its pointMasters are turned off.
const pointMasterNames = pointMasterArray[3 * i];
let pmVisibility = true;
for (let pm = 0; pm < pointMasterNames.length; pm++) {
const pointMasterName = pointMasterNames[pm];
const masterName = kin.pointmasterDict[pointMasterName];
if (visibilityState) {
const masterVisible = visibilityState.masterVisibility.get(masterName);
if (masterVisible === false) {
pmVisibility = false;
break;
}
} else {
const masterInfo = kin.masterDict[masterName];
if (masterInfo && !masterInfo.visible) {
pmVisibility = false;
break;
}
}
}
if (!pmVisibility) { continue; }
const vertexList: Vec3[] = [];
// Get the vertices for the triangle out of the position array and push them onto a list.
for (let j = 0; j < 3; j++) {
const v = Vec3.zero();
v[0] = coords[3 * (3 * i + j) + 0];
v[1] = coords[3 * (3 * i + j) + 1];
v[2] = coords[3 * (3 * i + j) + 2];
vertexList.push(v);
}
// Set the group per triangle so that we can do per-triangle coloring.
const group = index++;
builderState.currentGroup = group;
// colorArray may be undefined; push a default color when not provided.
// There is one color per group, even if we have two triangles in this group.
const color = colorArray && colorArray.length > i * 3 ? colorArray[3 * i] : Color.fromRgb(255, 255, 255);
colors.push(color);
// labelArray may be undefined; push an empty string when not provided
const label = labelArray && labelArray.length > i ? labelArray[i] : '';
labels.push(label);
// Find the vertics and normal for the triangle.
const a: Vec3 = vertexList[0];
const b: Vec3 = vertexList[1];
const c: Vec3 = vertexList[2];
// Put both orientations of the triangle. Add a small amount along the normal to make them
// not be exactly on top of each other so that we only see the front face of each.
let n = Vec3.zero();
Vec3.triangleNormal(n, a, b, c);
if (i % 2 === 1) {
// For ribbons, every other triangle is meant to be paired with the previous one to make a quad with the same normal.
// So use the same normal for every other triangle.
n = prevTriangleNormal || n;
}
prevTriangleNormal = n;
addOffsetTriangle(builderState, a, b, c, n, 0.01);
// Invert the normal for the back face.
Vec3.negate(n, n);
addOffsetTriangle(builderState, a, c, b, n, 0.01);
}
}
const mesh = MeshBuilder.getMesh(builderState);
return { mesh, colors, labels };
}
/**
* Build spheres geometry and collect per-sphere radii from the KIN BallList entries.
* Returns an object with the Spheres geometry and a Float32Array with per-center radii (one entry per center, in the same order they were added).
*/
async function getSpheres(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
const balls: BallList[] = kin.ballLists;
const builderState = SpheresBuilder.create();
const radii: number[] = [];
const colors: Color[] = [];
const labels: string[] = [];
// Every ball is in its own Molstar group because they may have individual radii and we look
// up the radius based on the group is in the size function.
let index = 0;
for (let i = 0; i < balls.length; i++) {
const ballList = balls[i];
const positionArray = ballList.positionArray;
const radiusArray = ballList.radiusArray;
const colorArray = ballList.colorArray;
const masterArray = ballList.masterArray;
// Check the visibility of all of our masters and skip this ball list if any of them are not visible.
const visible = getVisibility(ballList.group, ballList.subgroup, masterArray, kin, visibilityState);
if (!visible) { continue; }
const numBalls = positionArray.length / 3;
for (let j = 0; j < numBalls; j++) {
// Skip this element if any master associated with any of its pointMasters are turned off.
const pointMasterNames = ballList.pointmasterArray[j];
let pmVisibility = true;
for (let pm = 0; pm < pointMasterNames.length; pm++) {
const pointMasterName = pointMasterNames[pm];
const masterName = kin.pointmasterDict[pointMasterName];
if (visibilityState) {
const masterVisible = visibilityState.masterVisibility.get(masterName);
if (masterVisible === false) {
pmVisibility = false;
break;
}
} else {
const masterInfo = kin.masterDict[masterName];
if (masterInfo && !masterInfo.visible) {
pmVisibility = false;
break;
}
}
}
if (!pmVisibility) { continue; }
const group = index++;
builderState.add(positionArray[3 * j + 0], positionArray[3 * j + 1], positionArray[3 * j + 2], group);
// radiusArray may be undefined; push NaN when radius not provided
radii.push(radiusArray && radiusArray.length > j ? radiusArray[j] : NaN);
// colorArray may be undefined; push a default color when not provided
colors.push(colorArray && colorArray.length > j ? colorArray[j] : Color.fromRgb(255, 255, 255));
// labelArray may be undefined; push an empty string when not provided
labels.push(ballList.labelArray && ballList.labelArray.length > j ? ballList.labelArray[j] : '');
}
}
const spheres = builderState.getSpheres();
return { spheres, radii: new Float32Array(radii), colors, labels };
}
function makePointsShapeGetter(visibilityState?: KinemageVisibilityState) {
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapePointsParams>, shape?: Shape<Points>) => {
// Get our points, adding them from all of the entries in the dot lists
const { points: _points, colors, labels } = await getPoints(ctx, kinData.source, visibilityState);
// Color function signature: (groupId: number, instanceId: number) => Color
// For Lines the groupId corresponds to the line index (order added).
const colorFn = (group: number, instance: number) => {
return colors[group];
};
// Label function signature: (groupId: number, instanceId: number) => string
// For Lines the groupId corresponds to the line index (order added).
const labelFn = (group: number, instance: number) => {
return labels[group];
};
const _shape = Shape.create<Points>(
'kin-points',
kinData.source,
_points,
colorFn, // color function reads per-point colors
() => 1, // size function
labelFn // label function reads per-point labels
);
return _shape;
};
return getShape;
}
function makeLineShapeGetter(visibilityState?: KinemageVisibilityState) {
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapeLinesParams>, shape?: Shape<Lines>) => {
// Get our lines, adding them from all of the entries in the vector lists
const { lines: _lines, widths, colors, labels } = await getLines(ctx, kinData.source, visibilityState);
// Size function signature: (groupId: number, instanceId: number) => number
// For Lines the groupId corresponds to the line index (order added).
const sizeFn = (group: number, instance: number) => {
// We're specifying the radius, which is half the width.
let w = widths[group] / 2.0;
if (w < 1.0) { w = 1.0; }
return Number.isFinite(w) ? w : 1.0;
};
// Color function signature: (groupId: number, instanceId: number) => Color
// For Lines the groupId corresponds to the line index (order added).
const colorFn = (group: number, instance: number) => {
return colors[group];
};
// Label function signature: (groupId: number, instanceId: number) => string
// For Lines the groupId corresponds to the line index (order added).
const labelFn = (group: number, instance: number) => {
return labels[group];
};
const _shape = Shape.create<Lines>(
'kin-lines',
kinData.source,
_lines,
colorFn, // color function reads per-line colors
sizeFn, // size function reads per-line widths
labelFn // label function
);
return _shape;
};
return getShape;
}
function makeMeshShapeGetter(visibilityState?: KinemageVisibilityState) {
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapeMeshParams>, shape?: Shape<Mesh>) => {
let { mesh: _mesh, colors, labels } = await getMesh(ctx, kinData.source, visibilityState);
// Ensure that _mesh is not undifined before we pass it to Shape.create. If it is undefined, create an empty mesh instead.
if (!_mesh) {
console.warn('No mesh could be created from the KIN data. Creating an empty mesh instead.');
_mesh = Mesh.createEmpty();
}
// Color function signature: (groupId: number, instanceId: number) => Color
// For Lines the groupId corresponds to the line index (order added).
const colorFn = (group: number, instance: number) => {
return colors[group];
};
const labelFn = (group: number, instance: number) => {
return labels[group];
};
const _shape = Shape.create<Mesh>(
'kin-mesh',
kinData.source,
_mesh,
colorFn, // color function reads per-triangle colors
() => 1, // size function
labelFn // label function
);
return _shape;
};
return getShape;
}
/**
* Spheres shape getter: uses per-center radii read from the KIN BallList radiusArray when available.
*/
function makeSpheresShapeGetter(visibilityState?: KinemageVisibilityState) {
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapeSpheresParams>, shape?: Shape<Spheres>) => {
// Build spheres geometry and collect per-center radii
const { spheres: _spheres, radii, colors, labels } = await getSpheres(ctx, kinData.source, visibilityState);
// size function signature: (groupId: number, instanceId: number) => number
// For Spheres the groupId corresponds to the center index (order added).
const sizeFn = (group: number, instance: number) => {
const r = radii[group];
return Number.isFinite(r) ? r : 1.0;
};
// Color function signature: (groupId: number, instanceId: number) => Color
// For Spheres the groupId corresponds to the center index (order added).
const colorFn = (group: number, instance: number) => {
return colors[group];
};
// Label function signature: (groupId: number, instanceId: number) => string
// For Spheres the groupId corresponds to the center index (order added).
const labelFn = (group: number, instance: number) => {
return labels[group];
};
const _shape = Shape.create<Spheres>(
'kin-spheres',
kinData.source,
_spheres,
colorFn, // color function reads per-center colors
sizeFn, // size function reads per-center radii
labelFn // label function
);
return _shape;
};
return getShape;
}
export function shapePointsFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
return Task.create<ShapeProvider<KinData, Points, KinShapePointsParams>>('Kin Shape Points Provider', async ctx => {
return {
label: label ?? 'Points',
data: { source, transforms: params?.transforms },
params: createKinShapePointsParams(source),
getShape: makePointsShapeGetter(visibilityState),
geometryUtils: Points.Utils
};
});
}
export function shapeLinesFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
return Task.create<ShapeProvider<KinData, Lines, KinShapeLinesParams>>('Kin Shape Lines Provider', async ctx => {
return {
label: label ?? 'Lines',
data: { source, transforms: params?.transforms },
params: createKinShapeLinesParams(source),
getShape: makeLineShapeGetter(visibilityState),
geometryUtils: Lines.Utils
};
});
}
export function shapeMeshFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
return Task.create<ShapeProvider<KinData, Mesh, KinShapeMeshParams>>('Kin Shape Mesh Provider', async ctx => {
return {
label: label ?? 'Meshes',
data: { source, transforms: params?.transforms },
params: createKinShapeMeshParams(source),
getShape: makeMeshShapeGetter(visibilityState),
geometryUtils: Mesh.Utils
};
});
}
export function shapeSpheresFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
return Task.create<ShapeProvider<KinData, Spheres, KinShapeSpheresParams>>('Kin Shape Spheres Provider', async ctx => {
return {
label: label ?? 'Spheres',
data: { source, transforms: params?.transforms },
params: createKinShapeSpheresParams(source),
getShape: makeSpheresShapeGetter(visibilityState),
geometryUtils: Spheres.Utils
};
});
}

View File

@@ -1,116 +0,0 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/** Based on the ../anvil extension. */
import { Structure } from '../../mol-model/structure';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { CustomPropertyDescriptor } from '../../mol-model/custom-property';
import { CustomStructureProperty } from '../../mol-model-props/common/custom-structure-property';
import { CustomProperty } from '../../mol-model-props/common/custom-property';
import { Task } from '../../mol-task';
import { Kinemage } from './reader/schema';
import { parseKin } from './reader/parser';
export const KinemageParams = {
};
export type KinemageParams = typeof KinemageParams
export type KinemageProps = PD.Values<KinemageParams>
export const KinemageDataParams = {
...KinemageParams
};
export type KinemageDataParams = typeof KinemageDataParams
export type KinemageDataProps = PD.Values<KinemageDataParams>
export { KinemageData };
interface KinemageData {
/**
* List of Kinemages read from one or more files.
*/
readonly kinemages: Kinemage[]
}
const FileSourceParams = {
input: PD.File({ accept: '.kin', multiple: false })
};
type FileSourceProps = PD.Values<typeof FileSourceParams>
namespace KinemageData {
export enum Tag {
Representation = 'kinemage-3d'
}
export const symbols = {
};
async function loadKinemageData(data: string): Promise<Kinemage[]> {
const task = parseKin(data);
const result = await task.run();
if (result.isError) {
throw new Error('Failed to parse KIN data');
}
return result.result;
}
export async function open(file: FileSourceProps | File): Promise<KinemageData> {
let fileToRead: File;
if (file instanceof File) {
fileToRead = file;
} else if (file && file.input && file.input.file) {
fileToRead = file.input.file;
} else {
throw new Error('No file given');
}
const task = Task.create('Load KIN file', async ctx => {
const data = await fileToRead.text();
const kinemages = await loadKinemageData(data);
return kinemages;
});
const kinemages = await task.run();
return { kinemages };
}
}
export const KinemageDataProvider: CustomStructureProperty.Provider<KinemageDataParams, KinemageData> = CustomStructureProperty.createProvider({
label: 'Kinemage',
descriptor: CustomPropertyDescriptor({
name: 'Kinemage_loaded_data',
symbols: KinemageData.symbols,
}),
type: 'root',
defaultParams: KinemageDataParams,
getParams: (data: Structure) => KinemageDataParams,
isApplicable,
obtain: async (ctx: CustomProperty.Context, data: Structure, props: Partial<KinemageDataProps>) => {
const p = { ...PD.getDefaultValues(KinemageDataParams), ...props };
try {
return { value: await computeKinemageProps(ctx, data, p) };
} catch (e) {
// the "Residues Embedded in Membrane" symbol may bypass isApplicable() checks
console.warn('Failed to predict membrane orientation. This happens for short peptides and entries without amino acids.');
return { value: undefined };
}
}
});
function isApplicable(structure: Structure) {
return false;
}
async function computeKinemageProps(ctx: CustomProperty.Context, data: Structure, props: Partial<KinemageProps>): Promise<KinemageData> {
// Return an empty KinemageData object since the actual data will be loaded asynchronously via the `open` method.
// This allows the property to be attached to the structure without blocking on file loading.
return {
kinemages: []
};
}

View File

@@ -1,939 +0,0 @@
/**
* Copyright (c) 2025-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Based on earlier kin-parser.ts file from the NGL project (see second author notice below).
* @file Ported NGL-based Kinemage file parser
* @author ReliaSolve <russ@reliasolve.com>
* @private
*/
/**
* file Kin Parser
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { Kinemage, RibbonObject } from './schema';
import { Hsv } from '../../../mol-util/color/spaces/hsv';
import { Color } from '../../../mol-util/color';
const ColorDict: { [k: string]: Color } = {
red: Hsv.toColor(Hsv.fromArray([0, 100, 100])),
orange: Hsv.toColor(Hsv.fromArray([20, 100, 100])),
gold: Hsv.toColor(Hsv.fromArray([40, 100, 100])),
yellow: Hsv.toColor(Hsv.fromArray([60, 100, 100])),
lime: Hsv.toColor(Hsv.fromArray([80, 100, 100])),
green: Hsv.toColor(Hsv.fromArray([120, 80, 100])),
sea: Hsv.toColor(Hsv.fromArray([150, 100, 100])),
cyan: Hsv.toColor(Hsv.fromArray([180, 100, 85])),
sky: Hsv.toColor(Hsv.fromArray([210, 75, 95])),
blue: Hsv.toColor(Hsv.fromArray([240, 70, 100])),
purple: Hsv.toColor(Hsv.fromArray([275, 75, 100])),
magenta: Hsv.toColor(Hsv.fromArray([300, 95, 100])),
hotpink: Hsv.toColor(Hsv.fromArray([335, 100, 100])),
pink: Hsv.toColor(Hsv.fromArray([350, 55, 100])),
peach: Hsv.toColor(Hsv.fromArray([25, 75, 100])),
lilac: Hsv.toColor(Hsv.fromArray([275, 55, 100])),
pinktint: Hsv.toColor(Hsv.fromArray([340, 30, 100])),
peachtint: Hsv.toColor(Hsv.fromArray([25, 50, 100])),
yellowtint: Hsv.toColor(Hsv.fromArray([60, 50, 100])),
greentint: Hsv.toColor(Hsv.fromArray([135, 40, 100])),
bluetint: Hsv.toColor(Hsv.fromArray([220, 40, 100])),
lilactint: Hsv.toColor(Hsv.fromArray([275, 35, 100])),
white: Hsv.toColor(Hsv.fromArray([0, 0, 100])),
gray: Hsv.toColor(Hsv.fromArray([0, 0, 50])),
brown: Hsv.toColor(Hsv.fromArray([20, 45, 75])),
deadwhite: Hsv.toColor(Hsv.fromArray([0, 0, 100])),
deadblack: Hsv.toColor(Hsv.fromArray([0, 0, 0])),
invisible: Hsv.toColor(Hsv.fromArray([0, 0, 0]))
};
const reWhitespaceComma = /[\s,]+/;
const reCurlyWhitespace = /[^{}\s]*{[^{}]+}|[^{}\s]+/g;
const reTrimCurly = /^{+|}+$/g;
const reCollapseEqual = /\s*=\s*/g;
function parseListDef(line: string, localColorDict: { [k: string]: Color }) {
let name;
let defaultColor: Color = localColorDict['white']; // Default color is white, but it can be overridden by the list definition
let radius;
let nobutton = false;
const master = [];
let width = 2; // Default width is 2, but it can be overridden by the list definition
line = line.replace(reCollapseEqual, '=');
const lm = line.match(reCurlyWhitespace) as RegExpMatchArray;
for (let j = 1; j < lm.length; ++j) {
const e = lm[j];
if (e[0] === '{') {
name = e.substring(1, e.length - 1);
} else {
const es = e.split('=');
if (es.length === 2) {
if (es[0] === 'color') {
const colorName = parseStr(es[1]);
defaultColor = localColorDict[colorName];
} else if (es[0] === 'width') {
width = parseInt(es[1]);
} else if (es[0] === 'master') {
master.push(es[1].replace(reTrimCurly, ''));
} else if (es[0] === 'radius') {
radius = parseFloat(es[1]);
} else {
console.log('Kinemage: Unknown list definition term found: ' + es[0]);
}
} else if (e === 'nobutton') {
nobutton = true;
} else {
console.log('Kinemage: Unknown list definition term found: ' + e);
}
}
}
return {
listName: name,
listColor: defaultColor,
listMasters: master,
listWidth: width,
listRadius: radius,
nobutton: nobutton
};
}
function parseListElm(line: string, localColorDict: { [k: string]: Color }) {
line = line.trim();
const idx1 = line.indexOf('{');
const idx2 = line.indexOf('}');
const ls = line.substr(idx2 + 1).split(reWhitespaceComma);
const label = line.substr(idx1 + 1, idx2 - 1);
const position = [
parseFloat(ls[ls.length - 3]),
parseFloat(ls[ls.length - 2]),
parseFloat(ls[ls.length - 1])
];
let color, width, radius;
let lineBreak = false;
let triangleBreak = false;
const pointMasters: string[] = [];
for (let lsindex = 4; lsindex <= ls.length; lsindex++) {
const literal = ls[ls.length - lsindex];
if (literal in localColorDict) {
color = localColorDict[ls[ls.length - lsindex]];
}
if (literal.startsWith('width')) {
width = parseInt(literal.substring(5));
}
if (literal.startsWith('r=')) {
radius = parseFloat(literal.split('=')[1]);
}
if (literal.startsWith('P')) {
lineBreak = true;
}
if (literal.startsWith('X')) {
triangleBreak = true;
}
if (literal.startsWith("'") && literal.endsWith("'")) {
// Handle single-character tags by putting each character into a pointMaster tag, e.g. 'ab' would be two tags, 'a' and 'b'
const tagString: string = literal.substring(1, literal.length - 1);
for (let i = 0; i < tagString.length; i++) {
pointMasters.push(tagString[i]);
}
}
}
return {
label: label,
position: position,
color: color,
radius: radius,
width: width,
isLineBreak: lineBreak,
isTriangleBreak: triangleBreak,
pointMasters: pointMasters
};
}
function parseStr(line: string) {
const start = line.indexOf('{');
const end = line.indexOf('}');
return line.substring(
start !== -1 ? start + 1 : 0,
end !== -1 ? end : undefined
).trim();
}
function parseFlag(line: string) {
const end = line.indexOf('}');
return end === -1 ? undefined : line.substr(end + 1).trim();
}
function parseGroup(line: string) {
let name: string = '';
const master: string[] = [];
const flags: { [k: string]: string | boolean } = {};
line = line.replace(reCollapseEqual, '=');
const lm = line.match(reCurlyWhitespace) as RegExpMatchArray;
for (let j = 1; j < lm.length; ++j) {
const e = lm[j];
if (e[0] === '{') {
name = e.substring(1, e.length - 1);
} else {
const es = e.split('=');
if (es.length === 2) {
if (es[0] === 'master') {
master.push(es[1].replace(reTrimCurly, ''));
} else {
flags[es[0]] = es[1].replace(reTrimCurly, '');
}
} else {
flags[es[0]] = true;
}
}
}
return {
groupName: name,
groupFlags: flags,
groupMasters: master,
};
}
function parsePointmaster(line: string) {
let name: string = '';
const tags: string[] = [];
let on: boolean | undefined = undefined;
// Find the string name between curly braces, or print an error if not found
const nameMatch = line.match(/{([^}]+)}/);
if (nameMatch) {
name = nameMatch[1];
// Find all characters between the pair of single quotes, which are the tags, and add them to the tags array
const tagMatch = line.match(/'([^']+)'/);
if (tagMatch) {
const tagString: string = tagMatch[1];
for (let i = 0; i < tagString.length; i++) {
tags.push(tagString[i]);
}
// See if the line contains the word "on" or "off" and set the on variable accordingly
if (line.includes(' on')) {
on = true;
} else if (line.includes(' off')) {
on = false;
}
} else {
console.log('Kinemage: Pointmaster definition missing tags: ' + line);
}
} else {
console.log('Kinemage: Pointmaster definition missing name: ' + line);
}
return {
name: name,
tags: tags,
on: on
};
}
function convertKinTriangleArrays(ribbonObject: RibbonObject) {
// have to convert ribbons/triangle lists from stripdrawmode to normal drawmode
// index [ 0 1 2 3 4 5 6 7 8 91011 ]
// label/color/ptm [ 0 1 2 3 4 5 ] to [ 0 1 2 1 2 3 2 3 4 3 4 5 ]
// convertedindex [ 0 1 2 3 4 5 6 7 8 91011121314151617181920212223242526 ]
// index [ 0 1 2 3 4 5 6 7 8 91011121314 ] [ 0 1 2 3 4 5 6 7 8 3 4 5 6 7 8 91011 6 7 8 91011121314 ]
// position [ 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 ] to [ 0 0 0 1 1 1 2 2 2 1 1 1 2 2 2 3 3 3 2 2 2 3 3 3 4 4 4 ]
const { labelArray, positionArray, colorArray, breakArray } = ribbonObject;
const convertedLabels = [];
for (let i = 0; i < (labelArray.length - 2) * 3; ++i) {
convertedLabels[i] = labelArray[i - Math.floor(i / 3) * 2];
}
const convertedColors = [];
for (let i = 0; i < (colorArray.length - 2) * 3; ++i) {
convertedColors[i] = colorArray[i - Math.floor(i / 3) * 2];
}
const convertedPMs = [];
for (let i = 0; i < (ribbonObject.pointmasterArray.length - 2) * 3; ++i) {
convertedPMs[i] = ribbonObject.pointmasterArray[i - Math.floor(i / 3) * 2];
}
const convertedBreaks = [];
for (let i = 0; i < (breakArray.length - 2) * 3; ++i) {
convertedBreaks[i] = breakArray[i - Math.floor(i / 3) * 2];
}
const convertedPositions = [];
for (let i = 0; i < (positionArray.length / 3 - 2) * 9; ++i) {
convertedPositions[i] = positionArray[i - Math.floor(i / 9) * 6];
}
const vector3Positions = [];
for (let i = 0; i < (convertedPositions.length) / 3; ++i) {
vector3Positions.push([convertedPositions[i * 3], convertedPositions[i * 3] + 1, convertedPositions[i * 3] + 2]);
}
return {
group: ribbonObject.group,
subgroup: ribbonObject.subgroup,
name: ribbonObject.name,
masterArray: ribbonObject.masterArray,
pointmasterArray: convertedPMs,
nobutton: ribbonObject.nobutton,
labelArray: convertedLabels,
positionArray: convertedPositions,
breakArray: convertedBreaks,
colorArray: convertedColors,
pairTriangleNormals: ribbonObject.pairTriangleNormals
};
}
function removePointBreaksTriangleArrays(convertedRibbonObject: RibbonObject) {
// after converting ribbon/triangle arrys to drawmode, removed point break triangles
// label/color [ 0 1 2 3 4 5 ] to [ 0 1 2 1 2 3 2 3 4 3 4 5 ]
// position [ 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 ] to [ 0 0 0 1 1 1 2 2 2 1 1 1 2 2 2 3 3 3 2 2 2 3 3 3 4 4 4 ]
const { labelArray, positionArray, colorArray, breakArray } = convertedRibbonObject;
const editedLabels = [];
const editedPositions = [];
const editedColors = [];
const editedPMs = [];
const editedBreaks = [];
for (let i = 0; i < breakArray.length / 3; i++) {
const breakPointer = i * 3;
const positionPointer = i * 9;
if (!breakArray[breakPointer + 1] && !breakArray[breakPointer + 2]) {
editedLabels.push(labelArray[breakPointer]);
editedLabels.push(labelArray[breakPointer + 1]);
editedLabels.push(labelArray[breakPointer + 2]);
editedBreaks.push(breakArray[breakPointer]);
editedBreaks.push(breakArray[breakPointer + 1]);
editedBreaks.push(breakArray[breakPointer + 2]);
editedPositions.push(positionArray[positionPointer]);
editedPositions.push(positionArray[positionPointer + 1]);
editedPositions.push(positionArray[positionPointer + 2]);
editedPositions.push(positionArray[positionPointer + 3]);
editedPositions.push(positionArray[positionPointer + 4]);
editedPositions.push(positionArray[positionPointer + 5]);
editedPositions.push(positionArray[positionPointer + 6]);
editedPositions.push(positionArray[positionPointer + 7]);
editedPositions.push(positionArray[positionPointer + 8]);
editedColors.push(colorArray[breakPointer]);
editedColors.push(colorArray[breakPointer + 1]);
editedColors.push(colorArray[breakPointer + 2]);
editedPMs.push(convertedRibbonObject.pointmasterArray[breakPointer]);
editedPMs.push(convertedRibbonObject.pointmasterArray[breakPointer + 1]);
editedPMs.push(convertedRibbonObject.pointmasterArray[breakPointer + 2]);
}
}
return {
group: convertedRibbonObject.group,
subgroup: convertedRibbonObject.subgroup,
name: convertedRibbonObject.name,
masterArray: convertedRibbonObject.masterArray,
pointmasterArray: editedPMs,
nobutton: convertedRibbonObject.nobutton,
labelArray: editedLabels,
positionArray: editedPositions,
breakArray: editedBreaks,
colorArray: editedColors,
pairTriangleNormals: convertedRibbonObject.pairTriangleNormals
};
}
class KinParser {
// @brief Property that is filled in by the constructor as it parses the file. Read by the caller.
kinemage: Kinemage;
// @brief Constructor for the KinParser class.
// @param data The string data to be parsed, including all lines in the file.
constructor(data: string) {
this._parse(data);
}
private _parse(data: string) {
// http://kinemage.biochem.duke.edu/software/king.php
const kinemage: Kinemage = {
comments: [],
kinemage: undefined,
onewidth: undefined,
viewDict: {},
pdbfile: undefined,
texts: [],
text: '',
captions: [],
caption: '',
groupDict: {},
subgroupDict: {},
masterDict: {},
pointmasterDict: {},
dotLists: [],
vectorLists: [],
ballLists: [],
ribbonLists: [],
groupsAnimate: [],
activeAnimateGroup: -1,
groupsAnimate2: [],
activeAnimateGroup2: -1
};
this.kinemage = kinemage;
// Keep a local copy of the ColorDict that we can update with new colors defined in the file.
const localColorDict: { [k: string]: Color } = Object.assign({}, ColorDict);
let currentGroup: string = '';
let currentGroupMasters: string[];
let currentSubgroup: string = '';
let currentSubgroupMasters: string[];
let isDotList = false;
let prevDotLabel = '';
let dotDefaultColor: Color;
let dotLabel: string[], dotPosition: number[], dotColor: Color[], dotPointMasters: string[][];
let isVectorList = false;
let prevVecLabel = '';
let prevVecPosition: number[] | null = null;
let prevVecColor: Color | null = null;
let vecDefaultColor: Color, vecDefaultWidth: number;
let vecLabel1: string[], vecLabel2: string[], vecPosition1: number[], vecPosition2: number[], vecColor1: Color[], vecColor2: Color[];
let vecWidth: number[], vecPointMasters: string[][];
let isBallList = false;
let prevBallLabel = '';
let ballRadius: number[], ballDefaultColor: Color, ballDefaultRadius: number;
let ballLabel: string[], ballPosition: number[], ballColor: Color[], ballPointMasters: string[][];
let isRibbonList = false;
let ribbonIsTriangles = false;
let prevRibbonPointLabel = '';
let ribbonListDefaultColor: Color = localColorDict['white'];
let ribbonPointLabelArray: string[], ribbonPointPositionArray: number[], ribbonPointBreakArray: boolean[], ribbonPointColorArray: Color[];
let ribbonPointMasters: string[][];
let isText = false;
let isCaption = false;
let foundAnimate = false;
let found2Animate = false;
function _parseChunkOfLines(_i: number, _n: number, lines: string[]) {
for (let i = _i; i < _n; ++i) {
const line = lines[i];
if (line[0] === '@') {
isDotList = false;
isVectorList = false;
isBallList = false;
isRibbonList = false;
isText = false;
isCaption = false;
}
if (!line) {
isDotList = false;
isVectorList = false;
isBallList = false;
isRibbonList = false;
} else if (line.startsWith('@dot') /* dot or dotlist */) {
// @dotlist {x} color=white master={vdw contact} master={dots}
let { listColor, listName, listMasters, nobutton } = parseListDef(line, localColorDict);
isDotList = true;
prevDotLabel = '';
dotLabel = [];
dotPosition = [];
dotColor = [];
dotPointMasters = [];
dotDefaultColor = listColor;
if (currentGroupMasters) {
listMasters = listMasters.concat(currentGroupMasters);
}
if (currentSubgroupMasters) {
listMasters = listMasters.concat(currentSubgroupMasters);
}
kinemage.dotLists.push({
group: currentGroup,
subgroup: currentSubgroup,
name: listName,
masterArray: listMasters,
pointmasterArray: dotPointMasters,
nobutton: nobutton,
labelArray: dotLabel,
positionArray: dotPosition,
colorArray: dotColor
});
} else if (line.startsWith('@vector') /* vector or vectorlist */) {
// @vectorlist {x} color=white master={small overlap} master={dots}
let { listMasters, listName, listWidth, listColor, nobutton } = parseListDef(line, localColorDict);
if (listMasters) {
listMasters.forEach(function (name: string) {
if (!kinemage.masterDict[name]) {
kinemage.masterDict[name] = {
indent: false,
visible: true
};
}
});
}
isVectorList = true;
prevVecLabel = '';
prevVecPosition = null;
prevVecColor = null;
vecLabel1 = [];
vecLabel2 = [];
vecPosition1 = [];
vecPosition2 = [];
vecColor1 = [];
vecColor2 = [];
vecWidth = [];
vecDefaultColor = listColor;
vecPointMasters = [];
vecDefaultWidth = 2;
if (listWidth) {
vecDefaultWidth = listWidth;
}
if (currentGroupMasters) {
listMasters = listMasters.concat(currentGroupMasters);
}
if (currentSubgroupMasters) {
listMasters = listMasters.concat(currentSubgroupMasters);
}
kinemage.vectorLists.push({
group: currentGroup,
subgroup: currentSubgroup,
name: listName,
masterArray: listMasters,
pointmasterArray: vecPointMasters,
nobutton: nobutton,
label1Array: vecLabel1,
label2Array: vecLabel2,
position1Array: vecPosition1,
position2Array: vecPosition2,
color1Array: vecColor1,
color2Array: vecColor2,
width: vecWidth
});
} else if (line.startsWith('@ball') /* ball or balllist*/ || line.startsWith('@sphere') /* sphere or spherelist */) {
let { listName, listColor, listMasters, listRadius, nobutton } = parseListDef(line, localColorDict);
if (listMasters) {
listMasters.forEach(function (name: string) {
if (!kinemage.masterDict[name]) {
kinemage.masterDict[name] = {
indent: false,
visible: true
};
}
});
}
isBallList = true;
prevBallLabel = '';
ballLabel = [];
ballRadius = [];
ballPosition = [];
ballColor = [];
ballPointMasters = [];
ballDefaultColor = listColor;
ballDefaultRadius = listRadius !== undefined ? listRadius : 1;
if (currentGroupMasters) {
listMasters = listMasters.concat(currentGroupMasters);
}
if (currentSubgroupMasters) {
listMasters = listMasters.concat(currentSubgroupMasters);
}
kinemage.ballLists.push({
group: currentGroup,
subgroup: currentSubgroup,
name: listName,
masterArray: listMasters,
pointmasterArray: ballPointMasters,
nobutton: nobutton,
labelArray: ballLabel,
radiusArray: ballRadius,
positionArray: ballPosition,
colorArray: ballColor
});
} else if (line.startsWith('@ribbon') /* ribbon or ribbonlist */ || line.startsWith('@triangle') /* triangle or trianglelist */) {
let { listMasters, listName, listColor, nobutton } = parseListDef(line, localColorDict);
if (listMasters) {
listMasters.forEach(function (name: string) {
if (!kinemage.masterDict[name]) {
kinemage.masterDict[name] = {
indent: false,
visible: true
};
}
});
}
isRibbonList = true;
ribbonIsTriangles = line.startsWith('@triangle'); /* triangle or trianglelist */
prevRibbonPointLabel = '';
ribbonPointLabelArray = [];
ribbonPointPositionArray = [];
ribbonPointBreakArray = [];
ribbonPointColorArray = [];
ribbonListDefaultColor = listColor;
ribbonPointMasters = [];
if (currentGroupMasters) {
listMasters = listMasters.concat(currentGroupMasters);
}
if (currentSubgroupMasters) {
listMasters = listMasters.concat(currentSubgroupMasters);
}
kinemage.ribbonLists.push({
group: currentGroup,
subgroup: currentSubgroup,
name: listName,
masterArray: listMasters,
pointmasterArray: ribbonPointMasters,
nobutton: nobutton,
labelArray: ribbonPointLabelArray,
positionArray: ribbonPointPositionArray,
breakArray: ribbonPointBreakArray,
colorArray: ribbonPointColorArray,
pairTriangleNormals: !ribbonIsTriangles
});
} else if (line.startsWith('@text')) {
isText = true;
kinemage.texts.push(line.substr(5));
} else if (line.startsWith('@caption')) {
isCaption = true;
kinemage.captions.push(line.substr(8));
} else if (isDotList) {
// { CB THR 1 A}sky 'P' 18.915,14.199,5.024
let { label, color, position, pointMasters } = parseListElm(line, localColorDict);
if (label === '"') {
label = prevDotLabel;
} else {
prevDotLabel = label;
}
if (color === undefined) {
color = dotDefaultColor;
}
dotLabel.push(label);
dotPosition.push(...position);
dotColor.push(color);
dotPointMasters.push(pointMasters);
} else if (isVectorList) {
// { n thr A 1 B13.79 1crnFH} P 17.047, 14.099, 3.625 { n thr A 1 B13.79 1crnFH} L 17.047, 14.099, 3.625
const doubleLine = line.replace(/(?!^){/g, '\n{');
const splitLine = doubleLine.split(/\n/);
for (let i2 = 0; i2 < splitLine.length; i2++) {
const singlePointLine = splitLine[i2];
let { label, color, width, position, isLineBreak, pointMasters } = parseListElm(singlePointLine, localColorDict);
if (label === '"') {
label = prevVecLabel;
}
if (color === undefined) {
color = vecDefaultColor;
}
if (!isLineBreak) {
if (prevVecPosition !== null) {
if (width === undefined) {
width = vecDefaultWidth;
}
vecLabel1.push(prevVecLabel);
vecPosition1.push(...prevVecPosition);
vecColor1.push(prevVecColor ? prevVecColor : vecDefaultColor);
vecLabel2.push(label);
vecPosition2.push(...position);
vecColor2.push(color);
vecWidth.push(width);
vecPointMasters.push(pointMasters);
}
}
prevVecLabel = label;
prevVecPosition = position;
prevVecColor = color;
}
} else if (isBallList) {
// {cb arg A 1 1.431 -106.80} r=1.431 39.085, 8.083, 22.182
let { label, radius, color, position, pointMasters } = parseListElm(line, localColorDict);
if (label === '"') {
label = prevBallLabel;
} else {
prevBallLabel = label;
}
if (radius === undefined) {
radius = ballDefaultRadius;
}
if (color === undefined) {
color = ballDefaultColor;
}
ballLabel.push(label);
ballRadius.push(radius);
ballPosition.push(...position);
ballColor.push(color);
ballPointMasters.push(pointMasters);
} else if (isRibbonList) {
let { label, color, position, isTriangleBreak, pointMasters } = parseListElm(line, localColorDict);
if (label === '"') {
label = prevRibbonPointLabel;
} else {
prevRibbonPointLabel = label;
}
if (color === undefined) {
color = ribbonListDefaultColor;
}
ribbonPointLabelArray.push(label);
ribbonPointPositionArray.push(...position);
ribbonPointBreakArray.push(isTriangleBreak);
ribbonPointColorArray.push(color);
ribbonPointMasters.push(pointMasters);
} else if (isText) {
kinemage.texts.push(line);
} else if (isCaption) {
kinemage.captions.push(line);
} else if (line.startsWith('@kinemage')) {
kinemage.kinemage = parseInt(line.substr(9).trim());
} else if (line.startsWith('@onewidth')) {
kinemage.onewidth = true;
} else if (line.startsWith('@pdbfile')) {
kinemage.pdbfile = parseStr(line);
} else if (line.startsWith('@group')) {
const { groupName, groupFlags, groupMasters } = parseGroup(line);
if (!kinemage.groupDict[groupName as string]) {
kinemage.groupDict[groupName as string] = {
dominant: false,
// If the groupFlags include animate or 2animate, set those to true in the groupDict. Otherwise, set them to false.
animate: groupFlags['animate'] ? true : false,
'2animate': groupFlags['2animate'] ? true : false,
// If the foundAnimate or found2Animate flags are true, set off to true; otherwise set it to the flags value.
off: (foundAnimate || found2Animate) ? true : groupFlags['off'] ? true : false
};
// If the animate or 2animate flags are found in the groupFlags, set foundAnimate
// or found2Animate to true, respectively. Also update the list and index.
if (groupFlags['animate']) {
foundAnimate = true;
kinemage.groupsAnimate.push(groupName as string);
kinemage.activeAnimateGroup = 0;
}
if (groupFlags['2animate']) {
found2Animate = true;
kinemage.groupsAnimate2.push(groupName as string);
kinemage.activeAnimateGroup2 = 0;
}
currentGroupMasters = groupMasters;
}
currentGroup = groupName;
if (currentGroupMasters) {
currentGroupMasters.forEach(function (master) {
if (!kinemage.masterDict[master]) {
kinemage.masterDict[master] = {
indent: false,
visible: true
};
}
});
}
for (const key in groupFlags as { [k: string]: boolean }) {
kinemage.groupDict[groupName as string][key] = (groupFlags as { [k: string]: boolean })[key];
}
} else if (line.startsWith('@subgroup')) {
const { groupName, groupFlags, groupMasters } = parseGroup(line);
const combinedName = currentGroup + ':' + groupName as string;
if (!kinemage.subgroupDict[combinedName]) {
kinemage.subgroupDict[combinedName] = {
dominant: false,
// If the groupFlag includes "off", set off to true; otherwise, set it to false.
off: groupFlags['off'] ? true : false,
group: currentGroup
};
currentSubgroupMasters = groupMasters;
}
currentSubgroup = combinedName;
if (currentSubgroupMasters) {
currentSubgroupMasters.forEach(function (master) {
if (!kinemage.masterDict[master]) {
kinemage.masterDict[master] = {
indent: false,
visible: true
};
}
});
}
for (const key in groupFlags as { [k: string]: boolean }) {
kinemage.subgroupDict[combinedName as string][key] = (groupFlags as { [k: string]: boolean })[key];
}
} else if (line.startsWith('@master')) {
const name = parseStr(line);
const flag = parseFlag(line);
if (!kinemage.masterDict[name]) {
kinemage.masterDict[name] = {
indent: false,
visible: true
};
}
// TODO: There can be more than one flag on a @master line: indent, off, nobutton
if (flag === 'on') {
kinemage.masterDict[name].visible = true;
} else if (flag === 'off') {
kinemage.masterDict[name].visible = false;
} else if (flag === 'indent') {
kinemage.masterDict[name].indent = true;
} else if (!flag) {
// nothing to do
}
} else if (line.startsWith('@pointmaster')) {
const { name, tags, on } = parsePointmaster(line);
if (name.length > 0 && tags.length > 0) {
// Ensure that we have a masterDict entry for this pointmaster name, even though it doesn't have any flags of its own.
if (!kinemage.masterDict[name]) {
kinemage.masterDict[name] = {
indent: false,
visible: on !== false // If the on variable is explicitly false, set visible to false. Otherwise, set it to true.
};
}
// Add the mapping to point each single-character tag to the pointmaster name in the pointmasterDict.
for (let i = 0; i < tags.length; i++) {
kinemage.pointmasterDict[tags[i]] = name;
}
}
} else if (line.startsWith('@colorset')) {
// We have a string inside curly brackets {} followed by the name of an existing dictionary color.
const colorName = parseStr(line);
const colorReference = parseFlag(line);
if (colorReference && colorReference in localColorDict) {
localColorDict[colorName] = localColorDict[colorReference];
}
} else if (/^@(\d*)viewid\b/.test(line)) {
const m = line.match(/^@(\d*)viewid\b/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].name = parseStr(line);
} else if (/^@(\d*)center\b/.test(line)) {
// Match all of the line after center as another string.
const m = line.match(/^@(\d*)center\b\s*(.*)$/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
// Pull out the three whitespace-separated numbers after the keyword. Parse each as a float and
// add them to a length-3 list of numbers.
const rest = (m && m[2]) ? m[2].trim() : '';
// Split on whitespace and take the first three tokens, parsed as floating-point numbers, as the center coordinates.
const parts = rest.length > 0 ? rest.split(/\s+/).filter(Boolean) : [];
const centerTokens = parts.slice(0, 3).map(parseFloat);
// If the length is 3 and all are valid numbers, add the list of three numbers to the view dictionary.
if (centerTokens.length === 3 && centerTokens.every(num => !isNaN(num))) {
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].center = centerTokens;
}
} else if (/^@(\d*)matrix\b/.test(line)) {
// Match all of the line after matrix as another string.
const m = line.match(/^@(\d*)matrix\b\s*(.*)$/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
// Pull out the nine whitespace-separated numbers after the keyword. Parse each as a float and
// add them to a length-9 list of numbers.
const rest = (m && m[2]) ? m[2].trim() : '';
// Split on whitespace and take the first nine tokens, parsed as floating-point numbers, as the matrix values.
const parts = rest.length > 0 ? rest.split(/\s+/).filter(Boolean) : [];
const matrixTokens = parts.slice(0, 9).map(parseFloat);
// If the length is 9 and all are valid numbers, add the list of nine numbers to the view dictionary.
if (matrixTokens.length === 9 && matrixTokens.every(num => !isNaN(num))) {
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].matrix = matrixTokens;
}
} else if (/^@(\d*)span\b/.test(line)) {
// Match all of the line after span as another string.
const m = line.match(/^@(\d*)span\b\s*(.*)$/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
// Pull out the remainder of the line and parse it as a float.
const rest = (m && m[2]) ? m[2].trim() : '';
const spanValue = parseFloat(rest);
// If it is a valid number, add it to the view dictionary.
if (!isNaN(spanValue)) {
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].span = spanValue;
}
} else if (/^@(\d*)zoom\b/.test(line)) {
// Match all of the line after zoom as another string.
const m = line.match(/^@(\d*)zoom\b\s*(.*)$/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
// Pull out the remainder of the line and parse it as a float.
const rest = (m && m[2]) ? m[2].trim() : '';
const zoomValue = parseFloat(rest);
// If it is a valid number, add it to the view dictionary.
if (!isNaN(zoomValue)) {
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].zoom = zoomValue;
}
} else if (/^@(\d*)zslab\b/.test(line)) {
// Match all of the line after zslab as another string.
const m = line.match(/^@(\d*)zslab\b\s*(.*)$/);
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
// Pull out the remainder of the line and parse it as a float.
const rest = (m && m[2]) ? m[2].trim() : '';
const zslabValue = parseFloat(rest);
// If it is a valid number, add it to the view dictionary.
if (!isNaN(zslabValue)) {
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
kinemage.viewDict[viewCount].zslab = zslabValue;
}
} else {
console.log('Kinemage: Unrecognized line: ' + line);
}
}
}
// Break the file into a list of lines and then parse them all.
const lines = data.split(/\r?\n/);
_parseChunkOfLines(0, lines.length, lines);
kinemage.text = kinemage.texts.join('\n').trim();
kinemage.caption = kinemage.captions.join('\n').trim();
if (kinemage.ribbonLists) {
const convertedLists: RibbonObject[] = [];
kinemage.ribbonLists.forEach(function (listObject) {
convertedLists.push(removePointBreaksTriangleArrays(convertKinTriangleArrays(listObject)));
});
kinemage.ribbonLists = convertedLists;
}
}
}
export { KinParser };

View File

@@ -1,41 +0,0 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author ReliaSolve <russ@reliasolve.com>
*/
import { ReaderResult as Result } from '../../../mol-io/reader/result';
import { Task, RuntimeContext } from '../../../mol-task';
import { Kinemage } from './schema';
import { KinParser } from './kinparser';
async function parseInternal(data: string, ctx: RuntimeContext): Promise<Result<Kinemage[]>> {
const kinemages: Kinemage[] = [];
// Split the data into sections based on the '@kinemage' keyword, which indicates one or more kinemages in the file.
// Handle the case where there is no '@kinemage' keyword by parsing the entire file.
const kinemageSections = data.split(/@kinemage\s+\d+/); // Split based on '@kinemage' keyword followed by a number
// If there are one or more @kinemage sections, ignore the portion before the first one.
// This will either be an empty string (if the first section starts at the beginning of the file)
// or header data that is not part of a particular kinemage. This has the effect of removing
// the header data even in the case where there is a single @kinemage keyword.
if (kinemageSections.length > 1) {
kinemageSections.shift();
}
for (const section of kinemageSections) {
if (section.trim()) { // Ignore empty sections
const NGLParser = new KinParser(section.trim());
const kinData = NGLParser.kinemage;
kinemages.push(kinData);
}
}
return Result.success(kinemages);
}
export function parseKin(data: string) {
return Task.create<Result<Kinemage[]>>('Parse KIN', async ctx => {
return await parseInternal(data, ctx);
});
}

View File

@@ -1,82 +0,0 @@
/**
* Copyright (c) 2025-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author ReliaSolve <russ@reliasolve.com>
*/
import { Color } from '../../../mol-util/color';
export interface Kinemage {
readonly comments: ReadonlyArray<string>
kinemage?: number,
onewidth?: any,
viewDict: { [id: number]: View },
pdbfile?: string,
text: string,
texts: string[],
captions: string[],
caption: string,
groupDict: { [k: string]: { [k: string]: boolean } },
subgroupDict: { [k: string]: any }, // /< Subgroup key is "GroupName:SubgroupName" to preserve tree structure
masterDict: { [k: string]: { indent: boolean, visible: boolean } },
pointmasterDict: { [k: string]: string }, // /< Maps from single-character name to master name for points, e.g. 'a' -> 'alta'
dotLists: DotList[],
vectorLists: VectorList[],
ballLists: BallList[],
ribbonLists: RibbonObject[],
groupsAnimate: string[],
activeAnimateGroup: number,
groupsAnimate2: string[],
activeAnimateGroup2: number,
viewSnapshots?: {} // /< Used to store view snapshots in behavior.ts to use in ui.tsx
}
/** Common base for all list-like objects in a kinemage */
export interface KinListBase {
name?: string, // /< Optional name of the whole List
group: string, // /< Name of the group this List belongs to (may be '' if no group)
subgroup: string, // /< Name of the subgroup this List belongs to (may be '' if no subgroup)
nobutton: boolean, // /< Whether the list is a nobutton list (true if 'nobutton' keyword found)
masterArray: any[], // /< Array of master names per List, not per element
pointmasterArray: string[][] // /< Array of point master names per element
}
export interface DotList extends KinListBase {
labelArray: string[], // /< Array of labels per element
positionArray: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
colorArray: Color[] // /< Color for each element, as many as elements
}
export interface BallList extends KinListBase {
labelArray: string[], // /< Array of labels per element
positionArray: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
colorArray: Color[], // /< Color for each element, as many as elements
radiusArray: number[] // /< A single radius per element
}
export interface RibbonObject extends KinListBase {
labelArray: string[], // /< Array of labels per element
positionArray: number[], // /< Catenation of x, y, z for each element, 9x as many as triangles (3 vertices per triangle)
colorArray: Color[], // /< Color for each element, as many as elements
breakArray: boolean[], // /< A single boolean per element indicating if there is a break there
pairTriangleNormals: boolean // /< Whether to pair every other triangle normal for lighting (true for ribbons, false for triangles)
}
export interface VectorList extends KinListBase {
label1Array: string[], // /< Array of labels for the first half of each element
label2Array: string[], // /< Array of labels for the second half of each element
position1Array: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
position2Array: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
color1Array: Color[], // /< Color for first half of each element, as many as elements
color2Array: Color[], // /< Color for second half of each element, as many as elements
width: number[] // /< A single width per element
}
export interface View {
name?: string, // /< Optional name of the View
center?: number[], // /< X, Y, Z of the center of the view; the model rotates around this point
matrix?: number[], // /< Specifies and orthonormal rotation matrix defining view orientation
span?: number, // /< Specifies the (smaller of) width or height of the view in world coordinates at the center
zoom?: number, // /< Alternate zoom specification, indicates how much of the model is visible, 1=all, 2=half
zslab?: number // /< Distance from the center to the near and far clipping planes, 200 means same as span (half is percent of half span)
}

View File

@@ -1,34 +0,0 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/** Based on the ../anvil extension. */
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { Representation, RepresentationContext, RepresentationParamsGetter } from '../../mol-repr/representation';
import { Structure } from '../../mol-model/structure';
import { StructureRepresentation, StructureRepresentationStateBuilder } from '../../mol-repr/structure/representation';
import { ThemeRegistryContext } from '../../mol-theme/theme';
// TODO: Convert this approach to a more usual one that creates visuals during parse and shows them
// during visuals.
const KinemageDataVisuals = {
};
export const KinemageDataParams = {
visuals: PD.MultiSelect([], PD.objectToOptions(KinemageDataVisuals)),
};
export type KinemageDataParams = typeof KinemageDataParams
export type KinemageDataProps = PD.Values<KinemageDataParams>
export function getKinemageDataParams(ctx: ThemeRegistryContext, structure: Structure) {
return PD.clone(KinemageDataParams);
}
export type KinemageDataRepresentation = StructureRepresentation<KinemageDataParams>
export function KinemageDataRepresentation(ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, KinemageDataParams>): KinemageDataRepresentation {
return Representation.createMulti('Membrane Orientation', ctx, getParams, StructureRepresentationStateBuilder, KinemageDataVisuals as unknown as Representation.Def<Structure, KinemageDataParams>);
}

View File

@@ -1,429 +0,0 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/**
* Kinemage right-panel controls (right-panel only).
*
* Shows kinemage views, animate buttons, and group/subgroup/master toggles in the right inspector.
* Controls update visibility controller parameters which trigger rebuilds via the state tree.
*/
import * as React from 'react';
import { CollapsableState, CollapsableControls } from '../../mol-plugin-ui/base';
import { Camera } from '../../mol-canvas3d/camera';
import { applyViewSnapshot } from './behavior';
import { Kinemage } from './reader/schema';
import { StateTransforms } from '../../mol-plugin-state/transforms';
import { KinemageShapePointsProvider, KinemageShapeLinesProvider, KinemageShapeMeshProvider, KinemageShapeSpheresProvider } from './behavior';
interface KinemageControlState extends CollapsableState {
isBusy: boolean
}
function nameFromString(s: string | undefined) {
// If this is undefined, return undefined.
if (!s) return undefined;
// Return up to the first 30 characters of the string.
return s.length > 30 ? s.substring(0, 30) + '...' : s;
}
export class KinemageControls extends CollapsableControls<{}, KinemageControlState> {
protected defaultState(): KinemageControlState {
return {
header: 'Kinemage',
isCollapsed: false,
isBusy: false,
// default hidden until a kinemage is present
isHidden: true,
brand: { accent: 'cyan', svg: undefined as any }
};
}
componentDidMount() {
// Listen for shape/state changes: when state tree cells are created or removed the visuals changed.
this.subscribe(this.plugin.state.data.events.cell.created, (e: any) => this.onCellCreated(e));
this.subscribe(this.plugin.state.data.events.cell.removed, () => this.onCellRemoved());
// also track cell state updates that may change labels / visibility
this.subscribe(this.plugin.state.data.events.cell.stateUpdated, () => this.forceUpdate());
// ensure initial visibility reflects current state
this.updateVisibility();
}
private onCellCreated(e: any) {
this.updateVisibility();
}
private onCellRemoved() {
this.updateVisibility();
}
private updateVisibility() {
const kinemages = this.getKinemageList();
this.setState({ isHidden: kinemages.length === 0 });
}
private getKinemageList(): Array<{ kinData: Kinemage, ref: string, visControllerRef: string }> {
const result: Array<{ kinData: Kinemage, ref: string, visControllerRef: string }> = [];
try {
const cells = (this.plugin.state.data as any).cells as Map<string, any>;
for (const [ref, entry] of cells) {
const obj = (entry as any).obj;
// Look for Format.Json nodes that contain kinData and visibilityState (visibility controller)
if (obj && obj.data && (obj.data as any).kinData && (obj.data as any).visibilityState) {
result.push({
kinData: (obj.data as any).kinData,
ref,
visControllerRef: ref
});
}
}
} catch (e) {
console.warn('Failed to enumerate kinemage nodes', e);
}
return result;
}
private getAllDescendants(nodeRef: string): string[] {
const result: string[] = [];
const tree = this.plugin.state.data.tree;
const queue = [nodeRef];
while (queue.length > 0) {
const current = queue.shift()!;
const children = tree.children.get(current);
if (children) {
for (const childRef of children.values()) {
result.push(childRef);
queue.push(childRef);
}
}
}
return result;
}
private async applyView(kinData: Kinemage, viewKey: string) {
const snap = (kinData as any).viewSnapshots?.[viewKey];
if (snap) {
await applyViewSnapshot(this.plugin, snap as Partial<Camera.Snapshot>);
}
}
private async rebuildShapes(visControllerRef: string, kinData: Kinemage) {
const update = this.plugin.state.data.build();
// Delete all descendants (shape providers and representations)
const descendants = this.getAllDescendants(visControllerRef);
for (const nodeRef of descendants) {
update.delete(nodeRef);
}
await update.commit();
// Recreate shapes
const rebuildUpdate = this.plugin.state.data.build();
// Generate all shape types that have data, each as child of the visibility controller
if (kinData.dotLists.length > 0) {
rebuildUpdate
.to(visControllerRef)
.apply(KinemageShapePointsProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
if (kinData.vectorLists.length > 0) {
rebuildUpdate
.to(visControllerRef)
.apply(KinemageShapeLinesProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
if (kinData.ribbonLists.length > 0) {
rebuildUpdate
.to(visControllerRef)
.apply(KinemageShapeMeshProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D, { doubleSided: true });
}
if (kinData.ballLists.length > 0) {
rebuildUpdate
.to(visControllerRef)
.apply(KinemageShapeSpheresProvider, {}, { state: { isGhost: true } })
.apply(StateTransforms.Representation.ShapeRepresentation3D);
}
await rebuildUpdate.commit();
}
private async toggleVisibility(visControllerRef: string, kinData: Kinemage, target: { type: 'group' | 'subgroup' | 'master', key: string }) {
try {
const cell = this.plugin.state.data.cells.get(visControllerRef);
if (!cell || !cell.transform || !cell.transform.params) return;
const currentParams = cell.transform.params;
const newGroupVisibility = { ...currentParams.groupVisibility };
const newSubgroupVisibility = { ...currentParams.subgroupVisibility };
const newMasterVisibility = { ...currentParams.masterVisibility };
if (target.type === 'group') {
newGroupVisibility[target.key] = !newGroupVisibility[target.key];
} else if (target.type === 'subgroup') {
newSubgroupVisibility[target.key] = !newSubgroupVisibility[target.key];
} else {
newMasterVisibility[target.key] = !newMasterVisibility[target.key];
}
const update = this.plugin.state.data.build();
// Update the visibility controller
update.to(visControllerRef).update({
groupVisibility: newGroupVisibility,
subgroupVisibility: newSubgroupVisibility,
masterVisibility: newMasterVisibility
});
await update.commit();
// Rebuild all shapes to reflect new visibility
await this.rebuildShapes(visControllerRef, kinData);
} catch (e) {
console.error('Failed to toggle kinemage visibility', e);
}
}
private async triggerAnimateForKin(visControllerRef: string, kinData: Kinemage, mode: 'animate' | '2animate') {
try {
const cell = this.plugin.state.data.cells.get(visControllerRef);
if (!cell || !cell.transform || !cell.transform.params) return;
const currentParams = cell.transform.params;
const animateGroups = mode === 'animate' ? kinData.groupsAnimate : kinData.groupsAnimate2;
const currentActive = mode === 'animate' ? currentParams.activeAnimateGroup : currentParams.activeAnimateGroup2;
const nextActive = (currentActive + 1) % Math.max(1, animateGroups.length);
// IMPORTANT: Read the CURRENT visibility state from the controller node's data (not params)
// to preserve any changes made through UI interactions
const controllerCell = this.plugin.state.data.cells.get(visControllerRef);
const currentVisibilityState = controllerCell?.obj?.data ? (controllerCell.obj.data as any).visibilityState : null;
// Start with current actual visibility state
const newGroupVisibility = currentVisibilityState
? Object.fromEntries(currentVisibilityState.groupVisibility)
: { ...currentParams.groupVisibility };
// Only update the animate groups - leave everything else as-is
for (let i = 0; i < animateGroups.length; i++) {
newGroupVisibility[animateGroups[i]] = (i === nextActive);
}
const update = this.plugin.state.data.build();
// Update the visibility controller with current visibility PLUS animate changes
const updateParams: any = {
groupVisibility: newGroupVisibility,
};
if (mode === 'animate') {
updateParams.activeAnimateGroup = nextActive;
} else {
updateParams.activeAnimateGroup2 = nextActive;
}
// Also preserve other visibility states
if (currentVisibilityState) {
updateParams.subgroupVisibility = Object.fromEntries(currentVisibilityState.subgroupVisibility);
updateParams.masterVisibility = Object.fromEntries(currentVisibilityState.masterVisibility);
} else {
updateParams.subgroupVisibility = currentParams.subgroupVisibility;
updateParams.masterVisibility = currentParams.masterVisibility;
}
update.to(visControllerRef).update(updateParams);
await update.commit();
// Rebuild all shapes to reflect new visibility
await this.rebuildShapes(visControllerRef, kinData);
} catch (e) {
console.error('Failed to trigger animate', e);
}
}
private isVisible(visControllerRef: string, target: { type: 'group' | 'subgroup' | 'master', key: string }): boolean {
try {
const cell = this.plugin.state.data.cells.get(visControllerRef);
if (!cell || !cell.transform || !cell.transform.params) return true;
const params = cell.transform.params;
if (target.type === 'group') {
return params.groupVisibility[target.key] !== false;
} else if (target.type === 'subgroup') {
return params.subgroupVisibility[target.key] !== false;
} else {
return params.masterVisibility[target.key] !== false;
}
} catch (e) {
return true;
}
}
renderControls() {
const kins = this.getKinemageList();
if (kins.length === 0) return <div style={{ padding: '6px' }}>No Kinemage data</div>;
const blocks: React.ReactNode[] = [];
for (const { kinData, visControllerRef } of kins) {
const title = kinData.pdbfile || nameFromString(kinData.caption) || 'Kinemage';
const kinBlock: React.ReactNode[] = [];
// Title
kinBlock.push(
<div key={'title-' + title} style={{ padding: '6px', fontWeight: 'bold', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
{title}
</div>
);
// views
const viewEntries = Object.entries(kinData.viewDict || {});
if (viewEntries.length > 0) {
for (const [viewKey, viewObj] of viewEntries) {
const label = `View ${viewObj.name || `View ${viewKey}`}`;
kinBlock.push(
<div key={'view-' + title + '-' + viewKey} style={{ padding: '2px 6px' }}>
<button
className='msp-btn msp-btn-block'
onClick={() => this.applyView(kinData, viewKey)}
title={`Apply view: ${label}`}
>
{label}
</button>
</div>
);
}
}
// animate
if (kinData.groupsAnimate && kinData.groupsAnimate.length > 0) {
kinBlock.push(
<div key={'anim-' + title} style={{ padding: '2px 6px' }}>
<button
className='msp-btn msp-btn-block'
onClick={() => this.triggerAnimateForKin(visControllerRef, kinData, 'animate')}
title='Cycle through animation frames'
>
Animate
</button>
</div>
);
}
if (kinData.groupsAnimate2 && kinData.groupsAnimate2.length > 0) {
kinBlock.push(
<div key={'anim2-' + title} style={{ padding: '2px 6px' }}>
<button
className='msp-btn msp-btn-block'
onClick={() => this.triggerAnimateForKin(visControllerRef, kinData, '2animate')}
title='Cycle through second animation frames'
>
Animate2
</button>
</div>
);
}
// groups
for (const [groupKey, groupInfo] of Object.entries(kinData.groupDict || {})) {
if (!(groupInfo as any).nobutton) {
const visible = this.isVisible(visControllerRef, { type: 'group', key: groupKey });
// If this group is in animate or animate2, then add '*' before its groupKey name to indicate that it's an animation group
const isAnimate = (kinData.groupsAnimate?.includes(groupKey) ?? false) || (kinData.groupsAnimate2?.includes(groupKey) ?? false);
const label = isAnimate ? `* ${groupKey}` : groupKey;
kinBlock.push(
<div key={'group-' + title + '-' + groupKey} style={{ padding: '2px 6px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'group', key: groupKey })}
style={{ marginRight: '6px' }}
/>
<span title={label}>{label}</span>
</label>
</div>
);
}
// If this group is not dominant, find any subgroups of this group and show them here (indented) unless they have nobutton set
if (!(groupInfo as any).dominant) {
for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict || {})) {
if (subgroupKey.startsWith(groupKey + ':')) {
if ((subgroupInfo as any).nobutton) continue;
const visible = this.isVisible(visControllerRef, { type: 'subgroup', key: subgroupKey });
const subgroupLabel = subgroupKey.split(':')[1];
kinBlock.push(
<div key={'subgroup-' + title + '-' + subgroupKey} style={{ padding: '2px 6px', paddingLeft: '24px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'subgroup', key: subgroupKey })}
style={{ marginRight: '6px' }}
/>
<span title={subgroupLabel}>{subgroupLabel}</span>
</label>
</div>
);
}
}
}
}
// subgroups that don't belong to a group (standalone)
for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict || {})) {
// if parent group present, those groups' subgroups are already shown when iterating groups
if (subgroupKey.indexOf(':') !== -1) {
// subgroups with parent group; skip here (shown under parent group)
continue;
}
if ((subgroupInfo as any).nobutton) continue;
const visible = this.isVisible(visControllerRef, { type: 'subgroup', key: subgroupKey });
kinBlock.push(
<div key={'subgroup-' + title + '-' + subgroupKey} style={{ padding: '2px 6px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'subgroup', key: subgroupKey })}
style={{ marginRight: '6px' }}
/>
<span title={subgroupKey}>{subgroupKey}</span>
</label>
</div>
);
}
// masters
for (const [masterKey] of Object.entries(kinData.masterDict || {})) {
const visible = this.isVisible(visControllerRef, { type: 'master', key: masterKey });
kinBlock.push(
<div key={'master-' + title + '-' + masterKey} style={{ padding: '2px 6px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'master', key: masterKey })}
style={{ marginRight: '6px' }}
/>
<span title={masterKey}>{masterKey}</span>
</label>
</div>
);
}
blocks.push(<div key={'kin-block-' + title} className='msp-control-group-wrapper'>{kinBlock}</div>);
}
return <>{blocks}</>;
}
}

View 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]);
});
});

View File

@@ -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);
});
}

View 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
}

View 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,
};
});
}

View File

@@ -1,9 +1,8 @@
/**
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2024 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>
* @author Adam Midlik <midlik@gmail.com>
*/
import { PluginContext } from '../../mol-plugin/context';
@@ -77,10 +76,7 @@ const DownloadStructure = StateAction.build({
}, { isFlat: true, label: 'SWISS-MODEL', description: 'Loads the best homology model or experimental structure' }),
'alphafolddb': PD.Group({
provider: PD.Group({
id: PD.Text('Q8W3K0', {
label: 'ID(s)',
description: 'One or more comma/space separated IDs. Each ID can be either UniProt accession (e.g. Q14676, Q14676-2) or AlphaFoldDB model entity ID (e.g. AF-Q14676-F1, AF-Q14676-2-F1, AF-0000000066074510). Version suffixes (e.g. -v1) will be ignored and the newest model version will be downloaded.',
}),
id: PD.Text('Q8W3K0', { label: 'UniProtKB AC(s)', description: 'One or more comma/space separated ACs.' }),
encoding: PD.Select('bcif', PD.arrayToOptions(['cif', 'bcif'] as const)),
}, { pivot: 'id' }),
options
@@ -156,11 +152,7 @@ const DownloadStructure = StateAction.build({
case 'alphafolddb':
downloadParams = await getDownloadParams(src.params.provider.id,
async id => {
// id = UniProt accession: Q14676, Q14676-4
// id = model entity ID: AF-Q14676-F1, AF-Q14676-4-F1, AF-0000000066074510
// id = model entity ID + version to be ignored: AF-Q14676-4-F1-v6, AF-0000000066074510-v1
const cleanId = id.replace(/-v\d+$/i, '').toUpperCase(); // Ignore version suffix (e.g. "-v6") because it is not a part of the ID, but displayed on AFDB page and people often copy-paste it
const url = `https://www.alphafold.ebi.ac.uk/api/prediction/${cleanId}`;
const url = `https://www.alphafold.ebi.ac.uk/api/prediction/${id.toUpperCase()}`;
const info = await plugin.runTask(plugin.fetch({ url, type: 'json' }));
if (Array.isArray(info) && info.length > 0) {
const prop = src.params.provider.encoding === 'bcif' ? 'bcifUrl' : 'cifUrl';

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);
});
}
});

View File

@@ -1,74 +0,0 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author ReliaSolve <russ@reliasolve.com>
*
* Adapted from kin-parser.ts file from the NGL project:
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* Adapted from hsl.ts in this same directory:
* @author David Sehnal <david.sehnal@gmail.com>
*/
import type { Color } from '../color';
import { Rgb } from './rgb';
export { Hsv };
/** Hsv tuple: [h, s, v]
* - h in [0,360] degrees
* - s in [0,100] percent
* - v in [0,100] percent
*/
interface Hsv extends Array<number> { [d: number]: number, '@type': 'hsv', length: 3 }
function Hsv() {
return Hsv.zero();
}
namespace Hsv {
export function zero(): Hsv {
const out = [0.0, 0.0, 0.0];
out[0] = 0;
return out as Hsv;
}
/** Copy values from an array-like 3-tuple into `out`. */
export function fromArray(arr: ArrayLike<number>): Hsv {
const out = Hsv.zero();
out[0] = arr[0] ?? 0;
out[1] = arr[1] ?? 0;
out[2] = arr[2] ?? 0;
return out;
}
const _rgb = Rgb();
export function toColor(hsv: Hsv): Color {
toRgb(_rgb, hsv);
return Rgb.toColor(_rgb);
}
export function toRgb(out: Rgb, hsv: Hsv) {
let [h, s, v] = hsv;
h /= 360;
s /= 100;
v /= 100;
let r = 0, g = 0, b = 0;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
out[0] = r;
out[1] = g;
out[2] = b;
return out;
}
}