All files / src/structure three.ts

22.59% Statements 216/956
47.82% Branches 11/23
25% Functions 9/36
22.59% Lines 216/956

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384                                                                                                                                    1x 1x 1x   1x         1x     1x                   1x     1x     1x   1x 1x 1x 1x 1x 1x 77x 77x 77x 77x 77x 77x 77x   77x   77x   77x     77x 77x   77x 77x 77x 77x   77x   77x   77x 77x 77x   77x   77x 77x   77x 77x   77x   77x 77x 77x 77x 77x 77x   77x   77x   77x 77x   77x 77x 77x 77x 77x 77x 77x 77x 77x 77x   77x 77x   77x   1x 77x 77x 77x 77x 77x 77x 77x 77x 77x 77x 77x 77x 77x     77x 77x 77x   77x 77x 77x 1x 1x 99x 99x 99x 99x 99x 99x   99x 99x   99x 99x 99x     99x 99x 1x   1x 1x     1x 1x 1x 1x                     1x 1x 1x                                 1x 1x                                 1x 1x 1x 1x 1x                                                                             1x 1x             1x                                                                                       1x 1x 1x             1x 1x 1x                                       1x 1x 1x 1x 1x 1x                                                                                                         1x 1x 1x 1x 1x 1x                                                                                                             1x 1x           1x 1x 1x 1x 1x 1x 1x 4x 4x         1x 1x 1x 1x 1x 6x 6x       1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x 1x 1x 1x 1x 1x                                                                                                                                                                                                                                                                                                                                                                                                               1x 1x         1x 1x                                                                                                                                                               1x                                                                                                                                                                                                                                                                                                                                                                                                               1x 1x 1x 1x 1x 1x                                 1x 1x 1x 1x                                                                                                         1x                                       1x 1x                                                                                   1x                                                                                 1x                                     1x                                             1x                                 1x                                                         1x 1x                                                                             1x           1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x  
/**
 * The structure drawn on the GPU, through three.js (§25).
 *
 * The one place in this application a picture is not a `CanvasRenderingContext2D`,
 * and the reason is stated in `package.json`'s comment on the dependency: a lit
 * tube of thousands of beads under an orbiting camera, with sorted transparency
 * and picking, is not the arithmetic the hand-written PDF and charts were the
 * cheaper answer for. It draws the structure cell of a deck and nothing else.
 *
 * ## Thin, on purpose
 *
 * Everything with a number in it lives in `structure/scene.ts`, `camera.ts`
 * and `colour.ts` and is tested in node; this module turns those numbers into
 * buffers and back. It holds no camera state of its own — it is handed a
 * {@link StructureView} and applies its camera to a three.js one on every
 * frame — so the picture it draws and the picture the vector painter draws
 * are projections of the same object, and the export can never show a
 * different angle from the screen.
 *
 * ## What is rebuilt when
 *
 * Geometry is expensive and colours are not, so the two are kept apart. The
 * tube, the bead instances and the stick instances are rebuilt only when the
 * structure, the representation, a radius or the set of *shown* beads changes;
 * a change of colouring, of what is lit, of what is hovered or of the camera
 * touches a colour attribute or a matrix and nothing else. A colouring by a
 * track sampled on every pan would otherwise rebuild a tube per frame.
 *
 * ## Picking
 *
 * Against the bead instances, always — including under a tube, where the
 * beads are not drawn. The pick mesh sits on a layer the camera does not
 * render and the raycaster does, so a pick is always a bead index and never a
 * point on a surface with no locus.
 */
 
import {
  AmbientLight,
  BufferAttribute,
  CatmullRomCurve3,
  Color,
  ConeGeometry,
  CylinderGeometry,
  DirectionalLight,
  Fog,
  FrontSide,
  InstancedBufferAttribute,
  InstancedMesh,
  Matrix4,
  Mesh,
  MeshBasicMaterial,
  MeshStandardMaterial,
  Object3D,
  OrthographicCamera,
  PerspectiveCamera,
  Plane,
  Quaternion,
  Raycaster,
  Scene,
  SphereGeometry,
  TubeGeometry,
  Vector2,
  Vector3,
  WebGLRenderer,
} from 'three'
 
import { basis, eyeOf, halfHeight } from './camera'
import { hexOf } from './colour'
import { MARKED_BEAD, drawStructureLabels } from './painter'
import type { StructureView } from './painter'
import { centroidOf } from './scene'
import type { Structure } from './scene'
import type { BeadColours } from './colour'
 
/** The attribute {@link ghostable} reads each bead's alpha from. */
const GHOST_ALPHA = 'ghostAlpha'
 
/** Below this an alpha is *gone*: the fragment is discarded and writes no depth. */
const GHOST_GONE = 0.004
 
/** What the renderer clears to and writes labels in, read off the page. */
export interface RenderPalette {
  background: string
  ink: string
  halo: string
}
 
/** The layer the pick mesh lives on: raycast, never drawn. */
const PICK_LAYER = 1
 
/** Radial segments of a tube and a stick. Eight reads as round at any size a cell draws. */
const RADIAL = 8
 
/** Tube segments per bead along a run. */
const TUBE_PER_BEAD = 4
 
export class StructureRenderer {
  readonly el: HTMLCanvasElement
  private renderer: WebGLRenderer | null = null
  private readonly scene = new Scene()
  private perspective = new PerspectiveCamera(40, 1, 0.01, 100)
  private orthographic = new OrthographicCamera(-1, 1, 1, -1, 0.01, 100)
  private readonly root = new Object3D()
  private readonly lights = new Object3D()
  private beads: InstancedMesh | null = null
  private picks: InstancedMesh | null = null
  private halos: InstancedMesh | null = null
  private sticks: InstancedMesh | null = null
  private tubes: Mesh[] = []
  /** The dashes across the gaps of every chain: ink, always drawn. */
  private gapDashes: InstancedMesh | null = null
  /** The hovered contact's chord: dashes of ink between its two beads. */
  private chord: InstancedMesh | null = null
  /** The loops as chords (§25.5): one cylinder of ink per pair. */
  private chords: InstancedMesh | null = null
  /** The map's contacts from the pinned bead, one coloured cylinder each. */
  /** The reader's measurements, as dashed ink. */
  private measures: InstancedMesh | null = null
  private measuresFrom: ReadonlyArray<readonly [number, number]> | null = null
  /** The genomic ruler's ticks: small ink beads. */
  private ticks: InstancedMesh | null = null
  private ticksFrom: ReadonlyArray<{ bead: number; label: string }> | null = null
  private marks: InstancedMesh | null = null
  private marksFrom: ReadonlyArray<{ bead: number; colour: string }> | null = null
  /** Which bead each drawn mark sits on, so its ghosting can follow the bead's. */
  private marksBeads: number[] = []
  /** The same for the ruler's ticks. */
  private ticksBeads: number[] = []
  /** The compared model, in this one's frame: its own group, rebuilt when it changes. */
  private readonly overlayRoot = new Object3D()
  private overlayFrom: Structure | null = null
  private overlayKey = ''
  /** The model's reach in scene units, measured once per model. */
  private reach: { of: Structure; radius: number } | null = null
  /** The two clipping planes of the depth slab, kept and re-aimed each frame. */
  private readonly slabPlanes = [new Plane(), new Plane()]
  private chordsFrom: ReadonlyArray<readonly [number, number]> | null = null
  /** The centre of rotation — a ball at the camera's target with the three axes through it. */
  private readonly centre = new Object3D()
  private centreBall: Mesh | null = null
  /** The three arms out of the ball, shaft, head and stub apiece. */
  private centreArms: Mesh[] = []
  /** The centre left out of one frame: a *copy this* snapshot (§7.2). */
  private withoutCentre = false
  private readonly raycaster = new Raycaster()
  private width = 1
  private height = 1
  private view: StructureView | null = null
  private palette: RenderPalette
  /** The model the geometry was built from, so a model that **moves** rebuilds it. */
  private builtStructure: Structure | null = null
  /** What the geometry was last built for, so a change elsewhere does not rebuild it. */
  private built: string | null = null
  /** Bead index per pick instance, since hidden beads are not instanced. */
  private pickBeads: Int32Array = new Int32Array(0)
  private shown: Uint8Array = new Uint8Array(0)
  /** `true` after the GPU took the context away; the next frame rebuilds. */
  lost = false
  private readonly onLost = (event: Event): void => {
    event.preventDefault()
    this.lost = true
  }
  private readonly onRestored = (): void => {
    this.lost = false
    this.built = null
    this.render()
  }
 
