mirror of
https://github.com/molstar/molstar.git
synced 2026-06-05 05:44:23 +08:00
Compare commits
2 Commits
obj-format
...
dynamic-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26340bf215 | ||
|
|
60a7cab28f |
@@ -4,8 +4,6 @@ All notable changes to this project will be documented in this file, following t
|
||||
Note that since we don't clearly distinguish between a public and private interfaces there will be changes in non-major versions that are potentially breaking. If we make breaking changes to less used interfaces we will highlight it in here.
|
||||
|
||||
## [Unreleased]
|
||||
- Fix exported image artifacts on transparent background with emissive, bloom, or antialiasing
|
||||
- Fix cel-shaded ambient color being stripped to luminance (now uses full RGB, matching the classic lighting path)
|
||||
- Fix empty transforms default in `ShapeFromPly`
|
||||
- Use morton order for spheres in dot visual with lod-levels
|
||||
- Add `Camera.changed` event and rotation/translation setter/getter
|
||||
@@ -16,11 +14,7 @@ Note that since we don't clearly distinguish between a public and private interf
|
||||
- Fix memory leak in `State.dispose()` not invoking transformer `dispose` callbacks for live cells
|
||||
- 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
|
||||
- Add OBJ format support
|
||||
|
||||
## [v5.9.0] - 2026-05-03
|
||||
- Fix edge case when `PluginSpec.animations` is empty
|
||||
|
||||
@@ -15,7 +15,7 @@ import { GraphicsMode, MesoscaleGroup, MesoscaleState, getGraphicsModeProps, get
|
||||
import { ColorNames } from '../../../../mol-util/color/names';
|
||||
import { ShapeRepresentation3D, StructureRepresentation3D } from '../../../../mol-plugin-state/transforms/representation';
|
||||
import { ParseCif, ParsePly, ReadFile } from '../../../../mol-plugin-state/transforms/data';
|
||||
import { ModelFromTrajectory, TrajectoryFromGRO, TrajectoryFromMOL, TrajectoryFromMOL2, TrajectoryFromMmCif, TrajectoryFromPDB, TrajectoryFromSDF, TrajectoryFromXYZ } from '../../../../mol-plugin-state/transforms/model';
|
||||
import { ModelFromTrajectory, ShapeFromPly, 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,7 +24,6 @@ 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);
|
||||
|
||||
@@ -25,7 +25,6 @@ export type InteractionElementSchema =
|
||||
| { kind: 'weak-hydrogen-bond' } & InteractionElementSchemaBase
|
||||
| { kind: 'hydrophobic' } & InteractionElementSchemaBase
|
||||
| { kind: 'metal-coordination' } & InteractionElementSchemaBase
|
||||
| { kind: 'water-bridge' } & InteractionElementSchemaBase
|
||||
| { kind: 'covalent', degree?: 'aromatic' | 1 | 2 | 3 | 4 } & InteractionElementSchemaBase
|
||||
|
||||
export type InteractionKind = InteractionElementSchema['kind']
|
||||
@@ -40,7 +39,6 @@ export const InteractionKinds: InteractionKind[] = [
|
||||
'weak-hydrogen-bond',
|
||||
'hydrophobic',
|
||||
'metal-coordination',
|
||||
'water-bridge',
|
||||
'covalent',
|
||||
];
|
||||
|
||||
@@ -54,7 +52,6 @@ export type InteractionInfo =
|
||||
| { kind: 'weak-hydrogen-bond', hydrogenStructureRef?: string, hydrogen?: StructureElement.Loci }
|
||||
| { kind: 'hydrophobic' }
|
||||
| { kind: 'metal-coordination' }
|
||||
| { kind: 'water-bridge' }
|
||||
| { kind: 'covalent', degree?: 'aromatic' | 1 | 2 | 3 | 4 }
|
||||
|
||||
export interface StructureInteractionElement {
|
||||
@@ -83,5 +80,4 @@ export const InteractionTypeToKind = {
|
||||
[InteractionType.Hydrophobic]: 'hydrophobic' as InteractionKind,
|
||||
[InteractionType.MetalCoordination]: 'metal-coordination' as InteractionKind,
|
||||
[InteractionType.WeakHydrogenBond]: 'weak-hydrogen-bond' as InteractionKind,
|
||||
[InteractionType.WaterBridge]: 'water-bridge' as InteractionKind,
|
||||
};
|
||||
@@ -47,7 +47,6 @@ export const InteractionVisualParams = {
|
||||
'weak-hydrogen-bond': hydrogenVisualParams({ color: Color(0x0) }),
|
||||
'hydrophobic': visualParams({ color: Color(0x555555) }),
|
||||
'metal-coordination': visualParams({ color: Color(0x952e8f) }),
|
||||
'water-bridge': visualParams({ color: Color(0x00CCEE), style: 'dashed' }),
|
||||
'covalent': PD.Group({
|
||||
color: PD.Color(Color(0x999999)),
|
||||
radius: PD.Numeric(0.1, { min: 0.01, max: 1, step: 0.01 }),
|
||||
|
||||
@@ -78,7 +78,7 @@ export const apply_light_color = `
|
||||
}
|
||||
#pragma unroll_loop_end
|
||||
|
||||
outgoingLight += physicalMaterial.diffuseColor * uAmbientColor;
|
||||
outgoingLight += physicalMaterial.diffuseColor * luminance(uAmbientColor);
|
||||
#else
|
||||
ReflectedLight reflectedLight = ReflectedLight(vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0));
|
||||
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
|
||||
import { parseObj } from '../obj/parser';
|
||||
|
||||
// Simple triangle
|
||||
const objTriangle = `# simple triangle
|
||||
v 0.0 0.0 0.0
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
f 1 2 3
|
||||
`;
|
||||
|
||||
// Quad that gets fan-triangulated into 2 triangles
|
||||
const objQuad = `# quad fan-triangulated
|
||||
v -1.0 -1.0 0.0
|
||||
v 1.0 -1.0 0.0
|
||||
v 1.0 1.0 0.0
|
||||
v -1.0 1.0 0.0
|
||||
f 1 2 3 4
|
||||
`;
|
||||
|
||||
// Vertex normals
|
||||
const objWithNormals = `# vertex normals
|
||||
v 0.0 0.0 0.0
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
vn 0.0 0.0 1.0
|
||||
vn 0.0 0.0 1.0
|
||||
vn 0.0 0.0 1.0
|
||||
f 1//1 2//2 3//3
|
||||
`;
|
||||
|
||||
// v/vt/vn format (texture coords are ignored but should not break parsing)
|
||||
const objWithTexture = `v 0.0 0.0 0.0
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
vn 0.0 0.0 1.0
|
||||
f 1/1/1 2/2/1 3/3/1
|
||||
`;
|
||||
|
||||
// Multiple materials / usemtl groups
|
||||
const objMultiMaterial = `v 0.0 0.0 0.0
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
v 2.0 0.0 0.0
|
||||
v 2.5 1.0 0.0
|
||||
usemtl red
|
||||
f 1 2 3
|
||||
usemtl green
|
||||
f 2 4 5
|
||||
`;
|
||||
|
||||
// Negative indices (relative addressing)
|
||||
const objNegativeIndices = `v 0.0 0.0 0.0
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
f -3 -2 -1
|
||||
`;
|
||||
|
||||
// Comments and blank lines should be ignored
|
||||
const objWithComments = `# header comment
|
||||
# another comment
|
||||
|
||||
v 0.0 0.0 0.0
|
||||
# inline comment after data
|
||||
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
|
||||
f 1 2 3
|
||||
`;
|
||||
|
||||
// Unsupported directives (g, o, s, mtllib, vt, vp) should be silently skipped
|
||||
const objUnsupportedDirectives = `mtllib material.mtl
|
||||
o MyObject
|
||||
g mygroup
|
||||
s 1
|
||||
v 0.0 0.0 0.0
|
||||
vt 0.0 0.0
|
||||
vp 0.0 1.0
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
f 1 2 3
|
||||
`;
|
||||
|
||||
// Cube (6 faces × 2 triangles = 12 triangles)
|
||||
const objCube = `# unit cube
|
||||
v -1 -1 -1
|
||||
v 1 -1 -1
|
||||
v 1 1 -1
|
||||
v -1 1 -1
|
||||
v -1 -1 1
|
||||
v 1 -1 1
|
||||
v 1 1 1
|
||||
v -1 1 1
|
||||
# bottom (-z)
|
||||
f 1 2 3
|
||||
f 1 3 4
|
||||
# top (+z)
|
||||
f 5 6 7
|
||||
f 5 7 8
|
||||
# front (+x)
|
||||
f 2 6 7
|
||||
f 2 7 3
|
||||
# back (-x)
|
||||
f 5 1 4
|
||||
f 5 4 8
|
||||
# left (-y)
|
||||
f 1 5 6
|
||||
f 1 6 2
|
||||
# right (+y)
|
||||
f 4 3 7
|
||||
f 4 7 8
|
||||
`;
|
||||
|
||||
// CRLF line endings
|
||||
const objCRLF = '# crlf triangle\r\nv 0.0 0.0 0.0\r\nv 1.0 0.0 0.0\r\nv 0.5 1.0 0.0\r\nf 1 2 3\r\n';
|
||||
|
||||
// Tabs and leading whitespace before keywords
|
||||
const objLeadingWhitespace = '\tv 0.0 0.0 0.0\n v 1.0 0.0 0.0\n\t v 0.5 1.0 0.0\n\tf 1 2 3\n';
|
||||
|
||||
// Degenerate face (fewer than 3 vertices) should be skipped with a warning
|
||||
const objDegenerateFace = `v 0.0 0.0 0.0
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
f 1 2
|
||||
f 1 2 3
|
||||
`;
|
||||
|
||||
// Mixed face-vertices: some reference a normal, some do not, within one mesh
|
||||
const objMixedNormals = `v 0.0 0.0 0.0
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
vn 0.0 0.0 1.0
|
||||
f 1//1 2 3//1
|
||||
`;
|
||||
|
||||
// Negative normal indices (relative addressing for normals)
|
||||
const objNegativeNormalIndices = `v 0.0 0.0 0.0
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
vn 0.0 0.0 1.0
|
||||
f 1//-1 2//-1 3//-1
|
||||
`;
|
||||
|
||||
// usemtl reuse: a material name is referenced again after another material
|
||||
const objReusedMaterial = `v 0.0 0.0 0.0
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
v 2.0 0.0 0.0
|
||||
v 2.5 1.0 0.0
|
||||
usemtl red
|
||||
f 1 2 3
|
||||
usemtl green
|
||||
f 2 4 5
|
||||
usemtl red
|
||||
f 1 3 4
|
||||
`;
|
||||
|
||||
// Empty file
|
||||
const objEmpty = '';
|
||||
|
||||
// File with vertices but no faces
|
||||
const objNoFaces = `v 0.0 0.0 0.0
|
||||
v 1.0 0.0 0.0
|
||||
v 0.5 1.0 0.0
|
||||
`;
|
||||
|
||||
describe('obj reader', () => {
|
||||
it('parses a simple triangle', async () => {
|
||||
const parsed = await parseObj(objTriangle).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.positionCount).toBe(3);
|
||||
expect(obj.triangleCount).toBe(1);
|
||||
// First vertex
|
||||
expect(obj.positions[0]).toBeCloseTo(0.0);
|
||||
expect(obj.positions[1]).toBeCloseTo(0.0);
|
||||
expect(obj.positions[2]).toBeCloseTo(0.0);
|
||||
// Triangle indices (0-based)
|
||||
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('fan-triangulates a quad into two triangles', async () => {
|
||||
const parsed = await parseObj(objQuad).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.positionCount).toBe(4);
|
||||
expect(obj.triangleCount).toBe(2);
|
||||
// Fan from vertex 0: (0,1,2) and (0,2,3)
|
||||
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2, 0, 2, 3]);
|
||||
});
|
||||
|
||||
it('parses vertex normals with v//vn format', async () => {
|
||||
const parsed = await parseObj(objWithNormals).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.normalCount).toBe(3);
|
||||
expect(obj.triangleCount).toBe(1);
|
||||
expect(Array.from(obj.normalIndices)).toEqual([0, 1, 2]);
|
||||
// Normal z component of first normal
|
||||
expect(obj.normals[2]).toBeCloseTo(1.0);
|
||||
});
|
||||
|
||||
it('parses v/vt/vn format (texture coords ignored)', async () => {
|
||||
const parsed = await parseObj(objWithTexture).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.positionCount).toBe(3);
|
||||
expect(obj.normalCount).toBe(1);
|
||||
expect(obj.triangleCount).toBe(1);
|
||||
expect(Array.from(obj.normalIndices)).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it('assigns material groups via usemtl', async () => {
|
||||
const parsed = await parseObj(objMultiMaterial).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.triangleCount).toBe(2);
|
||||
// 'default' is always first; 'red' and 'green' added on use
|
||||
expect(obj.groups[1]).toBe('red');
|
||||
expect(obj.groups[2]).toBe('green');
|
||||
// First triangle belongs to 'red' (index 1), second to 'green' (index 2)
|
||||
expect(obj.groupIndices[0]).toBe(1);
|
||||
expect(obj.groupIndices[1]).toBe(2);
|
||||
});
|
||||
|
||||
it('handles negative (relative) vertex indices', async () => {
|
||||
const parsed = await parseObj(objNegativeIndices).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.triangleCount).toBe(1);
|
||||
// -3, -2, -1 with posCount=3 → 0, 1, 2
|
||||
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('ignores comments and blank lines', async () => {
|
||||
const parsed = await parseObj(objWithComments).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.positionCount).toBe(3);
|
||||
expect(obj.triangleCount).toBe(1);
|
||||
});
|
||||
|
||||
it('silently skips unsupported directives', async () => {
|
||||
const parsed = await parseObj(objUnsupportedDirectives).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.positionCount).toBe(3);
|
||||
expect(obj.triangleCount).toBe(1);
|
||||
});
|
||||
|
||||
it('parses a cube (12 triangles, 8 vertices)', async () => {
|
||||
const parsed = await parseObj(objCube).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.positionCount).toBe(8);
|
||||
expect(obj.triangleCount).toBe(12);
|
||||
expect(obj.positionIndices.length).toBe(36); // 12 * 3
|
||||
});
|
||||
|
||||
it('returns no normals when none are defined', async () => {
|
||||
const parsed = await parseObj(objTriangle).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.normalCount).toBe(0);
|
||||
// All normal indices should be -1
|
||||
expect(Array.from(obj.normalIndices)).toEqual([-1, -1, -1]);
|
||||
});
|
||||
|
||||
it('default group is always present', async () => {
|
||||
const parsed = await parseObj(objTriangle).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.groups[0]).toBe('default');
|
||||
expect(obj.groupIndices[0]).toBe(0);
|
||||
});
|
||||
|
||||
it('parses CRLF line endings', async () => {
|
||||
const parsed = await parseObj(objCRLF).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.positionCount).toBe(3);
|
||||
expect(obj.triangleCount).toBe(1);
|
||||
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('handles tabs and leading whitespace before keywords', async () => {
|
||||
const parsed = await parseObj(objLeadingWhitespace).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.positionCount).toBe(3);
|
||||
expect(obj.triangleCount).toBe(1);
|
||||
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('skips a degenerate face and emits a warning', async () => {
|
||||
const parsed = await parseObj(objDegenerateFace).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
// Only the valid triangle survives
|
||||
expect(obj.triangleCount).toBe(1);
|
||||
expect(Array.from(obj.positionIndices)).toEqual([0, 1, 2]);
|
||||
// A warning was recorded for the degenerate face
|
||||
expect(parsed.warnings.length).toBeGreaterThan(0);
|
||||
expect(parsed.warnings.some(w => w.includes('degenerate'))).toBe(true);
|
||||
});
|
||||
|
||||
it('parses faces with mixed normal/no-normal vertices', async () => {
|
||||
const parsed = await parseObj(objMixedNormals).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.positionCount).toBe(3);
|
||||
expect(obj.normalCount).toBe(1);
|
||||
expect(obj.triangleCount).toBe(1);
|
||||
// First and third vertices reference normal 0; the middle has none (-1)
|
||||
expect(Array.from(obj.normalIndices)).toEqual([0, -1, 0]);
|
||||
});
|
||||
|
||||
it('handles negative (relative) normal indices', async () => {
|
||||
const parsed = await parseObj(objNegativeNormalIndices).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.normalCount).toBe(1);
|
||||
expect(obj.triangleCount).toBe(1);
|
||||
// -1 with normCount=1 → 0
|
||||
expect(Array.from(obj.normalIndices)).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it('reuses an already-seen usemtl material name', async () => {
|
||||
const parsed = await parseObj(objReusedMaterial).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.triangleCount).toBe(3);
|
||||
// 'red' (1) and 'green' (2) are the only added groups; no duplicate 'red'
|
||||
expect(obj.groups[1]).toBe('red');
|
||||
expect(obj.groups[2]).toBe('green');
|
||||
expect(obj.groups.indexOf('red')).toBe(1);
|
||||
expect(obj.groups.lastIndexOf('red')).toBe(1);
|
||||
// Third face reuses 'red' (index 1)
|
||||
expect(Array.from(obj.groupIndices)).toEqual([1, 2, 1]);
|
||||
});
|
||||
|
||||
it('parses an empty file', async () => {
|
||||
const parsed = await parseObj(objEmpty).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.positionCount).toBe(0);
|
||||
expect(obj.normalCount).toBe(0);
|
||||
expect(obj.triangleCount).toBe(0);
|
||||
expect(obj.groups[0]).toBe('default');
|
||||
});
|
||||
|
||||
it('parses a file with vertices but no faces', async () => {
|
||||
const parsed = await parseObj(objNoFaces).run();
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
const obj = parsed.result;
|
||||
|
||||
expect(obj.positionCount).toBe(3);
|
||||
expect(obj.triangleCount).toBe(0);
|
||||
expect(obj.positionIndices.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,323 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2018 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 { 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';
|
||||
import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
|
||||
|
||||
// OBJ file format specification: http://www.martinreddy.net/gfx/3d/OBJ.spec
|
||||
|
||||
interface State {
|
||||
tokenizer: Tokenizer
|
||||
positions: ChunkedArray<number, 3>
|
||||
normals: ChunkedArray<number, 3>
|
||||
positionIndices: ChunkedArray<number, 3>
|
||||
normalIndices: ChunkedArray<number, 3>
|
||||
groupIndices: ChunkedArray<number, 1>
|
||||
groups: string[]
|
||||
currentGroupIdx: number
|
||||
warnings: string[]
|
||||
async function parseInternal(data: string, ctx: RuntimeContext): Promise<Result<Mesh>> {
|
||||
// TODO
|
||||
const mesh: Mesh = Mesh.createEmpty();
|
||||
// Mesh.computeNormalsImmediate(mesh)
|
||||
return Result.success(mesh);
|
||||
}
|
||||
|
||||
function State(data: StringLike): State {
|
||||
return {
|
||||
tokenizer: Tokenizer(data),
|
||||
positions: ChunkedArray.create(Float32Array, 3, 1024),
|
||||
normals: ChunkedArray.create(Float32Array, 3, 1024),
|
||||
positionIndices: ChunkedArray.create(Int32Array, 3, 1024),
|
||||
normalIndices: ChunkedArray.create(Int32Array, 3, 1024),
|
||||
groupIndices: ChunkedArray.create(Uint32Array, 1, 1024),
|
||||
groups: ['default'],
|
||||
currentGroupIdx: 0,
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
|
||||
// Character codes used for keyword identification without materializing strings
|
||||
const CC_v = 118; // 'v'
|
||||
const CC_n = 110; // 'n'
|
||||
const CC_f = 102; // 'f'
|
||||
const CC_u = 117; // 'u'
|
||||
const CC_s = 115; // 's'
|
||||
const CC_e = 101; // 'e'
|
||||
const CC_m = 109; // 'm'
|
||||
const CC_t = 116; // 't'
|
||||
const CC_l = 108; // 'l'
|
||||
const CC_HASH = 35; // '#'
|
||||
const CC_NEWLINE = 10; // '\n'
|
||||
const CC_CR = 13; // '\r'
|
||||
const CC_SPACE = 32; // ' '
|
||||
const CC_TAB = 9; // '\t'
|
||||
const CC_SLASH = 47; // '/'
|
||||
|
||||
/** Skip to the end of the current line without returning it. */
|
||||
function skipLine(tokenizer: Tokenizer): void {
|
||||
const { data } = tokenizer;
|
||||
while (tokenizer.position < tokenizer.length) {
|
||||
const c = data.charCodeAt(tokenizer.position);
|
||||
if (c === CC_NEWLINE) { ++tokenizer.position; ++tokenizer.lineNumber; return; }
|
||||
if (c === CC_CR) {
|
||||
++tokenizer.position;
|
||||
++tokenizer.lineNumber;
|
||||
if (tokenizer.position < tokenizer.length && data.charCodeAt(tokenizer.position) === CC_NEWLINE) ++tokenizer.position;
|
||||
return;
|
||||
}
|
||||
++tokenizer.position;
|
||||
}
|
||||
}
|
||||
|
||||
/** Skip inline whitespace (space/tab only — does not cross newlines). */
|
||||
function skipInlineWS(tokenizer: Tokenizer): void {
|
||||
const { data } = tokenizer;
|
||||
while (tokenizer.position < tokenizer.length) {
|
||||
const c = data.charCodeAt(tokenizer.position);
|
||||
if (c !== CC_SPACE && c !== CC_TAB) return;
|
||||
++tokenizer.position;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one whitespace-delimited token on the current line.
|
||||
* Returns false when end-of-line / end-of-file is reached before any character.
|
||||
* Leaves tokenizer.tokenStart/tokenEnd set to the token boundaries.
|
||||
*/
|
||||
function readInlineToken(tokenizer: Tokenizer): boolean {
|
||||
skipInlineWS(tokenizer);
|
||||
const { data } = tokenizer;
|
||||
if (tokenizer.position >= tokenizer.length) return false;
|
||||
const c = data.charCodeAt(tokenizer.position);
|
||||
if (c === CC_NEWLINE || c === CC_CR || c === CC_HASH) return false;
|
||||
tokenizer.tokenStart = tokenizer.position;
|
||||
while (tokenizer.position < tokenizer.length) {
|
||||
const ch = data.charCodeAt(tokenizer.position);
|
||||
if (ch === CC_SPACE || ch === CC_TAB || ch === CC_NEWLINE || ch === CC_CR || ch === CC_HASH) break;
|
||||
++tokenizer.position;
|
||||
}
|
||||
tokenizer.tokenEnd = tokenizer.position;
|
||||
return tokenizer.tokenEnd > tokenizer.tokenStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read up to `maxCount` face-vertex tokens from the current line into `facePos` / `faceNorm`.
|
||||
* Returns the number of tokens read.
|
||||
* Face vertex format: posIdx[/[texIdx][/normIdx]] (all 1-based, may be negative).
|
||||
*/
|
||||
function readFaceTokens(
|
||||
tokenizer: Tokenizer,
|
||||
facePos: Int32Array, faceNorm: Int32Array,
|
||||
maxCount: number,
|
||||
posCount: number, normCount: number
|
||||
): number {
|
||||
const { data } = tokenizer;
|
||||
let count = 0;
|
||||
while (count < maxCount && readInlineToken(tokenizer)) {
|
||||
const start = tokenizer.tokenStart;
|
||||
const end = tokenizer.tokenEnd;
|
||||
|
||||
// Find first slash within [start, end)
|
||||
let slash1 = -1;
|
||||
for (let i = start; i < end; ++i) {
|
||||
if (data.charCodeAt(i) === CC_SLASH) { slash1 = i; break; }
|
||||
}
|
||||
|
||||
let posIdx: number;
|
||||
let normIdx = -1;
|
||||
|
||||
if (slash1 === -1) {
|
||||
// "v"
|
||||
const p = parseInt(data, start, end);
|
||||
posIdx = p < 0 ? posCount + p : p - 1;
|
||||
} else {
|
||||
const p = parseInt(data, start, slash1);
|
||||
posIdx = p < 0 ? posCount + p : p - 1;
|
||||
|
||||
// Find second slash
|
||||
let slash2 = -1;
|
||||
for (let i = slash1 + 1; i < end; ++i) {
|
||||
if (data.charCodeAt(i) === CC_SLASH) { slash2 = i; break; }
|
||||
}
|
||||
|
||||
if (slash2 !== -1 && slash2 + 1 < end) {
|
||||
// "v/vt/vn" or "v//vn"
|
||||
const n = parseInt(data, slash2 + 1, end);
|
||||
normIdx = n < 0 ? normCount + n : n - 1;
|
||||
}
|
||||
// else "v/vt" — no normal
|
||||
}
|
||||
|
||||
facePos[count] = posIdx;
|
||||
faceNorm[count] = normIdx;
|
||||
++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Reusable scratch buffers for face vertex data (polygons up to MAX_FACE_VERTICES vertices)
|
||||
const MAX_FACE_VERTICES = 256;
|
||||
const _facePos = new Int32Array(MAX_FACE_VERTICES);
|
||||
const _faceNorm = new Int32Array(MAX_FACE_VERTICES);
|
||||
|
||||
function handleVertex(state: State): void {
|
||||
const { tokenizer } = state;
|
||||
let x = 0, y = 0, z = 0;
|
||||
if (readInlineToken(tokenizer)) x = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
|
||||
if (readInlineToken(tokenizer)) y = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
|
||||
if (readInlineToken(tokenizer)) z = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
|
||||
ChunkedArray.add3(state.positions, x, y, z);
|
||||
skipLine(tokenizer);
|
||||
}
|
||||
|
||||
function handleNormal(state: State): void {
|
||||
const { tokenizer } = state;
|
||||
let x = 0, y = 0, z = 0;
|
||||
if (readInlineToken(tokenizer)) x = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
|
||||
if (readInlineToken(tokenizer)) y = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
|
||||
if (readInlineToken(tokenizer)) z = parseFloat(tokenizer.data, tokenizer.tokenStart, tokenizer.tokenEnd);
|
||||
ChunkedArray.add3(state.normals, x, y, z);
|
||||
skipLine(tokenizer);
|
||||
}
|
||||
|
||||
function handleFace(state: State): void {
|
||||
const { tokenizer } = state;
|
||||
const posCount = state.positions.elementCount;
|
||||
const normCount = state.normals.elementCount;
|
||||
|
||||
const n = readFaceTokens(tokenizer, _facePos, _faceNorm, MAX_FACE_VERTICES, posCount, normCount);
|
||||
if (n < 3) {
|
||||
state.warnings.push(`Line ${tokenizer.lineNumber}: degenerate face with ${n} vertices, skipped`);
|
||||
skipLine(tokenizer);
|
||||
return;
|
||||
}
|
||||
// Warn if the polygon exceeded the scratch buffer capacity and was truncated.
|
||||
if (n === MAX_FACE_VERTICES && readInlineToken(tokenizer)) {
|
||||
state.warnings.push(`Line ${tokenizer.lineNumber}: face with more than ${MAX_FACE_VERTICES} vertices truncated`);
|
||||
}
|
||||
|
||||
// Fan-triangulate: (0,1,2), (0,2,3), ...
|
||||
const p0 = _facePos[0], n0 = _faceNorm[0];
|
||||
for (let i = 1; i < n - 1; ++i) {
|
||||
ChunkedArray.add3(state.positionIndices, p0, _facePos[i], _facePos[i + 1]);
|
||||
ChunkedArray.add3(state.normalIndices, n0, _faceNorm[i], _faceNorm[i + 1]);
|
||||
ChunkedArray.add(state.groupIndices, state.currentGroupIdx);
|
||||
}
|
||||
skipLine(tokenizer);
|
||||
}
|
||||
|
||||
function handleUsemtl(state: State): void {
|
||||
const { tokenizer } = state;
|
||||
// Read the rest of the line as the material name (trimmed)
|
||||
skipInlineWS(tokenizer);
|
||||
tokenizer.tokenStart = tokenizer.position;
|
||||
const { data } = tokenizer;
|
||||
while (tokenizer.position < tokenizer.length) {
|
||||
const c = data.charCodeAt(tokenizer.position);
|
||||
if (c === CC_NEWLINE || c === CC_CR) break;
|
||||
++tokenizer.position;
|
||||
}
|
||||
// Trim trailing whitespace
|
||||
let end = tokenizer.position;
|
||||
while (end > tokenizer.tokenStart && (data.charCodeAt(end - 1) === CC_SPACE || data.charCodeAt(end - 1) === CC_TAB)) --end;
|
||||
const name = end > tokenizer.tokenStart ? data.substring(tokenizer.tokenStart, end) : 'default';
|
||||
|
||||
const existing = state.groups.indexOf(name);
|
||||
if (existing !== -1) {
|
||||
state.currentGroupIdx = existing;
|
||||
} else {
|
||||
state.currentGroupIdx = state.groups.length;
|
||||
state.groups.push(name);
|
||||
}
|
||||
skipLine(tokenizer);
|
||||
}
|
||||
|
||||
async function parseInternal(data: StringLike, ctx: RuntimeContext): Promise<Result<ObjFile>> {
|
||||
const state = State(data);
|
||||
const { tokenizer } = state;
|
||||
const updateChunk = 100000;
|
||||
|
||||
while (tokenizer.position < tokenizer.length) {
|
||||
// Skip full-line whitespace and newlines between lines
|
||||
const c0 = tokenizer.data.charCodeAt(tokenizer.position);
|
||||
if (c0 === CC_NEWLINE) { ++tokenizer.position; ++tokenizer.lineNumber; continue; }
|
||||
if (c0 === CC_CR) {
|
||||
++tokenizer.position; ++tokenizer.lineNumber;
|
||||
if (tokenizer.position < tokenizer.length && tokenizer.data.charCodeAt(tokenizer.position) === CC_NEWLINE) ++tokenizer.position;
|
||||
continue;
|
||||
}
|
||||
if (c0 === CC_SPACE || c0 === CC_TAB) { skipInlineWS(tokenizer); continue; }
|
||||
if (c0 === CC_HASH) { skipLine(tokenizer); continue; }
|
||||
|
||||
// Identify keyword by inspecting character codes — no string allocation
|
||||
const c1 = tokenizer.position + 1 < tokenizer.length ? tokenizer.data.charCodeAt(tokenizer.position + 1) : -1;
|
||||
|
||||
if (c0 === CC_f && (c1 === CC_SPACE || c1 === CC_TAB)) {
|
||||
// "f " — face
|
||||
tokenizer.position += 2;
|
||||
handleFace(state);
|
||||
} else if (c0 === CC_v) {
|
||||
if (c1 === CC_SPACE || c1 === CC_TAB) {
|
||||
// "v " — vertex position
|
||||
tokenizer.position += 2;
|
||||
handleVertex(state);
|
||||
} else if (c1 === CC_n) {
|
||||
// "vn" — vertex normal
|
||||
tokenizer.position += 2;
|
||||
handleNormal(state);
|
||||
} else {
|
||||
// "vt", "vp", etc — skip
|
||||
skipLine(tokenizer);
|
||||
}
|
||||
} else if (
|
||||
c0 === CC_u &&
|
||||
tokenizer.position + 6 < tokenizer.length &&
|
||||
tokenizer.data.charCodeAt(tokenizer.position + 1) === CC_s &&
|
||||
tokenizer.data.charCodeAt(tokenizer.position + 2) === CC_e &&
|
||||
tokenizer.data.charCodeAt(tokenizer.position + 3) === CC_m &&
|
||||
tokenizer.data.charCodeAt(tokenizer.position + 4) === CC_t &&
|
||||
tokenizer.data.charCodeAt(tokenizer.position + 5) === CC_l
|
||||
) {
|
||||
// "usemtl"
|
||||
tokenizer.position += 6;
|
||||
handleUsemtl(state);
|
||||
} else {
|
||||
// "o", "g", "s", "mtllib", "call", etc. — skip entire line
|
||||
skipLine(tokenizer);
|
||||
}
|
||||
|
||||
if (ctx.shouldUpdate && tokenizer.lineNumber % updateChunk === 0) {
|
||||
await ctx.update({ message: 'Parsing OBJ', current: tokenizer.position, max: tokenizer.length });
|
||||
}
|
||||
}
|
||||
|
||||
const posArr = ChunkedArray.compact(state.positions) as Float32Array;
|
||||
const normArr = ChunkedArray.compact(state.normals) as Float32Array;
|
||||
const posIdxArr = ChunkedArray.compact(state.positionIndices) as Int32Array;
|
||||
const normIdxArr = ChunkedArray.compact(state.normalIndices) as Int32Array;
|
||||
const grpIdxArr = ChunkedArray.compact(state.groupIndices) as Uint32Array;
|
||||
|
||||
const result: ObjFile = {
|
||||
positions: posArr,
|
||||
normals: normArr,
|
||||
positionIndices: posIdxArr,
|
||||
normalIndices: normIdxArr,
|
||||
groupIndices: grpIdxArr,
|
||||
groups: state.groups,
|
||||
positionCount: state.positions.elementCount,
|
||||
normalCount: state.normals.elementCount,
|
||||
triangleCount: posIdxArr.length / 3
|
||||
};
|
||||
|
||||
return Result.success(result, state.warnings);
|
||||
}
|
||||
|
||||
export function parseObj(data: StringLike) {
|
||||
return Task.create<Result<ObjFile>>('Parse OBJ', async ctx => {
|
||||
export function parse(data: string) {
|
||||
return Task.create<Result<Mesh>>('Parse OBJ', async ctx => {
|
||||
return await parseInternal(data, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Intermediate representation of a parsed OBJ file.
|
||||
*
|
||||
* Positions and normals are stored as raw arrays from the file.
|
||||
* Faces have been triangulated (fan-triangulation) and are stored as
|
||||
* separate flat arrays of per-triangle-vertex position and normal indices.
|
||||
* Normal indices of -1 indicate that the vertex has no explicit normal.
|
||||
*/
|
||||
export interface ObjFile {
|
||||
/** Raw position data from `v` lines, interleaved [x0,y0,z0, x1,y1,z1, ...] */
|
||||
readonly positions: Float32Array
|
||||
/** Raw normal data from `vn` lines, interleaved [nx0,ny0,nz0, ...]. Length 0 if no normals. */
|
||||
readonly normals: Float32Array
|
||||
|
||||
/**
|
||||
* Per-face-vertex position index (0-based), length = triangleCount * 3.
|
||||
* Three consecutive values define one triangle: [p0, p1, p2, p3, p4, p5, ...]
|
||||
*/
|
||||
readonly positionIndices: Int32Array
|
||||
|
||||
/**
|
||||
* Per-face-vertex normal index (0-based), length = triangleCount * 3.
|
||||
* -1 means no explicit normal for that face-vertex.
|
||||
*/
|
||||
readonly normalIndices: Int32Array
|
||||
|
||||
/**
|
||||
* Material/group index per triangle, length = triangleCount.
|
||||
* Index into the `groups` array.
|
||||
*/
|
||||
readonly groupIndices: Uint32Array
|
||||
|
||||
/** Ordered list of material group names, populated from `usemtl` directives. */
|
||||
readonly groups: ReadonlyArray<string>
|
||||
|
||||
readonly positionCount: number
|
||||
readonly normalCount: number
|
||||
readonly triangleCount: number
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
|
||||
import { RuntimeContext, Task } from '../../mol-task';
|
||||
import { ShapeProvider } from '../../mol-model/shape/provider';
|
||||
import { Color } from '../../mol-util/color';
|
||||
import { ObjFile } from '../../mol-io/reader/obj/schema';
|
||||
import { MeshBuilder } from '../../mol-geo/geometry/mesh/mesh-builder';
|
||||
import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
|
||||
import { Shape } from '../../mol-model/shape';
|
||||
import { ChunkedArray } from '../../mol-data/util';
|
||||
import { ParamDefinition as PD } from '../../mol-util/param-definition';
|
||||
import { ColorNames } from '../../mol-util/color/names';
|
||||
import { deepClone } from '../../mol-util/object';
|
||||
import { Mat4 } from '../../mol-math/linear-algebra/3d/mat4';
|
||||
import { distinctColors } from '../../mol-util/color/distinct';
|
||||
import { ValueCell } from '../../mol-util/value-cell';
|
||||
|
||||
export type ObjData = {
|
||||
source: ObjFile,
|
||||
transforms?: Mat4[],
|
||||
}
|
||||
|
||||
export const ObjShapeParams = {
|
||||
...Mesh.Params,
|
||||
coloring: PD.MappedStatic('group', {
|
||||
group: PD.Group({}),
|
||||
uniform: PD.Group({
|
||||
color: PD.Color(ColorNames.grey),
|
||||
}, { isFlat: true })
|
||||
}),
|
||||
};
|
||||
export type ObjShapeParams = typeof ObjShapeParams
|
||||
|
||||
/**
|
||||
* Build an expanded mesh from indexed OBJ data.
|
||||
*
|
||||
* OBJ stores positions and normals as indexed arrays (each face-vertex
|
||||
* references a position index and an optional normal index). Mesh.create
|
||||
* requires a flat vertex array, so we expand the indexed data.
|
||||
*
|
||||
* A vertex key encodes (posIdx, normIdx, groupIdx) so that a position
|
||||
* shared between faces of different material groups or with different normals
|
||||
* gets a distinct mesh vertex — ensuring each vertex has one unambiguous
|
||||
* group ID and normal.
|
||||
*/
|
||||
async function getMesh(ctx: RuntimeContext, obj: ObjFile, mesh?: Mesh): Promise<Mesh> {
|
||||
const { positions, normals, positionIndices, normalIndices, groupIndices, triangleCount } = obj;
|
||||
const hasNormals = obj.normalCount > 0;
|
||||
|
||||
const builderState = MeshBuilder.createState(triangleCount * 3, triangleCount, mesh);
|
||||
const { vertices, normals: normBuf, indices, groups } = builderState;
|
||||
|
||||
// Group count for the composite dedup key; groups always contains at least 'default'.
|
||||
const groupCount = Math.max(1, obj.groups.length);
|
||||
|
||||
// Map from position index to a map of (normal index, group) composite keys to expanded vertex index.
|
||||
// Avoids allocating a string key per face-vertex on the hot path.
|
||||
const vertexMap = new Map<number, Map<number, number>>();
|
||||
|
||||
const getVertex = (pi: number, ni: number, groupId: number): number => {
|
||||
let inner = vertexMap.get(pi);
|
||||
if (inner === undefined) {
|
||||
inner = new Map<number, number>();
|
||||
vertexMap.set(pi, inner);
|
||||
}
|
||||
const subKey = (ni + 1) * groupCount + groupId;
|
||||
let idx = inner.get(subKey);
|
||||
if (idx === undefined) {
|
||||
idx = vertices.elementCount;
|
||||
inner.set(subKey, idx);
|
||||
|
||||
const po = pi * 3;
|
||||
ChunkedArray.add3(vertices, positions[po], positions[po + 1], positions[po + 2]);
|
||||
|
||||
if (hasNormals && ni >= 0) {
|
||||
const no = ni * 3;
|
||||
ChunkedArray.add3(normBuf, normals[no], normals[no + 1], normals[no + 2]);
|
||||
} else {
|
||||
ChunkedArray.add3(normBuf, 0, 0, 0);
|
||||
}
|
||||
|
||||
ChunkedArray.add(groups, groupId);
|
||||
}
|
||||
return idx;
|
||||
};
|
||||
|
||||
const updateChunk = 50000;
|
||||
|
||||
for (let t = 0; t < triangleCount; ++t) {
|
||||
const triOffset = t * 3;
|
||||
const groupId = groupIndices[t];
|
||||
|
||||
const i0 = getVertex(positionIndices[triOffset], hasNormals ? normalIndices[triOffset] : -1, groupId);
|
||||
const i1 = getVertex(positionIndices[triOffset + 1], hasNormals ? normalIndices[triOffset + 1] : -1, groupId);
|
||||
const i2 = getVertex(positionIndices[triOffset + 2], hasNormals ? normalIndices[triOffset + 2] : -1, groupId);
|
||||
|
||||
ChunkedArray.add3(indices, i0, i1, i2);
|
||||
|
||||
if (t % updateChunk === 0 && ctx.shouldUpdate) {
|
||||
await ctx.update({ message: 'Building OBJ mesh', current: t, max: triangleCount });
|
||||
}
|
||||
}
|
||||
|
||||
const m = MeshBuilder.getMesh(builderState);
|
||||
if (!hasNormals) Mesh.computeNormals(m);
|
||||
|
||||
ValueCell.updateIfChanged(m.varyingGroup, true);
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
type GroupColors = { kind: 'group', colors: Color[] } | { kind: 'uniform', color: Color }
|
||||
|
||||
function getColoring(obj: ObjFile, props: PD.Values<ObjShapeParams>): GroupColors {
|
||||
const { coloring } = props;
|
||||
if (coloring.name === 'uniform') {
|
||||
return { kind: 'uniform', color: coloring.params.color };
|
||||
}
|
||||
// Generate distinct colors for each group
|
||||
const n = Math.max(1, obj.groups.length);
|
||||
const colors = distinctColors(n);
|
||||
return { kind: 'group', colors };
|
||||
}
|
||||
|
||||
function createShape(objData: ObjData, mesh: Mesh, groupColors: GroupColors) {
|
||||
const { source, transforms } = objData;
|
||||
return Shape.create(
|
||||
'obj-mesh', source, mesh,
|
||||
(groupId: number) => {
|
||||
if (groupColors.kind === 'uniform') return groupColors.color;
|
||||
const idx = Math.min(groupId, groupColors.colors.length - 1);
|
||||
return groupColors.colors[idx];
|
||||
},
|
||||
() => 1,
|
||||
(groupId: number) => {
|
||||
const name = source.groups[groupId] ?? `Group ${groupId}`;
|
||||
return name;
|
||||
},
|
||||
transforms
|
||||
);
|
||||
}
|
||||
|
||||
function makeShapeGetter() {
|
||||
let _objData: ObjData | undefined;
|
||||
let _props: PD.Values<ObjShapeParams> | undefined;
|
||||
|
||||
let _shape: Shape<Mesh>;
|
||||
let _mesh: Mesh;
|
||||
let _groupColors: GroupColors;
|
||||
|
||||
const getShape = async (ctx: RuntimeContext, objData: ObjData, props: PD.Values<ObjShapeParams>, shape?: Shape<Mesh>) => {
|
||||
let newMesh = false;
|
||||
let newColor = false;
|
||||
|
||||
if (!_objData || _objData !== objData) {
|
||||
newMesh = true;
|
||||
}
|
||||
|
||||
if (!_props || !PD.isParamEqual(ObjShapeParams.coloring, _props.coloring, props.coloring)) {
|
||||
newColor = true;
|
||||
}
|
||||
|
||||
if (newMesh) {
|
||||
_mesh = await getMesh(ctx, objData.source, shape && shape.geometry);
|
||||
_groupColors = getColoring(objData.source, props);
|
||||
_shape = createShape(objData, _mesh, _groupColors);
|
||||
} else if (newColor) {
|
||||
_groupColors = getColoring(objData.source, props);
|
||||
_shape = createShape(objData, _mesh, _groupColors);
|
||||
}
|
||||
|
||||
_objData = objData;
|
||||
_props = deepClone(props);
|
||||
|
||||
return _shape;
|
||||
};
|
||||
return getShape;
|
||||
}
|
||||
|
||||
export function shapeFromObj(source: ObjFile, params?: { transforms?: Mat4[] }) {
|
||||
return Task.create<ShapeProvider<ObjData, Mesh, ObjShapeParams>>('Shape Provider', async _ctx => {
|
||||
return {
|
||||
label: 'Mesh',
|
||||
data: { source, transforms: params?.transforms },
|
||||
params: ObjShapeParams,
|
||||
getShape: makeShapeGetter(),
|
||||
geometryUtils: Mesh.Utils
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -133,7 +133,6 @@ export enum InteractionType {
|
||||
Hydrophobic = 6,
|
||||
MetalCoordination = 7,
|
||||
WeakHydrogenBond = 8,
|
||||
WaterBridge = 9,
|
||||
}
|
||||
|
||||
export function interactionTypeLabel(type: InteractionType): string {
|
||||
@@ -154,8 +153,6 @@ export function interactionTypeLabel(type: InteractionType): string {
|
||||
return 'Pi Stacking';
|
||||
case InteractionType.WeakHydrogenBond:
|
||||
return 'Weak Hydrogen Bond';
|
||||
case InteractionType.WaterBridge:
|
||||
return 'Water Bridge';
|
||||
case InteractionType.Unknown:
|
||||
return 'Unknown Interaction';
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { FeatureType, FeatureGroup, InteractionType } from './common';
|
||||
import { ContactProvider } from './contacts';
|
||||
import { MoleculeType, ProteinBackboneAtoms } from '../../../mol-model/structure/model/types';
|
||||
|
||||
export const GeometryParams = {
|
||||
const GeometryParams = {
|
||||
distanceMax: PD.Numeric(3.5, { min: 1, max: 5, step: 0.1 }),
|
||||
backbone: PD.Boolean(true, { description: 'Include backbone-to-backbone hydrogen bonds' }),
|
||||
accAngleDevMax: PD.Numeric(45, { min: 0, max: 180, step: 1 }, { description: 'Max deviation from ideal acceptor angle' }),
|
||||
@@ -29,7 +29,7 @@ export const GeometryParams = {
|
||||
accOutOfPlaneAngleMax: PD.Numeric(90, { min: 0, max: 180, step: 1 }),
|
||||
donOutOfPlaneAngleMax: PD.Numeric(45, { min: 0, max: 180, step: 1 }),
|
||||
};
|
||||
export type GeometryParams = typeof GeometryParams
|
||||
type GeometryParams = typeof GeometryParams
|
||||
type GeometryProps = PD.Values<GeometryParams>
|
||||
|
||||
const HydrogenBondsParams = {
|
||||
@@ -208,7 +208,7 @@ function isWeakHydrogenBond(ti: FeatureType, tj: FeatureType) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getGeometryOptions(props: GeometryProps) {
|
||||
function getGeometryOptions(props: GeometryProps) {
|
||||
return {
|
||||
ignoreHydrogens: props.ignoreHydrogens,
|
||||
includeBackbone: props.backbone,
|
||||
@@ -218,7 +218,7 @@ export function getGeometryOptions(props: GeometryProps) {
|
||||
maxDonOutOfPlaneAngle: degToRad(props.donOutOfPlaneAngleMax),
|
||||
};
|
||||
}
|
||||
export type GeometryOptions = ReturnType<typeof getGeometryOptions>
|
||||
type GeometryOptions = ReturnType<typeof getGeometryOptions>
|
||||
|
||||
function getHydrogenBondsOptions(props: HydrogenBondsProps) {
|
||||
return {
|
||||
@@ -232,7 +232,7 @@ type HydrogenBondsOptions = ReturnType<typeof getHydrogenBondsOptions>
|
||||
|
||||
const deg120InRad = degToRad(120);
|
||||
|
||||
export function checkGeometry(structure: Structure, don: Features.Info, acc: Features.Info, opts: GeometryOptions): true | undefined {
|
||||
function checkGeometry(structure: Structure, don: Features.Info, acc: Features.Info, opts: GeometryOptions): true | undefined {
|
||||
const donIndex = don.members[don.offsets[don.feature]];
|
||||
const accIndex = acc.members[acc.offsets[acc.feature]];
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) 2019-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
import { Structure, Unit, Bond, StructureElement } from '../../../mol-model/structure';
|
||||
import { Structure, Unit, Bond } from '../../../mol-model/structure';
|
||||
import { Features, FeaturesBuilder } from './features';
|
||||
import { ValenceModelProvider } from '../valence-model';
|
||||
import { InteractionsIntraContacts, InteractionsInterContacts, FeatureType, InteractionType, InteractionFlag, interactionTypeLabel } from './common';
|
||||
import { InteractionsIntraContacts, InteractionsInterContacts, FeatureType, interactionTypeLabel } from './common';
|
||||
import { IntraContactsBuilder, InterContactsBuilder } from './contacts-builder';
|
||||
import { IntMap, OrderedSet } from '../../../mol-data/int';
|
||||
import { IntMap } from '../../../mol-data/int';
|
||||
import { addUnitContacts, ContactTester, addStructureContacts, ContactsParams, ContactsProps } from './contacts';
|
||||
import { HalogenDonorProvider, HalogenAcceptorProvider, HalogenBondsProvider } from './halogen-bonds';
|
||||
import { HydrogenDonorProvider, WeakHydrogenDonorProvider, HydrogenAcceptorProvider, HydrogenBondsProvider, WeakHydrogenBondsProvider } from './hydrogen-bonds';
|
||||
import { WaterBridgesProvider } from './water-bridges';
|
||||
import { NegativChargeProvider, PositiveChargeProvider, AromaticRingProvider, IonicProvider, PiStackingProvider, CationPiProvider } from './charged';
|
||||
import { HydrophobicAtomProvider, HydrophobicProvider } from './hydrophobic';
|
||||
import { SetUtils } from '../../../mol-util/set';
|
||||
@@ -26,26 +25,10 @@ import { DataLocation } from '../../../mol-model/location';
|
||||
import { CentroidHelper } from '../../../mol-math/geometry/centroid-helper';
|
||||
import { Sphere3D } from '../../../mol-math/geometry';
|
||||
import { DataLoci } from '../../../mol-model/loci';
|
||||
import { bondLabel, bundleLabel, LabelGranularity } from '../../../mol-theme/label';
|
||||
import { bondLabel, LabelGranularity } from '../../../mol-theme/label';
|
||||
import { ObjectKeys } from '../../../mol-util/type-helpers';
|
||||
|
||||
export { Interactions, Bridges };
|
||||
export type { BridgeContact, BridgeContacts };
|
||||
|
||||
interface BridgeContact {
|
||||
readonly unitA: number
|
||||
readonly indexA: Features.FeatureIndex
|
||||
readonly unitB: number
|
||||
readonly indexB: Features.FeatureIndex
|
||||
/** mediator unit id */
|
||||
readonly unitM: number
|
||||
/** mediator feature facing endpoint A */
|
||||
readonly indexMA: Features.FeatureIndex
|
||||
/** mediator feature facing endpoint B */
|
||||
readonly indexMB: Features.FeatureIndex
|
||||
props: { type: InteractionType, flag: InteractionFlag }
|
||||
}
|
||||
type BridgeContacts = ReadonlyArray<BridgeContact>
|
||||
export { Interactions };
|
||||
|
||||
interface Interactions {
|
||||
/** Features of each unit */
|
||||
@@ -54,8 +37,6 @@ interface Interactions {
|
||||
unitsContacts: IntMap<InteractionsIntraContacts>
|
||||
/** Interactions between units */
|
||||
contacts: InteractionsInterContacts
|
||||
/** Bridge-mediated interactions covering the whole structure */
|
||||
bridges: BridgeContacts
|
||||
}
|
||||
|
||||
namespace Interactions {
|
||||
@@ -148,93 +129,6 @@ namespace Interactions {
|
||||
}
|
||||
}
|
||||
|
||||
namespace Bridges {
|
||||
export interface Data {
|
||||
readonly structure: Structure
|
||||
readonly bridges: BridgeContacts
|
||||
readonly unitsFeatures: IntMap<Features>
|
||||
}
|
||||
|
||||
export interface Element { bridgeIndex: number }
|
||||
|
||||
export interface Location extends DataLocation<Data, Element> {}
|
||||
|
||||
export function Location(data: Data, bridgeIndex = 0): Location {
|
||||
return DataLocation('bridges', data, { bridgeIndex });
|
||||
}
|
||||
|
||||
export function isLocation(x: any): x is Location {
|
||||
return !!x && x.kind === 'data-location' && x.tag === 'bridges';
|
||||
}
|
||||
|
||||
export interface Loci extends DataLoci<Data, Element> {}
|
||||
|
||||
export function Loci(data: Data, elements: ReadonlyArray<Element>): Loci {
|
||||
return DataLoci('bridges', data, elements,
|
||||
bs => getBoundingSphere(data, elements, bs),
|
||||
() => getLabel(data, elements));
|
||||
}
|
||||
|
||||
export function isLoci(x: any): x is Loci {
|
||||
return !!x && x.kind === 'data-loci' && x.tag === 'bridges';
|
||||
}
|
||||
|
||||
function getLabel(data: Data, elements: ReadonlyArray<Element>): string {
|
||||
const e = elements[0];
|
||||
if (e === undefined) return '';
|
||||
|
||||
const { structure, bridges, unitsFeatures } = data;
|
||||
const bridge = bridges[e.bridgeIndex];
|
||||
|
||||
const uA = structure.unitMap.get(bridge.unitA) as Unit.Atomic;
|
||||
const fA = unitsFeatures.get(bridge.unitA);
|
||||
const uM = structure.unitMap.get(bridge.unitM) as Unit.Atomic;
|
||||
const fM = unitsFeatures.get(bridge.unitM);
|
||||
const uB = structure.unitMap.get(bridge.unitB) as Unit.Atomic;
|
||||
const fB = unitsFeatures.get(bridge.unitB);
|
||||
|
||||
const options = { granularity: 'element' as LabelGranularity };
|
||||
if (fA.offsets[bridge.indexA + 1] - fA.offsets[bridge.indexA] > 1 ||
|
||||
fB.offsets[bridge.indexB + 1] - fB.offsets[bridge.indexB] > 1) {
|
||||
options.granularity = 'residue';
|
||||
}
|
||||
|
||||
return [
|
||||
interactionTypeLabel(bridge.props.type),
|
||||
bundleLabel({ loci: [
|
||||
StructureElement.Loci(structure, [{ unit: uA, indices: OrderedSet.ofSingleton(fA.members[fA.offsets[bridge.indexA]] as StructureElement.UnitIndex) }]),
|
||||
StructureElement.Loci(structure, [{ unit: uM, indices: OrderedSet.ofSingleton(fM.members[fM.offsets[bridge.indexMA]] as StructureElement.UnitIndex) }]),
|
||||
StructureElement.Loci(structure, [{ unit: uB, indices: OrderedSet.ofSingleton(fB.members[fB.offsets[bridge.indexB]] as StructureElement.UnitIndex) }]),
|
||||
] }, options),
|
||||
].join('</br>');
|
||||
}
|
||||
|
||||
function getBoundingSphere(data: Data, elements: ReadonlyArray<Element>, boundingSphere: Sphere3D) {
|
||||
return CentroidHelper.fromPairProvider(elements.length * 2, (i, pA, pB) => {
|
||||
const bridge = data.bridges[elements[i >> 1].bridgeIndex];
|
||||
|
||||
const uA = data.structure.unitMap.get(bridge.unitA) as Unit.Atomic;
|
||||
const fA = data.unitsFeatures.get(bridge.unitA);
|
||||
const uM = data.structure.unitMap.get(bridge.unitM) as Unit.Atomic;
|
||||
const fM = data.unitsFeatures.get(bridge.unitM);
|
||||
const uB = data.structure.unitMap.get(bridge.unitB) as Unit.Atomic;
|
||||
const fB = data.unitsFeatures.get(bridge.unitB);
|
||||
|
||||
const aIdx = fA.members[fA.offsets[bridge.indexA]];
|
||||
const mIdx = fM.members[fM.offsets[bridge.indexMA]];
|
||||
const bIdx = fB.members[fB.offsets[bridge.indexB]];
|
||||
|
||||
if ((i & 1) === 0) {
|
||||
uA.conformation.position(uA.elements[aIdx], pA);
|
||||
uM.conformation.position(uM.elements[mIdx], pB);
|
||||
} else {
|
||||
uM.conformation.position(uM.elements[mIdx], pA);
|
||||
uB.conformation.position(uB.elements[bIdx], pB);
|
||||
}
|
||||
}, boundingSphere);
|
||||
}
|
||||
}
|
||||
|
||||
const FeatureProviders = [
|
||||
HydrogenDonorProvider, WeakHydrogenDonorProvider, HydrogenAcceptorProvider,
|
||||
NegativChargeProvider, PositiveChargeProvider, AromaticRingProvider,
|
||||
@@ -280,30 +174,8 @@ export const ContactProviderParams = getProvidersParams([
|
||||
// 'weak-hydrogen-bonds',
|
||||
]);
|
||||
|
||||
const BridgeProviders = {
|
||||
'water-bridges': WaterBridgesProvider,
|
||||
};
|
||||
type BridgeProviders = typeof BridgeProviders
|
||||
|
||||
function getBridgeProviderParams(defaultOn: string[] = []) {
|
||||
const params: { [k in keyof BridgeProviders]: PD.Mapped<PD.NamedParamUnion<{
|
||||
on: PD.Group<BridgeProviders[k]['params']>
|
||||
off: PD.Group<{}>
|
||||
}>> } = Object.create(null);
|
||||
|
||||
Object.keys(BridgeProviders).forEach(k => {
|
||||
(params as any)[k] = PD.MappedStatic(defaultOn.includes(k) ? 'on' : 'off', {
|
||||
on: PD.Group(BridgeProviders[k as keyof BridgeProviders].params),
|
||||
off: PD.Group({})
|
||||
}, { cycle: true });
|
||||
});
|
||||
return params;
|
||||
}
|
||||
export const BridgeProviderParams = getBridgeProviderParams([]);
|
||||
|
||||
export const InteractionsParams = {
|
||||
providers: PD.Group(ContactProviderParams, { isFlat: true }),
|
||||
bridges: PD.Group(BridgeProviderParams, { isFlat: true }),
|
||||
contacts: PD.Group(ContactsParams, { label: 'Advanced Options' }),
|
||||
};
|
||||
export type InteractionsParams = typeof InteractionsParams
|
||||
@@ -330,9 +202,6 @@ export async function computeInteractions(ctx: CustomProperty.Context, structure
|
||||
|
||||
const requiredFeatures = new Set<FeatureType>();
|
||||
contactTesters.forEach(l => SetUtils.add(requiredFeatures, l.requiredFeatures));
|
||||
ObjectKeys(BridgeProviders).forEach(k => {
|
||||
if (p.bridges[k].name === 'on') SetUtils.add(requiredFeatures, BridgeProviders[k].requiredFeatures);
|
||||
});
|
||||
const featureProviders = FeatureProviders.filter(f => SetUtils.areIntersecting(requiredFeatures, f.types));
|
||||
|
||||
const unitsFeatures = IntMap.Mutable<Features>();
|
||||
@@ -359,9 +228,8 @@ export async function computeInteractions(ctx: CustomProperty.Context, structure
|
||||
}
|
||||
|
||||
const contacts = findInterUnitContacts(structure, unitsFeatures, contactTesters, p.contacts, options);
|
||||
const bridges = findBridges(structure, unitsFeatures, p.bridges);
|
||||
const interactions = { unitsFeatures, unitsContacts, contacts, bridges };
|
||||
|
||||
const interactions = { unitsFeatures, unitsContacts, contacts };
|
||||
refineInteractions(structure, interactions);
|
||||
return interactions;
|
||||
}
|
||||
@@ -392,19 +260,6 @@ function findIntraUnitContacts(structure: Structure, unit: Unit, features: Featu
|
||||
return builder.getContacts();
|
||||
}
|
||||
|
||||
function findBridges(structure: Structure, unitsFeatures: IntMap<Features>, props: PD.Values<typeof BridgeProviderParams>): BridgeContacts {
|
||||
const bridges: BridgeContact[] = [];
|
||||
|
||||
ObjectKeys(BridgeProviders).forEach(k => {
|
||||
const { name, params } = props[k];
|
||||
if (name === 'on') {
|
||||
for (const b of BridgeProviders[k].find(structure, unitsFeatures, params as any)) bridges.push(b);
|
||||
}
|
||||
});
|
||||
|
||||
return bridges;
|
||||
}
|
||||
|
||||
function findInterUnitContacts(structure: Structure, unitsFeatures: IntMap<Features>, contactTesters: ReadonlyArray<ContactTester>, props: ContactsProps, options?: ComputeInterctionsOptions) {
|
||||
const builder = InterContactsBuilder.create();
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2019-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
* @author Sebastian Bittrich <sebastian.m.bittrich@gmail.com>
|
||||
*
|
||||
* based in part on NGL (https://github.com/arose/ngl)
|
||||
*/
|
||||
|
||||
import { Interactions } from './interactions';
|
||||
import { InteractionType, InteractionFlag, InteractionsIntraContacts, FeatureType, InteractionsInterContacts } from './common';
|
||||
import { Unit, Structure, StructureElement } from '../../../mol-model/structure';
|
||||
import { Unit, Structure } from '../../../mol-model/structure';
|
||||
import { Features } from './features';
|
||||
import { cantorPairing } from '../../../mol-data/util/hash-functions';
|
||||
|
||||
interface ContactRefiner {
|
||||
isApplicable: (type: InteractionType) => boolean
|
||||
@@ -29,7 +27,6 @@ export function refineInteractions(structure: Structure, interactions: Interacti
|
||||
saltBridgeRefiner(structure, interactions),
|
||||
piStackingRefiner(structure, interactions),
|
||||
metalCoordinationRefiner(structure, interactions),
|
||||
waterBridgeRefiner(structure, interactions),
|
||||
];
|
||||
|
||||
for (let i = 0, il = contacts.edgeCount; i < il; ++i) {
|
||||
@@ -281,117 +278,4 @@ function metalCoordinationRefiner(structure: Structure, interactions: Interactio
|
||||
filterIntra([InteractionType.MetalCoordination], index, infoA, infoB, interactions.unitsContacts.get(infoA.unit.id));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function waterBridgeRefiner(_structure: Structure, interactions: Interactions): ContactRefiner {
|
||||
const { contacts, bridges, unitsFeatures } = interactions;
|
||||
|
||||
type AtomKey = number;
|
||||
type AtomPairSet = Map<AtomKey, Set<AtomKey>>;
|
||||
|
||||
function atomKey(unitId: number, atomIndex: StructureElement.UnitIndex): AtomKey {
|
||||
return cantorPairing(unitId, atomIndex);
|
||||
}
|
||||
|
||||
function featureMember(features: Features, featureIndex: Features.FeatureIndex): StructureElement.UnitIndex {
|
||||
return features.members[features.offsets[featureIndex]] as StructureElement.UnitIndex;
|
||||
}
|
||||
|
||||
function addAtomPair(
|
||||
set: AtomPairSet,
|
||||
unitA: number,
|
||||
atomA: StructureElement.UnitIndex,
|
||||
unitB: number,
|
||||
atomB: StructureElement.UnitIndex
|
||||
) {
|
||||
const a = atomKey(unitA, atomA);
|
||||
const b = atomKey(unitB, atomB);
|
||||
|
||||
let bs = set.get(a);
|
||||
if (bs === undefined) {
|
||||
bs = new Set();
|
||||
set.set(a, bs);
|
||||
}
|
||||
bs.add(b);
|
||||
|
||||
let as = set.get(b);
|
||||
if (as === undefined) {
|
||||
as = new Set();
|
||||
set.set(b, as);
|
||||
}
|
||||
as.add(a);
|
||||
}
|
||||
|
||||
function hasAtomPair(
|
||||
set: AtomPairSet,
|
||||
unitA: number,
|
||||
atomA: StructureElement.UnitIndex,
|
||||
unitB: number,
|
||||
atomB: StructureElement.UnitIndex
|
||||
): boolean {
|
||||
return set.get(atomKey(unitA, atomA))?.has(atomKey(unitB, atomB)) === true;
|
||||
}
|
||||
|
||||
function hasInfoPair(set: AtomPairSet, infoA: Features.Info, infoB: Features.Info): boolean {
|
||||
const { offsets: offsetsA, members: membersA, feature: featureA } = infoA;
|
||||
const { offsets: offsetsB, members: membersB, feature: featureB } = infoB;
|
||||
|
||||
for (let i = offsetsA[featureA], il = offsetsA[featureA + 1]; i < il; ++i) {
|
||||
const a = membersA[i] as StructureElement.UnitIndex;
|
||||
|
||||
for (let j = offsetsB[featureB], jl = offsetsB[featureB + 1]; j < jl; ++j) {
|
||||
const b = membersB[j] as StructureElement.UnitIndex;
|
||||
|
||||
if (hasAtomPair(set, infoA.unit.id, a, infoB.unit.id, b)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const bridgeLegs: AtomPairSet = new Map();
|
||||
|
||||
for (const wb of bridges) {
|
||||
if (wb.props.type !== InteractionType.WaterBridge) continue;
|
||||
|
||||
const fA = unitsFeatures.get(wb.unitA);
|
||||
const fM = unitsFeatures.get(wb.unitM);
|
||||
const fB = unitsFeatures.get(wb.unitB);
|
||||
|
||||
if (!fA || !fM || !fB) continue;
|
||||
|
||||
const atomA = featureMember(fA, wb.indexA);
|
||||
const atomMA = featureMember(fM, wb.indexMA);
|
||||
const atomMB = featureMember(fM, wb.indexMB);
|
||||
const atomB = featureMember(fB, wb.indexB);
|
||||
|
||||
// donor atom ↔ water oxygen
|
||||
addAtomPair(bridgeLegs, wb.unitA, atomA, wb.unitM, atomMA);
|
||||
|
||||
// water oxygen ↔ acceptor atom
|
||||
addAtomPair(bridgeLegs, wb.unitM, atomMB, wb.unitB, atomB);
|
||||
}
|
||||
|
||||
let intraContacts: InteractionsIntraContacts | undefined;
|
||||
|
||||
return {
|
||||
isApplicable: (type: InteractionType) => {
|
||||
return bridgeLegs.size > 0 && type === InteractionType.HydrogenBond;
|
||||
},
|
||||
handleInterContact: (index: number, infoA: Features.Info, infoB: Features.Info) => {
|
||||
if (hasInfoPair(bridgeLegs, infoA, infoB)) {
|
||||
contacts.edges[index].props.flag = InteractionFlag.Filtered;
|
||||
}
|
||||
},
|
||||
startUnit: (_unit: Unit.Atomic, contacts: InteractionsIntraContacts) => {
|
||||
intraContacts = contacts;
|
||||
},
|
||||
handleIntraContact: (index: number, infoA: Features.Info, infoB: Features.Info) => {
|
||||
if (!intraContacts) return;
|
||||
|
||||
if (hasInfoPair(bridgeLegs, infoA, infoB)) {
|
||||
intraContacts.edgeProps.flag[index] = InteractionFlag.Filtered;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Sebastian Bittrich <sebastian.m.bittrich@gmail.com>
|
||||
*/
|
||||
|
||||
import { Structure, Unit, StructureElement } from '../../../mol-model/structure';
|
||||
import { IntMap } from '../../../mol-data/int';
|
||||
import { Vec3 } from '../../../mol-math/linear-algebra';
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
import { MoleculeType, NucleicBackboneAtoms, ProteinBackboneAtoms } from '../../../mol-model/structure/model/types';
|
||||
import { StructureLookup3DResultContext } from '../../../mol-model/structure/structure/util/lookup3d';
|
||||
import { Features } from './features';
|
||||
import { FeatureType, InteractionType, InteractionFlag } from './common';
|
||||
import { GeometryOptions, checkGeometry } from './hydrogen-bonds';
|
||||
import { degToRad } from '../../../mol-math/misc';
|
||||
import { cantorPairing } from '../../../mol-data/util/hash-functions';
|
||||
|
||||
export type { WaterBridgeContact, WaterBridgeContacts };
|
||||
|
||||
interface WaterBridgeContact {
|
||||
/** non-water donor unit id */
|
||||
readonly unitA: number
|
||||
/** donor feature index in unitA */
|
||||
readonly indexA: Features.FeatureIndex
|
||||
/** non-water acceptor unit id */
|
||||
readonly unitB: number
|
||||
/** acceptor feature index in unitB */
|
||||
readonly indexB: Features.FeatureIndex
|
||||
/** bridging water unit id */
|
||||
readonly unitM: number
|
||||
/** water oxygen as HydrogenAcceptor (leg: donor → water) */
|
||||
readonly indexMA: Features.FeatureIndex
|
||||
/** water oxygen as HydrogenDonor (leg: water → acceptor) */
|
||||
readonly indexMB: Features.FeatureIndex
|
||||
props: { type: InteractionType.WaterBridge, flag: InteractionFlag }
|
||||
}
|
||||
|
||||
type WaterBridgeContacts = ReadonlyArray<WaterBridgeContact>;
|
||||
|
||||
export const WaterBridgesParams = {
|
||||
backbone: PD.Boolean(true, { description: 'Include backbone hydrogen bonds' }),
|
||||
ignoreHydrogens: PD.Boolean(true, { description: 'Ignore explicit hydrogens in geometric constraints' }),
|
||||
legDistMin: PD.Numeric(2.5, { min: 1, max: 4, step: 0.1 }, { description: 'Minimum leg distance (Å)' }),
|
||||
legDistMax: PD.Numeric(4.1, { min: 1, max: 6, step: 0.1 }, { description: 'Maximum leg distance (Å)' }),
|
||||
donAngleDevMax: PD.Numeric(80, { min: 0, max: 180, step: 1 }, { description: 'Max deviation from ideal donor angle' }),
|
||||
accAngleDevMax: PD.Numeric(50, { min: 0, max: 180, step: 1 }, { description: 'Max deviation from ideal acceptor angle' }),
|
||||
donOutOfPlaneAngleMax: PD.Numeric(45, { min: 0, max: 180, step: 1 }),
|
||||
accOutOfPlaneAngleMax: PD.Numeric(90, { min: 0, max: 180, step: 1 }),
|
||||
omegaMin: PD.Numeric(71, { min: 0, max: 180, step: 1 }, { description: 'Minimum A–W–B angle (°)' }),
|
||||
omegaMax: PD.Numeric(140, { min: 0, max: 180, step: 1 }, { description: 'Maximum A–W–B angle (°)' }),
|
||||
};
|
||||
export type WaterBridgesParams = typeof WaterBridgesParams;
|
||||
export type WaterBridgesProps = PD.Values<WaterBridgesParams>;
|
||||
|
||||
export const WaterBridgesProvider = {
|
||||
requiredFeatures: new Set([FeatureType.HydrogenDonor, FeatureType.HydrogenAcceptor]),
|
||||
params: WaterBridgesParams,
|
||||
find: findWaterBridgeContacts,
|
||||
};
|
||||
|
||||
function isWater(unit: Unit.Atomic, index: StructureElement.UnitIndex): boolean {
|
||||
return unit.model.atomicHierarchy.derived.residue.moleculeType[
|
||||
unit.residueIndex[unit.elements[index]]
|
||||
] === MoleculeType.Water;
|
||||
}
|
||||
|
||||
function isBackboneAtom(unit: Unit.Atomic, index: StructureElement.UnitIndex): boolean {
|
||||
const element = unit.elements[index];
|
||||
const moleculeType = unit.model.atomicHierarchy.derived.residue.moleculeType[unit.residueIndex[element]];
|
||||
if (moleculeType !== MoleculeType.Protein && moleculeType !== MoleculeType.RNA && moleculeType !== MoleculeType.DNA) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const atomId = unit.model.atomicHierarchy.atoms.label_atom_id.value(element);
|
||||
if (moleculeType === MoleculeType.Protein) {
|
||||
return ProteinBackboneAtoms.has(atomId);
|
||||
}
|
||||
|
||||
return NucleicBackboneAtoms.has(atomId);
|
||||
}
|
||||
|
||||
const _lookupCtx = StructureLookup3DResultContext();
|
||||
|
||||
type Candidate = {
|
||||
unit: Unit.Atomic
|
||||
featureIdx: Features.FeatureIndex
|
||||
memberIdx: StructureElement.UnitIndex
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
distSq: number
|
||||
};
|
||||
|
||||
type FeatureKey = number;
|
||||
|
||||
function featureKey(unitId: number, featureIndex: Features.FeatureIndex): FeatureKey {
|
||||
return cantorPairing(unitId, featureIndex);
|
||||
}
|
||||
|
||||
type BestBridge = { contact: WaterBridgeContact; combinedDistSq: number };
|
||||
type BestBridgeMap = Map<FeatureKey, Map<FeatureKey, BestBridge>>;
|
||||
|
||||
function getBestBridge(best: BestBridgeMap, donorKey: FeatureKey, acceptorKey: FeatureKey): BestBridge | undefined {
|
||||
return best.get(donorKey)?.get(acceptorKey);
|
||||
}
|
||||
|
||||
function setBestBridge(best: BestBridgeMap, donorKey: FeatureKey, acceptorKey: FeatureKey, value: BestBridge) {
|
||||
let acceptors = best.get(donorKey);
|
||||
if (acceptors === undefined) {
|
||||
acceptors = new Map();
|
||||
best.set(donorKey, acceptors);
|
||||
}
|
||||
acceptors.set(acceptorKey, value);
|
||||
}
|
||||
|
||||
function bestBridgeValues(best: BestBridgeMap): BestBridge[] {
|
||||
const values: BestBridge[] = [];
|
||||
for (const acceptors of best.values()) {
|
||||
for (const value of acceptors.values()) values.push(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function checkOmega(don: Candidate, posW: Vec3, acc: Candidate, cosOmegaMin: number, cosOmegaMax: number): boolean {
|
||||
const ax = don.x - posW[0];
|
||||
const ay = don.y - posW[1];
|
||||
const az = don.z - posW[2];
|
||||
|
||||
const bx = acc.x - posW[0];
|
||||
const by = acc.y - posW[1];
|
||||
const bz = acc.z - posW[2];
|
||||
|
||||
const aLenSq = ax * ax + ay * ay + az * az;
|
||||
const bLenSq = bx * bx + by * by + bz * bz;
|
||||
|
||||
if (aLenSq === 0 || bLenSq === 0) return false;
|
||||
|
||||
const cosOmega = (ax * bx + ay * by + az * bz) / Math.sqrt(aLenSq * bLenSq);
|
||||
|
||||
// cos decreases monotonically on [0, pi], so:
|
||||
// omega >= omegaMin && omega <= omegaMax
|
||||
// is equivalent to:
|
||||
// cos(omega) <= cos(omegaMin) && cos(omega) >= cos(omegaMax)
|
||||
return cosOmega <= cosOmegaMin && cosOmega >= cosOmegaMax;
|
||||
}
|
||||
|
||||
export function findWaterBridgeContacts(
|
||||
structure: Structure,
|
||||
unitsFeatures: IntMap<Features>,
|
||||
props: WaterBridgesProps
|
||||
): WaterBridgeContacts {
|
||||
const legOpts: GeometryOptions = {
|
||||
ignoreHydrogens: props.ignoreHydrogens,
|
||||
includeBackbone: props.backbone,
|
||||
maxAccAngleDev: degToRad(props.accAngleDevMax),
|
||||
maxDonAngleDev: degToRad(props.donAngleDevMax),
|
||||
maxAccOutOfPlaneAngle: degToRad(props.accOutOfPlaneAngleMax),
|
||||
maxDonOutOfPlaneAngle: degToRad(props.donOutOfPlaneAngleMax),
|
||||
};
|
||||
|
||||
const legDistMinSq = props.legDistMin * props.legDistMin;
|
||||
const legDistMaxSq = props.legDistMax * props.legDistMax;
|
||||
|
||||
const omegaMinRad = degToRad(props.omegaMin);
|
||||
const omegaMaxRad = degToRad(props.omegaMax);
|
||||
|
||||
if (omegaMinRad > omegaMaxRad) return [];
|
||||
|
||||
const cosOmegaMin = Math.cos(omegaMinRad);
|
||||
const cosOmegaMax = Math.cos(omegaMaxRad);
|
||||
|
||||
// Best bridge per unique donor/acceptor feature pair across all water molecules.
|
||||
const best: BestBridgeMap = new Map();
|
||||
|
||||
const wPos = Vec3();
|
||||
const candidatePos = Vec3();
|
||||
|
||||
for (const unitW of structure.units) {
|
||||
if (!Unit.isAtomic(unitW)) continue;
|
||||
|
||||
const featW = unitsFeatures.get(unitW.id);
|
||||
if (!featW || featW.count === 0) continue;
|
||||
|
||||
// Map each water-oxygen local index to its acceptor and donor feature indices.
|
||||
const waterMap = new Map<StructureElement.UnitIndex, {
|
||||
acc: Features.FeatureIndex | undefined,
|
||||
don: Features.FeatureIndex | undefined
|
||||
}>();
|
||||
|
||||
for (let fi = 0 as Features.FeatureIndex; fi < featW.count; fi++) {
|
||||
const mi = featW.members[featW.offsets[fi]] as StructureElement.UnitIndex;
|
||||
if (!isWater(unitW, mi)) continue;
|
||||
|
||||
const t = featW.types[fi];
|
||||
if (t !== FeatureType.HydrogenAcceptor && t !== FeatureType.HydrogenDonor) continue;
|
||||
|
||||
let e = waterMap.get(mi);
|
||||
if (!e) waterMap.set(mi, (e = { acc: undefined, don: undefined }));
|
||||
|
||||
if (t === FeatureType.HydrogenAcceptor) e.acc = fi;
|
||||
else e.don = fi;
|
||||
}
|
||||
|
||||
if (waterMap.size === 0) continue;
|
||||
|
||||
const infoWAcc = Features.Info(structure, unitW, featW);
|
||||
const infoWDon = Features.Info(structure, unitW, featW);
|
||||
|
||||
for (const [waterAtomIdx, { acc: accFW, don: donFW }] of waterMap) {
|
||||
if (accFW === undefined || donFW === undefined) continue;
|
||||
|
||||
unitW.conformation.position(unitW.elements[waterAtomIdx], wPos);
|
||||
|
||||
infoWAcc.feature = accFW;
|
||||
infoWDon.feature = donFW;
|
||||
|
||||
const { count, indices, units: hitUnits } =
|
||||
structure.lookup3d.find(wPos[0], wPos[1], wPos[2], props.legDistMax, _lookupCtx);
|
||||
|
||||
const donors: Candidate[] = [];
|
||||
const acceptors: Candidate[] = [];
|
||||
|
||||
const donorKeys = new Set<FeatureKey>();
|
||||
const acceptorKeys = new Set<FeatureKey>();
|
||||
|
||||
for (let r = 0; r < count; r++) {
|
||||
const hitUnit = hitUnits[r];
|
||||
if (!Unit.isAtomic(hitUnit)) continue;
|
||||
|
||||
const atomicUnit = hitUnit as Unit.Atomic;
|
||||
const hitLocalIdx = indices[r] as StructureElement.UnitIndex;
|
||||
|
||||
// Only skip the water atom itself. Other atoms in the same unit can still be valid.
|
||||
if (atomicUnit === unitW && hitLocalIdx === waterAtomIdx) continue;
|
||||
if (isWater(atomicUnit, hitLocalIdx)) continue;
|
||||
|
||||
const hitFeat = unitsFeatures.get(atomicUnit.id);
|
||||
if (!hitFeat || hitFeat.count === 0) continue;
|
||||
|
||||
const infoHit = Features.Info(structure, atomicUnit, hitFeat);
|
||||
|
||||
const { indices: fIdxs, offsets: fOff } = hitFeat.elementsIndex;
|
||||
for (let k = fOff[hitLocalIdx], kl = fOff[hitLocalIdx + 1]; k < kl; k++) {
|
||||
const fi = fIdxs[k] as Features.FeatureIndex;
|
||||
const fType = hitFeat.types[fi];
|
||||
|
||||
if (fType !== FeatureType.HydrogenDonor && fType !== FeatureType.HydrogenAcceptor) continue;
|
||||
|
||||
const memberIdx = hitFeat.members[hitFeat.offsets[fi]] as StructureElement.UnitIndex;
|
||||
|
||||
if (!props.backbone && isBackboneAtom(atomicUnit, memberIdx)) continue;
|
||||
|
||||
atomicUnit.conformation.position(atomicUnit.elements[memberIdx], candidatePos);
|
||||
|
||||
const distSq = Vec3.squaredDistance(candidatePos, wPos);
|
||||
if (distSq < legDistMinSq || distSq > legDistMaxSq) continue;
|
||||
|
||||
infoHit.feature = fi;
|
||||
|
||||
if (fType === FeatureType.HydrogenDonor) {
|
||||
const key = featureKey(atomicUnit.id, fi);
|
||||
if (donorKeys.has(key)) continue;
|
||||
|
||||
if (checkGeometry(structure, infoHit, infoWAcc, legOpts)) {
|
||||
donorKeys.add(key);
|
||||
donors.push({
|
||||
unit: atomicUnit,
|
||||
featureIdx: fi,
|
||||
memberIdx,
|
||||
x: candidatePos[0],
|
||||
y: candidatePos[1],
|
||||
z: candidatePos[2],
|
||||
distSq,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const key = featureKey(atomicUnit.id, fi);
|
||||
if (acceptorKeys.has(key)) continue;
|
||||
|
||||
if (checkGeometry(structure, infoWDon, infoHit, legOpts)) {
|
||||
acceptorKeys.add(key);
|
||||
acceptors.push({
|
||||
unit: atomicUnit,
|
||||
featureIdx: fi,
|
||||
memberIdx,
|
||||
x: candidatePos[0],
|
||||
y: candidatePos[1],
|
||||
z: candidatePos[2],
|
||||
distSq,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const don of donors) {
|
||||
for (const acc of acceptors) {
|
||||
// Reject bridges where donor and acceptor are the same physical atom
|
||||
// represented by different feature indices.
|
||||
if (don.unit === acc.unit && don.memberIdx === acc.memberIdx) continue;
|
||||
|
||||
if (!checkOmega(don, wPos, acc, cosOmegaMin, cosOmegaMax)) continue;
|
||||
|
||||
const combinedDistSq = don.distSq + acc.distSq;
|
||||
const donorKey = featureKey(don.unit.id, don.featureIdx);
|
||||
const acceptorKey = featureKey(acc.unit.id, acc.featureIdx);
|
||||
|
||||
const existing = getBestBridge(best, donorKey, acceptorKey);
|
||||
if (!existing || combinedDistSq < existing.combinedDistSq) {
|
||||
setBestBridge(best, donorKey, acceptorKey, {
|
||||
contact: {
|
||||
unitA: don.unit.id,
|
||||
indexA: don.featureIdx,
|
||||
unitB: acc.unit.id,
|
||||
indexB: acc.featureIdx,
|
||||
unitM: unitW.id,
|
||||
indexMA: accFW,
|
||||
indexMB: donFW,
|
||||
props: { type: InteractionType.WaterBridge, flag: InteractionFlag.None },
|
||||
},
|
||||
combinedDistSq,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestBridgeValues(best).map(e => e.contact);
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Sebastian Bittrich <sebastian.m.bittrich@gmail.com>
|
||||
*/
|
||||
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
import { VisualContext } from '../../../mol-repr/visual';
|
||||
import { Structure, StructureElement, Unit } from '../../../mol-model/structure';
|
||||
import { Theme } from '../../../mol-theme/theme';
|
||||
import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
|
||||
import { Vec3 } from '../../../mol-math/linear-algebra';
|
||||
import { createLinkCylinderMesh, LinkCylinderParams, LinkStyle } from '../../../mol-repr/structure/visual/util/link';
|
||||
import { ComplexMeshParams, ComplexVisual, ComplexMeshVisual } from '../../../mol-repr/structure/complex-visual';
|
||||
import { VisualUpdateState } from '../../../mol-repr/util';
|
||||
import { PickingId } from '../../../mol-geo/geometry/picking';
|
||||
import { EmptyLoci, Loci } from '../../../mol-model/loci';
|
||||
import { NullLocation } from '../../../mol-model/location';
|
||||
import { Interval, OrderedSet } from '../../../mol-data/int';
|
||||
import { InteractionsProvider } from '../interactions';
|
||||
import { LocationIterator } from '../../../mol-geo/util/location-iterator';
|
||||
import { BridgeContacts, Bridges } from '../interactions/interactions';
|
||||
import { Sphere3D } from '../../../mol-math/geometry';
|
||||
import { InteractionsSharedParams } from './shared';
|
||||
import { Features } from '../interactions/features';
|
||||
|
||||
type CanonicalLegIndices = {
|
||||
endpointA: Int32Array
|
||||
endpointB: Int32Array
|
||||
};
|
||||
|
||||
const CanonicalLegIndicesCache = new WeakMap<BridgeContacts, CanonicalLegIndices>();
|
||||
|
||||
function getCanonicalLegIndices(bridges: BridgeContacts): CanonicalLegIndices {
|
||||
const cached = CanonicalLegIndicesCache.get(bridges);
|
||||
if (cached) return cached;
|
||||
|
||||
const n = bridges.length;
|
||||
const endpointA = new Int32Array(n);
|
||||
const endpointB = new Int32Array(n);
|
||||
|
||||
const legA = new Map<string, number>();
|
||||
const legB = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const b = bridges[i];
|
||||
|
||||
const kA = `${b.unitA}|${b.indexA}|${b.unitM}|${b.indexMA}`;
|
||||
const kB = `${b.unitM}|${b.indexMB}|${b.unitB}|${b.indexB}`;
|
||||
|
||||
let ai = legA.get(kA);
|
||||
if (ai === undefined) {
|
||||
ai = i;
|
||||
legA.set(kA, i);
|
||||
}
|
||||
endpointA[i] = ai;
|
||||
|
||||
let bi = legB.get(kB);
|
||||
if (bi === undefined) {
|
||||
bi = i;
|
||||
legB.set(kB, i);
|
||||
}
|
||||
endpointB[i] = bi;
|
||||
}
|
||||
|
||||
const indices = { endpointA, endpointB };
|
||||
CanonicalLegIndicesCache.set(bridges, indices);
|
||||
return indices;
|
||||
}
|
||||
|
||||
function getFeatureMember(features: Features, featureIndex: Features.FeatureIndex): StructureElement.UnitIndex {
|
||||
return features.members[features.offsets[featureIndex]] as StructureElement.UnitIndex;
|
||||
}
|
||||
|
||||
function atomPosition(unit: Unit.Atomic, features: Features, featureIndex: Features.FeatureIndex, out: Vec3) {
|
||||
const atomLocalIdx = getFeatureMember(features, featureIndex);
|
||||
unit.conformation.position(unit.elements[atomLocalIdx], out);
|
||||
}
|
||||
|
||||
function setFeatureLocation(
|
||||
structure: Structure,
|
||||
location: StructureElement.Location,
|
||||
unitId: number,
|
||||
features: Features,
|
||||
featureIndex: Features.FeatureIndex
|
||||
) {
|
||||
const unit = structure.unitMap.get(unitId) as Unit.Atomic;
|
||||
const atomLocalIdx = getFeatureMember(features, featureIndex);
|
||||
|
||||
location.unit = unit;
|
||||
location.element = unit.elements[atomLocalIdx];
|
||||
}
|
||||
|
||||
function applyLegA(
|
||||
bridgeIndex: number,
|
||||
bridgeCount: number,
|
||||
canonical: CanonicalLegIndices,
|
||||
apply: (interval: Interval) => boolean
|
||||
) {
|
||||
let changed = false;
|
||||
const i = canonical.endpointA[bridgeIndex];
|
||||
|
||||
if (apply(Interval.ofSingleton(i))) changed = true;
|
||||
if (apply(Interval.ofSingleton(i + bridgeCount))) changed = true;
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function applyLegB(
|
||||
bridgeIndex: number,
|
||||
bridgeCount: number,
|
||||
canonical: CanonicalLegIndices,
|
||||
apply: (interval: Interval) => boolean
|
||||
) {
|
||||
let changed = false;
|
||||
const i = canonical.endpointB[bridgeIndex];
|
||||
|
||||
if (apply(Interval.ofSingleton(i + 2 * bridgeCount))) changed = true;
|
||||
if (apply(Interval.ofSingleton(i + 3 * bridgeCount))) changed = true;
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function createBridgeCylinderMesh(ctx: VisualContext, structure: Structure, theme: Theme, props: PD.Values<BridgeParams>, mesh?: Mesh) {
|
||||
if (!structure.hasAtomic) return Mesh.createEmpty(mesh);
|
||||
|
||||
const interactions = InteractionsProvider.get(structure).value;
|
||||
if (!interactions) return Mesh.createEmpty(mesh);
|
||||
|
||||
const { bridges, unitsFeatures } = interactions;
|
||||
|
||||
const n = bridges.length;
|
||||
if (!n) return Mesh.createEmpty(mesh);
|
||||
|
||||
const l = StructureElement.Location.create(structure);
|
||||
const { sizeFactor } = props;
|
||||
const canonical = getCanonicalLegIndices(bridges);
|
||||
|
||||
const builderProps = {
|
||||
// Four half-cylinders per bridge; createLinkCylinderMesh draws the A-side half per call:
|
||||
// [0, n): A→mediator, forward (A side)
|
||||
// [n, 2n): A→mediator, backward (mediator side)
|
||||
// [2n, 3n): mediator→B, forward (mediator side)
|
||||
// [3n, 4n): mediator→B, backward (B side)
|
||||
//
|
||||
// When multiple bridges share the same physical leg, only the first
|
||||
// occurrence is drawn; later ones map back to the canonical edge index.
|
||||
linkCount: 4 * n,
|
||||
|
||||
position: (posA: Vec3, posB: Vec3, edgeIndex: number) => {
|
||||
const b = bridges[edgeIndex % n];
|
||||
const uM = structure.unitMap.get(b.unitM) as Unit.Atomic;
|
||||
const fM = unitsFeatures.get(b.unitM);
|
||||
const leg = Math.floor(edgeIndex / n);
|
||||
|
||||
if (leg === 0) {
|
||||
const uA = structure.unitMap.get(b.unitA) as Unit.Atomic;
|
||||
const fA = unitsFeatures.get(b.unitA);
|
||||
atomPosition(uA, fA, b.indexA, posA);
|
||||
atomPosition(uM, fM, b.indexMA, posB);
|
||||
} else if (leg === 1) {
|
||||
const uA = structure.unitMap.get(b.unitA) as Unit.Atomic;
|
||||
const fA = unitsFeatures.get(b.unitA);
|
||||
atomPosition(uM, fM, b.indexMA, posA);
|
||||
atomPosition(uA, fA, b.indexA, posB);
|
||||
} else if (leg === 2) {
|
||||
const uB = structure.unitMap.get(b.unitB) as Unit.Atomic;
|
||||
const fB = unitsFeatures.get(b.unitB);
|
||||
atomPosition(uM, fM, b.indexMB, posA);
|
||||
atomPosition(uB, fB, b.indexB, posB);
|
||||
} else {
|
||||
const uB = structure.unitMap.get(b.unitB) as Unit.Atomic;
|
||||
const fB = unitsFeatures.get(b.unitB);
|
||||
atomPosition(uB, fB, b.indexB, posA);
|
||||
atomPosition(uM, fM, b.indexMB, posB);
|
||||
}
|
||||
},
|
||||
|
||||
ignore: (edgeIndex: number) => {
|
||||
const bi = edgeIndex % n;
|
||||
const leg = Math.floor(edgeIndex / n);
|
||||
|
||||
return leg <= 1
|
||||
? canonical.endpointA[bi] !== bi
|
||||
: canonical.endpointB[bi] !== bi;
|
||||
},
|
||||
|
||||
style: (_edgeIndex: number) => LinkStyle.Dashed,
|
||||
|
||||
radius: (edgeIndex: number) => {
|
||||
const b = bridges[edgeIndex % n];
|
||||
const leg = Math.floor(edgeIndex / n);
|
||||
const isLegA = leg <= 1;
|
||||
|
||||
if (isLegA) {
|
||||
const fA = unitsFeatures.get(b.unitA);
|
||||
const fM = unitsFeatures.get(b.unitM);
|
||||
|
||||
setFeatureLocation(structure, l, b.unitA, fA, b.indexA);
|
||||
const sizeA = theme.size.size(l);
|
||||
|
||||
setFeatureLocation(structure, l, b.unitM, fM, b.indexMA);
|
||||
const sizeM = theme.size.size(l);
|
||||
|
||||
return Math.min(sizeA, sizeM) * sizeFactor;
|
||||
} else {
|
||||
const fM = unitsFeatures.get(b.unitM);
|
||||
const fB = unitsFeatures.get(b.unitB);
|
||||
|
||||
setFeatureLocation(structure, l, b.unitM, fM, b.indexMB);
|
||||
const sizeM = theme.size.size(l);
|
||||
|
||||
setFeatureLocation(structure, l, b.unitB, fB, b.indexB);
|
||||
const sizeB = theme.size.size(l);
|
||||
|
||||
return Math.min(sizeM, sizeB) * sizeFactor;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const { mesh: m, boundingSphere } = createLinkCylinderMesh(ctx, builderProps, props, mesh);
|
||||
|
||||
if (boundingSphere) {
|
||||
m.setBoundingSphere(boundingSphere);
|
||||
} else if (m.triangleCount > 0) {
|
||||
const sphere = Sphere3D.expand(Sphere3D(), structure.boundary.sphere, sizeFactor);
|
||||
m.setBoundingSphere(sphere);
|
||||
}
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
export const BridgeParams = {
|
||||
...ComplexMeshParams,
|
||||
...LinkCylinderParams,
|
||||
...InteractionsSharedParams,
|
||||
};
|
||||
export type BridgeParams = typeof BridgeParams
|
||||
|
||||
export function BridgeVisual(materialId: number): ComplexVisual<BridgeParams> {
|
||||
return ComplexMeshVisual<BridgeParams>({
|
||||
defaultProps: PD.getDefaultValues(BridgeParams),
|
||||
createGeometry: createBridgeCylinderMesh,
|
||||
createLocationIterator: createBridgeIterator,
|
||||
getLoci: getBridgeLoci,
|
||||
eachLocation: eachBridgeInteraction,
|
||||
|
||||
setUpdateState: (
|
||||
state: VisualUpdateState,
|
||||
newProps: PD.Values<BridgeParams>,
|
||||
currentProps: PD.Values<BridgeParams>,
|
||||
newTheme: Theme,
|
||||
currentTheme: Theme,
|
||||
newStructure: Structure,
|
||||
_currentStructure: Structure
|
||||
) => {
|
||||
state.createGeometry = (
|
||||
newProps.sizeFactor !== currentProps.sizeFactor ||
|
||||
newProps.dashCount !== currentProps.dashCount ||
|
||||
newProps.dashScale !== currentProps.dashScale ||
|
||||
newProps.dashCap !== currentProps.dashCap ||
|
||||
newProps.radialSegments !== currentProps.radialSegments ||
|
||||
newTheme.size !== currentTheme.size
|
||||
);
|
||||
|
||||
const interactionsHash = InteractionsProvider.get(newStructure).version;
|
||||
if ((state.info.interactionsHash as number) !== interactionsHash) {
|
||||
state.createGeometry = true;
|
||||
state.updateTransform = true;
|
||||
state.updateColor = true;
|
||||
state.info.interactionsHash = interactionsHash;
|
||||
}
|
||||
}
|
||||
}, materialId);
|
||||
}
|
||||
|
||||
function getBridgeLoci(pickingId: PickingId, structure: Structure, id: number) {
|
||||
const { objectId, groupId } = pickingId;
|
||||
if (id !== objectId) return EmptyLoci;
|
||||
|
||||
const interactions = InteractionsProvider.get(structure).value;
|
||||
if (!interactions) return EmptyLoci;
|
||||
|
||||
const { bridges, unitsFeatures } = interactions;
|
||||
const n = bridges.length;
|
||||
|
||||
if (!n || groupId < 0 || groupId >= 4 * n) return EmptyLoci;
|
||||
|
||||
const bridgeIndex = groupId % n;
|
||||
|
||||
return Bridges.Loci({ structure, bridges, unitsFeatures }, [{ bridgeIndex }]);
|
||||
}
|
||||
|
||||
const __unitMap = new Map<number, OrderedSet<StructureElement.UnitIndex>>();
|
||||
|
||||
function eachBridgeInteraction(loci: Loci, structure: Structure, apply: (interval: Interval) => boolean, _isMarking: boolean) {
|
||||
let changed = false;
|
||||
|
||||
if (Bridges.isLoci(loci)) {
|
||||
if (!Structure.areEquivalent(loci.data.structure, structure)) return false;
|
||||
|
||||
const interactions = InteractionsProvider.get(structure).value;
|
||||
if (!interactions) return false;
|
||||
|
||||
const { bridges } = interactions;
|
||||
const n = bridges.length;
|
||||
if (!n) return false;
|
||||
|
||||
const canonical = getCanonicalLegIndices(bridges);
|
||||
|
||||
for (const e of loci.elements) {
|
||||
if (e.bridgeIndex < 0 || e.bridgeIndex >= n) continue;
|
||||
|
||||
if (applyLegA(e.bridgeIndex, n, canonical, apply)) changed = true;
|
||||
if (applyLegB(e.bridgeIndex, n, canonical, apply)) changed = true;
|
||||
}
|
||||
} else if (StructureElement.Loci.is(loci)) {
|
||||
if (!Structure.areEquivalent(loci.structure, structure)) return false;
|
||||
|
||||
const interactions = InteractionsProvider.get(structure).value;
|
||||
if (!interactions) return false;
|
||||
|
||||
const { bridges, unitsFeatures } = interactions;
|
||||
const n = bridges.length;
|
||||
if (!n) return false;
|
||||
|
||||
const canonical = getCanonicalLegIndices(bridges);
|
||||
|
||||
__unitMap.clear();
|
||||
for (const e of loci.elements) {
|
||||
__unitMap.set(e.unit.id, e.indices);
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const b = bridges[i];
|
||||
|
||||
const indicesA = __unitMap.get(b.unitA);
|
||||
const indicesM = __unitMap.get(b.unitM);
|
||||
const indicesB = __unitMap.get(b.unitB);
|
||||
|
||||
if (!indicesA && !indicesM && !indicesB) continue;
|
||||
|
||||
let hitA = false;
|
||||
if (indicesA) {
|
||||
const fA = unitsFeatures.get(b.unitA);
|
||||
const mi = getFeatureMember(fA, b.indexA);
|
||||
hitA = OrderedSet.has(indicesA, mi);
|
||||
}
|
||||
|
||||
let hitM = false;
|
||||
if (indicesM) {
|
||||
const fM = unitsFeatures.get(b.unitM);
|
||||
const miA = getFeatureMember(fM, b.indexMA);
|
||||
const miB = getFeatureMember(fM, b.indexMB);
|
||||
hitM = OrderedSet.has(indicesM, miA) || OrderedSet.has(indicesM, miB);
|
||||
}
|
||||
|
||||
let hitB = false;
|
||||
if (indicesB) {
|
||||
const fB = unitsFeatures.get(b.unitB);
|
||||
const mi = getFeatureMember(fB, b.indexB);
|
||||
hitB = OrderedSet.has(indicesB, mi);
|
||||
}
|
||||
|
||||
if (hitA || hitM) {
|
||||
if (applyLegA(i, n, canonical, apply)) changed = true;
|
||||
}
|
||||
|
||||
if (hitB || hitM) {
|
||||
if (applyLegB(i, n, canonical, apply)) changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
__unitMap.clear();
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function createBridgeIterator(structure: Structure): LocationIterator {
|
||||
const interactions = InteractionsProvider.get(structure).value;
|
||||
if (!interactions) return LocationIterator(0, 1, 1, () => NullLocation, true);
|
||||
|
||||
const { bridges, unitsFeatures } = interactions;
|
||||
|
||||
const n = bridges.length;
|
||||
const groupCount = 4 * n;
|
||||
const instanceCount = 1;
|
||||
|
||||
const data: Bridges.Data = { structure, bridges, unitsFeatures };
|
||||
const location = Bridges.Location(data);
|
||||
const { element } = location;
|
||||
|
||||
const getLocation = (groupIndex: number) => {
|
||||
element.bridgeIndex = n === 0 ? 0 : groupIndex % n;
|
||||
return location;
|
||||
};
|
||||
|
||||
return LocationIterator(groupCount, instanceCount, 1, getLocation, true);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2019-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
@@ -12,23 +12,20 @@ import { UnitsRepresentation, StructureRepresentation, StructureRepresentationSt
|
||||
import { InteractionsIntraUnitParams, InteractionsIntraUnitVisual } from './interactions-intra-unit-cylinder';
|
||||
import { InteractionsProvider } from '../interactions';
|
||||
import { InteractionsInterUnitParams, InteractionsInterUnitVisual } from './interactions-inter-unit-cylinder';
|
||||
import { BridgeParams, BridgeVisual } from './interactions-bridge-cylinder';
|
||||
import { CustomProperty } from '../../common/custom-property';
|
||||
import { getUnitKindsParam } from '../../../mol-repr/structure/params';
|
||||
|
||||
const InteractionsVisuals = {
|
||||
'intra-unit': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, InteractionsIntraUnitParams>) => UnitsRepresentation('Intra-unit interactions cylinder', ctx, getParams, InteractionsIntraUnitVisual),
|
||||
'inter-unit': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, InteractionsInterUnitParams>) => ComplexRepresentation('Inter-unit interactions cylinder', ctx, getParams, InteractionsInterUnitVisual),
|
||||
'bridge': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, BridgeParams>) => ComplexRepresentation('Bridge cylinder', ctx, getParams, BridgeVisual),
|
||||
};
|
||||
|
||||
export const InteractionsParams = {
|
||||
...InteractionsIntraUnitParams,
|
||||
...InteractionsInterUnitParams,
|
||||
...BridgeParams,
|
||||
unitKinds: getUnitKindsParam(['atomic']),
|
||||
sizeFactor: PD.Numeric(0.2, { min: 0.01, max: 1, step: 0.01 }),
|
||||
visuals: PD.MultiSelect(['intra-unit', 'inter-unit', 'bridge'], PD.objectToOptions(InteractionsVisuals)),
|
||||
visuals: PD.MultiSelect(['intra-unit', 'inter-unit'], PD.objectToOptions(InteractionsVisuals)),
|
||||
};
|
||||
export type InteractionsParams = typeof InteractionsParams
|
||||
export function getInteractionParams(ctx: ThemeRegistryContext, structure: Structure) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2019-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
* @author Sebastian Bittrich <sebastian.m.bittrich@gmail.com>
|
||||
*/
|
||||
|
||||
import { Location } from '../../../mol-model/location';
|
||||
@@ -13,7 +12,7 @@ import { ThemeDataContext } from '../../../mol-theme/theme';
|
||||
import { ColorTheme, LocationColor } from '../../../mol-theme/color';
|
||||
import { InteractionType } from '../interactions/common';
|
||||
import { TableLegend } from '../../../mol-util/legend';
|
||||
import { Interactions, Bridges } from '../interactions/interactions';
|
||||
import { Interactions } from '../interactions/interactions';
|
||||
import { CustomProperty } from '../../common/custom-property';
|
||||
import { hash2 } from '../../../mol-data/util';
|
||||
import { ColorThemeCategory } from '../../../mol-theme/color/categories';
|
||||
@@ -30,7 +29,6 @@ const InteractionTypeColors = ColorMap({
|
||||
CationPi: 0xFF8000,
|
||||
PiStacking: 0x8CB366,
|
||||
WeakHydrogenBond: 0xC5DDEC,
|
||||
WaterBridge: 0x00CCEE,
|
||||
});
|
||||
|
||||
const InteractionTypeColorTable: [string, Color][] = [
|
||||
@@ -42,7 +40,6 @@ const InteractionTypeColorTable: [string, Color][] = [
|
||||
['Cation Pi', InteractionTypeColors.CationPi],
|
||||
['Pi Stacking', InteractionTypeColors.PiStacking],
|
||||
['Weak HydrogenBond', InteractionTypeColors.WeakHydrogenBond],
|
||||
['Water Bridge', InteractionTypeColors.WaterBridge],
|
||||
];
|
||||
|
||||
function typeColor(type: InteractionType): Color {
|
||||
@@ -63,8 +60,6 @@ function typeColor(type: InteractionType): Color {
|
||||
return InteractionTypeColors.PiStacking;
|
||||
case InteractionType.WeakHydrogenBond:
|
||||
return InteractionTypeColors.WeakHydrogenBond;
|
||||
case InteractionType.WaterBridge:
|
||||
return InteractionTypeColors.WaterBridge;
|
||||
case InteractionType.Unknown:
|
||||
return DefaultColor;
|
||||
}
|
||||
@@ -96,9 +91,6 @@ export function InteractionTypeColorTheme(ctx: ThemeDataContext, props: PD.Value
|
||||
return typeColor(contacts.edges[idx].props.type);
|
||||
}
|
||||
}
|
||||
if (Bridges.isLocation(location)) {
|
||||
return typeColor(location.data.bridges[location.element.bridgeIndex].props.type);
|
||||
}
|
||||
return DefaultColor;
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2018-2025 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>
|
||||
@@ -95,22 +95,10 @@ namespace UnitRing {
|
||||
Elements.SN, Elements.SB,
|
||||
Elements.BI
|
||||
] as ElementSymbol[]);
|
||||
/**
|
||||
* Elements that are sp3 (and therefore non-aromatic) when degree >= 4 with no pi bonds.
|
||||
* Excludes O (never realistically reaches degree 4) and N (quaternary N can be aromatic,
|
||||
* but is guarded by the hasPiBond check below).
|
||||
*/
|
||||
const Sp3RingCheckElements = new Set([
|
||||
Elements.B, Elements.C, Elements.N,
|
||||
Elements.SI, Elements.P, Elements.S,
|
||||
Elements.GE, Elements.AS,
|
||||
Elements.SN, Elements.SB,
|
||||
Elements.BI
|
||||
] as ElementSymbol[]);
|
||||
const AromaticRingPlanarityThreshold = 0.05;
|
||||
|
||||
export function isAromatic(unit: Unit.Atomic, ring: UnitRing): boolean {
|
||||
const { elements, bonds: { b, offset, edgeProps: { flags, order } } } = unit;
|
||||
const { elements, bonds: { b, offset, edgeProps: { flags } } } = unit;
|
||||
const { type_symbol, label_comp_id } = unit.model.atomicHierarchy.atoms;
|
||||
|
||||
// ignore Proline (can be flat because of bad geometry)
|
||||
@@ -132,25 +120,6 @@ namespace UnitRing {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0, il = ring.length; i < il; ++i) {
|
||||
const aI = ring[i];
|
||||
const elem = type_symbol.value(elements[aI]);
|
||||
if (!Sp3RingCheckElements.has(elem)) continue;
|
||||
|
||||
let degree = 0;
|
||||
let hasPiBond = false;
|
||||
for (let j = offset[aI], jl = offset[aI + 1]; j < jl; ++j) {
|
||||
degree += 1;
|
||||
const f = flags[j];
|
||||
const o = order[j];
|
||||
if (BondType.is(BondType.Flag.Aromatic, f) || o === 2 || o === 3) {
|
||||
hasPiBond = true;
|
||||
}
|
||||
}
|
||||
if (degree >= 4 && !hasPiBond) return false;
|
||||
}
|
||||
|
||||
if (aromaticBondCount === 2 * ring.length) return true;
|
||||
if (!hasAromaticRingElement) return false;
|
||||
if (ring.length < 5) return false;
|
||||
|
||||
@@ -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>
|
||||
@@ -436,12 +436,12 @@ export const LoadTrajectory = StateAction.build({
|
||||
|
||||
//
|
||||
|
||||
const dependsOn = [model.ref, coordinates.ref];
|
||||
// dependsOn is auto-derived from the `getDependencies` hook on TrajectoryFromModelAndCoordinates
|
||||
const traj = state.build().toRoot()
|
||||
.apply(TrajectoryFromModelAndCoordinates, {
|
||||
modelRef: model.ref,
|
||||
coordinatesRef: coordinates.ref
|
||||
}, { dependsOn })
|
||||
})
|
||||
.apply(StateTransforms.Model.ModelFromTrajectory, { modelIndex: 0 });
|
||||
|
||||
await state.updateTree(traj).runInContext(taskCtx);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2022-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
@@ -12,7 +12,7 @@ import { degToRad } from '../../../mol-math/misc';
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
import { PluginStateAnimation } from '../model';
|
||||
|
||||
const _dir = Vec3(), _axis = Vec3(), _rot = Quat(), _up = Vec3(), _side = Vec3();
|
||||
const _dir = Vec3(), _axis = Vec3(), _rot = Quat();
|
||||
|
||||
type State = { snapshot: Camera.Snapshot };
|
||||
|
||||
@@ -24,7 +24,6 @@ export const AnimateCameraRock = PluginStateAnimation.create({
|
||||
durationInMs: PD.Numeric(4000, { min: 100, max: 20000, step: 100 }),
|
||||
speed: PD.Numeric(1, { min: 1, max: 10, step: 1 }, { description: 'How many times to rock from side to side.' }),
|
||||
angle: PD.Numeric(10, { min: 0, max: 180, step: 1 }, { description: 'How many degrees to rotate in each direction.' }),
|
||||
axis: PD.Vec3(Vec3.create(0, -1, 0), {}, { description: 'Axis of rotation in camera space' }),
|
||||
}),
|
||||
initialState: (p, ctx) => ({ snapshot: ctx.canvas3d!.camera.getSnapshot() }) as State,
|
||||
getDuration: p => ({ kind: 'fixed', durationMs: p.durationInMs }),
|
||||
@@ -48,25 +47,11 @@ export const AnimateCameraRock = PluginStateAnimation.create({
|
||||
const angle = Math.sin(phase * ctx.params.speed * Math.PI * 2) * degToRad(ctx.params.angle);
|
||||
|
||||
Vec3.sub(_dir, snapshot.position, snapshot.target);
|
||||
|
||||
// Transform axis from camera space to world space
|
||||
Vec3.normalize(_axis, _dir); // Z = view direction
|
||||
Vec3.normalize(_up, snapshot.up); // Y = up
|
||||
Vec3.cross(_side, _up, _axis); // X = right
|
||||
Vec3.normalize(_side, _side);
|
||||
const a = ctx.params.axis ?? Vec3.create(0, -1, 0); // default for backwards compatibility
|
||||
Vec3.set(_axis,
|
||||
a[0] * _side[0] + a[1] * _up[0] + a[2] * _axis[0],
|
||||
a[0] * _side[1] + a[1] * _up[1] + a[2] * _axis[1],
|
||||
a[0] * _side[2] + a[1] * _up[2] + a[2] * _axis[2]
|
||||
);
|
||||
Vec3.normalize(_axis, _axis);
|
||||
|
||||
Vec3.normalize(_axis, snapshot.up);
|
||||
Quat.setAxisAngle(_rot, _axis, angle);
|
||||
Vec3.transformQuat(_dir, _dir, _rot);
|
||||
Vec3.transformQuat(_up, snapshot.up, _rot);
|
||||
const position = Vec3.add(Vec3(), snapshot.target, _dir);
|
||||
ctx.plugin.canvas3d?.requestCameraReset({ snapshot: { ...snapshot, position, up: _up }, durationMs: 0 });
|
||||
ctx.plugin.canvas3d?.requestCameraReset({ snapshot: { ...snapshot, position }, durationMs: 0 });
|
||||
|
||||
if (phase >= 0.99999) {
|
||||
return { kind: 'finished' };
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2020-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2020-2022 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 { Camera } from '../../../mol-canvas3d/camera';
|
||||
@@ -12,7 +11,7 @@ import { Vec3 } from '../../../mol-math/linear-algebra/3d/vec3';
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
import { PluginStateAnimation } from '../model';
|
||||
|
||||
const _dir = Vec3(), _axis = Vec3(), _rot = Quat(), _up = Vec3(), _side = Vec3();
|
||||
const _dir = Vec3(), _axis = Vec3(), _rot = Quat();
|
||||
|
||||
type State = { snapshot: Camera.Snapshot };
|
||||
|
||||
@@ -23,7 +22,7 @@ export const AnimateCameraSpin = PluginStateAnimation.create({
|
||||
params: () => ({
|
||||
durationInMs: PD.Numeric(4000, { min: 100, max: 20000, step: 100 }),
|
||||
speed: PD.Numeric(1, { min: 1, max: 10, step: 1 }, { description: 'How many times to spin in the specified duration.' }),
|
||||
axis: PD.Vec3(Vec3.create(0, -1, 0), {}, { description: 'Axis of rotation in camera space' }),
|
||||
direction: PD.Select<'cw' | 'ccw'>('cw', [['cw', 'Clockwise'], ['ccw', 'Counter Clockwise']], { cycle: true })
|
||||
}),
|
||||
initialState: (_, ctx) => ({ snapshot: ctx.canvas3d?.camera.getSnapshot()! }) as State,
|
||||
getDuration: p => ({ kind: 'fixed', durationMs: p.durationInMs }),
|
||||
@@ -43,28 +42,14 @@ export const AnimateCameraSpin = PluginStateAnimation.create({
|
||||
const phase = t.animation
|
||||
? t.animation?.currentFrame / (t.animation.frameCount + 1)
|
||||
: clamp(t.current / ctx.params.durationInMs, 0, 1);
|
||||
const angle = 2 * Math.PI * phase * ctx.params.speed;
|
||||
const angle = 2 * Math.PI * phase * ctx.params.speed * (ctx.params.direction === 'ccw' ? -1 : 1);
|
||||
|
||||
Vec3.sub(_dir, snapshot.position, snapshot.target);
|
||||
|
||||
// Transform axis from camera space to world space
|
||||
Vec3.normalize(_axis, _dir); // Z = view direction
|
||||
Vec3.normalize(_up, snapshot.up); // Y = up
|
||||
Vec3.cross(_side, _up, _axis); // X = right
|
||||
Vec3.normalize(_side, _side);
|
||||
const a = ctx.params.axis ?? Vec3.create(0, -1, 0); // default for backwards compatibility
|
||||
Vec3.set(_axis,
|
||||
a[0] * _side[0] + a[1] * _up[0] + a[2] * _axis[0],
|
||||
a[0] * _side[1] + a[1] * _up[1] + a[2] * _axis[1],
|
||||
a[0] * _side[2] + a[1] * _up[2] + a[2] * _axis[2]
|
||||
);
|
||||
Vec3.normalize(_axis, _axis);
|
||||
|
||||
Vec3.normalize(_axis, snapshot.up);
|
||||
Quat.setAxisAngle(_rot, _axis, angle);
|
||||
Vec3.transformQuat(_dir, _dir, _rot);
|
||||
Vec3.transformQuat(_up, snapshot.up, _rot);
|
||||
const position = Vec3.add(Vec3(), snapshot.target, _dir);
|
||||
ctx.plugin.canvas3d?.requestCameraReset({ snapshot: { ...snapshot, position, up: _up }, durationMs: 0 });
|
||||
ctx.plugin.canvas3d?.requestCameraReset({ snapshot: { ...snapshot, position }, durationMs: 0 });
|
||||
|
||||
if (phase >= 0.99999) {
|
||||
return { kind: 'finished' };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2018-2020 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,31 +23,7 @@ export const PlyProvider = DataFormatProvider({
|
||||
.to(data)
|
||||
.apply(StateTransforms.Data.ParsePly, {}, { state: { isGhost: true } });
|
||||
|
||||
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);
|
||||
const shape = format.apply(StateTransforms.Model.ShapeFromPly);
|
||||
|
||||
await format.commit();
|
||||
|
||||
@@ -63,7 +39,6 @@ export const ObjProvider = DataFormatProvider({
|
||||
|
||||
export const BuiltInShapeFormats = [
|
||||
['ply', PlyProvider] as const,
|
||||
['obj', ObjProvider] as const,
|
||||
] as const;
|
||||
|
||||
export type BuildInShapeFormat = (typeof BuiltInShapeFormats)[number][0]
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2018-2025 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,7 +10,6 @@ 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';
|
||||
@@ -80,7 +79,6 @@ export namespace PluginStateObject {
|
||||
export class Prmtop extends Create<PrmtopFile>({ name: 'PRMTOP File', typeClass: 'Data' }) { }
|
||||
export class Top extends Create<TopFile>({ name: 'TOP File', typeClass: 'Data' }) { }
|
||||
export class Ply extends Create<PlyFile>({ name: 'PLY File', typeClass: 'Data' }) { }
|
||||
export class Obj extends Create<ObjFile>({ name: 'OBJ File', typeClass: 'Data' }) { }
|
||||
export class Ccp4 extends Create<Ccp4File>({ name: 'CCP4/MRC/MAP File', typeClass: 'Data' }) { }
|
||||
export class Dsn6 extends Create<Dsn6File>({ name: 'DSN6/BRIX File', typeClass: 'Data' }) { }
|
||||
export class Dx extends Create<DxFile>({ name: 'DX File', typeClass: 'Data' }) { }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2018-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>
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -42,7 +41,6 @@ export { ParsePsf };
|
||||
export { ParsePrmtop };
|
||||
export { ParseTop };
|
||||
export { ParsePly };
|
||||
export { ParseObj };
|
||||
export { ParseCcp4 };
|
||||
export { ParseDsn6 };
|
||||
export { ParseDx };
|
||||
@@ -405,22 +403,6 @@ const ParsePly = PluginStateTransform.BuiltIn({
|
||||
}
|
||||
});
|
||||
|
||||
type ParseObj = typeof ParseObj
|
||||
const ParseObj = PluginStateTransform.BuiltIn({
|
||||
name: 'parse-obj',
|
||||
display: { name: 'Parse OBJ', description: 'Parse OBJ from String data' },
|
||||
from: [SO.Data.String],
|
||||
to: SO.Format.Obj
|
||||
})({
|
||||
apply({ a }) {
|
||||
return Task.create('Parse OBJ', async ctx => {
|
||||
const parsed = await OBJ.parseObj(a.data).runInContext(ctx);
|
||||
if (parsed.isError) throw new Error(parsed.message);
|
||||
return new SO.Format.Obj(parsed.result, { label: 'OBJ Data' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type ParseCcp4 = typeof ParseCcp4
|
||||
const ParseCcp4 = PluginStateTransform.BuiltIn({
|
||||
name: 'parse-ccp4',
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
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 { Vec3 } from '../../mol-math/linear-algebra';
|
||||
import { Mat4, Vec3 } from '../../mol-math/linear-algebra';
|
||||
import { shapeFromPly } from '../../mol-model-formats/shape/ply';
|
||||
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';
|
||||
@@ -21,7 +22,7 @@ import { PluginContext } from '../../mol-plugin/context';
|
||||
import { MolScriptBuilder } from '../../mol-script/language/builder';
|
||||
import { Expression } from '../../mol-script/language/expression';
|
||||
import { Script } from '../../mol-script/script';
|
||||
import { StateObject, StateTransformer } from '../../mol-state';
|
||||
import { StateObject, StateTransform, StateTransformer } from '../../mol-state';
|
||||
import { RuntimeContext, Task } from '../../mol-task';
|
||||
import { deepEqual } from '../../mol-util';
|
||||
import { ParamDefinition as PD } from '../../mol-util/param-definition';
|
||||
@@ -92,6 +93,7 @@ export { StructureComplexElement };
|
||||
export { StructureComponent };
|
||||
export { CustomModelProperties };
|
||||
export { CustomStructureProperties };
|
||||
export { ShapeFromPly };
|
||||
|
||||
type CoordinatesFromDcd = typeof CoordinatesFromDcd
|
||||
const CoordinatesFromDcd = PluginStateTransform.BuiltIn({
|
||||
@@ -245,6 +247,12 @@ const TrajectoryFromModelAndCoordinates = PluginStateTransform.BuiltIn({
|
||||
coordinatesRef: PD.Text('', { isHidden: true }),
|
||||
}
|
||||
})({
|
||||
getDependencies: ({ modelRef, coordinatesRef }: { modelRef: string, coordinatesRef: string }) => {
|
||||
const deps: StateTransform.Ref[] = [];
|
||||
if (modelRef) deps.push(modelRef as StateTransform.Ref);
|
||||
if (coordinatesRef) deps.push(coordinatesRef as StateTransform.Ref);
|
||||
return deps;
|
||||
},
|
||||
apply({ params, dependencies }) {
|
||||
return Task.create('Create trajectory from model/topology and coordinates', async ctx => {
|
||||
const coordinates = dependencies![params.coordinatesRef].data as Coordinates;
|
||||
@@ -1293,3 +1301,25 @@ async function attachStructureProps(structure: Structure, ctx: PluginContext, ta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ShapeFromPly = typeof ShapeFromPly
|
||||
const ShapeFromPly = PluginStateTransform.BuiltIn({
|
||||
name: 'shape-from-ply',
|
||||
display: { name: 'Shape from PLY', description: 'Create Shape from PLY data' },
|
||||
from: SO.Format.Ply,
|
||||
to: SO.Shape.Provider,
|
||||
params(a) {
|
||||
return {
|
||||
transforms: PD.Optional(PD.Value([Mat4.identity()], { isHidden: true })),
|
||||
label: PD.Optional(PD.Text('', { isHidden: true }))
|
||||
};
|
||||
}
|
||||
})({
|
||||
apply({ a, params }) {
|
||||
return Task.create('Create shape from PLY', async ctx => {
|
||||
const shape = await shapeFromPly(a.data, params).runInContext(ctx);
|
||||
const props = { label: params.label || 'Shape' };
|
||||
return new SO.Shape.Provider(shape, props);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2021-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2021 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';
|
||||
@@ -10,8 +9,6 @@ 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';
|
||||
@@ -70,49 +67,3 @@ 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import { volumeFromCube } from '../../mol-model-formats/volume/cube';
|
||||
import { volumeFromDx } from '../../mol-model-formats/volume/dx';
|
||||
import { Grid, Volume } from '../../mol-model/volume';
|
||||
import { PluginContext } from '../../mol-plugin/context';
|
||||
import { StateSelection, StateTransformer } from '../../mol-state';
|
||||
import { StateSelection, StateTransform, StateTransformer } from '../../mol-state';
|
||||
import { volumeFromSegmentationData } from '../../mol-model-formats/volume/segmentation';
|
||||
import { getTransformFromParams, TransformParam, transformParamsNeedCentroid } from './helpers';
|
||||
|
||||
@@ -233,7 +233,8 @@ const AssignColorVolume = PluginStateTransform.BuiltIn({
|
||||
const props = { label: a.label, description: 'Volume + Colors' };
|
||||
return new SO.Volume.Data(volume, props);
|
||||
});
|
||||
}
|
||||
},
|
||||
getDependencies: ({ ref }) => ref ? [ref as StateTransform.Ref] : []
|
||||
});
|
||||
|
||||
type VolumeTransform = typeof VolumeTransform;
|
||||
|
||||
324
src/mol-state/_spec/dependencies.spec.ts
Normal file
324
src/mol-state/_spec/dependencies.spec.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
|
||||
import { State, StateObject, StateObjectCell, StateTransform, StateTransformer, StateTreeCycleError } from '../../mol-state';
|
||||
import { Task } from '../../mol-task';
|
||||
import { ParamDefinition as PD } from '../../mol-util/param-definition';
|
||||
|
||||
interface TypeInfo { name: string; typeClass: 'Root' | 'Data' }
|
||||
const Create = StateObject.factory<TypeInfo>();
|
||||
|
||||
class Root extends Create({ name: 'Root', typeClass: 'Root' }) { }
|
||||
class Leaf extends Create<{ value: number }>({ name: 'Leaf', typeClass: 'Data' }) { }
|
||||
|
||||
const NS = 'state-deps-spec';
|
||||
let counter = 0;
|
||||
const uniq = (s: string) => `${s}-${counter++}`;
|
||||
|
||||
function newState() {
|
||||
return State.create(new Root({}), { runTask: <T>(t: Task<T>) => t.run() });
|
||||
}
|
||||
|
||||
/** Plain leaf created from Root with a number param. */
|
||||
function constLeaf() {
|
||||
return StateTransformer.create<Root, Leaf, { value: number }>(NS, {
|
||||
name: uniq('const-leaf'),
|
||||
from: [Root],
|
||||
to: [Leaf],
|
||||
display: { name: 'Const Leaf' },
|
||||
params: () => ({ value: PD.Numeric(0) }) as any,
|
||||
apply({ params }) { return new Leaf({ value: params.value }); },
|
||||
update({ oldParams, newParams }) {
|
||||
return oldParams.value === newParams.value
|
||||
? StateTransformer.UpdateResult.Unchanged
|
||||
: StateTransformer.UpdateResult.Recreate;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Leaf whose value is read from a single explicit dependsOn ref. */
|
||||
function deriveFromDep(depRef: string) {
|
||||
return StateTransformer.create<Root, Leaf, {}>(NS, {
|
||||
name: uniq('derive-from-dep'),
|
||||
from: [Root],
|
||||
to: [Leaf],
|
||||
display: { name: 'Derive From Dep' },
|
||||
params: () => ({}) as any,
|
||||
apply({ dependencies }) {
|
||||
const dep = dependencies?.[depRef] as Leaf;
|
||||
if (!dep) throw new Error('missing dep');
|
||||
return new Leaf({ value: dep.data.value + 100 });
|
||||
},
|
||||
update({ b, dependencies }) {
|
||||
const dep = dependencies?.[depRef] as Leaf;
|
||||
if (!dep) throw new Error('missing dep');
|
||||
(b.data as { value: number }).value = dep.data.value + 100;
|
||||
return StateTransformer.UpdateResult.Updated;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('State dependencies - linking', () => {
|
||||
it('explicit dependsOn establishes an edge and passes the dep object to apply', async () => {
|
||||
const state = newState();
|
||||
const A = constLeaf();
|
||||
const B = deriveFromDep('leaf-a');
|
||||
|
||||
const builder = state.build();
|
||||
builder.toRoot<Root>().apply(A as any, { value: 7 }, { ref: 'leaf-a' });
|
||||
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['leaf-a'] });
|
||||
await state.runTask(state.updateTree(builder));
|
||||
|
||||
const b = state.cells.get('leaf-b')!;
|
||||
expect(b.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
|
||||
expect((b.obj as Leaf).data.value).toBe(107);
|
||||
|
||||
const a = state.cells.get('leaf-a')!;
|
||||
expect(a.dependencies.dependentBy.map(c => c.transform.ref)).toEqual(['leaf-b']);
|
||||
});
|
||||
|
||||
it('re-evaluates dependents when the source updates', async () => {
|
||||
const state = newState();
|
||||
const A = constLeaf();
|
||||
const B = deriveFromDep('leaf-a');
|
||||
|
||||
const builder1 = state.build();
|
||||
builder1.toRoot<Root>().apply(A as any, { value: 1 }, { ref: 'leaf-a' });
|
||||
builder1.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['leaf-a'] });
|
||||
await state.runTask(state.updateTree(builder1));
|
||||
expect((state.cells.get('leaf-b')!.obj as Leaf).data.value).toBe(101);
|
||||
|
||||
const builder2 = state.build();
|
||||
builder2.to('leaf-a').update({ value: 5 });
|
||||
await state.runTask(state.updateTree(builder2));
|
||||
expect((state.cells.get('leaf-b')!.obj as Leaf).data.value).toBe(105);
|
||||
});
|
||||
|
||||
it('throws when an explicit dependsOn references a non-existent transform', async () => {
|
||||
const state = newState();
|
||||
const B = deriveFromDep('missing-ref');
|
||||
|
||||
const builder = state.build();
|
||||
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['missing-ref'] });
|
||||
await expect(state.runTask(state.updateTree(builder))).rejects.toThrow(/non-existent transform/);
|
||||
});
|
||||
|
||||
it('honors getDependencies(params) and relinks when params change', async () => {
|
||||
const state = newState();
|
||||
const A = constLeaf();
|
||||
const A2 = constLeaf();
|
||||
|
||||
const PickViaParams = StateTransformer.create<Root, Leaf, { which: string }>(NS, {
|
||||
name: uniq('pick-via-params'),
|
||||
from: [Root],
|
||||
to: [Leaf],
|
||||
display: { name: 'Pick' },
|
||||
params: () => ({ which: PD.Text('leaf-a') }) as any,
|
||||
getDependencies(params) { return params.which ? [params.which as StateTransform.Ref] : []; },
|
||||
apply({ params, dependencies }) {
|
||||
const dep = dependencies?.[params.which] as Leaf;
|
||||
return new Leaf({ value: dep ? dep.data.value : -1 });
|
||||
},
|
||||
update({ b, newParams, dependencies }) {
|
||||
const dep = dependencies?.[newParams.which] as Leaf;
|
||||
(b.data as { value: number }).value = dep ? dep.data.value : -1;
|
||||
return StateTransformer.UpdateResult.Updated;
|
||||
}
|
||||
});
|
||||
|
||||
const builder = state.build();
|
||||
builder.toRoot<Root>().apply(A as any, { value: 11 }, { ref: 'leaf-a' });
|
||||
builder.toRoot<Root>().apply(A2 as any, { value: 22 }, { ref: 'leaf-a2' });
|
||||
builder.toRoot<Root>().apply(PickViaParams as any, { which: 'leaf-a' }, { ref: 'pick' });
|
||||
await state.runTask(state.updateTree(builder));
|
||||
|
||||
const pick = state.cells.get('pick')!;
|
||||
expect(pick.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
|
||||
expect((pick.obj as Leaf).data.value).toBe(11);
|
||||
|
||||
const update = state.build();
|
||||
update.to('pick').update({ which: 'leaf-a2' });
|
||||
await state.runTask(state.updateTree(update));
|
||||
|
||||
const pick2 = state.cells.get('pick')!;
|
||||
expect(pick2.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a2']);
|
||||
expect((pick2.obj as Leaf).data.value).toBe(22);
|
||||
// Old source no longer reverse-linked.
|
||||
expect(state.cells.get('leaf-a')!.dependencies.dependentBy.length).toBe(0);
|
||||
expect(state.cells.get('leaf-a2')!.dependencies.dependentBy.map(c => c.transform.ref)).toEqual(['pick']);
|
||||
});
|
||||
|
||||
it('auto-collects refs from PD.ValueRef parameter values', async () => {
|
||||
const state = newState();
|
||||
const A = constLeaf();
|
||||
|
||||
const ViaValueRef = StateTransformer.create<Root, Leaf, { target: { ref: string, getValue: () => Leaf } }>(NS, {
|
||||
name: uniq('via-value-ref'),
|
||||
from: [Root],
|
||||
to: [Leaf],
|
||||
display: { name: 'Via ValueRef' },
|
||||
params: () => ({
|
||||
target: PD.ValueRef<Leaf>(() => [], (ref, getData) => getData(ref))
|
||||
}) as any,
|
||||
apply({ params, dependencies }) {
|
||||
const dep = dependencies?.[params.target.ref] as Leaf;
|
||||
return new Leaf({ value: dep ? dep.data.value * 2 : -1 });
|
||||
}
|
||||
});
|
||||
|
||||
const builder = state.build();
|
||||
builder.toRoot<Root>().apply(A as any, { value: 9 }, { ref: 'leaf-a' });
|
||||
builder.toRoot<Root>().apply(ViaValueRef as any, {
|
||||
target: { ref: 'leaf-a', getValue: () => null as any }
|
||||
}, { ref: 'vr' });
|
||||
await state.runTask(state.updateTree(builder));
|
||||
|
||||
const vr = state.cells.get('vr')!;
|
||||
expect(vr.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
|
||||
expect((vr.obj as Leaf).data.value).toBe(18);
|
||||
});
|
||||
|
||||
it('falls back to a structural scan when the schema is unavailable', async () => {
|
||||
const state = newState();
|
||||
const A = constLeaf();
|
||||
|
||||
// No `def.params` - params normalization will drop unknown fields at
|
||||
// evaluation time, but link-time collection (via the structural
|
||||
// fallback) still happens against the original transform.params.
|
||||
const Structural = StateTransformer.create<Root, Leaf, any>(NS, {
|
||||
name: uniq('structural'),
|
||||
from: [Root],
|
||||
to: [Leaf],
|
||||
display: { name: 'Structural' },
|
||||
apply({ dependencies }) {
|
||||
const ref = dependencies ? Object.keys(dependencies)[0] : undefined;
|
||||
const dep = ref ? dependencies![ref] as Leaf : undefined;
|
||||
return new Leaf({ value: dep ? dep.data.value + 1000 : -1 });
|
||||
}
|
||||
});
|
||||
|
||||
const builder = state.build();
|
||||
builder.toRoot<Root>().apply(A as any, { value: 3 }, { ref: 'leaf-a' });
|
||||
builder.toRoot<Root>().apply(Structural as any, {
|
||||
link: { ref: 'leaf-a', getValue: () => null }
|
||||
}, { ref: 'struct' });
|
||||
await state.runTask(state.updateTree(builder));
|
||||
|
||||
const s = state.cells.get('struct')!;
|
||||
expect(s.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
|
||||
expect((s.obj as Leaf).data.value).toBe(1003);
|
||||
});
|
||||
|
||||
it('filters out self and root refs from getDependencies', async () => {
|
||||
const state = newState();
|
||||
|
||||
const SelfRef = StateTransformer.create<Root, Leaf, {}>(NS, {
|
||||
name: uniq('self-ref'),
|
||||
from: [Root],
|
||||
to: [Leaf],
|
||||
display: { name: 'Self Ref' },
|
||||
params: () => ({}) as any,
|
||||
getDependencies() { return ['self', StateTransform.RootRef as any]; },
|
||||
apply() { return new Leaf({ value: 42 }); }
|
||||
});
|
||||
|
||||
const builder = state.build();
|
||||
builder.toRoot<Root>().apply(SelfRef as any, {}, { ref: 'self' });
|
||||
await state.runTask(state.updateTree(builder));
|
||||
|
||||
const cell = state.cells.get('self')!;
|
||||
expect(cell.dependencies.dependsOn.length).toBe(0);
|
||||
expect((cell.obj as Leaf).data.value).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('State dependencies - cycle detection', () => {
|
||||
it('throws StateTreeCycleError for a direct A → B → A cycle', async () => {
|
||||
const state = newState();
|
||||
|
||||
// Two transformers, each declaring a getDependencies pointing at the other.
|
||||
const A = StateTransformer.create<Root, Leaf, {}>(NS, {
|
||||
name: uniq('cycle-a'),
|
||||
from: [Root],
|
||||
to: [Leaf],
|
||||
display: { name: 'Cycle A' },
|
||||
params: () => ({}) as any,
|
||||
getDependencies() { return ['cyc-b' as any]; },
|
||||
apply() { return new Leaf({ value: 0 }); }
|
||||
});
|
||||
const B = StateTransformer.create<Root, Leaf, {}>(NS, {
|
||||
name: uniq('cycle-b'),
|
||||
from: [Root],
|
||||
to: [Leaf],
|
||||
display: { name: 'Cycle B' },
|
||||
params: () => ({}) as any,
|
||||
getDependencies() { return ['cyc-a' as any]; },
|
||||
apply() { return new Leaf({ value: 0 }); }
|
||||
});
|
||||
|
||||
const builder = state.build();
|
||||
builder.toRoot<Root>().apply(A as any, {}, { ref: 'cyc-a' });
|
||||
builder.toRoot<Root>().apply(B as any, {}, { ref: 'cyc-b' });
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await state.runTask(state.updateTree(builder));
|
||||
} catch (e) { caught = e; }
|
||||
expect(caught).toBeInstanceOf(StateTreeCycleError);
|
||||
const cycle = (caught as StateTreeCycleError).cycle;
|
||||
expect(cycle[0]).toBe(cycle[cycle.length - 1]);
|
||||
expect(cycle).toEqual(expect.arrayContaining(['cyc-a', 'cyc-b']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('State dependencies - deferred resolution', () => {
|
||||
/** Force evaluation order: place dependent subtree first under root so
|
||||
* tree pre-order visits it before its dependency. */
|
||||
it('resolves cross-subtree deps even when the dependent is scheduled first', async () => {
|
||||
const state = newState();
|
||||
const A = constLeaf();
|
||||
const B = deriveFromDep('leaf-a');
|
||||
|
||||
const builder = state.build();
|
||||
// B added FIRST - so its subtree comes before A's in tree pre-order.
|
||||
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['leaf-a'] });
|
||||
builder.toRoot<Root>().apply(A as any, { value: 4 }, { ref: 'leaf-a' });
|
||||
|
||||
await state.runTask(state.updateTree(builder));
|
||||
|
||||
expect((state.cells.get('leaf-a')!.obj as Leaf).data.value).toBe(4);
|
||||
expect((state.cells.get('leaf-b')!.obj as Leaf).data.value).toBe(104);
|
||||
});
|
||||
|
||||
it('propagates a clear error when a dep has errored and cannot resolve', async () => {
|
||||
const state = newState();
|
||||
|
||||
const Boom = StateTransformer.create<Root, Leaf, {}>(NS, {
|
||||
name: uniq('boom'),
|
||||
from: [Root],
|
||||
to: [Leaf],
|
||||
display: { name: 'Boom' },
|
||||
params: () => ({}) as any,
|
||||
apply() { throw new Error('intentional'); }
|
||||
});
|
||||
const B = deriveFromDep('boom');
|
||||
|
||||
const builder = state.build();
|
||||
builder.toRoot<Root>().apply(Boom as any, {}, { ref: 'boom' });
|
||||
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['boom'] });
|
||||
// The state surfaces transform errors via console.error; suppress the noise.
|
||||
const err = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await state.runTask(state.updateTree(builder));
|
||||
} finally {
|
||||
err.mockRestore();
|
||||
}
|
||||
|
||||
const b: StateObjectCell = state.cells.get('leaf-b')!;
|
||||
expect(b.status).toBe('error');
|
||||
expect(b.errorText).toMatch(/Unresolved dependency|missing dep|intentional/);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { StateObject, StateObjectCell, StateObjectSelector } from './object';
|
||||
@@ -24,7 +25,22 @@ import { arraySetAdd, arraySetRemove } from '../mol-util/array';
|
||||
import { UniqueArray } from '../mol-data/generic/unique-array';
|
||||
import { assignIfUndefined } from '../mol-util/object';
|
||||
|
||||
export { State };
|
||||
export { State, StateTreeCycleError };
|
||||
|
||||
/**
|
||||
* Thrown when a cycle is detected in the state-tree dependency graph
|
||||
* (the cross-edges defined by `transform.dependsOn` and effective
|
||||
* dependencies derived from params). `cycle` holds the closed path,
|
||||
* e.g. `['A', 'B', 'A']`.
|
||||
*/
|
||||
class StateTreeCycleError extends Error {
|
||||
readonly cycle: StateTransform.Ref[];
|
||||
constructor(cycle: StateTransform.Ref[]) {
|
||||
super(`Cyclic state-tree dependency detected: ${cycle.join(' -> ')}`);
|
||||
this.name = 'StateTreeCycleError';
|
||||
this.cycle = cycle;
|
||||
}
|
||||
}
|
||||
|
||||
class State {
|
||||
private _tree: TransientTree;
|
||||
@@ -494,6 +510,21 @@ interface UpdateContext {
|
||||
wasAborted: boolean,
|
||||
newCurrent?: Ref,
|
||||
|
||||
/**
|
||||
* Refs that are scheduled to be (re-)evaluated in this update pass:
|
||||
* the union of every `roots[i]` and its descendants. Used to distinguish
|
||||
* "dep not yet produced this pass" (defer + retry) from "dep can never
|
||||
* be produced this pass" (throw).
|
||||
*/
|
||||
scheduled?: Set<Ref>,
|
||||
|
||||
/**
|
||||
* Subtree roots that were skipped because at least one of their
|
||||
* dependencies hadn't been evaluated yet. Drained after the main loop
|
||||
* makes a fixpoint pass.
|
||||
*/
|
||||
deferred?: Ref[],
|
||||
|
||||
getCellData: (ref: string) => any
|
||||
}
|
||||
|
||||
@@ -573,11 +604,45 @@ async function update(ctx: UpdateContext) {
|
||||
// Set status of cells that will be updated to 'pending'.
|
||||
initCellStatus(ctx, roots);
|
||||
|
||||
// Build the set of refs that will be (re-)evaluated this pass so that
|
||||
// `updateSubtree` can distinguish "dep not produced yet" (defer + retry)
|
||||
// from "dep can never be produced" (throw via resolveDependencies).
|
||||
ctx.scheduled = collectScheduled(ctx, roots);
|
||||
|
||||
// Sequentially update all the subtrees.
|
||||
for (const root of roots) {
|
||||
await updateSubtree(ctx, root);
|
||||
}
|
||||
|
||||
// Drain the deferred queue: nodes whose `dependsOn` cells weren't yet
|
||||
// evaluated when first visited get retried until either all succeed or
|
||||
// a full pass makes no forward progress (true deadlock / unresolvable).
|
||||
if (ctx.deferred && ctx.deferred.length > 0) {
|
||||
while (ctx.deferred.length > 0) {
|
||||
const pending = ctx.deferred;
|
||||
ctx.deferred = [];
|
||||
let progress = false;
|
||||
for (const ref of pending) {
|
||||
const before = ctx.deferred.length;
|
||||
await updateSubtree(ctx, ref);
|
||||
// Forward progress = this attempt did not re-defer the same ref.
|
||||
const reDeferred = ctx.deferred.length > before
|
||||
&& ctx.deferred[ctx.deferred.length - 1] === ref;
|
||||
if (!reDeferred) progress = true;
|
||||
}
|
||||
if (!progress) {
|
||||
const stuck = ctx.deferred.map(r => {
|
||||
const c = ctx.cells.get(r);
|
||||
if (!c) return r;
|
||||
const blockers = pendingBlockers(ctx, c);
|
||||
return `${r} (waiting on: ${blockers.length ? blockers.join(', ') : 'unknown'})`;
|
||||
});
|
||||
ctx.deferred = [];
|
||||
throw new Error(`Unresolved dependency: ${stuck.join('; ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync cell states
|
||||
if (!ctx.editInfo) {
|
||||
syncNewStates(ctx);
|
||||
@@ -663,7 +728,13 @@ function setCellStatus(ctx: UpdateContext, ref: Ref, status: StateObjectCell.Sta
|
||||
}
|
||||
|
||||
function initCellStatusVisitor(t: StateTransform, _: any, ctx: UpdateContext) {
|
||||
ctx.cells.get(t.ref)!.transform = t;
|
||||
const cell = ctx.cells.get(t.ref)!;
|
||||
cell.transform = t;
|
||||
if (relinkCells(cell, ctx)) {
|
||||
// Edges changed (e.g. param update added/removed dependencies) —
|
||||
// re-verify there is no cycle.
|
||||
checkDependenciesCycle(cell);
|
||||
}
|
||||
setCellStatus(ctx, t.ref, 'pending');
|
||||
}
|
||||
|
||||
@@ -707,9 +778,10 @@ function addCellsVisitor(transform: StateTransform, _: any, { ctx, added, visite
|
||||
// type LinkCellsCtx = { ctx: UpdateContext, visited: Set<Ref>, dependent: UniqueArray<Ref, StateObjectCell> }
|
||||
|
||||
function linkCells(target: StateObjectCell, ctx: UpdateContext) {
|
||||
if (!target.transform.dependsOn) return;
|
||||
const effective = StateTransform.getEffectiveDependsOn(target.transform);
|
||||
if (effective.length === 0) return;
|
||||
|
||||
for (const ref of target.transform.dependsOn) {
|
||||
for (const ref of effective) {
|
||||
const t = ctx.tree.transforms.get(ref);
|
||||
if (!t) {
|
||||
throw new Error(`Cannot depend on a non-existent transform.`);
|
||||
@@ -721,6 +793,103 @@ function linkCells(target: StateObjectCell, ctx: UpdateContext) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff the current outgoing dependency edges of `target` against the effective
|
||||
* set derived from its (possibly updated) transform. Unlinks stale edges and
|
||||
* links new ones so the dependency graph reflects param changes. Idempotent
|
||||
* when nothing has changed. Returns `true` if any edge was added or removed.
|
||||
*/
|
||||
function relinkCells(target: StateObjectCell, ctx: UpdateContext): boolean {
|
||||
const effective = StateTransform.getEffectiveDependsOn(target.transform);
|
||||
const current = target.dependencies.dependsOn;
|
||||
|
||||
// Fast path: same number and all current refs are still in effective.
|
||||
if (current.length === effective.length) {
|
||||
let same = true;
|
||||
for (const c of current) {
|
||||
if (effective.indexOf(c.transform.ref) < 0) { same = false; break; }
|
||||
}
|
||||
if (same) return false;
|
||||
}
|
||||
|
||||
const desired = new Set(effective);
|
||||
let changed = false;
|
||||
|
||||
// Remove stale outgoing edges.
|
||||
for (let i = current.length - 1; i >= 0; i--) {
|
||||
const dep = current[i];
|
||||
if (!desired.has(dep.transform.ref)) {
|
||||
current.splice(i, 1);
|
||||
arraySetRemove(dep.dependencies.dependentBy, target);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new outgoing edges.
|
||||
const have = new Set(current.map(c => c.transform.ref));
|
||||
for (const ref of effective) {
|
||||
if (have.has(ref)) continue;
|
||||
const t = ctx.tree.transforms.get(ref);
|
||||
if (!t) {
|
||||
throw new Error(`Cannot depend on a non-existent transform.`);
|
||||
}
|
||||
const cell = ctx.cells.get(ref)!;
|
||||
arraySetAdd(target.dependencies.dependsOn, cell);
|
||||
arraySetAdd(cell.dependencies.dependentBy, target);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a cycle in the `dependsOn` graph reachable from `start`.
|
||||
*
|
||||
* Iterative DFS, returning the closed cycle path (first occurrence of the
|
||||
* repeated ref appended at the end) or `undefined` if none. Operates on the
|
||||
* already-linked cell graph; safe to call after `linkCells` / `relinkCells`.
|
||||
*/
|
||||
function detectDependenciesCycle(start: StateObjectCell): StateTransform.Ref[] | undefined {
|
||||
if (start.dependencies.dependsOn.length === 0) return void 0;
|
||||
const stack: { cell: StateObjectCell, idx: number }[] = [{ cell: start, idx: 0 }];
|
||||
const onPath = new Set<StateTransform.Ref>([start.transform.ref]);
|
||||
const fully = new Set<StateTransform.Ref>();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const top = stack[stack.length - 1];
|
||||
const deps = top.cell.dependencies.dependsOn;
|
||||
if (top.idx >= deps.length) {
|
||||
onPath.delete(top.cell.transform.ref);
|
||||
fully.add(top.cell.transform.ref);
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
const next = deps[top.idx++];
|
||||
const ref = next.transform.ref;
|
||||
if (fully.has(ref)) continue;
|
||||
if (onPath.has(ref)) {
|
||||
const path: StateTransform.Ref[] = [];
|
||||
let started = false;
|
||||
for (const s of stack) {
|
||||
if (started || s.cell.transform.ref === ref) {
|
||||
started = true;
|
||||
path.push(s.cell.transform.ref);
|
||||
}
|
||||
}
|
||||
path.push(ref);
|
||||
return path;
|
||||
}
|
||||
onPath.add(ref);
|
||||
stack.push({ cell: next, idx: 0 });
|
||||
}
|
||||
return void 0;
|
||||
}
|
||||
|
||||
function checkDependenciesCycle(cell: StateObjectCell) {
|
||||
const cycle = detectDependenciesCycle(cell);
|
||||
if (cycle) throw new StateTreeCycleError(cycle);
|
||||
}
|
||||
|
||||
function initCells(ctx: UpdateContext, roots: Ref[]) {
|
||||
const initCtx: InitCellsCtx = { ctx, visited: new Set(), added: [] };
|
||||
|
||||
@@ -734,6 +903,12 @@ function initCells(ctx: UpdateContext, roots: Ref[]) {
|
||||
linkCells(cell, ctx);
|
||||
}
|
||||
|
||||
// Cycle detection over the dependency cross-edges of newly added cells.
|
||||
// Parent/child is already a tree, so cycles can only enter via dependsOn.
|
||||
for (const cell of initCtx.added) {
|
||||
checkDependenciesCycle(cell);
|
||||
}
|
||||
|
||||
let dependent: UniqueArray<Ref, StateObjectCell>;
|
||||
|
||||
// Find dependent cells
|
||||
@@ -834,7 +1009,54 @@ type UpdateNodeResult =
|
||||
|
||||
const ParentNullErrorText = 'Parent is null';
|
||||
|
||||
function collectScheduledVisitor(t: StateTransform, _: any, s: Set<Ref>) {
|
||||
s.add(t.ref);
|
||||
}
|
||||
|
||||
function collectScheduled(ctx: UpdateContext, roots: Ref[]): Set<Ref> {
|
||||
const out = new Set<Ref>();
|
||||
for (const root of roots) {
|
||||
const node = ctx.tree.transforms.get(root);
|
||||
if (!node) continue;
|
||||
StateTree.doPreOrder(ctx.tree, node, out, collectScheduledVisitor);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refs that `cell` depends on which are scheduled for evaluation in this
|
||||
* pass but haven't produced an object yet (and aren't in an error state).
|
||||
* An empty result means deps are either ready, in error, or out-of-scope —
|
||||
* in any of those cases the cell should proceed (and may throw at
|
||||
* `resolveDependencies` time if the dep is genuinely missing).
|
||||
*/
|
||||
function pendingBlockers(ctx: UpdateContext, cell: StateObjectCell): Ref[] {
|
||||
const blockers: Ref[] = [];
|
||||
const scheduled = ctx.scheduled;
|
||||
if (!scheduled) return blockers;
|
||||
for (const dep of cell.dependencies.dependsOn) {
|
||||
if (dep.obj) continue;
|
||||
if (dep.status === 'error') continue;
|
||||
if (!scheduled.has(dep.transform.ref)) continue;
|
||||
blockers.push(dep.transform.ref);
|
||||
}
|
||||
return blockers;
|
||||
}
|
||||
|
||||
async function updateSubtree(ctx: UpdateContext, root: Ref) {
|
||||
const cell = ctx.cells.get(root);
|
||||
if (cell && cell.dependencies.dependsOn.length > 0) {
|
||||
const blockers = pendingBlockers(ctx, cell);
|
||||
if (blockers.length > 0) {
|
||||
// A dependency hasn't been produced yet this pass — defer this
|
||||
// subtree and retry after other roots have run. Children will be
|
||||
// visited when the deferred re-attempt succeeds.
|
||||
if (!ctx.deferred) ctx.deferred = [];
|
||||
ctx.deferred.push(root);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setCellStatus(ctx, root, 'processing');
|
||||
|
||||
let isNull = false;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { StateTransformer } from './transformer';
|
||||
import { UUID } from '../mol-util';
|
||||
import { hashMurmur128o } from '../mol-data/util/hash-functions';
|
||||
import { ParamDefinition as PD } from '../mol-util/param-definition';
|
||||
import { arraySetAdd, arraySetRemove } from '../mol-util/array';
|
||||
|
||||
export { Transform as StateTransform };
|
||||
|
||||
@@ -170,6 +173,97 @@ namespace Transform {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default param schema for the transform's transformer, caching
|
||||
* the result on the transform instance. Returns `undefined` if the transformer
|
||||
* has no `params` definition or if calling it throws.
|
||||
*/
|
||||
export function tryGetDefaultParamDefinition(t: Transform): PD.Params | undefined {
|
||||
const any = t as any;
|
||||
if (any._defaultSchemaSet) return any._defaultSchema;
|
||||
any._defaultSchemaSet = true;
|
||||
try {
|
||||
const def = t.transformer.definition;
|
||||
if (def.params) {
|
||||
any._defaultSchema = def.params(undefined as any, undefined) as PD.Params;
|
||||
}
|
||||
} catch {
|
||||
// Schema could not be obtained.
|
||||
}
|
||||
return any._defaultSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effective set of sibling-like dependencies for a transform.
|
||||
*
|
||||
* Combines (in order, de-duplicated):
|
||||
* 1. Explicit `t.dependsOn` (back-compat / non-param refs).
|
||||
* 2. Refs from `transformer.definition.getDependencies(params)` if defined.
|
||||
* 3. Refs collected from `PD.ValueRef` / `PD.DataRef` parameter values.
|
||||
*
|
||||
* Self-references and the root ref are filtered out. If the param schema
|
||||
* can't be obtained, auto-derivation falls back to a structural scan of
|
||||
* parameter values for `{ ref, getValue }` shaped objects.
|
||||
*/
|
||||
export function getEffectiveDependsOn(t: Transform): Ref[] {
|
||||
const out: string[] = [];
|
||||
|
||||
if (t.dependsOn) {
|
||||
for (const r of t.dependsOn) {
|
||||
arraySetAdd(out, r);
|
||||
}
|
||||
}
|
||||
|
||||
const def = t.transformer.definition;
|
||||
const params = t.params as any;
|
||||
|
||||
if (def.getDependencies && params) {
|
||||
try {
|
||||
const extra = def.getDependencies(params);
|
||||
if (extra) {
|
||||
for (const r of extra) {
|
||||
arraySetAdd(out, r);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep reconciliation robust if a user hook misbehaves.
|
||||
}
|
||||
}
|
||||
|
||||
if (params) {
|
||||
const schema = tryGetDefaultParamDefinition(t);
|
||||
if (schema) {
|
||||
PD.collectRefs(schema, params, out);
|
||||
} else {
|
||||
collectStructuralRefs(params, out);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out self-references and the root ref.
|
||||
arraySetRemove(out, t.ref);
|
||||
arraySetRemove(out, RootRef);
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectStructuralRefs(value: any, out: string[], depth = 0): string[] {
|
||||
if (!value || typeof value !== 'object' || depth > 6) return out;
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
collectStructuralRefs(v, out, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const ref = (value as any).ref;
|
||||
if (typeof ref === 'string' && typeof (value as any).getValue === 'function') {
|
||||
arraySetAdd(out, ref);
|
||||
return out;
|
||||
}
|
||||
for (const k of Object.keys(value)) {
|
||||
collectStructuralRefs(value[k], out, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const _emptyParams = {};
|
||||
/** Updates the version of the transform to be computed as hash of the parameters */
|
||||
export function setParamsHashVersion(t: Transform) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { Task } from '../mol-task';
|
||||
@@ -118,6 +119,16 @@ namespace Transformer {
|
||||
|
||||
/** Custom conversion to and from JSON */
|
||||
readonly customSerialization?: { toJSON(params: P, obj?: B): any, fromJSON(data: any): P }
|
||||
|
||||
/**
|
||||
* Derive sibling-like state-tree dependencies (other cells' refs) from the
|
||||
* current parameter values. Returned refs are merged with explicit
|
||||
* `dependsOn` and any refs auto-collected from `PD.ValueRef` / `PD.DataRef`
|
||||
* parameters to form the effective dependency set used by reconciliation.
|
||||
*
|
||||
* Return an empty array or undefined to opt out.
|
||||
*/
|
||||
getDependencies?(params: P): StateTransform.Ref[] | undefined
|
||||
}
|
||||
|
||||
export interface Definition<A extends StateObject = StateObject, B extends StateObject = StateObject, P extends {} = {}> extends DefinitionBase<A, B, P> {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
* @author Gianluca Tomasello <giagitom@gmail.com>
|
||||
*/
|
||||
|
||||
export { PixelData };
|
||||
@@ -38,14 +37,12 @@ namespace PixelData {
|
||||
/** to undo pre-multiplied alpha */
|
||||
export function divideByAlpha(pixelData: PixelData): PixelData {
|
||||
const { array } = pixelData;
|
||||
// clamp: emissive, bloom and antialiasing can lift premul RGB above alpha; without it Uint8Array silently wraps.
|
||||
const max = (array instanceof Uint8Array) ? 255 : 1;
|
||||
const factor = (array instanceof Uint8Array) ? 255 : 1;
|
||||
for (let i = 0, il = array.length; i < il; i += 4) {
|
||||
const a = array[i + 3] / max;
|
||||
if (a === 0) continue;
|
||||
array[i] = Math.min(max, array[i] / a);
|
||||
array[i + 1] = Math.min(max, array[i + 1] / a);
|
||||
array[i + 2] = Math.min(max, array[i + 2] / a);
|
||||
const a = array[i + 3] / factor;
|
||||
array[i] /= a;
|
||||
array[i + 1] /= a;
|
||||
array[i + 2] /= a;
|
||||
}
|
||||
return pixelData;
|
||||
}
|
||||
|
||||
@@ -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 Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
@@ -18,6 +18,7 @@ import { getColorListFromName, ColorListName } from './color/lists';
|
||||
import { Asset } from './assets';
|
||||
import { ColorListEntry } from './color/color';
|
||||
import { EPSILON } from '../mol-math/linear-algebra/3d/common';
|
||||
import { arraySetAdd } from './array';
|
||||
|
||||
export namespace ParamDefinition {
|
||||
export interface Info {
|
||||
@@ -445,6 +446,47 @@ export namespace ParamDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
function collectRefValue(p: Any, value: any, out: string[]): string[] {
|
||||
if (value === undefined || value === null) return out;
|
||||
|
||||
if (p.type === 'value-ref' || p.type === 'data-ref') {
|
||||
const v = value as ValueRef['defaultValue'];
|
||||
if (v && typeof v.ref === 'string' && v.ref) {
|
||||
arraySetAdd(out, v.ref);
|
||||
}
|
||||
} else if (p.type === 'group') {
|
||||
collectRefsImpl(p.params, value, out);
|
||||
} else if (p.type === 'mapped') {
|
||||
const v = value as NamedParams;
|
||||
if (!v) return out;
|
||||
const param = p.map(v.name);
|
||||
collectRefValue(param, v.params, out);
|
||||
} else if (p.type === 'object-list') {
|
||||
if (!hasValueRef(p.element)) return out;
|
||||
for (const e of value) {
|
||||
collectRefsImpl(p.element, e, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectRefsImpl(params: Params, values: any, out: string[]): string[] {
|
||||
for (const n of Object.keys(params)) {
|
||||
collectRefValue(params[n], values?.[n], out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all non-empty `ref` strings of `value-ref` and `data-ref` parameter
|
||||
* values into a set. Used by `mol-state` to derive transform dependencies from
|
||||
* parameter values.
|
||||
*/
|
||||
export function collectRefs(params: Params, values: any, out: string[]): string[] {
|
||||
if (!params || !values) return out;
|
||||
return collectRefsImpl(params, values, out);
|
||||
}
|
||||
|
||||
export function setDefaultValues<T extends Params>(params: T, defaultValues: Values<T>) {
|
||||
for (const k of Object.keys(params)) {
|
||||
if (params[k].isOptional) continue;
|
||||
|
||||
Reference in New Issue
Block a user