-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmolwhiz.html
More file actions
1705 lines (1687 loc) · 197 KB
/
Copy pathmolwhiz.html
File metadata and controls
1705 lines (1687 loc) · 197 KB
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
<!-- molWhiz — original molecular visualizer (three.js MIT scaffolding; all molecule code original).
Dual hidable sidebars (left = functions, right = appearance), like brainWhiz.
Phase 1+: PDB/SDF/XYZ parse + bond inference, instanced spacefill / ball-&-stick / sticks,
material styles, colour by element/chain/residue, spin X/Y/Z, click-to-identify, PDB-ID fetch,
built-ins, fully-customizable procedural B-DNA.
© 2026 Roger Newman-Norlund. Not for redistribution without a licence. -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>molWhiz — molecular visualizer</title>
<style>
:root{--bg:#0a0e16;--panel:#121826;--txt:#e6edf7;--muted:#8ea2c0;--acc:#5db0ff;--line:#26314a}
*{box-sizing:border-box} html,body{margin:0;height:100%;background:var(--bg);color:var(--txt);font:13px system-ui,Segoe UI,Roboto,sans-serif;overflow:hidden}
#app{position:fixed;inset:0} #app canvas{display:block}
/* top bar */
#topbar{position:fixed;top:0;left:0;right:0;height:46px;display:flex;align-items:center;gap:8px;padding:0 10px;background:rgba(14,19,28,.92);border-bottom:1px solid var(--line);z-index:20}
#topbar h1{font-size:16px;margin:0;letter-spacing:.5px} #topbar h1 b{color:var(--acc)}
#verPill{font-size:10px;font-weight:700;color:var(--acc);background:rgba(93,176,255,.14);border:1px solid var(--line);border-radius:20px;padding:2px 8px;margin-left:-2px;letter-spacing:.3px}
#topbar .sp{flex:1}
.tbtn{background:#1b2436;color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:6px 10px;font:inherit;cursor:pointer}
.tbtn:hover{border-color:var(--acc)} .tbtn.on{background:var(--acc);color:#06223a;border-color:var(--acc);font-weight:600}
/* sidebars */
.side{position:fixed;top:46px;bottom:0;width:270px;background:var(--panel);overflow-y:auto;padding:12px;z-index:15;transition:transform .18s}
#sidebar{left:0;border-right:1px solid var(--line)} #sidebar.hidden{transform:translateX(-100%)}
#sidebarR{right:0;border-left:1px solid var(--line)} #sidebarR.hidden{transform:translateX(100%)}
.grp{border-top:1px solid var(--line);padding:10px 0 4px} .grp:first-child{border-top:0}
.lbl{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:7px}
.grp>.lbl{cursor:pointer;user-select:none} .grp>.lbl::before{content:"▾ ";font-size:9px;color:var(--acc)} .grp.collapsed>.lbl::before{content:"▸ "} .grp.collapsed>.lbl{margin-bottom:0} .grp.collapsed>*:not(.lbl){display:none!important}
.row{display:flex;align-items:center;gap:6px;margin-bottom:7px}
.row label{min-width:62px;color:var(--muted)} .row label.tog{min-width:0}
select,input[type=text]{flex:1;min-width:0;background:#0c1220;color:var(--txt);border:1px solid var(--line);border-radius:6px;padding:5px 7px;font:inherit}
input[type=range]{flex:1} input[type=color]{width:30px;height:24px;border:1px solid var(--line);border-radius:5px;background:none;padding:0}
.num{min-width:30px;text-align:right;color:var(--muted);font-size:11px}
button{background:#1b2436;color:var(--txt);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font:inherit;cursor:pointer}
button:hover{border-color:var(--acc)} button.acc{background:var(--acc);color:#06223a;border-color:var(--acc);font-weight:600}
.seg{display:flex;flex-wrap:wrap;gap:4px} .seg button{flex:1 1 auto;min-width:56px;padding:5px 4px;font-size:11px} .seg button.on{background:var(--acc);color:#06223a;border-color:var(--acc);font-weight:600}
.cgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}
.cgrid .c{display:flex;flex-direction:column;align-items:center;gap:2px;font-size:10px;color:var(--muted)}
#hud{position:fixed;left:50%;transform:translateX(-50%);bottom:14px;background:rgba(10,14,22,.9);border:1px solid var(--line);border-radius:8px;padding:8px 14px;z-index:9;opacity:0;transition:opacity .15s;pointer-events:none;text-align:center}
#hud.on{opacity:1} #hud .t{font-weight:700} #hud .s{color:var(--muted);font-size:11px}
#zoomBar{position:fixed;bottom:12px;left:50%;transform:translateX(-50%);z-index:8;display:flex;align-items:center;gap:8px;background:rgba(10,14,22,.72);border:1px solid var(--line);border-radius:20px;padding:5px 14px}
#zoomBar input{width:200px}
#vignette{position:fixed;inset:0;z-index:6;pointer-events:none;display:none;background:radial-gradient(ellipse at center, rgba(0,0,0,0) 45%, rgba(0,0,0,0.55) 100%)}
#vignette.on{display:block}
#legend{position:fixed;left:290px;bottom:14px;z-index:8;display:none;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.4)}
#legend.on{display:block}
#seqPanel{position:fixed;top:46px;left:290px;right:290px;z-index:12;background:rgba(12,16,24,.96);border:1px solid var(--line);border-radius:0 0 8px 8px;padding:8px 10px;display:none;max-height:180px;overflow:auto}
#seqPanel.on{display:block} #seqHd{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.seqrow{display:flex;flex-wrap:wrap;align-items:center;gap:1px;margin-bottom:3px}
.seqch{font-weight:700;color:var(--acc);margin-right:6px;font-size:11px;min-width:16px}
.seqc{font:600 11px ui-monospace,monospace;width:15px;height:18px;display:flex;align-items:center;justify-content:center;background:#1a2130;color:#cfe0f5;border-radius:2px;cursor:pointer;user-select:none}
.seqc:hover{background:#2a3852} .seqc.sel{background:#ffe066;color:#1a1a1a}
.suggest{margin-top:4px} .suggest .afitem{padding:5px 7px;font-size:11px;background:#141a24;border:1px solid var(--line);border-radius:4px;margin-bottom:3px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.suggest .afitem:hover{background:#243248} .suggest .afitem b{color:var(--acc)}
@keyframes blipred{0%,100%{background:#141a24}50%{background:#5a1620;border-color:#ff5b6e}}
.blip{animation:blipred .4s ease 2}
#atomProps{position:fixed;left:290px;bottom:14px;width:264px;max-width:calc(100vw - 320px);background:rgba(10,14,22,.96);border:1px solid var(--acc);border-radius:10px;padding:12px 14px;z-index:30;opacity:0;transition:opacity .15s;pointer-events:none;font-size:12px;line-height:1.55}
#atomProps.on{opacity:1;pointer-events:auto} #atomProps .hd{font-weight:700;font-size:14px;margin-bottom:6px;padding-right:16px;cursor:move;user-select:none}
#atomProps .bd b{color:#eaf2ff;font-weight:600} #atomProps .bd .k{color:var(--muted);display:inline-block;min-width:96px}
#atomProps .x{position:absolute;top:8px;right:8px;background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px}
#atomProps .dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px;vertical-align:middle}
#figModal{position:fixed;inset:0;z-index:60;background:rgba(6,10,16,.97);display:none;grid-template-columns:310px 1fr;gap:14px;padding:16px}
#figModal.on{display:grid}
#figCtl{overflow-y:auto;font-size:12px} #figCtl h2{font-size:15px;margin:0 0 10px;display:flex;justify-content:space-between;align-items:center}
#figPrevWrap{display:flex;align-items:center;justify-content:center;overflow:auto;background:#1a1f28;border:1px solid var(--line);border-radius:8px;padding:14px}
#figGrid{display:grid;gap:8px;width:100%;max-width:900px;align-content:center}
.figcell{position:relative;aspect-ratio:4/3;border:1px dashed var(--line);border-radius:6px;display:flex;align-items:center;justify-content:center;background:#0c1119;overflow:hidden;cursor:pointer}
.figcell.filled{border-style:solid;cursor:default}
.figcell img{width:100%;height:100%;object-fit:contain}
.figcell .plus{font-size:30px;color:var(--muted)}
.figcell .cbtns{position:absolute;top:3px;right:3px;display:none;gap:3px}
.figcell.filled:hover .cbtns{display:flex}
.figcell .cbtns button{background:#000b;border:none;border-radius:4px;color:#fff;padding:1px 5px;font-size:11px;cursor:pointer}
.figcell .cbtns button:hover{color:var(--acc)}
.figcell .clab{position:absolute;bottom:0;left:0;right:0;background:#000a;color:#fff;border:none;font-size:11px;padding:2px 5px;text-align:center}
.figcell .clab::placeholder{color:#ffffff88}
.figcell .tag{position:absolute;top:3px;left:5px;background:#000a;color:#fff;font-weight:700;font-size:11px;padding:0 4px;border-radius:3px}
#status{font-size:11px;color:var(--muted);min-height:14px}
#drop{position:fixed;inset:0;z-index:50;display:none;align-items:center;justify-content:center;background:rgba(10,20,40,.75);border:3px dashed var(--acc);font-size:22px;font-weight:700;color:#dff0ff}
#drop.on{display:flex}
</style>
<div id="app"></div>
<div id="vignette"></div>
<div id="drop">Drop a .pdb · .cif · .mol2 · .sdf/.mol · .xyz · .gro · .pdbqt · or .mwz/.mwzproj</div>
<div id="hud"><div class="t"></div><div class="s"></div></div>
<div id="zoomBar"><span title="zoom">🔍</span><input type="range" id="zoom" min="0" max="100" value="30"></div>
<canvas id="legend"></canvas>
<div id="seqPanel"><div id="seqHd"><b>Sequence</b> <span style="color:var(--muted);font-size:11px">click residues to highlight</span><button id="seqClear" class="tbtn" style="margin-left:auto">clear</button><button id="seqClose" class="tbtn">✕</button></div><div id="seqBody"></div></div>
<div id="atomProps"><button class="x" id="apX" title="close">✕</button><div class="hd"></div><div class="bd"></div></div>
<div id="figModal">
<div id="figCtl">
<h2>🎬 Figure builder <button class="tbtn" id="figClose">✕ close</button></h2>
<div style="font-size:10px;color:var(--muted);margin:0 0 8px">Set a grid, then click a <b>+</b> slot to drop in the current 3D view. Rotate/restyle the molecule behind this dialog between captures. Edit ✎ / restore ⚙ / recapture ⟳ / remove ✕ per panel.</div>
<div class="row"><label>Grid</label><input type="number" id="figRows" min="1" max="6" value="1" style="width:46px"> rows × <input type="number" id="figCols" min="1" max="6" value="2" style="width:46px"> cols</div>
<div class="row"><label>Title</label><input id="figTitle" placeholder="(optional figure title)"></div>
<div class="row"><label>Gap</label><input type="range" id="figGap" min="0" max="40" step="2" value="14"><span class="num" id="figGapV">14</span></div>
<div class="row"><label>Labels</label><select id="figLabel"><option value="ABC">A B C…</option><option value="abc">a b c…</option><option value="123">1 2 3…</option><option value="none">none</option></select></div>
<div class="row"><label>Background</label><input type="color" id="figBg" value="#ffffff"><button class="tbtn" id="figBgW" title="white">▢</button><button class="tbtn" id="figBgB" title="black">▣</button></div>
<div class="row"><button id="figFill6" style="flex:1" title="auto-capture 6 standard views into a 2×3">⤢ Auto 6-view (2×3)</button><button id="figClear" title="clear all slots">🗑</button></div>
<div class="lbl" style="margin-top:10px">Export</div>
<div class="row"><label>PNG scale</label><select id="figScale"><option>1</option><option selected>2</option><option>3</option><option>4</option></select><button id="figPNG" class="acc">PNG</button></div>
<div class="row" style="margin-top:5px"><button id="figPDF" style="flex:1">PDF</button><button id="figSVG" style="flex:1">SVG</button></div>
<div id="figOut" style="font-size:11px;color:var(--muted);margin-top:8px"></div>
</div>
<div id="figPrevWrap"><div id="figGrid"></div></div>
</div>
<div id="topbar">
<button class="tbtn" id="tglL" title="show / hide functions">☰</button>
<h1>mol<b>Whiz</b></h1><span id="verPill" title="build version"></span>
<select id="example" style="max-width:210px"><option value="">Gallery…</option>
<optgroup label="Small molecules (offline)"><option value="ex:caffeine">Caffeine</option><option value="ex:benzene">Benzene</option><option value="ex:ethanol">Ethanol</option><option value="ex:water">Water</option><option value="ex:methane">Methane</option></optgroup>
<optgroup label="Drugs (online · PubChem)"><option value="chem:aspirin">Aspirin</option><option value="chem:ibuprofen">Ibuprofen</option><option value="chem:acetaminophen">Acetaminophen (Tylenol)</option><option value="chem:penicillin g">Penicillin G</option><option value="chem:morphine">Morphine</option><option value="chem:dopamine">Dopamine</option><option value="chem:serotonin">Serotonin</option><option value="chem:nicotine">Nicotine</option><option value="chem:aspartame">Aspartame</option><option value="chem:sildenafil">Sildenafil (Viagra)</option><option value="chem:atorvastatin">Atorvastatin (Lipitor)</option><option value="chem:penicillin">Penicillin</option></optgroup>
<optgroup label="Biochemistry (online · PubChem)"><option value="chem:glucose">Glucose</option><option value="chem:sucrose">Sucrose</option><option value="chem:cholesterol">Cholesterol</option><option value="chem:ATP">ATP</option><option value="chem:NAD+">NAD⁺</option><option value="chem:heme b">Heme B</option><option value="chem:retinol">Retinol (vit A)</option><option value="chem:ascorbic acid">Vitamin C</option><option value="chem:citric acid">Citric acid</option><option value="chem:testosterone">Testosterone</option><option value="chem:estradiol">Estradiol</option><option value="chem:cortisol">Cortisol</option><option value="chem:glycine">Glycine</option><option value="chem:L-tryptophan">Tryptophan</option></optgroup>
<optgroup label="Proteins"><option value="pdb:1CRN">Crambin (1CRN)</option><option value="pdb:1UBQ">Ubiquitin (1UBQ)</option><option value="pdb:3I40">Insulin (3I40)</option><option value="pdb:1HHO">Hemoglobin (1HHO)</option><option value="pdb:1IGT">Antibody IgG (1IGT)</option><option value="pdb:1GFL">Green fluorescent protein (1GFL)</option><option value="pdb:2LYZ">Lysozyme (2LYZ)</option><option value="pdb:1MBN">Myoglobin (1MBN)</option><option value="pdb:4HHB">Deoxyhaemoglobin (4HHB)</option><option value="pdb:1ATP">Protein kinase A (1ATP)</option></optgroup>
<optgroup label="Bound complexes (+ ligand)"><option value="pdb:1HSG">HIV protease + drug (1HSG)</option><option value="pdb:6LU7">SARS-CoV-2 Mpro + inhibitor (6LU7)</option><option value="pdb:1STP">Streptavidin + biotin (1STP)</option><option value="pdb:2HHB">Hemoglobin + heme (2HHB)</option><option value="pdb:3PTB">Trypsin + benzamidine (3PTB)</option><option value="pdb:1M17">EGFR kinase + erlotinib (1M17)</option><option value="pdb:2XYT">COX-2 + ibuprofen-like (2XYT)</option><option value="pdb:4DFR">DHFR + methotrexate (4DFR)</option></optgroup>
<optgroup label="🔮 AlphaFold (predicted, by UniProt)"><option value="af:P69905">Hemoglobin α (P69905)</option><option value="af:P01308">Insulin (P01308)</option><option value="af:P04637">p53 tumour suppressor (P04637)</option><option value="af:P00698">Lysozyme C (P00698)</option><option value="af:P0DTC2">SARS-CoV-2 spike (P0DTC2)</option><option value="af:P0DP23">Calmodulin (P0DP23)</option><option value="af:P02769">Serum albumin (P02769)</option></optgroup>
<optgroup label="DNA / DNA–protein"><option value="dna:build">🧬 Build B-DNA (from sequence)</option><option value="pdb:1BNA">B-DNA dodecamer (1BNA)</option><option value="pdb:1AOI">Nucleosome — DNA+histones (1AOI)</option><option value="pdb:1LMB">λ repressor–DNA (1LMB)</option><option value="pdb:1D66">GAL4–DNA (1D66)</option><option value="pdb:6TNA">tRNA (6TNA)</option></optgroup>
</select>
<input type="text" id="findq" placeholder="Search any molecule — name · IUPAC · SMILES · PDB" autocomplete="off" style="flex:1 1 320px;min-width:230px;max-width:460px">
<button class="tbtn" id="findBtn" title="Search by common name (aspirin), systematic IUPAC name (2-acetyloxybenzoic acid), SMILES (CC(=O)O), or 4-char PDB ID (1CRN). 3D from PubChem · OPSIN · CACTUS · RCSB.">🔎</button>
<div id="findSuggest" class="suggest" style="position:fixed;z-index:40;display:none;max-height:340px;overflow:auto"></div>
<input type="text" id="pdbid" placeholder="PDB ID" maxlength="8" style="max-width:96px;text-transform:uppercase">
<button class="tbtn" id="fetchBtn">Fetch</button>
<span class="sp"></span>
<button class="tbtn" id="fitBtn" title="fit to view">⤢ Fit</button>
<button class="tbtn" id="pngBtn">🖼 PNG</button>
<button class="tbtn" id="seqBtn" title="sequence viewer — click residues to highlight">🧬 Seq</button>
<button class="tbtn" id="figBtn" title="multi-panel figure builder">🎬 Figure</button>
<button class="tbtn" id="tglR" title="show / hide appearance">🎨</button>
</div>
<!-- LEFT: functions -->
<div id="sidebar" class="side">
<div class="grp"><div class="lbl">Load structure</div>
<div class="row"><button id="openBtn" style="flex:1">📂 Open .pdb / .cif / .sdf / .xyz…</button>
<input type="file" id="file" accept=".pdb,.ent,.pdbqt,.cif,.mmcif,.sdf,.mol,.mol2,.xyz,.gro,.mwz,.mwzproj,.json" style="display:none"></div>
<div class="row"><span style="font-size:11px;color:var(--muted)">…or drag a file anywhere · or Fetch a PDB ID above</span></div>
<div class="row" style="margin-top:5px"><input type="text" id="afid" placeholder="UniProt or protein name…" autocomplete="off"><button id="afBtn" title="fetch predicted structure from AlphaFold">🔮 AlphaFold</button></div>
<div id="afSuggest" class="suggest"></div>
<div id="status">caffeine</div>
</div>
<div class="grp"><div class="lbl">🧬 Build B-DNA from sequence</div>
<div class="row"><input type="text" id="dnaSeq" placeholder="ATGCATTAGC…" value="ATGGCCTAGCGTACGATTACC"></div>
<div class="row"><button id="dnaBtn" class="acc" style="flex:1">Build double helix</button>
<button id="dnaRand" title="random sequence">🎲</button></div>
<div class="row"><span style="font-size:11px;color:var(--muted)">Appearance (radius, ball size, base colours…) is in the 🎨 panel.</span></div>
</div>
<div class="grp"><div class="lbl">📏 Measure</div>
<div class="row"><button id="measBtn" style="flex:1">Distance / angle</button><button id="measKeep" title="keep this measurement & start a new one">📌</button><button id="measClear" title="clear all">✕</button></div>
<div id="measOut" style="font-size:11px;color:var(--muted);min-height:14px"></div>
</div>
<div class="grp"><div class="lbl">🔗 Contacts</div>
<div class="row"><label class="tog"><input type="checkbox" id="hbondOn"> show H-bonds / polar contacts</label></div>
<div class="row"><label>Colour</label><input type="color" id="hbColor" value="#66e0ff"><label class="tog" style="margin-left:8px"><input type="checkbox" id="hbDashed" checked> dashed</label></div>
<div class="row"><label>Thickness</label><input type="range" id="hbThick" min="0.02" max="0.3" step="0.01" value="0.06"><span class="num" id="hbThickV">0.06</span></div>
<div class="row"><label>Dash len</label><input type="range" id="hbDash" min="0.15" max="1.2" step="0.05" value="0.4"><span class="num" id="hbDashV">0.40</span></div>
<div class="row"><label>Gap</label><input type="range" id="hbGap" min="0" max="1" step="0.05" value="0.25"><span class="num" id="hbGapV">0.25</span></div>
<div id="hbondOut" style="font-size:11px;color:var(--muted);min-height:14px"></div>
</div>
<div class="grp"><div class="lbl">🎯 Selection</div>
<div class="row"><label>Chain</label><select id="selChain"><option value="">(all chains)</option></select></div>
<div class="seg" id="selKind" style="margin-top:5px"><button data-k="protein">Protein</button><button data-k="nucleic">Nucleic</button><button data-k="ligand">Ligand</button><button data-k="water">Water</button></div>
<div style="font-size:10px;color:var(--muted);margin:5px 0">Pick a chain and/or kind, then act:</div>
<div class="row"><button id="selIsolate" style="flex:1">Isolate</button><button id="selHide" style="flex:1">Hide</button></div>
<div class="row"><input type="color" id="selColor" value="#ff3b30"><button id="selColorBtn" style="flex:1">Colour selection</button></div>
<div class="row"><button id="selReset" style="flex:1">Show all · reset colours</button></div>
<div class="row" style="margin-top:5px"><button id="activeSiteBtn" class="acc" style="flex:1" title="Find the bound ligand and isolate it with the residues that line its pocket + H-bonds + labels">🎯 Active site (ligand pocket)</button>
<input type="number" id="pocketR" value="4.5" min="2" max="8" step="0.5" style="width:52px" title="pocket radius (Å)"></div>
<div class="row"><label class="tog"><input type="checkbox" id="resLabels"> residue labels (selection)</label></div>
<div id="selOut" style="font-size:11px;color:var(--muted);min-height:14px"></div>
</div>
<div class="grp"><div class="lbl">🔧 Build / edit</div>
<div class="row"><button id="buildBtn" style="flex:1">Build mode: OFF</button></div>
<div id="buildUI" style="display:none">
<div style="font-size:10px;color:var(--muted);margin:2px 0 5px">Click an atom to grow from it (or start empty). Buttons add a bonded atom/group.</div>
<div class="lbl" style="margin:4px 0 3px">Atoms</div>
<div class="seg" id="buildEls"><button data-el="H">H</button><button data-el="C" class="on">C</button><button data-el="N">N</button><button data-el="O">O</button><button data-el="S">S</button><button data-el="P">P</button><button data-el="F">F</button><button data-el="CL">Cl</button></div>
<div class="lbl" style="margin:8px 0 3px">Fragments (snap on)</div>
<div class="seg" id="buildFrags"><button data-frag="ch3">–CH₃</button><button data-frag="oh">–OH</button><button data-frag="nh2">–NH₂</button><button data-frag="carbonyl">=O</button><button data-frag="phenyl">–C₆H₅</button><button data-frag="water">+H₂O</button></div>
<div class="row" style="margin-top:7px"><button id="buildAddBtn" class="acc" style="flex:1" title="add the selected element bonded to the growth atom">+ add atom</button><button id="buildDel" title="delete selected atom">🗑</button></div>
<div class="row"><button id="buildSetEl" style="flex:1" title="change the clicked atom to the chosen element">Set clicked atom → element</button></div>
<div class="row"><button id="buildDelMol" style="flex:1" title="delete the whole connected molecule the atom belongs to">🗑 delete molecule</button><button id="buildUndo" title="undo (⌘Z)">↶ undo</button></div>
<div class="row"><button id="buildNew" style="flex:1">New molecule (clear)</button><button id="buildFillH" title="fill open valences with H">+H all</button></div>
<div style="font-size:10px;color:var(--muted);line-height:1.4">Tip: drag an atom to move it (snaps to bond length). New atoms are placed at the correct bond length in the least-crowded direction (VSEPR).</div>
<div id="buildOut" style="font-size:11px;color:var(--muted);min-height:14px">selected: none</div>
</div>
</div>
<div class="grp"><div class="lbl">🎞 Motion & animation</div>
<div class="row"><label>Spin axis</label>
<div class="seg" id="spinSeg"><button data-ax="x">X</button><button data-ax="y">Y</button><button data-ax="z">Z</button></div></div>
<div class="row" style="margin-top:7px"><label>Speed</label><input type="range" id="spinSpeed" min="0.1" max="4" step="0.1" value="1"><span class="num" id="spinSpeedV">1.0</span></div>
<div class="row"><label class="tog"><input type="checkbox" id="rockMode"> rock (oscillate)</label></div>
<div class="row"><button class="tbtn" id="spinStop" style="width:100%">■ stop all motion</button></div>
<div class="row"><button id="turntableBtn" class="acc" style="width:100%" title="record a 360° spin to a .webm video">🎥 Record turntable (.webm)</button></div>
</div>
<div class="grp"><div class="lbl">💾 Export / save</div>
<div style="font-size:10px;color:var(--muted);margin-bottom:5px">.mwz = scene (molecule + look + camera) · .mwzproj = project. Drag either back in to restore.</div>
<div class="row"><button id="saveScene" class="acc" style="flex:1" title="molecule + all appearance + camera">Save scene (.mwz)</button></div>
<div class="row"><button id="saveProject" style="flex:1">Save project (.mwzproj)</button><button id="loadPresetBtn" title="load .mwz / .mwzproj">📂</button><input type="file" id="presetFile" accept=".mwz,.mwzproj,.json" style="display:none"></div>
<div class="lbl" style="margin:8px 0 3px">Molecule geometry</div>
<div class="row"><button id="expXYZ" style="flex:1">.xyz</button><button id="expPDB" style="flex:1">.pdb</button><button id="expMOL2" style="flex:1">.mol2</button></div>
<div class="row"><button id="expHTML" style="flex:1" title="self-contained interactive viewer">Export interactive .html</button></div>
</div>
<div class="grp"><div class="lbl">About</div>
<div style="font-size:11px;color:var(--muted);line-height:1.5">Original molecular viewer. Reads PDB / SDF·MOL / XYZ. Bonds inferred by covalent radii. Click any atom to identify it.<br><a href="https://rnorlund.github.io/brainWhiz/" target="_blank" style="color:var(--muted)">← brainWhiz</a> · © 2026</div>
</div>
</div>
<!-- RIGHT: appearance -->
<div id="sidebarR" class="side">
<div class="grp"><div class="lbl">Representation</div>
<div class="seg" id="repSeg"><button data-rep="bas" class="on">Ball & stick</button><button data-rep="space">Spacefill</button><button data-rep="stick">Sticks</button><button data-rep="cartoon">Cartoon</button><button data-rep="surface">Surface</button><button data-rep="atomic">⚛ Atoms</button><button data-rep="voronoi">◆ Cells</button></div>
<div class="row" style="margin-top:7px"><label>Atom shape</label>
<select id="atomShape"><option value="sphere">Sphere</option><option value="cube">Cube</option><option value="ico">Icosahedron</option><option value="octa">Octahedron</option><option value="tetra">Tetrahedron</option><option value="dodeca">Dodecahedron</option><option value="cone">Cone</option><option value="torus">Torus</option><option value="cyl">Cylinder</option><option value="diamond">Diamond</option></select></div>
<div class="row"><label>Connector</label>
<select id="bondShape"><option value="cyl">Cylinder</option><option value="box">Square rod</option><option value="hex">Hex prism</option><option value="tri">Triangular</option><option value="line">Thin line</option><option value="none">None</option></select></div>
<div class="row"><label>Atom size</label><input type="range" id="atomScale" min="0.08" max="1" step="0.02" value="0.28"><span class="num" id="atomScaleV">0.28</span></div>
<div class="row"><label>Bond size</label><input type="range" id="bondR" min="0.04" max="0.4" step="0.01" value="0.15"><span class="num" id="bondRV">0.15</span></div>
<div class="row"><label class="tog"><input type="checkbox" id="showH" checked> show hydrogens</label></div>
<div class="row"><label class="tog"><input type="checkbox" id="hideSolvent" checked> hide waters / ions</label></div>
<div class="row"><label class="tog"><input type="checkbox" id="sideChains"> side chains on cartoon</label></div>
</div>
<div class="grp"><div class="lbl">Material / texture</div>
<div class="row"><label title="15 baked-studio matcaps live in this dropdown (Clay, Chrome, Gold, Pearl…)">Style / matcap ▾</label>
<select id="material"><optgroup label="Shaded"><option value="standard">Standard</option><option value="matte">Matte</option><option value="glossy">Glossy</option><option value="metal">Metallic</option><option value="clearcoat">Clearcoat</option><option value="iridescent">Iridescent</option><option value="toon">Toon (cel)</option><option value="glass">Glass</option></optgroup>
<optgroup label="Matcap (baked studio)"><option value="matcap:Clay">Clay</option><option value="matcap:Skin">Skin</option><option value="matcap:Pearl">Pearl</option><option value="matcap:Jade">Jade</option><option value="matcap:Bronze">Bronze</option><option value="matcap:Chrome">Chrome</option><option value="matcap:Gold">Gold</option><option value="matcap:Wax">Wax</option><option value="matcap:Basalt">Basalt</option><option value="matcap:Copper">Copper</option><option value="matcap:Pewter">Pewter</option><option value="matcap:Ruby">Ruby</option><option value="matcap:Emerald">Emerald</option><option value="matcap:Sapphire">Sapphire</option><option value="matcap:Porcelain">Porcelain</option></optgroup></select></div>
<div class="row"><label class="tog"><input type="checkbox" id="rim"> rim glow</label></div>
<div class="row"><label class="tog"><input type="checkbox" id="wireframe"> wireframe</label> <label class="tog" style="margin-left:10px"><input type="checkbox" id="flatShade"> flat facets</label></div>
<div class="row"><label>Pattern</label>
<select id="pattern"><option>None</option><option>Checkerboard</option><option>Stripes</option><option>Grid lines</option><option>Dots</option><option>Hatch</option><option>Cross-hatch</option><option>Bricks</option><option>Waves</option><option>Concentric</option><option>Lattice</option></select></div>
<div class="row"><label>Pat scale</label><input type="range" id="patScale" min="0.15" max="20" step="0.05" value="0.6"><span class="num" id="patScaleV">0.60</span></div>
<div class="row"><label>Pat strength</label><input type="range" id="patStr" min="0" max="1" step="0.05" value="0.5"><span class="num" id="patStrV">0.50</span></div>
<div class="row"><label>Pat tilt</label><input type="range" id="patTilt" min="0" max="90" step="5" value="0"><span class="num" id="patTiltV">0</span></div>
<div class="row"><label class="tog"><input type="checkbox" id="patCut"> cut holes (perforate)</label></div>
</div>
<div class="grp"><div class="lbl">💡 Lighting & finish</div>
<div class="seg" id="ltPreset" style="margin-bottom:6px"><button data-p="studio" class="on">Studio</button><button data-p="soft">Soft</button><button data-p="dramatic">Dramatic</button><button data-p="rim">Rim</button><button data-p="warm">Warm</button></div>
<div class="row"><label title="overall brightness (camera exposure)">Exposure</label><input type="range" id="ltExposure" min="0.2" max="2.5" step="0.05" value="1.05"><span class="num" id="ltExposureV">1.05</span></div>
<div class="row"><label title="strength of the studio environment reflections (image-based lighting)">Reflections</label><input type="range" id="ltEnv" min="0" max="3" step="0.05" value="1"><span class="num" id="ltEnvV">1.00</span></div>
<div class="row"><label>Tone map</label><select id="ltTone"><option value="ACES">ACES filmic</option><option value="Neutral">Neutral</option><option value="Reinhard">Reinhard</option><option value="Cineon">Cineon</option><option value="Linear">Linear</option><option value="None">None (raw)</option></select></div>
<div class="lbl2" style="margin-top:7px;font-size:11px;color:var(--muted)">Ambient (fills shadows)</div>
<div class="row"><label>Amount</label><input type="range" id="ltAmb" min="0" max="3" step="0.05" value="1"><span class="num" id="ltAmbV">1.00</span>
<input type="color" id="ltSky" value="#ffffff" style="width:28px" title="sky colour"><input type="color" id="ltGround" value="#33404f" style="width:28px" title="ground colour"></div>
<div class="lbl2" style="margin-top:7px;font-size:11px;color:var(--muted)">Key light (main)</div>
<div class="row"><label>Intensity</label><input type="range" id="ltKey" min="0" max="4" step="0.05" value="1.6"><span class="num" id="ltKeyV">1.60</span><input type="color" id="ltKeyCol" value="#ffffff" style="width:28px" title="key colour"></div>
<div class="row"><label>Direction</label><input type="range" id="ltKeyAz" min="0" max="360" step="5" value="45" title="azimuth (spin around)"><input type="range" id="ltKeyEl" min="-90" max="90" step="5" value="35" title="elevation (up/down)"></div>
<div class="lbl2" style="margin-top:7px;font-size:11px;color:var(--muted)">Fill + back lights</div>
<div class="row"><label>Fill</label><input type="range" id="ltFill" min="0" max="3" step="0.05" value="0.5"><span class="num" id="ltFillV">0.50</span><input type="color" id="ltFillCol" value="#99bbff" style="width:28px" title="fill colour"></div>
<div class="row"><label>Back / rim</label><input type="range" id="ltBack" min="0" max="3" step="0.05" value="0"><span class="num" id="ltBackV">0.00</span><input type="color" id="ltBackCol" value="#ffffff" style="width:28px" title="back-light colour"></div>
<div class="lbl2" style="margin-top:7px;font-size:11px;color:var(--muted)">Surface finish (diffuse ↔ specular)</div>
<div class="row"><label class="tog"><input type="checkbox" id="ltFinish"> custom finish (override style)</label></div>
<div class="row"><label title="0 = shiny/specular, 1 = matte/diffuse">Roughness</label><input type="range" id="ltRough" min="0" max="1" step="0.02" value="0.35"><span class="num" id="ltRoughV">0.35</span></div>
<div class="row"><label title="0 = plastic/dielectric, 1 = metal">Metalness</label><input type="range" id="ltMetal" min="0" max="1" step="0.02" value="0"><span class="num" id="ltMetalV">0.00</span></div>
</div>
<div class="grp"><div class="lbl">✨ Glow / aura</div>
<div class="row"><label class="tog"><input type="checkbox" id="auraOn"> aura glow</label>
<input type="color" id="auraColor" value="#5db0ff" style="width:34px;margin-left:6px" title="aura colour">
<label class="tog" style="margin-left:6px" title="cycle the aura colour"><input type="checkbox" id="auraRainbow"> 🌈</label></div>
<div class="row"><label>Reach</label><input type="range" id="auraSize" min="1.1" max="3" step="0.05" value="1.6"><span class="num" id="auraSizeV">1.60</span></div>
<div class="row"><label>Intensity</label><input type="range" id="auraIntensity" min="0" max="2" step="0.05" value="0.9"><span class="num" id="auraIntensityV">0.90</span></div>
<div class="row"><label>Pulse</label><input type="range" id="auraPulse" min="0" max="4" step="0.1" value="0"><span class="num" id="auraPulseV">0.0</span></div>
</div>
<div class="grp"><div class="lbl">✏ Edges / depth</div>
<div class="row"><label class="tog"><input type="checkbox" id="outlineOn"> ink outline</label> <label class="tog" style="margin-left:10px"><input type="checkbox" id="depthCue"> depth cue</label></div>
<div class="row"><label>Outline width</label><input type="range" id="outlineW" min="0.5" max="5" step="0.5" value="2"><span class="num" id="outlineWV">2.0</span></div>
</div>
<div class="grp"><div class="lbl">🎬 Cinematic FX</div>
<div class="row"><label class="tog"><input type="checkbox" id="fxOn"> ambient occlusion + bloom (SSAO·SMAA)</label></div>
<div class="row"><label>Bloom</label><input type="range" id="fxBloom" min="0" max="1.5" step="0.05" value="0.5"><span class="num" id="fxBloomV">0.50</span></div>
<div class="row"><label title="only pixels brighter than this glow — lower it to bloom coloured atoms too, not just white">Glow threshold</label><input type="range" id="fxThresh" min="0" max="1" step="0.05" value="0.85"><span class="num" id="fxThreshV">0.85</span></div>
<div style="font-size:10px;color:var(--muted)">Softer contact shadows in crevices + glow. Heavier — toggle off on very large structures.</div>
</div>
<div class="grp"><div class="lbl">⚛ Atomic view (Bohr model)</div>
<div class="row"><label>Spread</label><input type="range" id="atSpread" min="1.5" max="8" step="0.5" value="4"><span class="num" id="atSpreadV">4.0</span></div>
<div class="row"><label>Nucleus size</label><input type="range" id="atNuc" min="0.4" max="2" step="0.1" value="1"><span class="num" id="atNucV">1.0</span></div>
<div class="row"><label>e⁻ speed</label><input type="range" id="atESpeed" min="0" max="3" step="0.1" value="1"><span class="num" id="atESpeedV">1.0</span></div>
<div class="row"><label class="tog"><input type="checkbox" id="atShells" checked> show shell rings</label> <label class="tog" style="margin-left:8px"><input type="checkbox" id="atLobes" checked> lone-pair lobes</label></div>
<div style="font-size:10px;color:var(--muted);line-height:1.45">Protons red · neutrons grey · electrons cyan. Bonding pairs orbit the bond; lone pairs sit on the atom. Best for small molecules (water, methane…).</div>
</div>
<div class="grp"><div class="lbl">✂ Clip / section cut</div>
<div class="row"><label class="tog"><input type="checkbox" id="clipOn"> enable cut</label></div>
<div class="seg" id="clipAxis" style="margin-top:5px"><button data-cax="x" class="on">X</button><button data-cax="y">Y</button><button data-cax="z">Z</button></div>
<div class="row" style="margin-top:7px"><label>Position</label><input type="range" id="clipPos" min="-1" max="1" step="0.02" value="0"><span class="num" id="clipPosV">0.00</span></div>
<div class="row"><label class="tog"><input type="checkbox" id="clipFlip"> flip side</label></div>
</div>
<div class="grp"><div class="lbl">Colour by</div>
<div class="seg" id="colSeg"><button data-col="element" class="on">Element</button><button data-col="chain">Chain</button><button data-col="residue">Residue</button><button data-col="rainbow">Rainbow</button><button data-col="bfactor" title="B-factor, or pLDDT confidence for AlphaFold models">B / pLDDT</button><button data-col="data" title="Colour by values from a CSV you load (per residue or per atom)">📊 Data</button></div>
<div class="row" style="margin-top:6px"><button id="dataCsvBtn" style="flex:1" title="CSV/TSV columns: chain,res,value · or res,value · or serial,value (per atom). Header row optional.">📊 Colour by CSV values…</button>
<input type="file" id="dataCsv" accept=".csv,.tsv,.txt" style="display:none"></div>
<div id="dataOut" style="font-size:11px;color:var(--muted);min-height:13px"></div>
<div class="row" style="margin-top:6px"><label class="tog"><input type="checkbox" id="legendOn"> show colour legend</label></div>
</div>
<div class="grp"><div class="lbl">Surface</div>
<div class="row"><label>Detail</label><input type="range" id="surfQual" min="0.35" max="1.6" step="0.05" value="0.75"><span class="num" id="surfQualV">0.75</span></div>
<div class="row"><label title="Laplacian relaxation passes — higher = smoother, less blocky">Smoothing</label><input type="range" id="surfSmooth" min="0" max="8" step="1" value="3"><span class="num" id="surfSmoothV">3</span></div>
<div class="row"><label>Inflate</label><input type="range" id="surfProbe" min="0" max="1.6" step="0.1" value="0.4"><span class="num" id="surfProbeV">0.4</span></div>
<div class="row"><label>Opacity</label><input type="range" id="surfOpac" min="0.15" max="1" step="0.05" value="1"><span class="num" id="surfOpacV">1.0</span></div>
<div class="row"><label class="tog"><input type="checkbox" id="surfLig" checked> show ligands</label></div>
</div>
<div class="grp"><div class="lbl">🧬 DNA appearance</div>
<div class="row"><label>Helix R</label><input type="range" id="dnaRadius" min="5" max="16" step="0.5" value="9.5"><span class="num" id="dnaRadiusV">9.5</span></div>
<div class="row"><label>Rise</label><input type="range" id="dnaRise" min="2" max="6" step="0.1" value="3.4"><span class="num" id="dnaRiseV">3.4</span></div>
<div class="row"><label>Twist°</label><input type="range" id="dnaTwist" min="20" max="60" step="0.5" value="34.3"><span class="num" id="dnaTwistV">34.3</span></div>
<div class="row"><label>Backbone</label><input type="range" id="dnaBB" min="0.4" max="3" step="0.1" value="1.4"><span class="num" id="dnaBBV">1.4</span></div>
<div class="row"><label>Base ball</label><input type="range" id="dnaBase" min="0.4" max="3" step="0.1" value="1.2"><span class="num" id="dnaBaseV">1.2</span></div>
<div class="row"><label>Rung</label><input type="range" id="dnaRung" min="0.1" max="1.2" step="0.05" value="0.4"><span class="num" id="dnaRungV">0.4</span></div>
<div class="cgrid" style="margin-top:4px">
<div class="c">A<input type="color" id="colA" value="#ff4d4d"></div>
<div class="c">T<input type="color" id="colT" value="#4d7bff"></div>
<div class="c">G<input type="color" id="colG" value="#4dff4d"></div>
<div class="c">C<input type="color" id="colC" value="#ffd24d"></div>
<div class="c">back<input type="color" id="colBB" value="#c8a05a"></div>
</div>
</div>
<div class="grp"><div class="lbl">Scene</div>
<div class="row"><label>Background</label><input type="color" id="bg" value="#0a0e16"></div>
<div class="row"><label class="tog"><input type="checkbox" id="gradBg"> gradient backdrop</label></div>
<div class="row"><label class="tog"><input type="checkbox" id="vignetteOn"> vignette</label> <label class="tog" style="margin-left:10px"><input type="checkbox" id="shadowsOn"> shadows</label></div>
</div>
</div>
<script>
(function(){ var served=location.protocol.indexOf('http')===0;
var three = served ? './vendor/three.module.js' : 'https://unpkg.com/three@0.160.0/build/three.module.js';
var addons = served ? './vendor/jsm/' : 'https://unpkg.com/three@0.160.0/examples/jsm/';
var im=document.createElement('script'); im.type='importmap';
im.textContent=JSON.stringify({imports:{'three':three,'three/addons/':addons}}); document.head.appendChild(im);
document.write('<scr'+'ipt src="'+(served?'./vendor/jspdf.umd.min.js':'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js')+'"><\/scr'+'ipt>'); // for figure PDF export
})();
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
// Cinematic post-FX (SSAO/bloom/SMAA) are loaded LAZILY on first enable — see ensureFX(). They must NOT be static imports:
// vendor/jsm/postprocessing/ isn't vendored, so a static import 404s on the served site and kills the whole module (blank canvas).
const $ = id=>document.getElementById(id);
// ---------- public reference DATA (not code): CPK colours + covalent + vdW radii (Å) ----------
const CPK = { H:'#ffffff',C:'#3a3a3a',N:'#3050f8',O:'#ff2010',S:'#e6c000',P:'#ff8000',
F:'#33cc33',CL:'#1fd01f',BR:'#a52a2a',I:'#940094',B:'#ffb5b5',SI:'#f0c8a0',
FE:'#e06633',ZN:'#7d80b0',NA:'#ab5cf2',K:'#8f40d4',CA:'#3dff00',MG:'#8aff00',
MN:'#9c7ac7',CU:'#c88033',SE:'#ffa100',default:'#ff69b4' };
const COV = { H:0.31,C:0.76,N:0.71,O:0.66,S:1.05,P:1.07,F:0.57,CL:1.02,BR:1.20,I:1.39,
B:0.84,SI:1.11,FE:1.32,ZN:1.22,NA:1.66,K:2.03,CA:1.76,MG:1.41,MN:1.39,CU:1.32,SE:1.20,default:0.9 };
const VDW = { H:1.10,C:1.70,N:1.55,O:1.52,S:1.80,P:1.80,F:1.47,CL:1.75,BR:1.85,I:1.98,
B:1.92,SI:2.10,FE:2.0,ZN:2.1,NA:2.27,K:2.75,CA:2.31,MG:1.73,MN:2.0,CU:2.0,SE:1.90,default:1.7 };
const elCol=e=>CPK[e]||CPK.default, elCov=e=>COV[e]||COV.default, elVdw=e=>VDW[e]||VDW.default;
const PAL=['#ff3b30','#ffcc00','#34c759','#00c7be','#5db0ff','#bf5af2','#ff77d0','#ff9f0a','#a2d729','#64d2ff','#ff6482','#c0a3ff'];
const RESCOL={ALA:'#8cff8c',GLY:'#dddddd',SER:'#ffb570',THR:'#ffb570',CYS:'#ffe000',VAL:'#8cff8c',LEU:'#8cff8c',ILE:'#8cff8c',MET:'#ffe000',PRO:'#8cff8c',PHE:'#7ca0ff',TYR:'#7ca0ff',TRP:'#7ca0ff',ASP:'#ff5050',GLU:'#ff5050',ASN:'#00d0d0',GLN:'#00d0d0',HIS:'#7cc0ff',LYS:'#3050ff',ARG:'#3050ff',default:'#c0c0c0'};
// element facts (public reference data): [name, Z, atomic mass u, Pauling electronegativity, typical valence]
const ELEM={ H:['Hydrogen',1,1.008,2.20,1], C:['Carbon',6,12.011,2.55,4], N:['Nitrogen',7,14.007,3.04,3], O:['Oxygen',8,15.999,3.44,2],
S:['Sulfur',16,32.06,2.58,2], P:['Phosphorus',15,30.974,2.19,5], F:['Fluorine',9,18.998,3.98,1], CL:['Chlorine',17,35.45,3.16,1],
BR:['Bromine',35,79.904,2.96,1], I:['Iodine',53,126.90,2.66,1], B:['Boron',5,10.81,2.04,3], SI:['Silicon',14,28.085,1.90,4],
FE:['Iron',26,55.845,1.83,3], ZN:['Zinc',30,65.38,1.65,2], NA:['Sodium',11,22.990,0.93,1], K:['Potassium',19,39.098,0.82,1],
CA:['Calcium',20,40.078,1.00,2], MG:['Magnesium',12,24.305,1.31,2], MN:['Manganese',25,54.938,1.55,2], CU:['Copper',29,63.546,1.90,2],
SE:['Selenium',34,78.971,2.55,2] };
// solvent / buffer / ion residues to hide by default (crystallographic waters + cryoprotectants + loose ions)
const SOLVENT=new Set(['HOH','WAT','DOD','H2O','NA','CL','K','MG','CA','ZN','MN','FE','CU','NI','CO','CD','SO4','PO4','GOL','EDO','ACT','BME','DMS','FMT','IOD','BR','CS','RB','SR','BA','LI','PEG','MPD','TRS','EPE']);
const isSolvent=a=>a.het && SOLVENT.has((a.resName||'').toUpperCase());
// ---------- three.js ----------
const app=$('app');
const renderer=new THREE.WebGLRenderer({antialias:true, preserveDrawingBuffer:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2)); renderer.localClippingEnabled=true; app.appendChild(renderer.domElement);
const VERSION='v3.20'; $('verPill').textContent=VERSION; // bump on every iteration
const scene=new THREE.Scene(); scene.background=new THREE.Color(0x0a0e16);
const _pmrem=new THREE.PMREMGenerator(renderer); scene.environment=_pmrem.fromScene(new RoomEnvironment(),0.04).texture; // studio env → real reflections on metal/glass/glossy
renderer.toneMapping=THREE.ACESFilmicToneMapping; renderer.toneMappingExposure=1.05;
const camera=new THREE.PerspectiveCamera(45, innerWidth/innerHeight, 0.1, 5000); camera.position.set(0,0,60);
const controls=new OrbitControls(camera, renderer.domElement); controls.enableDamping=true; controls.enableZoom=false; // wheel/trackpad routed through the zoom slider instead (see below)
// Always orbit about the (visible) centre of mass — even after panning/moving the model, so it never spins about a distant point.
function molWorldCOM(){ const A=MOL.atoms; if(!A.length) return new THREE.Vector3(); let x=0,y=0,z=0,n=0;
for(const a of A){ if(a.hidden)continue; x+=a.x;y+=a.y;z+=a.z;n++; } if(!n){ for(const a of A){x+=a.x;y+=a.y;z+=a.z;} n=A.length; }
return molGroup.localToWorld(new THREE.Vector3(x/n,y/n,z/n)); }
controls.addEventListener('start',()=>{ if(_lastBtn===0 && dragAtom<0 && !builderOn && !measMode) controls.target.copy(molWorldCOM()); }); // left-drag = rotate → recentre pivot on COM
const hemi=new THREE.HemisphereLight(0xffffff,0x33404f,1.0); scene.add(hemi);
const key=new THREE.DirectionalLight(0xffffff,1.6); key.position.set(1,1,1); scene.add(key);
const fill=new THREE.DirectionalLight(0x99bbff,0.5); fill.position.set(-1,-0.5,-1); scene.add(fill);
const back=new THREE.DirectionalLight(0xffffff,0.0); back.position.set(-0.5,0.7,-1); scene.add(back); // rim/back light (driven by the Lighting panel)
const molGroup=new THREE.Group(); scene.add(molGroup);
// ---------- Unity-style move gizmo (X/Y/Z arrows) for the selected atom in build mode ----------
const gizmo=new TransformControls(camera, renderer.domElement); gizmo.setSize(0.8); gizmo.visible=false; scene.add(gizmo);
const gizmoProxy=new THREE.Object3D(); scene.add(gizmoProxy);
gizmo.addEventListener('dragging-changed',e=>{ controls.enableRotate=!e.value; });
gizmo.addEventListener('mouseDown',()=>{ if(typeof builderSel!=='undefined'&&builderSel>=0) pushUndo(); });
gizmo.addEventListener('objectChange',()=>{ if(builderSel<0||!MOL.atoms[builderSel])return; const l=molGroup.worldToLocal(gizmoProxy.position.clone()); const a=MOL.atoms[builderSel]; a.x=l.x;a.y=l.y;a.z=l.z; delete a.r; rebuild(); if(buildMarker)buildMarker.position.set(a.x,a.y,a.z); });
function updateGizmo(){ if(builderOn && builderSel>=0 && MOL.atoms[builderSel]){ const a=MOL.atoms[builderSel]; molGroup.updateMatrixWorld(); gizmoProxy.position.copy(molGroup.localToWorld(new THREE.Vector3(a.x,a.y,a.z))); gizmo.attach(gizmoProxy); gizmo.visible=true; } else { gizmo.detach(); gizmo.visible=false; } }
// ---------- optional cinematic post-processing (SSAO + bloom + SMAA), loaded lazily on first enable ----------
let composer=null, ssaoPass=null, bloomPass=null, _fxMods=null, _fxLoading=false;
// Load the postprocessing passes from the CDN on demand. Explicit CDN URLs (not the import map) so relative deps (./Pass.js,
// ../shaders/…) resolve from the CDN too, while their bare `three` import still maps to the app's three — works served & file://.
async function ensureFX(){ if(_fxMods) return _fxMods; const B='https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/';
const m=await Promise.all([ import(B+'EffectComposer.js'), import(B+'RenderPass.js'), import(B+'SSAOPass.js'), import(B+'UnrealBloomPass.js'), import(B+'SMAAPass.js'), import(B+'OutputPass.js') ]);
_fxMods={ EffectComposer:m[0].EffectComposer, RenderPass:m[1].RenderPass, SSAOPass:m[2].SSAOPass, UnrealBloomPass:m[3].UnrealBloomPass, SMAAPass:m[4].SMAAPass, OutputPass:m[5].OutputPass }; return _fxMods; }
function buildComposer(){ if(composer||!_fxMods) return; const F=_fxMods; composer=new F.EffectComposer(renderer);
composer.addPass(new F.RenderPass(scene,camera));
ssaoPass=new F.SSAOPass(scene,camera,innerWidth,innerHeight); ssaoPass.kernelRadius=6; ssaoPass.minDistance=0.001; ssaoPass.maxDistance=0.08; composer.addPass(ssaoPass);
bloomPass=new F.UnrealBloomPass(new THREE.Vector2(innerWidth,innerHeight),0.5,0.5,0.85); composer.addPass(bloomPass);
composer.addPass(new F.SMAAPass(innerWidth,innerHeight));
composer.addPass(new F.OutputPass()); }
const fxOn=()=>$('fxOn')&&$('fxOn').checked;
function renderFrame(w,h){
if(fxOn()){ if(!_fxMods){ if(!_fxLoading){ _fxLoading=true; ensureFX().then(()=>{buildComposer();}).catch(e=>{ if($('fxOn'))$('fxOn').checked=false; if($('status'))$('status').textContent='cinematic FX need internet (CDN) — disabled'; }); } renderer.render(scene,camera); return; } // still loading → plain render this frame
buildComposer(); if(!composer){ renderer.render(scene,camera); return; }
if(bloomPass){ bloomPass.strength=+($('fxBloom')?.value ?? 0.5); bloomPass.threshold=+($('fxThresh')?.value ?? 0.85); } if(w){composer.setSize(w,h);} composer.render(); if(w){composer.setSize(innerWidth,innerHeight);} }
else renderer.render(scene,camera); }
function resize(){ renderer.setSize(innerWidth,innerHeight); camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix(); if(composer) composer.setSize(innerWidth,innerHeight); }
addEventListener('resize',resize); resize();
const spinAx={x:false,y:false,z:false}; let rockPhase=0, electronT=0, auraHue=0.58;
function loop(){ requestAnimationFrame(loop); controls.update();
// adaptive near/far so the molecule never clips out on zoom in/out (proportional near, far past the model)
const dist=camera.position.distanceTo(controls.target), R=(molR||30)*4+50;
const nf=Math.max(0.02, dist*0.02), ff=dist+R; if(camera.near!==nf||camera.far!==ff){ camera.near=nf; camera.far=ff; camera.updateProjectionMatrix(); }
if($('depthCue')&&$('depthCue').checked){ const mr=(molR||30); if(!scene.fog)scene.fog=new THREE.Fog(0,1,1); scene.fog.color.copy(bgColor); scene.fog.near=Math.max(0.1,dist-mr*0.9); scene.fog.far=dist+mr*1.2; } else if(scene.fog){ scene.fog=null; }
const s=+$('spinSpeed').value*0.01;
if($('rockMode').checked){ rockPhase+=s; const off=Math.sin(rockPhase)*0.6; // gentle oscillation on active axes
if(spinAx.x)molGroup.rotation.x=off; if(spinAx.y)molGroup.rotation.y=off; if(spinAx.z)molGroup.rotation.z=off; }
else { if(spinAx.x)molGroup.rotation.x+=s; if(spinAx.y)molGroup.rotation.y+=s; if(spinAx.z)molGroup.rotation.z+=s; }
if(electronOrbits.length){ electronT+=0.016; for(const o of electronOrbits){ const c=o.c,u=o.u,v=o.v,r=o.r;
for(const e of o.es){ const ca=Math.cos(e.ph+electronT*e.sp)*r, sa=Math.sin(e.ph+electronT*e.sp)*r;
e.mesh.position.set(c.x+u.x*ca+v.x*sa, c.y+u.y*ca+v.y*sa, c.z+u.z*ca+v.z*sa); } } }
if(auraGroup){ const pul=+$('auraPulse').value, base=+$('auraIntensity').value;
const mul = pul>0 ? (0.55+0.45*Math.sin(performance.now()*0.001*pul)) : 1;
if($('auraRainbow').checked){ auraHue=(auraHue+0.004)%1; const c=new THREE.Color().setHSL(auraHue,0.8,0.6);
auraGroup.children.forEach(m=>{ const u=m.material.uniforms; if(u&&u.uColor)u.uColor.value.copy(c); else if(m.material.color)m.material.color.copy(c); }); }
auraGroup.children.forEach(m=>{ const u=m.material.uniforms, lvl=(m.userData.aBase!=null?m.userData.aBase:0.09)*base*mul; // LIVE intensity × mesh base × pulse
if(u&&u.uStr) u.uStr.value=lvl; else m.material.opacity=lvl; }); }
renderFrame(); }
requestAnimationFrame(loop); // deferred so module-scoped state (electronOrbits…) is initialized first
let MOL={atoms:[],bonds:[]}, sphereMesh=null, bondMesh=null, cartoonGroup=null, surfMesh=null, atomicGroup=null, electronOrbits=[], auraGroup=null, voronoiGroup=null, hbondGroup=null, outlineGroup=null, labelsGroup=null, pickList=[], isDNA=false, molR=30;
const resSel=new Set(); // sequence-viewer residue highlights (chain|resSeq)
const AA1={ALA:'A',ARG:'R',ASN:'N',ASP:'D',CYS:'C',GLN:'Q',GLU:'E',GLY:'G',HIS:'H',ILE:'I',LEU:'L',LYS:'K',MET:'M',PHE:'F',PRO:'P',SER:'S',THR:'T',TRP:'W',TYR:'Y',VAL:'V'};
let curRep='bas', curCol='element', curClipAxis='x', molProject=null;
// ---------- parsers ----------
function parsePDB(text){ const atoms=[], sMap=new Map(), conect=[], helix=new Set(), sheet=new Set(); let modelDone=false;
const range=(ch,a,b,set)=>{ if(!isFinite(a)||!isFinite(b)) return; for(let s=a;s<=b;s++) set.add(ch+':'+s); };
for(const line of text.split(/\r?\n/)){ const rec=line.slice(0,6);
if(rec==='ENDMDL'){ modelDone=true; continue; } // NMR/multi-model: keep only the first model
if(modelDone && (rec==='ATOM '||rec==='HETATM')) continue;
if(rec==='ATOM '||rec==='HETATM'){ let el=line.slice(76,78).trim().toUpperCase(); const name=line.slice(12,16).trim();
if(!el){ el=name.replace(/[^A-Za-z]/g,'').slice(0,2).toUpperCase(); if(!COV[el]) el=el[0]; }
const x=+line.slice(30,38),y=+line.slice(38,46),z=+line.slice(46,54); if(!isFinite(x)||!isFinite(y)||!isFinite(z)) continue;
const serial=+line.slice(6,11); sMap.set(serial,atoms.length);
atoms.push({el,x,y,z,name,resName:line.slice(17,20).trim(),resSeq:line.slice(22,26).trim(),chain:line.slice(21,22).trim()||'A',serial,het:rec==='HETATM',b:+line.slice(60,66)||0});
} else if(rec==='CONECT'){ const a=+line.slice(6,11); for(let c=11;c<31;c+=5){ const b=+line.slice(c,c+5); if(b) conect.push([a,b]); } }
else if(rec.slice(0,5)==='HELIX'){ range(line.slice(19,20).trim()||'A', +line.slice(21,25), +line.slice(33,37), helix); }
else if(rec.slice(0,5)==='SHEET'){ range(line.slice(21,22).trim()||'A', +line.slice(22,26), +line.slice(33,37), sheet); } }
const set=new Set(), bonds=[]; for(const [a,b] of conect){ const i=sMap.get(a),j=sMap.get(b); if(i==null||j==null) continue; const k=i<j?i+'_'+j:j+'_'+i; if(!set.has(k)){set.add(k);bonds.push([i,j]);} }
return {atoms,bonds,_hasConect:conect.length>0, ss:{helix,sheet}}; }
function parseXYZ(text){ const L=text.split(/\r?\n/), n=+L[0], atoms=[];
for(let i=0;i<n;i++){ const p=(L[2+i]||'').trim().split(/\s+/); if(p.length<4) continue; atoms.push({el:p[0].toUpperCase(),x:+p[1],y:+p[2],z:+p[3],name:p[0],resName:'MOL',resSeq:'1',chain:'A',serial:i+1}); } return {atoms,bonds:[]}; }
function parseSDF(text){ const L=text.split(/\r?\n/), c=L[3]||'', na=+c.slice(0,3), nb=+c.slice(3,6), atoms=[],bonds=[];
for(let i=0;i<na;i++){ const l=L[4+i]; atoms.push({el:l.slice(31,34).trim().toUpperCase(),x:+l.slice(0,10),y:+l.slice(10,20),z:+l.slice(20,30),name:l.slice(31,34).trim(),resName:'LIG',resSeq:'1',chain:'A',serial:i+1}); }
for(let i=0;i<nb;i++){ const l=L[4+na+i]; bonds.push([(+l.slice(0,3))-1,(+l.slice(3,6))-1]); } return {atoms,bonds}; }
// Tripos MOL2 (SYBYL) — common export from docking/RDKit/OpenBabel; has explicit bonds
function parseMol2(text){ const L=text.split(/\r?\n/), atoms=[], bonds=[]; let sec='';
for(const raw of L){ const ln=raw.trim(); if(ln.startsWith('@<TRIPOS>')){ sec=ln.slice(9); continue; }
if(sec==='ATOM'){ const p=ln.split(/\s+/); if(p.length<6) continue; let el=p[5].split('.')[0].toUpperCase(); if(!COV[el]) el=el.replace(/[^A-Z]/g,'').slice(0,2)||'C';
atoms.push({el, x:+p[2],y:+p[3],z:+p[4], name:p[1], resName:(p[7]||'MOL').slice(0,3), resSeq:p[6]||'1', chain:'A', serial:atoms.length+1, het:false}); }
else if(sec==='BOND'){ const p=ln.split(/\s+/); if(p.length<4) continue; const a=(+p[1])-1, b=(+p[2])-1; if(a>=0&&b>=0) bonds.push([a,b]); } }
return {atoms, bonds}; }
// GROMACS .gro — fixed columns, coordinates in nm (×10 → Å)
function parseGro(text){ const L=text.split(/\r?\n/), n=+((L[1]||'').trim()), atoms=[];
for(let i=0;i<n && 2+i<L.length;i++){ const l=L[2+i]; const name=l.slice(10,15).trim(), x=+l.slice(20,28)*10, y=+l.slice(28,36)*10, z=+l.slice(36,44)*10; if(!isFinite(x)) continue;
let el=name.replace(/[0-9]/g,'').slice(0,2).toUpperCase(); if(!COV[el]) el=el[0]||'C';
atoms.push({el,x,y,z,name,resName:l.slice(5,10).trim(),resSeq:l.slice(0,5).trim(),chain:'A',serial:atoms.length+1,het:false}); }
return {atoms,bonds:[]}; }
// minimal mmCIF: read the _atom_site loop (+ _struct_conf helices, _struct_sheet_range sheets)
function parseCIF(text){ const L=text.split(/\r?\n/), atoms=[], helix=new Set(), sheet=new Set();
const addRange=(ch,a,b,set)=>{ if(!isFinite(a)||!isFinite(b))return; for(let s=a;s<=b;s++) set.add(ch+':'+s); };
for(let i=0;i<L.length;i++){ if(L[i].trim()!=='loop_') continue;
const tags=[]; let j=i+1; while(j<L.length && L[j].trim().startsWith('_')){ tags.push(L[j].trim().split(/\s+/)[0]); j++; }
const pre = tags[0]||'';
const rows=[]; for(; j<L.length; j++){ const t=L[j].trim(); if(t===''||t==='#'||t==='loop_'||t.startsWith('_')) break; if(t.startsWith(';')) continue; rows.push(t); }
if(pre.startsWith('_atom_site.')){ const idx={}; tags.forEach((t,k)=>idx[t.replace('_atom_site.','')]=k);
for(const row of rows){ const f=row.match(/"[^"]*"|'[^']*'|\S+/g); if(!f||f.length<tags.length) continue; const g=k=>{ const v=f[idx[k]]; return v==null?'':v.replace(/^['"]|['"]$/g,''); };
const x=+g('Cartn_x'),y=+g('Cartn_y'),z=+g('Cartn_z'); if(!isFinite(x)||!isFinite(y)||!isFinite(z)) continue;
let el=g('type_symbol').toUpperCase(); const name=g('label_atom_id'); if(!el) el=name.replace(/[^A-Za-z]/g,'').slice(0,2).toUpperCase();
atoms.push({el,x,y,z,name,resName:g('label_comp_id'),resSeq:g('auth_seq_id')||g('label_seq_id')||'1',chain:g('auth_asym_id')||g('label_asym_id')||'A',serial:atoms.length+1,het:g('group_PDB')==='HETATM',b:+g('B_iso_or_equiv')||0}); }
i=j-1; }
else if(pre.startsWith('_struct_conf.')){ const idx={}; tags.forEach((t,k)=>idx[t.replace('_struct_conf.','')]=k);
for(const row of rows){ const f=row.match(/"[^"]*"|'[^']*'|\S+/g); if(!f)continue; addRange(f[idx.beg_auth_asym_id]||'A', +f[idx.beg_auth_seq_id], +f[idx.end_auth_seq_id], helix); } i=j-1; }
else if(pre.startsWith('_struct_sheet_range.')){ const idx={}; tags.forEach((t,k)=>idx[t.replace('_struct_sheet_range.','')]=k);
for(const row of rows){ const f=row.match(/"[^"]*"|'[^']*'|\S+/g); if(!f)continue; addRange(f[idx.beg_auth_asym_id]||'A', +f[idx.beg_auth_seq_id], +f[idx.end_auth_seq_id], sheet); } i=j-1; } }
return {atoms, bonds:[], ss:{helix,sheet}}; }
function inferBonds(atoms){ const bonds=[], n=atoms.length, cell=3.2, grid=new Map(), key=(a,b,c)=>a+','+b+','+c;
for(let i=0;i<n;i++){ const a=atoms[i], gx=Math.floor(a.x/cell),gy=Math.floor(a.y/cell),gz=Math.floor(a.z/cell), k=key(gx,gy,gz); (grid.get(k)||grid.set(k,[]).get(k)).push(i); }
for(let i=0;i<n;i++){ const a=atoms[i], gx=Math.floor(a.x/cell),gy=Math.floor(a.y/cell),gz=Math.floor(a.z/cell);
for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++){ const arr=grid.get(key(gx+dx,gy+dy,gz+dz)); if(!arr) continue;
for(const j of arr){ if(j<=i) continue; const b=atoms[j], dd=(a.x-b.x)**2+(a.y-b.y)**2+(a.z-b.z)**2, max=(elCov(a.el)+elCov(b.el))*1.3; if(dd>0.16 && dd<max*max){ if(a.el==='H'&&b.el==='H') continue; bonds.push([i,j]); } } } }
return bonds; }
// ---------- procedural matcap library (clean-room: our own baked studio environment, no external assets) ----------
const _hex2rgb=h=>{ h=h.replace('#',''); return [parseInt(h.slice(0,2),16)/255,parseInt(h.slice(2,4),16)/255,parseInt(h.slice(4,6),16)/255]; };
const _sm=t=>{ t=t<0?0:t>1?1:t; return t*t*(3-2*t); };
function _env(dx,dy,dz){ const t=_sm(dy*0.5+0.5); let r=0.16+0.82*t,g=0.18+0.84*t,b=0.22+0.86*t;
const hb=Math.exp(-((dy)/0.10)*((dy)/0.10))*0.85; r+=hb;g+=hb;b+=hb;
const kx=-0.45,ky=0.66,kz=0.6,kl=Math.hypot(kx,ky,kz),kd=Math.max(0,(dx*kx+dy*ky+dz*kz)/kl); const key=Math.pow(kd,120)*3.2; r+=key;g+=key;b+=key;
const fx=0.72,fy=0.08,fz=0.45,fl=Math.hypot(fx,fy,fz),fd=Math.max(0,(dx*fx+dy*fy+dz*fz)/fl); const fill=Math.pow(fd,26)*1.1; r+=fill*0.8;g+=fill*0.88;b+=fill*1.05;
const sx=0.1,sy=0.9,sz=0.2,sl=Math.hypot(sx,sy,sz),sd=Math.max(0,(dx*sx+dy*sy+dz*sz)/sl); const top=Math.pow(sd,40)*1.4; r+=top;g+=top;b+=top;
const bd=Math.max(0,-dz),back=Math.pow(bd,8)*0.22; r+=back;g+=back;b+=back; return [r,g,b]; }
function makeMatcapTex(o){ const N=256,c=document.createElement('canvas'); c.width=c.height=N; const x=c.getContext('2d'),img=x.createImageData(N,N),d=img.data;
const base=_hex2rgb(o.base),tint=o.tint?_hex2rgb(o.tint):base,type=o.type||'matte',refl=o.refl??0.0,gain=o.gain??1;
for(let py=0;py<N;py++)for(let px=0;px<N;px++){ const nx=(px-128)/128,ny=-(py-128)/128,r2=nx*nx+ny*ny,i=(py*N+px)*4;
if(r2>1){ d[i+3]=0; continue; } const nz=Math.sqrt(1-r2),ndv=nz,fres=Math.pow(1-ndv,3);
const rx=2*nz*nx,ry=2*nz*ny,rz=2*nz*nz-1,e=_env(rx,ry,rz);
const kx=-0.42,ky=0.6,kz=0.68,kl=Math.hypot(kx,ky,kz),ndl=Math.max(0,(nx*kx+ny*ky+nz*kz)/kl); let rr,gg,bb;
if(type==='metal'){ rr=base[0]*0.10+e[0]*tint[0]*refl; gg=base[1]*0.10+e[1]*tint[1]*refl; bb=base[2]*0.10+e[2]*tint[2]*refl; }
else if(type==='chrome'){ rr=e[0]*(0.85+0.15*base[0]); gg=e[1]*(0.85+0.15*base[1]); bb=e[2]*(0.85+0.15*base[2]); }
else if(type==='glass'){ const f=0.15+0.85*fres; rr=e[0]*0.4*f+base[0]*0.18+fres*1.1; gg=e[1]*0.4*f+base[1]*0.2+fres*1.15; bb=e[2]*0.45*f+base[2]*0.26+fres*1.25; }
else if(type==='gem'){ const body=0.35+0.65*ndl; rr=base[0]*body+e[0]*refl*0.5+base[0]*fres*0.7+Math.pow(Math.max(0,rz),60)*1.2; gg=base[1]*body+e[1]*refl*0.5+base[1]*fres*0.7+Math.pow(Math.max(0,rz),60)*1.2; bb=base[2]*body+e[2]*refl*0.5+base[2]*fres*0.7+Math.pow(Math.max(0,rz),60)*1.2; }
else if(type==='pearl'){ const ph=fres*2.0+ndl,ir=[0.5+0.5*Math.cos(6.28*ph),0.5+0.5*Math.cos(6.28*ph+2.1),0.5+0.5*Math.cos(6.28*ph+4.2)],body=0.55+0.45*ndl,sp=Math.pow(Math.max(0,rz),70)*0.6; rr=base[0]*body+ir[0]*0.32*(0.4+fres)+sp; gg=base[1]*body+ir[1]*0.32*(0.4+fres)+sp; bb=base[2]*body+ir[2]*0.32*(0.4+fres)+sp; }
else { const amb=o.amb??0.4,sp=Math.pow(Math.max(0,rz),o.sExp??30)*(o.sInt??0.25),envamb=0.12*refl;
rr=base[0]*(amb+(1-amb)*ndl)+e[0]*envamb*base[0]+sp; gg=base[1]*(amb+(1-amb)*ndl)+e[1]*envamb*base[1]+sp; bb=base[2]*(amb+(1-amb)*ndl)+e[2]*envamb*base[2]+sp;
if(o.sss){ rr+=tint[0]*fres*o.sss; gg+=tint[1]*fres*o.sss; bb+=tint[2]*fres*o.sss; } }
d[i]=Math.min(255,rr*gain*255); d[i+1]=Math.min(255,gg*gain*255); d[i+2]=Math.min(255,bb*gain*255); d[i+3]=255; }
x.putImageData(img,0,0); const t=new THREE.CanvasTexture(c); t.colorSpace=THREE.SRGBColorSpace; return t; }
const MATCAP_PRESETS={ Clay:{type:'matte',base:'#c0876a',amb:0.5,sExp:6,sInt:0.05,sss:0.12,tint:'#ffd8c0'}, Skin:{type:'matte',base:'#e6b496',amb:0.5,sExp:14,sInt:0.14,sss:0.28,tint:'#ff9a86'},
Pearl:{type:'pearl',base:'#e8e0ee'}, Jade:{type:'gem',base:'#2f9f72',refl:0.5}, Bronze:{type:'metal',base:'#8a5a2a',tint:'#caa05a',refl:0.9}, Chrome:{type:'chrome',base:'#aab0bc'},
Gold:{type:'metal',base:'#c9a23c',tint:'#ffd76a',refl:1.0,gain:1.05}, Wax:{type:'matte',base:'#e8c98a',amb:0.52,sExp:12,sInt:0.18,sss:0.5,tint:'#ff9d5a'},
Basalt:{type:'matte',base:'#3a3f47',amb:0.34,sExp:18,sInt:0.18,refl:0.4}, Copper:{type:'metal',base:'#a05a34',tint:'#e08a5a',refl:0.92}, Pewter:{type:'metal',base:'#8c93a0',tint:'#c8cdd6',refl:0.7},
Ruby:{type:'gem',base:'#a01030',refl:0.55}, Emerald:{type:'gem',base:'#0f7a48',refl:0.55}, Sapphire:{type:'gem',base:'#173a9a',refl:0.6}, Porcelain:{type:'matte',base:'#f0eee8',amb:0.62,sExp:60,sInt:0.5,refl:0.5} };
const MATCAPS={}; for(const k of Object.keys(MATCAP_PRESETS)) MATCAPS[k]=makeMatcapTex(MATCAP_PRESETS[k]);
// ---------- procedural surface patterns (ported from brainWhiz; live via shared uniforms) ----------
const PAT_ID={None:0,Checkerboard:1,Stripes:2,'Grid lines':3,Dots:4,Hatch:5,'Cross-hatch':6,Bricks:7,Waves:8,Concentric:9,Lattice:10};
const patU={ uPattern:{value:0}, uScale:{value:0.6}, uContrast:{value:0.5}, uPatAngle:{value:0}, uPatMode:{value:0} };
const PAT_GLSL=`
if(uPattern>0){ vec3 q=vPatPos*uScale; { float ca=cos(uPatAngle),sa=sin(uPatAngle); q.xy=mat2(ca,-sa,sa,ca)*q.xy; } float on=1.0;
if(uPattern==1){ on=1.0-mod(floor(q.x)+floor(q.y)+floor(q.z),2.0); }
else if(uPattern==2){ on=1.0-step(0.5,fract(q.x)); }
else if(uPattern==3){ vec3 g=abs(fract(q)-0.5); on=smoothstep(0.0,0.06,min(min(g.x,g.y),g.z)); }
else if(uPattern==4){ vec3 f=fract(q)-0.5; on=smoothstep(0.18,0.27,length(f.xy)); }
else if(uPattern==5){ on=1.0-step(0.5,fract(q.x+q.y)); }
else if(uPattern==6){ vec2 d=abs(fract(vec2(q.x+q.y,q.x-q.y))-0.5); on=smoothstep(0.0,0.07,min(d.x,d.y)); }
else if(uPattern==7){ float ox=mod(floor(q.y),2.0)*0.5; vec2 d=abs(fract(vec2(q.x+ox,q.y))-0.5); on=smoothstep(0.0,0.06,min(d.x,d.y)); }
else if(uPattern==8){ on=1.0-step(0.5,fract(q.y+0.32*sin(q.x*6.2831853))); }
else if(uPattern==9){ on=smoothstep(0.0,0.08,abs(fract(length(q.xy))-0.5)); }
else if(uPattern==10){ vec3 g=abs(fract(q)-0.5); on=1.0-smoothstep(0.0,0.05,min(min(g.x,g.y),g.z)); }
if(uPatMode==0){ diffuseColor.rgb*=mix(uContrast,1.0,on); } else { if(on<(1.0-uContrast)) discard; } }`;
function attachPattern(m){ m.onBeforeCompile=(sh)=>{ for(const k in patU) sh.uniforms[k]=patU[k];
sh.vertexShader='varying vec3 vPatPos;\n'+sh.vertexShader.replace('#include <begin_vertex>','#include <begin_vertex>\n#ifdef USE_INSTANCING\n vPatPos=(instanceMatrix*vec4(position,1.0)).xyz;\n#else\n vPatPos=position;\n#endif');
sh.fragmentShader='varying vec3 vPatPos;\nuniform int uPattern;\nuniform float uScale;\nuniform float uContrast;\nuniform int uPatMode;\nuniform float uPatAngle;\n'+sh.fragmentShader.replace('#include <color_fragment>','#include <color_fragment>\n'+PAT_GLSL); }; }
// ---------- materials ----------
const clipPlanes=[]; let clipEnabled=false; // world-space section cuts (see updateClip)
function makeMat(){ const st=$('material').value; const rim=$('rim').checked;
const wire=$('wireframe')&&$('wireframe').checked, flat=$('flatShade')&&$('flatShade').checked; let m;
if(st.indexOf('matcap:')===0){ m=new THREE.MeshMatcapMaterial({matcap:MATCAPS[st.slice(7)]||null, flatShading:flat}); }
else if(st==='matte') m=new THREE.MeshStandardMaterial({roughness:1,metalness:0,flatShading:flat});
else if(st==='glossy') m=new THREE.MeshStandardMaterial({roughness:0.12,metalness:0.0,flatShading:flat});
else if(st==='metal') m=new THREE.MeshStandardMaterial({roughness:0.3,metalness:0.9,flatShading:flat});
else if(st==='toon') m=new THREE.MeshToonMaterial({});
else if(st==='glass') m=new THREE.MeshStandardMaterial({roughness:0.05,metalness:0.0,transparent:true,opacity:0.45,flatShading:flat});
else if(st==='clearcoat') m=new THREE.MeshPhysicalMaterial({roughness:0.35,metalness:0.0,clearcoat:1.0,clearcoatRoughness:0.08,flatShading:flat});
else if(st==='iridescent') m=new THREE.MeshPhysicalMaterial({roughness:0.25,metalness:0.3,iridescence:1.0,iridescenceIOR:1.3,iridescenceThicknessRange:[100,700],flatShading:flat});
else m=new THREE.MeshStandardMaterial({roughness:0.35,metalness:0.0,flatShading:flat});
if(rim && st.indexOf('matcap:')!==0){ m.emissive=new THREE.Color(0x2a4a80); m.emissiveIntensity=0.5; }
if('envMapIntensity' in m && $('ltEnv')) m.envMapIntensity=+$('ltEnv').value; // Lighting panel: reflection strength
if($('ltFinish')&&$('ltFinish').checked && ('roughness' in m) && !m.isMeshMatcapMaterial && !m.isMeshToonMaterial){ m.roughness=+$('ltRough').value; m.metalness=+$('ltMetal').value; } // custom diffuse↔specular finish
if(wire) m.wireframe=true;
if(clipEnabled && clipPlanes.length){ m.clippingPlanes=clipPlanes; m.clipShadows=true; }
attachPattern(m); // procedural pattern (live via shared patU uniforms)
return m; }
// ---------- section cut (world-space clipping plane) ----------
function updateClip(){ clipEnabled=$('clipOn').checked; const ax=curClipAxis, flip=$('clipFlip').checked, pos=(+$('clipPos').value)*(molR||30);
const n=new THREE.Vector3(ax==='x'?1:0, ax==='y'?1:0, ax==='z'?1:0); if(!flip) n.negate();
const plane=new THREE.Plane(n, flip? -pos : pos); clipPlanes.length=0; clipPlanes.push(plane);
molGroup.traverse(o=>{ if(o.isMesh){ const ms=Array.isArray(o.material)?o.material:[o.material]; ms.forEach(m=>{ if(m===undefined)return; m.clippingPlanes=clipEnabled?clipPlanes:null; m.side=clipEnabled?THREE.DoubleSide:m.side; m.needsUpdate=true; }); } }); }
// ---------- atom properties (shift-click): element facts + how it bonds here ----------
function showAtomProps(gi){ const A=MOL.atoms, a=A[gi], sym=(a.el||'').replace('BASE_',''), e=ELEM[sym];
const nb=[]; for(const b of MOL.bonds){ if(b[0]===gi) nb.push(b[1]); else if(b[1]===gi) nb.push(b[0]); }
const neighEls=nb.map(j=>(A[j].el||'').replace('BASE_','')); const p=$('atomProps');
p.querySelector('.hd').innerHTML=`<span class="dot" style="background:${atomColor(a)}"></span>${e?e[0]:sym} — ${a.name||sym}`;
let h='';
if(e){ h+=`<div><span class="k">Element</span><b>${sym}</b> · Z=${e[1]}</div>`;
h+=`<div><span class="k">Atomic mass</span>${e[2]} u</div>`;
h+=`<div><span class="k">Electronegativity</span>${e[3]} (Pauling)</div>`;
h+=`<div><span class="k">Typical valence</span>${e[4]}</div>`; }
h+=`<div><span class="k">Residue</span>${a.resName} ${a.resSeq} · chain ${a.chain}</div>`;
h+=`<div><span class="k">Bonds here</span>${nb.length}${nb.length?': '+neighEls.join(', '):''}</div>`;
if(e && nb.length){ // polarity of each bond from ΔEN
const parts=nb.map(j=>{ const s2=(A[j].el||'').replace('BASE_',''), e2=ELEM[s2]; if(!e2) return `${s2} n/a`;
const d=Math.abs(e[3]-e2[3]); const kind=d<0.5?'nonpolar covalent':d<1.7?'polar covalent':'ionic-like';
return `${sym}–${s2} ΔEN ${d.toFixed(2)} → ${kind}`; });
h+=`<div style="margin-top:5px;color:var(--muted)">${parts.join('<br>')}</div>`;
if(e[4] && nb.length>e[4]) h+=`<div style="color:#ffb570">hypervalent / metal coordination (${nb.length} > typical ${e[4]})</div>`;
else if(e[4] && nb.length<e[4] && !a.het) h+=`<div style="color:var(--muted)">remaining valences → H (often implicit) or lone pairs</div>`; }
p.querySelector('.bd').innerHTML=h; p.classList.add('on'); }
// ---------- shape factories (atoms + connectors) for custom looks ----------
function atomGeom(){ switch($('atomShape').value){
case 'cube': return new THREE.BoxGeometry(1.6,1.6,1.6);
case 'ico': return new THREE.IcosahedronGeometry(1.05,0);
case 'octa': return new THREE.OctahedronGeometry(1.15,0);
case 'tetra': return new THREE.TetrahedronGeometry(1.35,0);
case 'dodeca': return new THREE.DodecahedronGeometry(1.05,0);
case 'cone': return new THREE.ConeGeometry(1.05,1.9,18);
case 'torus': return new THREE.TorusGeometry(0.72,0.42,12,22);
case 'cyl': return new THREE.CylinderGeometry(0.95,0.95,1.7,18);
case 'diamond': return new THREE.OctahedronGeometry(1.25,0);
default: return new THREE.SphereGeometry(1,20,16);
}}
function nucleonGeom(){ switch($('atomShape').value){ // nucleons follow 'Atom shape' too, but low-poly & ~unit-radius so a big nucleus stays legible
case 'cube': case 'diamond': return new THREE.BoxGeometry(1.5,1.5,1.5);
case 'ico': return new THREE.IcosahedronGeometry(1,0);
case 'octa': return new THREE.OctahedronGeometry(1.1,0);
case 'tetra': return new THREE.TetrahedronGeometry(1.3,0);
case 'dodeca': return new THREE.DodecahedronGeometry(1,0);
default: return new THREE.SphereGeometry(1,10,8);
}}
function bondGeom(){ switch($('bondShape').value){
case 'box': return new THREE.BoxGeometry(2,1,2); // square rod
case 'hex': return new THREE.CylinderGeometry(1,1,1,6,1,true);
case 'tri': return new THREE.CylinderGeometry(1,1,1,3,1,true);
default: return new THREE.CylinderGeometry(1,1,1,14,1,true);
}}
// ---------- build instanced geometry ----------
const _m=new THREE.Matrix4(),_q=new THREE.Quaternion(),_v=new THREE.Vector3(),_up=new THREE.Vector3(0,1,0),_s=new THREE.Vector3(),_c=new THREE.Color();
function clearMesh(){ for(const o of [sphereMesh,bondMesh]){ if(o){ molGroup.remove(o); o.geometry.dispose(); o.material.dispose(); } } sphereMesh=bondMesh=null; pickList=[];
if(cartoonGroup){ molGroup.remove(cartoonGroup); cartoonGroup.traverse(o=>{ if(o.geometry)o.geometry.dispose(); if(o.material)o.material.dispose(); }); cartoonGroup=null; }
if(surfMesh){ molGroup.remove(surfMesh); surfMesh.geometry.dispose(); surfMesh.material.dispose(); surfMesh=null; }
if(atomicGroup){ molGroup.remove(atomicGroup); atomicGroup.traverse(o=>{ if(o.geometry)o.geometry.dispose(); if(o.material)o.material.dispose(); }); atomicGroup=null; electronOrbits=[]; }
if(auraGroup){ molGroup.remove(auraGroup); auraGroup.traverse(o=>{ if(o.geometry)o.geometry.dispose(); if(o.material)o.material.dispose(); }); auraGroup=null; }
if(voronoiGroup){ molGroup.remove(voronoiGroup); voronoiGroup.traverse(o=>{ if(o.geometry)o.geometry.dispose(); if(o.material)o.material.dispose(); }); voronoiGroup=null; }
if(hbondGroup){ molGroup.remove(hbondGroup); hbondGroup.geometry.dispose(); hbondGroup.material.dispose(); hbondGroup=null; }
if(outlineGroup){ molGroup.remove(outlineGroup); outlineGroup.traverse(o=>{ if(o.geometry)o.geometry.dispose(); if(o.material)o.material.dispose(); }); outlineGroup=null; }
if(labelsGroup){ molGroup.remove(labelsGroup); labelsGroup.traverse(o=>{ if(o.material){o.material.map&&o.material.map.dispose(); o.material.dispose();} }); labelsGroup=null; } }
// backbone cartoon: smooth Catmull-Rom tube through CA atoms per chain, radius+colour by secondary structure
function ssOf(a){ const k=a.chain+':'+a.resSeq; if(MOL.ss&&MOL.ss.helix.has(k))return'H'; if(MOL.ss&&MOL.ss.sheet.has(k))return'E'; return'C'; }
const isNuc=rn=>/^(D?[ATGCUI]|R[ATGCU])$/i.test((rn||'').trim());
const NUC_ANCHOR={P:4,"C4'":3,"C1'":2,BB:2,"C3'":1}; // backbone-trace atom priority
const BASE_COL={A:'#ff5b6e',DA:'#ff5b6e',G:'#4dff88',DG:'#4dff88',C:'#ffd23f',DC:'#ffd23f',T:'#5db0ff',DT:'#5db0ff',U:'#c07bff',RU:'#c07bff'};
function structHasNucleic(){ if(MOL._dna) return true; return MOL.atoms.some(a=>isNuc(a.resName)&&NUC_ANCHOR[a.name]); }
function isMacro(){ return structHasNucleic() || MOL.atoms.some(a=>!a.het&&a.name==='CA'); } // has a protein or nucleic backbone
const MACRO_WARN=' ⚠ best for proteins/DNA — use Ball & stick for small molecules';
// ---------- selection & isolation ----------
let selChain='', selKind='';
function kindOf(a){ if(isSolvent(a))return'water'; if(a.het)return'ligand'; if(isNuc(a.resName)||NUC_ANCHOR[a.name])return'nucleic'; return'protein'; }
function selPred(){ return a=> (!selChain||a.chain===selChain) && (!selKind||kindOf(a)===selKind); }
function populateChains(){ const s=$('selChain'); if(!s)return; const set=[...new Set(MOL.atoms.map(a=>a.chain))].sort();
s.innerHTML='<option value="">(all chains)</option>'+set.map(c=>`<option value="${c}">chain ${c}</option>`).join(''); selChain=''; }
function selAct(action){ const p=selPred(); let n=0;
if((action==='isolate'||action==='hide') && !MOL.atoms.some(a=>p(a))){ // nothing matches → would blank the view; refuse + flash red
const seg=$('selKind'); if(seg){ seg.classList.remove('blip'); void seg.offsetWidth; seg.classList.add('blip'); } if($('selOut'))$('selOut').textContent='empty selection — nothing to '+action; return; }
if(action==='isolate') MOL.atoms.forEach(a=>{ a.hidden=!p(a); if(p(a))n++; });
else if(action==='hide') MOL.atoms.forEach(a=>{ if(p(a)){a.hidden=true;n++;} });
else if(action==='colour'){ const c=$('selColor').value; MOL.atoms.forEach(a=>{ if(p(a)){a.ovcol=c;n++;} }); }
else { MOL.atoms.forEach(a=>{ a.hidden=false; a.ovcol=null; }); }
rebuild(); if($('selOut'))$('selOut').textContent = action==='reset' ? 'all shown · colours reset' : `${n} atoms ${action==='colour'?'coloured':action+'d'}`; }
// ---------- one-click active site: find the bound ligand, isolate it + the residues lining its pocket, with H-bonds + labels ----------
function activeSite(radiusArg){ const A=MOL.atoms, radius=+radiusArg || +($('pocketR')&&$('pocketR').value) || 4.5;
const groups={}; for(let i=0;i<A.length;i++){ const a=A[i]; if(a.het && !isSolvent(a)){ const k=a.chain+'|'+a.resSeq+'|'+a.resName; (groups[k]=groups[k]||[]).push(i); } }
const keys=Object.keys(groups);
if(!keys.length){ const b=$('activeSiteBtn'); if(b){ b.classList.remove('blip'); void b.offsetWidth; b.classList.add('blip'); } if($('selOut'))$('selOut').textContent='no ligand found — need a bound non-water HETATM (try 1HSG, 3PTB, 4DFR…)'; return; }
keys.sort((x,y)=>groups[y].length-groups[x].length); const ligKey=keys[0], ligIdx=groups[ligKey], R2=radius*radius; // biggest non-solvent HETATM = the ligand
const pocket=new Set();
for(let i=0;i<A.length;i++){ const a=A[i]; if(isSolvent(a))continue; if(a.chain+'|'+a.resSeq+'|'+a.resName===ligKey)continue;
for(const j of ligIdx){ const dx=a.x-A[j].x,dy=a.y-A[j].y,dz=a.z-A[j].z; if(dx*dx+dy*dy+dz*dz<=R2){ pocket.add(a.chain+'|'+a.resSeq); break; } } }
const keep=new Set(ligIdx); for(let i=0;i<A.length;i++){ if(pocket.has(A[i].chain+'|'+A[i].resSeq)) keep.add(i); }
A.forEach((a,i)=>{ a.hidden=!keep.has(i); a.ovcol=null; });
if($('hbondOn'))$('hbondOn').checked=true; if($('resLabels'))$('resLabels').checked=true;
if(curRep!=='bas'){ curRep='bas'; [...$('repSeg').children].forEach(x=>x.classList.toggle('on',x.dataset.rep==='bas')); }
rebuild();
let cx=0,cy=0,cz=0; ligIdx.forEach(j=>{cx+=A[j].x;cy+=A[j].y;cz+=A[j].z;}); cx/=ligIdx.length;cy/=ligIdx.length;cz/=ligIdx.length; // frame on the ligand
let rr=6; keep.forEach(i=>{ rr=Math.max(rr,Math.hypot(A[i].x-cx,A[i].y-cy,A[i].z-cz)); });
const d=rr/Math.sin(camera.fov*Math.PI/360)*1.35; molGroup.rotation.set(0,0,0); controls.target.set(cx,cy,cz); camera.up.set(0,1,0); camera.position.set(cx,cy,cz+d);
controls.minDistance=rr*0.1; controls.maxDistance=rr*9; controls.update(); syncZoom();
const lname=ligKey.split('|')[2]; if($('selOut'))$('selOut').textContent=`Active site: ligand ${lname} + ${pocket.size} residues within ${radius} Å · H-bonds + labels on`; }
// ---------- sequence viewer ----------
function buildSeqPanel(){ const el=$('seqBody'); if(!el)return; el.innerHTML=''; const chains=new Map();
for(const a of MOL.atoms){ if(a.het)continue; const anchor=a.name==='CA'||(NUC_ANCHOR[a.name]&&(MOL._dna||isNuc(a.resName))); if(!anchor)continue;
if(!chains.has(a.chain))chains.set(a.chain,new Map()); const rm=chains.get(a.chain); if(!rm.has(a.resSeq))rm.set(a.resSeq,a); }
if(!chains.size){ el.innerHTML='<span style="color:var(--muted);font-size:11px">No protein/nucleic residues in this structure.</span>'; return; }
for(const [ch,rm] of chains){ const row=document.createElement('div'); row.className='seqrow'; const lab=document.createElement('span'); lab.className='seqch'; lab.textContent=ch; row.appendChild(lab);
const res=[...rm.entries()].sort((x,y)=>(+x[0])-(+y[0]));
for(const [rs,a] of res){ const c=document.createElement('span'); c.className='seqc'+(resSel.has(ch+'|'+rs)?' sel':''); c.textContent=AA1[a.resName]||(isNuc(a.resName)?a.resName.replace(/^D/,''):(a.resName[0]||'?')); c.title=`${a.resName} ${rs} · chain ${ch}`; c.dataset.k=ch+'|'+rs; row.appendChild(c); }
el.appendChild(row); } }
function toggleSeqPanel(){ const p=$('seqPanel'); const on=!p.classList.contains('on'); p.classList.toggle('on',on); if(on) buildSeqPanel(); }
// geometric secondary-structure guess (for AlphaFold/mmCIF with no HELIX/SHEET records): CA-CA spacing
function computeSS(atoms){ const helix=new Set(), sheet=new Set(), byChain={};
atoms.forEach(a=>{ if(!a.het && a.name==='CA'){ (byChain[a.chain]||(byChain[a.chain]=[])).push(a); } });
for(const ch in byChain){ const cas=byChain[ch]; cas.sort((p,q)=>(+p.resSeq)-(+q.resSeq)); const n=cas.length;
const d=(i,j)=>(i<0||j>=n)?1e9:Math.hypot(cas[i].x-cas[j].x,cas[i].y-cas[j].y,cas[i].z-cas[j].z);
for(let i=0;i+4<n;i++){ if(d(i,i+2)<6.2 && d(i,i+3)<6.0 && d(i,i+4)<6.7){ for(let k=i;k<=i+4;k++) helix.add(ch+':'+cas[k].resSeq); } } // α-helix
for(let i=0;i+2<n;i++){ const dd=d(i,i+2); if(dd>6.2 && dd<7.6 && !helix.has(ch+':'+cas[i].resSeq)){ sheet.add(ch+':'+cas[i].resSeq); sheet.add(ch+':'+cas[i+1].resSeq); sheet.add(ch+':'+cas[i+2].resSeq); } } } // extended strand
return {helix,sheet}; }
// flat ribbon (rectangular cross-section swept along the curve) — arrow=true tapers to a β-strand arrowhead
// swept ribbon with a ROUNDED-RECTANGLE (superellipse) cross-section → smooth, professional ribbons/arrows
const _RIBPROF=(function(){ const M=28, p=[]; for(let k=0;k<M;k++){ const a=k/M*Math.PI*2, cx=Math.cos(a), cy=Math.sin(a);
p.push([Math.sign(cx)*Math.pow(Math.abs(cx),0.4), Math.sign(cy)*Math.pow(Math.abs(cy),0.4)]); } return p; })();
function ribbonGeom(curve, seg, halfW, halfT, arrow){ const fr=curve.computeFrenetFrames(seg,false), pos=[], idx=[], rings=[], M=_RIBPROF.length;
for(let i=0;i<=seg;i++){ const t=i/seg, p=curve.getPointAt(t), N=fr.normals[i], B=fr.binormals[i]; let hw=halfW;
if(arrow){ const a0=0.68; hw = t<a0 ? halfW : halfW*2.0*(1-(t-a0)/(1-a0)); } // constant body, then arrowhead flare→point
const ring=[]; for(const [cb,cn] of _RIBPROF) ring.push(p.clone().addScaledVector(B,cb*hw).addScaledVector(N,cn*halfT)); rings.push(ring); }
for(let i=0;i<seg;i++){ const base=pos.length/3; for(const v of rings[i]) pos.push(v.x,v.y,v.z); for(const v of rings[i+1]) pos.push(v.x,v.y,v.z);
for(let e=0;e<M;e++){ const n=(e+1)%M; idx.push(base+e,base+M+e,base+M+n, base+e,base+M+n,base+n); } }
const g=new THREE.BufferGeometry(); g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3)); g.setIndex(idx); g.computeVertexNormals(); return g; }
function buildCartoon(){ const grp=new THREE.Group(); const chains=new Map();
for(const a of MOL.atoms){ if(!a.het && a.name==='CA' && !a.hidden){ (chains.get(a.chain)||chains.set(a.chain,[]).get(a.chain)).push(a); } }
const chainCol=ch=>{ let h=0; for(const c of ch)h+=c.charCodeAt(0); return PAL[h%PAL.length]; };
for(const [ch,cas] of chains){ if(cas.length<2) continue; cas.sort((p,q)=>(+p.resSeq)-(+q.resSeq));
const flush=(arr,ss)=>{ if(arr.length<2) return; const pts=arr.map(a=>new THREE.Vector3(a.x,a.y,a.z));
const curve=new THREE.CatmullRomCurve3(pts,false,'catmullrom',0.5), seg=Math.max(24,arr.length*14); // denser along the backbone → smoother sweep
const col=(curCol==='chain')?chainCol(ch):(curCol==='rainbow'||curCol==='bfactor')?atomColor(arr[Math.floor(arr.length/2)]):(ss==='H'?'#ff5b6e':ss==='E'?'#ffd23f':'#9fd0ff');
let geo; if(ss==='H') geo=ribbonGeom(curve,seg,1.2,0.2,false); // helix: flat rounded ribbon
else if(ss==='E') geo=ribbonGeom(curve,seg,1.1,0.2,true); // strand: flat rounded arrow
else geo=new THREE.TubeGeometry(curve,seg,0.3,16,false); // coil: smooth thin tube
const mesh=new THREE.Mesh(geo, makeMat()); mesh.material.color.set(col); if(ss!=='C') mesh.material.side=THREE.DoubleSide; grp.add(mesh); };
let run=[cas[0]], curSS=ssOf(cas[0]);
for(let i=1;i<cas.length;i++){ const s=ssOf(cas[i]); if(s!==curSS){ flush(run.concat([cas[i]]), curSS); run=[cas[i]]; curSS=s; } else run.push(cas[i]); }
flush(run, curSS); }
// ---- nucleic (DNA/RNA): smooth backbone tube per strand + coloured base rungs ----
if(structHasNucleic()){ const nucCh=new Map(), baseAt=new Map();
for(const a of MOL.atoms){ if(a.hidden||!(MOL._dna||isNuc(a.resName))) continue; const p=NUC_ANCHOR[a.name];
if(p){ if(!nucCh.has(a.chain)) nucCh.set(a.chain,new Map()); const rm=nucCh.get(a.chain), cur=rm.get(a.resSeq); if(!cur||p>cur.p) rm.set(a.resSeq,{a,p}); }
if((a.el||'').startsWith('BASE_')||a.name==='N1'||a.name==='N9'){ const k=a.chain+'|'+a.resSeq; if(!baseAt.has(k)) baseAt.set(k,a); } }
const _up2=new THREE.Vector3(0,1,0);
const rung=(p0,p1,col)=>{ const dir=p1.clone().sub(p0), len=dir.length(); if(len<0.1)return; dir.normalize();
const q=new THREE.Quaternion().setFromUnitVectors(_up2,dir), g=new THREE.CylinderGeometry(0.28,0.28,len,8), m=new THREE.Mesh(g,makeMat());
m.material.color.set(col); m.position.copy(p0).add(p1).multiplyScalar(0.5); m.quaternion.copy(q); grp.add(m); };
for(const [ch,rm] of nucCh){ const arr=[...rm.values()].map(v=>v.a).sort((x,y)=>(+x.resSeq)-(+y.resSeq)); if(arr.length<2) continue;
const pts=arr.map(a=>new THREE.Vector3(a.x,a.y,a.z)), curve=new THREE.CatmullRomCurve3(pts,false,'catmullrom',0.5), seg=Math.max(20,arr.length*10);
const col=(curCol==='chain')?chainCol(ch):(curCol==='rainbow'||curCol==='bfactor')?atomColor(arr[Math.floor(arr.length/2)]):'#c8a05a';
const tube=new THREE.TubeGeometry(curve,seg,0.85,16,false), tm=new THREE.Mesh(tube,makeMat()); tm.material.color.set(col); grp.add(tm);
for(const a of arr){ const bk=baseAt.get(a.chain+'|'+a.resSeq); if(bk){ const bc=(curCol==='chain'||curCol==='rainbow'||curCol==='bfactor')?col:(BASE_COL[a.resName]||'#c8a05a');
rung(new THREE.Vector3(a.x,a.y,a.z), new THREE.Vector3(bk.x,bk.y,bk.z), bc); } } }
}
return grp; }
let _dataMap=null, _dataRange=null, _dataTitle='Data', _dataMode='res', _dataMatched=0; // user CSV values mapped onto residues/atoms
// Parse a CSV/TSV of your own per-residue or per-atom values and colour the structure by them.
// Accepted shapes (header optional): chain,res,value · res,value · serial,value (atom) · residueLabel,value ("A45"/"A:45"/"45").
function loadDataCSV(text,fname){ const rows=text.replace(/^/,'').trim().split(/\r?\n/).filter(l=>l.trim()); if(!rows.length){ $('dataOut').textContent='empty file'; return; }
const delim=/\t/.test(rows[0])?'\t':(/;/.test(rows[0])&&!/,/.test(rows[0])?';':','); const first=rows[0].split(delim).map(s=>s.trim());
const headed=first.some(c=>c&&isNaN(parseFloat(c))&&!/^[A-Za-z]?:?\d+$/.test(c)); // a header row has words that aren't residue labels
const hdr=headed?first.map(s=>s.toLowerCase()):null, start=headed?1:0;
let mode='res'; if(hdr && hdr.some(h=>/serial|atom(?!ic)|^idx$/.test(h))) mode='atom';
_dataTitle = (headed && hdr.length>=2 && hdr[hdr.length-1]) ? first[first.length-1] : (fname? fname.replace(/\.[^.]+$/,'') : 'Data');
const map=new Map(); let vmin=1e30,vmax=-1e30,n=0;
for(let i=start;i<rows.length;i++){ const p=rows[i].split(delim).map(s=>s.trim()); if(p.length<2) continue;
const val=parseFloat(p[p.length-1]); if(!isFinite(val)) continue;
if(mode==='atom'){ map.set('#'+p[0], val); }
else if(p.length>=3){ map.set(p[0]+'|'+p[1], val); } // chain,res,value
else { const id=p[0], m=id.match(/^([A-Za-z]?)[:_ ]?(-?\d+)$/); if(m&&m[1]) map.set(m[1]+'|'+m[2], val); map.set('*|'+(m?m[2]:id), val); } // res,value (chain optional)
if(val<vmin)vmin=val; if(val>vmax)vmax=val; n++; }
if(!n){ $('dataOut').textContent='no numeric rows found — expected e.g. "A,45,1.3"'; return; }
_dataMap=map; _dataMode=mode; _dataRange={min:vmin,max:vmax}; applyDataToAtoms();
if(!_dataMatched){ $('dataOut').textContent=`loaded ${n} values but 0 matched this structure — check chain/residue IDs`; $('dataOut').style.color='#ffb570'; return; }
$('dataOut').style.color=''; $('dataOut').textContent=`${_dataMatched} atoms coloured · ${n} ${mode==='atom'?'atoms':'residues'} · ${vmin.toPrecision(3)} → ${vmax.toPrecision(3)}`;
curCol='data'; [...$('colSeg').children].forEach(x=>x.classList.toggle('on',x.dataset.col==='data')); rebuild(); }
function applyDataToAtoms(){ _dataMatched=0; if(!_dataMap){ MOL.atoms.forEach(a=>a.dv=null); return; }
MOL.atoms.forEach(a=>{ let v; if(_dataMode==='atom') v=_dataMap.get('#'+a.serial);
else { v=_dataMap.get(a.chain+'|'+a.resSeq); if(v==null) v=_dataMap.get('*|'+a.resSeq); }
a.dv=(v==null?null:v); if(v!=null)_dataMatched++; }); }
let _ccx=null; // colour context (b-factor range, per-chain residue range) — recomputed each rebuild
function computeColorCtx(){ const A=MOL.atoms; let bmin=1e9,bmax=-1e9; const cs={};
for(const a of A){ const b=a.b||0; if(b<bmin)bmin=b; if(b>bmax)bmax=b; const s=+a.resSeq; if(!cs[a.chain])cs[a.chain]={min:1e9,max:-1e9}; if(s<cs[a.chain].min)cs[a.chain].min=s; if(s>cs[a.chain].max)cs[a.chain].max=s; }
_ccx={bmin,bmax,cs,isPLDDT:(bmin>=0&&bmax<=100&&bmax>1)}; } // AF pLDDT lives in 0..100
const _spectrum=f=>{ f=f<0?0:f>1?1:f; return '#'+new THREE.Color().setHSL((1-f)*0.66,0.9,0.55).getHexString(); }; // blue→red
function atomColor(a){ if(resSel.size && resSel.has(a.chain+'|'+a.resSeq)) return '#ffe066'; // sequence-viewer highlight
if(a.ovcol) return a.ovcol; // selection colour override wins
if(curCol==='chain'){ let h=0; for(const ch of a.chain) h+=ch.charCodeAt(0); return PAL[h%PAL.length]; }
if(curCol==='residue') return RESCOL[a.resName]||RESCOL.default;
if(curCol==='rainbow'){ const c=_ccx&&_ccx.cs[a.chain]; return _spectrum(c&&c.max>c.min?((+a.resSeq)-c.min)/(c.max-c.min):0.5); } // N→C spectrum per chain
if(curCol==='bfactor'){ const b=a.b||0; if(_ccx&&_ccx.isPLDDT) return b>=90?'#0053d6':b>=70?'#65cbf3':b>=50?'#ffdb13':'#ff7d45'; // AlphaFold pLDDT palette
return _spectrum(_ccx&&_ccx.bmax>_ccx.bmin?(b-_ccx.bmin)/(_ccx.bmax-_ccx.bmin):0.5); }
if(curCol==='data'){ const v=a.dv; if(v==null) return '#454b57'; const r=_dataRange; return _spectrum(r&&r.max>r.min?(v-r.min)/(r.max-r.min):0.5); } // your CSV values
return elCol(a.el); }
// ---------- colour legend (composited into the app overlay + PNG/figure exports) ----------
function legendCanvas(scale){ scale=scale||1; const rows=[]; let title='', grad=null;
if(curCol==='bfactor' && _ccx && _ccx.isPLDDT){ title='pLDDT confidence'; rows.push(['#0053d6','Very high (≥90)'],['#65cbf3','Confident (70–90)'],['#ffdb13','Low (50–70)'],['#ff7d45','Very low (<50)']); }
else if(curCol==='bfactor' && _ccx){ title='B-factor'; grad=[_ccx.bmin.toFixed(0), _ccx.bmax.toFixed(0)]; }
else if(curCol==='data' && _dataRange){ title=_dataTitle||'Data'; grad=[(''+(+_dataRange.min.toPrecision(3))), (''+(+_dataRange.max.toPrecision(3)))]; }
else if(curCol==='rainbow'){ title='Chain N → C'; grad=['N','C']; }
else if(curCol==='chain'){ title='Chains'; const chs=[...new Set(MOL.atoms.filter(a=>!a.hidden).map(a=>a.chain))].sort(); for(const c of chs.slice(0,14)){ let h=0; for(const x of c)h+=x.charCodeAt(0); rows.push([PAL[h%PAL.length],'Chain '+c]); } }
else if(curCol==='element'){ title='Elements'; const els=[...new Set(MOL.atoms.filter(a=>!a.hidden).map(a=>(a.el||'').replace('BASE_','')))].filter(e=>CPK[e]); for(const e of els.slice(0,12)) rows.push([elCol(e),e]); }
else return null;
const fs=13*scale, pad=10*scale, sw=15*scale, rh=fs+7*scale, cvs=document.createElement('canvas'), g=cvs.getContext('2d');
g.font=`${fs}px system-ui`; let tw=g.measureText(title).width; for(const r of rows) tw=Math.max(tw, sw+7*scale+g.measureText(r[1]).width);
const barW=140*scale, W=pad*2+Math.max(tw, grad?barW:0), H=pad*2 + (fs+8*scale) + (grad? (18*scale+fs+4*scale) : rows.length*rh);
cvs.width=Math.ceil(W); cvs.height=Math.ceil(H); g.font=`bold ${fs}px system-ui`;
g.fillStyle='rgba(12,16,24,0.82)'; if(g.roundRect){g.beginPath();g.roundRect(0,0,W,H,8*scale);g.fill();}else g.fillRect(0,0,W,H);
g.fillStyle='#eaf2ff'; g.textBaseline='top'; g.fillText(title, pad, pad);
let y=pad+fs+8*scale;
if(grad){ const gr=g.createLinearGradient(pad,0,pad+barW,0); for(let s=0;s<=10;s++){ gr.addColorStop(s/10, _spectrum(s/10)); } g.fillStyle=gr; g.fillRect(pad,y,barW,14*scale);
g.font=`${fs}px system-ui`; g.fillStyle='#cfe0f5'; g.fillText(grad[0],pad,y+16*scale); const rtw=g.measureText(grad[1]).width; g.fillText(grad[1],pad+barW-rtw,y+16*scale); }
else { g.font=`${fs}px system-ui`; for(const [col,lab] of rows){ g.fillStyle=col; if(g.roundRect){g.beginPath();g.roundRect(pad,y,sw,sw,3*scale);g.fill();}else g.fillRect(pad,y,sw,sw); g.fillStyle='#dfe8f5'; g.fillText(lab,pad+sw+7*scale,y+1*scale); y+=rh; } }
return cvs; }
// residue labels (sprites at CA / nucleic anchor of shown residues in the current selection; capped)
function buildResLabels(){ if(!$('resLabels')||!$('resLabels').checked) return; const p=selPred(), useRes=resSel.size>0, anchors=new Map();
for(const a of MOL.atoms){ if(a.hidden||a.het) continue; const isAnchor=a.name==='CA'||(NUC_ANCHOR[a.name]&&(MOL._dna||isNuc(a.resName))); if(!isAnchor) continue;
if(useRes){ if(!resSel.has(a.chain+'|'+a.resSeq)) continue; } else if(!p(a)) continue; // highlighted residues if any, else the chain/kind selection
const k=a.chain+'|'+a.resSeq; if(!anchors.has(k)) anchors.set(k,a); }
const arr=[...anchors.values()], cap=150, step=arr.length>cap?Math.ceil(arr.length/cap):1; labelsGroup=new THREE.Group();
for(let i=0;i<arr.length;i+=step){ const a=arr[i], one=AA1[a.resName]?a.resName:(a.resName); const sp=labelSprite(`${one} ${a.resSeq}`,'#e7eefb'); sp.position.set(a.x,a.y,a.z); labelsGroup.add(sp); }
molGroup.add(labelsGroup); if(step>1&&$('selOut')) $('selOut').textContent=`labels: every ${step}th residue (${Math.ceil(arr.length/step)} shown)`; }
function decorations(){ buildAura(); buildHBonds(); addOutline(); buildResLabels(); applyShadows(); }
// ---------- scene look: gradient backdrop, shadows, vignette ----------
const bgColor=new THREE.Color(0x0a0e16);
function applyBackground(){ bgColor.set($('bg').value);
if($('gradBg')&&$('gradBg').checked){ const c=document.createElement('canvas'); c.width=4; c.height=256; const g=c.getContext('2d');
const gr=g.createLinearGradient(0,0,0,256); gr.addColorStop(0,bgColor.clone().multiplyScalar(0.45).getStyle()); gr.addColorStop(1,bgColor.clone().lerp(new THREE.Color(0xffffff),0.14).getStyle());
g.fillStyle=gr; g.fillRect(0,0,4,256); const t=new THREE.CanvasTexture(c); t.colorSpace=THREE.SRGBColorSpace; scene.background=t; }
else scene.background=bgColor.clone(); }
// ---------- Lighting panel: exposure, IBL, tone map, 3-point lights, finish ----------
const _TONE={ACES:THREE.ACESFilmicToneMapping, Neutral:(THREE.NeutralToneMapping||THREE.ACESFilmicToneMapping), Reinhard:THREE.ReinhardToneMapping, Cineon:THREE.CineonToneMapping, Linear:THREE.LinearToneMapping, None:THREE.NoToneMapping};
function _az2vec(az,el){ const a=az*Math.PI/180, e=el*Math.PI/180; return new THREE.Vector3(Math.cos(e)*Math.sin(a), Math.sin(e), Math.cos(e)*Math.cos(a)); }
function applyLights(){ if(!$('ltExposure')) return;
renderer.toneMappingExposure=+$('ltExposure').value;
const nt=_TONE[$('ltTone').value]||THREE.ACESFilmicToneMapping, toneChanged=(renderer.toneMapping!==nt); renderer.toneMapping=nt;
hemi.intensity=+$('ltAmb').value; hemi.color.set($('ltSky').value); hemi.groundColor.set($('ltGround').value);
key.intensity=+$('ltKey').value; key.color.set($('ltKeyCol').value);
if(!($('shadowsOn')&&$('shadowsOn').checked)) key.position.copy(_az2vec(+$('ltKeyAz').value,+$('ltKeyEl').value)); // shadows own the key position
fill.intensity=+$('ltFill').value; fill.color.set($('ltFillCol').value);
back.intensity=+$('ltBack').value; back.color.set($('ltBackCol').value);
const env=+$('ltEnv').value, fin=$('ltFinish').checked, ro=+$('ltRough').value, me=+$('ltMetal').value;
molGroup.traverse(o=>{ if(!o.material)return; (Array.isArray(o.material)?o.material:[o.material]).forEach(m=>{
if('envMapIntensity' in m) m.envMapIntensity=env;
if(fin && ('roughness' in m) && !m.isMeshMatcapMaterial && !m.isMeshToonMaterial){ m.roughness=ro; m.metalness=me; }
if(toneChanged) m.needsUpdate=true; }); }); }
const LIGHT_PRESETS={
studio: {ltExposure:1.05,ltEnv:1.0,ltTone:'ACES',ltAmb:1.0,ltSky:'#ffffff',ltGround:'#33404f',ltKey:1.6,ltKeyCol:'#ffffff',ltKeyAz:45,ltKeyEl:35,ltFill:0.5,ltFillCol:'#99bbff',ltBack:0.0,ltBackCol:'#ffffff'},
soft: {ltExposure:1.1, ltEnv:1.3,ltTone:'ACES',ltAmb:1.8,ltSky:'#eef4ff',ltGround:'#5a6470',ltKey:1.0,ltKeyCol:'#ffffff',ltKeyAz:40,ltKeyEl:55,ltFill:0.9,ltFillCol:'#cfe0ff',ltBack:0.2,ltBackCol:'#ffffff'},
dramatic:{ltExposure:1.0, ltEnv:0.6,ltTone:'ACES',ltAmb:0.25,ltSky:'#ffffff',ltGround:'#0a0e16',ltKey:2.8,ltKeyCol:'#fff4e6',ltKeyAz:60,ltKeyEl:25,ltFill:0.12,ltFillCol:'#5577aa',ltBack:0.6,ltBackCol:'#bcd4ff'},
rim: {ltExposure:1.0, ltEnv:0.8,ltTone:'ACES',ltAmb:0.5,ltSky:'#ffffff',ltGround:'#22303f',ltKey:1.1,ltKeyCol:'#ffffff',ltKeyAz:35,ltKeyEl:30,ltFill:0.3,ltFillCol:'#88aaff',ltBack:2.2,ltBackCol:'#eaf2ff'},
warm: {ltExposure:1.1, ltEnv:1.0,ltTone:'ACES',ltAmb:0.8,ltSky:'#ffe9cf',ltGround:'#3a2a1e',ltKey:2.0,ltKeyCol:'#ffd9a8',ltKeyAz:70,ltKeyEl:20,ltFill:0.5,ltFillCol:'#ff9e6b',ltBack:0.5,ltBackCol:'#ffd0a0'} };
function applyLightPreset(name){ const p=LIGHT_PRESETS[name]; if(!p)return; for(const id in p){ const el=$(id); if(!el)continue; el.value=p[id]; const v=$(id+'V'); if(v)v.textContent=(+p[id]).toFixed?( isNaN(+p[id])?p[id]:(+p[id]).toFixed(2)):p[id]; }
[...$('ltPreset').children].forEach(b=>b.classList.toggle('on',b.dataset.p===name)); applyLights(); }
function applyShadows(){ const on=$('shadowsOn')&&$('shadowsOn').checked; renderer.shadowMap.enabled=on; renderer.shadowMap.type=THREE.PCFSoftShadowMap; key.castShadow=on;
if(on){ const r=(molR||30); key.position.set(1,1,1).normalize().multiplyScalar(r*3); key.shadow.mapSize.set(2048,2048);
const c=key.shadow.camera; c.left=-r*1.4;c.right=r*1.4;c.top=r*1.4;c.bottom=-r*1.4;c.near=r*0.5;c.far=r*7; c.updateProjectionMatrix(); key.shadow.bias=-0.0007; }
else key.position.set(1,1,1);
molGroup.traverse(o=>{ if(o.isMesh){ o.castShadow=on; o.receiveShadow=on; if(o.material){(Array.isArray(o.material)?o.material:[o.material]).forEach(m=>m.needsUpdate=true);} } }); }
function compositeVignette(target){ if(!$('vignetteOn')||!$('vignetteOn').checked) return; const x=target.getContext('2d'), w=target.width, h=target.height;
const g=x.createRadialGradient(w/2,h/2,Math.min(w,h)*0.42, w/2,h/2,Math.max(w,h)*0.62); g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.55)'); x.fillStyle=g; x.fillRect(0,0,w,h); }
function updateLegend(){ const el=$('legend'); if(!el)return; if(!$('legendOn').checked){ el.classList.remove('on'); return; }
const c=legendCanvas(1); if(!c){ el.classList.remove('on'); return; } el.width=c.width; el.height=c.height; el.getContext('2d').clearRect(0,0,c.width,c.height); el.getContext('2d').drawImage(c,0,0); el.classList.add('on'); }
function compositeLegend(target, scale){ if(!$('legendOn')||!$('legendOn').checked) return; const c=legendCanvas(scale||1); if(!c) return; const x=target.getContext('2d'); x.drawImage(c, 10, target.height-c.height-10); }
function atomRad(a,rep){ const asc=+$('atomScale').value, br=+$('bondR').value;
if(rep==='stick') return br; // sticks: uniform thin (even DNA) — distinct from ball & stick
if(a.r!=null) return a.r*(rep==='space'?1.5:1.0); // custom (DNA) radius
if(rep==='space') return elVdw(a.el);
return elVdw(a.el)*asc; }
// HETATM ligands as ball&stick on top of a surface (populates sphereMesh/bondMesh → clearMesh disposes)
function buildLigands(){ const A=MOL.atoms, showH=$('showH').checked, br=+$('bondR').value;
const hideSolv=$('hideSolvent').checked;
const idx=[]; for(let i=0;i<A.length;i++){ const a=A[i]; if(a.het && (showH||a.el!=='H') && !(hideSolv&&isSolvent(a)) && !a.hidden) idx.push(i); }
if(!idx.length) return; const asc=+$('atomScale').value;
sphereMesh=new THREE.InstancedMesh(atomGeom(), makeMat(), idx.length); let n=0;
for(const i of idx){ const a=A[i], r=elVdw(a.el)*asc; _m.compose(_v.set(a.x,a.y,a.z),_q.identity(),_s.set(r,r,r)); sphereMesh.setMatrixAt(n,_m); sphereMesh.setColorAt(n,_c.set(atomColor(a))); pickList[n]=i; n++; }
sphereMesh.instanceMatrix.needsUpdate=true; if(sphereMesh.instanceColor)sphereMesh.instanceColor.needsUpdate=true; molGroup.add(sphereMesh);
const shown=new Set(idx), bs=MOL.bonds.filter(b=>shown.has(b[0])&&shown.has(b[1])); if(!bs.length) return;
bondMesh=new THREE.InstancedMesh(bondGeom(), makeMat(), bs.length*2); let b=0;
for(const bd of bs){ const a=A[bd[0]], c=A[bd[1]], rr=bd[2]!=null?bd[2]:br;
const ax=new THREE.Vector3(a.x,a.y,a.z), cxx=new THREE.Vector3(c.x,c.y,c.z), dir=cxx.clone().sub(ax), len=dir.length()/2; dir.normalize(); _q.setFromUnitVectors(_up,dir);
const mid=ax.clone().add(cxx).multiplyScalar(0.5);
_m.compose(ax.clone().add(dir.clone().multiplyScalar(len/2)),_q,_s.set(rr,len,rr)); bondMesh.setMatrixAt(b,_m); bondMesh.setColorAt(b,_c.set(atomColor(a))); b++;
_m.compose(mid.clone().add(dir.clone().multiplyScalar(len/2)),_q,_s.set(rr,len,rr)); bondMesh.setMatrixAt(b,_m); bondMesh.setColorAt(b,_c.set(atomColor(c))); b++; }
bondMesh.instanceMatrix.needsUpdate=true; if(bondMesh.instanceColor)bondMesh.instanceColor.needsUpdate=true; molGroup.add(bondMesh); }
// ---------- molecular surface (original: Gaussian atom-density field + naive Surface Nets) ----------
// Density f(p)=Σ exp(-AG·d²/r²); iso=exp(-AG) puts an isolated atom's surface at radius r (=vdW·scale+inflate).
function buildSurface(){ const AG=2.2, iso=Math.exp(-AG);
const hideSolv=$('hideSolvent').checked;
// surface is built over HEAVY atoms only (H barely affects the envelope and would otherwise dominate nearest-atom colouring)
let A=MOL.atoms.filter(a=> a.el!=='H' && !(hideSolv&&isSolvent(a)) && !a.hidden); if(!A.length) A=MOL.atoms.filter(a=>!(hideSolv&&isSolvent(a))); if(!A.length) return null;
const probe=+$('surfProbe').value, opac=+$('surfOpac').value;
const rad=A.map(a=> (a.r!=null? a.r*1.6 : elVdw(a.el)) + probe);
let mn=[1e9,1e9,1e9], mx=[-1e9,-1e9,-1e9], rmax=0;
for(let i=0;i<A.length;i++){ const a=A[i],r=rad[i]; if(r>rmax)rmax=r;
if(a.x<mn[0])mn[0]=a.x; if(a.y<mn[1])mn[1]=a.y; if(a.z<mn[2])mn[2]=a.z;
if(a.x>mx[0])mx[0]=a.x; if(a.y>mx[1])mx[1]=a.y; if(a.z>mx[2])mx[2]=a.z; }
const pad=rmax*1.5; for(let k=0;k<3;k++){ mn[k]-=pad; mx[k]+=pad; }
let sp=+$('surfQual').value; const NMAX=144;
const maxExt=Math.max(mx[0]-mn[0],mx[1]-mn[1],mx[2]-mn[2]);
sp=Math.min(sp, maxExt/64); // finer base grid (≥~64 cells across) so the envelope isn't blocky
const dimOf=k=>Math.ceil((mx[k]-mn[k])/sp)+1;
while(Math.max(dimOf(0),dimOf(1),dimOf(2))>NMAX) sp*=1.15;
const nx=dimOf(0),ny=dimOf(1),nz=dimOf(2), field=new Float32Array(nx*ny*nz);
const idx=(x,y,z)=>x+nx*(y+ny*z);
// splat each atom into its neighbourhood only (O(atoms·localcells))
for(let ai=0;ai<A.length;ai++){ const a=A[ai], r=rad[ai], r2=r*r, cut=r*Math.sqrt(6/AG), c2=cut*cut;
const x0=Math.max(0,Math.floor((a.x-cut-mn[0])/sp)), x1=Math.min(nx-1,Math.ceil((a.x+cut-mn[0])/sp));
const y0=Math.max(0,Math.floor((a.y-cut-mn[1])/sp)), y1=Math.min(ny-1,Math.ceil((a.y+cut-mn[1])/sp));
const z0=Math.max(0,Math.floor((a.z-cut-mn[2])/sp)), z1=Math.min(nz-1,Math.ceil((a.z+cut-mn[2])/sp));
for(let z=z0;z<=z1;z++){ const pz=mn[2]+z*sp, dz=pz-a.z; for(let y=y0;y<=y1;y++){ const py=mn[1]+y*sp, dy=py-a.y;
for(let x=x0;x<=x1;x++){ const px=mn[0]+x*sp, dx=px-a.x, d2=dx*dx+dy*dy+dz*dz; if(d2>c2) continue;
field[idx(x,y,z)]+=Math.exp(-AG*d2/r2); } } } }
// ---- naive Surface Nets ----
const R=[[0,0,0],[1,0,0],[1,1,0],[0,1,0],[0,0,1],[1,0,1],[1,1,1],[0,1,1]];
const ED=[[0,1],[1,2],[2,3],[3,0],[4,5],[5,6],[6,7],[7,4],[0,4],[1,5],[2,6],[3,7]];
const cx=nx-1,cy=ny-1,cz=nz-1, cVert=new Int32Array(cx*cy*cz).fill(-1), cIdx=(x,y,z)=>x+cx*(y+cy*z);
const pos=[];
for(let z=0;z<cz;z++)for(let y=0;y<cy;y++)for(let x=0;x<cx;x++){ let mask=0; const cv=[];
for(let i=0;i<8;i++){ const c=R[i], v=field[idx(x+c[0],y+c[1],z+c[2])]; cv.push(v); if(v<iso) mask|=(1<<i); }
if(mask===0||mask===255) continue;
let ex=0,ey=0,ez=0,cnt=0;
for(const [a,b] of ED){ const va=cv[a],vb=cv[b]; if((va<iso)===(vb<iso)) continue; const t=(iso-va)/(vb-va), P=R[a],Q=R[b];
ex+=P[0]+(Q[0]-P[0])*t; ey+=P[1]+(Q[1]-P[1])*t; ez+=P[2]+(Q[2]-P[2])*t; cnt++; }
cVert[cIdx(x,y,z)]=pos.length/3;
pos.push(mn[0]+(x+ex/cnt)*sp, mn[1]+(y+ey/cnt)*sp, mn[2]+(z+ez/cnt)*sp); }
if(!pos.length) return null;
const cell=(x,y,z)=>(x<0||y<0||z<0||x>=cx||y>=cy||z>=cz)?-1:cVert[cIdx(x,y,z)];
const tris=[], quad=(a,b,c,d)=>{ if(a<0||b<0||c<0||d<0) return; tris.push(a,b,c, a,c,d); };
// one quad per grid edge with a sign change (double-sided material → winding is irrelevant)
for(let z=0;z<nz;z++)for(let y=0;y<ny;y++)for(let x=0;x<nx;x++){ const in0=field[idx(x,y,z)]>=iso;
if(x+1<nx && (field[idx(x+1,y,z)]>=iso)!==in0) quad(cell(x,y-1,z-1),cell(x,y,z-1),cell(x,y,z),cell(x,y-1,z));
if(y+1<ny && (field[idx(x,y+1,z)]>=iso)!==in0) quad(cell(x-1,y,z-1),cell(x,y,z-1),cell(x,y,z),cell(x-1,y,z));
if(z+1<nz && (field[idx(x,y,z+1)]>=iso)!==in0) quad(cell(x-1,y-1,z),cell(x,y-1,z),cell(x,y,z),cell(x-1,y,z)); }
// Laplacian smoothing — Surface Nets is faceted at grid resolution. Relax POSITIONS *and* NORMALS toward neighbour
// averages: the per-atom metaball bumps live mostly in the (field-gradient) normals, so smoothing normals is what
// actually removes the "chunky" look. Adjacency (_smAdj) is reused for the normal pass below.
const _smN=pos.length/3, _smIt=Math.round(+($('surfSmooth')?$('surfSmooth').value:3)); let _smAdj=null;
if(_smIt>0 && _smN>3){ _smAdj=Array.from({length:_smN},()=>new Set());
for(let t=0;t<tris.length;t+=3){ const a=tris[t],b=tris[t+1],c=tris[t+2]; _smAdj[a].add(b);_smAdj[a].add(c);_smAdj[b].add(a);_smAdj[b].add(c);_smAdj[c].add(a);_smAdj[c].add(b); }
const lam=0.6; for(let it=0;it<_smIt;it++){ const np=pos.slice();
for(let v=0;v<_smN;v++){ const ns=_smAdj[v]; if(ns.size<3)continue; let sx=0,sy=0,sz=0; for(const j of ns){ sx+=pos[j*3];sy+=pos[j*3+1];sz+=pos[j*3+2]; } const k=ns.size;
np[v*3]+=lam*(sx/k-pos[v*3]); np[v*3+1]+=lam*(sy/k-pos[v*3+1]); np[v*3+2]+=lam*(sz/k-pos[v*3+2]); }
for(let i=0;i<pos.length;i++) pos[i]=np[i]; } }
const g=new THREE.BufferGeometry(); g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3)); g.setIndex(tris);
// analytic per-vertex NORMALS (−∇field, winding-independent → no speckle) + nearest-atom COLOUR, one grid pass
{ const gc=4.0, gh=new Map(), gk=(a,b,c)=>a+','+b+','+c;
for(let i=0;i<A.length;i++){ const a=A[i], k=gk(Math.floor(a.x/gc),Math.floor(a.y/gc),Math.floor(a.z/gc)); (gh.get(k)||gh.set(k,[]).get(k)).push(i); }
const nrm=new Float32Array(pos.length), doCol=(curCol!=='none'), col=doCol?new Float32Array(pos.length):null, tmp=new THREE.Color();
for(let v=0;v<pos.length;v+=3){ const px=pos[v],py=pos[v+1],pz=pos[v+2]; let best=-1,bd=1e9,nxg=0,nyg=0,nzg=0;
const gx=Math.floor(px/gc),gy=Math.floor(py/gc),gz=Math.floor(pz/gc);
for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++){ const arr=gh.get(gk(gx+dx,gy+dy,gz+dz)); if(!arr)continue;
for(const i of arr){ const a=A[i], ex=px-a.x,ey=py-a.y,ez=pz-a.z, dd=ex*ex+ey*ey+ez*ez, r2=rad[i]*rad[i], w=Math.exp(-AG*dd/r2)/r2;
nxg+=w*ex; nyg+=w*ey; nzg+=w*ez; if(dd<bd){bd=dd;best=i;} } }
let ln=Math.hypot(nxg,nyg,nzg)||1; nrm[v]=nxg/ln; nrm[v+1]=nyg/ln; nrm[v+2]=nzg/ln; // outward = away from atoms
if(doCol){ if(best<0){ for(let i=0;i<A.length;i++){ const a=A[i], dd=(a.x-px)**2+(a.y-py)**2+(a.z-pz)**2; if(dd<bd){bd=dd;best=i;} } }
tmp.set(atomColor(A[best])); col[v]=tmp.r; col[v+1]=tmp.g; col[v+2]=tmp.b; } }
if(_smAdj){ for(let it=0;it<_smIt;it++){ const nn=nrm.slice(); // smooth the shading normals → blurs per-atom bumps into a smooth envelope
for(let v=0;v<_smN;v++){ const ns=_smAdj[v]; if(ns.size<3)continue; let sx=0,sy=0,sz=0; for(const j of ns){ sx+=nrm[j*3];sy+=nrm[j*3+1];sz+=nrm[j*3+2]; } const k=ns.size;
let lx=nrm[v*3]+0.75*(sx/k-nrm[v*3]), ly=nrm[v*3+1]+0.75*(sy/k-nrm[v*3+1]), lz=nrm[v*3+2]+0.75*(sz/k-nrm[v*3+2]); const l=Math.hypot(lx,ly,lz)||1; nn[v*3]=lx/l; nn[v*3+1]=ly/l; nn[v*3+2]=lz/l; }
nrm.set(nn); } }
g.setAttribute('normal',new THREE.Float32BufferAttribute(nrm,3));
if(doCol) g.setAttribute('color',new THREE.Float32BufferAttribute(col,3)); }
const mat=makeMat(); mat.side=THREE.DoubleSide; mat.flatShading=false;
if(curCol!=='none') mat.vertexColors=true; else mat.color.set('#8fd0ff');
if(opac<1){ mat.transparent=true; mat.opacity=opac; }
return new THREE.Mesh(g, mat); }
// ---------- atomic / electron view (educational Bohr model: nucleus + shells + shared/lone pairs) ----------
function bohrShells(Z){ let rem=Z, s=[], n=1; while(rem>0){ const cap=2*n*n, c=Math.min(cap,rem); s.push(c); rem-=c; n++; } return s; } // 2,8,18,… (light-atom teaching model)
const _perp=a=>{ const t=Math.abs(a.x)<0.9?new THREE.Vector3(1,0,0):new THREE.Vector3(0,1,0); const u=new THREE.Vector3().crossVectors(a,t).normalize(); const v=new THREE.Vector3().crossVectors(a,u).normalize(); return [u,v]; };
function addOrbit(grp,c,axis,r,electrons,speed,eMat,meta){ const [u,v]=_perp(axis.clone().normalize());
if($('atShells').checked){ const ring=new THREE.Mesh(new THREE.TorusGeometry(r,0.018*r+0.01,6,48), new THREE.MeshBasicMaterial({color:0x2d4a6a,transparent:true,opacity:0.6}));
const m=new THREE.Matrix4().makeBasis(u,v,axis.clone().normalize()); m.setPosition(c); ring.applyMatrix4(m); grp.add(ring); }
const es=[]; for(let i=0;i<electrons;i++){ const e=new THREE.Mesh(new THREE.SphereGeometry(0.12,12,10), eMat); e.userData=Object.assign({kind:'e'},meta||{}); grp.add(e); es.push({mesh:e, ph:i/electrons*Math.PI*2, sp:speed}); }
electronOrbits.push({c:c.clone(), u, v, r, es}); }
// lone-pair lobes (VSEPR): teardrop clouds pointing away from the bonds, each holding 2 electrons
const _lobeGeo=new THREE.SphereGeometry(1,16,12), _zAx=new THREE.Vector3(0,0,1);
function lobeDirs(bondDirs, L){ const anti=new THREE.Vector3(); bondDirs.forEach(d=>anti.add(d));
if(anti.lengthSq()<1e-6) anti.set(0,1,0); else anti.negate().normalize();
if(L<=1) return [anti];
let perp = bondDirs.length>=2 ? new THREE.Vector3().crossVectors(bondDirs[0],bondDirs[1]) : new THREE.Vector3();
if(perp.lengthSq()<1e-4) perp.crossVectors(anti, Math.abs(anti.y)<0.9?new THREE.Vector3(0,1,0):new THREE.Vector3(1,0,0));
perp.normalize();
if(L===2) return [anti.clone().applyAxisAngle(perp, 0.96), anti.clone().applyAxisAngle(perp,-0.96)]; // ~55° apart
const out=[]; for(let k=0;k<L;k++) out.push(anti.clone().applyAxisAngle(perp,0.9).applyAxisAngle(anti,k/L*Math.PI*2)); return out; }
function addLobe(grp,center,dir,NS,eMat,meta){ dir=dir.clone().normalize(); const len=1.15*NS, wid=0.5*NS;
const m=new THREE.Mesh(_lobeGeo, new THREE.MeshStandardMaterial({color:0x9fd0ff,transparent:true,opacity:0.22,roughness:0.6,emissive:0x24406e,emissiveIntensity:0.4,depthWrite:false}));
m.quaternion.setFromUnitVectors(_zAx, dir); m.scale.set(wid,wid,len); m.position.copy(center).addScaledVector(dir, 0.9*NS+len*0.5); m.userData=Object.assign({kind:'lobe'},meta||{}); grp.add(m);
for(let e=0;e<2;e++){ const s=new THREE.Mesh(new THREE.SphereGeometry(0.12,12,10), eMat); s.position.copy(m.position).add(new THREE.Vector3(0.18*NS*(e?1:-1),0,0).applyQuaternion(m.quaternion)); s.userData=Object.assign({kind:'e'},meta||{}); grp.add(s); } }
function buildAtomic(){ atomicGroup=new THREE.Group(); electronOrbits=[]; const A=MOL.atoms, hideSolv=$('hideSolvent').checked, showH=$('showH').checked;
const SP=+$('atSpread').value, NS=+$('atNuc').value, ESP=+$('atESpeed').value;
const idx=[]; for(let i=0;i<A.length;i++){ const a=A[i]; if(!showH&&a.el==='H')continue; if(hideSolv&&isSolvent(a))continue; if(a.hidden)continue; if(ELEM[(a.el||'').replace('BASE_','')]) idx.push(i); }
if(!idx.length){ $('status').textContent='atomic view needs simple elements (H,C,N,O…)'; molGroup.add(atomicGroup); return; }
const eMat=new THREE.MeshStandardMaterial({color:0x7ecbff,emissive:0x2a6bd0,emissiveIntensity:0.9,roughness:0.4});
const pMat=new THREE.MeshStandardMaterial({color:0xff4a4a,emissive:0x501010,emissiveIntensity:0.35,roughness:0.5});
const nMat=new THREE.MeshStandardMaterial({color:0xb8bcc4,roughness:0.6});
const P=i=>new THREE.Vector3(A[i].x*SP,A[i].y*SP,A[i].z*SP);
// bonds among shown atoms
const shown=new Set(idx), bondsOf={}; idx.forEach(i=>bondsOf[i]=0);
const useBonds=MOL.bonds.filter(b=>shown.has(b[0])&&shown.has(b[1])); useBonds.forEach(b=>{bondsOf[b[0]]++;bondsOf[b[1]]++;});
for(const i of idx){ const a=A[i], sym=(a.el||'').replace('BASE_',''), e=ELEM[sym], Z=e[1], mass=e[2], N=Math.max(0,Math.round(mass)-Z), pos=P(i);
// nucleus: pack Z protons + N neutrons (fibonacci sphere), protons/neutrons interleaved
const T=Z+N, rn=(0.16+0.06*Math.cbrt(T))*NS, nucR=0.5*rn, ga=Math.PI*(3-Math.sqrt(5));
const nuc=new THREE.InstancedMesh(nucleonGeom(), new THREE.MeshStandardMaterial({roughness:0.5,vertexColors:false}), T); // 'Atom shape' also styles the nucleons
nuc.material.dispose(); const nucMat=new THREE.MeshStandardMaterial({roughness:0.5}); nuc.material=nucMat;
const _mm=new THREE.Matrix4(), _cc=new THREE.Color(); let pc=0,nc=0; const ntypes=[];
for(let k=0;k<T;k++){ const y=T>1?1-2*(k+0.5)/T:0, rr=Math.sqrt(Math.max(0,1-y*y)), th=k*ga, rad=rn*(0.35+0.65*Math.cbrt((k+1)/T));
const px=pos.x+rad*rr*Math.cos(th), py=pos.y+rad*y, pz=pos.z+rad*rr*Math.sin(th);
_mm.makeScale(nucR,nucR,nucR); _mm.setPosition(px,py,pz); nuc.setMatrixAt(k,_mm);
const proton=(k%2===0 && pc<Z)|| nc>=N; if(proton){ _cc.set('#ff4a4a'); pc++; ntypes.push('p'); } else { _cc.set('#b8bcc4'); nc++; ntypes.push('n'); } nuc.setColorAt(k,_cc); }
nuc.instanceMatrix.needsUpdate=true; if(nuc.instanceColor)nuc.instanceColor.needsUpdate=true;
nuc.userData={kind:'nuc', atom:i, sym, Z, N, types:ntypes}; atomicGroup.add(nuc); // pickable: click a nucleon → proton/neutron info
// shells: core shells drawn full; valence shell holds only the NON-bonding (lone) electrons
const sh=bohrShells(Z), nb=bondsOf[i]||0, valIdx=sh.length-1, lobesOn=$('atLobes')&&$('atLobes').checked;
const nbDirs=[]; for(const b of useBonds){ if(b[0]===i)nbDirs.push(P(b[1]).sub(pos).normalize()); else if(b[1]===i)nbDirs.push(P(b[0]).sub(pos).normalize()); }
for(let s=0;s<sh.length;s++){ let cnt=sh[s]; if(s===valIdx) cnt=Math.max(0, sh[s]-Math.min(nb,sh[s])); // bonding e⁻ move into shared pairs
if(cnt<=0 && s===valIdx) continue;
if(s===valIdx && lobesOn && cnt>=2){ const L=Math.floor(cnt/2), dirs=lobeDirs(nbDirs,L); dirs.forEach(d=>addLobe(atomicGroup,pos,d,NS,eMat,{atom:i,sym,lone:true})); // lone pairs → VSEPR lobes
const rem=cnt-2*L; if(rem>0){ const ax=new THREE.Vector3(Math.sin(valIdx*1.3),Math.cos(valIdx*2.1),0.4).normalize(); addOrbit(atomicGroup,pos,ax,rn+(0.9+0.7*valIdx)*NS*0.9,rem,ESP*(1.6/(valIdx+1)),eMat,{atom:i,sym,shell:valIdx+1}); } continue; }
const axis=new THREE.Vector3(Math.sin(s*1.3),Math.cos(s*2.1),Math.sin(s*0.7)+0.4).normalize();
addOrbit(atomicGroup,pos,axis,rn+ (0.9+0.7*s)*NS*0.9, cnt, ESP*(1.6/(s+1)), eMat, {atom:i,sym,shell:s+1}); }
}