  constructor(palette: RenderPalette) {
    this.palette = palette
    this.el = document.createElement('canvas')
    this.el.className = 'structure-canvas'
    this.el.addEventListener('webglcontextlost', this.onLost)
    this.el.addEventListener('webglcontextrestored', this.onRestored)
    this.scene.add(this.root)
    this.scene.add(this.lights)
    this.scene.add(this.centre)
    this.centre.visible = false
    this.scene.add(this.overlayRoot)
    const ambient = new AmbientLight(0xffffff, 0.9)
    const key = new DirectionalLight(0xffffff, 1.6)
    key.position.set(1, 1.2, 1.5)
    const fill = new DirectionalLight(0xffffff, 0.5)
    fill.position.set(-1, -0.4, -1)
    this.lights.add(ambient, key, fill)
    this.raycaster.layers.set(PICK_LAYER)
  }
 
  /** Whether this browser gave a WebGL context at all. */
  get available(): boolean {
    return this.contextOrNull() !== null
  }
 
  private contextOrNull(): WebGLRenderer | null {
    if (this.renderer !== null) return this.renderer
    try {
      this.renderer = new WebGLRenderer({ canvas: this.el, antialias: true, alpha: false })
      this.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1))
      this.renderer.setSize(this.width, this.height, false)
      return this.renderer
    } catch {
      return null
    }
  }
 
  setPalette(palette: RenderPalette): void {
    this.palette = palette
    // The fog and the ink are the page's, so a change of theme is a recolour.
    this.recolour()
    this.render()
  }
 
  /** CSS pixels. */
  setSize(width: number, height: number): void {
    this.width = Math.max(1, Math.round(width))
    this.height = Math.max(1, Math.round(height))
    this.renderer?.setSize(this.width, this.height, false)
    this.render()
  }
 
  /** Adopt a view and draw it. */
  setView(view: StructureView): void {
    const previous = this.view
    this.view = view
    const key = buildKey(view)
    // **The model itself, as well as the key.** {@link buildKey} is counts and
    // style: how many beads, how they are drawn, how wide. It says nothing
    // about *where* the beads are, because until the mirrors (§25.5) nothing
    // ever moved them — a model was built once from its file and stood still.
    // A reflection and a turn into another model's frame both hand this a new
    // `Structure` with the same counts and the same style, so the key matched,
    // the geometry was never rebuilt, and the fibre on screen stayed exactly
    // where it was while the chain **names** — drawn by the panel from the
    // structure on every frame — flipped. The picture and its labels
    // disagreed about which model was on screen.
    if (key !== this.built || this.builtStructure !== view.structure) {
      this.rebuild(view)
      this.built = key
      this.builtStructure = view.structure
    } else if (previous === null || previous.colours !== view.colours || previous.lit !== view.lit) {
      this.recolour()
    }
    this.rebuildOverlay(view)
    this.mark()
    this.render()
  }
 
  /**
   * The bead under a point in the canvas, in CSS pixels from its top-left, or
   * `-1`.
   */
  pick(x: number, y: number): number {
    const view = this.view
    if (view === null || this.picks === null) return -1
    const ndc = new Vector2((x / this.width) * 2 - 1, -(y / this.height) * 2 + 1)
    const camera = this.applyCamera(view)
    this.raycaster.setFromCamera(ndc, camera)
    const hits = this.raycaster.intersectObject(this.picks, false)
    const first = hits.find((hit) => hit.instanceId !== undefined)
    if (first === undefined || first.instanceId === undefined) return -1
    return this.pickBeads[first.instanceId] ?? -1
  }
 
  /**
   * The picture at a size, as a 2D canvas: the GL frame plus the labels.
   *
   * Rendered at the requested size rather than screen-grabbed, so a 300 dpi
   * export is a 300 dpi drawing (§7.2's rule, kept for the one raster here).
   *
   * `centre: false` leaves the centre of rotation out: it says where the
   * model *turns*, which is a control, and *copy this* is the model alone.
   */
  snapshot(width: number, height: number, options: { centre?: boolean } = {}): HTMLCanvasElement | null {
    const renderer = this.contextOrNull()
    const view = this.view
    if (renderer === null || view === null) return null
    const out = document.createElement('canvas')
    out.width = Math.max(1, Math.round(width))
    out.height = Math.max(1, Math.round(height))
    const ctx = out.getContext('2d')
    if (ctx === null) return null
    const heldWidth = this.width
    const heldHeight = this.height
    const heldRatio = renderer.getPixelRatio()
    try {
      this.withoutCentre = options.centre === false
      renderer.setPixelRatio(1)
      this.width = out.width
      this.height = out.height
      renderer.setSize(out.width, out.height, false)
      this.render()
      ctx.drawImage(this.el, 0, 0)
    } finally {
      this.withoutCentre = false
      this.width = heldWidth
      this.height = heldHeight
      renderer.setPixelRatio(heldRatio)
      renderer.setSize(heldWidth, heldHeight, false)
      this.render()
    }
    // The words on the model, in the cell's own CSS pixels scaled to this
    // canvas: the same function the vector page writes them with
    // (`structure/painter.ts`), so the PNG and the PDF name the same things.
    ctx.save()
    ctx.setTransform(out.width / heldWidth, 0, 0, out.height / heldHeight, 0, 0)
    drawStructureLabels(ctx, { width: heldWidth, height: heldHeight }, view, this.palette)
    ctx.restore()
    return out
  }
 
  render(): void {
    const renderer = this.contextOrNull()
    const view = this.view
    if (renderer === null || view === null || this.lost) return
    const camera = this.applyCamera(view)
    const background = new Color(hexOf(this.palette.background))
    this.scene.background = background
    // The depth slab: two planes facing each other along the line of sight,
    // either side of the centre of rotation, at a fraction of the model's
    // reach — everything nearer than the front one or farther than the back
    // one is cut away, which is how the inside of a globule is looked at.
    // Measured whether or not the slab clips, because it is also the depth the
    // fog is read against — see below.
    const half = this.slabHalf(view)
    if (view.style.slab < 1) {
      const { forward } = basis(view.camera)
      const [tx, ty, tz] = view.camera.target
      const f = new Vector3(forward[0], forward[1], forward[2])
      const t = new Vector3(tx, ty, tz)
      // Keep what is on the far side of the front plane (normal along
      // forward) and the near side of the back plane (normal against it).
      this.slabPlanes[0].setFromNormalAndCoplanarPoint(f, t.clone().addScaledVector(f, -half))
      this.slabPlanes[1].setFromNormalAndCoplanarPoint(f.clone().negate(), t.clone().addScaledVector(f, half))
      renderer.clippingPlanes = this.slabPlanes
    } else if (renderer.clippingPlanes.length > 0) {
      renderer.clippingPlanes = []
    }
    // **The depth cue and the slab are one instrument.** The fog starts at the
    // centre of rotation — the plane the slab is centred on, which ⌘-click
    // moves — and reaches the page exactly where the slab cuts, so what the
    // fog is saying is *how close this is to being cut away*. It used to be
    // set from the camera distance alone, which on a thin slab left every one
    // of the few beads it kept already half dissolved: the fog was measuring
    // the whole model while the slab was showing a slice of it.
    //
    // The slider weakens it by pushing that plane out: at 1 the far side of
    // the slab is the page, at a half it is half way there.
    const d = view.camera.distance
    const f = view.style.fog
    this.scene.fog = view.style.fogOn && f > 0 ? new Fog(background, d, d + half / f) : null
    this.placeCentre(view)
    renderer.render(this.scene, camera)
  }
 
  /**
   * Half the slab's thickness, in scene units: how far either side of the
   * centre of rotation the model is kept. The model's whole reach at slab 1,
   * so the slider runs from a sheet to everything.
   *
   * **In the scene's units, not the file's.** It used to be
   * `structure.scale * 2 * slab`, and `scale` is the factor back to the
   * file's own units — nanometres, or whatever the solver worked in — while
   * the coordinates it is stored beside are the *normalised* ones. On a model
   * whose radius of gyration was a thousand nanometres the planes were put a
   * thousand times too far apart, which is why the slab appeared to do
   * nothing at any setting.
   */
  private slabHalf(view: StructureView): number {
    return Math.max(1e-3, this.reachOf(view.structure) * view.style.slab)
  }
 
  /**
   * How far the farthest present bead is from the model's centre, in scene
   * units. Measured once per model: it is a fact about the coordinates, and
   * they do not change under the camera.
   */
  private reachOf(structure: Structure): number {
    if (this.reach !== null && this.reach.of === structure) return this.reach.radius
    const all = centroidOf(structure, { [Symbol.iterator]: () => counting(structure.set.length) })
    const radius = all === null ? 1 : Math.max(1e-3, all.radius)
    this.reach = { of: structure, radius }
    return radius
  }
 
  /**
   * The bead under the pointer, or the nearest one within `radius` pixels.
   *
   * A bead is a few pixels across and a fibre is thinner; asking a reader to
   * land a pointer exactly on one is asking them to aim at the picture rather
   * than at the model. So the ray is cast first — which is exact, and respects
   * what is in front of what — and only where it hits nothing does the search
   * widen to the nearest bead whose centre falls within the radius, the
   * nearest to the eye winning where several do.
   *
   * Screen space and one pass over the beads, rather than a fan of rays: a ray
   * against an instanced mesh tests every instance, so eight of them is eight
   * times the work for a worse answer.
   */
  pickNear(x: number, y: number, radius: number): number {
    const exact = this.pick(x, y)
    if (exact >= 0) return exact
    const view = this.view
    if (view === null || radius <= 0) return -1
    const camera = this.applyCamera(view)
    const { structure } = view
    const point = new Vector3()
    // The planes are the ones the last frame was clipped by, which is what the
    // reader is looking at.
    const clipped = view.style.slab < 1
    let best = -1
    let bestAway = Infinity
    let bestDepth = Infinity
    for (let i = 0; i < structure.set.length; i++) {
      if (this.shown[i] !== 1) continue
      point.set(structure.x[i], structure.y[i], structure.z[i])
      // What the slab has cut away cannot be aimed at: it is not on screen.
      if (clipped && (this.slabPlanes[0].distanceToPoint(point) < 0 || this.slabPlanes[1].distanceToPoint(point) < 0)) {
        continue
      }
      point.project(camera)
      if (point.z < -1 || point.z > 1) continue
      const px = (point.x * 0.5 + 0.5) * this.width
      const py = (0.5 - point.y * 0.5) * this.height
      const away = Math.hypot(px - x, py - y)
      if (away > radius) continue
      // Nearest to the eye first, and only then nearest to the pointer: a bead
      // behind another is not the one being aimed at.
      if (point.z < bestDepth - 1e-6 || (point.z < bestDepth + 1e-6 && away < bestAway)) {
        best = i
        bestAway = away
        bestDepth = point.z
      }
    }
    return best
  }
 
  /**
   * The centre of rotation, when the style shows it (§25.5): a ball at the
   * camera's target with three arms out of it, sized to the view so it reads
   * the same at every zoom, drawn over the model since a centre inside a tube
   * is still the centre. Rebuilt only when the size changes by more than a
   * little.
   *
   * **Arms rather than lines through it, and a head on each.** The gizmo is on
   * by default now, so it has to say what it is at a glance and to somebody
   * who never read a word about it: a ball where the model turns, and one arm
   * per axis pointing the way that axis *goes*. Six half-lines crossing in a
   * dot said none of that — which way was +x, and which end was the centre,
   * were both things a reader had to turn the model to find out. The dot is
   * still drawn in ink and the arms in the axis colours every 3D tool uses
   * (x red, y green, z blue), so the thing the reader already knows is the
   * thing they see.
   *
   * The whole gizmo carries the model's **planted frame** (`axesFrame`): the
   * identity is the model's own axes, and *replant* writes the view's there.
   */
  private placeCentre(view: StructureView): void {
    const show = view.style.axes && !this.withoutCentre
    this.centre.visible = show
    if (!show) return
    const size = view.camera.distance * 0.12
    if (this.centreBall === null || Math.abs(this.centre.scale.x - size) / size > 0.01) {
      this.centre.clear()
      this.centreBall?.geometry.dispose()
      for (const arm of this.centreArms) arm.geometry.dispose()
      this.centreArms = []
      this.centreBall = new Mesh(
        new SphereGeometry(0.075, 16, 12),
        new MeshBasicMaterial({ color: hexOf(this.palette.ink), transparent: true, opacity: 0.8, depthTest: false }),
      )
      this.centreBall.renderOrder = 10
      this.centre.add(this.centreBall)
      // One arm per axis: a shaft from the ball and a head at its end, so the
      // sign is read off the picture rather than inferred from it. The
      // negative half is a thinner, shorter stub — enough to say the axis is
      // a line and not a ray, and not enough to be mistaken for the head.
      const axes: Array<{ dir: [number, number, number]; colour: number }> = [
        { dir: [1, 0, 0], colour: 0xd8453c },
        { dir: [0, 1, 0], colour: 0x2f9e44 },
        { dir: [0, 0, 1], colour: 0x3b7ddd },
      ]
      for (const axis of axes) {
        const material = new MeshBasicMaterial({ color: axis.colour, transparent: true, opacity: 0.95, depthTest: false })
        const shaft = new Mesh(new CylinderGeometry(0.022, 0.022, 0.8, 10), material)
        const head = new Mesh(new ConeGeometry(0.07, 0.22, 12), material)
        const stub = new Mesh(new CylinderGeometry(0.012, 0.012, 0.45, 8), material)
        // A cylinder stands up the y axis; turn it to the axis it draws.
        const turn = new Quaternion().setFromUnitVectors(
          new Vector3(0, 1, 0),
          new Vector3(axis.dir[0], axis.dir[1], axis.dir[2]),
        )
        shaft.quaternion.copy(turn)
        head.quaternion.copy(turn)
        stub.quaternion.copy(turn)
        shaft.position.set(axis.dir[0] * 0.4, axis.dir[1] * 0.4, axis.dir[2] * 0.4)
        head.position.set(axis.dir[0] * 0.91, axis.dir[1] * 0.91, axis.dir[2] * 0.91)
        stub.position.set(axis.dir[0] * -0.225, axis.dir[1] * -0.225, axis.dir[2] * -0.225)
        for (const part of [shaft, head, stub]) {
          part.renderOrder = 10
          this.centre.add(part)
          this.centreArms.push(part)
        }
      }
      this.centre.scale.setScalar(size)
    }
    const [x, y, z] = view.camera.target
    this.centre.position.set(x, y, z)
    const [qx, qy, qz, qw] = view.style.axesFrame
    this.centre.quaternion.set(qx, qy, qz, qw)
  }
 
  destroy(): void {
    this.el.removeEventListener('webglcontextlost', this.onLost)
    this.el.removeEventListener('webglcontextrestored', this.onRestored)
    this.clear()
    this.renderer?.dispose()
    this.renderer = null
  }
 
  // -------------------------------------------------------------------------
  // Building
  // -------------------------------------------------------------------------
 
  private clear(): void {
    for (const mesh of [this.beads, this.picks, this.halos, this.sticks, ...this.tubes]) {
      if (mesh === null) continue
      this.root.remove(mesh)
      mesh.geometry.dispose()
      const material = mesh.material
      if (Array.isArray(material)) material.forEach((m) => m.dispose())
      else material.dispose()
    }
    for (const ink of [this.chords, this.chord, this.gapDashes, this.measures, this.ticks, this.marks]) {
      if (ink === null) continue
      this.root.remove(ink)
      ink.geometry.dispose()
      ;(ink.material as MeshBasicMaterial).dispose()
    }
    this.chords = null
    this.chordsFrom = null
    this.gapDashes = null
    this.measures = null
    this.measuresFrom = null
    this.ticks = null
    this.ticksBeads = []
    this.ticksFrom = null
    this.marks = null
    this.marksBeads = []
    this.marksFrom = null
    this.beads = null
    this.picks = null
    this.halos = null
    this.sticks = null
    this.tubes = []
    this.chord = null
  }
 
  private rebuild(view: StructureView): void {
    this.clear()
    const { structure, style } = view
    const n = structure.set.length
    const shown = shownBeads(view)
    this.shown = shown
    let count = 0
    for (let i = 0; i < n; i++) count += shown[i]
    const beadRadius = structure.step * style.beadRadius
    const stickRadius = structure.step * style.tubeRadius
    const lit = true
 
    // The pick instances, always: one sphere per shown bead at the bead radius
    // or, under a tube, at the tube's, so a pick on the tube lands on the bead
    // the surface belongs to.
    const pickRadius = style.beads ? beadRadius : Math.max(stickRadius, beadRadius * 0.6)
    this.pickBeads = new Int32Array(count)
    this.picks = new InstancedMesh(new SphereGeometry(1, 8, 6), new MeshBasicMaterial(), Math.max(1, count))
    this.picks.count = count
    this.picks.layers.set(PICK_LAYER)
    const matrix = new Matrix4()
    let at = 0
    for (let i = 0; i < n; i++) {
      if (shown[i] === 0) continue
      matrix.makeScale(pickRadius, pickRadius, pickRadius)
      matrix.setPosition(structure.x[i], structure.y[i], structure.z[i])
      this.picks.setMatrixAt(at, matrix)
      this.pickBeads[at] = i
      at++
    }
    this.picks.instanceMatrix.needsUpdate = true
    // A raycast against an instanced mesh is gated by its bounding sphere,
    // which three computes once and never again: left as it was, it is the
    // sphere of a mesh that had no instances yet, and every ray misses. The
    // same sphere is what frustum culling reads, which is why every object
    // under the root is added with culling off (`frustumCulled = false`): a
    // halo or a chord placed after its sphere was computed was culled as
    // out of view and never drawn.
    this.picks.computeBoundingSphere()
    this.root.add(this.picks)
    this.picks.frustumCulled = false
 
    const drawBeads = style.beads
    const drawSticks = style.sticks
    const drawTube = style.tube
 
    if (drawBeads) {
      this.beads = new InstancedMesh(new SphereGeometry(1, 16, 12), surface(lit), Math.max(1, count))
      this.beads.geometry.setAttribute(GHOST_ALPHA, ghostAttribute(count, true))
      this.beads.count = count
      at = 0
      for (let i = 0; i < n; i++) {
        if (shown[i] === 0) continue
        const r = beadRadius
        matrix.makeScale(r, r, r)
        matrix.setPosition(structure.x[i], structure.y[i], structure.z[i])
        this.beads.setMatrixAt(at, matrix)
        at++
      }
      this.beads.instanceMatrix.needsUpdate = true
      this.root.add(this.beads)
      this.beads.frustumCulled = false
    }
 
    // The dashes across every gap, whatever else is drawn: ink, a little
    // wider than a stick, so a chain that stops where a bead is missing reads
    // as one chain with a hole in it rather than as two chains.
    //
    // **Ghosted with the chain they belong to.** They were flat ink at full
    // opacity, so a ghosted chromosome kept its dashes at full strength: a
    // model faded to almost nothing still had a cage of black dashes over it,
    // which read as the one thing being *shown*. A gap is a fact about a
    // chain, so it fades with the chain — which means remembering, per
    // segment, which pair of beads it crosses.
    const gaps = gapPairsOf(view, shown)
    if (gaps.length > 0) {
      const segments: Array<[Vector3, Vector3]> = []
      const owners: number[] = []
      const dash = stickRadius * 3
      const gap = stickRadius * 2
      for (let g = 0; g < gaps.length; g++) {
        const [a, b] = gaps[g]
        const from = new Vector3(structure.x[a], structure.y[a], structure.z[a])
        const to = new Vector3(structure.x[b], structure.y[b], structure.z[b])
        const length = from.distanceTo(to)
        const dir = to.clone().sub(from).normalize()
        for (let at = 0; at < length; at += dash + gap) {
          const end = Math.min(length, at + dash)
          segments.push([from.clone().addScaledVector(dir, at), from.clone().addScaledVector(dir, end)])
          owners.push(g)
        }
      }
      this.gapDashes = cylinders(segments, stickRadius * INK_SHARE, ghostable(inkMaterial(this.palette.ink, 1)))
      this.gapDashes.geometry.setAttribute(GHOST_ALPHA, ghostAttribute(Math.max(1, segments.length), true))
      this.gapDashes.userData['gaps'] = gaps
      this.gapDashes.userData['owners'] = Int32Array.from(owners)
      this.root.add(this.gapDashes)
    }
 
    if (drawSticks || drawTube) {
      // Sticks between neighbours on a run, as instanced cylinders, each
      // turned from the y axis onto its pair. A tube's runs of one bead have
      // no tube and are given a stick's worth of nothing, which is what a lone
      // bead is.
      const pairs = pairsOf(view, shown)
      if (drawSticks && pairs.length > 0) {
        this.sticks = new InstancedMesh(
          new CylinderGeometry(1, 1, 1, RADIAL, 1, true),
          surface(lit),
          pairs.length,
        )
        this.sticks.geometry.setAttribute(GHOST_ALPHA, ghostAttribute(pairs.length, true))
        const q = new Quaternion()
        const up = new Vector3(0, 1, 0)
        const dir = new Vector3()
        for (let p = 0; p < pairs.length; p++) {
          const [a, b] = pairs[p]
          dir.set(structure.x[b] - structure.x[a], structure.y[b] - structure.y[a], structure.z[b] - structure.z[a])
          const length = dir.length()
          q.setFromUnitVectors(up, dir.clone().normalize())
          matrix.compose(
            new Vector3((structure.x[a] + structure.x[b]) / 2, (structure.y[a] + structure.y[b]) / 2, (structure.z[a] + structure.z[b]) / 2),
            q,
            new Vector3(stickRadius, length, stickRadius),
          )
          this.sticks.setMatrixAt(p, matrix)
        }
        this.sticks.instanceMatrix.needsUpdate = true
        this.sticks.userData['pairs'] = pairs
        this.root.add(this.sticks)
        this.sticks.frustumCulled = false
      }
      if (drawTube) {
        for (const chain of structure.chains) {
          if (view.haplotype >= 0 && chain.hap !== view.haplotype) continue
          for (let r = 0; r < chain.runs.length; r += 2) {
            const from = chain.runs[r]
            const to = chain.runs[r + 1]
            const run: number[] = []
            for (let k = from; k < to; k++) if (shown[chain.beads[k]] === 1) run.push(chain.beads[k])
            if (run.length < 2) continue
            const points = run.map((i) => new Vector3(structure.x[i], structure.y[i], structure.z[i]))
            const curve = new CatmullRomCurve3(points, false, 'catmullrom', 0.5)
            const segments = Math.max(1, (run.length - 1) * TUBE_PER_BEAD)
            const geometry = new TubeGeometry(curve, segments, stickRadius, RADIAL, false)
            // Which bead each ring of vertices belongs to, by arc length along
            // the polyline, so a colouring reads along the fibre.
            const owner = new Int32Array(segments + 1)
            const lengths = cumulative(points)
            const total = lengths[lengths.length - 1]
            for (let s = 0; s <= segments; s++) {
              const along = (s / segments) * total
              let k = 0
              while (k + 1 < lengths.length - 1 && lengths[k + 1] <= along) k++
              const span = lengths[k + 1] - lengths[k]
              const t = span > 0 ? (along - lengths[k]) / span : 0
              owner[s] = run[t < 0.5 ? k : k + 1]
            }
            geometry.setAttribute('color', new BufferAttribute(new Float32Array(geometry.attributes.position.count * 3), 3))
            geometry.setAttribute(GHOST_ALPHA, ghostAttribute(geometry.attributes.position.count, false))
            const mesh = new Mesh(geometry, surface(lit, true))
            mesh.userData['owner'] = owner
            mesh.userData['radial'] = RADIAL
            this.tubes.push(mesh)
            this.root.add(mesh)
          }
        }
      }
    }
 
    // The halo behind a hovered or pinned bead: a copy of the sphere, a little
    // larger, drawn back faces only, which reads as an outline round it.
    // Translucent and depth-tested rather than back faces only: it sits **in
    // front of** the bead or tube it marks, which it encloses, and **behind**
    // any part of the model nearer the eye, as a ball at that place would.
    // The outline it used to be read through a tube in front of it and was
    // swallowed by one behind.
    this.halos = new InstancedMesh(
      new SphereGeometry(1, 16, 12),
      new MeshBasicMaterial({
        color: hexOf(this.palette.ink),
        side: FrontSide,
        transparent: true,
        opacity: 0.55,
        depthWrite: false,
      }),
      8,
    )
    this.halos.count = 0
    this.root.add(this.halos)
    this.halos.frustumCulled = false
 
    // The chords are cylinders rather than GL lines, which are one pixel
    // wide on every platform that matters: a line that says *these two touch*
    // has to be seen at the size of the tube it crosses. Placed by `mark`.
    this.chord = null
    this.chords = null
    this.chordsFrom = null
 
    this.recolour()
  }
 
  /** Write every colour attribute from the view's colours, lit mask and palette. */
  private recolour(): void {
    const view = this.view
    if (view === null) return
    const { structure } = view
    const n = structure.set.length
    const rgb = view.colours.rgb
    // **The colour is the bead's own, ghosted or not.** Fading is alpha
    // (`ghostable`), not a mix towards the page: a ghosted bead keeps its hue,
    // lets the model behind it through rather than painting over it, and at an
    // opacity of zero is gone rather than page-coloured and still in the way.
    const colourOf = (i: number, out: Color): void => {
      out.setRGB(rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2])
    }
    const alphaOf = (i: number): number => (view.lit !== null && view.lit[i] === 0 ? view.style.opacity : 1)
    const colour = new Color()
    if (this.beads !== null) {
      const alpha = this.beads.geometry.getAttribute(GHOST_ALPHA) as BufferAttribute
      for (let at = 0; at < this.pickBeads.length; at++) {
        colourOf(this.pickBeads[at], colour)
        this.beads.setColorAt(at, colour)
        alpha.setX(at, alphaOf(this.pickBeads[at]))
      }
      if (this.beads.instanceColor !== null) this.beads.instanceColor.needsUpdate = true
      alpha.needsUpdate = true
    }
    if (this.sticks !== null) {
      const pairs = this.sticks.userData['pairs'] as Array<[number, number]>
      const alpha = this.sticks.geometry.getAttribute(GHOST_ALPHA) as BufferAttribute
      const other = new Color()
      for (let p = 0; p < pairs.length; p++) {
        colourOf(pairs[p][0], colour)
        colourOf(pairs[p][1], other)
        colour.lerp(other, 0.5)
        this.sticks.setColorAt(p, colour)
        // A stick spanning the edge of the window fades to the mean of its two
        // ends, so the fibre thins into the ghost rather than stopping dead.
        alpha.setX(p, (alphaOf(pairs[p][0]) + alphaOf(pairs[p][1])) / 2)
      }
      if (this.sticks.instanceColor !== null) this.sticks.instanceColor.needsUpdate = true
      alpha.needsUpdate = true
    }
    if (this.gapDashes !== null) {
      // The mean of the pair it crosses, as a stick takes: a gap that spans
      // the edge of the window thins into the ghost rather than stopping dead.
      const gaps = this.gapDashes.userData['gaps'] as Array<[number, number]> | undefined
      const owners = this.gapDashes.userData['owners'] as Int32Array | undefined
      const alpha = this.gapDashes.geometry.getAttribute(GHOST_ALPHA) as BufferAttribute | undefined
      if (gaps !== undefined && owners !== undefined && alpha !== undefined) {
        for (let s = 0; s < owners.length; s++) {
          const [a, b] = gaps[owners[s]]
          alpha.setX(s, (alphaOf(a) + alphaOf(b)) / 2)
        }
        alpha.needsUpdate = true
      }
    }
    for (const tube of this.tubes) {
      const owner = tube.userData['owner'] as Int32Array
      const radial = tube.userData['radial'] as number
      const attribute = tube.geometry.getAttribute('color') as BufferAttribute
      const array = attribute.array as Float32Array
      const alpha = tube.geometry.getAttribute(GHOST_ALPHA) as BufferAttribute
      const alphas = alpha.array as Float32Array
      for (let s = 0; s < owner.length; s++) {
        colourOf(owner[s], colour)
        const a = alphaOf(owner[s])
        for (let ring = 0; ring <= radial; ring++) {
          const v = s * (radial + 1) + ring
          array[v * 3] = colour.r
          array[v * 3 + 1] = colour.g
          array[v * 3 + 2] = colour.b
          alphas[v] = a
        }
      }
      attribute.needsUpdate = true
      alpha.needsUpdate = true
    }
    this.fadeMarks()
    if (this.halos !== null) {
      ;(this.halos.material as MeshBasicMaterial).color.setHex(hexOf(this.palette.ink))
    }
    for (const ink of [this.chord, this.chords, this.gapDashes, this.measures, this.ticks]) {
      if (ink !== null) (ink.material as MeshBasicMaterial).color.setHex(hexOf(this.palette.ink))
    }
    void n
  }
 
  /** Place the halos and the chord for what is hovered and pinned. */
  private mark(): void {
    const view = this.view
    if (view === null || this.halos === null) return
    const { structure, style } = view
    const marked = [...new Set([...view.hovered, ...(view.pinned >= 0 ? [view.pinned] : [])])].filter(
      (i) => i >= 0 && i < structure.set.length && this.shown[i] === 1,
    )
    const stickRadius = structure.step * style.tubeRadius
    const radius =
      (style.beads ? structure.step * style.beadRadius : Math.max(stickRadius, structure.step * style.beadRadius * 0.6)) *
      1.45
    const matrix = new Matrix4()
    const count = Math.min(marked.length, 8)
    for (let at = 0; at < count; at++) {
      const i = marked[at]
      matrix.makeScale(radius, radius, radius)
      matrix.setPosition(structure.x[i], structure.y[i], structure.z[i])
      this.halos.setMatrixAt(at, matrix)
    }
    this.halos.count = count
    this.halos.instanceMatrix.needsUpdate = true
 
    // The loops as chords, rebuilt only when the list is a new one: the panel
    // hands the same array back frame after frame while nothing changed.
    const wanted = style.chords ? view.chords : NO_CHORDS
    if (this.chordsFrom !== wanted) {
      this.chordsFrom = wanted
      if (this.chords !== null) {
        this.root.remove(this.chords)
        this.chords.geometry.dispose()
        this.chords = null
      }
      const kept = wanted.filter(([a, b]) => this.shown[a] === 1 && this.shown[b] === 1)
      if (kept.length > 0) {
        const segments = kept.map(
          ([a, b]) =>
            [
              new Vector3(structure.x[a], structure.y[a], structure.z[a]),
              new Vector3(structure.x[b], structure.y[b], structure.z[b]),
            ] as [Vector3, Vector3],
        )
        this.chords = cylinders(segments, stickRadius * INK_SHARE, inkMaterial(this.palette.ink, 0.9))
        this.root.add(this.chords)
      }
    }
 
    // The reader's measurements: dashes of ink between the two beads.
    if (this.measuresFrom !== view.measures) {
      this.measuresFrom = view.measures
      if (this.measures !== null) {
        this.root.remove(this.measures)
        this.measures.geometry.dispose()
        this.measures = null
      }
      const segments: Array<[Vector3, Vector3]> = []
      for (const [a, b] of view.measures) {
        if (this.shown[a] !== 1 || this.shown[b] !== 1) continue
        const from = new Vector3(structure.x[a], structure.y[a], structure.z[a])
        const to = new Vector3(structure.x[b], structure.y[b], structure.z[b])
        const length = from.distanceTo(to)
        const dir = to.clone().sub(from).normalize()
        const dash = stickRadius * 2.5
        const gap = stickRadius * 1.5
        for (let at = 0; at < length; at += dash + gap) {
          segments.push([from.clone().addScaledVector(dir, at), from.clone().addScaledVector(dir, Math.min(length, at + dash))])
        }
      }
      if (segments.length > 0) {
        this.measures = cylinders(segments, stickRadius * 0.6, inkMaterial(this.palette.ink, 1))
        this.root.add(this.measures)
      }
    }
 
    // The ruler's ticks: a small bead of ink at each, over the model.
    if (this.ticksFrom !== view.ruler) {
      this.ticksFrom = view.ruler
      if (this.ticks !== null) {
        this.root.remove(this.ticks)
        this.ticks.geometry.dispose()
        this.ticks = null
      }
      const kept = view.ruler.filter((tick) => this.shown[tick.bead] === 1)
      if (kept.length > 0) {
        const r = Math.max(stickRadius * 1.6, structure.step * style.beadRadius * 0.8)
        const mesh = new InstancedMesh(
          new SphereGeometry(1, 10, 8),
          ghostable(inkMaterial(this.palette.ink, 1)),
          kept.length,
        )
        mesh.geometry.setAttribute(GHOST_ALPHA, ghostAttribute(kept.length, true))
        const matrix = new Matrix4()
        kept.forEach((tick, at) => {
          matrix.makeScale(r, r, r)
          matrix.setPosition(structure.x[tick.bead], structure.y[tick.bead], structure.z[tick.bead])
          mesh.setMatrixAt(at, matrix)
        })
        mesh.instanceMatrix.needsUpdate = true
        mesh.frustumCulled = false
        this.ticks = mesh
        this.ticksBeads = kept.map((tick) => tick.bead)
        this.root.add(mesh)
      }
    }
 
    // The reader's marked places: the bead again at twice the radius, in the
    // mark's own colour. A place is a thing on the model, so it is drawn as a
    // bigger bead rather than as one more line over it.
    if (this.marksFrom !== view.marks) {
      this.marksFrom = view.marks
      if (this.marks !== null) {
        this.root.remove(this.marks)
        this.marks.geometry.dispose()
        this.marks = null
      }
      const kept = view.marks.filter((mark) => this.shown[mark.bead] === 1)
      if (kept.length > 0) {
        const mesh = new InstancedMesh(
          new SphereGeometry(1, 16, 12),
          // **No `vertexColors`.** The colour of a marked bead comes from the
          // instance, through `setColorAt`, and asking for vertex colours as
          // well made every one of them *black*: `vertexColors` defines
          // `USE_COLOR`, whose shader does `vColor.rgb *= color`, and `color`
          // is an attribute a plain sphere has not got. A missing attribute
          // reads as `(0, 0, 0)` — `defaultAttributeValues` would have said
          // otherwise but exists only on `ShaderMaterial` — and the next line
          // of the same chunk multiplies the instance colour into that zero.
          // So a mark drawn in the colour the Manual annotations card gave it
          // came out black, while the same mark in an exported PDF came out
          // right, the 2D painter taking the colour as a colour.
          //
          // `ghostable`, though, because a mark **outside the window is
          // ghosted** like the bead it sits on (§25.5). It was not, so a
          // telomere — which rides this same channel — stayed a solid yellow
          // ball in a model faded to a trace, and a genome's worth of them read
          // as the brightest thing in a picture of one window. The 2D painter
          // had it right from the start, so the screen and the exported PDF
          // disagreed about the same model.
          ghostable(new MeshStandardMaterial({ roughness: 0.55, metalness: 0 })),
          kept.length,
        )
        const matrix = new Matrix4()
        const colour = new Color()
        kept.forEach((mark, at) => {
          const r = structure.step * style.beadRadius * MARKED_BEAD
          matrix.makeScale(r, r, r)
          matrix.setPosition(structure.x[mark.bead], structure.y[mark.bead], structure.z[mark.bead])
          mesh.setMatrixAt(at, matrix)
          colour.set(mark.colour)
          mesh.setColorAt(at, colour)
        })
        mesh.geometry.setAttribute(GHOST_ALPHA, ghostAttribute(kept.length, true))
        mesh.instanceMatrix.needsUpdate = true
        if (mesh.instanceColor !== null) mesh.instanceColor.needsUpdate = true
        mesh.frustumCulled = false
        this.marks = mesh
        this.marksBeads = kept.map((mark) => mark.bead)
        this.root.add(mesh)
      }
    }
 
    // The hovered contact's chord: dashes of ink, wider than the loops', on
    // top of everything else that is a line.
    if (this.chord !== null) {
      this.root.remove(this.chord)
      this.chord.geometry.dispose()
      this.chord = null
    }
    const chord = view.chord
    if (chord !== null && this.shown[chord[0]] === 1 && this.shown[chord[1]] === 1) {
      const [a, b] = chord
      const from = new Vector3(structure.x[a], structure.y[a], structure.z[a])
      const to = new Vector3(structure.x[b], structure.y[b], structure.z[b])
      const length = from.distanceTo(to)
      const dir = to.clone().sub(from).normalize()
      const dash = stickRadius * 3
      const gap = stickRadius * 2
      const segments: Array<[Vector3, Vector3]> = []
      for (let at = 0; at < length; at += dash + gap) {
        segments.push([from.clone().addScaledVector(dir, at), from.clone().addScaledVector(dir, Math.min(length, at + dash))])
      }
      this.chord = cylinders(segments, stickRadius * 0.8, inkMaterial(this.palette.ink, 1))
      this.root.add(this.chord)
    }
    // The marks and the ticks are built *here*, and `recolour` runs before
    // `mark` — so a mesh made this frame would keep the opaque alpha it was
    // filled with until the next time the window moved. That is a telomere
    // drawn whole over a model faded to a trace, which is the thing this is
    // for.
    this.fadeMarks()
  }
 
  /**
   * Fade every mark with the bead it sits on (§25.5).
   *
   * The reader's own marked places, the telomeres that ride the same channel,
   * and the ruler's ticks. A place on the fibre is a fact about that bead, so
   * a mark on a bead that is barely there is barely there — and a window of a
   * genome used to be read against a constellation of solid yellow telomeres
   * nowhere near it, the brightest thing in a picture of somewhere else.
   *
   * Called from both roads into the picture, because the two meshes are built
   * in `mark` and the alphas are written in `recolour`, and neither runs after
   * the other in every frame.
   */
  private fadeMarks(): void {
    const view = this.view
    if (view === null) return
    const alphaOf = (i: number): number => (view.lit !== null && view.lit[i] === 0 ? view.style.opacity : 1)
    for (const drawn of [
      { mesh: this.marks, beads: this.marksBeads },
      { mesh: this.ticks, beads: this.ticksBeads },
    ]) {
      const attribute = drawn.mesh?.geometry.getAttribute(GHOST_ALPHA) as BufferAttribute | undefined
      if (attribute === undefined) continue
      for (let at = 0; at < drawn.beads.length; at++) attribute.setX(at, alphaOf(drawn.beads[at]))
      attribute.needsUpdate = true
    }
  }
 
  /**
   * The compared model (§25.5), in this model's frame: its runs as a thin
   * tube in its own colours, rebuilt only when the model or the radius
   * changes. Ghosting and the window do not apply to it — it is the other
   * model, whole, beside this one — and it is drawn a little thinner so the
   * two read apart where they cross.
   */
  private rebuildOverlay(view: StructureView): void {
    const overlay = view.overlay
    const key = overlay === null ? '' : `${view.style.tubeRadius}|${view.style.tube}|${view.style.sticks}`
    if (overlay?.structure === this.overlayFrom && key === this.overlayKey) {
      if (overlay !== null) this.recolourOverlay(overlay.colours)
      return
    }
    this.overlayFrom = overlay?.structure ?? null
    this.overlayKey = key
    for (const child of [...this.overlayRoot.children]) {
      this.overlayRoot.remove(child)
      const mesh = child as Mesh
      mesh.geometry?.dispose()
      const material = mesh.material
      if (Array.isArray(material)) material.forEach((m) => m.dispose())
      else material?.dispose()
    }
    if (overlay === null) return
    const o = overlay.structure
    const radius = view.structure.step * view.style.tubeRadius * 0.7
    for (const chain of o.chains) {
      for (let r = 0; r < chain.runs.length; r += 2) {
        const run: number[] = []
        for (let k = chain.runs[r]; k < chain.runs[r + 1]; k++) if (o.present[chain.beads[k]] === 1) run.push(chain.beads[k])
        if (run.length < 2) continue
        const points = run.map((i) => new Vector3(o.x[i], o.y[i], o.z[i]))
        const curve = new CatmullRomCurve3(points, false, 'catmullrom', 0.5)
        const segments = Math.max(1, (run.length - 1) * TUBE_PER_BEAD)
        const geometry = new TubeGeometry(curve, segments, radius, RADIAL, false)
        const owner = new Int32Array(segments + 1)
        const lengths = cumulative(points)
        const total = lengths[lengths.length - 1]
        for (let sgm = 0; sgm <= segments; sgm++) {
          const along = (sgm / segments) * total
          let k = 0
          while (k + 1 < lengths.length - 1 && lengths[k + 1] <= along) k++
          const span = lengths[k + 1] - lengths[k]
          const t = span > 0 ? (along - lengths[k]) / span : 0
          owner[sgm] = run[t < 0.5 ? k : k + 1]
        }
        geometry.setAttribute('color', new BufferAttribute(new Float32Array(geometry.attributes.position.count * 3), 3))
        const mesh = new Mesh(geometry, surface(true, true))
        mesh.userData['owner'] = owner
        mesh.userData['radial'] = RADIAL
        mesh.frustumCulled = false
        this.overlayRoot.add(mesh)
      }
    }
    this.recolourOverlay(overlay.colours)
  }
 
  private recolourOverlay(colours: BeadColours): void {
    const rgb = colours.rgb
    for (const child of this.overlayRoot.children) {
      const mesh = child as Mesh
      const owner = mesh.userData['owner'] as Int32Array | undefined
      const radial = mesh.userData['radial'] as number | undefined
      const attribute = mesh.geometry.getAttribute('color') as BufferAttribute | undefined
      if (owner === undefined || radial === undefined || attribute === undefined) continue
      const array = attribute.array as Float32Array
      const perRing = radial + 1
      for (let v = 0; v < attribute.count; v++) {
        const ring = Math.min(owner.length - 1, Math.floor(v / perRing))
        const i = owner[ring]
        array[v * 3] = rgb[i * 3]
        array[v * 3 + 1] = rgb[i * 3 + 1]
        array[v * 3 + 2] = rgb[i * 3 + 2]
      }
      attribute.needsUpdate = true
    }
  }
 
  /** The three.js camera for a view, positioned from the pure one. */
  private applyCamera(view: StructureView): PerspectiveCamera | OrthographicCamera {
    const camera = view.camera
    const aspect = this.width / this.height
    const eye = eyeOf(camera)
    const { up } = basis(camera)
    const near = Math.max(0.005, camera.distance * 0.02)
    const far = camera.distance * 30 + 10
    if (camera.projection === 'orthographic') {
      const half = halfHeight(camera)
      const cam = this.orthographic
      cam.left = -half * aspect
      cam.right = half * aspect
      cam.top = half
      cam.bottom = -half
      cam.near = near
      cam.far = far
      cam.position.set(eye[0], eye[1], eye[2])
      cam.up.set(up[0], up[1], up[2])
      cam.lookAt(camera.target[0], camera.target[1], camera.target[2])
      cam.updateProjectionMatrix()
      this.lights.position.copy(cam.position)
      this.lights.quaternion.copy(cam.quaternion)
      return cam
    }
    const cam = this.perspective
    cam.fov = camera.fov
    cam.aspect = aspect
    cam.near = near
    cam.far = far
    cam.position.set(eye[0], eye[1], eye[2])
    cam.up.set(up[0], up[1], up[2])
    cam.lookAt(camera.target[0], camera.target[1], camera.target[2])
    cam.updateProjectionMatrix()
    // The lights ride with the eye, so the lit side of the model is the side
    // the reader is looking at whatever the orbit.
    this.lights.position.copy(cam.position)
    this.lights.quaternion.copy(cam.quaternion)
    return cam
  }
}
 
