@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,658 @@
1
+ /**
2
+ * The cartoon: a chain's backbone swept as a **ribbon** rather than as a tube.
3
+ *
4
+ * This is the one representation whose whole job is to be recognised. A figure
5
+ * in a paper draws a helix as a twisted band and a strand as a flat arrow, and
6
+ * has since Jane Richardson drew the first one by hand in 1980 — the convention
7
+ * is old enough that a reader decodes it without a legend, which is exactly why
8
+ * a picture that departs from it reads as *wrong* rather than as different.
9
+ *
10
+ * The node drew that with `Geo.tube` before this module existed, and the two
11
+ * things it could not express are the two the convention is made of:
12
+ *
13
+ * **A cross-section has a width and a thickness, not a radius.** A tube's is a
14
+ * circle, so a helix could only be told from a coil by being *fatter* — and a
15
+ * fat circle swept along the α-carbons of a helix is close to self-intersecting,
16
+ * because those carbons corkscrew about the helix axis at ~2.3 Å with a 5.4 Å
17
+ * pitch. What comes out is a lumpy sausage: the tube's own diameter is most of
18
+ * the coil's, so the spiral fills in and the shape stops being legible. Swept
19
+ * flat instead — ~2.2 Å across and ~0.5 Å thick — the same spiral reads as the
20
+ * band every textbook draws, because the eye is now reading the *face* turning
21
+ * rather than a diameter changing.
22
+ *
23
+ * **A band has a side, and a curve through α-carbons does not say which.** So
24
+ * the frame is built from the residue's own carbonyl (see
25
+ * {@link TracePoint.normal}), which is what turns with the fold. A ribbon swept
26
+ * on an arbitrary frame twists at random and looks like a misprint.
27
+ *
28
+ * Everything here is derived **per structure**, cached by the node against the
29
+ * inputs that produced it, and never rebuilt for a camera move — the same
30
+ * bargain the instance lists make in `shared.ts`.
31
+ */
32
+
33
+ import type { BufferGeometry3D, Color, Vector3 } from "@motionscript/core"
34
+
35
+ import { resolveColor3D } from "@motionscript/core/component"
36
+ import type { ProteinChain, SecondaryStructure, TracePoint } from "./structure"
37
+
38
+ /**
39
+ * A run of backbone drawn as one mesh: a whole stretch of one secondary
40
+ * structure, with its per-residue colours carried as a vertex attribute.
41
+ *
42
+ * The unit is the **span**, not the run of constant colour that a tube is swept
43
+ * as (see `backboneRuns`), and both halves of that matter. An arrowhead is a
44
+ * statement about where a strand *ends*, so it can only be placed by something
45
+ * that can see the whole strand — split the strand into four differently
46
+ * coloured pieces first and each piece grows an arrow of its own. And a colour
47
+ * that varies along the span is what a vertex attribute is for, which is also
48
+ * what retires the faceted-polyline compromise the tube path has to make under
49
+ * the residue spectrum.
50
+ */
51
+ export interface CartoonSpan {
52
+ /** What this stretch is doing, which decides its cross-section. */
53
+ secondary: SecondaryStructure
54
+ /**
55
+ * The residues of the span, N→C, **overlapping its neighbours by one point**
56
+ * at each end so consecutive spans meet rather than leaving a gap.
57
+ */
58
+ points: TracePoint[]
59
+ /** One sRGB colour per entry of {@link points}. */
60
+ colors: string[]
61
+ }
62
+
63
+ /**
64
+ * How wide and how thick each secondary structure's ribbon is, as multiples of
65
+ * the node's `ribbonRadius`.
66
+ *
67
+ * Stated as a ratio to one control rather than as six numbers in the inspector:
68
+ * "how heavy is the cartoon" is one question an author actually asks, and the
69
+ * *proportions* between a helix, a strand and a loop are the convention rather
70
+ * than a preference. The absolute values are the ones the established viewers
71
+ * settled on — a helix band about 2.2 Å across and half an Ångström thick, a
72
+ * strand slightly wider still, and a loop a thin round cord.
73
+ *
74
+ * A coil stays round (width equals thickness), which is the other half of the
75
+ * statement: a loop has no face to turn, so giving it one would claim a
76
+ * structure the file did not annotate.
77
+ */
78
+ const SECTION: Readonly<
79
+ Record<SecondaryStructure, { width: number; thickness: number }>
80
+ > = {
81
+ helix: { width: 2.4, thickness: 0.5 },
82
+ sheet: { width: 2.6, thickness: 0.45 },
83
+ coil: { width: 1, thickness: 1 },
84
+ }
85
+
86
+ /** How wide a strand's arrowhead is at its base, in the same multiples. */
87
+ const ARROW_WIDTH = 4.4
88
+
89
+ /**
90
+ * How many residues at the C-terminal end of a strand the arrowhead occupies.
91
+ *
92
+ * Residues rather than a fraction of the span, because an arrowhead is a fixed
93
+ * *shape* — the same barb whether the strand is five residues or fifteen. A
94
+ * fraction would draw a stubby wedge on a short strand and a long spike on a
95
+ * β-hairpin's arm. Capped below at a third of the span so a three-residue
96
+ * strand is still a strand with a point on it rather than one long taper.
97
+ */
98
+ const ARROW_RESIDUES = 2.4
99
+
100
+ /** What a strand's arrow narrows to at its tip, so the point is a point. */
101
+ const ARROW_TIP = 0.08
102
+
103
+ /**
104
+ * How many samples each residue-to-residue step is drawn with.
105
+ *
106
+ * The curve is the picture here. Consecutive α-carbons are 3.8 Å apart and a
107
+ * helix turns fully in 3.6 of them, so a ribbon drawn straight between residues
108
+ * is a polygon with about five sides per turn — which is exactly the faceted,
109
+ * angular look that separates a rendering nobody trusts from one that reads as
110
+ * a molecule. Tied to the node's mesh detail so the same control that coarsens
111
+ * the spheres coarsens this.
112
+ */
113
+ function subdivisionsFor(quality: number): number {
114
+ return Math.min(10, Math.max(3, Math.round(quality / 2)))
115
+ }
116
+
117
+ /**
118
+ * Splits a chain's trace into the spans a cartoon is built from — one per
119
+ * unbroken stretch of the same secondary structure.
120
+ *
121
+ * Consecutive spans overlap by one point, for the reason `backboneRuns`'
122
+ * consecutive runs do: the shared residue is the last sample of one sweep and
123
+ * the first of the next, so the two meshes butt up rather than leaving a hole
124
+ * at every helix-to-loop transition.
125
+ */
126
+ export function cartoonSpans(
127
+ chain: ProteinChain,
128
+ colors: string[]
129
+ ): CartoonSpan[] {
130
+ const spans: CartoonSpan[] = []
131
+
132
+ chain.trace.forEach((point, index) => {
133
+ const color = colors[index] ?? colors[0] ?? "#ffffff"
134
+ let current = spans[spans.length - 1]
135
+
136
+ if (!current || current.secondary !== point.secondary) {
137
+ const previous = current
138
+ current = { secondary: point.secondary, points: [], colors: [] }
139
+ // Carry the previous span's last residue in as this one's first.
140
+ if (previous) {
141
+ current.points.push(previous.points[previous.points.length - 1]!)
142
+ current.colors.push(previous.colors[previous.colors.length - 1]!)
143
+ }
144
+ spans.push(current)
145
+ }
146
+
147
+ current.points.push(point)
148
+ current.colors.push(color)
149
+ })
150
+
151
+ // A lone point is not a curve: there is no direction to sweep along and no
152
+ // tangent to build a frame from. Only ever the first span, since every later
153
+ // one is seeded with its predecessor's last point.
154
+ return spans.filter((span) => span.points.length > 1)
155
+ }
156
+
157
+ /**
158
+ * One span, as a mesh.
159
+ *
160
+ * `null` when there is nothing to sweep, so the caller can skip the op rather
161
+ * than hand the renderer an empty buffer.
162
+ *
163
+ * The arrays are freshly allocated and never mutated afterwards, which is what
164
+ * makes `staticData` honest: the renderer then compares them by identity and
165
+ * uploads each exactly once, where re-deriving them per frame would re-upload
166
+ * the whole fold sixty times a second to spin the camera.
167
+ */
168
+ export function cartoonGeometry(
169
+ span: CartoonSpan,
170
+ options: { radius: number; quality: number }
171
+ ): BufferGeometry3D | null {
172
+ const samples = sampleSpan(span, options)
173
+ if (samples.length < 2) return null
174
+ return sweep(samples, profileFor(span.secondary, options.quality))
175
+ }
176
+
177
+ // --- Sampling --------------------------------------------------------------
178
+
179
+ /** One cross-section of the sweep: where it is, how it is turned, how big. */
180
+ interface Sample {
181
+ position: Vector3
182
+ /** Unit tangent — the direction the sweep is travelling. */
183
+ tangent: Vector3
184
+ /** Unit **wide** direction: the ribbon's face turns with this. */
185
+ normal: Vector3
186
+ /** Unit binormal, completing the frame. */
187
+ binormal: Vector3
188
+ halfWidth: number
189
+ halfThickness: number
190
+ /** Linear RGB, as a vertex colour attribute holds it. */
191
+ color: [number, number, number]
192
+ }
193
+
194
+ /**
195
+ * The span, resampled onto a smooth curve with a frame and a size at every
196
+ * sample.
197
+ *
198
+ * Three things happen here that each fix something visible:
199
+ *
200
+ * **Catmull-Rom through the residues**, so the ribbon is a curve rather than a
201
+ * five-sided polygon per helical turn — see {@link subdivisionsFor}.
202
+ *
203
+ * **The frames are made continuous before anything is interpolated.** A
204
+ * β-strand's carbonyls point alternately to one side and the other, which is
205
+ * chemistry rather than noise: hydrogen bonds go to the neighbouring strand on
206
+ * both sides. Swept as given, the ribbon would flip 180° at every residue and
207
+ * come out as a chain of triangles. Flipping each frame to agree with its
208
+ * predecessor is what every viewer does and what makes a strand flat.
209
+ *
210
+ * **The arrow is measured in residues from the C-terminal end**, before
211
+ * subdivision, so it is the same barb on a long strand and a short one.
212
+ */
213
+ function sampleSpan(
214
+ span: CartoonSpan,
215
+ options: { radius: number; quality: number }
216
+ ): Sample[] {
217
+ const points = span.points
218
+ const count = points.length
219
+ const section = SECTION[span.secondary]
220
+ const radius = Math.max(0.01, options.radius)
221
+
222
+ const frames = continuousFrames(points)
223
+ const tints = span.colors.map((color) => linearRgb(color))
224
+
225
+ // Where the arrowhead starts, as a position along the span in *residues*.
226
+ // A strand shorter than the barb gives up a third of itself to it rather than
227
+ // becoming one long taper.
228
+ const arrow =
229
+ span.secondary === "sheet"
230
+ ? Math.max(count - 1 - ARROW_RESIDUES, (count - 1) * (2 / 3))
231
+ : Infinity
232
+
233
+ const steps = subdivisionsFor(options.quality)
234
+ const samples: Sample[] = []
235
+
236
+ // Positions first, frames second: a tangent is a finite difference over the
237
+ // *sampled* curve, so every position has to exist before any of them can be
238
+ // taken. Doing it in one pass would take each tangent from the residues
239
+ // instead, which is the polyline this subdivision exists to get away from.
240
+ const curve: Vector3[] = []
241
+ const carried: Vector3[] = []
242
+ const colors: [number, number, number][] = []
243
+ const positions: number[] = []
244
+
245
+ for (let i = 0; i < count - 1; i++) {
246
+ // The last segment emits its endpoint too, so the sweep reaches the final
247
+ // residue rather than stopping one sample short of it.
248
+ const last = i === count - 2
249
+ for (let s = 0; s < steps + (last ? 1 : 0); s++) {
250
+ const t = s / steps
251
+ curve.push(
252
+ catmullRom(
253
+ points[i - 1] ?? points[i]!,
254
+ points[i]!,
255
+ points[i + 1]!,
256
+ points[i + 2] ?? points[i + 1]!,
257
+ t
258
+ )
259
+ )
260
+ carried.push(lerpVector(frames[i]!, frames[i + 1]!, t))
261
+ colors.push(lerpRgb(tints[i]!, tints[i + 1]!, t))
262
+ positions.push(i + t)
263
+ }
264
+ }
265
+
266
+ for (let i = 0; i < curve.length; i++) {
267
+ const position = curve[i]!
268
+ const tangent = normalize(
269
+ subtract(
270
+ curve[Math.min(curve.length - 1, i + 1)]!,
271
+ curve[Math.max(0, i - 1)]!
272
+ )
273
+ )
274
+ // Squared against the tangent, so the frame is orthonormal even though the
275
+ // carbonyl it came from is not perpendicular to the chain.
276
+ const normal = perpendicular(carried[i]!, tangent)
277
+ const binormal = normalize(cross(tangent, normal))
278
+
279
+ const along = positions[i]!
280
+ const width =
281
+ along <= arrow
282
+ ? section.width
283
+ : // Linear from the barb's base to its point. The base is *wider* than
284
+ // the strand, which is what makes an arrowhead read as one rather
285
+ // than as the strand simply stopping.
286
+ ARROW_WIDTH +
287
+ (ARROW_TIP - ARROW_WIDTH) *
288
+ clamp01((along - arrow) / Math.max(1e-6, count - 1 - arrow))
289
+
290
+ samples.push({
291
+ position,
292
+ tangent,
293
+ normal,
294
+ binormal,
295
+ halfWidth: radius * width,
296
+ halfThickness: radius * section.thickness,
297
+ color: colors[i]!,
298
+ })
299
+ }
300
+
301
+ return samples
302
+ }
303
+
304
+ /**
305
+ * Each residue's ribbon direction, made to agree with its predecessor.
306
+ *
307
+ * Falls back to the curve's own binormal — the axis its turn is about — where
308
+ * the file gives no carbonyl. That is the right stand-in rather than an
309
+ * arbitrary perpendicular: in a helix the carbonyl points very nearly along the
310
+ * helix axis, and so does the binormal of the curve the α-carbons trace, so a
311
+ * Cα-only model comes out with its bands lying the same way a complete file's
312
+ * do.
313
+ */
314
+ function continuousFrames(points: TracePoint[]): Vector3[] {
315
+ const frames: Vector3[] = []
316
+
317
+ for (let i = 0; i < points.length; i++) {
318
+ const point = points[i]!
319
+ let direction = point.normal ?? binormalAt(points, i)
320
+ // Degenerate — three collinear residues with no carbonyl. Any perpendicular
321
+ // will do, and the frame is squared against the tangent anyway.
322
+ if (lengthOf(direction) < 1e-6) direction = { x: 0, y: 0, z: 1 }
323
+
324
+ const previous = frames[i - 1]
325
+ // The 180° flip a β-strand's alternating carbonyls would otherwise put into
326
+ // the sweep. See {@link sampleSpan}.
327
+ if (previous && dot(previous, direction) < 0) direction = negate(direction)
328
+ frames.push(normalize(direction))
329
+ }
330
+
331
+ return frames
332
+ }
333
+
334
+ /** The curve's binormal at a residue — the axis its local turn is about. */
335
+ function binormalAt(points: TracePoint[], index: number): Vector3 {
336
+ const before = points[Math.max(0, index - 1)]!
337
+ const here = points[index]!
338
+ const after = points[Math.min(points.length - 1, index + 1)]!
339
+ return cross(subtract(here, before), subtract(after, here))
340
+ }
341
+
342
+ // --- Sweeping --------------------------------------------------------------
343
+
344
+ /**
345
+ * One vertex of a cross-section: where it sits in the frame's `(wide, thick)`
346
+ * plane, and which way the surface faces there.
347
+ *
348
+ * A hard edge is **two entries at the same place with different normals**,
349
+ * which is what gives a ribbon the crisp corner a cartoon has rather than the
350
+ * chamfered one a shared-vertex mesh with averaged normals would. It is also
351
+ * why the normals are stated rather than derived: `computeNormals` averages
352
+ * across whatever shares a vertex, and averaging is exactly what a corner must
353
+ * not do.
354
+ */
355
+ interface ProfileVertex {
356
+ /** Position along the ribbon's width, in `[-1, 1]`. */
357
+ u: number
358
+ /** Position along its thickness, in `[-1, 1]`. */
359
+ v: number
360
+ /** Surface direction at that vertex, in the same plane. */
361
+ nu: number
362
+ nv: number
363
+ }
364
+
365
+ /**
366
+ * The cross-section a secondary structure is swept with.
367
+ *
368
+ * A loop is a round cord and a helix or a strand is a flat band — the whole
369
+ * distinction the convention rests on, and the one a radius could not express.
370
+ * The band is a rectangle rather than an ellipse because its *edge* is what the
371
+ * eye reads a twist by: an ellipse turning through 90° goes smoothly from wide
372
+ * to narrow and reads as a tube pinching, where a rectangle's corner catches the
373
+ * light and reads as a face turning over.
374
+ */
375
+ function profileFor(
376
+ secondary: SecondaryStructure,
377
+ quality: number
378
+ ): ProfileVertex[] {
379
+ if (secondary === "coil") {
380
+ const sides = Math.min(16, Math.max(5, Math.round(quality / 2)))
381
+ const profile: ProfileVertex[] = []
382
+ for (let i = 0; i < sides; i++) {
383
+ const angle = (i / sides) * Math.PI * 2
384
+ const u = Math.cos(angle)
385
+ const v = Math.sin(angle)
386
+ // Smooth: one vertex per side, its normal radial, so a cord reads as
387
+ // round rather than as a faceted prism.
388
+ profile.push({ u, v, nu: u, nv: v })
389
+ }
390
+ return profile
391
+ }
392
+
393
+ // Four faces, each with its own pair of corners, walked so consecutive
394
+ // entries are consecutive around the section.
395
+ return [
396
+ { u: 1, v: 1, nu: 0, nv: 1 },
397
+ { u: -1, v: 1, nu: 0, nv: 1 },
398
+ { u: -1, v: 1, nu: -1, nv: 0 },
399
+ { u: -1, v: -1, nu: -1, nv: 0 },
400
+ { u: -1, v: -1, nu: 0, nv: -1 },
401
+ { u: 1, v: -1, nu: 0, nv: -1 },
402
+ { u: 1, v: -1, nu: 1, nv: 0 },
403
+ { u: 1, v: 1, nu: 1, nv: 0 },
404
+ ]
405
+ }
406
+
407
+ /**
408
+ * The samples and the profile, as one indexed mesh.
409
+ *
410
+ * Ends are **capped**: a ribbon is a solid band and an open end shows the
411
+ * inside of the far face, which at a helix-to-loop transition would be a
412
+ * visible hole rather than a join. Cheap — one fan per end — and it is what
413
+ * lets a span be drawn as its own mesh at all.
414
+ */
415
+ function sweep(samples: Sample[], profile: ProfileVertex[]): BufferGeometry3D {
416
+ const ring = profile.length
417
+ const position: number[] = []
418
+ const normal: number[] = []
419
+ const color: number[] = []
420
+ const index: number[] = []
421
+
422
+ for (const sample of samples) {
423
+ for (const vertex of profile) {
424
+ const u = vertex.u * sample.halfWidth
425
+ const v = vertex.v * sample.halfThickness
426
+ position.push(
427
+ sample.position.x + sample.normal.x * u + sample.binormal.x * v,
428
+ sample.position.y + sample.normal.y * u + sample.binormal.y * v,
429
+ sample.position.z + sample.normal.z * u + sample.binormal.z * v
430
+ )
431
+ // A normal does not survive a non-uniform scale the way a position does:
432
+ // squashing a circle to a 5:1 ellipse tips every one of its normals
433
+ // toward the wide axis. Dividing by the half-extents is the inverse
434
+ // transpose of that scale, written out for two dimensions.
435
+ const n = normalize2(
436
+ vertex.nu / sample.halfWidth,
437
+ vertex.nv / sample.halfThickness
438
+ )
439
+ normal.push(
440
+ sample.normal.x * n[0] + sample.binormal.x * n[1],
441
+ sample.normal.y * n[0] + sample.binormal.y * n[1],
442
+ sample.normal.z * n[0] + sample.binormal.z * n[1]
443
+ )
444
+ color.push(sample.color[0], sample.color[1], sample.color[2])
445
+ }
446
+ }
447
+
448
+ for (let i = 0; i < samples.length - 1; i++) {
449
+ for (let j = 0; j < ring; j++) {
450
+ const next = (j + 1) % ring
451
+ const a = profile[j]!
452
+ const b = profile[next]!
453
+ // The two halves of a hard corner sit in the same place, so the quad
454
+ // between them has no area. Skipping it keeps the index buffer honest
455
+ // rather than filling it with degenerate triangles.
456
+ if (a.u === b.u && a.v === b.v) continue
457
+ const i0 = i * ring + j
458
+ const i1 = i * ring + next
459
+ const i2 = (i + 1) * ring + next
460
+ const i3 = (i + 1) * ring + j
461
+ index.push(i0, i1, i2, i0, i2, i3)
462
+ }
463
+ }
464
+
465
+ capEnd(samples[0]!, profile, position, normal, color, index, false)
466
+ capEnd(
467
+ samples[samples.length - 1]!,
468
+ profile,
469
+ position,
470
+ normal,
471
+ color,
472
+ index,
473
+ true
474
+ )
475
+
476
+ return {
477
+ type: "buffer",
478
+ position: new Float32Array(position),
479
+ normal: new Float32Array(normal),
480
+ color: new Float32Array(color),
481
+ index: new Uint32Array(index),
482
+ // Built once per structure and cached by the node; nothing mutates these
483
+ // arrays afterwards, so the renderer may compare them by identity alone.
484
+ staticData: true,
485
+ }
486
+ }
487
+
488
+ /** A triangle fan closing one end of the sweep, facing along the tangent. */
489
+ function capEnd(
490
+ sample: Sample,
491
+ profile: ProfileVertex[],
492
+ position: number[],
493
+ normal: number[],
494
+ color: number[],
495
+ index: number[],
496
+ forward: boolean
497
+ ): void {
498
+ const sign = forward ? 1 : -1
499
+ const nx = sample.tangent.x * sign
500
+ const ny = sample.tangent.y * sign
501
+ const nz = sample.tangent.z * sign
502
+
503
+ const center = position.length / 3
504
+ position.push(sample.position.x, sample.position.y, sample.position.z)
505
+ normal.push(nx, ny, nz)
506
+ color.push(sample.color[0], sample.color[1], sample.color[2])
507
+
508
+ const first = position.length / 3
509
+ for (const vertex of profile) {
510
+ const u = vertex.u * sample.halfWidth
511
+ const v = vertex.v * sample.halfThickness
512
+ position.push(
513
+ sample.position.x + sample.normal.x * u + sample.binormal.x * v,
514
+ sample.position.y + sample.normal.y * u + sample.binormal.y * v,
515
+ sample.position.z + sample.normal.z * u + sample.binormal.z * v
516
+ )
517
+ normal.push(nx, ny, nz)
518
+ color.push(sample.color[0], sample.color[1], sample.color[2])
519
+ }
520
+
521
+ for (let j = 0; j < profile.length; j++) {
522
+ const next = (j + 1) % profile.length
523
+ // Wound the opposite way at the two ends, so both caps face outward.
524
+ if (forward) index.push(center, first + j, first + next)
525
+ else index.push(center, first + next, first + j)
526
+ }
527
+ }
528
+
529
+ // --- Small vector maths ----------------------------------------------------
530
+ //
531
+ // Written out rather than reached for, on the same terms as `chemistry.ts`:
532
+ // this package has to stay loadable in a bare Node process, and none of it is
533
+ // more than a few lines.
534
+
535
+ function catmullRom(
536
+ p0: { x: number; y: number; z: number },
537
+ p1: { x: number; y: number; z: number },
538
+ p2: { x: number; y: number; z: number },
539
+ p3: { x: number; y: number; z: number },
540
+ t: number
541
+ ): Vector3 {
542
+ const t2 = t * t
543
+ const t3 = t2 * t
544
+ const a = -0.5 * t3 + t2 - 0.5 * t
545
+ const b = 1.5 * t3 - 2.5 * t2 + 1
546
+ const c = -1.5 * t3 + 2 * t2 + 0.5 * t
547
+ const d = 0.5 * t3 - 0.5 * t2
548
+ return {
549
+ x: p0.x * a + p1.x * b + p2.x * c + p3.x * d,
550
+ y: p0.y * a + p1.y * b + p2.y * c + p3.y * d,
551
+ z: p0.z * a + p1.z * b + p2.z * c + p3.z * d,
552
+ }
553
+ }
554
+
555
+ function subtract(
556
+ a: { x: number; y: number; z: number },
557
+ b: { x: number; y: number; z: number }
558
+ ): Vector3 {
559
+ return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }
560
+ }
561
+
562
+ function cross(a: Vector3, b: Vector3): Vector3 {
563
+ return {
564
+ x: a.y * b.z - a.z * b.y,
565
+ y: a.z * b.x - a.x * b.z,
566
+ z: a.x * b.y - a.y * b.x,
567
+ }
568
+ }
569
+
570
+ function dot(a: Vector3, b: Vector3): number {
571
+ return a.x * b.x + a.y * b.y + a.z * b.z
572
+ }
573
+
574
+ function negate(v: Vector3): Vector3 {
575
+ return { x: -v.x, y: -v.y, z: -v.z }
576
+ }
577
+
578
+ function lengthOf(v: Vector3): number {
579
+ return Math.hypot(v.x, v.y, v.z)
580
+ }
581
+
582
+ function normalize(v: Vector3): Vector3 {
583
+ const length = lengthOf(v)
584
+ if (length < 1e-9) return { x: 0, y: 0, z: 1 }
585
+ return { x: v.x / length, y: v.y / length, z: v.z / length }
586
+ }
587
+
588
+ function normalize2(u: number, v: number): [number, number] {
589
+ const length = Math.hypot(u, v)
590
+ if (length < 1e-9) return [1, 0]
591
+ return [u / length, v / length]
592
+ }
593
+
594
+ /** `v` with everything along `axis` taken out of it, normalized. */
595
+ function perpendicular(v: Vector3, axis: Vector3): Vector3 {
596
+ const along = dot(v, axis)
597
+ const out = {
598
+ x: v.x - axis.x * along,
599
+ y: v.y - axis.y * along,
600
+ z: v.z - axis.z * along,
601
+ }
602
+ // The carbonyl parallel to the chain — vanishingly rare, but it would leave
603
+ // no frame at all. Any perpendicular is correct; take one from the axis.
604
+ if (lengthOf(out) < 1e-6) {
605
+ const fallback =
606
+ Math.abs(axis.x) < 0.9 ? { x: 1, y: 0, z: 0 } : { x: 0, y: 1, z: 0 }
607
+ return normalize(cross(axis, fallback))
608
+ }
609
+ return normalize(out)
610
+ }
611
+
612
+ function lerpVector(
613
+ a: { x: number; y: number; z: number },
614
+ b: { x: number; y: number; z: number },
615
+ t: number
616
+ ): Vector3 {
617
+ return {
618
+ x: a.x + (b.x - a.x) * t,
619
+ y: a.y + (b.y - a.y) * t,
620
+ z: a.z + (b.z - a.z) * t,
621
+ }
622
+ }
623
+
624
+ function lerpRgb(
625
+ a: [number, number, number],
626
+ b: [number, number, number],
627
+ t: number
628
+ ): [number, number, number] {
629
+ return [
630
+ a[0] + (b[0] - a[0]) * t,
631
+ a[1] + (b[1] - a[1]) * t,
632
+ a[2] + (b[2] - a[2]) * t,
633
+ ]
634
+ }
635
+
636
+ function clamp01(value: number): number {
637
+ return Math.min(1, Math.max(0, value))
638
+ }
639
+
640
+ /**
641
+ * A colour as the **linear** RGB triple a vertex attribute holds.
642
+ *
643
+ * The same conversion `grid3d.ts` documents and for the same reason: three
644
+ * reads vertex colours as linear, so an sRGB channel uploaded raw paints far
645
+ * too bright. Skipping it here would wash every ribbon out against the atoms
646
+ * beside it, which take their colour through the material instead.
647
+ */
648
+ function linearRgb(value: Color): [number, number, number] {
649
+ const [r, g, b] = resolveColor3D(value)
650
+ return [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)]
651
+ }
652
+
653
+ /** The sRGB transfer function, inverted. three's own `SRGBToLinear`. */
654
+ function srgbToLinear(channel: number): number {
655
+ return channel < 0.04045
656
+ ? channel * 0.0773993808
657
+ : Math.pow(channel * 0.9478672986 + 0.0521327014, 2.4)
658
+ }