@motionscript/molecule 0.0.0-stage → 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +201 -0
  3. package/dist/browser/index.js +4 -0
  4. package/dist/browser/index.js.map +7 -0
  5. package/dist/browser/manifest.json +11 -0
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +3 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/nodes.d.ts +18 -0
  11. package/dist/nodes.d.ts.map +1 -0
  12. package/dist/nodes.js +18 -0
  13. package/dist/nodes.js.map +1 -0
  14. package/dist/protein/chemistry.d.ts +52 -0
  15. package/dist/protein/chemistry.d.ts.map +1 -0
  16. package/dist/protein/chemistry.js +208 -0
  17. package/dist/protein/chemistry.js.map +1 -0
  18. package/dist/protein/index.d.ts +35 -0
  19. package/dist/protein/index.d.ts.map +1 -0
  20. package/dist/protein/index.js +35 -0
  21. package/dist/protein/index.js.map +1 -0
  22. package/dist/protein/parse.d.ts +32 -0
  23. package/dist/protein/parse.d.ts.map +1 -0
  24. package/dist/protein/parse.js +387 -0
  25. package/dist/protein/parse.js.map +1 -0
  26. package/dist/protein/protein.d.ts +265 -0
  27. package/dist/protein/protein.d.ts.map +1 -0
  28. package/dist/protein/protein.js +645 -0
  29. package/dist/protein/protein.js.map +1 -0
  30. package/dist/protein/ribbon.d.ts +83 -0
  31. package/dist/protein/ribbon.d.ts.map +1 -0
  32. package/dist/protein/ribbon.js +468 -0
  33. package/dist/protein/ribbon.js.map +1 -0
  34. package/dist/protein/shared.d.ts +221 -0
  35. package/dist/protein/shared.d.ts.map +1 -0
  36. package/dist/protein/shared.js +478 -0
  37. package/dist/protein/shared.js.map +1 -0
  38. package/dist/protein/structure.d.ts +184 -0
  39. package/dist/protein/structure.d.ts.map +1 -0
  40. package/dist/protein/structure.js +324 -0
  41. package/dist/protein/structure.js.map +1 -0
  42. package/package.json +64 -3
  43. package/registry.json +22 -0
  44. package/src/index.ts +2 -0
  45. package/src/nodes.ts +18 -0
  46. package/src/protein/chemistry.ts +223 -0
  47. package/src/protein/index.ts +34 -0
  48. package/src/protein/parse.ts +427 -0
  49. package/src/protein/protein.ts +897 -0
  50. package/src/protein/ribbon.ts +658 -0
  51. package/src/protein/shared.ts +622 -0
  52. package/src/protein/structure.ts +491 -0
  53. package/README.md +0 -4