/** The material a bead, a stick or a tube is drawn in: shaded, or flat. */
function surface(lit: boolean, vertexColors = false): MeshStandardMaterial | MeshBasicMaterial {
  return ghostable(
    lit
      ? new MeshStandardMaterial({ roughness: 0.6, metalness: 0.05, vertexColors })
      : new MeshBasicMaterial({ vertexColors }),
  )
}
 
/**
 * Let a material draw each bead at its own alpha, from a {@link GHOST_ALPHA}
 * attribute.
 *
 * **Ghosting used to be a colour, and a colour is not transparency.** A ghosted
 * bead was its own colour mixed towards the page, which on a white page reads
 * as *pale* — the thing behind it is still hidden, because the geometry is
 * still opaque and still writes depth, and at an opacity of zero the bead does
 * not disappear, it becomes a white bead that punches a hole in whatever it is
 * in front of. The 2D painter never had this problem (`painter.ts` sets
 * `globalAlpha` and always has), so the same model exported to PDF and drawn on
 * screen disagreed about what ghosting meant. This is the screen catching up:
 * the alpha is real, `0` is gone and `1` is as drawn, exactly as §25.5 says.
 *
 * One attribute serves both kinds of geometry because GLSL does not care which
 * it came from: on an `InstancedMesh` an `InstancedBufferAttribute` of this name
 * is one value per instance, and on a plain `Mesh` a `BufferAttribute` of it is
 * one per vertex, and `attribute float` reads either.
 *
 * A fragment under {@link GHOST_GONE} is **discarded** rather than blended.
 * That is what makes zero mean *gone*: a discarded fragment writes no depth, so
 * a bead the reader has faded all the way out stops hiding the model behind it
 * instead of standing there invisibly in the way.
 *
 * `defaultAttributeValues` is the safety net for a geometry that has no such
 * attribute at all — the overlay's tubes, anything added later. Without it a
 * missing attribute reads as zero and every fragment would discard, which is a
 * model that silently does not draw.
 */
