@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,622 @@
1
+ /**
2
+ * What the {@link Protein} node turns a parsed molecule into: which atoms are
3
+ * showing, what colour each one takes, and how a bond becomes a cylinder.
4
+ *
5
+ * Everything here is **derived per structure**, not per frame. The node caches
6
+ * what these return against the inputs that produced them (see
7
+ * `Protein.instances`), because the camera moves every frame and none of this
8
+ * changes when it does — and a mid-sized entry is tens of thousands of atoms, so
9
+ * rebuilding the arrays sixty times a second to spin the view would be the
10
+ * difference between a smooth orbit and a slideshow.
11
+ */
12
+
13
+ import type {
14
+ Color,
15
+ Quaternion,
16
+ Transform3D,
17
+ Vector3,
18
+ } from "@motionscript/core"
19
+
20
+ import { elementColor, vanDerWaalsRadius } from "./chemistry"
21
+ import type {
22
+ ProteinAtom,
23
+ ProteinChain,
24
+ ProteinStructure,
25
+ SecondaryStructure,
26
+ } from "./structure"
27
+
28
+ /**
29
+ * How the molecule is drawn.
30
+ *
31
+ * The four that answer genuinely different questions, which is why there are
32
+ * four rather than a dozen:
33
+ *
34
+ * - `spacefill` — every atom at its van der Waals radius. What the molecule's
35
+ * *surface* is: the shape it presents to everything around it.
36
+ * - `ballAndStick` — atoms shrunk, bonds drawn. What the molecule's *chemistry*
37
+ * is, and the only one where an individual atom can be picked out.
38
+ * - `backbone` — a thin even tube through the α-carbons. Where the chain *goes*,
39
+ * with nothing else in the way.
40
+ * - `ribbon` — the same curve, swelling along helices and strands and coloured
41
+ * by them. What the protein is *folded into*, which is what a figure in a
42
+ * paper is nearly always about.
43
+ */
44
+ export const PROTEIN_REPRESENTATIONS = [
45
+ "ribbon",
46
+ "backbone",
47
+ "ballAndStick",
48
+ "spacefill",
49
+ ] as const
50
+
51
+ export type ProteinRepresentation = (typeof PROTEIN_REPRESENTATIONS)[number]
52
+
53
+ /**
54
+ * What decides an atom's colour.
55
+ *
56
+ * Each one answers a question the picture might be asking, and the default
57
+ * differs by representation for exactly that reason: a space-filling model with
58
+ * no element colouring is a featureless blob, while a ribbon coloured by element
59
+ * is a single shade of grey.
60
+ */
61
+ export const PROTEIN_COLOR_SCHEMES = [
62
+ /** Helix / sheet / coil. Fold. */
63
+ "secondary",
64
+ /** CPK — oxygen red, nitrogen blue, carbon grey. Chemistry. */
65
+ "element",
66
+ /** One hue per chain. Which subunit is which, in a complex. */
67
+ "chain",
68
+ /** A spectrum from each chain's N terminus to its C terminus. Direction. */
69
+ "residue",
70
+ /** One colour throughout, whatever the node's own `color` is. */
71
+ "uniform",
72
+ ] as const
73
+
74
+ export type ProteinColorScheme = (typeof PROTEIN_COLOR_SCHEMES)[number]
75
+
76
+ /**
77
+ * The per-chain palette.
78
+ *
79
+ * Eight, spread around the wheel rather than sampled from a gradient: chains are
80
+ * a *nominal* scale — chain B is not between A and C in any sense — so the
81
+ * colours have to be distinguishable rather than ordered. A ninth chain wraps,
82
+ * which is the honest failure for a complex that large; the alternative is
83
+ * colours nobody can tell apart.
84
+ *
85
+ * Eight is therefore the *palette's* length rather than a constant anything
86
+ * downstream may assume: an author who edits these edits eight entries, and the
87
+ * wrap is modulo whatever the list holds.
88
+ */
89
+ export const CHAIN_COLORS = [
90
+ "#4f9dff",
91
+ "#ff8a3d",
92
+ "#4ddb9a",
93
+ "#e05fd0",
94
+ "#ffd166",
95
+ "#8b7bff",
96
+ "#ff6b6b",
97
+ "#3fd0d6",
98
+ ] as const
99
+
100
+ /**
101
+ * Helix, sheet and coil.
102
+ *
103
+ * The conventional assignment — warm for helices, cool for strands, pale for
104
+ * everything else — which is old enough to be read without a legend.
105
+ */
106
+ export const SECONDARY_COLORS: Readonly<Record<SecondaryStructure, string>> = {
107
+ helix: "#f0605d",
108
+ sheet: "#f5c451",
109
+ coil: "#c8ccd4",
110
+ }
111
+
112
+ /** Where the residue spectrum starts and ends, as hues in degrees. */
113
+ const SPECTRUM_START = 250
114
+ const SPECTRUM_END = 0
115
+
116
+ /** The saturation and lightness the residue spectrum is swept at. */
117
+ const SPECTRUM_SATURATION = 0.72
118
+ const SPECTRUM_LIGHTNESS = 0.58
119
+
120
+ /**
121
+ * Every colour a scheme reaches for that is **not** the node's own `color`.
122
+ *
123
+ * Three schemes paint from more than one colour — the fold's three states, the
124
+ * eight chain hues, the two ends of the N→C ramp — and until now all three were
125
+ * frozen in this file. They are conventional rather than arbitrary, which is why
126
+ * the defaults below are exactly what they were, but "conventional" is a good
127
+ * reason for a *default* and a poor one for a rule: a figure has a palette, and
128
+ * a molecule in it that refuses to join in is the one element on the slide
129
+ * somebody has to work around.
130
+ *
131
+ * A **read** shape rather than a prop. The node holds these as thirteen separate
132
+ * props and gathers them into one of these to hand to the colourers below, and
133
+ * that split is deliberate rather than clumsy: a prop is a whole value, so a
134
+ * single `palette` prop would make every `to` that reddens the helices a
135
+ * statement about all thirteen colours, snapping the twelve the author never
136
+ * touched back to whatever the command happened to carry. Thirteen props carry
137
+ * over one at a time and tween one at a time, which is what every other colour
138
+ * in the app does.
139
+ *
140
+ * Nothing here reaches the `element` scheme, deliberately. CPK is not a palette
141
+ * anybody chose — oxygen is red because oxygen is red — so it stays in
142
+ * `chemistry.ts` with the van der Waals radii, which are facts about the same
143
+ * atoms and equally not a matter of taste.
144
+ */
145
+ export interface ProteinPalette {
146
+ /** Secondary structure, when the scheme is `secondary`. */
147
+ helix: string
148
+ sheet: string
149
+ coil: string
150
+ /**
151
+ * One hue per chain, when the scheme is `chain`, wrapping past the end.
152
+ *
153
+ * A list rather than a fixed eight so the wrap follows what is actually here:
154
+ * an author who deletes down to three colours gets a three-colour cycle, which
155
+ * is a legible answer, where a wrap modulo a constant would index past it.
156
+ */
157
+ chains: readonly string[]
158
+ /** The two ends of the N→C ramp, when the scheme is `residue`. */
159
+ residueStart: string
160
+ residueEnd: string
161
+ }
162
+
163
+ /**
164
+ * The palette every scheme has always drawn — the conventional assignment, now
165
+ * stated as a value the author can take over rather than as a constant.
166
+ *
167
+ * The two spectrum ends are **computed** from the hues the ramp used to be
168
+ * written as, rather than typed out as hex, so the default sweep is the same
169
+ * blue-through-green-to-red it has always been by construction instead of by
170
+ * somebody having done the arithmetic correctly once.
171
+ */
172
+ export const DEFAULT_PROTEIN_PALETTE: ProteinPalette = {
173
+ helix: SECONDARY_COLORS.helix,
174
+ sheet: SECONDARY_COLORS.sheet,
175
+ coil: SECONDARY_COLORS.coil,
176
+ chains: CHAIN_COLORS,
177
+ residueStart: hslHex(SPECTRUM_START, SPECTRUM_SATURATION, SPECTRUM_LIGHTNESS),
178
+ residueEnd: hslHex(SPECTRUM_END, SPECTRUM_SATURATION, SPECTRUM_LIGHTNESS),
179
+ }
180
+
181
+ /**
182
+ * How many chain colours the palette offers, and therefore what the chain cycle
183
+ * wraps at.
184
+ *
185
+ * Eight, because that is how many hues can be told apart at a glance — the
186
+ * reason {@link CHAIN_COLORS} has eight — and it is named here because three
187
+ * places have to agree on it: the schema declares this many rows, the props
188
+ * mapper reads this many, and the node holds this many.
189
+ */
190
+ export const CHAIN_COLOR_COUNT = CHAIN_COLORS.length
191
+
192
+ /**
193
+ * The appearance-bag field names the palette is edited as, in panel order.
194
+ *
195
+ * Three places have to agree on this list and none of them can derive it from
196
+ * the others: the schema declares a row per name, `proteinMotionProps` copies a
197
+ * row per name onto the props, and the node declares a `@property` per name.
198
+ * The third is the one that cannot be written as a loop — a decorator is a
199
+ * declaration — so what this buys is the first two staying in step with it, and
200
+ * a test pins the third against it.
201
+ *
202
+ * `color` is deliberately not here. It is the node's own colour rather than part
203
+ * of a scheme's palette: it is what `uniform` paints and what every scheme falls
204
+ * back to, so it lives beside `representation` as a field of the molecule.
205
+ */
206
+ export const PROTEIN_PALETTE_FIELDS = [
207
+ "helixColor",
208
+ "sheetColor",
209
+ "coilColor",
210
+ "residueStartColor",
211
+ "residueEndColor",
212
+ ...CHAIN_COLORS.map((_, index) => `chainColor${index + 1}` as const),
213
+ ] as const
214
+
215
+ /** The default each {@link PROTEIN_PALETTE_FIELDS} entry carries, by name. */
216
+ export const PROTEIN_PALETTE_DEFAULTS: Readonly<Record<string, string>> = {
217
+ helixColor: DEFAULT_PROTEIN_PALETTE.helix,
218
+ sheetColor: DEFAULT_PROTEIN_PALETTE.sheet,
219
+ coilColor: DEFAULT_PROTEIN_PALETTE.coil,
220
+ residueStartColor: DEFAULT_PROTEIN_PALETTE.residueStart,
221
+ residueEndColor: DEFAULT_PROTEIN_PALETTE.residueEnd,
222
+ ...Object.fromEntries(
223
+ DEFAULT_PROTEIN_PALETTE.chains.map((colour, index) => [
224
+ `chainColor${index + 1}`,
225
+ colour,
226
+ ])
227
+ ),
228
+ }
229
+
230
+ /**
231
+ * Tween: two `#rrggbb` strings, blended per channel.
232
+ *
233
+ * The palette entries are the one family of colours here that stay **strings**
234
+ * rather than going through `resolveColor3D` — an instance tint is handed a hex
235
+ * string — so they need a tween of their own rather than {@link lerpColor3D}.
236
+ *
237
+ * Blended in RGB, unlike the residue ramp {@link spectrum} sweeps: that one is a
238
+ * *spectrum*, where covering the wheel is the whole point, while this is one
239
+ * colour becoming another, where the short straight path is what "fading to red"
240
+ * means. Anything unparseable snaps, which is the only honest answer for a value
241
+ * with no channels to interpolate.
242
+ */
243
+ export function lerpHexColor(from: string, to: string, t: number): string {
244
+ const a = hexRgb(from)
245
+ const b = hexRgb(to)
246
+ if (!a || !b) return t < 1 ? from : to
247
+ const byte = (x: number, y: number): string =>
248
+ Math.round(x + (y - x) * t)
249
+ .toString(16)
250
+ .padStart(2, "0")
251
+ return `#${byte(a[0], b[0])}${byte(a[1], b[1])}${byte(a[2], b[2])}`
252
+ }
253
+
254
+ /** A `#rrggbb` string as three 0–255 channels, or `null` if it isn't one. */
255
+ function hexRgb(value: string): [number, number, number] | null {
256
+ const hex = /^#?([0-9a-f]{6})$/i.exec(value.trim())
257
+ if (!hex) return null
258
+ const int = parseInt(hex[1]!, 16)
259
+ return [(int >> 16) & 255, (int >> 8) & 255, int & 255]
260
+ }
261
+
262
+ /**
263
+ * The colour of every atom, in the order the structure holds them.
264
+ *
265
+ * One flat array rather than a function called per atom, because the instance
266
+ * builder walks the whole list and an array is what `Graphics3D.instances` takes
267
+ * anyway.
268
+ */
269
+ export function atomColors(
270
+ structure: ProteinStructure,
271
+ scheme: ProteinColorScheme,
272
+ uniform: string,
273
+ palette: ProteinPalette = DEFAULT_PROTEIN_PALETTE
274
+ ): string[] {
275
+ if (scheme === "uniform") return structure.atoms.map(() => uniform)
276
+ if (scheme === "element") {
277
+ return structure.atoms.map((atom) => elementColor(atom.element))
278
+ }
279
+
280
+ const chains = chainOrder(structure)
281
+ if (scheme === "chain") {
282
+ return structure.atoms.map((atom) =>
283
+ chainColor(chains, atom.chain, palette)
284
+ )
285
+ }
286
+
287
+ if (scheme === "secondary") {
288
+ const lookup = secondaryByResidue(structure)
289
+ return structure.atoms.map(
290
+ (atom) => palette[lookup.get(residueKey(atom)) ?? "coil"]
291
+ )
292
+ }
293
+
294
+ // `residue` — a spectrum along each chain, so position is read against the
295
+ // chain the residue is in rather than against the whole file. Without that, a
296
+ // short chain beside a long one would be drawn in a single colour.
297
+ const positions = residuePositions(structure)
298
+ return structure.atoms.map((atom) => {
299
+ const position = positions.get(residueKey(atom))
300
+ return position === undefined ? uniform : spectrum(position, palette)
301
+ })
302
+ }
303
+
304
+ /**
305
+ * The colour of each point along one chain's backbone.
306
+ *
307
+ * Separate from {@link atomColors} because a trace point is a *residue* and an
308
+ * atom is not: the element scheme has nothing to say about a residue (every
309
+ * trace atom is a carbon, so the whole ribbon would be grey), so it falls back
310
+ * to the chain colouring — which is what somebody who picked "element" and then
311
+ * switched to a ribbon actually wants to see.
312
+ */
313
+ export function traceColors(
314
+ structure: ProteinStructure,
315
+ chain: ProteinChain,
316
+ scheme: ProteinColorScheme,
317
+ uniform: string,
318
+ palette: ProteinPalette = DEFAULT_PROTEIN_PALETTE
319
+ ): string[] {
320
+ if (scheme === "uniform") return chain.trace.map(() => uniform)
321
+ if (scheme === "secondary") {
322
+ return chain.trace.map((point) => palette[point.secondary])
323
+ }
324
+ if (scheme === "residue") {
325
+ const last = Math.max(1, chain.trace.length - 1)
326
+ return chain.trace.map((point) => spectrum(point.index / last, palette))
327
+ }
328
+
329
+ const chains = chainOrder(structure)
330
+ const colour = chainColor(chains, chain.id, palette)
331
+ return chain.trace.map(() => colour)
332
+ }
333
+
334
+ /**
335
+ * A colour on the N→C spectrum, `0` at the start of a chain and `1` at its end.
336
+ *
337
+ * Interpolated in **HSL**, which is what makes the default a spectrum at all: a
338
+ * straight RGB blend between the two ends passes through grey, where a hue sweep
339
+ * runs the ramp everybody recognises. And along the *direct numeric path*
340
+ * between the two hues rather than the shorter way round the wheel — 250° to 0°
341
+ * is the familiar blue-through-green-to-red run, and taking the short arc would
342
+ * hop through magenta instead and cover a quarter of the colours.
343
+ */
344
+ function spectrum(position: number, palette: ProteinPalette): string {
345
+ const from = hexHsl(palette.residueStart)
346
+ const to = hexHsl(palette.residueEnd)
347
+ const t = clamp01(position)
348
+ return hslHex(
349
+ from.h + (to.h - from.h) * t,
350
+ from.s + (to.s - from.s) * t,
351
+ from.l + (to.l - from.l) * t
352
+ )
353
+ }
354
+
355
+ function clamp01(value: number): number {
356
+ return Math.min(1, Math.max(0, value))
357
+ }
358
+
359
+ /**
360
+ * HSL to a `#rrggbb` string.
361
+ *
362
+ * Written out rather than reached for, because the spectrum is the one place
363
+ * this package generates a colour instead of being handed one, and every other
364
+ * colour path here takes hex.
365
+ */
366
+ function hslHex(hue: number, saturation: number, lightness: number): string {
367
+ const h = ((hue % 360) + 360) % 360
368
+ const c = (1 - Math.abs(2 * lightness - 1)) * saturation
369
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
370
+ const m = lightness - c / 2
371
+
372
+ const [r, g, b] =
373
+ h < 60
374
+ ? [c, x, 0]
375
+ : h < 120
376
+ ? [x, c, 0]
377
+ : h < 180
378
+ ? [0, c, x]
379
+ : h < 240
380
+ ? [0, x, c]
381
+ : h < 300
382
+ ? [x, 0, c]
383
+ : [c, 0, x]
384
+
385
+ const byte = (value: number): string =>
386
+ Math.round((value + m) * 255)
387
+ .toString(16)
388
+ .padStart(2, "0")
389
+ return `#${byte(r!)}${byte(g!)}${byte(b!)}`
390
+ }
391
+
392
+ /**
393
+ * A `#rrggbb` string back to HSL — the inverse of {@link hslHex}, and here for
394
+ * the one caller that needs it: the residue ramp is authored as two colours and
395
+ * swept as a hue.
396
+ *
397
+ * Anything unparseable comes back as the default ramp's own start, so a half
398
+ * typed hex in the inspector fades toward a colour rather than painting `NaN`
399
+ * into every residue of the chain.
400
+ */
401
+ function hexHsl(value: string): { h: number; s: number; l: number } {
402
+ const hex = /^#?([0-9a-f]{6})$/i.exec(value.trim())
403
+ if (!hex) return { h: SPECTRUM_START, s: SPECTRUM_SATURATION, l: SPECTRUM_LIGHTNESS }
404
+
405
+ const int = parseInt(hex[1]!, 16)
406
+ const r = ((int >> 16) & 255) / 255
407
+ const g = ((int >> 8) & 255) / 255
408
+ const b = (int & 255) / 255
409
+
410
+ const max = Math.max(r, g, b)
411
+ const min = Math.min(r, g, b)
412
+ const l = (max + min) / 2
413
+ const d = max - min
414
+ if (d === 0) return { h: 0, s: 0, l }
415
+
416
+ const s = d / (1 - Math.abs(2 * l - 1))
417
+ const h =
418
+ max === r
419
+ ? 60 * (((g - b) / d) % 6)
420
+ : max === g
421
+ ? 60 * ((b - r) / d + 2)
422
+ : 60 * ((r - g) / d + 4)
423
+ return { h: (h + 360) % 360, s, l }
424
+ }
425
+
426
+ /** The chain identifiers in the order they first appear, for palette indexing. */
427
+ function chainOrder(structure: ProteinStructure): Map<string, number> {
428
+ const order = new Map<string, number>()
429
+ for (const atom of structure.atoms) {
430
+ if (!order.has(atom.chain)) order.set(atom.chain, order.size)
431
+ }
432
+ return order
433
+ }
434
+
435
+ /** The palette entry for a chain, wrapping past the end. */
436
+ function chainColor(
437
+ order: Map<string, number>,
438
+ chain: string,
439
+ palette: ProteinPalette
440
+ ): string {
441
+ const colours =
442
+ palette.chains.length > 0 ? palette.chains : DEFAULT_PROTEIN_PALETTE.chains
443
+ const index = order.get(chain) ?? 0
444
+ return colours[index % colours.length]!
445
+ }
446
+
447
+ /** A residue's identity across the whole structure — its chain and its number. */
448
+ function residueKey(atom: ProteinAtom): string {
449
+ return `${atom.chain}:${atom.residueSeq}`
450
+ }
451
+
452
+ /**
453
+ * Each residue's position along its own chain, `0`–`1`.
454
+ *
455
+ * Read off the *traces* rather than counted over the atoms, so it means the same
456
+ * thing the ribbon means by it — and so a residue the file resolved no backbone
457
+ * for simply has no position, and takes the fallback colour rather than shifting
458
+ * every residue after it along the spectrum.
459
+ */
460
+ function residuePositions(structure: ProteinStructure): Map<string, number> {
461
+ const positions = new Map<string, number>()
462
+ for (const chain of structure.chains) {
463
+ const last = Math.max(1, chain.trace.length - 1)
464
+ for (const point of chain.trace) {
465
+ positions.set(`${chain.id}:${point.residueSeq}`, point.index / last)
466
+ }
467
+ }
468
+ return positions
469
+ }
470
+
471
+ /** Each residue's secondary structure, keyed as {@link residueKey} does. */
472
+ function secondaryByResidue(
473
+ structure: ProteinStructure
474
+ ): Map<string, SecondaryStructure> {
475
+ const lookup = new Map<string, SecondaryStructure>()
476
+ for (const chain of structure.chains) {
477
+ for (const point of chain.trace) {
478
+ lookup.set(`${chain.id}:${point.residueSeq}`, point.secondary)
479
+ }
480
+ }
481
+ return lookup
482
+ }
483
+
484
+ // --- Geometry --------------------------------------------------------------
485
+
486
+ /** An atom's drawn radius in Ångströms, at a given scale of its true size. */
487
+ export function atomRadius(atom: ProteinAtom, scale: number): number {
488
+ return vanDerWaalsRadius(atom.element) * scale
489
+ }
490
+
491
+ /**
492
+ * The placement of a stick running from `from` to `to`.
493
+ *
494
+ * A cylinder geometry is built along **+Y**, centred on the origin and one unit
495
+ * tall, so placing one takes all three parts of a transform: move it to the
496
+ * midpoint, scale it to the bond's length, and turn +Y onto the bond's
497
+ * direction.
498
+ *
499
+ * The turn is a quaternion rather than an Euler triple because there is no
500
+ * ordering of three axis rotations that doesn't gimbal somewhere, and a molecule
501
+ * has bonds pointing every way there is — one of them would land exactly on the
502
+ * degenerate axis and the stick would spin to a wrong orientation.
503
+ */
504
+ export function stickPlacement(
505
+ from: Vector3,
506
+ to: Vector3,
507
+ radius: number
508
+ ): Transform3D {
509
+ const dx = to.x - from.x
510
+ const dy = to.y - from.y
511
+ const dz = to.z - from.z
512
+ const length = Math.hypot(dx, dy, dz) || 1
513
+
514
+ return {
515
+ position: [
516
+ (from.x + to.x) / 2,
517
+ (from.y + to.y) / 2,
518
+ (from.z + to.z) / 2,
519
+ ],
520
+ scale: [radius, length, radius],
521
+ quaternion: upTo(dx / length, dy / length, dz / length),
522
+ }
523
+ }
524
+
525
+ /**
526
+ * The shortest rotation taking **+Y** onto a unit direction.
527
+ *
528
+ * The general form is "rotate about the axis perpendicular to both, by the angle
529
+ * between them", which for a fixed source axis collapses to the cross product
530
+ * with `(0, 1, 0)` — hence the two components rather than three.
531
+ *
532
+ * The antiparallel case has to be handled outright: a direction pointing
533
+ * straight down has a *zero* cross product with up, so the axis is undefined and
534
+ * the general form produces a quaternion of all zeros, which is not a rotation
535
+ * at all. Any half-turn about a perpendicular axis is correct there, and X is as
536
+ * good as any.
537
+ */
538
+ function upTo(x: number, y: number, z: number): Quaternion {
539
+ if (y > 0.999999) return { x: 0, y: 0, z: 0, w: 1 }
540
+ if (y < -0.999999) return { x: 1, y: 0, z: 0, w: 0 }
541
+
542
+ // axis = up × d, normalized; angle = acos(up · d) = acos(y).
543
+ const axisX = z
544
+ const axisZ = -x
545
+ const axisLength = Math.hypot(axisX, axisZ) || 1
546
+ const angle = Math.acos(Math.min(1, Math.max(-1, y)))
547
+ const sin = Math.sin(angle / 2)
548
+
549
+ return {
550
+ x: (axisX / axisLength) * sin,
551
+ y: 0,
552
+ z: (axisZ / axisLength) * sin,
553
+ w: Math.cos(angle / 2),
554
+ }
555
+ }
556
+
557
+ /** One stretch of a chain drawn as a single swept tube. */
558
+ export interface BackboneRun {
559
+ points: Vector3[]
560
+ color: string
561
+ secondary: SecondaryStructure
562
+ }
563
+
564
+ /**
565
+ * Splits a chain's trace into the runs a backbone is swept as.
566
+ *
567
+ * A run is a stretch that can be drawn as **one** `Geo.tube`, and what forces a
568
+ * break is anything the tube carries once for its whole length: its colour
569
+ * always, and — for a cartoon — its radius, since a helix that doesn't swell is
570
+ * not a helix anyone will recognise.
571
+ *
572
+ * Consecutive runs **overlap by one point**, which is what keeps the tubes
573
+ * meeting rather than leaving a gap at every transition: the shared point is the
574
+ * end of one sweep and the start of the next, so their ends sit in the same
575
+ * place.
576
+ *
577
+ * The trade this makes is worth stating. A tube is swept along a Catmull-Rom
578
+ * curve through its own points, so a long run comes out smooth and a two-point
579
+ * run comes out straight. Under the colourings a fold is usually drawn in —
580
+ * secondary structure, chain, one colour — runs are long and the curve is
581
+ * smooth. Under the residue spectrum every point is its own colour, so the
582
+ * chain becomes a faceted polyline with a gradient along it. That is the honest
583
+ * cost of colouring per residue without a mesh built vertex by vertex, and at
584
+ * the scale a whole fold is viewed at it reads as a curve anyway.
585
+ */
586
+ export function backboneRuns(
587
+ chain: ProteinChain,
588
+ colors: string[],
589
+ splitBySecondary: boolean
590
+ ): BackboneRun[] {
591
+ const runs: BackboneRun[] = []
592
+
593
+ for (let index = 0; index < chain.trace.length; index++) {
594
+ const point = chain.trace[index]!
595
+ const color = colors[index] ?? colors[0] ?? "#ffffff"
596
+ let current = runs[runs.length - 1]
597
+
598
+ const breaks =
599
+ !current ||
600
+ current.color !== color ||
601
+ (splitBySecondary && current.secondary !== point.secondary)
602
+
603
+ if (breaks) {
604
+ // Carry the previous run's last point in as this one's first, so the two
605
+ // sweeps butt up against each other.
606
+ const seed = current ? [current.points[current.points.length - 1]!] : []
607
+ current = { points: seed, color, secondary: point.secondary }
608
+ runs.push(current)
609
+ }
610
+ current.points.push({ x: point.x, y: point.y, z: point.z })
611
+ }
612
+
613
+ // A run of one point is not a curve — there is no direction to sweep along.
614
+ // It can only be the very first run when the trace starts with a lone point,
615
+ // since every later run is seeded with its predecessor's last point.
616
+ return runs.filter((run) => run.points.length > 1)
617
+ }
618
+
619
+ /** The colour a `Color` prop resolves to when a scheme wants a plain string. */
620
+ export function hexOf(value: Color): string {
621
+ return typeof value === "string" ? value : "#ffffff"
622
+ }