-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathindex.html
More file actions
2911 lines (2690 loc) · 121 KB
/
Copy pathindex.html
File metadata and controls
2911 lines (2690 loc) · 121 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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>holision</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='4' fill='%2308080c'/%3E%3Cpolygon points='16,4 28,16 16,28 4,16' fill='none' stroke='%23089BDF' stroke-width='2'/%3E%3Cline x1='4' y1='16' x2='28' y2='16' stroke='%23089BDF' stroke-width='1.5' opacity='.5'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #08080c; }
canvas { display: block; }
#intro-overlay {
position: fixed;
inset: 0;
background: #08080c;
z-index: 100;
pointer-events: none;
transition: opacity 2.0s ease-out;
opacity: 1;
}
#intro-overlay.fade { opacity: 0; }
#intro-overlay.fade #load-boxes { display: none; }
#load-boxes {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
display: flex; gap: 4px; align-items: center;
}
#tile-loader {
position: fixed;
bottom: 18px; left: 50%;
transform: translateX(-50%);
display: none; gap: 4px; align-items: center;
z-index: 50;
}
#tile-loader.active { display: flex; }
.lb {
width: 9px; height: 9px;
border: 1px solid rgba(8,155,223,0.6);
background: rgba(8,155,223,0.45);
opacity: 0;
will-change: opacity;
animation: lb-seq 0.8s linear infinite;
}
.lb:nth-child(1) { animation-delay: 0s; }
.lb:nth-child(2) { animation-delay: -0.72s; }
.lb:nth-child(3) { animation-delay: -0.64s; }
.lb:nth-child(4) { animation-delay: -0.56s; }
.lb:nth-child(5) { animation-delay: -0.48s; }
@keyframes lb-seq {
0% { opacity: 1; }
10% { opacity: 0.4; }
20% { opacity: 0.1; }
30% { opacity: 0; }
90% { opacity: 0; }
100%{ opacity: 1; }
}
/* util strip */
.util-strip {
position:fixed; bottom:10px; right:10px;
display:flex; gap:6px; align-items:center;
pointer-events:all;
z-index:65;
}
.util-btn {
font-size:12px; color:rgba(255,255,255,0.75);
border:1px solid rgba(255,255,255,0.18); background:rgba(255,255,255,0.06);
padding:4px 9px; border-radius:2px; cursor:pointer; font-family:monospace;
transition:all 0.15s;
}
.util-btn.active { color:#fff; background:rgba(8,60,100,0.6); border-color:#089BDF; }
@keyframes cam-pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
.util-btn.loading svg { animation: cam-pulse 0.8s ease-in-out infinite; }
/* panel */
.panel-zone {
position:fixed; bottom:42px; right:10px;
pointer-events:all;
z-index:50;
}
.panel-zone.hidden { display:none; }
.fp {
padding:8px 10px;
background:rgba(8,8,12,0.96);
border:1px solid rgba(255,255,255,0.09);
border-radius:3px;
display:flex; flex-direction:column; gap:12px;
}
.fp-title { font-size:9px; letter-spacing:0.12em; text-transform:uppercase; color:rgba(255,255,255,0.18); }
.fp-row { display:flex; gap:14px; align-items:center; min-height:28px; }
.grp { display:flex; flex-direction:column; gap:6px; }
.fc { display:flex; flex-direction:column; gap:4px; align-items:center; }
.fc-lbl { font-family:'Share Tech', sans-serif; font-size:10px; font-weight:700; color:rgba(255,255,255,0.3); letter-spacing:0.04em; text-transform:uppercase; white-space:nowrap; }
.fc-val { font-size:11px; color:rgba(255,255,255,0.6); }
.fc-val.cv { color:rgba(255,150,150,0.8); }
.fc-val.fv { color:rgba(90,200,255,0.8); }
/* custom slider row */
.csr {
position:relative; height:28px;
display:flex; align-items:center; padding:0 10px;
border:1px solid rgba(255,255,255,0.08); border-radius:2px;
overflow:hidden; cursor:pointer; user-select:none;
}
.csr-fill {
position:absolute; left:4px; top:4px; bottom:4px;
background:rgba(255,255,255,0.06); border-radius:1px;
border-right:1px solid rgba(255,255,255,0.22);
pointer-events:none;
}
.csr.cut-sl .csr-fill { background:rgba(255,60,60,0.08); border-right-color:rgba(255,120,120,0.45); }
.csr.fill-sl .csr-fill { background:rgba(8,155,223,0.08); border-right-color:rgba(90,200,255,0.45); }
.csr:hover .csr-fill { background:rgba(255,255,255,0.11); border-right-width:2px; border-right-color:rgba(255,255,255,0.45); }
.csr.cut-sl:hover .csr-fill { background:rgba(255,60,60,0.14); border-right-color:rgba(255,120,120,0.75); }
.csr.fill-sl:hover .csr-fill { background:rgba(8,155,223,0.14); border-right-color:rgba(90,200,255,0.75); }
.csr-label {
position:relative; z-index:1; flex:1;
font-family:'Share Tech', sans-serif; font-size:10px; font-weight:700;
letter-spacing:0.04em; text-transform:uppercase;
color:rgba(255,255,255,0.3); pointer-events:none;
}
.csr-val {
position:relative; z-index:1;
font-family:'Share Tech Mono', monospace; font-size:11px;
color:rgba(255,255,255,0.6); pointer-events:none;
}
.csr.cut-sl .csr-val { color:rgba(255,150,150,0.8); }
.csr.fill-sl .csr-val { color:rgba(90,200,255,0.8); }
.csr input[type=range] {
position:absolute; inset:0; opacity:0;
margin:0; cursor:pointer; z-index:2;
}
/* number input */
.ni {
width:40px; font-family:'Share Tech Mono', monospace; font-size:11px;
background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1);
color:rgba(255,255,255,0.6); padding:3px 5px; border-radius:2px; text-align:center;
-moz-appearance:textfield; outline:none;
}
.ni::-webkit-outer-spin-button, .ni::-webkit-inner-spin-button { opacity:0.3; }
/* color swatch */
.cs { width:18px; height:18px; border-radius:2px; border:1px solid rgba(255,255,255,0.15); cursor:pointer; flex-shrink:0; -webkit-appearance:none; padding:0; }
.cs::-webkit-color-swatch-wrapper { padding:0; }
.cs::-webkit-color-swatch { border:none; border-radius:1px; }
.csr .cs { position:relative; z-index:3; width:14px; height:14px; margin-right:7px; border-radius:2px; }
/* inline cluster */
.cl { display:flex; align-items:center; gap:5px; }
.cl-lbl { font-family:'Share Tech', sans-serif; font-size:10px; font-weight:700; letter-spacing:0.04em; text-transform:uppercase; color:rgba(255,255,255,0.25); white-space:nowrap; }
/* major buttons */
.maj-btns { display:flex; gap:3px; }
.maj { font-size:10px; padding:2px 6px; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.04); color:rgba(255,255,255,0.3); border-radius:2px; cursor:pointer; font-family:'Share Tech Mono', monospace; transition:all 0.15s; }
.maj.active { border-color:rgba(255,255,255,0.3); background:rgba(255,255,255,0.1); color:rgba(255,255,255,0.8); }
/* site picker */
.site-sel { flex:1; font-size:11px; padding:4px 8px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.05); color:rgba(255,255,255,0.85); border-radius:2px; font-family:'Share Tech Mono', monospace; outline:none; transition:all 0.15s; }
.site-sel:hover, .site-sel:focus { border-color:rgba(255,255,255,0.3); background:rgba(255,255,255,0.08); }
.latlon-input { border:none; background:transparent; outline:none; font-size:11px; font-family:'Share Tech Mono', monospace; color:rgba(255,255,255,0.6); text-align:right; width:100%; cursor:text; }
.latlon-input:focus { color:rgba(255,255,255,0.9); }
.site-sel option { background:#0b0d10; color:#cfd2d6; }
.site-sel.loading, .site-sel:disabled { opacity:0.4; pointer-events:none; }
.latlon-input:disabled { opacity:0.35; pointer-events:none; }
.csr.webcam-disabled { opacity:0.35; pointer-events:none; }
/* pan compass */
.pan-grid { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:2px 0; }
.nav-info { flex:1; }
.nav-desc { font-size:10px; font-family:'Share Tech', sans-serif; color:rgba(255,255,255,0.25); margin:4px 0 0; line-height:1.4; }
.compass-rose { width:92px; height:55px; flex-shrink:0; }
.compass-rose .pan { cursor:pointer; }
.compass-rose .pan .fin { fill:#17171A; stroke:#313134; stroke-width:2; }
.compass-rose .pan:hover .fin { stroke:#676767; }
.compass-rose .pan:active .fin { fill:#191929; stroke:#2EBDFF; }
.compass-rose .pan .ltr { fill:white; opacity:0.4; pointer-events:none; }
.compass-rose .pan:active .ltr { opacity:1; }
.compass-rose .pan.disabled { opacity:0.2; pointer-events:none; }
.pan-readout { font-size:10px; font-family:'Share Tech Mono', monospace; color:rgba(255,255,255,0.4); line-height:1.5; white-space:pre; }
/* toggles */
.toggles { display:flex; gap:5px; flex-wrap:wrap; }
.tog-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:5px; }
.tog-grid .tog { text-align:center; padding:4px 6px; }
.tog { font-size:11px; padding:4px 11px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.05); color:rgba(255,255,255,0.35); border-radius:2px; cursor:pointer; white-space:nowrap; font-family:'Share Tech', sans-serif; font-weight:700; letter-spacing:0.04em; transition:all 0.15s; }
.tog[data-mode="cut"].active { background:rgba(245,20,20,0.1); border-color:rgba(255,80,80,0.5); color:rgba(255,150,150,0.9); }
.tog[data-mode="fill"].active { background:rgba(8,155,223,0.1); border-color:rgba(8,155,223,0.5); color:rgba(90,200,255,0.9); }
.tog[data-mode="ann"].active,
.tog[data-mode="lines"].active,
.tog[data-mode="contour"].active { background:rgba(255,255,255,0.12); border-color:rgba(255,255,255,0.35); color:rgba(255,255,255,0.85); }
.tog.suppressed { opacity:0.35; pointer-events:none; }
.tog[data-mode="sat"].active { background:rgba(60,180,100,0.1); border-color:rgba(60,200,100,0.5); color:rgba(120,230,150,0.9); }
/* ctrl button */
.ctrl-btn {
font-family:'Share Tech', sans-serif; font-size:10px; font-weight:700; letter-spacing:0.08em; text-transform:uppercase;
color:rgba(255,255,255,0.5); background:transparent;
border:1px solid rgba(255,255,255,0.15); border-radius:2px;
padding:6px 14px; cursor:pointer; transition:all 0.15s;
}
.ctrl-btn.active { color:#fff; background:rgba(8,155,223,0.12); border-color:rgba(8,155,223,0.6); text-shadow:0 0 8px rgba(8,155,223,0.6); }
.ann-wrap {
position: relative;
width: 0; height: 0;
pointer-events: none;
}
.ann-label {
position: absolute;
left: 0; top: 50%;
transform: translate(10px, -50%);
color: rgba(255,255,255,0.85);
font-size: 11px;
letter-spacing: 0.05em;
white-space: nowrap;
pointer-events: none;
transition: opacity 0.2s cubic-bezier(0,0,0.2,1);
border: 1px solid rgba(255,255,255,0.7);
padding: 2px 6px;
background: rgba(0,0,0,0.12);
backdrop-filter: blur(6px);
border-radius: 0;
}
.ann-label-left {
position: absolute;
right: 0; top: 50%;
transform: translate(-10px, -50%);
color: rgba(255,255,255,0.85);
font-size: 11px;
letter-spacing: 0.05em;
white-space: nowrap;
pointer-events: none;
transition: opacity 0.2s cubic-bezier(0,0,0.2,1);
border: 1px solid rgba(255,255,255,0.7);
padding: 2px 6px;
background: rgba(0,0,0,0.12);
backdrop-filter: blur(6px);
border-radius: 0;
}
.ann-label-top {
position: absolute;
left: 50%; top: 0;
transform: translate(-50%, calc(-100% - 6px));
color: rgba(255,255,255,0.85);
font-size: 11px;
letter-spacing: 0.05em;
white-space: nowrap;
pointer-events: none;
transition: opacity 0.2s cubic-bezier(0,0,0.2,1);
border: 1px solid rgba(255,80,80,0.9);
padding: 2px 6px;
background: rgba(0,0,0,0.12);
backdrop-filter: blur(6px);
border-radius: 0;
}
.ann-label-bottom {
position: absolute;
left: 50%; top: 0;
transform: translate(-50%, 6px);
color: rgba(255,255,255,0.85);
font-size: 11px;
letter-spacing: 0.05em;
white-space: nowrap;
pointer-events: none;
transition: opacity 0.2s cubic-bezier(0,0,0.2,1);
border: 1px solid rgba(8,155,223,0.8);
padding: 2px 6px;
background: rgba(0,0,0,0.12);
backdrop-filter: blur(6px);
border-radius: 0;
}
.ann-lbl { font-family:'Share Tech', sans-serif; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; }
.ann-val { font-family:'Share Tech Mono', monospace; margin-left:5px; letter-spacing:-0.03em; }
.dir-label {
font-family:'Share Tech', sans-serif; font-weight:700; font-size:11px;
letter-spacing:0.1em; text-transform:uppercase;
padding:2px 6px; border:1px solid rgba(255,255,255,0.45);
color:rgba(255,255,255,0.7); background:rgba(0,0,0,0.12);
backdrop-filter:blur(6px); pointer-events:none;
transition:opacity 0.2s cubic-bezier(0,0,0.2,1);
}
.dir-label.north { border-color:rgba(8,155,223,0.8); color:rgba(90,200,255,0.9); }
.contour-label {
position: absolute;
left: 0; top: 50%;
transform: translate(4px, -50%);
color: rgba(255,255,255,0.5);
font-family: monospace;
font-size: 10px;
letter-spacing: 0.03em;
white-space: nowrap;
pointer-events: none;
transition: opacity 0.2s cubic-bezier(0,0,0.2,1);
}
.ann-label-net {
position: absolute;
left: 0; top: 0;
transform: translate(-50%, -50%);
color: rgba(255,255,255,0.9);
font-size: 11px;
letter-spacing: 0.05em;
white-space: nowrap;
pointer-events: none;
transition: opacity 0.2s cubic-bezier(0,0,0.2,1);
border: 1px solid rgba(255,255,255,0.7);
padding: 2px 6px;
background: rgba(0,0,0,0.25);
backdrop-filter: blur(6px);
border-radius: 0;
}
.credit {
position:fixed; bottom:10px; left:10px;
font-family:'Share Tech Mono', monospace; font-size:10px; letter-spacing:-0.05em;
color:rgba(255,255,255,0.15); text-decoration:none;
transition:color 0.2s; pointer-events:all;
}
.credit:hover { color:rgba(255,255,255,0.4); }
#geo-hud {
position:fixed; bottom:48px; left:50%;
transform:translateX(-50%);
text-align:center; pointer-events:none; z-index:50;
display:flex; flex-direction:column; align-items:center; gap:3px;
}
#geo-hud .hud-name {
font-family:'Share Tech', sans-serif; font-size:14px;
font-weight:700; letter-spacing:0.14em; text-transform:uppercase;
color:rgba(255,255,255,0.72); white-space:nowrap;
}
#geo-hud .hud-coords {
font-family:'Share Tech Mono', monospace; font-size:10px;
letter-spacing:0.08em; color:rgba(255,255,255,0.3); white-space:nowrap;
}
/* responsive panel */
.fp { max-height: calc(100vh - 60px); overflow-y: auto; scrollbar-width: none; }
.fp::-webkit-scrollbar { display: none; }
.panel-zone { transform-origin: bottom right; }
@media (min-width: 769px) and (max-width: 1100px) { .panel-zone { transform: scale(0.88); } }
/* tablet + mobile: bottom sheet */
@media (max-width: 768px) {
.util-strip { top: 10px; bottom: auto; gap: 8px; }
#anim-btn { display: none; }
.util-btn {
font-size: 16px; padding: 10px 14px;
min-width: 44px; min-height: 44px;
display: flex; align-items: center; justify-content: center;
}
.ctrl-btn {
font-size: 12px; padding: 10px 18px;
min-height: 44px; letter-spacing: 0.1em;
}
.site-sel { min-height: 44px; font-size: 13px; }
.panel-zone {
transform: translateY(0);
bottom: 0; left: 0; right: 0;
transition: transform 0.32s cubic-bezier(0.32, 0.72, 0, 1);
}
.panel-zone.hidden { display: block !important; transform: translateY(100%) !important; }
.fp {
border-radius: 14px 14px 0 0;
max-height: 65vh; width: 100%; box-sizing: border-box;
padding-top: 20px;
}
.fp::before {
content: ''; display: block;
width: 36px; height: 4px;
background: rgba(255,255,255,0.2); border-radius: 2px;
margin: -12px auto 12px;
}
}
@media (max-width: 768px) and (max-height: 500px) { .fp { max-height: 80vh; } }
.export-sheet {
position: fixed; bottom: 0; left: 0; right: 0;
z-index: 60;
transition: transform 0.32s cubic-bezier(0.32, 0.72, 0, 1);
}
.export-sheet.hidden { transform: translateY(100%); }
.export-fp {
border-radius: 14px 14px 0 0;
width: 100%; box-sizing: border-box;
max-width: 480px; margin: 0 auto;
}
.export-opt {
display: flex; align-items: center; gap: 10px;
width: 100%; padding: 10px 12px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
color: rgba(255,255,255,0.75); cursor: pointer;
font-family: 'Share Tech', sans-serif; font-size: 12px;
letter-spacing: 0.04em; text-align: left;
transition: background 0.15s, border-color 0.15s;
}
.export-opt:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.2); }
.export-opt svg { flex-shrink: 0; opacity: 0.6; }
.export-opt-name { font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: rgba(255,255,255,0.85); }
.export-opt-desc { font-size: 10px; color: rgba(255,255,255,0.3); margin-left: auto; letter-spacing: 0.05em; }
</style>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="intro-overlay">
<div id="load-boxes">
<div class="lb"></div>
<div class="lb"></div>
<div class="lb"></div>
<div class="lb"></div>
<div class="lb"></div>
</div>
</div>
<div id="export-sheet" class="export-sheet hidden">
<div class="fp export-fp">
<div class="grp">
<button id="export-png" class="export-opt"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v13M6 11l6 6 6-6M3 21h18"/></svg><span class="export-opt-name">PNG</span><span class="export-opt-desc">Screenshot</span></button>
<button id="export-dxf" class="export-opt"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v13M6 11l6 6 6-6M3 21h18"/></svg><span class="export-opt-name">DXF</span><span class="export-opt-desc">Contours — Blender / AutoCAD</span></button>
<button id="export-geojson" class="export-opt"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v13M6 11l6 6 6-6M3 21h18"/></svg><span class="export-opt-name">GeoJSON</span><span class="export-opt-desc">Contours — Mapbox / QGIS</span></button>
</div>
</div>
</div>
<div id="tile-loader">
<div class="lb"></div>
<div class="lb"></div>
<div class="lb"></div>
<div class="lb"></div>
<div class="lb"></div>
</div>
<a class="credit" href="https://github.com/arshlibruh-code/holision" target="_blank" rel="noopener">@arshlibruh / holision</a>
<div id="geo-hud"></div>
<div class="util-strip">
<button id="anim-btn" class="util-btn" title="animate base Y">▶</button>
<button id="spin-btn" class="util-btn" title="auto rotate">⟳</button>
<button id="export-btn" class="ctrl-btn">Export</button>
<button class="ctrl-btn active" id="ctrl-toggle">Controls</button>
</div>
<div class="panel-zone" id="ctrl-panel">
<div class="fp">
<div class="grp">
<div class="fp-row" style="align-items:center;">
<div class="fc-lbl">Site</div>
<select id="site-select" class="site-sel">
<option value="bingham">Bingham Canyon Mine</option>
<option value="grand-canyon">Grand Canyon</option>
<option value="everest">Mount Everest</option>
<option value="matterhorn">Matterhorn</option>
<option value="mont-blanc">Mont Blanc</option>
<option value="half-dome">Half Dome / Yosemite</option>
<option value="mount-fuji">Mount Fuji</option>
<option value="kilimanjaro">Kilimanjaro</option>
<option value="vesuvius">Vesuvius</option>
<option value="death-valley">Death Valley</option>
<option value="hvannadalshnjukur">Hvannadalshnjúkur</option>
<option value="custom">Custom…</option>
</select>
<button id="loc-btn" class="util-btn" title="use camera for live depth">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
</button>
</div>
<div class="csr" id="site-custom-row">
<span class="csr-label">Lat, Lon</span>
<input type="text" id="site-latlon" placeholder="40.52, -112.15" class="latlon-input">
</div>
<div class="csr">
<div class="csr-fill"></div>
<span class="csr-label">Zoom</span>
<span class="csr-val" id="zoom-label">13</span>
<input type="range" id="zoom-slider" min="8" max="14" step="1" value="13">
</div>
<div class="csr">
<div class="csr-fill"></div>
<span class="csr-label">Exaggeration</span>
<span class="csr-val" id="exag-label">1.0×</span>
<input type="range" id="exag-slider" min="0.5" max="5" step="0.1" value="1">
</div>
<div class="pan-grid">
<div class="nav-info">
<p class="nav-desc">Shift terrain one tile<br>in any cardinal direction</p>
</div>
<svg class="compass-rose" viewBox="0 0 92 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<g class="pan pan-up">
<path class="fin" fill-rule="evenodd" clip-rule="evenodd" d="M45.9141 1.41406L47.4141 2.91406L60.9141 16.4141L62.4141 17.9141V25.4141H47.4141H44.4141H29.4141V17.9141L30.9141 16.4141L44.4141 2.91406L45.9141 1.41406Z"/>
<path class="ltr" d="M47.7939 10.4141V15.6504H46.8376L44.5595 12.3547H44.5212V15.6504H43.4141V10.4141H44.3857L46.6459 13.7072H46.6919V10.4141H47.7939Z"/>
</g>
<g class="pan pan-down">
<path class="fin" fill-rule="evenodd" clip-rule="evenodd" d="M45.9141 53.4141L47.4141 51.9141L60.9141 38.4141L62.4141 36.9141V29.4141H47.4141H44.4141H29.4141V36.9141L30.9141 38.4141L44.4141 51.9141L45.9141 53.4141Z"/>
<path class="ltr" d="M46.4234 39.9916C46.403 39.7854 46.3152 39.6251 46.1601 39.5109C46.005 39.3967 45.7945 39.3396 45.5286 39.3396C45.3479 39.3396 45.1953 39.3652 45.0709 39.4163C44.9464 39.4658 44.851 39.5348 44.7845 39.6234C44.7197 39.7121 44.6874 39.8126 44.6874 39.9251C44.6839 40.0189 44.7036 40.1007 44.7462 40.1706C44.7905 40.2405 44.851 40.301 44.9277 40.3521C45.0044 40.4016 45.093 40.445 45.1936 40.4825C45.2942 40.5183 45.4016 40.549 45.5158 40.5746L45.9862 40.6871C46.2146 40.7382 46.4243 40.8064 46.6152 40.8916C46.8061 40.9768 46.9714 41.0817 47.1112 41.2061C47.251 41.3305 47.3592 41.4771 47.4359 41.6459C47.5143 41.8146 47.5544 42.0081 47.5561 42.2263C47.5544 42.5467 47.4726 42.8246 47.3107 43.0598C47.1504 43.2933 46.9186 43.4749 46.6152 43.6044C46.3135 43.7322 45.9496 43.7962 45.5234 43.7962C45.1007 43.7962 44.7325 43.7314 44.4189 43.6018C44.107 43.4723 43.8632 43.2805 43.6876 43.0266C43.5138 42.7709 43.4226 42.4547 43.4141 42.078H44.4854C44.4973 42.2536 44.5476 42.4001 44.6362 42.5178C44.7266 42.6337 44.8467 42.7214 44.9967 42.7811C45.1484 42.8391 45.3197 42.868 45.5107 42.868C45.6982 42.868 45.8609 42.8408 45.999 42.7862C46.1388 42.7317 46.247 42.6558 46.3237 42.5587C46.4004 42.4615 46.4388 42.3499 46.4388 42.2237C46.4388 42.1061 46.4038 42.0072 46.3339 41.9271C46.2658 41.847 46.1652 41.7788 46.0322 41.7226C45.901 41.6663 45.7399 41.6152 45.549 41.5692L44.9788 41.426C44.5374 41.3186 44.1888 41.1507 43.9331 40.9223C43.6774 40.6939 43.5504 40.3862 43.5521 39.9993C43.5504 39.6822 43.6348 39.4053 43.8053 39.1683C43.9774 38.9314 44.2135 38.7464 44.5135 38.6135C44.8135 38.4805 45.1544 38.4141 45.5362 38.4141C45.9249 38.4141 46.2641 38.4805 46.5538 38.6135C46.8453 38.7464 47.072 38.9314 47.2339 39.1683C47.3959 39.4053 47.4794 39.6797 47.4845 39.9916H46.4234Z"/>
</g>
<g class="pan pan-right">
<path class="fin" fill-rule="evenodd" clip-rule="evenodd" d="M90.4141 28.9141L88.9141 30.4141L75.4141 43.9141L73.9141 45.4141L66.4141 45.4141L66.4141 30.4141L66.4141 27.4141L66.4141 12.4141L73.9141 12.4141L75.4141 13.9141L88.9141 27.4141L90.4141 28.9141Z"/>
<path class="ltr" d="M75.4141 31.6504V26.4141H78.9425V27.3268H76.5212V28.5746H78.7609V29.4874H76.5212V30.7376H78.9527V31.6504H75.4141Z"/>
</g>
<g class="pan pan-left">
<path class="fin" fill-rule="evenodd" clip-rule="evenodd" d="M1.41406 29.9141L2.91406 31.4141L16.4141 44.9141L17.9141 46.4141L25.4141 46.4141L25.4141 31.4141L25.4141 28.4141L25.4141 13.4141L17.9141 13.4141L16.4141 14.9141L2.91406 28.4141L1.41406 29.9141Z"/>
<path class="ltr" d="M10.9124 32.6504L9.41406 27.4141H10.6234L11.4902 31.0524H11.5337L12.4899 27.4141H13.5254L14.4791 31.0601H14.5251L15.3919 27.4141H16.6013L15.103 32.6504H14.024L13.0268 29.2268H12.9859L11.9913 32.6504H10.9124Z"/>
</g>
</svg>
</div>
<div class="tog-grid" id="mode-btns">
<button data-mode="cut" class="tog active" style="grid-column:span 2">Cut</button>
<button data-mode="fill" class="tog active" style="grid-column:span 2">Fill</button>
<button data-mode="sat" class="tog active" style="grid-column:span 2">Satellite</button>
<button data-mode="invert" id="invert-btn" class="tog" style="grid-column:span 2; display:none">Invert Depth</button>
<button data-mode="ann" class="tog active" style="grid-column:span 2">Annotations</button>
<button data-mode="lines" class="tog active" style="grid-column:span 2">Contours</button>
<button data-mode="contour" class="tog active" style="grid-column:span 2">Contour Labels</button>
</div>
<div class="csr">
<div class="csr-fill"></div>
<span class="csr-label">Base Level</span>
<span class="csr-val" id="base-label">0.10 m</span>
<input type="range" id="base-slider" min="-2" max="6" step="0.05" value="0.1">
</div>
</div>
<div class="grp">
<div class="csr cut-sl">
<div class="csr-fill"></div>
<span class="csr-label">Cut Depth</span>
<span class="csr-val" id="offset-label">0.0 m</span>
<input type="range" id="offset-slider" min="0" max="20" step="0.05" value="0">
</div>
<div class="csr fill-sl">
<div class="csr-fill"></div>
<span class="csr-label">Fill Depth</span>
<span class="csr-val" id="fill-offset-label">0.0 m</span>
<input type="range" id="fill-offset-slider" min="0" max="20" step="0.05" value="0">
</div>
</div>
<div class="grp">
<div class="csr">
<div class="csr-fill"></div>
<span class="csr-label">Wall Density</span>
<span class="csr-val" id="wall-label">0.50 m</span>
<input type="range" id="wall-slider" min="0.2" max="5" step="0.1" value="0.5">
</div>
<div class="csr">
<div class="csr-fill"></div>
<input type="color" class="cs" id="wall-top-color" value="#ff0000">
<span class="csr-label">Top Color</span>
<span class="csr-val" id="wall-top-alpha-val">0.10</span>
<input type="range" id="wall-top-alpha" min="0" max="1" step="0.05" value="0.1">
</div>
<div class="csr">
<div class="csr-fill"></div>
<input type="color" class="cs" id="wall-bot-color" value="#000000">
<span class="csr-label">Bottom Color</span>
<span class="csr-val" id="wall-bot-alpha-val">0.00</span>
<input type="range" id="wall-bot-alpha" min="0" max="1" step="0.05" value="0">
</div>
</div>
<div class="grp">
<div class="csr">
<div class="csr-fill"></div>
<span class="csr-label">Minor Interval</span>
<span class="csr-val" id="minor-label">0.15 m</span>
<input type="range" id="minor-slider" min="0.01" max="1.2" step="0.01" value="0.15">
</div>
<div class="fp-row" style="align-items:center;">
<div class="fc-lbl">Major Interval</div>
<div class="maj-btns" id="major-btns">
<button data-n="2" class="maj">×2</button>
<button data-n="3" class="maj">×3</button>
<button data-n="5" class="maj">×5</button>
<button data-n="10" class="maj active">×10</button>
</div>
</div>
<div class="csr">
<div class="csr-fill"></div>
<input type="color" class="cs" id="minor-color" value="#ffffff">
<span class="csr-label">Minor Lines</span>
<span class="csr-val" id="minor-opacity-val">0.12</span>
<input type="range" id="minor-opacity" min="0" max="1" step="0.05" value="0.12">
</div>
<div class="csr">
<div class="csr-fill"></div>
<input type="color" class="cs" id="major-color" value="#ffffff">
<span class="csr-label">Major Lines</span>
<span class="csr-val" id="major-opacity-val">1.00</span>
<input type="range" id="major-opacity" min="0" max="1" step="0.05" value="1">
</div>
</div>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import Stats from 'three/addons/libs/stats.module.js';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
const eio = t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
const stats = new Stats();
stats.dom.style.position = 'fixed';
stats.dom.style.top = '0';
stats.dom.style.left = '0';
stats.dom.style.transform = 'scale(0.6)';
stats.dom.style.transformOrigin = 'top left';
document.body.appendChild(stats.dom);
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(innerWidth, innerHeight);
labelRenderer.domElement.style.position = 'fixed';
labelRenderer.domElement.style.top = '0';
labelRenderer.domElement.style.left = '0';
labelRenderer.domElement.style.pointerEvents = 'none';
document.body.appendChild(labelRenderer.domElement);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(devicePixelRatio);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x08080c);
scene.fog = new THREE.Fog(0x08080c, window.innerWidth <= 768 ? 20 : 38, 62);
const camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 200);
camera.position.set(0, 22, 40);
camera.lookAt(0, -3, 0);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.autoRotate = false;
controls.autoRotateSpeed = 2.0;
controls.target.set(0, -3, 0);
controls.minDistance = 8;
controls.maxDistance = 85;
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const sun = new THREE.DirectionalLight(0xfff5e0, 1.4);
sun.position.set(6, 12, 6);
scene.add(sun);
let baseLeader, peakLeader, lowLeader, groundLeader, baseAnn, peakAnn, lowAnn, groundAnn, cutAnn, fillAnn, netAnn;
let cutBranches, cutTrunk, fillBranches, fillTrunk;
const cutTrunkMid = new THREE.Vector3();
const fillTrunkMid = new THREE.Vector3();
let cutAnnActive = false, fillAnnActive = false;
let showAnns = true, showContours = true, showLines = true;
let netLineAlpha = 0, netLineTgt = 0, prevTs = 0;
let annFade = 1, annFadeTgt = 1;
let cutTotalVol = 0, fillTotalVol = 0;
const annDirData = {
base: { v2: new THREE.Vector3(), v3: new THREE.Vector3(), div: null, dir: 0, flipping: false },
peak: { v2: new THREE.Vector3(), v3: new THREE.Vector3(), div: null, dir: 0, flipping: false },
low: { v2: new THREE.Vector3(), v3: new THREE.Vector3(), div: null, dir: 0, flipping: false },
ground: { v2: new THREE.Vector3(), v3: new THREE.Vector3(), div: null, dir: 0, flipping: false },
cut: { v2: new THREE.Vector3(), v3: new THREE.Vector3(), div: null, dir: 0, flipping: false },
};
let contourCandidates = [];
let contourLabelPool = [];
let cwu, fwu;
let ghostGroup, ghostFillGroup, cutBaseGroup;
const DEPTH_SCALE = 4;
let dtW = Math.ceil(innerWidth / DEPTH_SCALE);
let dtH = Math.ceil(innerHeight / DEPTH_SCALE);
const depthTarget = new THREE.WebGLRenderTarget(dtW, dtH);
const depthMat = new THREE.MeshDepthMaterial({ depthPacking: THREE.BasicDepthPacking, side: THREE.DoubleSide });
let depthBuffer = new Uint8Array(dtW * dtH * 4);
let depthReady = false;
// BasicDepthPacking stores (1 - fragCoordZ) in R — close = bright, far = dark
function sampleSceneDepth(sx, sy) {
if (!depthReady) return 1;
const tx = Math.min(dtW - 1, Math.max(0, Math.floor(sx / DEPTH_SCALE)));
const ty = Math.min(dtH - 1, Math.max(0, Math.floor((innerHeight - sy) / DEPTH_SCALE)));
return 1 - depthBuffer[(ty * dtW + tx) * 4] / 255;
}
const SIZE = 22;
const SEG = 140;
const CELL_AREA = (SIZE / SEG) ** 2;
const SITES = {
'bingham': { lat: 40.5230, lon: -112.1510, z: 13, name: 'Bingham Canyon Mine' },
'grand-canyon': { lat: 36.0544, lon: -112.1401, z: 12, name: 'Grand Canyon' },
'everest': { lat: 27.9881, lon: 86.9250, z: 12, name: 'Mount Everest' },
'matterhorn': { lat: 45.9764, lon: 7.6586, z: 13, name: 'Matterhorn' },
'mont-blanc': { lat: 45.8326, lon: 6.8652, z: 10, name: 'Mont Blanc' },
'half-dome': { lat: 37.7459, lon:-119.5332, z: 13, name: 'Half Dome / Yosemite' },
'mount-fuji': { lat: 35.3606, lon: 138.7274, z: 12, name: 'Mount Fuji' },
'kilimanjaro': { lat: -3.0674, lon: 37.3556, z: 11, name: 'Kilimanjaro' },
'vesuvius': { lat: 40.8210, lon: 14.4260, z: 13, name: 'Vesuvius' },
'death-valley': { lat: 36.5054, lon:-116.9347, z: 9, name: 'Death Valley' },
'hvannadalshnjukur': { lat: 64.0163, lon: -16.6806, z: 13, name: 'Hvannadalshnjúkur' },
};
let CURRENT_SITE = 'grand-canyon';
// ── Webcam / Live Depth pipeline ──
let webcamActive = false;
let webcamStream = null;
let webcamVideo = null;
let webcamCanvas = null;
let webcamCtx = null;
let webcamLastTile = null;
let webcamTex = null;
let webcamBaseSnapped = false;
let webcamPrevShowSat = null;
const DEPTH_HEIGHT_SCALE = 2.0;
let DEPTH_INVERT = false;
const CAM_FLIP_X = false; // flip depth + texture horizontally
const CAM_FLIP_Y = false; // flip depth + texture vertically
function latLonToTile(lat, lon, z) {
const n = 2 ** z;
const latRad = lat * Math.PI / 180;
const x = Math.floor((lon + 180) / 360 * n);
const y = Math.floor((1 - Math.asinh(Math.tan(latRad)) / Math.PI) / 2 * n);
return { x, y };
}
function tileCenterLatLon(x, y, z) {
const n = 2 ** z;
const lon = (x + 0.5) / n * 360 - 180;
const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 0.5) / n)));
return { lat: latRad * 180 / Math.PI, lon };
}
async function loadTerrarium(x, y, z) {
const url = `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${z}/${x}/${y}.png`;
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise((res, rej) => { img.onload = res; img.onerror = () => rej(new Error('tile fetch failed: ' + url)); img.src = url; });
const off = new OffscreenCanvas(256, 256);
const ctx = off.getContext('2d');
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, 256, 256).data;
const elev = new Float32Array(256 * 256);
let min = Infinity, max = -Infinity;
for (let i = 0; i < 256 * 256; i++) {
const e = (data[i*4] * 256 + data[i*4+1] + data[i*4+2] / 256) - 32768;
elev[i] = e;
if (e < min) min = e;
if (e > max) max = e;
}
const { lat } = tileCenterLatLon(x, y, z);
const tileMeters = 40075016.686 * Math.cos(lat * Math.PI / 180) / (2 ** z);
return { elev, min, max, tileMeters, lat };
}
async function loadSatellite(x, y, z) {
const url = `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error('satellite fetch failed');
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
new THREE.TextureLoader().load(blobUrl, tex => { URL.revokeObjectURL(blobUrl); tex.flipY = false; resolve(tex); }, undefined, err => { URL.revokeObjectURL(blobUrl); reject(err); });
});
}
const initSite = SITES[CURRENT_SITE];
const initXY = latLonToTile(initSite.lat, initSite.lon, initSite.z);
let currentTile = { x: initXY.x, y: initXY.y, z: initSite.z };
let anchorLat = initSite.lat, anchorLon = initSite.lon;
let elevData = await loadTerrarium(currentTile.x, currentTile.y, currentTile.z);
let METERS_PER_UNIT = elevData.tileMeters / SIZE;
let UNIT_SCALE = METERS_PER_UNIT;
let VOL_SCALE = UNIT_SCALE ** 3;
loadSatellite(currentTile.x, currentTile.y, currentTile.z).then(tex => {
satLoaded = true;
if (terrainShader) {
terrainShader.uniforms.uSatMap.value = tex;
terrainShader.uniforms.uHasSat.value = showSat ? 1 : 0;
} else {
pendingSatTex = tex;
}
}).catch(err => console.error('[sat] error:', err));
function sampleElev(u, v) {
const x = u * 255, y = v * 255;
const x0 = Math.floor(x), y0 = Math.floor(y);
const x1 = Math.min(255, x0 + 1), y1 = Math.min(255, y0 + 1);
const fx = x - x0, fy = y - y0;
const e = elevData.elev;
const a = e[y0 * 256 + x0], b = e[y0 * 256 + x1];
const c = e[y1 * 256 + x0], d = e[y1 * 256 + x1];
return (a * (1-fx) + b * fx) * (1-fy) + (c * (1-fx) + d * fx) * fy;
}
let EXAG = 1.0;
let CUT_OFFSET = 0.0;
let FILL_OFFSET = 0.0;
const TARGET_CUT_OFFSET = 13.0;
let BASE_Y = 0.10;
let showCut = true, showFill = true;
function computeViewMode() {
if (showCut && showFill) return 0;
if (showCut && !showFill) return 1;
if (!showCut && showFill) return 2;
return 3;
}
// terrain material — PBR with fragment-shader cut/fill boundary
let terrainShader = null;
let pendingSatTex = null;
let showSat = true;
let satLoaded = false;
let satRevealCurrent = 1.0;
let satRevealTarget = 1.0;
let cutRevealCurrent = 1.0, cutRevealTarget = 1.0;
let fillRevealCurrent = 1.0, fillRevealTarget = 1.0;
const terrainMat = new THREE.MeshPhysicalMaterial({
side: THREE.DoubleSide,
roughness: 0.8,
metalness: 0.0,
clearcoat: 0.1,
clearcoatRoughness: 0.4,
transparent: true,
});
terrainMat.onBeforeCompile = (shader) => {
shader.uniforms.uBaseY = { value: BASE_Y };
shader.uniforms.uViewMode = { value: 0 };
shader.uniforms.uSatMap = { value: null };
shader.uniforms.uHasSat = { value: 0 };
shader.uniforms.uPeak = { value: ANIM_MAX };
shader.uniforms.uLow = { value: ANIM_MIN };
shader.uniforms.uSatReveal = { value: satRevealCurrent };
shader.uniforms.uCutReveal = { value: cutRevealCurrent };
shader.uniforms.uFillReveal = { value: fillRevealCurrent };
terrainShader = shader;
// apply satellite texture if it arrived before first compile
if (pendingSatTex) {
shader.uniforms.uSatMap.value = pendingSatTex;
shader.uniforms.uHasSat.value = 1;
pendingSatTex = null;
}
// derive UV from position — avoids dependency on USE_UV / uv attribute availability
shader.vertexShader = 'varying float vTerrainY;\nvarying vec2 vTerrainUV;\n' + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
'#include <begin_vertex>\nvTerrainY = position.y;\nvTerrainUV = vec2(position.x / 22.0 + 0.5, position.z / 22.0 + 0.5);'
);
shader.fragmentShader = 'varying float vTerrainY;\nvarying vec2 vTerrainUV;\nuniform float uBaseY;\nuniform int uViewMode;\nuniform sampler2D uSatMap;\nuniform int uHasSat;\nuniform float uPeak;\nuniform float uLow;\nuniform float uSatReveal;\nuniform float uCutReveal;\nuniform float uFillReveal;\n' + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace(
'#include <color_fragment>',
`
vec3 cutCol = vec3(0.96, 0.08, 0.08);
vec3 fillCol = vec3(0.03, 0.35, 0.78);
float normY = clamp((vTerrainY - uLow) / max(uPeak - uLow, 0.001), 0.0, 1.0);
float satMask = uHasSat == 1 ? smoothstep(uSatReveal + 0.08, uSatReveal - 0.08, normY) : 0.0;
vec3 satTex = uHasSat == 1 ? texture2D(uSatMap, vTerrainUV).rgb : vec3(0.06, 0.06, 0.09);
vec3 satCol = mix(vec3(0.06, 0.06, 0.09), satTex, satMask);
bool isCut = vTerrainY > uBaseY;
float cutRange = max(uPeak - uBaseY, 0.001);
float fillRange = max(uBaseY - uLow, 0.001);
float cutT = clamp((vTerrainY - uBaseY) / cutRange, 0.0, 1.0);
float fillT = clamp((uBaseY - vTerrainY) / fillRange, 0.0, 1.0);
float blend = pow(isCut ? cutT : fillT, 0.6);
float cutRevMask = smoothstep(1.0 - uCutReveal - 0.08, 1.0 - uCutReveal + 0.08, cutT);
float fillRevMask = smoothstep(1.0 - uFillReveal - 0.08, 1.0 - uFillReveal + 0.08, fillT);
vec3 zoneCol;
if (uViewMode == 3) {
zoneCol = satCol;
} else {
zoneCol = isCut ? mix(satCol, cutCol, blend * cutRevMask) : mix(satCol, fillCol, blend * fillRevMask);
}
diffuseColor.rgb = zoneCol;
diffuseColor.a = 1.0;
`
);
};
// geometry
const terrainGeo = new THREE.PlaneGeometry(SIZE, SIZE, SEG, SEG);
terrainGeo.rotateX(-Math.PI / 2);
const baseGeo = new THREE.PlaneGeometry(SIZE, SIZE, SEG, SEG);
baseGeo.rotateX(-Math.PI / 2);
const tPos = terrainGeo.attributes.position;
let base = null;
const heights = new Float32Array(tPos.count);
const cutWallGeo = new THREE.BufferGeometry();
const fillWallGeo = new THREE.BufferGeometry();
const cutBaseGeo = new THREE.BufferGeometry();
const contourGeo = new THREE.BufferGeometry();
const cutBranchGeo = new THREE.BufferGeometry();
const cutTrunkGeo = new THREE.BufferGeometry();
cutTrunkGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
const fillBranchGeo = new THREE.BufferGeometry();
const fillTrunkGeo = new THREE.BufferGeometry();
fillTrunkGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
const netCutGeo = new THREE.BufferGeometry();
netCutGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
const netFillGeo = new THREE.BufferGeometry();
netFillGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(18), 3));
// cache X/Z since they never change
const txCache = new Float32Array(tPos.count);
const tzCache = new Float32Array(tPos.count);
for (let i = 0; i < tPos.count; i++) {
txCache[i] = tPos.getX(i);
tzCache[i] = tPos.getZ(i);
}
// pre-build triangle adjacency (static — terrain topology never changes)
const srcIdx = terrainGeo.index.array;
const numTris = srcIdx.length / 3;
const triNeighbors = Array.from({ length: numTris }, () => []);
{
const edgeMap = new Map();
for (let t = 0; t < numTris; t++) {
const a = srcIdx[t*3], b = srcIdx[t*3+1], c = srcIdx[t*3+2];
for (const [p, q] of [[a,b],[b,c],[c,a]]) {
const k = p < q ? p * 25000 + q : q * 25000 + p;
if (edgeMap.has(k)) {
const o = edgeMap.get(k);
triNeighbors[t].push(o);
triNeighbors[o].push(t);
} else {
edgeMap.set(k, t);
}
}
}
}
function rebuildTerrain() {
for (let i = 0; i < tPos.count; i++) {
const x = tPos.getX(i), z = tPos.getZ(i);
const u = (x + SIZE/2) / SIZE;
const v = (z + SIZE/2) / SIZE;
const e = sampleElev(u, v);
heights[i] = e / METERS_PER_UNIT;
tPos.setY(i, heights[i] * EXAG);
}
tPos.needsUpdate = true;
terrainGeo.computeVertexNormals();
}
rebuildTerrain();
function computeContour(baseY) {
const verts = [], ringVerts = [], wallPts = [];
for (let i = 0; i < srcIdx.length; i += 3) {
const a = srcIdx[i], b = srcIdx[i+1], c = srcIdx[i+2];
const ha = heights[a], hb = heights[b], hc = heights[c];
const pts = [];
for (const [p, q, hp, hq] of [[a,b,ha,hb],[b,c,hb,hc],[c,a,hc,ha]]) {
if ((hp < baseY) !== (hq < baseY)) {
const t = (baseY - hp) / (hq - hp);
pts.push(txCache[p] + t*(txCache[q]-txCache[p]), tzCache[p] + t*(tzCache[q]-tzCache[p]));
}
}
if (pts.length === 4) {
verts.push(pts[0], baseY*EXAG+0.02, pts[1], pts[2], baseY*EXAG+0.02, pts[3]);
ringVerts.push(pts[0], baseY*EXAG, pts[1], pts[2], baseY*EXAG, pts[3]);
wallPts.push(pts[0], pts[1], pts[2], pts[3]);
}
}
const wallVerts = [], wallGrads = [], fillWallVerts = [], fillWallGrads = [];
const cellSize = wallSpacing;
const minD2 = cellSize * cellSize;
const grid = new Map();
for (let i = 0; i < wallPts.length; i += 2) {
const wx = wallPts[i], wz = wallPts[i+1];
const cx = Math.floor(wx/cellSize), cz = Math.floor(wz/cellSize);
let tooClose = false;
outer: for (let dx = -1; dx <= 1; dx++) {
for (let dz = -1; dz <= 1; dz++) {
const cell = grid.get((cx+dx)*65521 + (cz+dz));
if (!cell) continue;
for (let j = 0; j < cell.length; j += 2) {