function ghostable<T extends MeshStandardMaterial | MeshBasicMaterial>(material: T): T {
  material.transparent = true
  // `defaultAttributeValues` is a real property of every three material and is
  // missing from its types, so it is reached through a narrow cast rather than
  // left out — without it a geometry with no such attribute reads zero and
  // discards every fragment, which is a model that silently does not draw.
  const defaults = material as unknown as { defaultAttributeValues?: Record<string, number[]> }
  defaults.defaultAttributeValues = { ...defaults.defaultAttributeValues, [GHOST_ALPHA]: [1] }
  material.onBeforeCompile = (shader) => {
    shader.vertexShader = `attribute float ${GHOST_ALPHA};\nvarying float vGhostAlpha;\n${shader.vertexShader}`.replace(
      '#include <begin_vertex>',
      `#include <begin_vertex>\n\tvGhostAlpha = ${GHOST_ALPHA};`,
    )
    shader.fragmentShader = `varying float vGhostAlpha;\n${shader.fragmentShader}`.replace(
      '#include <dithering_fragment>',
      `#include <dithering_fragment>\n\tif ( vGhostAlpha < ${GHOST_GONE.toFixed(4)} ) discard;\n\tgl_FragColor.a *= vGhostAlpha;`,
    )
  }
  // Without a key of its own three would hand this material a program compiled
  // for an unpatched one of the same kind, and the attribute would never exist.
  material.customProgramCacheKey = () => GHOST_ALPHA
  return material
}
 
