Compare commits

...

243 Commits

Author SHA1 Message Date
Alexander Rose
a3b54ff88c lint/format 2026-05-09 22:08:08 -07:00
Alexander Rose
3e7614d75c move spec 2026-05-09 17:37:47 -07:00
Alexander Rose
a01e8f26bd changelog 2026-05-09 17:33:31 -07:00
Alexander Rose
351faf3c45 Merge branch 'master' of https://github.com/molstar/molstar into pr/russell-taylor/1806 2026-05-09 17:32:53 -07:00
Russ Taylor
f500372c16 Re-styling the Kinemage extension right-hand UI to better match MolStar style 2026-05-05 08:50:17 -04:00
Russ Taylor
2714d32e15 Implementing @pointmaster behavior properly. 2026-05-04 12:20:18 -04:00
Russ Taylor
2d400d9166 Updating README 2026-04-27 10:36:01 -04:00
Russ Taylor
ebceecb3e6 Set background color to black when selecting a Kinemage view 2026-04-27 10:33:01 -04:00
Russ Taylor
a87f92bf7d Adding * in front of animation groups 2026-04-27 10:27:20 -04:00
Russ Taylor
4033bc93c2 Adding subgroup visibility controls under groups when appropriate 2026-04-27 10:23:49 -04:00
Russ Taylor
6c4ba7af61 Removing unused activeKinemage index 2026-04-27 10:13:49 -04:00
Russ Taylor
bc9584e49b Removing global state and using Transforms instead. Unregistering right-hand-side GUI objects when their associated State Tree objects are deleted. 2026-04-27 10:00:19 -04:00
Russ Taylor
550d898c4f Renaming file and removing commented-out code. 2026-04-20 14:44:49 -04:00
Russ Taylor
5e16c340dc Converting Kinemage parser to using Color rather than number[] and moving HSV conversion into standard location 2026-04-20 14:34:05 -04:00
Russ Taylor
bcb18a8faf Reverting another change no longer needed. 2026-04-20 11:32:31 -04:00
Russ Taylor
cd7d8f704e Reverting changes made to get things to compile on an earlier master branch 2026-04-20 11:25:10 -04:00
Russ Taylor
0d197b2dc5 Merge branch 'kinemage-rebase' of https://github.com/ReliaSolve/molstar into kinemage-rebase 2026-04-20 09:38:26 -04:00
Russ Taylor
b3ce268f0e Reverting changes to central files that I thought I had to make to get the code to compile on a previous master checkout. The repository compiles without them now. 2026-04-20 09:37:51 -04:00
Russ Taylor
2da02daadc Comment change 2026-04-20 09:29:06 -04:00
Russ Taylor
999e5a47af Comment and README changes 2026-04-20 09:29:06 -04:00
Russ Taylor
ff3fad0789 Allow loading of multiple kinemages, seeing the controls for all of them. 2026-04-20 09:29:06 -04:00
Russ Taylor
5af6265c07 Putting back animation controls and maintaining views across visibility changes 2026-04-20 09:29:06 -04:00
Russ Taylor
4ad7a96191 Visibility of Kinemage controls now working and they are showing up in the right-hand control panel. 2026-04-20 09:29:06 -04:00
Russ Taylor
71215d183d Continuing to implement controls on the right. 2026-04-20 09:29:06 -04:00
Russ Taylor
e864f13a66 Starting down the path of moving the Kinemage GUI controls to the right-side panel. Puts the placeholder there but now shows only part of the geometry and does not see any Kinemage data. 2026-04-20 09:29:06 -04:00
Russ Taylor
59298be573 Moving kin.ts into extensions/kinemage 2026-04-20 09:29:06 -04:00
Russ Taylor
2a57867167 Moving reader code for kinemage into its extensions directory 2026-04-20 09:29:06 -04:00
Russ Taylor
a817a70b46 Revering whitespace edits. 2026-04-20 09:29:06 -04:00
Russ Taylor
5afb7981ae Removing grammar fix and carriage return at end of file. 2026-04-20 09:29:06 -04:00
Russ Taylor
2308dfd22e Removing extra line added to file. 2026-04-20 09:29:06 -04:00
Russ Taylor
f0de2cea2c Fixing author tags in documents 2026-04-20 09:29:06 -04:00
Russ Taylor
8f9c687935 Removing obsolete entry 2026-04-20 09:29:06 -04:00
Russ Taylor
1f56405d79 Updating contributer documentation 2026-04-20 09:29:06 -04:00
Russ Taylor
37af8c66a1 This version requires us not to flip the winding numbers of every other triangle so that the colors match. 2026-04-20 09:29:06 -04:00
Russ Taylor
a1fefa2efa Adding KinemageExtension to viewer app 2026-04-20 09:29:06 -04:00
Russ Taylor
958f3011e4 Fixing assignments to handle strings or string arrays to allow the code to compile 2026-04-20 09:29:03 -04:00
Russ Taylor
8d48fa67ae Removing initial plugin-based Kinemage reader stubs, leaving the extension that handles both File/Open and drag-and-drop 2026-04-20 09:27:41 -04:00
Russ Taylor
87158db7c0 Removing obsolete @todo comments 2026-04-20 09:27:41 -04:00
Russ Taylor
bac65cc71e Animation toggles visibility checkboxes on the groups as it runs. 2026-04-20 09:27:41 -04:00
Russ Taylor
3ece0c74c6 Strating down the path of handling GUI updates with animation 2026-04-20 09:27:41 -04:00
Russ Taylor
89fbb690fe Adding animate and 2animate buttons that do not yet adjust the GUI state to track the changes 2026-04-20 09:27:41 -04:00
Russ Taylor
918d02fec4 Make the Transforms associated with the geometry into ghosts so they don't show up in the GUI 2026-04-20 09:27:41 -04:00
Russ Taylor
8af3a240b5 Destroy old objects when we change visibility rather than just hiding them 2026-04-20 09:27:41 -04:00
Russ Taylor
a80284ac02 Fixing parsing of nobutton tag on list 2026-04-20 09:27:41 -04:00
Russ Taylor
1b48f4c32a Removing spurious declaration 2026-04-20 09:27:41 -04:00
Russ Taylor
1055eab4c5 Removing unused parameter 2026-04-20 09:27:41 -04:00
Russ Taylor
d4445cef5c Turning all but the first group that is in animate off 2026-04-20 09:27:41 -04:00
Russ Taylor
063d327a5f Updating README 2026-04-20 09:27:41 -04:00
Russ Taylor
34f409e683 Handling 'nobutton' keyword when parsing and also fixing the display of GUI elements 2026-04-20 09:27:41 -04:00
Russ Taylor
8415ed1b92 Orders GUI elements so that subgroups after their group 2026-04-20 09:27:41 -04:00
Russ Taylor
c92147289e Adding subgroup visibility controls 2026-04-20 09:27:41 -04:00
Russ Taylor
5b16213cd2 Cleaning up the visibility calculations for both masters and groups 2026-04-20 09:27:41 -04:00
Russ Taylor
bbd36e1838 Adding group visibility controls. 2026-04-20 09:27:41 -04:00
Russ Taylor
1a0b30d6eb Updating Kinemage README with new capabilities 2026-04-20 09:27:41 -04:00
Russ Taylor
e309e8917a Split each vector in half, label and color each half by the nearest endpoint. This makes the pop-up labels match what is expected 2026-04-20 09:27:41 -04:00
Russ Taylor
14135b8386 Ghosting the visibility controls for shapes in kinemages because they will be controlled by the masters and groups 2026-04-20 09:27:41 -04:00
Russ Taylor
3230a6a7dc Don't repeat kinemage construction when a later file is loaded 2026-04-20 09:27:41 -04:00
Russ Taylor
34ebc5ab7a Keep the viewpoint from changing when we make masters visible and invisible 2026-04-20 09:27:41 -04:00
Russ Taylor
d8b62c5cbb Removing obsolete view code 2026-04-20 09:27:41 -04:00
Russ Taylor
358ef44780 Master visibility now working, though it causes view recentering. Removed spurious calls from view adjustment but still happening 2026-04-20 09:27:41 -04:00
Russ Taylor
fb2f79a395 Factoring out shape creation function so we can call it again later. Keeping track of kinData 2026-04-20 09:27:41 -04:00
Russ Taylor
e3a95e0a08 Keeping track of the shapes that are created for a kinemage 2026-04-20 09:27:41 -04:00
Russ Taylor
a1b09ccc1c Adding group and subgroup visibility calculations to kinemage files 2026-04-20 09:27:41 -04:00
Russ Taylor
26b31b3fcc Adding off entry for groups and subgroups that defaults to false 2026-04-20 09:27:41 -04:00
Russ Taylor
3027418d31 Control the geometry generation in kinemages based on the visibility of masters for each list. This is not yet tied into changes caused by the visibility buttons, but it now respects the initial states of the masters in the kinemage file. 2026-04-20 09:27:41 -04:00
Russ Taylor
5a69fb691d Updating default visibility when parsing kinemage files. Adding master controls whose visibility icons toggle the state. This does not yet change the visibility of objects in the scene 2026-04-20 09:27:41 -04:00
Russ Taylor
cced98c93f Separating the parsing and geometry generation for kinemages 2026-04-20 09:27:41 -04:00
Russ Taylor
6c299161fe Removing obsolete comment 2026-04-20 09:27:41 -04:00
Russ Taylor
10575ac361 Updating README 2026-04-20 09:27:41 -04:00
Russ Taylor
d715330d8e Tweak 2026-04-20 09:27:41 -04:00
Russ Taylor
fdba049982 Changing the name of the view selection GUI elements to match the view that they provide. 2026-04-20 09:27:41 -04:00
Russ Taylor
270d7386b2 Transposing the orientation matrix to match Mol* orientation 2026-04-20 09:27:41 -04:00
Russ Taylor
a28c2f0995 Adding GUI elements to select Views when they are present in the Kinemage file. 2026-04-20 09:27:41 -04:00
Russ Taylor
196e17ff0d Constructing Camera.Snapshot objects for each Kinemage View. 2026-04-20 09:27:41 -04:00
Russ Taylor
c7efac0a78 Adding parsing of view parameters from Kinemage 2026-04-20 09:27:41 -04:00
Russ Taylor
75eb04070c Updating README 2026-04-20 09:27:41 -04:00
Russ Taylor
d6c9ae1fbe Naming the GUI elements after the PDB file if it is specified in the Kinemage file 2026-04-20 09:27:41 -04:00
Russ Taylor
24a6403025 Enabling specifying the name of a geometry type loaded by a Kinemage. Not adding entries for object types that are empty lists 2026-04-20 09:27:41 -04:00
Russ Taylor
842e5d890e Simplifying function 2026-04-20 09:27:41 -04:00
Russ Taylor
b34d1cca00 Removing unused objects 2026-04-20 09:27:41 -04:00
Russ Taylor
af4dc090c4 Removing unused objects left over from original code copied from 2026-04-20 09:27:41 -04:00
Russ Taylor
1fa090d162 Removing usused Preset 2026-04-20 09:27:41 -04:00
Russ Taylor
4d5b749e3e Cleaning up unused objects 2026-04-20 09:27:41 -04:00
Russ Taylor
6a736eb89f Updating comments 2026-04-20 09:27:41 -04:00
Russ Taylor
cfead0481f Simplifying function 2026-04-20 09:27:41 -04:00
Russ Taylor
fbbd7e623e Removing de-duplication code 2026-04-20 09:27:41 -04:00
Russ Taylor
f16707b849 Hack of commenting out the visuals to make it only parse once 2026-04-20 09:27:41 -04:00
Russ Taylor
76b0b23c07 Wraps the text in a file when loading, but this causes it to be parsed twice. 2026-04-20 09:27:41 -04:00
Russ Taylor
d4a2bd7cba Starting to implement standard file loading for .kin files 2026-04-20 09:27:41 -04:00
Russ Taylor
c572feb1d2 Factoring out file loading from drag and drop handler 2026-04-20 09:27:41 -04:00
Russ Taylor
121f8eab3e Updating README 2026-04-20 09:27:41 -04:00
Russ Taylor
8c49b82c3d Updating README 2026-04-20 09:27:41 -04:00
Russ Taylor
ac7faf8524 Adding README.md for Kinemage extension 2026-04-20 09:27:41 -04:00
Russ Taylor
5d6d91a331 Making Kinemage ribbons have the same normal for every pair of triangles 2026-04-20 09:27:41 -04:00
Russ Taylor
d476db556d Adding labels to elements loaded from Kinemage 2026-04-20 09:27:41 -04:00
Russ Taylor
354d092834 Adding per-sphere coloring 2026-04-20 09:27:41 -04:00
Russ Taylor
5cde26a8e2 Adding per-dot coloring 2026-04-20 09:27:41 -04:00
Russ Taylor
b905178395 Fixing per-group coloring on meshes and cleaning up 2026-04-20 09:27:41 -04:00
Russ Taylor
543d014d0d Enabling support for coloring of ribbons, including rendering both sides with the same color. 2026-04-20 09:27:41 -04:00
Russ Taylor
a17afa59b3 Adding line coloring. 2026-04-20 09:27:41 -04:00
Russ Taylor
bd6be354d5 Handling @colorset lines in Kinemage. Reporting when we have an unrecognized list element. 2026-04-20 09:27:41 -04:00
Russ Taylor
2058d605c7 Setting Kinemage line radius as half the width, clamped to a minimum of 1.0 2026-04-20 09:27:41 -04:00
Russ Taylor
ba60188758 Fixing width code on vectors. Cleaning up color code 2026-04-20 09:27:40 -04:00
Russ Taylor
52f2ddf715 Adding control over line width. Allowing short forms of list names. Working on passing color through 2026-04-20 09:27:40 -04:00
Russ Taylor
aa20fffbfb Adding sphere generation for BallList in Kinemage files. Added reading of radius from list line to enable list-wide specification 2026-04-20 09:27:40 -04:00
Russ Taylor
b59d11c91a Fixing over-counting of points 2026-04-20 09:27:40 -04:00
Russ Taylor
8fdc29d048 Reducing the number of objects and commits 2026-04-20 09:27:40 -04:00
Russ Taylor
d52ea41051 Only reporting an opened file if we get a kinemage 2026-04-20 09:27:40 -04:00
Russ Taylor
852be261dd More work but less fragile 2026-04-20 09:27:40 -04:00
Russ Taylor
95e9a3012d Continued cleanup 2026-04-20 09:27:40 -04:00
Russ Taylor
dd0a45c154 Cleaning up 2026-04-20 09:27:40 -04:00
Russ Taylor
4692d63a2b More cleanup 2026-04-20 09:27:40 -04:00
Russ Taylor
0689ecabb6 Cleaning up nesting and variables 2026-04-20 09:27:40 -04:00
Russ Taylor
424f576e99 Also draws points for dotLists in Kinemage 2026-04-20 09:27:40 -04:00
Russ Taylor
4407994195 Kinemage drag-and-drop handler now shows both lines and ribbons 2026-04-20 09:27:40 -04:00
Russ Taylor
4c6331e72d Renaming the Kinemage shape provider pipeline to include the name lines so we can make separate ones for meshes and balls 2026-04-20 09:27:40 -04:00
Russ Taylor
825514dd10 First working Kinemage extension code that can draw lines from all drag-and-drop kinemages 2026-04-20 09:27:40 -04:00
Russ Taylor
8e2967b993 Changing name to kinemage 2026-04-20 09:27:40 -04:00
Russ Taylor
a46ba63e31 Fixing name on KinemageDataProvider 2026-04-20 09:27:40 -04:00
Russ Taylor
cf9fe99a81 Renaming for clarity 2026-04-20 09:27:40 -04:00
Russ Taylor
2909a209c3 Modifying the kinemage parser to be able to read more than one kinemage entry from the same file. Hooking in a drag and drop handler on .kin files that for now just stores things into a global variable. Adjusting the parsing of kinemage through the original plugin path to handle the new parsing. 2026-04-20 09:27:40 -04:00
Russ Taylor
1322882444 Duplicating ANVIL structure in a kinemage extension directory as a basis for a new Kinemage loading extension
Trying to stick with the master code, which has changed a lot.
2026-04-20 09:27:40 -04:00
Russ Taylor
119c548fa7 Initial implementation of converting vectors into lines. Still needs groups, colors, labels, etc. 2026-04-20 09:27:40 -04:00
Russ Taylor
539442f710 Initial construction of lines from vector lists. Still need to do multiple vector lists, colors, labels, and more. 2026-04-20 09:27:40 -04:00
Russ Taylor
8841f04af6 Removing copied PLY parsing and geometry generation code from KIN file reader. Passing the Kinemage data structure from the parser to the geometry generator. Stubs now in place for KIN with no geometry currently being generated. Also updated the kin.spec.ts file to check a Kinemage file. 2026-04-20 09:27:40 -04:00
Russ Taylor
8c2d3a577a Organizing and commenting list structures. Specifying types where known. 2026-04-20 09:27:40 -04:00
Russ Taylor
bed4b728d3 Moving interface definitions to scheme.ts for Kin file reader. 2026-04-20 09:27:40 -04:00
Russ Taylor
aa87acc0a7 Initial pull-in of NGL Kinemage parser code. It is called by parser.ts and counts of objects are printed. 2026-04-20 09:27:40 -04:00
Russ Taylor
7d1e2b44db Adding KIN loading to mesoscale app 2026-04-20 09:27:40 -04:00
Russ Taylor
c1c1badf62 Initial Kinemage commit that copies the PLY files and references to make it possible to load a PLY-formate file from a file with a KIN extension.
Overwriting package-lock.json
2026-04-20 09:27:40 -04:00
Russ Taylor
e270a83909 Comment change 2026-04-13 13:42:00 -04:00
Russ Taylor
a40b737c6f Comment and README changes 2026-04-13 13:40:52 -04:00
Russ Taylor
942533ed2b Allow loading of multiple kinemages, seeing the controls for all of them. 2026-04-13 13:28:24 -04:00
Russ Taylor
546f3cd3c5 Putting back animation controls and maintaining views across visibility changes 2026-04-13 13:23:02 -04:00
Russ Taylor
21597b1fdd Visibility of Kinemage controls now working and they are showing up in the right-hand control panel. 2026-04-13 12:29:27 -04:00
Russ Taylor
31d8568c1a Continuing to implement controls on the right. 2026-04-13 12:11:46 -04:00
Russ Taylor
3630cd14e8 Starting down the path of moving the Kinemage GUI controls to the right-side panel. Puts the placeholder there but now shows only part of the geometry and does not see any Kinemage data. 2026-04-13 11:18:07 -04:00
Russ Taylor
4f083f10e6 Moving kin.ts into extensions/kinemage 2026-04-13 09:02:59 -04:00
Russ Taylor
371ef984c0 Moving reader code for kinemage into its extensions directory 2026-04-13 08:58:27 -04:00
Russ Taylor
e2db1257cd Revering whitespace edits. 2026-04-13 08:36:49 -04:00
Russ Taylor
c812e72a1a Removing grammar fix and carriage return at end of file. 2026-04-13 08:32:35 -04:00
Russ Taylor
ef9b89820d Removing extra line added to file. 2026-04-13 08:30:47 -04:00
Russ Taylor
5fd453c77a Fixing author tags in documents 2026-04-03 17:28:29 -04:00
Russ Taylor
7f2b10674e Removing obsolete entry 2026-04-03 17:26:50 -04:00
Russ Taylor
238e5e0b88 Updating contributer documentation 2026-04-03 16:58:28 -04:00
Russ Taylor
1f26b5c339 This version requires us not to flip the winding numbers of every other triangle so that the colors match. 2026-04-03 16:51:55 -04:00
Russ Taylor
eac478e7cb Adding KinemageExtension to viewer app 2026-04-03 16:45:26 -04:00
Russ Taylor
d0c59fdc92 Fixing assignments to handle strings or string arrays to allow the code to compile 2026-04-03 16:26:28 -04:00
Russ Taylor
7e61bcad32 Overwriting package-lock.json based on new build 2026-04-03 13:56:49 -04:00
Russ Taylor
7e98870dce Removing initial plugin-based Kinemage reader stubs, leaving the extension that handles both File/Open and drag-and-drop 2026-04-03 13:30:45 -04:00
Russ Taylor
405dc0d90a Removing obsolete @todo comments 2026-04-03 13:30:45 -04:00
Russ Taylor
7a362c816e Animation toggles visibility checkboxes on the groups as it runs. 2026-04-03 13:30:45 -04:00
Russ Taylor
7e1396b74c Strating down the path of handling GUI updates with animation 2026-04-03 13:30:45 -04:00
Russ Taylor
68ad1ec065 Adding animate and 2animate buttons that do not yet adjust the GUI state to track the changes 2026-04-03 13:30:45 -04:00
Russ Taylor
430f8da44e Make the Transforms associated with the geometry into ghosts so they don't show up in the GUI 2026-04-03 13:30:45 -04:00
Russ Taylor
68866cd2de Destroy old objects when we change visibility rather than just hiding them 2026-04-03 13:30:45 -04:00
Russ Taylor
05888bec50 Fixing parsing of nobutton tag on list 2026-04-03 13:30:45 -04:00
Russ Taylor
65e1cb4a5d Removing spurious declaration 2026-04-03 13:30:45 -04:00
Russ Taylor
50f571b0d3 Removing unused parameter 2026-04-03 13:30:45 -04:00
Russ Taylor
d86b31edf8 Turning all but the first group that is in animate off 2026-04-03 13:30:45 -04:00
Russ Taylor
ec107352b4 Updating README 2026-04-03 13:30:45 -04:00
Russ Taylor
1d42d5a2d6 Handling 'nobutton' keyword when parsing and also fixing the display of GUI elements 2026-04-03 13:30:45 -04:00
Russ Taylor
02d1dcb9d9 Orders GUI elements so that subgroups after their group 2026-04-03 13:30:45 -04:00
Russ Taylor
d86c3621b7 Adding subgroup visibility controls 2026-04-03 13:30:45 -04:00
Russ Taylor
f2724491c2 Cleaning up the visibility calculations for both masters and groups 2026-04-03 13:30:45 -04:00
Russ Taylor
f4c84a6930 Adding group visibility controls. 2026-04-03 13:30:45 -04:00
Russ Taylor
0eb9b286b4 Updating Kinemage README with new capabilities 2026-04-03 13:30:45 -04:00
Russ Taylor
da006391da Split each vector in half, label and color each half by the nearest endpoint. This makes the pop-up labels match what is expected 2026-04-03 13:30:45 -04:00
Russ Taylor
130e33f8c3 Ghosting the visibility controls for shapes in kinemages because they will be controlled by the masters and groups 2026-04-03 13:30:45 -04:00
Russ Taylor
109e528d1c Don't repeat kinemage construction when a later file is loaded 2026-04-03 13:30:45 -04:00
Russ Taylor
5df69cd84a Keep the viewpoint from changing when we make masters visible and invisible 2026-04-03 13:30:45 -04:00
Russ Taylor
973afa2237 Removing obsolete view code 2026-04-03 13:30:45 -04:00
Russ Taylor
0088d3e1bf Master visibility now working, though it causes view recentering. Removed spurious calls from view adjustment but still happening 2026-04-03 13:30:45 -04:00
Russ Taylor
26e6a11fa8 Factoring out shape creation function so we can call it again later. Keeping track of kinData 2026-04-03 13:30:45 -04:00
Russ Taylor
056e2c5182 Keeping track of the shapes that are created for a kinemage 2026-04-03 13:30:45 -04:00
Russ Taylor
0e7cde24bc Adding group and subgroup visibility calculations to kinemage files 2026-04-03 13:30:45 -04:00
Russ Taylor
36ce262970 Adding off entry for groups and subgroups that defaults to false 2026-04-03 13:30:45 -04:00
Russ Taylor
289c8181c8 Control the geometry generation in kinemages based on the visibility of masters for each list. This is not yet tied into changes caused by the visibility buttons, but it now respects the initial states of the masters in the kinemage file. 2026-04-03 13:30:45 -04:00
Russ Taylor
eb0fd490d4 Updating default visibility when parsing kinemage files. Adding master controls whose visibility icons toggle the state. This does not yet change the visibility of objects in the scene 2026-04-03 13:30:45 -04:00
Russ Taylor
70c073c43c Separating the parsing and geometry generation for kinemages 2026-04-03 13:30:45 -04:00
Russ Taylor
0565df4df9 Removing obsolete comment 2026-04-03 13:30:45 -04:00
Russ Taylor
6dd425cb55 Updating README 2026-04-03 13:30:45 -04:00
Russ Taylor
c0f994a506 Tweak 2026-04-03 13:30:45 -04:00
Russ Taylor
63705ed158 Changing the name of the view selection GUI elements to match the view that they provide. 2026-04-03 13:30:45 -04:00
Russ Taylor
fda9069f17 Transposing the orientation matrix to match Mol* orientation 2026-04-03 13:30:45 -04:00
Russ Taylor
66bffd8403 Adding GUI elements to select Views when they are present in the Kinemage file. 2026-04-03 13:30:45 -04:00
Russ Taylor
4a7d83c85b Constructing Camera.Snapshot objects for each Kinemage View. 2026-04-03 13:30:45 -04:00
Russ Taylor
fef649ce09 Adding parsing of view parameters from Kinemage 2026-04-03 13:30:45 -04:00
Russ Taylor
794f81bb8e Updating README 2026-04-03 13:30:45 -04:00
Russ Taylor
bc5648620d Naming the GUI elements after the PDB file if it is specified in the Kinemage file 2026-04-03 13:30:45 -04:00
Russ Taylor
07897f57f3 Enabling specifying the name of a geometry type loaded by a Kinemage. Not adding entries for object types that are empty lists 2026-04-03 13:30:45 -04:00
Russ Taylor
77f756dfe0 Simplifying function 2026-04-03 13:30:44 -04:00
Russ Taylor
15eef7b688 Removing unused objects 2026-04-03 13:30:44 -04:00
Russ Taylor
5d0ba7504b Removing unused objects left over from original code copied from 2026-04-03 13:30:44 -04:00
Russ Taylor
8d59b5b814 Removing usused Preset 2026-04-03 13:30:44 -04:00
Russ Taylor
86871124d5 Cleaning up unused objects 2026-04-03 13:30:44 -04:00
Russ Taylor
1a328d98b6 Updating comments 2026-04-03 13:30:44 -04:00
Russ Taylor
fcbc3ab3d0 Simplifying function 2026-04-03 13:30:44 -04:00
Russ Taylor
f36093dad9 Removing de-duplication code 2026-04-03 13:30:44 -04:00
Russ Taylor
d4c2bb85cb Hack of commenting out the visuals to make it only parse once 2026-04-03 13:30:44 -04:00
Russ Taylor
d53c1e8e65 Wraps the text in a file when loading, but this causes it to be parsed twice. 2026-04-03 13:30:44 -04:00
Russ Taylor
f189d0bdab Starting to implement standard file loading for .kin files 2026-04-03 13:30:44 -04:00
Russ Taylor
395eddd927 Factoring out file loading from drag and drop handler 2026-04-03 13:30:44 -04:00
Russ Taylor
eb1d48a73c Updating README 2026-04-03 13:30:44 -04:00
Russ Taylor
38b0bb8d7d Updating README 2026-04-03 13:30:44 -04:00
Russ Taylor
0daffa6b57 Adding README.md for Kinemage extension 2026-04-03 13:30:44 -04:00
Russ Taylor
e1d5d369f1 Making Kinemage ribbons have the same normal for every pair of triangles 2026-04-03 13:30:44 -04:00
Russ Taylor
6b88acd2bc Adding labels to elements loaded from Kinemage 2026-04-03 13:30:44 -04:00
Russ Taylor
6384eac7e7 Adding per-sphere coloring 2026-04-03 13:30:44 -04:00
Russ Taylor
fd409ce27f Adding per-dot coloring 2026-04-03 13:30:44 -04:00
Russ Taylor
c21c9f5160 Fixing per-group coloring on meshes and cleaning up 2026-04-03 13:30:44 -04:00
Russ Taylor
71d00a22dd Enabling support for coloring of ribbons, including rendering both sides with the same color. 2026-04-03 13:30:44 -04:00
Russ Taylor
5554697028 Adding line coloring. 2026-04-03 13:30:44 -04:00
Russ Taylor
709ac8430a Handling @colorset lines in Kinemage. Reporting when we have an unrecognized list element. 2026-04-03 13:30:44 -04:00
Russ Taylor
2ee08f6161 Setting Kinemage line radius as half the width, clamped to a minimum of 1.0 2026-04-03 13:30:44 -04:00
Russ Taylor
3608578528 Fixing width code on vectors. Cleaning up color code 2026-04-03 13:30:44 -04:00
Russ Taylor
5114a211fd Adding control over line width. Allowing short forms of list names. Working on passing color through 2026-04-03 13:30:44 -04:00
Russ Taylor
8cc947c998 Adding sphere generation for BallList in Kinemage files. Added reading of radius from list line to enable list-wide specification 2026-04-03 13:30:44 -04:00
Russ Taylor
9105737834 Fixing over-counting of points 2026-04-03 13:30:44 -04:00
Russ Taylor
84bcbd1ca6 Reducing the number of objects and commits 2026-04-03 13:30:44 -04:00
Russ Taylor
144967dbd3 Only reporting an opened file if we get a kinemage 2026-04-03 13:30:44 -04:00
Russ Taylor
bdb33b9398 More work but less fragile 2026-04-03 13:30:44 -04:00
Russ Taylor
ade6ef5631 Continued cleanup 2026-04-03 13:30:44 -04:00
Russ Taylor
3bc60d1d59 Cleaning up 2026-04-03 13:30:44 -04:00
Russ Taylor
4068c45eb4 More cleanup 2026-04-03 13:30:44 -04:00
Russ Taylor
52d0ff4a67 Cleaning up nesting and variables 2026-04-03 13:30:44 -04:00
Russ Taylor
f346d15bef Also draws points for dotLists in Kinemage 2026-04-03 13:30:44 -04:00
Russ Taylor
b2ce1fc6fa Kinemage drag-and-drop handler now shows both lines and ribbons 2026-04-03 13:30:44 -04:00
Russ Taylor
4e331001ef Renaming the Kinemage shape provider pipeline to include the name lines so we can make separate ones for meshes and balls 2026-04-03 13:30:44 -04:00
Russ Taylor
7d67304e4c First working Kinemage extension code that can draw lines from all drag-and-drop kinemages 2026-04-03 13:30:44 -04:00
Russ Taylor
babda601cb Changing name to kinemage 2026-04-03 13:30:44 -04:00
Russ Taylor
8bdfff5e94 Fixing name on KinemageDataProvider 2026-04-03 13:30:44 -04:00
Russ Taylor
be4b408ddc Renaming for clarity 2026-04-03 13:30:44 -04:00
Russ Taylor
230697fbb4 Modifying the kinemage parser to be able to read more than one kinemage entry from the same file. Hooking in a drag and drop handler on .kin files that for now just stores things into a global variable. Adjusting the parsing of kinemage through the original plugin path to handle the new parsing. 2026-04-03 13:30:44 -04:00
Russ Taylor
78ab6b0c95 Duplicating ANVIL structure in a kinemage extension directory as a basis for a new Kinemage loading extension
Trying to stick with the master code, which has changed a lot.
2026-04-03 13:30:20 -04:00
Russ Taylor
1f0c24b58e Initial implementation of converting vectors into lines. Still needs groups, colors, labels, etc. 2026-04-03 13:27:53 -04:00
Russ Taylor
f5c587bfe5 Initial construction of lines from vector lists. Still need to do multiple vector lists, colors, labels, and more. 2026-04-03 13:27:53 -04:00
Russ Taylor
52b1c7e4d9 Removing copied PLY parsing and geometry generation code from KIN file reader. Passing the Kinemage data structure from the parser to the geometry generator. Stubs now in place for KIN with no geometry currently being generated. Also updated the kin.spec.ts file to check a Kinemage file. 2026-04-03 13:27:53 -04:00
Russ Taylor
e76d02bc8c Organizing and commenting list structures. Specifying types where known. 2026-04-03 13:27:53 -04:00
Russ Taylor
481b763049 Moving interface definitions to scheme.ts for Kin file reader. 2026-04-03 13:27:53 -04:00
Russ Taylor
7bfef2ae40 Initial pull-in of NGL Kinemage parser code. It is called by parser.ts and counts of objects are printed. 2026-04-03 13:27:53 -04:00
Russ Taylor
01fe10ebdc Adding KIN loading to mesoscale app 2026-04-03 13:27:53 -04:00
Russ Taylor
4e4b80a7b2 Initial Kinemage commit that copies the PLY files and references to make it possible to load a PLY-formate file from a file with a KIN extension.
Overwriting package-lock.json
2026-04-03 13:27:18 -04:00
14 changed files with 2832 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ Note that since we don't clearly distinguish between a public and private interf
- Add mesoscale representation preset
- Add presets option to `ObjectList` param definition
- Fix memory leak in `State.dispose()` not invoking transformer `dispose` callbacks for live cells
- Adds File/Open and drag-and-drop support for Kinemage files in the viewer app
## [v5.9.0] - 2026-05-03
- Fix edge case when `PluginSpec.animations` is empty

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author ReliaSolve <russ@reliasolve.com>
*/
import { parseKin } from '../reader/parser';
const kinString = `@kinemage 1
@caption probe.2.26.021123, run Tue Apr 23 14:49:17 2024
command: C:\tmp\cctbx_phenix\build\probe\exe\probe.exe -kin -mc -het -once -wat2wat -onlybadout -stdbonds water all 1ssxFH.pdb
@group dominant {dots}
@subgroup dominant {once dots}
@master {bad overlap}
@pointmaster 'O' {Hets contacts}
@vectorlist {x} color=red master={bad overlap}
{ O HOH 319 A}hotpink P 'O' 31.146,32.100,-1.425 {"}hotpink 'O' 31.015,32.234,-1.324
{"}hotpink P 'O' 31.607,32.750,-1.156 {"}hotpink 'O' 31.410,32.784,-1.097
{"}hotpink P 'O' 31.263,32.074,-1.185 {"}hotpink 'O' 31.117,32.209,-1.122
{ O BHOH 338 A}hotpink P 'O' 32.540,45.631,10.833 {"}hotpink 'O' 32.430,45.771,10.977
{"}hotpink P 'O' 32.316,45.500,10.828 {"}hotpink 'O' 32.230,45.689,10.998
{"}hotpink P 'O' 32.068,45.424,10.824 {"}hotpink 'O' 32.034,45.604,10.975
{"}hotpink P 'O' 32.729,45.605,11.052 {"}hotpink 'O' 32.572,45.765,11.173
`;
// @todo Replace with more complex kinemage
const kinComplexString = `@kinemage 1
@caption probe.2.26.021123, run Tue Apr 23 14:49:17 2024
command: C:\tmp\cctbx_phenix\build\probe\exe\probe.exe -kin -mc -het -once -wat2wat -onlybadout -stdbonds water all 1ssxFH.pdb
@group dominant {dots}
@subgroup dominant {once dots}
@master {bad overlap}
@pointmaster 'O' {Hets contacts}
@vectorlist {x} color=red master={bad overlap}
{ O HOH 319 A}hotpink P 'O' 31.146,32.100,-1.425 {"}hotpink 'O' 31.015,32.234,-1.324
{"}hotpink P 'O' 31.607,32.750,-1.156 {"}hotpink 'O' 31.410,32.784,-1.097
{"}hotpink P 'O' 31.263,32.074,-1.185 {"}hotpink 'O' 31.117,32.209,-1.122
{ O BHOH 338 A}hotpink P 'O' 32.540,45.631,10.833 {"}hotpink 'O' 32.430,45.771,10.977
{"}hotpink P 'O' 32.316,45.500,10.828 {"}hotpink 'O' 32.230,45.689,10.998
{"}hotpink P 'O' 32.068,45.424,10.824 {"}hotpink 'O' 32.034,45.604,10.975
{"}hotpink P 'O' 32.729,45.605,11.052 {"}hotpink 'O' 32.572,45.765,11.173
`;
describe('kin reader', () => {
it('basic', async () => {
const parsed = await parseKin(kinString).run();
if (parsed.isError) return;
if (parsed.result.length !== 1) return;
const kinemage = parsed.result[0];
const vectors = kinemage.vectorLists;
expect(vectors.length).toEqual(1);
const element = vectors[0];
expect(element.name).toEqual('x');
expect(element.position1Array.length).toEqual(7);
// TODO: Add more tests
expect.assertions(3);
});
it('complex', async () => {
const parsed = await parseKin(kinComplexString).run();
if (parsed.isError) return;
// TODO: Add more complex tests
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,305 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Russ Taylor <russ@reliasolve.com>
*/
/**
* Kinemage right-panel controls (right-panel only).
*
* Shows kinemage views, animate buttons, and group/subgroup/master toggles in the right inspector.
* Controls directly operate on the loaded kinemage runtime data and call exported helpers
* to rebuild visuals. No State Tree JSON nodes are created for these UI items.
*/
import * as React from 'react';
import { CollapsableState, CollapsableControls } from '../../mol-plugin-ui/base';
import { Camera } from '../../mol-canvas3d/camera';
import { applyViewSnapshot, rebuildShapesForKinemage } from './behavior';
import { Kinemage } from './reader/schema';
interface KinemageControlState extends CollapsableState {
isBusy: boolean
}
function nameFromString(s: string | undefined) {
// If this is undefined, return undefined.
if (!s) return undefined;
// Return up to the first 30 characters of the string.
return s.length > 30 ? s.substring(0, 30) + '...' : s;
}
export class KinemageControls extends CollapsableControls<{}, KinemageControlState> {
protected defaultState(): KinemageControlState {
return {
header: 'Kinemage',
isCollapsed: false,
isBusy: false,
// default hidden until a kinemage is present
isHidden: true,
brand: { accent: 'cyan', svg: undefined as any }
};
}
componentDidMount() {
// Listen for shape/state changes: when state tree cells are created or removed the visuals changed.
this.subscribe(this.plugin.state.data.events.cell.created, (e: any) => this.onCellCreated(e));
this.subscribe(this.plugin.state.data.events.cell.removed, () => this.onCellRemoved());
// also track cell state updates that may change labels / visibility
this.subscribe(this.plugin.state.data.events.cell.stateUpdated, () => this.forceUpdate());
// ensure initial visibility reflects current state
this.updateVisibility();
}
private onCellCreated(e: any) {
this.updateVisibility();
}
private onCellRemoved() {
this.updateVisibility();
}
private updateVisibility() {
const kinemages = this.getKinemageList();
this.setState({ isHidden: kinemages.length === 0 });
}
private getKinemageList(): Array<{ kinData: Kinemage, ref: string }> {
const result: Array<{ kinData: Kinemage, ref: string }> = [];
try {
const cells = (this.plugin.state.data as any).cells as Map<string, any>;
for (const [ref, entry] of cells) {
const obj = (entry as any).obj;
// Look for Format.Json nodes that contain kinData
if (obj && obj.data && (obj.data as any).kinData) {
result.push({ kinData: (obj.data as any).kinData, ref });
}
}
} catch (e) {
console.warn('Failed to enumerate kinemage nodes', e);
}
return result;
}
private async applyView(kinData: Kinemage, viewKey: string) {
const snap = (kinData as any).viewSnapshots?.[viewKey];
if (snap) {
await applyViewSnapshot(this.plugin, snap as Partial<Camera.Snapshot>);
}
}
private async toggleVisibility(kinData: Kinemage, kinRef: string, target: { type: 'group' | 'subgroup' | 'master', key: string }) {
try {
if (target.type === 'group') {
const g = kinData.groupDict[target.key];
if (g) g.off = !g.off;
} else if (target.type === 'subgroup') {
const s = kinData.subgroupDict[target.key];
if (s) s.off = !s.off;
} else {
const m = kinData.masterDict[target.key];
if (m) m.visible = !m.visible;
}
// Rebuild shapes for this kinemage using the state ref
await rebuildShapesForKinemage(this.plugin, { ref: kinRef } as any);
this.updateVisibility();
} catch (e) {
console.error('Failed to toggle kinemage visibility', e);
}
}
private async triggerAnimateForKin(kinData: Kinemage, kinRef: string, mode: 'animate' | '2animate') {
try {
if (mode === 'animate') {
kinData.activeAnimateGroup = (kinData.activeAnimateGroup + 1) % Math.max(1, kinData.groupsAnimate.length);
// Make only the active animate group visible, hide the others (if any)
for (let i = 0; i < kinData.groupsAnimate.length; i++) {
const groupName = kinData.groupsAnimate[i];
const groupInfo = kinData.groupDict[groupName];
if (groupInfo) {
groupInfo.off = (i !== kinData.activeAnimateGroup);
}
}
} else {
kinData.activeAnimateGroup2 = (kinData.activeAnimateGroup2 + 1) % Math.max(1, kinData.groupsAnimate2.length);
// Make only the active animate group visible, hide the others (if any)
for (let i = 0; i < kinData.groupsAnimate2.length; i++) {
const groupName = kinData.groupsAnimate2[i];
const groupInfo = kinData.groupDict[groupName];
if (groupInfo) {
groupInfo.off = (i !== kinData.activeAnimateGroup2);
}
}
}
// Rebuild shapes for this kinemage using the state ref
await rebuildShapesForKinemage(this.plugin, { ref: kinRef } as any);
this.updateVisibility();
} catch (e) {
console.error('Failed to trigger animate', e);
}
}
renderControls() {
const kins = this.getKinemageList();
if (kins.length === 0) return <div style={{ padding: '6px' }}>No Kinemage data</div>;
const blocks: React.ReactNode[] = [];
for (const { kinData, ref } of kins) {
const title = kinData.pdbfile || nameFromString(kinData.caption) || 'Kinemage';
const kinBlock: React.ReactNode[] = [];
// Title
kinBlock.push(
<div key={'title-' + title} style={{ padding: '6px', fontWeight: 'bold', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
{title}
</div>
);
// views
const viewEntries = Object.entries(kinData.viewDict || {});
if (viewEntries.length > 0) {
for (const [viewKey, viewObj] of viewEntries) {
const label = `View ${viewObj.name || `View ${viewKey}`}`;
kinBlock.push(
<div key={'view-' + title + '-' + viewKey} style={{ padding: '2px 6px' }}>
<button
className='msp-btn msp-btn-block'
onClick={() => this.applyView(kinData, viewKey)}
title={`Apply view: ${label}`}
>
{label}
</button>
</div>
);
}
}
// animate
if (kinData.groupsAnimate && kinData.groupsAnimate.length > 0) {
kinBlock.push(
<div key={'anim-' + title} style={{ padding: '2px 6px' }}>
<button
className='msp-btn msp-btn-block'
onClick={() => this.triggerAnimateForKin(kinData, ref, 'animate')}
title='Cycle through animation frames'
>
Animate
</button>
</div>
);
}
if (kinData.groupsAnimate2 && kinData.groupsAnimate2.length > 0) {
kinBlock.push(
<div key={'anim2-' + title} style={{ padding: '2px 6px' }}>
<button
className='msp-btn msp-btn-block'
onClick={() => this.triggerAnimateForKin(kinData, ref, '2animate')}
title='Cycle through second animation frames'
>
Animate2
</button>
</div>
);
}
// groups
for (const [groupKey, groupInfo] of Object.entries(kinData.groupDict || {})) {
if (!(groupInfo as any).nobutton) {
const visible = !(groupInfo as any).off;
// If this group is in animate or animate2, then add '*' before its groupKey name to indicate that it's an animation group
const isAnimate = kinData.groupsAnimate?.includes(groupKey) || kinData.groupsAnimate2?.includes(groupKey);
const label = isAnimate ? `* ${groupKey}` : groupKey;
kinBlock.push(
<div key={'group-' + title + '-' + groupKey} style={{ padding: '2px 6px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(kinData, ref, { type: 'group', key: groupKey })}
style={{ marginRight: '6px' }}
/>
<span title={label}>{label}</span>
</label>
</div>
);
}
// If this group is not dominant, find any subgroups of this group and show them here (indented) unless they have nobutton set
if (!(groupInfo as any).dominant) {
for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict || {})) {
if (subgroupKey.startsWith(groupKey + ':')) {
if ((subgroupInfo as any).nobutton) continue;
const visible = !(subgroupInfo as any).off;
const subgroupLabel = subgroupKey.split(':')[1];
kinBlock.push(
<div key={'subgroup-' + title + '-' + subgroupKey} style={{ padding: '2px 6px', paddingLeft: '24px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(kinData, ref, { type: 'subgroup', key: subgroupKey })}
style={{ marginRight: '6px' }}
/>
<span title={subgroupLabel}>{subgroupLabel}</span>
</label>
</div>
);
}
}
}
}
// subgroups that don't belong to a group (standalone)
for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict || {})) {
// if parent group present, those groups' subgroups are already shown when iterating groups
if (subgroupKey.indexOf(':') !== -1) {
// subgroups with parent group; skip here (shown under parent group)
continue;
}
if ((subgroupInfo as any).nobutton) continue;
const visible = !(subgroupInfo as any).off;
kinBlock.push(
<div key={'subgroup-' + title + '-' + subgroupKey} style={{ padding: '2px 6px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(kinData, ref, { type: 'subgroup', key: subgroupKey })}
style={{ marginRight: '6px' }}
/>
<span title={subgroupKey}>{subgroupKey}</span>
</label>
</div>
);
}
// masters
for (const [masterKey, masterInfo] of Object.entries(kinData.masterDict || {})) {
const visible = !!(masterInfo && (masterInfo as any).visible);
kinBlock.push(
<div key={'master-' + title + '-' + masterKey} style={{ padding: '2px 6px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type='checkbox'
checked={visible}
onChange={() => this.toggleVisibility(kinData, ref, { type: 'master', key: masterKey })}
style={{ marginRight: '6px' }}
/>
<span title={masterKey}>{masterKey}</span>
</label>
</div>
);
}
blocks.push(<div key={'kin-block-' + title} className='msp-control-group-wrapper'>{kinBlock}</div>);
}
return <>{blocks}</>;
}
}

View File

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