@@ -0,0 +1,897 @@
1
+ import {
2
+ command,
3
+ driveCommand,
4
+ easeInOut,
5
+ node,
6
+ type Command,
7
+ type CommandArgs,
8
+ Canvas3D,
9
+ Geo,
10
+ Graphics3D,
11
+ Mat,
12
+ Scene3D,
13
+ property,
14
+ type BufferGeometry3D,
15
+ type Canvas3DProps,
16
+ type Color,
17
+ type NodeConfig,
18
+ type Transform3D,
19
+ type Vector3,
20
+ } from "@motionscript/core"
21
+
22
+ import {
23
+ lerpColor3D,
24
+ cameraOrbit,
25
+ resolveColor3D,
26
+ snapValue,
27
+ orbitProps,
28
+ type OrbitTarget,
29
+ } from "@motionscript/core/component"
30
+ import {
31
+ atomColors,
32
+ atomRadius,
33
+ backboneRuns,
34
+ DEFAULT_PROTEIN_PALETTE,
35
+ hexOf,
36
+ lerpHexColor,
37
+ stickPlacement,
38
+ traceColors,
39
+ type ProteinColorScheme,
40
+ type ProteinPalette,
41
+ type ProteinRepresentation,
42
+ } from "./shared"
43
+ import { cartoonGeometry, cartoonSpans } from "./ribbon"
44
+ import {
45
+ EMPTY_STRUCTURE,
46
+ type ProteinAtom,
47
+ type ProteinStructure,
48
+ } from "./structure"
49
+
50
+ export interface ProteinProps extends Canvas3DProps {
51
+ /**
52
+ * The molecule to draw, already parsed.
53
+ *
54
+ * A value rather than a source, and that is the whole arrangement: reading a
55
+ * coordinate file means a network round-trip or a file read, and this node is
56
+ * constructed inside a synchronous render pass. So the fetching and the
57
+ * parsing happen *before* the build — see `parseStructure` and the app's
58
+ * structure store — and what arrives here is finished data. It is the same
59
+ * bargain a chart makes with its rows.
60
+ */
61
+ structure: ProteinStructure
62
+ /** How the molecule is drawn. See {@link ProteinRepresentation}. */
63
+ representation: ProteinRepresentation
64
+ /** What decides an atom's colour. See {@link ProteinColorScheme}. */
65
+ colorScheme: ProteinColorScheme
66
+ /** The colour the `uniform` scheme paints, and every scheme's fallback. */
67
+ color: Color
68
+ /**
69
+ * Every colour the *other* schemes paint from — see {@link ProteinPalette},
70
+ * which is the shape these are read as rather than a prop of its own.
71
+ *
72
+ * Thirteen props rather than one palette because a prop is a whole value: a
73
+ * single one would make a `to` that reddens the helices a statement about the
74
+ * eight chain colours too, and the ones the author never touched would snap.
75
+ * Each of these carries over and tweens on its own, like every other colour in
76
+ * the app.
77
+ */
78
+ helixColor: string
79
+ sheetColor: string
80
+ coilColor: string
81
+ /** The two ends of the N→C ramp, swept through HSL between them. */
82
+ residueStartColor: string
83
+ residueEndColor: string
84
+ /** One per chain, cycling past the eighth. */
85
+ chainColor1: string
86
+ chainColor2: string
87
+ chainColor3: string
88
+ chainColor4: string
89
+ chainColor5: string
90
+ chainColor6: string
91
+ chainColor7: string
92
+ chainColor8: string
93
+ /**
94
+ * Atom size as a fraction of the true van der Waals radius.
95
+ *
96
+ * 1 is the space-filling model — atoms at the size they actually are, touching
97
+ * their neighbours. Ball-and-stick shrinks them further on its own, so this
98
+ * stays "how big are the atoms" in both rather than meaning something
99
+ * different in each.
100
+ */
101
+ atomScale: number
102
+ /** Bond stick radius, in Ångströms. */
103
+ bondRadius: number
104
+ /** Backbone tube radius, in Ångströms. A ribbon swells past this on a helix. */
105
+ ribbonRadius: number
106
+ /** Whether to draw ligands, ions and cofactors — everything that isn't polymer. */
107
+ ligands: boolean
108
+ /** Whether to draw crystallographic waters. Off, because there are hundreds. */
109
+ waters: boolean
110
+ /** Mesh detail: segments around a sphere or a stick. */
111
+ quality: number
112
+ /** Camera orbit about the vertical axis, in **degrees**. Animate this to spin. */
113
+ orbit: number
114
+ /** Camera elevation above the horizon, in **degrees**. */
115
+ elevation: number
116
+ /**
117
+ * How far back the camera sits, as a multiple of the molecule's own radius.
118
+ *
119
+ * A multiple rather than a distance, because the things this node draws differ
120
+ * in size by two orders of magnitude — crambin is 12 Å across and a ribosome
121
+ * is 300 — and a stored distance that framed one would put the other off
122
+ * screen or inside the camera. As a ratio the same number frames both, and it
123
+ * goes on meaning the same thing when the structure is swapped.
124
+ */
125
+ zoom: number
126
+ /** Strength of the ambient fill. */
127
+ ambientIntensity: number
128
+ /** Colour of the ambient fill. White leaves the molecule's own colours alone. */
129
+ ambientColor: Color
130
+ /** Strength of the key light; the rim light is derived from it. */
131
+ keyIntensity: number
132
+ /** Colour of the key light, and of the rim derived from it. */
133
+ keyColor: Color
134
+ }
135
+
136
+ /** {@link ProteinProps.zoom}, held inside what can actually be framed. */
137
+ const MIN_ZOOM = 0.2
138
+ const MAX_ZOOM = 12
139
+ const DEFAULT_ZOOM = 2.6
140
+
141
+ /**
142
+ * How much of its true size an atom is drawn at in ball-and-stick, on top of
143
+ * whatever {@link ProteinProps.atomScale} says.
144
+ *
145
+ * Ball-and-stick is not a smaller space-filling model — it is a different claim
146
+ * about what matters. Atoms at a quarter of their radius stop touching, which is
147
+ * what makes room for the bonds that are the whole point of the representation.
148
+ */
149
+ const BALL_SCALE = 0.28
150
+
151
+ /**
152
+ * A molecular structure viewer.
153
+ *
154
+ * Extends {@link Canvas3D} — as `Graph3D` does — so this class only has to say
155
+ * *what* to draw, and the bridge from the 2D scene graph into the 3D renderer is
156
+ * the base class's business. It overrides `buildScene3D`, the single seam both
157
+ * the real render and the asset declaration pass go through.
158
+ *
159
+ * <Protein width="fill" height="fill"
160
+ * structure={parseStructure(text, "6LU7")}
161
+ * representation="ribbon" colorScheme="secondary" />
162
+ *
163
+ * **There is no pointer here.** Every other molecular viewer is driven by
164
+ * dragging the molecule around, and this one cannot be: the studio renders a
165
+ * timeline, so the camera has to be something a `to` command can tween and a
166
+ * scrub can reproduce exactly — frame 300 must be identical whether it was
167
+ * reached by playing forward or by dragging the playhead backwards. So the
168
+ * camera is three tweenable numbers (`orbit`, `elevation`, `zoom`), and spinning
169
+ * the molecule is a command rather than a gesture.
170
+ *
171
+ * Atoms and bonds are drawn **instanced**: one sphere geometry and one cylinder
172
+ * geometry, placed thousands of times in a single draw call each. A mesh per
173
+ * atom would be tens of thousands of draw calls a frame, which no renderer
174
+ * survives — and the instance lists themselves are cached (see {@link built}),
175
+ * because they don't change when the camera moves and the camera is what
176
+ * normally moves.
177
+ */
178
+ @node({
179
+ key: "protein",
180
+ parentKey: "node",
181
+ forkable: true,
182
+ layout: {
183
+ children: "freeform",
184
+ acceptsChildren: false,
185
+ defaultWidthMode: "fixed",
186
+ defaultHeightMode: "fixed",
187
+ },
188
+ seed: {
189
+ width: 720,
190
+ height: 560,
191
+ },
192
+ })
193
+ export class Protein extends Canvas3D<ProteinProps> {
194
+ /**
195
+ * The molecule.
196
+ *
197
+ * Snaps rather than interpolating, and every other non-scalar prop here
198
+ * interpolates. There is no halfway point between two different molecules —
199
+ * atom 4,000 of one is not the same atom as atom 4,000 of the other, and
200
+ * blending their coordinates would produce a cloud that is neither. Swapping
201
+ * the structure is a change of *subject*, so it lands at the end of a tween
202
+ * the way a change of text does.
203
+ */
204
+ @property({ default: EMPTY_STRUCTURE, tween: snapValue })
205
+ declare structure: ProteinStructure
206
+
207
+ @property({ default: "ribbon", tween: snapValue })
208
+ declare representation: ProteinRepresentation
209
+
210
+ @property({ default: "secondary", tween: snapValue })
211
+ declare colorScheme: ProteinColorScheme
212
+
213
+ @property({ default: "#4f9dff", mapper: resolveColor3D, tween: lerpColor3D })
214
+ declare color: Color
215
+
216
+ /**
217
+ * The palette, thirteen props of it — see {@link ProteinProps.helixColor} for
218
+ * why it is thirteen and not one.
219
+ *
220
+ * No `mapper`, unlike the three `Color` props around them: these stay hex
221
+ * strings because a hex string is what an instance tint is handed, and
222
+ * {@link lerpHexColor} is the tween that goes with that. The defaults are
223
+ * written out rather than read off {@link DEFAULT_PROTEIN_PALETTE} because a
224
+ * decorator default has to be a literal the class carries; the two are pinned
225
+ * against each other by a test.
226
+ */
227
+ @property({ default: "#f0605d", tween: lerpHexColor })
228
+ declare helixColor: string
229
+
230
+ @property({ default: "#f5c451", tween: lerpHexColor })
231
+ declare sheetColor: string
232
+
233
+ @property({ default: "#c8ccd4", tween: lerpHexColor })
234
+ declare coilColor: string
235
+
236
+ @property({
237
+ default: DEFAULT_PROTEIN_PALETTE.residueStart,
238
+ tween: lerpHexColor,
239
+ })
240
+ declare residueStartColor: string
241
+
242
+ @property({
243
+ default: DEFAULT_PROTEIN_PALETTE.residueEnd,
244
+ tween: lerpHexColor,
245
+ })
246
+ declare residueEndColor: string
247
+
248
+ @property({ default: "#4f9dff", tween: lerpHexColor })
249
+ declare chainColor1: string
250
+
251
+ @property({ default: "#ff8a3d", tween: lerpHexColor })
252
+ declare chainColor2: string
253
+
254
+ @property({ default: "#4ddb9a", tween: lerpHexColor })
255
+ declare chainColor3: string
256
+
257
+ @property({ default: "#e05fd0", tween: lerpHexColor })
258
+ declare chainColor4: string
259
+
260
+ @property({ default: "#ffd166", tween: lerpHexColor })
261
+ declare chainColor5: string
262
+
263
+ @property({ default: "#8b7bff", tween: lerpHexColor })
264
+ declare chainColor6: string
265
+
266
+ @property({ default: "#ff6b6b", tween: lerpHexColor })
267
+ declare chainColor7: string
268
+
269
+ @property({ default: "#3fd0d6", tween: lerpHexColor })
270
+ declare chainColor8: string
271
+
272
+ @property({ default: 1 }) declare atomScale: number
273
+ @property({ default: 0.18 }) declare bondRadius: number
274
+ @property({ default: 0.45 }) declare ribbonRadius: number
275
+
276
+ @property({ default: true, tween: snapValue }) declare ligands: boolean
277
+ @property({ default: false, tween: snapValue }) declare waters: boolean
278
+
279
+ /**
280
+ * Structural, like `Graph3D.segments`: a geometry is immutable, so every
281
+ * intermediate value of a tween reallocates the sphere and the cylinder. Set
282
+ * once; the appearance schema marks it non-animatable to say the same thing
283
+ * from the other side.
284
+ */
285
+ @property({ default: 12 }) declare quality: number
286
+
287
+ @property({ default: 35 }) declare orbit: number
288
+ @property({ default: 18 }) declare elevation: number
289
+ @property({ default: DEFAULT_ZOOM }) declare zoom: number
290
+
291
+ /**
292
+ * The default rig, and only the default rig — the same four controls the 3D
293
+ * viewport carries, in the same order and with the same defaults, because
294
+ * "how is this lit" is one question and a molecule is not a special case of
295
+ * it. Each light is a **strength and a colour**, in that order: how much
296
+ * light, and what colour it is.
297
+ *
298
+ * Both colours default to white rather than to a tint, so a rig nobody has
299
+ * touched grades nothing. A coloured default would be a look applied to every
300
+ * structure in every scene by a control the author never opened.
301
+ */
302
+ @property({ default: 0.55 }) declare ambientIntensity: number
303
+
304
+ @property({ default: "#ffffff", mapper: resolveColor3D, tween: lerpColor3D })
305
+ declare ambientColor: Color
306
+
307
+ @property({ default: 1.6 }) declare keyIntensity: number
308
+
309
+ @property({ default: "#ffffff", mapper: resolveColor3D, tween: lerpColor3D })
310
+ declare keyColor: Color
311
+
312
+ constructor(props?: NodeConfig<Protein, ProteinProps>) {
313
+ super(props as NodeConfig<Canvas3D<ProteinProps>, ProteinProps>)
314
+ }
315
+
316
+ /**
317
+ * Frame the scene at a spherical camera placement. Every axis is optional,
318
+ * so "pull back" and "spin round" stay separate intentions — see
319
+ * {@link OrbitTarget}.
320
+ */
321
+ @command({
322
+ args: [
323
+ {
324
+ key: "target", kind: "orbit", properties: {
325
+ orbit: { kind: "number", step: 1, unit: "°" },
326
+ elevation: { kind: "number", min: -89, max: 89, step: 1, unit: "°" },
327
+ zoom: { kind: "number", min: 0.2, max: 24, step: 0.1, unit: "×" },
328
+ },
329
+ },
330
+ ],
331
+ })
332
+ orbitTo(args: CommandArgs<{ target: OrbitTarget }> & { duration: number }): Command<ProteinProps> {
333
+ return this.to({
334
+ data: orbitProps(args.data?.target ?? {}) as Partial<ProteinProps>,
335
+ duration: args.duration,
336
+ easing: args.easing ?? easeInOut(),
337
+ })
338
+ }
339
+
340
+ /**
341
+ * Drift the camera around the molecule for the command's duration — a
342
+ * continuous orbit at `speed` with a lateral `sway` and a `zoom` pulse
343
+ * (`breathe`), both on a shared `cycle` so they don't beat against each
344
+ * other. `settle` eases in from wherever the camera already is, so `float`
345
+ * after an `orbitTo` does not snap.
346
+ */
347
+ @command({
348
+ args: [
349
+ { key: "speed", kind: "number", default: 36, min: -360, max: 360, step: 1, unit: "°/s" },
350
+ { key: "elevation", kind: "number", default: 18, min: -89, max: 89, step: 1, unit: "°" },
351
+ { key: "zoom", kind: "number", default: 2.6, min: 0.2, max: 12, step: 0.1, unit: "×" },
352
+ { key: "sway", kind: "number", default: 6, min: 0, max: 45, step: 1, unit: "°" },
353
+ { key: "breathe", kind: "number", default: 0.08, min: 0, max: 0.6, step: 0.01, scale: 100, unit: "%" },
354
+ { key: "cycle", kind: "number", default: 8, min: 0.5, max: 60, step: 0.5, unit: "s" },
355
+ { key: "settle", kind: "number", default: 1, min: 0, max: 20, step: 0.1, unit: "s" },
356
+ ],
357
+ })
358
+ float(args: CommandArgs<{
359
+ speed?: number
360
+ elevation?: number
361
+ zoom?: number
362
+ sway?: number
363
+ breathe?: number
364
+ cycle?: number
365
+ settle?: number
366
+ }> & { duration?: number }): Command<ProteinProps> {
367
+ const {
368
+ speed = 36,
369
+ elevation = 18,
370
+ zoom = 2.6,
371
+ sway = 6,
372
+ breathe = 0.08,
373
+ cycle = 8,
374
+ settle = 1,
375
+ } = args.data ?? {}
376
+ const duration = args.duration ?? 8
377
+ const easing = args.easing ?? easeInOut()
378
+
379
+ const fromOrbit = this.orbit
380
+ const fromElevation = this.elevation
381
+ const fromZoom = this.zoom
382
+ const period = Math.max(cycle, 0.0001)
383
+
384
+ return driveCommand(duration, (t) => {
385
+ const seconds = t * duration
386
+ // Ease from the camera's current placement into the drift over `settle`
387
+ // seconds. A zero `settle` starts the drift immediately.
388
+ const blend = settle <= 0 ? 1 : easing(Math.min(1, seconds / settle))
389
+ const phase = (seconds / period) * Math.PI * 2
390
+
391
+ const driftOrbit = fromOrbit + speed * seconds + Math.sin(phase) * sway
392
+ const driftZoom = zoom * (1 + Math.sin(phase) * breathe)
393
+
394
+ this.set({
395
+ orbit: fromOrbit + (driftOrbit - fromOrbit) * blend,
396
+ elevation: fromElevation + (elevation - fromElevation) * blend,
397
+ zoom: fromZoom + (driftZoom - fromZoom) * blend,
398
+ } as Partial<ProteinProps>)
399
+ }) as Command<ProteinProps>
400
+ }
401
+
402
+ /**
403
+ * The thirteen colour props, as the one value the colourers take.
404
+ *
405
+ * Rebuilt per call rather than cached: it is thirteen property reads behind
406
+ * `built()`'s own cache, which is what stops it happening per frame.
407
+ */
408
+ private paletteOf(): ProteinPalette {
409
+ return {
410
+ helix: this.helixColor,
411
+ sheet: this.sheetColor,
412
+ coil: this.coilColor,
413
+ residueStart: this.residueStartColor,
414
+ residueEnd: this.residueEndColor,
415
+ chains: [
416
+ this.chainColor1,
417
+ this.chainColor2,
418
+ this.chainColor3,
419
+ this.chainColor4,
420
+ this.chainColor5,
421
+ this.chainColor6,
422
+ this.chainColor7,
423
+ this.chainColor8,
424
+ ],
425
+ }
426
+ }
427
+
428
+ // ---- Drawing ------------------------------------------------------------
429
+
430
+ protected override buildScene3D(): Scene3D {
431
+ const scene = new Scene3D()
432
+ const structure = this.structure
433
+ // Framed on what is actually **drawn**, not on the whole file. A crystal
434
+ // structure carries hundreds of ordered waters scattered well beyond the
435
+ // molecule, and they are hidden by default — framing the sphere that holds
436
+ // them would park the camera 40% further back than the picture needs, which
437
+ // reads as a molecule adrift in an empty box.
438
+ const radius = this.built().radius
439
+ const distance = radius * clampZoom(this.zoom)
440
+
441
+ // The clipping planes are cut to the molecule rather than fixed, because
442
+ // "far enough away to see all of it" differs a hundredfold between a peptide
443
+ // and a virus capsid — and a depth buffer stretched over a range it doesn't
444
+ // need spends its precision on empty space, which shows up as z-fighting
445
+ // between atoms that are genuinely an Ångström apart.
446
+ // Kept explicit rather than left to the camera's own derivation: that sizes
447
+ // the planes to the scene's bounding box, and a molecule's *atoms* are what
448
+ // need the precision here, not its extent.
449
+ //
450
+ // Polar placement is the camera's vocabulary now, so the three props pass
451
+ // straight through; `cameraOrbit` is the quarter turn between the stored
452
+ // zero and the camera's.
453
+ scene.perspective({
454
+ fov: 45,
455
+ near: Math.max(0.1, radius * 0.02),
456
+ far: distance + radius * 4,
457
+ orbit: cameraOrbit(this.orbit),
458
+ elevation: this.elevation,
459
+ distance,
460
+ })
461
+
462
+ // The backdrop is the node's own **Fills** section — a `Canvas3D`
463
+ // composites its 3D pass over its fill layers, so a paint stack behind a
464
+ // molecule is not merely possible but strictly better than one colour, and
465
+ // an empty stack is what transparency already means everywhere else in the
466
+ // app. There is no 3D background pass to reach for and no clear colour to
467
+ // set; see `resolveViewport3DFills`, which carries the colour this node
468
+ // used to hold in its appearance bag across to that stack.
469
+
470
+ // A key light, a weaker rim from the opposite side, and enough ambient that
471
+ // an atom facing away is still an atom rather than a silhouette. Placed
472
+ // relative to the molecule so the lighting holds at any size — a light's
473
+ // parameters and its placement are separate arguments, because placement is
474
+ // a transform rather than a property of the lamp.
475
+ //
476
+ // The rim takes the key's colour as well as a fraction of its strength: it
477
+ // is the key seen from the other side rather than a light of its own, which
478
+ // is why there are four controls and not six. The same rig, and the same
479
+ // argument, as the 3D viewport's — see `Canvas3DView.buildScene3D`.
480
+ scene
481
+ .light({
482
+ type: "ambient",
483
+ intensity: this.ambientIntensity,
484
+ color: this.ambientColor,
485
+ })
486
+ .light(
487
+ {
488
+ type: "directional",
489
+ intensity: this.keyIntensity,
490
+ color: this.keyColor,
491
+ },
492
+ { position: [radius, radius * 2, radius * 2] }
493
+ )
494
+ .light(
495
+ {
496
+ type: "directional",
497
+ intensity: this.keyIntensity * 0.4,
498
+ color: this.keyColor,
499
+ },
500
+ { position: [-radius * 2, -radius, -radius * 1.5] }
501
+ )
502
+
503
+ // Everything is drawn inside one group that puts the molecule's centroid on
504
+ // the origin, so the camera — which always looks at the origin — frames it
505
+ // wherever the crystallographer's coordinate system happened to put it. PDB
506
+ // coordinates are in the crystal's frame and routinely sit hundreds of
507
+ // Ångströms from zero.
508
+ //
509
+ // `begin`/`end` is what a `Graphics3D.group` was: a transform pushed over
510
+ // everything drawn between them. It lives on the scene now rather than on
511
+ // the graphics, because hierarchy is a property of the scene.
512
+ const g3 = new Graphics3D()
513
+ this.addMolecule(g3)
514
+
515
+ const { center } = structure
516
+ scene.begin({
517
+ id: "molecule",
518
+ transform: { position: [-center.x, -center.y, -center.z] },
519
+ })
520
+ scene.draw(g3)
521
+ scene.end()
522
+
523
+ return scene
524
+ }
525
+
526
+ /** The molecule itself, in whichever representation is showing. */
527
+ private addMolecule(g3: Graphics3D): void {
528
+ const built = this.built()
529
+
530
+ this.addSpheres(g3, built.spheres, built.sphereTints, "atoms")
531
+ this.addSticks(g3, built.sticks, built.stickTints, "bonds")
532
+
533
+ for (const run of built.runs) {
534
+ g3.mesh(
535
+ Geo.tube({
536
+ points: run.points,
537
+ radius: run.radius,
538
+ // `[along the curve, around it]`. Segments along the curve are
539
+ // proportional to the run's own length, so the sweep follows it rather
540
+ // than cutting corners across a tight turn — and a short run doesn't
541
+ // pay for a long one's tessellation.
542
+ segments: [Math.max(6, run.points.length * 4), this.stickQuality()],
543
+ }),
544
+ Mat.standard({ color: run.color, roughness: 0.45, metalness: 0.05 })
545
+ )
546
+ }
547
+
548
+ for (const ribbon of built.ribbons) {
549
+ g3.mesh(
550
+ ribbon,
551
+ // `vertexColors`, where every other material here takes a `color`. A
552
+ // cartoon span is one mesh covering many residues and the colouring is
553
+ // per residue, so the colour varies *within* the primitive — which is
554
+ // the one thing a material's uniform colour cannot express. Safe in a
555
+ // way it would not be on the instanced spheres (see {@link addSpheres}):
556
+ // this geometry genuinely carries a `color` attribute.
557
+ Mat.standard({
558
+ vertexColors: true,
559
+ roughness: 0.45,
560
+ metalness: 0.05,
561
+ })
562
+ )
563
+ }
564
+ }
565
+
566
+ /** One instanced sphere op, skipped entirely when there is nothing in it. */
567
+ private addSpheres(
568
+ g3: Graphics3D,
569
+ placements: Transform3D[],
570
+ tints: string[],
571
+ key: string
572
+ ): void {
573
+ if (placements.length === 0) return
574
+ const quality = this.sphereQuality()
575
+ g3.instances(
576
+ // A unit sphere, sized per instance. One geometry for the whole molecule:
577
+ // the renderer dedupes by identity and uploads it once, where a radius
578
+ // baked into the geometry would mean one upload per element.
579
+ Geo.sphere({
580
+ radius: 1,
581
+ // `[longitude, latitude]` — half as many bands as meridians, which is
582
+ // what a sphere wants and what three's own defaults do.
583
+ segments: [quality, Math.max(4, Math.round(quality / 2))],
584
+ }),
585
+ // No `vertexColors` here, and that is the whole trick rather than an
586
+ // omission. An instance's tint arrives as its own attribute and the
587
+ // renderer samples it whenever one is present; asking for *vertex* colours
588
+ // as well makes the shader read a per-vertex `color` the geometry doesn't
589
+ // have, which supplies zeroes — and the molecule renders solid black.
590
+ Mat.standard({ roughness: 0.35, metalness: 0.05 }),
591
+ placements,
592
+ { colors: tints, transform: { key } }
593
+ )
594
+ }
595
+
596
+ /** One instanced cylinder op. */
597
+ private addSticks(
598
+ g3: Graphics3D,
599
+ placements: Transform3D[],
600
+ tints: string[],
601
+ key: string
602
+ ): void {
603
+ if (placements.length === 0) return
604
+ g3.instances(
605
+ // A unit cylinder — one tall, centred on the origin, running along +Y —
606
+ // which `stickPlacement` moves, turns and stretches onto each bond.
607
+ Geo.cylinder({
608
+ radius: 1,
609
+ height: 1,
610
+ segments: this.stickQuality(),
611
+ // The ends are inside the atoms they join, so nothing can see them.
612
+ capped: false,
613
+ }),
614
+ // Bare of `vertexColors` for the reason the spheres are — see above.
615
+ Mat.standard({ roughness: 0.4, metalness: 0.05 }),
616
+ placements,
617
+ { colors: tints, transform: { key } }
618
+ )
619
+ }
620
+
621
+ // ---- What gets drawn ----------------------------------------------------
622
+
623
+ /**
624
+ * Everything the builder needs, cached against the inputs that decide it.
625
+ *
626
+ * This is the node's one piece of memoisation and it earns its keep. The
627
+ * builder runs every frame; between two frames the thing that has normally
628
+ * changed is the camera; and none of what is cached here depends on the
629
+ * camera. Without it, orbiting a 20,000-atom structure would rebuild a
630
+ * 20,000-entry placement list and a 40,000-entry stick list sixty times a
631
+ * second — for a picture whose atoms have not moved.
632
+ *
633
+ * The structure is compared by **identity** rather than by contents: it is a
634
+ * parsed value produced once, upstream, so a different object genuinely means
635
+ * a different molecule.
636
+ */
637
+ private cache: {
638
+ key: string
639
+ structure: ProteinStructure
640
+ built: Built
641
+ } | null = null
642
+
643
+ private built(): Built {
644
+ const structure = this.structure
645
+ const key = [
646
+ this.representation,
647
+ this.colorScheme,
648
+ hexOf(this.color),
649
+ // The palette decides every colour the scheme paints, so a change to it
650
+ // changes the tint arrays this cache is holding — exactly as `color` does.
651
+ // Missing it meant editing a chain colour repainted nothing until
652
+ // something else in this key happened to move.
653
+ paletteKey(this.paletteOf()),
654
+ this.ligands,
655
+ this.waters,
656
+ this.atomScale,
657
+ this.bondRadius,
658
+ this.ribbonRadius,
659
+ // In the key now, where it never used to be: the cartoon's curve is
660
+ // subdivided against the mesh detail (see `subdivisionsFor`), so a change
661
+ // to it changes the geometry this cache is holding. The atoms never
662
+ // needed it — `quality` reaches their spheres as a `segments` count the
663
+ // renderer reads off the descriptor, not as anything built here.
664
+ this.quality,
665
+ ].join("|")
666
+
667
+ const cached = this.cache
668
+ if (cached && cached.structure === structure && cached.key === key) {
669
+ return cached.built
670
+ }
671
+
672
+ const built = this.build(structure)
673
+ this.cache = { key, structure, built }
674
+ return built
675
+ }
676
+
677
+ /** Builds the placement lists for the current representation. */
678
+ private build(structure: ProteinStructure): Built {
679
+ const palette = this.paletteOf()
680
+ const colors = atomColors(
681
+ structure,
682
+ this.colorScheme,
683
+ hexOf(this.color),
684
+ palette
685
+ )
686
+ const showing = structure.atoms.map((atom) => this.isShowing(atom))
687
+ const representation = this.representation
688
+ const spaceFilling = representation === "spacefill"
689
+ const atomic = spaceFilling || representation === "ballAndStick"
690
+
691
+ // On a backbone or a ribbon the polymer is the curve, so only what *isn't*
692
+ // polymer keeps its atoms — an inhibitor sitting in an active site is
693
+ // usually the entire subject of the picture, and a bare backbone doesn't
694
+ // show it. On the atomic representations everything showing is drawn.
695
+ const drawAtom = (index: number): boolean => {
696
+ if (!showing[index]) return false
697
+ if (atomic) return true
698
+ const kind = structure.atoms[index]!.kind
699
+ return kind !== "amino" && kind !== "nucleic"
700
+ }
701
+
702
+ const built: Built = {
703
+ spheres: [],
704
+ sphereTints: [],
705
+ sticks: [],
706
+ stickTints: [],
707
+ runs: [],
708
+ ribbons: [],
709
+ radius: structure.radius,
710
+ }
711
+
712
+ const scale = this.atomScale * (spaceFilling ? 1 : BALL_SCALE)
713
+ for (let i = 0; i < structure.atoms.length; i++) {
714
+ if (!drawAtom(i)) continue
715
+ const atom = structure.atoms[i]!
716
+ built.spheres.push({
717
+ position: [atom.x, atom.y, atom.z],
718
+ scale: atomRadius(atom, scale),
719
+ })
720
+ built.sphereTints.push(colors[i]!)
721
+ }
722
+
723
+ // A space-filling model has no visible bonds — the atoms are at their true
724
+ // radii and already overlapping — so the sticks are simply not built.
725
+ if (!spaceFilling) {
726
+ for (const bond of structure.bonds) {
727
+ if (!drawAtom(bond.a) || !drawAtom(bond.b)) continue
728
+ const a = structure.atoms[bond.a]!
729
+ const b = structure.atoms[bond.b]!
730
+ // Two half-sticks rather than one, each in its own atom's colour: a
731
+ // single stick has to be *some* colour, and whichever atom's it takes,
732
+ // the far end reads as belonging to the wrong one. Splitting at the
733
+ // midpoint is what ball-and-stick models have done since they were brass.
734
+ const mid: Vector3 = {
735
+ x: (a.x + b.x) / 2,
736
+ y: (a.y + b.y) / 2,
737
+ z: (a.z + b.z) / 2,
738
+ }
739
+ built.sticks.push(stickPlacement(a, mid, this.bondRadius))
740
+ built.stickTints.push(colors[bond.a]!)
741
+ built.sticks.push(stickPlacement(mid, b, this.bondRadius))
742
+ built.stickTints.push(colors[bond.b]!)
743
+ }
744
+ }
745
+
746
+ if (!atomic) this.addRuns(built, structure)
747
+ built.radius = framingRadius(built, structure)
748
+ return built
749
+ }
750
+
751
+ /**
752
+ * The polymer, as whichever sweep its representation calls for.
753
+ *
754
+ * Two genuinely different builds rather than one with a radius switch, and
755
+ * the split is the same one the representations themselves are: `backbone`
756
+ * asks *where does the chain go*, which an even round tube answers exactly
757
+ * and completely; `ribbon` asks *what is it folded into*, which is the
758
+ * cartoon convention and needs a cross-section with a width, a thickness and
759
+ * a side (see `ribbon.ts`).
760
+ */
761
+ private addRuns(built: Built, structure: ProteinStructure): void {
762
+ const cartoon = this.representation === "ribbon"
763
+ const uniform = hexOf(this.color)
764
+ const palette = this.paletteOf()
765
+
766
+ structure.chains.forEach((chain, index) => {
767
+ const colors = traceColors(
768
+ structure,
769
+ chain,
770
+ this.colorScheme,
771
+ uniform,
772
+ palette
773
+ )
774
+
775
+ if (cartoon) {
776
+ for (const span of cartoonSpans(chain, colors)) {
777
+ const geometry = cartoonGeometry(span, {
778
+ radius: this.ribbonRadius,
779
+ quality: this.quality,
780
+ })
781
+ if (geometry) built.ribbons.push(geometry)
782
+ }
783
+ return
784
+ }
785
+
786
+ backboneRuns(chain, colors, false).forEach((run, runIndex) => {
787
+ built.runs.push({
788
+ points: run.points,
789
+ color: run.color,
790
+ radius: this.ribbonRadius,
791
+ // Keyed by its place in the chain rather than by its slot in the op
792
+ // list, so adding a run doesn't renumber every later one and force the
793
+ // tail of the renderer's cache to rebuild.
794
+ key: `chain:${index}:${runIndex}`,
795
+ })
796
+ })
797
+ })
798
+ }
799
+
800
+ /** Whether an atom passes the ligand and water filters. */
801
+ private isShowing(atom: ProteinAtom): boolean {
802
+ if (atom.kind === "water") return this.waters
803
+ if (atom.kind === "amino" || atom.kind === "nucleic") return true
804
+ return this.ligands
805
+ }
806
+
807
+ /** Segments around a sphere's equator. */
808
+ private sphereQuality(): number {
809
+ return Math.max(4, Math.round(this.quality))
810
+ }
811
+
812
+ /** Segments around a stick or a tube — half a sphere's, since it has no poles. */
813
+ private stickQuality(): number {
814
+ return Math.max(3, Math.round(this.quality / 2))
815
+ }
816
+ }
817
+
818
+ /** A palette flattened for {@link Protein.built}'s cache key. */
819
+ function paletteKey(palette: ProteinPalette): string {
820
+ return [
821
+ palette.helix,
822
+ palette.sheet,
823
+ palette.coil,
824
+ palette.residueStart,
825
+ palette.residueEnd,
826
+ ...palette.chains,
827
+ ].join(",")
828
+ }
829
+
830
+ /** One swept stretch of backbone. */
831
+ interface BuiltRun {
832
+ points: Vector3[]
833
+ color: string
834
+ radius: number
835
+ key: string
836
+ }
837
+
838
+ /** Everything the per-frame builder emits, built once per change and cached. */
839
+ interface Built {
840
+ spheres: Transform3D[]
841
+ sphereTints: string[]
842
+ sticks: Transform3D[]
843
+ stickTints: string[]
844
+ /** Even round tubes — the `backbone` representation. */
845
+ runs: BuiltRun[]
846
+ /** Cartoon meshes, one per secondary-structure span — the `ribbon` one. */
847
+ ribbons: BufferGeometry3D[]
848
+ /** How far the drawn parts reach from the molecule's centroid, in Ångströms. */
849
+ radius: number
850
+ }
851
+
852
+ /**
853
+ * The radius the camera frames: the furthest *drawn* thing from the centroid.
854
+ *
855
+ * Measured over the placements rather than over the atom list, so it covers
856
+ * exactly what will appear — the backbone runs when a ribbon is showing, the
857
+ * atoms when it isn't, and neither the hidden waters nor the ligands somebody
858
+ * switched off.
859
+ *
860
+ * Falls back to the whole structure's radius when nothing is drawn at all, so a
861
+ * node whose filters have hidden everything still has a camera distance rather
862
+ * than a zero.
863
+ */
864
+ function framingRadius(built: Built, structure: ProteinStructure): number {
865
+ const { center } = structure
866
+ let furthest = 0
867
+
868
+ const reach = (x: number, y: number, z: number) => {
869
+ const d = Math.hypot(x - center.x, y - center.y, z - center.z)
870
+ if (d > furthest) furthest = d
871
+ }
872
+
873
+ for (const sphere of built.spheres) {
874
+ const [x, y, z] = sphere.position as [number, number, number]
875
+ reach(x, y, z)
876
+ }
877
+ for (const run of built.runs) {
878
+ for (const point of run.points) reach(point.x, point.y, point.z)
879
+ }
880
+ // The cartoon's vertices rather than its residues: it is already a finished
881
+ // buffer by the time this runs, and reading it is both exact — a strand's
882
+ // arrowhead reaches wider than the α-carbons it was built from — and cheaper
883
+ // than keeping a parallel copy of the curve alive to measure instead.
884
+ for (const ribbon of built.ribbons) {
885
+ const position = ribbon.position
886
+ for (let i = 0; i + 2 < position.length; i += 3) {
887
+ reach(position[i]!, position[i + 1]!, position[i + 2]!)
888
+ }
889
+ }
890
+
891
+ return furthest > 0 ? furthest : structure.radius
892
+ }
893
+
894
+ function clampZoom(zoom: number): number {
895
+ if (!Number.isFinite(zoom)) return DEFAULT_ZOOM
896
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom))
897
+ }