/** One value per instance or per vertex, filled opaque, for {@link ghostable}. */
function ghostAttribute(count: number, instanced: boolean): BufferAttribute | InstancedBufferAttribute {
  const values = new Float32Array(Math.max(1, count)).fill(1)
  return instanced ? new InstancedBufferAttribute(values, 1) : new BufferAttribute(values, 1)
}
 
/** Which beads are drawn at all under this view. */
function shownBeads(view: StructureView): Uint8Array {
  const { structure, style } = view
  const n = structure.set.length
  const shown = new Uint8Array(n)
  for (let i = 0; i < n; i++) {
    if (structure.present[i] === 0) continue
    if (view.haplotype >= 0 && structure.model.hap[i] !== view.haplotype) continue
    if (view.lit !== null && view.lit[i] === 0 && style.others === 'hide') continue
    shown[i] = 1
  }
  return shown
}
 
/** The bead pairs a stick joins: neighbours on a run. */
function pairsOf(view: StructureView, shown: Uint8Array): Array<[number, number]> {
  const { structure } = view
  const pairs: Array<[number, number]> = []
  for (const chain of structure.chains) {
    if (view.haplotype >= 0 && chain.hap !== view.haplotype) continue
    for (let r = 0; r < chain.runs.length; r += 2) {
      for (let k = chain.runs[r] + 1; k < chain.runs[r + 1]; k++) {
        const a = chain.beads[k - 1]
        const b = chain.beads[k]
        if (shown[a] === 1 && shown[b] === 1) pairs.push([a, b])
      }
    }
  }
  return pairs
}
 
/** The two beads either side of each gap in a chain: the last of one run, the first of the next. */
function gapPairsOf(view: StructureView, shown: Uint8Array): Array<[number, number]> {
  const { structure } = view
  const pairs: Array<[number, number]> = []
  for (const chain of structure.chains) {
    if (view.haplotype >= 0 && chain.hap !== view.haplotype) continue
    for (let r = 2; r < chain.runs.length; r += 2) {
      const a = chain.beads[chain.runs[r - 1] - 1]
      const b = chain.beads[chain.runs[r]]
      if (shown[a] === 1 && shown[b] === 1) pairs.push([a, b])
    }
  }
  return pairs
}
 
const NO_CHORDS: ReadonlyArray<readonly [number, number]> = []
 
/**
 * A black line along or across the fibre — a gap's dashes, a loop's chord — as
 * a share of the tube's own radius.
 *
 * Half. These lines are *about* the model rather than part of it, and one as
 * thick as the fibre it crosses reads as a piece of fibre; at half it reads as
 * a line drawn over one, which is what it is.
 */
const INK_SHARE = 0.5
 
/** A flat material of the page's ink, for every line the model carries. */
/** `0, 1, … n − 1`, for a helper that wants an iterable of every bead. */
function* counting(n: number): Generator<number, void, void> {
  for (let i = 0; i < n; i++) yield i
}
 
function inkMaterial(ink: string, opacity: number): MeshBasicMaterial {
  return opacity >= 1
    ? new MeshBasicMaterial({ color: hexOf(ink) })
    : new MeshBasicMaterial({ color: hexOf(ink), transparent: true, opacity })
}
 
/**
 * One instanced mesh of cylinders, one per segment, each turned from the y
 * axis onto its two ends. Never culled: a segment placed after the sphere
 * was computed was culled as out of view and never drawn.
 */
function cylinders(
  segments: ReadonlyArray<readonly [Vector3, Vector3]>,
  radius: number,
  material: MeshBasicMaterial,
): InstancedMesh {
  const mesh = new InstancedMesh(new CylinderGeometry(1, 1, 1, 8, 1, true), material, Math.max(1, segments.length))
  mesh.count = segments.length
  const matrix = new Matrix4()
  const q = new Quaternion()
  const up = new Vector3(0, 1, 0)
  segments.forEach(([from, to], at) => {
    const dir = to.clone().sub(from)
    const length = dir.length()
    q.setFromUnitVectors(up, length > 0 ? dir.normalize() : up)
    matrix.compose(from.clone().add(to).multiplyScalar(0.5), q, new Vector3(radius, Math.max(length, 1e-6), radius))
    mesh.setMatrixAt(at, matrix)
  })
  mesh.instanceMatrix.needsUpdate = true
  mesh.frustumCulled = false
  return mesh
}
 
/** Cumulative polyline length at each point, from zero. */
function cumulative(points: Vector3[]): number[] {
  const out = [0]
  for (let k = 1; k < points.length; k++) out.push(out[k - 1] + points[k].distanceTo(points[k - 1]))
  return out
}
 
/** What the geometry depends on, so a change elsewhere leaves it alone. */
function buildKey(view: StructureView): string {
  const { structure, style } = view
  let litKey = ''
  if (view.lit !== null && style.others === 'hide') {
    // Under `hide` the lit mask changes which beads exist, so it is part of
    // the key; under `ghost` and `show` it is a colour and is not.
    let hash = 0
    for (let i = 0; i < view.lit.length; i++) hash = (hash * 31 + view.lit[i]) | 0
    litKey = String(hash)
  }
  return [
    structure.set.length,
    structure.placed,
    style.beads,
    style.sticks,
    style.tube,
    style.beadRadius,
    style.tubeRadius,
    view.haplotype,
    style.others,
    litKey,
  ].join('|')
}