Spaces:
Running on Zero
Running on Zero
Download demo.html from eira06/depthwizard-backend: direct link, hf CLI and curl.
- Browser
- Download file 109 kB
-
https://huggingface.co/spaces/eira06/depthwizard-backend/resolve/main/demo.html
- Command line
-
hf download hf://spaces/eira06/depthwizard-backend/demo.html
-
curl -L -o demo.html https://huggingface.co/spaces/eira06/depthwizard-backend/resolve/main/demo.html
109 kB
| <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>DepthWizard — 3D Demo</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> | |
| <style> | |
| :root{ | |
| --bg:#08080a; | |
| --panel:#101012; | |
| --panel2:#0c0c0e; | |
| --line:#232327; | |
| --line-soft:#1a1a1d; | |
| --fg:#f4f3ef; | |
| --fg-dim:#93938f; | |
| --fg-faint:#4d4d4f; | |
| --display:'Space Grotesk',sans-serif; | |
| --body:'Inter',sans-serif; | |
| --mono:'IBM Plex Mono',monospace; | |
| --radius:12px; | |
| } | |
| *{box-sizing:border-box;} | |
| html{scroll-behavior:smooth;scrollbar-color:#3a3a3d transparent;} | |
| ::-webkit-scrollbar{width:9px;height:9px;} | |
| ::-webkit-scrollbar-thumb{background:#2a2a2d;border-radius:5px;} | |
| ::-webkit-scrollbar-track{background:transparent;} | |
| html,body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--body);-webkit-font-smoothing:antialiased;} | |
| ::selection{background:var(--fg);color:var(--bg);} | |
| button{font-family:var(--body);cursor:pointer;} | |
| svg{display:block;} | |
| @media (prefers-reduced-motion: reduce){*{transition:none ;animation:none ;scroll-behavior:auto ;}} | |
| /* ===================================================================== | |
| HERO — the beginning | |
| ===================================================================== */ | |
| .hero-page{ | |
| min-height:100vh; | |
| display:flex; | |
| flex-direction:column; | |
| border-bottom:1px solid var(--line); | |
| position:relative; | |
| overflow:hidden; | |
| } | |
| .hero-frame{max-width:1180px;margin:0 auto;padding:0 32px;width:100%;flex:1;display:flex;flex-direction:column;} | |
| .hero-nav{ | |
| display:flex;align-items:center;justify-content:space-between; | |
| padding:26px 0 22px; | |
| border-bottom:1px solid var(--line); | |
| } | |
| .hero-wordmark{ | |
| font-family:var(--display); | |
| font-size:20px; | |
| letter-spacing:0.02em; | |
| font-weight:700; | |
| } | |
| .hero-meta{ | |
| font-family:var(--mono); | |
| font-size:12px; | |
| color:var(--fg-dim); | |
| letter-spacing:0.02em; | |
| display:flex; | |
| gap:22px; | |
| } | |
| .hero-meta b{color:var(--fg);font-weight:500;} | |
| .hero-main{ | |
| position:relative; | |
| flex:1; | |
| display:flex; | |
| align-items:center; | |
| padding:60px 0 40px; | |
| } | |
| .hero-contours{ | |
| position:absolute; | |
| right:-6%; | |
| top:50%; | |
| transform:translateY(-50%); | |
| width:660px; | |
| max-width:72%; | |
| opacity:0.5; | |
| pointer-events:none; | |
| } | |
| .hero-contours path{ | |
| fill:none; | |
| stroke:var(--fg-faint); | |
| stroke-width:1; | |
| } | |
| .hero-eyebrow{ | |
| font-family:var(--mono); | |
| font-size:12px; | |
| color:var(--fg-dim); | |
| letter-spacing:0.14em; | |
| text-transform:uppercase; | |
| margin-bottom:20px; | |
| } | |
| .hero-h1{ | |
| font-family:var(--display); | |
| font-weight:600; | |
| font-size:clamp(38px,5.6vw,64px); | |
| line-height:1.03; | |
| margin:0 0 24px; | |
| max-width:680px; | |
| letter-spacing:-0.01em; | |
| } | |
| .hero-sub{ | |
| font-size:16.5px; | |
| color:var(--fg-dim); | |
| max-width:480px; | |
| line-height:1.65; | |
| margin:0 0 34px; | |
| } | |
| .hero-cta-row{display:flex;align-items:center;gap:16px;flex-wrap:wrap;} | |
| .hero-cta{ | |
| display:inline-flex;align-items:center;gap:9px; | |
| background:var(--fg);color:var(--bg); | |
| border:none;border-radius:9px; | |
| padding:14px 22px;font-size:13.5px;font-weight:600; | |
| font-family:var(--display); | |
| transition:opacity .15s ease, transform .15s ease; | |
| } | |
| .hero-cta:hover{opacity:0.88;transform:translateY(-1px);} | |
| .hero-cta svg{width:14px;height:14px;} | |
| .hero-modes{ | |
| display:inline-flex; | |
| border:1px solid var(--line); | |
| border-radius:9px; | |
| overflow:hidden; | |
| font-family:var(--mono); | |
| font-size:11.5px; | |
| } | |
| .hero-modes span{ | |
| padding:12px 15px; | |
| color:var(--fg-dim); | |
| letter-spacing:0.02em; | |
| } | |
| .hero-modes span.on{background:var(--panel);color:var(--fg);} | |
| .hero-modes span + span{border-left:1px solid var(--line);} | |
| .scroll-cue{ | |
| display:flex;flex-direction:column;align-items:center;gap:8px; | |
| padding:20px 0 6px; | |
| color:var(--fg-faint); | |
| font-family:var(--mono); | |
| font-size:10.5px; | |
| letter-spacing:0.1em; | |
| text-transform:uppercase; | |
| } | |
| .scroll-cue svg{width:15px;height:15px;animation:bob 1.8s infinite ease-in-out;} | |
| @keyframes bob{0%,100%{transform:translateY(0);opacity:0.5;}50%{transform:translateY(5px);opacity:1;}} | |
| /* ===================================================================== | |
| DASHBOARD — "as it is" reference section | |
| ===================================================================== */ | |
| .dash-page{ | |
| background: | |
| radial-gradient(ellipse 900px 500px at 15% -10%, rgba(240,240,235,0.045), transparent 60%), | |
| radial-gradient(ellipse 700px 500px at 100% 10%, rgba(240,240,235,0.03), transparent 55%), | |
| var(--bg); | |
| padding:22px 0 46px; | |
| } | |
| .dash-app{max-width:1440px;margin:0 auto;padding:0 16px;width:100%;} | |
| .dash-app header{ | |
| display:flex;align-items:center;justify-content:space-between; | |
| padding:12px 16px; | |
| background:var(--panel); | |
| border:1px solid var(--line); | |
| border-radius:var(--radius); | |
| margin-bottom:14px; | |
| gap:16px; | |
| flex-wrap:wrap; | |
| } | |
| .brand{display:flex;align-items:center;gap:10px;} | |
| .brand svg{width:28px;height:28px;} | |
| .brand .titles{line-height:1.15;} | |
| .brand .name{font-family:var(--display);font-weight:700;font-size:15px;letter-spacing:0.01em;} | |
| .brand .tag{font-size:11px;color:var(--fg-dim);} | |
| .stepper{display:flex;align-items:center;gap:4px;flex:1;justify-content:center;min-width:240px;} | |
| .step{display:flex;flex-direction:column;align-items:center;gap:4px;position:relative;min-width:54px;} | |
| .step .circle{ | |
| width:24px;height:24px;border-radius:50%; | |
| border:1px solid var(--line); | |
| display:flex;align-items:center;justify-content:center; | |
| font-family:var(--mono);font-size:10px;color:var(--fg-dim); | |
| background:var(--panel2); | |
| transition:all .3s ease; | |
| } | |
| .step.done .circle{background:var(--fg);border-color:var(--fg);color:var(--bg);} | |
| .step.active .circle{border-color:var(--fg);color:var(--fg);} | |
| .step .slabel{font-size:10px;color:var(--fg-dim);white-space:nowrap;} | |
| .step.done .slabel, .step.active .slabel{color:var(--fg);} | |
| .step-line{flex:1;height:1px;background:var(--line);margin:0 2px;position:relative;top:-11px;min-width:12px;} | |
| .step-line.done{background:var(--fg-faint);} | |
| .head-right{display:flex;align-items:center;gap:12px;} | |
| .status{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--fg-dim);} | |
| .status .dot{width:7px;height:7px;border-radius:50%;background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,0.15);} | |
| .btn-export{ | |
| display:flex;align-items:center;gap:6px; | |
| background:var(--fg);color:var(--bg); | |
| border:none;border-radius:7px; | |
| padding:8px 12px;font-size:12px;font-weight:600; | |
| } | |
| .btn-export svg{width:13px;height:13px;} | |
| .layout{display:grid;grid-template-columns:minmax(220px, 240px) 1fr minmax(230px, 260px);gap:12px;align-items:start;} | |
| @media (max-width:1200px){.layout{grid-template-columns:1fr 1fr;}} | |
| @media (max-width:850px){.layout{grid-template-columns:1fr;}} | |
| .card{ | |
| background:var(--panel); | |
| border:1px solid var(--line); | |
| border-radius:var(--radius); | |
| padding:16px; | |
| } | |
| .card + .card{margin-top:14px;} | |
| .card-title{ | |
| display:flex;align-items:center;gap:8px; | |
| font-size:11px;letter-spacing:0.07em;text-transform:uppercase;color:var(--fg-dim); | |
| margin-bottom:14px;font-weight:600; | |
| } | |
| .card-title svg{width:13px;height:13px;opacity:0.8;} | |
| .dropzone{ | |
| border:1.5px dashed var(--line); | |
| border-radius:8px; | |
| padding:26px 14px; | |
| text-align:center; | |
| display:flex;flex-direction:column;align-items:center;gap:10px; | |
| cursor:pointer;transition:border-color .2s, background .2s; | |
| } | |
| .dropzone:hover, .dropzone.drag{border-color:var(--fg-faint);background:var(--panel2);} | |
| .dropzone svg{width:26px;height:26px;color:var(--fg-dim);} | |
| .dropzone .dz-t{font-size:13px;font-weight:500;} | |
| .dropzone .dz-s{font-size:11px;color:var(--fg-faint);} | |
| .browse-btn{ | |
| margin-top:2px;background:var(--panel2);border:1px solid var(--line);color:var(--fg); | |
| border-radius:6px;padding:7px 14px;font-size:12px; | |
| } | |
| input[type=file]{display:none;} | |
| .file-card{ | |
| margin-top:12px;background:var(--panel2);border:1px solid var(--line-soft);border-radius:8px;padding:12px; | |
| font-size:12px; | |
| } | |
| .file-card .fname{display:flex;align-items:center;justify-content:space-between;font-weight:500;margin-bottom:8px;} | |
| .file-card .fname .x{color:var(--fg-faint);cursor:pointer;} | |
| .file-row{display:flex;align-items:center;gap:8px;color:var(--fg-dim);padding:3px 0;font-family:var(--mono);font-size:11px;} | |
| .file-row svg{width:12px;height:12px;flex-shrink:0;} | |
| .file-row .fval{color:var(--fg);margin-left:auto;} | |
| .mode-opt{ | |
| display:flex;align-items:center;gap:9px; | |
| padding:10px 12px;border:1px solid var(--line);border-radius:8px; | |
| font-size:12.5px;margin-bottom:8px;cursor:pointer; | |
| transition:border-color .2s, background .2s; | |
| } | |
| .mode-opt.active{border-color:var(--fg-faint);background:var(--panel2);} | |
| .mode-opt .radio{width:14px;height:14px;border-radius:50%;border:1.5px solid var(--fg-faint);flex-shrink:0;position:relative;} | |
| .mode-opt.active .radio::after{content:'';position:absolute;inset:2.5px;border-radius:50%;background:var(--fg);} | |
| .mode-opt svg{width:14px;height:14px;color:var(--fg-dim);} | |
| .process-btn{ | |
| width:100%;margin-top:6px; | |
| background:var(--fg);color:var(--bg);border:none;border-radius:8px; | |
| padding:12px;font-size:13px;font-weight:600; | |
| display:flex;align-items:center;justify-content:center;gap:8px; | |
| transition:all .2s ease; | |
| } | |
| .process-btn:disabled{opacity:0.4;cursor:not-allowed;} | |
| .process-btn svg{width:12px;height:12px;} | |
| .process-btn.ready{ | |
| background:#22c55e ;color:#052e16 ;font-weight:700 ; | |
| box-shadow:0 0 20px rgba(34,197,94,0.45); | |
| animation:pulseReady 2s infinite; | |
| } | |
| @keyframes pulseReady { | |
| 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.6); } | |
| 70% { box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); } | |
| 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } | |
| } | |
| .dropzone.has-file{ | |
| border:2px solid #22c55e ; | |
| background:rgba(34,197,94,0.06) ; | |
| box-shadow:0 0 16px rgba(34,197,94,0.2); | |
| } | |
| .file-preview-thumb{ | |
| width:100%;height:120px;object-fit:cover;border-radius:6px; | |
| margin-bottom:10px;border:1px solid var(--line);display:block; | |
| } | |
| .upload-badge{ | |
| display:inline-flex;align-items:center;gap:5px; | |
| background:#22c55e;color:#052e16;font-family:var(--mono); | |
| font-size:10.5px;font-weight:700;padding:3px 8px;border-radius:4px; | |
| letter-spacing:0.04em;margin-bottom:8px; | |
| } | |
| .datasource-row{display:flex;align-items:center;gap:9px;padding:6px 0;font-size:12px;color:var(--fg-dim);} | |
| .datasource-row svg.ico{width:14px;height:14px;color:var(--fg-dim);flex-shrink:0;} | |
| .datasource-row .check{margin-left:auto;width:13px;height:13px;color:#22c55e;} | |
| .datasource-row.pending .check{color:var(--fg-faint);} | |
| .tabs{display:flex;gap:2px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:3px;margin-bottom:12px;width:fit-content;} | |
| .tab{ | |
| padding:7px 14px;font-size:12px;color:var(--fg-dim);border-radius:6px;background:none;border:none; | |
| letter-spacing:0.02em;font-weight:500; | |
| } | |
| .tab.active{background:var(--fg);color:var(--bg);} | |
| .main-view{position:relative;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#000;aspect-ratio:16/10;} | |
| .main-view canvas{width:100%;height:100%;object-fit:cover;display:block;} | |
| /* Custom 3D Cursor: Hand with glowing blue dot */ | |
| #webglCanvas { | |
| cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg filter='drop-shadow(0px 1px 2px rgba(0,0,0,0.5))'%3E%3Cpath d='M11 13.5V6a2 2 0 0 1 4 0v7.5m0-4.5a2 2 0 0 1 4 0v4.5m0-2.5a2 2 0 0 1 4 0v3.5m0-1.5a2 2 0 0 1 4 0v6.5a6.5 6.5 0 0 1-6.5 6.5h-2a6.5 6.5 0 0 1-6.1-4.2L6.8 19.3a2 2 0 0 1 3-2.5l1.2.9' fill='%23ffffff' stroke='%2318181b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='17' cy='18.5' r='3.8' fill='%2338bdf8' stroke='%230284c7' stroke-width='1'/%3E%3Ccircle cx='17' cy='18.5' r='1.8' fill='%23ffffff'/%3E%3C/g%3E%3C/svg%3E") 16 18, grab, pointer ; | |
| } | |
| #webglCanvas:active { | |
| cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg filter='drop-shadow(0px 1px 2px rgba(0,0,0,0.5))'%3E%3Cpath d='M11 14.5V9a2 2 0 0 1 4 0v5.5m0-3a2 2 0 0 1 4 0v3m0-1.5a2 2 0 0 1 4 0v2.5m0 0a2 2 0 0 1 4 0v4a6.5 6.5 0 0 1-6.5 6.5h-2a6.5 6.5 0 0 1-6.1-4.2L6.8 20.3a2 2 0 0 1 3-2.5l1.2.9' fill='%23e2e8f0' stroke='%2318181b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='17' cy='18.5' r='3.8' fill='%230284c7' stroke='%230369a1' stroke-width='1'/%3E%3Ccircle cx='17' cy='18.5' r='1.8' fill='%23ffffff'/%3E%3C/g%3E%3C/svg%3E") 16 18, grabbing, pointer ; | |
| } | |
| .view-label{position:absolute;top:10px;left:12px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.06em;color:var(--fg-dim);background:rgba(10,10,10,0.6);padding:4px 8px;border-radius:5px;z-index:10;} | |
| .view-controls{position:absolute;top:10px;right:10px;display:flex;flex-direction:column;gap:4px;z-index:10;} | |
| .vc-btn{ | |
| width:28px;height:28px;border-radius:6px;background:rgba(19,19,20,0.85);border:1px solid var(--line); | |
| display:flex;align-items:center;justify-content:center;color:var(--fg-dim); | |
| } | |
| .vc-btn svg{width:13px;height:13px;} | |
| .cursor-hud{ | |
| position:absolute;bottom:10px;left:12px; | |
| background:rgba(12,12,14,0.85);backdrop-filter:blur(8px); | |
| border:1px solid var(--line);border-radius:6px; | |
| padding:5px 10px;font-family:var(--mono);font-size:11px;color:var(--fg-dim); | |
| display:flex;gap:12px;z-index:10;pointer-events:none; | |
| } | |
| .cursor-hud strong{color:var(--fg);font-weight:500;} | |
| .sub-panels{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:12px;} | |
| @media (max-width:760px){.sub-panels{grid-template-columns:1fr;}} | |
| .sub-panel{border:1px solid var(--line);border-radius:10px;background:var(--panel);overflow:hidden;} | |
| .sp-head{display:flex;justify-content:space-between;align-items:center;padding:9px 12px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.05em;text-transform:uppercase;color:var(--fg-dim);border-bottom:1px solid var(--line-soft);} | |
| .sp-body{position:relative;aspect-ratio:1/1;background:#000;} | |
| .sp-body canvas{width:100%;height:100%;display:block;} | |
| .legend{position:absolute;right:8px;top:8px;bottom:8px;width:14px;border-radius:3px;background:linear-gradient(to bottom, #fff, #000);border:1px solid rgba(255,255,255,0.15);} | |
| .legend-tag{position:absolute;right:26px;font-family:var(--mono);font-size:9px;color:var(--fg-dim);} | |
| .legend-tag.top{top:8px;} | |
| .legend-tag.bottom{bottom:8px;} | |
| .stat2x2{display:grid;grid-template-columns:1fr 1fr;gap:10px;} | |
| .stat-box{border:1px solid var(--line);border-radius:8px;padding:11px 12px;background:var(--panel2);} | |
| .stat-box .slabel2{display:flex;align-items:center;gap:6px;font-size:10.5px;color:var(--fg-dim);margin-bottom:6px;} | |
| .stat-box .slabel2 svg{width:12px;height:12px;} | |
| .stat-box .sval{font-family:var(--display);font-size:19px;font-weight:600;} | |
| .calib-row{display:flex;align-items:center;gap:9px;padding:7px 0;font-size:12px;} | |
| .calib-row svg.ico{width:14px;height:14px;color:var(--fg-dim);flex-shrink:0;} | |
| .calib-row .cname{color:var(--fg-dim);flex:1;} | |
| .calib-row .cweight{font-family:var(--mono);font-size:11px;color:var(--fg);} | |
| .range-wrap{margin-top:6px;} | |
| .range-label-row{display:flex;justify-content:space-between;font-size:12px;margin-bottom:10px;} | |
| .range-label-row .rname{color:var(--fg-dim);} | |
| .range-label-row .rval{font-family:var(--mono);color:var(--fg);} | |
| .range-track{position:relative;height:4px;background:var(--line);border-radius:2px;margin-bottom:6px;} | |
| .range-fill{position:absolute;height:100%;background:var(--fg);border-radius:2px;} | |
| .range-ticks{display:flex;justify-content:space-between;font-family:var(--mono);font-size:9.5px;color:var(--fg-faint);} | |
| .cmp-row{margin-bottom:11px;} | |
| .cmp-row:last-child{margin-bottom:0;} | |
| .cmp-top{display:flex;justify-content:space-between;font-size:11.5px;margin-bottom:5px;} | |
| .cmp-top .cmpname{color:var(--fg-dim);} | |
| .cmp-top .cmpval{font-family:var(--mono);color:var(--fg);} | |
| .cmp-bar-track{height:5px;background:var(--line);border-radius:3px;overflow:hidden;} | |
| .cmp-bar-fill{height:100%;background:var(--fg-faint);border-radius:3px;} | |
| .cmp-row.best .cmp-bar-fill{background:var(--fg);} | |
| .bottom-row{display:grid;grid-template-columns:1.1fr 1.6fr 1fr;gap:14px;margin-top:14px;} | |
| @media (max-width:1180px){.bottom-row{grid-template-columns:1fr;}} | |
| .val-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;} | |
| .val-box{border:1px solid var(--line);border-radius:8px;padding:11px 12px;background:var(--panel2);} | |
| .val-box .vlabel{font-size:10px;color:var(--fg-dim);letter-spacing:0.03em;margin-bottom:6px;} | |
| .val-box .vval{font-family:var(--display);font-size:18px;font-weight:600;} | |
| .elev-legend{display:flex;gap:16px;font-size:11px;color:var(--fg-dim);margin-bottom:8px;} | |
| .elev-legend span{display:flex;align-items:center;gap:6px;} | |
| .elev-legend .swatch{width:14px;height:2px;background:var(--fg);} | |
| .elev-legend .swatch.dashed{background:none;border-top:2px dashed var(--fg-faint);} | |
| .controls-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;} | |
| @media (max-width:500px){.controls-grid{grid-template-columns:repeat(3,1fr);}} | |
| .ctrl-box{ | |
| border:1px solid var(--line);border-radius:8px;padding:12px 6px; | |
| display:flex;flex-direction:column;align-items:center;gap:7px;background:var(--panel2); | |
| cursor:pointer;user-select:none;transition:border-color .15s; | |
| } | |
| .ctrl-box:hover{border-color:var(--fg-faint);} | |
| .ctrl-box svg{width:16px;height:16px;color:var(--fg-dim);} | |
| .ctrl-box .cl{font-size:10.5px;color:var(--fg-dim);} | |
| .tex-btn{ | |
| flex:1;padding:5px 0;font-size:10.5px;font-family:var(--mono);background:var(--panel2); | |
| color:var(--fg-dim);border:1px solid var(--line);border-radius:5px;cursor:pointer; | |
| transition:all .15s;font-weight:500; | |
| } | |
| .tex-btn:hover{color:var(--fg);border-color:var(--fg-faint);} | |
| .tex-btn.active{background:var(--fg);color:var(--bg);font-weight:600;border-color:var(--fg);} | |
| </style></head><body> | |
| <div class="dash-page" id="dashboard"> | |
| <div class="dash-app"> | |
| <header> | |
| <div class="brand"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M3 20L9 8l4 7 2-3 6 8H3z"/></svg> | |
| <div class="titles"> | |
| <div class="name">DEPTHWIZARD</div> | |
| <div class="tag">Single-View Elevation & 3D Reconstruction</div> | |
| </div> | |
| </div> | |
| <div class="stepper" id="stepper"> | |
| <div class="step" data-i="0"><div class="circle">1</div><div class="slabel">Input</div></div> | |
| <div class="step-line" data-i="0"></div> | |
| <div class="step" data-i="1"><div class="circle">2</div><div class="slabel">Depth Estimation</div></div> | |
| <div class="step-line" data-i="1"></div> | |
| <div class="step" data-i="2"><div class="circle">3</div><div class="slabel">Scale Calibration</div></div> | |
| <div class="step-line" data-i="2"></div> | |
| <div class="step" data-i="3"><div class="circle">4</div><div class="slabel">DSM Generation</div></div> | |
| <div class="step-line" data-i="3"></div> | |
| <div class="step" data-i="4"><div class="circle">5</div><div class="slabel">3D Reconstruction</div></div> | |
| </div> | |
| <div class="head-right"> | |
| <div class="status"><span class="dot"></span><span id="statusText">System Ready</span></div> | |
| <a href="m5_3d_viewer_sharp.html" target="_blank" class="btn-export" style="background:rgba(56, 189, 248, 0.12);border-color:#38bdf8;color:#38bdf8;text-decoration:none;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/></svg>Sharp 3D Viewer ↗</a> | |
| <button class="btn-export" id="btnExport"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 19h16"/></svg>Export</button> | |
| </div> | |
| </header> | |
| <div class="layout"> | |
| <div class="col-left"> | |
| <div class="card"> | |
| <div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>INPUT</div> | |
| <div class="dropzone" id="dropzone" tabindex="0"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M7 18a4 4 0 01-1-7.9A5 5 0 0116 8a4.5 4.5 0 011 8.9"/><path d="M12 12v7m0-7l-3 3m3-3l3 3"/></svg> | |
| <div class="dz-t">Drag & Drop Image</div> | |
| <div class="dz-s">or</div> | |
| <button class="browse-btn" id="browseBtn" type="button">Browse Files</button> | |
| </div> | |
| <input type="file" id="fileInput" accept=".png,.jpg,.jpeg,.tif,.tiff" style="display:none;"> | |
| <!-- Quick-load sample preset buttons --> | |
| <div class="samples-container" style="margin-top:12px;"> | |
| <div style="font-size:11px;font-weight:600;letter-spacing:0.5px;color:var(--fg-faint);margin-bottom:6px;display:flex;align-items:center;gap:5px;"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:11px;height:11px;"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg> | |
| OR LOAD SAMPLE PRESETS | |
| </div> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;"> | |
| <button class="sample-preset-btn" type="button" data-url="/data/potsdam_sample_1024.tif" data-name="potsdam_sample_1024.tif" style="background:var(--panel2);border:1px solid var(--line-soft);color:var(--fg);border-radius:6px;padding:6px 8px;font-size:11px;font-family:var(--mono);cursor:pointer;text-align:left;display:flex;align-items:center;gap:5px;transition:border-color 0.2s, background 0.2s;"> | |
| <span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#38bdf8;"></span> Potsdam Tile (GeoTIFF) | |
| </button> | |
| <button class="sample-preset-btn" type="button" data-url="/demoImages/sat1.jpg" data-name="sat1.jpg" style="background:var(--panel2);border:1px solid var(--line-soft);color:var(--fg);border-radius:6px;padding:6px 8px;font-size:11px;font-family:var(--mono);cursor:pointer;text-align:left;display:flex;align-items:center;gap:5px;transition:border-color 0.2s, background 0.2s;"> | |
| <span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#22c55e;"></span> Satellite Urban | |
| </button> | |
| <button class="sample-preset-btn" type="button" data-url="/demoImages/sat2.png" data-name="sat2.png" style="background:var(--panel2);border:1px solid var(--line-soft);color:var(--fg);border-radius:6px;padding:6px 8px;font-size:11px;font-family:var(--mono);cursor:pointer;text-align:left;display:flex;align-items:center;gap:5px;transition:border-color 0.2s, background 0.2s;"> | |
| <span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#a855f7;"></span> Satellite Hi-Res | |
| </button> | |
| <button class="sample-preset-btn" type="button" data-url="/demoImages/sat4.jpg" data-name="sat4.jpg" style="background:var(--panel2);border:1px solid var(--line-soft);color:var(--fg);border-radius:6px;padding:6px 8px;font-size:11px;font-family:var(--mono);cursor:pointer;text-align:left;display:flex;align-items:center;gap:5px;transition:border-color 0.2s, background 0.2s;"> | |
| <span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#f59e0b;"></span> Coastal Scene | |
| </button> | |
| </div> | |
| </div> | |
| <div class="file-card" id="fileCard" style="display:none;"> | |
| <div class="fname"><span id="fnameText">scene_01.tif</span><span class="x" id="removeFile">✕</span></div> | |
| <div class="file-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>Type<span class="fval" id="ftype">GeoTIFF</span></div> | |
| <div class="file-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 7h16M4 12h16M4 17h10"/></svg>Size<span class="fval" id="fsize">12.4 MB</span></div> | |
| <div class="file-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 010 18 15 15 0 010-18z"/></svg>CRS<span class="fval" id="fcrs">Pending...</span></div> | |
| <div class="file-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 20l6-16 4 10 3-6 3 12"/></svg>Resolution<span class="fval" id="fres">—</span></div> | |
| <div class="file-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="4" width="16" height="16" rx="1"/></svg>Dimensions<span class="fval" id="fdims">—</span></div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/><circle cx="12" cy="12" r="5"/></svg>MODE</div> | |
| <div class="mode-opt active" id="modeGeoOpt"> | |
| <div class="radio"></div> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 12h18M12 3a15 15 0 010 18 15 15 0 010-18z"/></svg> | |
| Metric DSM (Georeferenced) | |
| </div> | |
| <div class="mode-opt" id="modeRelOpt"> | |
| <div class="radio"></div> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/></svg> | |
| Relative DSM (Non-georeferenced) | |
| </div> | |
| <button class="process-btn" id="processBtn" disabled> | |
| <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg> | |
| Process Image | |
| </button> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/></svg>DATA SOURCES</div> | |
| <div class="datasource-row" id="dsDepth"> | |
| <svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/></svg> | |
| Depth Anything V2 | |
| <svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M4 12l5 5L20 6"/></svg> | |
| </div> | |
| <div class="datasource-row pending" id="dsShadow"> | |
| <svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="7" r="4"/><path d="M4 20c0-3.5 3.5-6 8-6s8 2.5 8 6"/></svg> | |
| Shadow Constraint | |
| <svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M4 12l5 5L20 6"/></svg> | |
| </div> | |
| <div class="datasource-row pending" id="dsDEM"> | |
| <svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 17l5-9 4 6 3-5 6 8"/></svg> | |
| DEM / SRTM | |
| <svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M4 12l5 5L20 6"/></svg> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-center"> | |
| <div class="tabs" id="tabs"> | |
| <button class="tab active" data-tab="overview">OVERVIEW</button> | |
| <button class="tab" data-tab="depth">DEPTH</button> | |
| <button class="tab" data-tab="dsm">DSM</button> | |
| <button class="tab" data-tab="error">ERROR MAP</button> | |
| <button class="tab" data-tab="3d">3D VIEW</button> | |
| </div> | |
| <div class="main-view" id="mainView"> | |
| <div class="view-label" id="viewLabel">RGB IMAGE</div> | |
| <div class="view-controls"> | |
| <div class="vc-btn" title="Zoom In"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg></div> | |
| <div class="vc-btn" title="Zoom Out"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14"/></svg></div> | |
| <div class="vc-btn" title="Reset View"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/></svg></div> | |
| <div class="vc-btn" title="Fullscreen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></div> | |
| </div> | |
| <div class="cursor-hud" id="cursorHud"> | |
| <span><strong id="hudCoord">X: — | Y: —</strong></span> | |
| <span>Elev: <strong id="hudElev">—</strong></span> | |
| <span>Slope: <strong id="hudSlope">—</strong></span> | |
| <span>Conf: <strong id="hudConf">—</strong></span> | |
| </div> | |
| <canvas id="mainCanvas"></canvas> | |
| <canvas id="webglCanvas" style="position:absolute; top:0; left:0; width:100%; height:100%; display:none; z-index:5;"></canvas> | |
| </div> | |
| <div class="sub-panels"> | |
| <div class="sub-panel"> | |
| <div class="sp-head"><span>Relative Depth (Disparity)</span></div> | |
| <div class="sp-body"><canvas id="depthCanvas"></canvas><div class="legend" style="background:linear-gradient(to bottom, #fde725, #cc4778, #0d0887);"></div><div class="legend-tag top">Near</div><div class="legend-tag bottom">Far</div></div> | |
| </div> | |
| <div class="sub-panel"> | |
| <div class="sp-head"><span>Digital Surface Model (DSM)</span></div> | |
| <div class="sp-body"><canvas id="dsmCanvas"></canvas><div class="legend" style="background:linear-gradient(to bottom, #d73027, #fee08b, #66bd63, #1a9850);"></div><div class="legend-tag top">High<br>35 m</div><div class="legend-tag bottom">Low<br>10 m</div></div> | |
| </div> | |
| <div class="sub-panel"> | |
| <div class="sp-head"><span>Error & Uncertainty Map</span></div> | |
| <div class="sp-body"><canvas id="errCanvas"></canvas><div class="legend" style="background:linear-gradient(to bottom, #fcffa4, #f98e09, #bc3754, #57106e, #000004);"></div><div class="legend-tag top">High<br>5 m</div><div class="legend-tag bottom">Low<br>0 m</div></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-right"> | |
| <div class="card"> | |
| <div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19h16M8 19V9m4 10V5m4 14v-7"/></svg>ANALYSIS</div> | |
| <div class="stat2x2"> | |
| <div class="stat-box"><div class="slabel2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 20l6-16 4 10 3-6 3 12"/></svg>Height Range</div><div class="sval" id="statHeight">—</div></div> | |
| <div class="stat-box"><div class="slabel2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 20h18M3 20L15 6l6 14"/></svg>Slope Range</div><div class="sval" id="statSlope">—</div></div> | |
| <div class="stat-box"><div class="slabel2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 3l7 4v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V7z"/></svg>Confidence</div><div class="sval" id="statConf">—</div></div> | |
| <div class="stat-box"><div class="slabel2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="9" width="16" height="12"/><path d="M9 21v-5h6v5M9 9V4h6v5"/></svg>Mode</div><div class="sval" id="statBuilding" style="font-size:15px;">—</div></div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 21V9m8 12V3m8 18v-7"/></svg>CALIBRATION SOURCES</div> | |
| <div class="calib-row"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/></svg><span class="cname">Monocular Depth</span><span class="cweight">—</span></div> | |
| <div class="calib-row"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 17l5-9 4 6 3-5 6 8"/></svg><span class="cname">Spatial Reference</span><span class="cweight">—</span></div> | |
| <div class="calib-row"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="7" r="4"/><path d="M4 20c0-3.5 3.5-6 8-6s8 2.5 8 6"/></svg><span class="cname">Shadow Constraint</span><span class="cweight">—</span></div> | |
| <div class="calib-row"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="10" width="4" height="10"/><rect x="10" y="6" width="4" height="14"/><rect x="16" y="3" width="4" height="17"/></svg><span class="cname">Calibration Method</span><span class="cweight">—</span></div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 6h16M4 12h10M4 18h6"/></svg>OUTPUT INFO</div> | |
| <div class="datasource-row"> | |
| <svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="2"/></svg> | |
| Mode | |
| <svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M4 12l5 5L20 6"/></svg> | |
| </div> | |
| <div class="datasource-row"> | |
| <svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/></svg> | |
| Depth Model | |
| <svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M4 12l5 5L20 6"/></svg> | |
| </div> | |
| <div class="datasource-row"> | |
| <svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 17l5-9 4 6 3-5 6 8"/></svg> | |
| CRS / Transform | |
| <svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M4 12l5 5L20 6"/></svg> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="12" width="4" height="8"/><rect x="10" y="8" width="4" height="12"/><rect x="17" y="4" width="4" height="16"/></svg>CALIBRATION RESULT</div> | |
| <div class="cmp-row"><div class="cmp-top"><span class="cmpname">Linear (OLS)</span><span class="cmpval">—</span></div><div class="cmp-bar-track"><div class="cmp-bar-fill" style="width:100%"></div></div></div> | |
| <div class="cmp-row"><div class="cmp-top"><span class="cmpname">Non-linear</span><span class="cmpval">—</span></div><div class="cmp-bar-track"><div class="cmp-bar-fill" style="width:80%"></div></div></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="bottom-row"> | |
| <div class="card" style="margin-top:0;"> | |
| <div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="9"/></svg>VALIDATION (vs Reference DSM)</div> | |
| <div class="val-grid"> | |
| <div class="val-box"><div class="vlabel">MAE</div><div class="vval" id="valMAE">—</div></div> | |
| <div class="val-box"><div class="vlabel">RMSE</div><div class="vval" id="valRMSE">—</div></div> | |
| <div class="val-box"><div class="vlabel">R² (Correlation)</div><div class="vval" id="valR2">—</div></div> | |
| <div class="val-box"><div class="vlabel">Bias</div><div class="vval" id="valBias">—</div></div> | |
| </div> | |
| </div> | |
| <div class="card" style="margin-top:0;"> | |
| <div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 17l5-8 4 5 4-9 5 12"/></svg>ELEVATION PROFILE (Sample Line)</div> | |
| <div class="elev-legend"> | |
| <span><span class="swatch"></span>Predicted DSM</span> | |
| <span><span class="swatch dashed"></span>Reference DSM</span> | |
| </div> | |
| <canvas id="elevChart" style="width:100%;height:160px;"></canvas> | |
| </div> | |
| <div class="card" style="margin-top:0;"> | |
| <div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>3D CONTROLS (sat2Map 3D Suite)</div> | |
| <div class="controls-grid"> | |
| <div class="ctrl-box" id="btnOrbit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="2"/></svg><span class="cl">Orbit</span></div> | |
| <div class="ctrl-box" id="btnFly"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 12l20-8-8 20-3-9-9-3z"/></svg><span class="cl">Fly</span></div> | |
| <div class="ctrl-box" id="btnZoom"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3M11 8v6M8 11h6"/></svg><span class="cl">Zoom</span></div> | |
| <div class="ctrl-box" id="btnMeasure"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 22L22 2M15 5l4 4M10 10l4 4M5 15l4 4"/></svg><span class="cl">Measure</span></div> | |
| <div class="ctrl-box" id="btnSnap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="6" width="18" height="13" rx="2"/><circle cx="12" cy="12.5" r="3.5"/><path d="M8 6l1.5-2h5L16 6"/></svg><span class="cl">Snapshot</span></div> | |
| </div> | |
| <div style="margin-top:10px;padding:8px 10px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--fg-dim);margin-bottom:6px;"> | |
| <span>Vertical Exaggeration</span> | |
| <span id="heightScaleVal" style="font-family:var(--mono);font-weight:600;color:var(--fg);">1.0x</span> | |
| </div> | |
| <input type="range" id="heightScaleSlider" min="0.2" max="3.0" step="0.1" value="1.0" style="width:100%;cursor:pointer;accent-color:#38bdf8;"> | |
| <div style="display:flex;gap:4px;margin-top:8px;flex-wrap:wrap;"> | |
| <button type="button" class="tex-btn active" data-tex="rgb">RGB</button> | |
| <button type="button" class="tex-btn" data-tex="dsm">DSM</button> | |
| <button type="button" class="tex-btn" data-tex="slope">Slope</button> | |
| <button type="button" class="tex-btn" data-tex="facets">Facets</button> | |
| <button type="button" class="tex-btn" data-tex="points">Points</button> | |
| <button type="button" class="tex-btn" data-tex="wire">Wire</button> | |
| </div> | |
| <div id="measureHud" style="display:none;margin-top:8px;padding:6px 8px;background:rgba(34,197,94,0.12);border:1px solid #22c55e;border-radius:6px;font-family:var(--mono);font-size:10.5px;color:#86efac;justify-content:space-between;align-items:center;"> | |
| <span id="measureText">Click 2 points on 3D terrain</span> | |
| <span id="measureClear" style="cursor:pointer;color:#ef4444;font-weight:bold;margin-left:6px;padding:0 4px;" title="Clear Measurement">✕</span> | |
| </div> | |
| <div style="display:flex;gap:6px;margin-top:8px;"> | |
| <button type="button" id="btnExportPly" style="flex:1;background:var(--panel);border:1px solid var(--line);color:var(--fg);border-radius:6px;padding:6px 6px;font-size:10.5px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:4px;"> | |
| <svg style="width:12px;height:12px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg> | |
| .PLY Points | |
| </button> | |
| <button type="button" id="btnExportObj" style="flex:1;background:var(--panel);border:1px solid var(--line);color:var(--fg);border-radius:6px;padding:6px 6px;font-size:10.5px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:4px;"> | |
| <svg style="width:12px;height:12px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg> | |
| .OBJ Mesh | |
| </button> | |
| </div> | |
| <div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--line);"> | |
| <div style="font-size:10px;font-family:var(--mono);color:var(--fg-dim);text-transform:uppercase;margin-bottom:4px;">Edge Style (Three.js)</div> | |
| <div style="display:flex;gap:4px;"> | |
| <button type="button" class="tex-btn active" id="btnEdgeSharp">Sharp (Walls)</button> | |
| <button type="button" class="tex-btn" id="btnEdgeSmooth">Smooth</button> | |
| </div> | |
| <div style="font-size:10px;font-family:var(--mono);color:var(--fg-dim);text-transform:uppercase;margin-top:6px;margin-bottom:4px;">Render Mode</div> | |
| <div style="display:flex;gap:4px;"> | |
| <button type="button" class="tex-btn active" id="btnModeTerrain">Terrain</button> | |
| <button type="button" class="tex-btn" id="btnModeBlocks">Building Blocks</button> | |
| </div> | |
| <div style="font-size:10px;font-family:var(--mono);color:var(--fg-dim);text-transform:uppercase;margin-top:6px;margin-bottom:4px;">Terrain Denoising</div> | |
| <div style="display:flex;gap:4px;"> | |
| <button type="button" class="tex-btn active" id="btnFilterMedian">Median Denoise: ON</button> | |
| <button type="button" class="tex-btn" id="btnFilterRaw">Raw</button> | |
| </div> | |
| <a href="m5_3d_viewer_sharp.html" target="_blank" style="display:flex;align-items:center;justify-content:center;gap:6px;margin-top:10px;padding:7px;background:rgba(56,189,248,0.12);border:1px solid #38bdf8;border-radius:6px;color:#38bdf8;font-size:11px;font-weight:600;text-decoration:none;"> | |
| <svg style="width:14px;height:14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/></svg> | |
| Launch Full M5 Sharp 3D Viewer ↗ | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| (function(){ | |
| let selectedFile = null; | |
| let lastResult = null; | |
| const API_BASE = ''; | |
| // Three.js State | |
| let threeRenderer = null, threeScene = null, threeCamera = null, threeControls = null; | |
| let terrainMesh = null; | |
| let isFlying = false; | |
| let flyAngle = 0; | |
| let animFrameId = null; | |
| let webglCanvas = null; | |
| function fitCanvas(canvas){ | |
| if(!canvas) return null; | |
| const rect = canvas.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| const parentRect = canvas.parentElement ? canvas.parentElement.getBoundingClientRect() : null; | |
| const w = rect.width > 20 ? rect.width : (parentRect && parentRect.width > 20 ? parentRect.width : (canvas.clientWidth || 640)); | |
| const h = rect.height > 20 ? rect.height : (parentRect && parentRect.height > 20 ? parentRect.height : (canvas.clientHeight || 400)); | |
| canvas.width = Math.max(64, Math.floor(w * dpr)); | |
| canvas.height = Math.max(64, Math.floor(h * dpr)); | |
| return canvas.getContext('2d'); | |
| } | |
| function drawBase64ToCanvas(canvas, b64url, fitMode){ | |
| if(!canvas) return Promise.resolve(); | |
| fitMode = fitMode || 'contain'; | |
| return new Promise(resolve => { | |
| if(!b64url){ resolve(); return; } | |
| const img = new Image(); | |
| img.onload = function(){ | |
| const ctx = fitCanvas(canvas); | |
| if(!ctx){ resolve(); return; } | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.imageSmoothingQuality = 'high'; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| const cw = canvas.width; | |
| const ch = canvas.height; | |
| const iw = img.naturalWidth || img.width || cw; | |
| const ih = img.naturalHeight || img.height || ch; | |
| if(fitMode === 'contain'){ | |
| const scale = Math.min(cw / iw, ch / ih); | |
| const dw = iw * scale; | |
| const dh = ih * scale; | |
| const dx = (cw - dw) / 2; | |
| const dy = (ch - dh) / 2; | |
| ctx.drawImage(img, dx, dy, dw, dh); | |
| } else { | |
| ctx.drawImage(img, 0, 0, cw, ch); | |
| } | |
| resolve(); | |
| }; | |
| img.onerror = function(err){ | |
| console.warn('Failed to load image for canvas:', err); | |
| resolve(); | |
| }; | |
| img.src = b64url; | |
| }); | |
| } | |
| function formatBytes(bytes){ | |
| if(bytes < 1024) return bytes + ' B'; | |
| if(bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB'; | |
| return (bytes/1048576).toFixed(1) + ' MB'; | |
| } | |
| function extLabel(name){ | |
| const ext = (name||'').split('.').pop().toLowerCase(); | |
| if(ext==='tif'||ext==='tiff') return 'GeoTIFF'; | |
| if(ext==='png') return 'PNG'; | |
| if(ext==='jpg'||ext==='jpeg') return 'JPEG'; | |
| return ext.toUpperCase(); | |
| } | |
| const dropzone = document.getElementById('dropzone'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const fileCard = document.getElementById('fileCard'); | |
| const processBtn = document.getElementById('processBtn'); | |
| const statusText = document.getElementById('statusText'); | |
| const mainCanvas = document.getElementById('mainCanvas'); | |
| const mainView = document.getElementById('mainView'); | |
| // Hero CTA smooth scroll | |
| const heroCta = document.querySelector('.hero-cta'); | |
| if(heroCta){ | |
| heroCta.addEventListener('click', function(){ | |
| document.getElementById('dashboard').scrollIntoView({ behavior: 'smooth' }); | |
| }); | |
| } | |
| // Prevent browser window from opening/downloading dropped image files | |
| ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function(evt){ | |
| window.addEventListener(evt, function(e){ | |
| if(evt === 'dragover' || evt === 'drop') { | |
| e.preventDefault(); | |
| } | |
| }, false); | |
| }); | |
| // Dropzone drag & drop handlers | |
| ['dragenter', 'dragover'].forEach(function(evt){ | |
| dropzone.addEventListener(evt, function(e){ | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| dropzone.classList.add('drag'); | |
| }); | |
| }); | |
| ['dragleave', 'dragend'].forEach(function(evt){ | |
| dropzone.addEventListener(evt, function(e){ | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| dropzone.classList.remove('drag'); | |
| }); | |
| }); | |
| dropzone.addEventListener('drop', function(e){ | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| dropzone.classList.remove('drag'); | |
| if(e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) { | |
| loadFile(e.dataTransfer.files[0]); | |
| } | |
| }); | |
| // Click handlers for file upload | |
| dropzone.addEventListener('click', function(e){ | |
| fileInput.value = ''; | |
| fileInput.click(); | |
| }); | |
| var browseBtn = document.getElementById('browseBtn'); | |
| if(browseBtn){ | |
| browseBtn.addEventListener('click', function(e){ | |
| e.stopPropagation(); | |
| fileInput.value = ''; | |
| fileInput.click(); | |
| }); | |
| } | |
| fileInput.addEventListener('click', function(e){ | |
| e.stopPropagation(); | |
| }); | |
| fileInput.addEventListener('change', function(e){ | |
| if(e.target.files && e.target.files.length) { | |
| loadFile(e.target.files[0]); | |
| } | |
| }); | |
| // Preset sample buttons loader | |
| async function loadPresetSample(url, filename){ | |
| statusText.textContent = 'Fetching ' + filename + '...'; | |
| try { | |
| var resp = await fetch(url); | |
| if(!resp.ok) throw new Error('Could not fetch sample ' + resp.status); | |
| var blob = await resp.blob(); | |
| var mime = filename.endsWith('.tif') || filename.endsWith('.tiff') ? 'image/tiff' : (filename.endsWith('.png') ? 'image/png' : 'image/jpeg'); | |
| var sampleFile = new File([blob], filename, { type: mime }); | |
| loadFile(sampleFile); | |
| } catch(err) { | |
| console.error('Preset loading error:', err); | |
| statusText.textContent = 'Failed to load sample: ' + err.message; | |
| } | |
| } | |
| document.querySelectorAll('.sample-preset-btn').forEach(function(btn){ | |
| btn.addEventListener('click', function(e){ | |
| e.stopPropagation(); | |
| var url = this.getAttribute('data-url'); | |
| var name = this.getAttribute('data-name'); | |
| loadPresetSample(url, name); | |
| }); | |
| }); | |
| document.getElementById('removeFile').addEventListener('click', function(e){ | |
| e.stopPropagation(); | |
| fileInput.value = ''; | |
| fileCard.style.display = 'none'; | |
| selectedFile = null; | |
| processBtn.disabled = true; | |
| processBtn.classList.remove('ready'); | |
| dropzone.classList.remove('has-file'); | |
| var thumb = document.getElementById('fileThumb'); | |
| if(thumb) thumb.remove(); | |
| var badge = document.getElementById('fileBadge'); | |
| if(badge) badge.remove(); | |
| statusText.textContent = 'System Ready'; | |
| }); | |
| function loadFile(file){ | |
| selectedFile = file; | |
| dropzone.classList.add('has-file'); | |
| processBtn.disabled = false; | |
| processBtn.classList.add('ready'); | |
| statusText.textContent = '✓ Ready to Process'; | |
| var dot = document.querySelector('.status .dot'); | |
| if(dot){ dot.style.background = '#22c55e'; dot.style.boxShadow = '0 0 0 4px rgba(34,197,94,0.3)'; } | |
| document.getElementById('fnameText').textContent = file.name; | |
| document.getElementById('fsize').textContent = formatBytes(file.size); | |
| const label = extLabel(file.name); | |
| document.getElementById('ftype').textContent = label; | |
| fileCard.style.display = 'block'; | |
| let badge = document.getElementById('fileBadge'); | |
| if(!badge){ | |
| badge = document.createElement('div'); | |
| badge.id = 'fileBadge'; | |
| badge.className = 'upload-badge'; | |
| badge.innerHTML = '<span>●</span> IMAGE LOADED'; | |
| fileCard.insertBefore(badge, fileCard.firstChild); | |
| } | |
| document.getElementById('fcrs').textContent = 'Pending analysis...'; | |
| document.getElementById('fres').textContent = '—'; | |
| document.getElementById('fdims').textContent = '—'; | |
| if(label === 'GeoTIFF'){ | |
| document.getElementById('modeGeoOpt').classList.add('active'); | |
| document.getElementById('modeRelOpt').classList.remove('active'); | |
| } else { | |
| document.getElementById('modeRelOpt').classList.add('active'); | |
| document.getElementById('modeGeoOpt').classList.remove('active'); | |
| } | |
| // Switch view to overview so the preview is immediately active | |
| currentTab = 'overview'; | |
| document.querySelectorAll('.tab').forEach(function(t){ t.classList.toggle('active', t.dataset.tab === 'overview'); }); | |
| document.getElementById('viewLabel').textContent = 'INPUT PREVIEW: ' + file.name.toUpperCase(); | |
| if(webglCanvas) webglCanvas.style.display = 'none'; | |
| mainCanvas.style.display = 'block'; | |
| // Load preview image onto thumbnail and main canvas | |
| if(label !== 'GeoTIFF' && !file.name.toLowerCase().endsWith('.tif') && !file.name.toLowerCase().endsWith('.tiff')){ | |
| const reader = new FileReader(); | |
| reader.onload = function(e){ | |
| const dataUrl = e.target.result; | |
| currentImages.rgb = dataUrl; | |
| let thumb = document.getElementById('fileThumb'); | |
| if(!thumb){ | |
| thumb = document.createElement('img'); | |
| thumb.id = 'fileThumb'; | |
| thumb.className = 'file-preview-thumb'; | |
| fileCard.insertBefore(thumb, badge.nextSibling); | |
| } | |
| thumb.src = dataUrl; | |
| thumb.style.display = 'block'; | |
| drawBase64ToCanvas(mainCanvas, dataUrl, 'contain'); | |
| }; | |
| reader.readAsDataURL(file); | |
| } else { | |
| var ctx = fitCanvas(mainCanvas); | |
| if(ctx){ | |
| ctx.fillStyle = '#101012'; | |
| ctx.fillRect(0, 0, mainCanvas.width, mainCanvas.height); | |
| ctx.fillStyle = '#22c55e'; | |
| ctx.font = 'bold 16px sans-serif'; | |
| ctx.fillText('GeoTIFF Raster Loaded: ' + file.name, 24, mainCanvas.height / 2 - 10); | |
| ctx.fillStyle = '#93938f'; | |
| ctx.font = '13px monospace'; | |
| ctx.fillText('Click "Process Image" to decode raster and generate 3D elevation', 24, mainCanvas.height / 2 + 18); | |
| } | |
| } | |
| } | |
| document.getElementById('modeGeoOpt').addEventListener('click', function(){ | |
| this.classList.add('active'); | |
| document.getElementById('modeRelOpt').classList.remove('active'); | |
| }); | |
| document.getElementById('modeRelOpt').addEventListener('click', function(){ | |
| this.classList.add('active'); | |
| document.getElementById('modeGeoOpt').classList.remove('active'); | |
| }); | |
| var steps = document.querySelectorAll('.step'); | |
| var stepLines = document.querySelectorAll('.step-line'); | |
| function setStep(activeIdx, doneUpTo){ | |
| steps.forEach(function(s,i){ | |
| s.classList.remove('active','done'); | |
| if(i < doneUpTo) s.classList.add('done'); | |
| else if(i === activeIdx) s.classList.add('active'); | |
| }); | |
| stepLines.forEach(function(l,i){ l.classList.toggle('done', i < doneUpTo); }); | |
| } | |
| processBtn.addEventListener('click', function(){ | |
| if(!selectedFile || processBtn.disabled) return; | |
| processImage(); | |
| }); | |
| function populateFileCardFromResponse(r){ | |
| var meta = r.metadata || {}; | |
| document.getElementById('fcrs').textContent = r.crs || 'Non-georeferenced'; | |
| var gsd = meta.gsd_x || r.gsd_x; | |
| document.getElementById('fres').textContent = gsd ? (parseFloat(gsd).toFixed(3) + ' m/px') : '—'; | |
| document.getElementById('fdims').textContent = r.width + ' \u00d7 ' + r.height; | |
| } | |
| function updateAnalysisStats(r){ | |
| var hMin = r.height_min, hMax = r.height_max; | |
| document.getElementById('statHeight').textContent = (hMin !== undefined && hMax !== undefined) ? (hMin.toFixed(1) + ' \u2013 ' + hMax.toFixed(1) + ' ' + r.height_unit) : '—'; | |
| document.getElementById('statSlope').textContent = (r.slope_min !== undefined && r.slope_max !== undefined) ? (r.slope_min.toFixed(1) + '\u00b0 \u2013 ' + r.slope_max.toFixed(1) + '\u00b0') : '—'; | |
| document.getElementById('statConf').textContent = r.confidence_mean !== undefined ? ((r.confidence_mean * 100).toFixed(0) + '%') : '—'; | |
| document.getElementById('statBuilding').textContent = r.mode === 'absolute' ? 'Metric DSM' : 'Relative DSM'; | |
| } | |
| function updateCalibrationSources(r){ | |
| var cal = (r.metadata && r.metadata.calibration) || {}; | |
| var rows = document.querySelectorAll('.calib-row'); | |
| if(rows[0]){ | |
| rows[0].querySelector('.cname').textContent = 'Monocular Depth (DAV2)'; | |
| rows[0].querySelector('.cweight').textContent = 'Method: ' + (cal.method || 'none'); | |
| } | |
| if(rows[1]){ | |
| rows[1].querySelector('.cname').textContent = r.crs ? 'Geospatial Reference' : 'No CRS'; | |
| rows[1].querySelector('.cweight').textContent = r.gsd_x ? ('GSD: ' + r.gsd_x + ' m') : '—'; | |
| } | |
| if(rows[2]){ | |
| rows[2].querySelector('.cname').textContent = 'Shadow Constraint'; | |
| rows[2].querySelector('.cweight').textContent = (r.metadata && r.metadata.shadow_mode) || 'disabled'; | |
| } | |
| if(rows[3]){ | |
| rows[3].querySelector('.cname').textContent = 'Calibration Method'; | |
| rows[3].querySelector('.cweight').textContent = (cal.method === 'gcp' ? 'User GCPs' : (cal.method === 'dem' ? 'DEM / SRTM' : 'Relative')); | |
| } | |
| // Data sources checkmarks | |
| const dsShadow = document.getElementById('dsShadow'); | |
| if(dsShadow){ | |
| if(r.metadata && r.metadata.shadow_mode && r.metadata.shadow_mode !== 'disabled') dsShadow.classList.remove('pending'); | |
| else dsShadow.classList.add('pending'); | |
| } | |
| const dsDEM = document.getElementById('dsDEM'); | |
| if(dsDEM){ | |
| if(cal.method === 'dem') dsDEM.classList.remove('pending'); | |
| else dsDEM.classList.add('pending'); | |
| } | |
| } | |
| function updateMethodComparison(r){ | |
| var card = document.querySelector('.col-right'); | |
| var cal = (r.metadata && r.metadata.calibration) || {}; | |
| var cmpDiv = card.querySelector('.card:last-child'); | |
| if(!cmpDiv) return; | |
| var rows = cmpDiv.querySelectorAll('.cmp-row'); | |
| if(rows[0]){ | |
| rows[0].querySelector('.cmpname').textContent = 'Linear (OLS)'; | |
| rows[0].querySelector('.cmpval').textContent = cal.gcp_mae ? (cal.gcp_mae.toFixed(2) + ' m MAE') : (cal.scale_a ? ('a=' + cal.scale_a.toFixed(2)) : 'baseline'); | |
| } | |
| if(rows[1]){ | |
| rows[1].querySelector('.cmpname').textContent = cal.method === 'dem' ? 'DEM Datum Fit' : 'Prior / RMSE'; | |
| rows[1].querySelector('.cmpval').textContent = cal.gcp_rmse ? (cal.gcp_rmse.toFixed(2) + ' m RMSE') : (cal.terrain_anchor_elevation ? (cal.terrain_anchor_elevation.toFixed(1) + ' m datum') : '—'); | |
| } | |
| } | |
| function updateValidation(r){ | |
| var v = r.validation; | |
| if(!v){ | |
| document.getElementById('valMAE').textContent = '—'; | |
| document.getElementById('valRMSE').textContent = '—'; | |
| document.getElementById('valR2').textContent = '—'; | |
| document.getElementById('valBias').textContent = '—'; | |
| return; | |
| } | |
| document.getElementById('valMAE').textContent = v.mae != null ? v.mae.toFixed(2) + ' m' : '—'; | |
| document.getElementById('valRMSE').textContent = v.rmse != null ? v.rmse.toFixed(2) + ' m' : '—'; | |
| document.getElementById('valR2').textContent = v.r2 != null ? v.r2.toFixed(3) : '—'; | |
| document.getElementById('valBias').textContent = v.bias != null ? ((v.bias >= 0 ? '+' : '') + v.bias.toFixed(2) + ' m') : '—'; | |
| } | |
| function drawElevChart(profile){ | |
| var canvas = document.getElementById('elevChart'); | |
| if(!canvas) return; | |
| var rect = canvas.getBoundingClientRect(); | |
| var dpr = window.devicePixelRatio || 1; | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| var ctx = canvas.getContext('2d'); | |
| ctx.scale(dpr, dpr); | |
| var W = rect.width, H = rect.height; | |
| var padL = 46, padB = 20, padT = 10, padR = 8; | |
| var plotW = W - padL - padR, plotH = H - padT - padB; | |
| ctx.clearRect(0, 0, W, H); | |
| // profile is an array of {x, elevation} objects | |
| var rawProfile = profile || []; | |
| if(!rawProfile.length){ return; } | |
| // Extract numeric elevation values, replace nulls with 0 | |
| var vals = rawProfile.map(function(pt){ | |
| var v = (pt && pt.elevation !== undefined && pt.elevation !== null) ? pt.elevation : null; | |
| return v; | |
| }); | |
| var validVals = vals.filter(function(v){ return v !== null && isFinite(v); }); | |
| if(!validVals.length){ return; } | |
| var mn = Math.min.apply(null, validVals); | |
| var mx = Math.max.apply(null, validVals); | |
| var span = (mx - mn) > 1e-6 ? (mx - mn) : 1; | |
| var unit = (lastResult && lastResult.height_unit) ? lastResult.height_unit : ''; | |
| ctx.strokeStyle = '#232326'; | |
| ctx.lineWidth = 1; | |
| ctx.font = '10px IBM Plex Mono, monospace'; | |
| ctx.fillStyle = '#5c5c60'; | |
| for(var g = 0; g <= 4; g++){ | |
| var v = mn + (span * g / 4); | |
| var yy = padT + plotH - (g / 4) * plotH; | |
| ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(W - padR, yy); ctx.stroke(); | |
| ctx.fillText(v.toFixed(1) + (unit ? ' ' + unit : ''), 2, yy + 3); | |
| } | |
| ctx.setLineDash([]); | |
| ctx.strokeStyle = '#f2f2f0'; | |
| ctx.lineWidth = 1.8; | |
| ctx.beginPath(); | |
| var started = false; | |
| for(var i = 0; i < vals.length; i++){ | |
| var xp = padL + (i / (vals.length - 1)) * plotW; | |
| var vv = vals[i]; | |
| if(vv === null || !isFinite(vv)){ started = false; continue; } | |
| var yp = padT + plotH - ((vv - mn) / span) * plotH; | |
| if(!started){ ctx.moveTo(xp, yp); started = true; } else { ctx.lineTo(xp, yp); } | |
| } | |
| ctx.stroke(); | |
| } | |
| function updateExportBtn(r){ | |
| var btn = document.querySelector('.btn-export'); | |
| if(btn && r.export_url){ | |
| btn.onclick = function(){ window.location.href = API_BASE + r.export_url; }; | |
| btn.style.opacity = '1'; | |
| } | |
| } | |
| var currentTab = 'overview'; | |
| var currentImages = {}; | |
| var tabLabels = {overview:'RGB IMAGE', depth:'RELATIVE DEPTH', dsm:'DSM (METRIC)', error:'ERROR MAP', '3d':'3D VIEW'}; | |
| document.querySelectorAll('.tab').forEach(function(btn){ | |
| btn.addEventListener('click', function(){ | |
| document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); }); | |
| btn.classList.add('active'); | |
| currentTab = btn.dataset.tab; | |
| document.getElementById('viewLabel').textContent = tabLabels[currentTab] || 'RGB IMAGE'; | |
| renderActiveTab(); | |
| }); | |
| }); | |
| // Sub-panel click to switch tabs | |
| document.getElementById('depthCanvas').parentElement.parentElement.addEventListener('click', function(){ | |
| document.querySelector('.tab[data-tab="depth"]').click(); | |
| }); | |
| document.getElementById('dsmCanvas').parentElement.parentElement.addEventListener('click', function(){ | |
| document.querySelector('.tab[data-tab="dsm"]').click(); | |
| }); | |
| document.getElementById('errCanvas').parentElement.parentElement.addEventListener('click', function(){ | |
| document.querySelector('.tab[data-tab="error"]').click(); | |
| }); | |
| function updateCursorHud(u, v){ | |
| if(!lastResult) return; | |
| var w = lastResult.visual_width || 256; | |
| var h = lastResult.visual_height || 256; | |
| var x = Math.min(w - 1, Math.max(0, Math.floor(u * w))); | |
| var y = Math.min(h - 1, Math.max(0, Math.floor(v * h))); | |
| var idx = y * w + x; | |
| var elev = (lastResult.height_map && lastResult.height_map[idx] !== undefined) ? lastResult.height_map[idx] : null; | |
| var slope = (lastResult.slope_map && lastResult.slope_map[idx] !== undefined) ? lastResult.slope_map[idx] : null; | |
| var conf = (lastResult.confidence_map && lastResult.confidence_map[idx] !== undefined) ? lastResult.confidence_map[idx] : null; | |
| var unit = lastResult.height_unit || 'm'; | |
| var origW = lastResult.width || w; | |
| var origH = lastResult.height || h; | |
| var origX = Math.min(origW - 1, Math.max(0, Math.floor(u * origW))); | |
| var origY = Math.min(origH - 1, Math.max(0, Math.floor(v * origH))); | |
| document.getElementById('hudCoord').textContent = 'X: ' + origX + ' | Y: ' + origY; | |
| document.getElementById('hudElev').textContent = (elev !== null) ? elev.toFixed(1) + ' ' + unit : '—'; | |
| document.getElementById('hudSlope').textContent = (slope !== null) ? slope.toFixed(1) + '°' : '—'; | |
| document.getElementById('hudConf').textContent = (conf !== null) ? Math.round(conf * 100) + '%' : '—'; | |
| } | |
| // 2D Canvas Cursor Mapping | |
| mainCanvas.addEventListener('mousemove', function(e){ | |
| var rect = mainCanvas.getBoundingClientRect(); | |
| var u = (e.clientX - rect.left) / rect.width; | |
| var v = (e.clientY - rect.top) / rect.height; | |
| if(u >= 0 && u <= 1 && v >= 0 && v <= 1){ | |
| updateCursorHud(u, v); | |
| } | |
| }); | |
| // ========================================================================= | |
| // THREE.JS 3D WEBGL TERRAIN ENGINE & ROBUST ORBIT CONTROLS | |
| // ========================================================================= | |
| class RobustOrbitControls { | |
| constructor(camera, domElement) { | |
| this.camera = camera; | |
| this.domElement = domElement; | |
| this.target = new THREE.Vector3(0, 0, 0); | |
| this.enabled = true; | |
| this.enableDamping = true; | |
| this.dampingFactor = 0.08; | |
| this.minDistance = 2.0; | |
| this.maxDistance = 150.0; | |
| this.minPolarAngle = 0.05; | |
| this.maxPolarAngle = Math.PI / 2 - 0.02; | |
| const offset = new THREE.Vector3().copy(this.camera.position).sub(this.target); | |
| this.radius = Math.max(this.minDistance, offset.length()); | |
| this.theta = Math.atan2(offset.x, offset.z); | |
| this.phi = Math.acos(Math.max(-1, Math.min(1, offset.y / this.radius))); | |
| this.targetRadius = this.radius; | |
| this.targetTheta = this.theta; | |
| this.targetPhi = this.phi; | |
| this.isPointerDown = false; | |
| this.pointerStart = { x: 0, y: 0 }; | |
| this.activeButton = 0; | |
| this.domElement.addEventListener('pointerdown', (e) => { | |
| if (!this.enabled) return; | |
| this.isPointerDown = true; | |
| this.activeButton = e.button; | |
| this.pointerStart.x = e.clientX; | |
| this.pointerStart.y = e.clientY; | |
| try { this.domElement.setPointerCapture(e.pointerId); } catch(err){} | |
| }); | |
| window.addEventListener('pointermove', (e) => { | |
| if (!this.enabled || !this.isPointerDown) return; | |
| const dx = e.clientX - this.pointerStart.x; | |
| const dy = e.clientY - this.pointerStart.y; | |
| this.pointerStart.x = e.clientX; | |
| this.pointerStart.y = e.clientY; | |
| if (this.activeButton === 0) { // Left click / touch: Orbit | |
| this.targetTheta -= dx * 0.007; | |
| this.targetPhi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.targetPhi - dy * 0.007)); | |
| } else if (this.activeButton === 2) { // Right click: Pan | |
| const panSpeed = this.radius * 0.0015; | |
| const forward = new THREE.Vector3().subVectors(this.target, this.camera.position); | |
| forward.y = 0; | |
| forward.normalize(); | |
| const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize(); | |
| this.target.addScaledVector(right, -dx * panSpeed); | |
| this.target.y += dy * panSpeed; | |
| } | |
| }); | |
| window.addEventListener('pointerup', (e) => { | |
| this.isPointerDown = false; | |
| try { this.domElement.releasePointerCapture(e.pointerId); } catch(err){} | |
| }); | |
| this.domElement.addEventListener('wheel', (e) => { | |
| if (!this.enabled) return; | |
| e.preventDefault(); | |
| const factor = e.deltaY > 0 ? 1.08 : 0.92; | |
| this.targetRadius = Math.max(this.minDistance, Math.min(this.maxDistance, this.targetRadius * factor)); | |
| }, { passive: false }); | |
| this.domElement.addEventListener('contextmenu', e => e.preventDefault()); | |
| } | |
| update() { | |
| if (this.enableDamping) { | |
| this.radius += (this.targetRadius - this.radius) * this.dampingFactor; | |
| this.theta += (this.targetTheta - this.theta) * this.dampingFactor; | |
| this.phi += (this.targetPhi - this.phi) * this.dampingFactor; | |
| } else { | |
| this.radius = this.targetRadius; | |
| this.theta = this.targetTheta; | |
| this.phi = this.targetPhi; | |
| } | |
| const sinPhi = Math.sin(this.phi); | |
| this.camera.position.x = this.target.x + this.radius * sinPhi * Math.sin(this.theta); | |
| this.camera.position.y = this.target.y + this.radius * Math.cos(this.phi); | |
| this.camera.position.z = this.target.z + this.radius * sinPhi * Math.cos(this.theta); | |
| this.camera.lookAt(this.target); | |
| } | |
| reset(planeH) { | |
| planeH = planeH || 20; | |
| this.target.set(0, 1.0, 0); | |
| this.targetTheta = 0; | |
| this.targetPhi = Math.PI / 3.2; | |
| this.targetRadius = 18 + planeH * 0.45; | |
| } | |
| } | |
| var raycaster = null; | |
| var mouseVec = null; | |
| let cachedNormH = null; | |
| let cachedBaseScale = 1.5; | |
| let loadedTextures = { rgb: null, dsm: null, slope: null, error: null }; | |
| let current3DTexMode = 'rgb'; | |
| function initThreeJS(){ | |
| if(threeRenderer) return; | |
| if(typeof THREE === 'undefined') return; | |
| webglCanvas = document.getElementById('webglCanvas'); | |
| if(!webglCanvas) return; | |
| threeScene = new THREE.Scene(); | |
| threeScene.background = new THREE.Color(0x08080a); | |
| const rect = mainView.getBoundingClientRect(); | |
| threeCamera = new THREE.PerspectiveCamera(45, rect.width / rect.height, 0.1, 1000); | |
| threeCamera.position.set(0, 18, 22); | |
| threeRenderer = new THREE.WebGLRenderer({ canvas: webglCanvas, antialias: true, preserveDrawingBuffer: true }); | |
| threeRenderer.setSize(rect.width, rect.height); | |
| threeRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| threeRenderer.shadowMap.enabled = true; | |
| // Use robust built-in controller for 100% reliable orbit & pan & zoom | |
| threeControls = new RobustOrbitControls(threeCamera, webglCanvas); | |
| raycaster = new THREE.Raycaster(); | |
| mouseVec = new THREE.Vector2(); | |
| webglCanvas.addEventListener('mousemove', function(e){ | |
| if(!lastResult || !terrainMesh || !threeCamera || !raycaster) return; | |
| var r = webglCanvas.getBoundingClientRect(); | |
| mouseVec.x = ((e.clientX - r.left) / r.width) * 2 - 1; | |
| mouseVec.y = -((e.clientY - r.top) / r.height) * 2 + 1; | |
| raycaster.setFromCamera(mouseVec, threeCamera); | |
| var hits = raycaster.intersectObject(terrainMesh); | |
| if(hits.length > 0 && hits[0].uv){ | |
| var u = hits[0].uv.x; | |
| var v = 1.0 - hits[0].uv.y; | |
| updateCursorHud(u, v); | |
| } | |
| }); | |
| const ambient = new THREE.AmbientLight(0xffffff, 0.75); | |
| threeScene.add(ambient); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 0.85); | |
| dirLight.position.set(20, 35, 20); | |
| threeScene.add(dirLight); | |
| const fillLight = new THREE.DirectionalLight(0xbfdbfe, 0.40); | |
| fillLight.position.set(-20, 15, -20); | |
| threeScene.add(fillLight); | |
| const grid = new THREE.GridHelper(30, 30, 0x333339, 0x18181c); | |
| grid.position.y = -0.05; | |
| threeScene.add(grid); | |
| function animate(){ | |
| animFrameId = requestAnimationFrame(animate); | |
| if(isFlying && threeControls){ | |
| threeControls.targetTheta += 0.008; | |
| threeControls.update(); | |
| } else if(threeControls){ | |
| threeControls.update(); | |
| } | |
| threeRenderer.render(threeScene, threeCamera); | |
| } | |
| animate(); | |
| window.addEventListener('resize', function(){ | |
| if(threeRenderer && webglCanvas){ | |
| const r = mainView.getBoundingClientRect(); | |
| threeCamera.aspect = r.width / r.height; | |
| threeCamera.updateProjectionMatrix(); | |
| threeRenderer.setSize(r.width, r.height); | |
| } | |
| }); | |
| } | |
| let cachedPlaneW = 22; | |
| let cachedPlaneH = 22; | |
| let cachedGridW = 256; | |
| let cachedGridH = 256; | |
| let skirtMesh = null; | |
| function buildSkirtGeometry(planeW, planeH, gridW, gridH, heightsNorm, scaleY, bottomY = -0.25){ | |
| const geom = new THREE.BufferGeometry(); | |
| const positions = []; | |
| const indices = []; | |
| const idx = (row, col) => row * gridW + col; | |
| const xCoord = col => (col / (gridW - 1) - 0.5) * planeW; | |
| const zCoord = row => (row / (gridH - 1) - 0.5) * planeH; | |
| let vCount = 0; | |
| function addWallQuad(x1, z1, yTop1, x2, z2, yTop2){ | |
| positions.push( | |
| x1, yTop1, z1, | |
| x1, bottomY, z1, | |
| x2, yTop2, z2, | |
| x2, bottomY, z2 | |
| ); | |
| indices.push( | |
| vCount, vCount + 1, vCount + 2, | |
| vCount + 2, vCount + 1, vCount + 3 | |
| ); | |
| vCount += 4; | |
| } | |
| // Top edge | |
| for(let c = 0; c < gridW - 1; c++){ | |
| const y1 = (heightsNorm[idx(0, c)] || 0) * scaleY; | |
| const y2 = (heightsNorm[idx(0, c + 1)] || 0) * scaleY; | |
| addWallQuad(xCoord(c), zCoord(0), y1, xCoord(c + 1), zCoord(0), y2); | |
| } | |
| // Bottom edge | |
| for(let c = 0; c < gridW - 1; c++){ | |
| const y1 = (heightsNorm[idx(gridH - 1, c)] || 0) * scaleY; | |
| const y2 = (heightsNorm[idx(gridH - 1, c + 1)] || 0) * scaleY; | |
| addWallQuad(xCoord(c + 1), zCoord(gridH - 1), y2, xCoord(c), zCoord(gridH - 1), y1); | |
| } | |
| // Left edge | |
| for(let r = 0; r < gridH - 1; r++){ | |
| const y1 = (heightsNorm[idx(r, 0)] || 0) * scaleY; | |
| const y2 = (heightsNorm[idx(r + 1, 0)] || 0) * scaleY; | |
| addWallQuad(xCoord(0), zCoord(r + 1), y2, xCoord(0), zCoord(r), y1); | |
| } | |
| // Right edge | |
| for(let r = 0; r < gridH - 1; r++){ | |
| const y1 = (heightsNorm[idx(r, gridW - 1)] || 0) * scaleY; | |
| const y2 = (heightsNorm[idx(r + 1, gridW - 1)] || 0) * scaleY; | |
| addWallQuad(xCoord(gridW - 1), zCoord(r), y1, xCoord(gridW - 1), zCoord(r + 1), y2); | |
| } | |
| geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); | |
| geom.setIndex(indices); | |
| geom.computeVertexNormals(); | |
| return geom; | |
| } | |
| let pointCloudMesh = null; | |
| let isMeasuring = false; | |
| let measurePts = []; | |
| let measureMarkers = []; | |
| let measureLine = null; | |
| // --------------------------------------------------------------- | |
| // SHARP 3D TERRAIN & ADVANCED RENDERING PIPELINE (M5 Integration) | |
| // --------------------------------------------------------------- | |
| let edgeStyle = 'sharp'; // 'sharp' (faceted walls) | 'smooth' (interpolated) | |
| let renderMode3D = 'terrain'; // 'terrain' | 'blocks' | |
| let medianFilterEnabled = true; | |
| let buildingBlocksGroup = null; | |
| function smoothHeightGrid(heightData, width, height, passes = 1) { | |
| let src = Float32Array.from(heightData); | |
| const win = new Float32Array(9); | |
| for (let p = 0; p < passes; p++) { | |
| const dst = new Float32Array(src.length); | |
| for (let y = 0; y < height; y++) { | |
| for (let x = 0; x < width; x++) { | |
| let n = 0; | |
| for (let dy = -1; dy <= 1; dy++) { | |
| for (let dx = -1; dx <= 1; dx++) { | |
| const nx = x + dx, ny = y + dy; | |
| if (nx >= 0 && nx < width && ny >= 0 && ny < height) { | |
| const v = src[ny * width + nx]; | |
| if (isFinite(v)) { | |
| let k = n++; | |
| while (k > 0 && win[k - 1] > v) { win[k] = win[k - 1]; k--; } | |
| win[k] = v; | |
| } | |
| } | |
| } | |
| } | |
| dst[y * width + x] = n > 0 ? win[n >> 1] : 0; | |
| } | |
| } | |
| src = dst; | |
| } | |
| return src; | |
| } | |
| function buildSharpTerrainGeometry(heights, gridW, gridH, hMin, hMax, hSpan, planeW, planeH, scaleY) { | |
| const halfW = planeW / 2; | |
| const halfH = planeH / 2; | |
| const csX = planeW / gridW; | |
| const csZ = planeH / gridH; | |
| const N = gridW * gridH; | |
| const EPS = 1e-5; | |
| const STEP_LEVELS = 64; | |
| const BASE_Y = -0.35; | |
| const WALL_COLOR = [0.73, 0.76, 0.81]; | |
| const H = new Float32Array(N); | |
| for (let i = 0; i < N; i++) { | |
| const v = (heights[i] !== null && heights[i] !== undefined && isFinite(heights[i])) ? heights[i] : hMin; | |
| const c = Math.min(hMax, Math.max(hMin, v)); | |
| const q = Math.round(((c - hMin) / hSpan) * STEP_LEVELS) / STEP_LEVELS; | |
| H[i] = q * scaleY; | |
| } | |
| let wallQuads = 2 * (gridW + gridH); // skirts | |
| for (let gz = 0; gz < gridH; gz++) { | |
| for (let gx = 0; gx < gridW; gx++) { | |
| const i = gz * gridW + gx; | |
| if (gx < gridW - 1 && Math.abs(H[i] - H[i + 1]) > EPS) wallQuads++; | |
| if (gz < gridH - 1 && Math.abs(H[i] - H[i + gridW]) > EPS) wallQuads++; | |
| } | |
| } | |
| const quadCount = N + wallQuads; | |
| const pos = new Float32Array(quadCount * 12); | |
| const nor = new Float32Array(quadCount * 12); | |
| const col = new Float32Array(quadCount * 12); | |
| const uv = new Float32Array(quadCount * 8); | |
| const idx = new Uint32Array(quadCount * 6); | |
| let q = 0; | |
| const uvOf = (x, z) => [(x + halfW) / planeW, 1.0 - (z + halfH) / planeH]; | |
| function pushQuad(c, n, uvs, rgb) { | |
| const ax = c[1][0] - c[0][0], ay = c[1][1] - c[0][1], az = c[1][2] - c[0][2]; | |
| const bx = c[2][0] - c[0][0], by = c[2][1] - c[0][1], bz = c[2][2] - c[0][2]; | |
| const gxn = ay * bz - az * by, gyn = az * bx - ax * bz, gzn = ax * by - ay * bx; | |
| const order = (gxn * n[0] + gyn * n[1] + gzn * n[2]) < 0 ? [0, 3, 2, 1] : [0, 1, 2, 3]; | |
| const base = q * 4; | |
| for (let k = 0; k < 4; k++) { | |
| const s = order[k], o = base + k; | |
| pos[o * 3] = c[s][0]; pos[o * 3 + 1] = c[s][1]; pos[o * 3 + 2] = c[s][2]; | |
| nor[o * 3] = n[0]; nor[o * 3 + 1] = n[1]; nor[o * 3 + 2] = n[2]; | |
| uv[o * 2] = uvs[s][0]; uv[o * 2 + 1] = uvs[s][1]; | |
| col[o * 3] = rgb[0]; col[o * 3 + 1] = rgb[1]; col[o * 3 + 2] = rgb[2]; | |
| } | |
| idx[q * 6] = base; idx[q * 6 + 1] = base + 1; idx[q * 6 + 2] = base + 2; | |
| idx[q * 6 + 3] = base; idx[q * 6 + 4] = base + 2; idx[q * 6 + 5] = base + 3; | |
| q++; | |
| } | |
| // 1) Column tops | |
| for (let gz = 0; gz < gridH; gz++) { | |
| for (let gx = 0; gx < gridW; gx++) { | |
| const i = gz * gridW + gx; | |
| const x0 = -halfW + gx * csX, x1 = x0 + csX; | |
| const z0 = -halfH + gz * csZ, z1 = z0 + csZ; | |
| const y = H[i]; | |
| const c = [[x0, y, z0], [x1, y, z0], [x1, y, z1], [x0, y, z1]]; | |
| pushQuad(c, [0, 1, 0], c.map(p => uvOf(p[0], p[2])), [1, 1, 1]); | |
| } | |
| } | |
| const topQuads = q; | |
| // 2) Vertical walls & skirts | |
| const cellUV = (gx, gz) => [(gx + 0.5) / gridW, 1.0 - (gz + 0.5) / gridH]; | |
| const wallUV = (u) => [u, u, u, u]; | |
| for (let gz = 0; gz < gridH; gz++) { | |
| for (let gx = 0; gx < gridW; gx++) { | |
| const i = gz * gridW + gx; | |
| const x0 = -halfW + gx * csX, x1 = x0 + csX; | |
| const z0 = -halfH + gz * csZ, z1 = z0 + csZ; | |
| if (gx < gridW - 1) { | |
| const d = H[i] - H[i + 1]; | |
| if (Math.abs(d) > EPS) { | |
| const hi = Math.max(H[i], H[i + 1]), lo = Math.min(H[i], H[i + 1]); | |
| const u = d > 0 ? cellUV(gx, gz) : cellUV(gx + 1, gz); | |
| pushQuad([[x1, lo, z0], [x1, lo, z1], [x1, hi, z1], [x1, hi, z0]], | |
| d > 0 ? [1, 0, 0] : [-1, 0, 0], wallUV(u), WALL_COLOR); | |
| } | |
| } | |
| if (gz < gridH - 1) { | |
| const d = H[i] - H[i + gridW]; | |
| if (Math.abs(d) > EPS) { | |
| const hi = Math.max(H[i], H[i + gridW]), lo = Math.min(H[i], H[i + gridW]); | |
| const u = d > 0 ? cellUV(gx, gz) : cellUV(gx, gz + 1); | |
| pushQuad([[x0, lo, z1], [x1, lo, z1], [x1, hi, z1], [x0, hi, z1]], | |
| d > 0 ? [0, 0, 1] : [0, 0, -1], wallUV(u), WALL_COLOR); | |
| } | |
| } | |
| // Border skirts | |
| const u = cellUV(gx, gz), y = H[i], b = BASE_Y; | |
| if (gx === 0) pushQuad([[-halfW, b, z0], [-halfW, b, z1], [-halfW, y, z1], [-halfW, y, z0]], [-1, 0, 0], wallUV(u), WALL_COLOR); | |
| if (gx === gridW - 1) pushQuad([[halfW, b, z0], [halfW, b, z1], [halfW, y, z1], [halfW, y, z0]], [1, 0, 0], wallUV(u), WALL_COLOR); | |
| if (gz === 0) pushQuad([[x0, b, -halfH], [x1, b, -halfH], [x1, y, -halfH], [x0, y, -halfH]], [0, 0, -1], wallUV(u), WALL_COLOR); | |
| if (gz === gridH - 1) pushQuad([[x0, b, halfH], [x1, b, halfH], [x1, y, halfH], [x0, y, halfH]], [0, 0, 1], wallUV(u), WALL_COLOR); | |
| } | |
| } | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); | |
| geo.setAttribute('normal', new THREE.BufferAttribute(nor, 3)); | |
| geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); | |
| geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); | |
| geo.setIndex(new THREE.BufferAttribute(idx, 1)); | |
| geo.addGroup(0, topQuads * 6, 0); | |
| geo.addGroup(topQuads * 6, (q - topQuads) * 6, 1); | |
| return geo; | |
| } | |
| function applyHeightScale(mult){ | |
| if(!lastResult) return; | |
| build3DMesh(lastResult); | |
| } | |
| function set3DTextureMode(mode){ | |
| current3DTexMode = mode; | |
| document.querySelectorAll('.tex-btn').forEach(function(b){ | |
| if(b.dataset.tex) b.classList.toggle('active', b.dataset.tex === mode); | |
| }); | |
| if(!terrainMesh) return; | |
| if(mode === 'points'){ | |
| terrainMesh.visible = false; | |
| if(skirtMesh) skirtMesh.visible = false; | |
| if(pointCloudMesh) pointCloudMesh.visible = true; | |
| } else { | |
| if(pointCloudMesh) pointCloudMesh.visible = false; | |
| terrainMesh.visible = true; | |
| if(skirtMesh) skirtMesh.visible = (mode !== 'wire' && edgeStyle === 'smooth'); | |
| const targetMat = Array.isArray(terrainMesh.material) ? terrainMesh.material[0] : terrainMesh.material; | |
| if(mode === 'wire'){ | |
| targetMat.map = null; | |
| targetMat.wireframe = true; | |
| } else { | |
| targetMat.wireframe = false; | |
| if(loadedTextures[mode]){ | |
| targetMat.map = loadedTextures[mode]; | |
| } | |
| } | |
| targetMat.needsUpdate = true; | |
| } | |
| if(threeRenderer && threeScene && threeCamera) threeRenderer.render(threeScene, threeCamera); | |
| } | |
| function clear3DMeasurement(){ | |
| measurePts = []; | |
| measureMarkers.forEach(function(m){ | |
| if(threeScene) threeScene.remove(m); | |
| m.geometry.dispose(); | |
| m.material.dispose(); | |
| }); | |
| measureMarkers = []; | |
| if(measureLine && threeScene){ | |
| threeScene.remove(measureLine); | |
| measureLine.geometry.dispose(); | |
| measureLine.material.dispose(); | |
| measureLine = null; | |
| } | |
| var mText = document.getElementById('measureText'); | |
| if(mText) mText.textContent = 'Click 2 points on 3D terrain'; | |
| if(threeRenderer && threeScene && threeCamera) threeRenderer.render(threeScene, threeCamera); | |
| } | |
| function buildBuildingBlocks(r, heights, planeW, planeH, gridW, gridH, minH, maxH, span, scaleY){ | |
| const group = new THREE.Group(); | |
| // Ground plane | |
| const groundGeo = new THREE.PlaneGeometry(planeW, planeH, 1, 1); | |
| groundGeo.rotateX(-Math.PI / 2); | |
| const groundMat = new THREE.MeshStandardMaterial({ | |
| map: loadedTextures[current3DTexMode] || null, | |
| roughness: 0.90, | |
| metalness: 0.05 | |
| }); | |
| const groundMesh = new THREE.Mesh(groundGeo, groundMat); | |
| groundMesh.position.y = -0.01; | |
| group.add(groundMesh); | |
| const finite = []; | |
| for(let i = 0; i < heights.length; i++){ | |
| if(isFinite(heights[i])) finite.push(heights[i]); | |
| } | |
| finite.sort((a,b) => a - b); | |
| const groundLevel = finite[Math.floor(0.35 * (finite.length - 1))] || minH; | |
| const threshold = groundLevel + 0.18 * span; | |
| const mask = new Uint8Array(gridW * gridH); | |
| for(let i = 0; i < gridW * gridH; i++){ | |
| mask[i] = (isFinite(heights[i]) && heights[i] > threshold) ? 1 : 0; | |
| } | |
| const eroded = new Uint8Array(gridW * gridH); | |
| for(let y = 0; y < gridH; y++){ | |
| for(let x = 0; x < gridW; x++){ | |
| const idx = y * gridW + x; | |
| if(!mask[idx]) continue; | |
| const okL = x > 0 && mask[idx - 1]; | |
| const okR = x < gridW - 1 && mask[idx + 1]; | |
| const okU = y > 0 && mask[idx - gridW]; | |
| const okD = y < gridH - 1 && mask[idx + gridW]; | |
| eroded[idx] = (okL && okR && okU && okD) ? 1 : 0; | |
| } | |
| } | |
| const visited = new Uint8Array(gridW * gridH); | |
| const blobs = []; | |
| const stack = []; | |
| for(let start = 0; start < gridW * gridH; start++){ | |
| if(visited[start] || !eroded[start]) continue; | |
| let gxMin = gridW, gxMax = -1, gzMin = gridH, gzMax = -1, count = 0; | |
| stack.length = 0; | |
| stack.push(start); | |
| visited[start] = 1; | |
| while(stack.length){ | |
| const idx = stack.pop(); | |
| const gx = idx % gridW, gz = (idx / gridW) | 0; | |
| if(gx < gxMin) gxMin = gx; | |
| if(gx > gxMax) gxMax = gx; | |
| if(gz < gzMin) gzMin = gz; | |
| if(gz > gzMax) gzMax = gz; | |
| count++; | |
| const neighbors = [ | |
| (gx > 0) ? idx - 1 : -1, | |
| (gx < gridW - 1) ? idx + 1 : -1, | |
| idx - gridW, | |
| idx + gridW | |
| ]; | |
| for(let k = 0; k < 4; k++){ | |
| const n = neighbors[k]; | |
| if(n >= 0 && n < gridW * gridH && !visited[n] && eroded[n]){ | |
| visited[n] = 1; | |
| stack.push(n); | |
| } | |
| } | |
| } | |
| if(count >= 8){ | |
| let sum = 0, bCount = 0; | |
| for(let gz = gzMin; gz <= gzMax; gz++){ | |
| for(let gx = gxMin; gx <= gxMax; gx++){ | |
| const idx = gz * gridW + gx; | |
| if(mask[idx]){ sum += heights[idx]; bCount++; } | |
| } | |
| } | |
| blobs.push({ gxMin, gxMax, gzMin, gzMax, avgH: bCount > 0 ? sum / bCount : threshold }); | |
| } | |
| } | |
| const minFootprint = planeW * 0.02; | |
| const sideMat = new THREE.MeshStandardMaterial({ color: 0x94a3b8, roughness: 0.90 }); | |
| blobs.forEach(function(b){ | |
| const x0 = -planeW / 2 + (b.gxMin / gridW) * planeW; | |
| const x1 = -planeW / 2 + ((b.gxMax + 1) / gridW) * planeW; | |
| const z0 = -planeH / 2 + (b.gzMin / gridH) * planeH; | |
| const z1 = -planeH / 2 + ((b.gzMax + 1) / gridH) * planeH; | |
| const footW = Math.abs(x1 - x0); | |
| const footD = Math.abs(z1 - z0); | |
| if(footW < minFootprint || footD < minFootprint) return; | |
| const normH = Math.min(1.0, Math.max(0.05, (b.avgH - minH) / span)); | |
| const boxH = Math.max(0.1, normH * scaleY); | |
| const cx = (x0 + x1) / 2; | |
| const cz = (z0 + z1) / 2; | |
| const boxGeo = new THREE.BoxGeometry(footW, boxH, footD); | |
| let topMat = new THREE.MeshStandardMaterial({ color: 0x38bdf8, roughness: 0.70 }); | |
| if(loadedTextures[current3DTexMode]){ | |
| const uMin = b.gxMin / gridW, uMax = (b.gxMax + 1) / gridW; | |
| const vMin = b.gzMin / gridH, vMax = (b.gzMax + 1) / gridH; | |
| const t = loadedTextures[current3DTexMode].clone(); | |
| t.needsUpdate = true; | |
| t.wrapS = THREE.ClampToEdgeWrapping; | |
| t.wrapT = THREE.ClampToEdgeWrapping; | |
| t.repeat.set(uMax - uMin, vMax - vMin); | |
| t.offset.set(uMin, 1.0 - vMax); | |
| topMat = new THREE.MeshStandardMaterial({ map: t, roughness: 0.70 }); | |
| } | |
| const boxMesh = new THREE.Mesh(boxGeo, [sideMat, sideMat, topMat, sideMat, sideMat, sideMat]); | |
| boxMesh.position.set(cx, boxH / 2, cz); | |
| group.add(boxMesh); | |
| }); | |
| buildingBlocksGroup = group; | |
| terrainMesh = groundMesh; | |
| threeScene.add(group); | |
| } | |
| function build3DMesh(r){ | |
| if(!threeScene || !r || !r.height_map) return; | |
| if(buildingBlocksGroup){ | |
| threeScene.remove(buildingBlocksGroup); | |
| buildingBlocksGroup.traverse(function(o){ | |
| if(o.geometry) o.geometry.dispose(); | |
| if(o.material){ | |
| if(Array.isArray(o.material)) o.material.forEach(m => m.dispose()); | |
| else o.material.dispose(); | |
| } | |
| }); | |
| buildingBlocksGroup = null; | |
| } | |
| if(terrainMesh){ | |
| threeScene.remove(terrainMesh); | |
| terrainMesh.geometry.dispose(); | |
| if(Array.isArray(terrainMesh.material)){ | |
| terrainMesh.material.forEach(m => { if(m.map) m.map.dispose(); m.dispose(); }); | |
| } else { | |
| if(terrainMesh.material.map) terrainMesh.material.map.dispose(); | |
| terrainMesh.material.dispose(); | |
| } | |
| terrainMesh = null; | |
| } | |
| if(pointCloudMesh){ | |
| threeScene.remove(pointCloudMesh); | |
| pointCloudMesh.geometry.dispose(); | |
| pointCloudMesh.material.dispose(); | |
| pointCloudMesh = null; | |
| } | |
| if(skirtMesh){ | |
| threeScene.remove(skirtMesh); | |
| skirtMesh.geometry.dispose(); | |
| skirtMesh.material.dispose(); | |
| skirtMesh = null; | |
| } | |
| clear3DMeasurement(); | |
| const gridW = r.visual_width || 256; | |
| const gridH = r.visual_height || 256; | |
| const aspect = (r.height && r.width) ? (r.height / r.width) : (gridH / gridW); | |
| const planeW = 22; | |
| const planeH = 22 * aspect; | |
| cachedPlaneW = planeW; | |
| cachedPlaneH = planeH; | |
| cachedGridW = gridW; | |
| cachedGridH = gridH; | |
| const rawHeights = r.height_map; | |
| const heights = medianFilterEnabled ? smoothHeightGrid(rawHeights, gridW, gridH, 1) : rawHeights; | |
| // Null-safe min/max | |
| let minH = Infinity, maxH = -Infinity; | |
| for(let i = 0; i < heights.length; i++){ | |
| const v = heights[i]; | |
| if(v !== null && v !== undefined && isFinite(v)){ | |
| if(v < minH) minH = v; | |
| if(v > maxH) maxH = v; | |
| } | |
| } | |
| if(!isFinite(minH)) minH = 0; | |
| if(!isFinite(maxH)) maxH = 1; | |
| const span = (maxH - minH) > 1e-6 ? (maxH - minH) : 1.0; | |
| cachedNormH = new Float32Array(heights.length); | |
| for(let i = 0; i < heights.length; i++){ | |
| const v = heights[i]; | |
| cachedNormH[i] = (v !== null && v !== undefined && isFinite(v)) ? (v - minH) / span : 0.0; | |
| } | |
| cachedBaseScale = 1.4; | |
| const sliderElem = document.getElementById('heightScaleSlider'); | |
| const currentExag = parseFloat(sliderElem ? sliderElem.value : '1.0'); | |
| const scaleY = cachedBaseScale * currentExag; | |
| // Setup Textures | |
| loadedTextures = { rgb: null, dsm: null, slope: null, facets: null, error: null }; | |
| const texLoader = new THREE.TextureLoader(); | |
| function setupTexture(url, key){ | |
| if(!url) return; | |
| texLoader.load(url, function(tex){ | |
| tex.minFilter = THREE.LinearFilter; | |
| tex.magFilter = THREE.LinearFilter; | |
| tex.anisotropy = Math.min(threeRenderer ? threeRenderer.capabilities.getMaxAnisotropy() : 4, 8); | |
| loadedTextures[key] = tex; | |
| if(current3DTexMode === key && terrainMesh){ | |
| const targetMat = Array.isArray(terrainMesh.material) ? terrainMesh.material[0] : terrainMesh.material; | |
| targetMat.map = tex; | |
| targetMat.wireframe = false; | |
| targetMat.needsUpdate = true; | |
| if(threeRenderer) threeRenderer.render(threeScene, threeCamera); | |
| } | |
| }); | |
| } | |
| setupTexture(r.rgb, 'rgb'); | |
| setupTexture(r.dsm_preview, 'dsm'); | |
| setupTexture(r.slope_preview, 'slope'); | |
| setupTexture(r.facet_preview, 'facets'); | |
| setupTexture(r.error_preview, 'error'); | |
| if(renderMode3D === 'blocks'){ | |
| buildBuildingBlocks(r, heights, planeW, planeH, gridW, gridH, minH, maxH, span, scaleY); | |
| } else if(edgeStyle === 'sharp'){ | |
| // Sharp faceted columns + real vertical walls (M5 sharp style) | |
| const sharpGeo = buildSharpTerrainGeometry(heights, gridW, gridH, minH, maxH, span, planeW, planeH, scaleY); | |
| const topMat = new THREE.MeshStandardMaterial({ | |
| map: loadedTextures[current3DTexMode] || null, | |
| roughness: 0.65, | |
| metalness: 0.05 | |
| }); | |
| const wallMat = new THREE.MeshStandardMaterial({ | |
| color: 0x94a3b8, | |
| roughness: 0.90, | |
| metalness: 0.0 | |
| }); | |
| terrainMesh = new THREE.Mesh(sharpGeo, [topMat, wallMat]); | |
| threeScene.add(terrainMesh); | |
| } else { | |
| // Smooth continuous plane with interpolated vertex normals | |
| const geometry = new THREE.PlaneGeometry(planeW, planeH, gridW - 1, gridH - 1); | |
| geometry.rotateX(-Math.PI / 2); | |
| const pos = geometry.attributes.position; | |
| for(let i = 0; i < pos.count && i < cachedNormH.length; i++){ | |
| pos.setY(i, cachedNormH[i] * scaleY); | |
| } | |
| pos.needsUpdate = true; | |
| geometry.computeVertexNormals(); | |
| const material = new THREE.MeshStandardMaterial({ | |
| map: loadedTextures[current3DTexMode] || null, | |
| roughness: 0.60, | |
| metalness: 0.08, | |
| side: THREE.DoubleSide | |
| }); | |
| terrainMesh = new THREE.Mesh(geometry, material); | |
| threeScene.add(terrainMesh); | |
| // Skirt for smooth terrain | |
| const skirtGeom = buildSkirtGeometry(planeW, planeH, gridW, gridH, cachedNormH, scaleY); | |
| const skirtMat = new THREE.MeshStandardMaterial({ | |
| color: 0x121216, | |
| roughness: 0.85, | |
| metalness: 0.05, | |
| side: THREE.DoubleSide | |
| }); | |
| skirtMesh = new THREE.Mesh(skirtGeom, skirtMat); | |
| skirtMesh.visible = (current3DTexMode !== 'points'); | |
| threeScene.add(skirtMesh); | |
| } | |
| // Build 3D Point Cloud Representation (THREE.Points) | |
| const basePtsGeo = new THREE.PlaneGeometry(planeW, planeH, gridW - 1, gridH - 1); | |
| basePtsGeo.rotateX(-Math.PI / 2); | |
| const ptPos = basePtsGeo.attributes.position; | |
| for(let i = 0; i < ptPos.count && i < cachedNormH.length; i++){ | |
| ptPos.setY(i, cachedNormH[i] * scaleY); | |
| } | |
| ptPos.needsUpdate = true; | |
| const ptMaterial = new THREE.PointsMaterial({ | |
| size: 0.12, | |
| color: 0x38bdf8, | |
| sizeAttenuation: true | |
| }); | |
| pointCloudMesh = new THREE.Points(basePtsGeo, ptMaterial); | |
| pointCloudMesh.visible = (current3DTexMode === 'points'); | |
| threeScene.add(pointCloudMesh); | |
| if(threeControls){ | |
| threeControls.reset(planeH); | |
| } | |
| // Default to auto-fly in 3D view | |
| isFlying = true; | |
| const btnFlyElem = document.getElementById('btnFly'); | |
| if(btnFlyElem){ | |
| btnFlyElem.style.borderColor = '#22c55e'; | |
| btnFlyElem.style.background = 'rgba(34, 197, 94, 0.15)'; | |
| btnFlyElem.style.color = '#22c55e'; | |
| } | |
| } | |
| // Height Exaggeration Slider listener | |
| const heightSlider = document.getElementById('heightScaleSlider'); | |
| const heightVal = document.getElementById('heightScaleVal'); | |
| if(heightSlider && heightVal){ | |
| heightSlider.addEventListener('input', function(){ | |
| const v = parseFloat(this.value); | |
| heightVal.textContent = v.toFixed(1) + 'x'; | |
| applyHeightScale(v); | |
| }); | |
| } | |
| // 3D Texture Switcher buttons listener | |
| document.querySelectorAll('.tex-btn').forEach(function(btn){ | |
| btn.addEventListener('click', function(){ | |
| if(this.dataset.tex) set3DTextureMode(this.dataset.tex); | |
| }); | |
| }); | |
| // Edge Style & Render Mode Toggles (Three.js Sharp integration) | |
| const btnEdgeSharp = document.getElementById('btnEdgeSharp'); | |
| const btnEdgeSmooth = document.getElementById('btnEdgeSmooth'); | |
| if(btnEdgeSharp && btnEdgeSmooth){ | |
| btnEdgeSharp.addEventListener('click', function(){ | |
| edgeStyle = 'sharp'; | |
| btnEdgeSharp.classList.add('active'); | |
| btnEdgeSmooth.classList.remove('active'); | |
| if(lastResult) build3DMesh(lastResult); | |
| }); | |
| btnEdgeSmooth.addEventListener('click', function(){ | |
| edgeStyle = 'smooth'; | |
| btnEdgeSmooth.classList.add('active'); | |
| btnEdgeSharp.classList.remove('active'); | |
| if(lastResult) build3DMesh(lastResult); | |
| }); | |
| } | |
| const btnModeTerrain = document.getElementById('btnModeTerrain'); | |
| const btnModeBlocks = document.getElementById('btnModeBlocks'); | |
| if(btnModeTerrain && btnModeBlocks){ | |
| btnModeTerrain.addEventListener('click', function(){ | |
| renderMode3D = 'terrain'; | |
| btnModeTerrain.classList.add('active'); | |
| btnModeBlocks.classList.remove('active'); | |
| if(lastResult) build3DMesh(lastResult); | |
| }); | |
| btnModeBlocks.addEventListener('click', function(){ | |
| renderMode3D = 'blocks'; | |
| btnModeBlocks.classList.add('active'); | |
| btnModeTerrain.classList.remove('active'); | |
| if(lastResult) build3DMesh(lastResult); | |
| }); | |
| } | |
| const btnFilterMedian = document.getElementById('btnFilterMedian'); | |
| const btnFilterRaw = document.getElementById('btnFilterRaw'); | |
| if(btnFilterMedian && btnFilterRaw){ | |
| btnFilterMedian.addEventListener('click', function(){ | |
| medianFilterEnabled = true; | |
| btnFilterMedian.classList.add('active'); | |
| btnFilterRaw.classList.remove('active'); | |
| if(lastResult) build3DMesh(lastResult); | |
| }); | |
| btnFilterRaw.addEventListener('click', function(){ | |
| medianFilterEnabled = false; | |
| btnFilterRaw.classList.add('active'); | |
| btnFilterMedian.classList.remove('active'); | |
| if(lastResult) build3DMesh(lastResult); | |
| }); | |
| } | |
| // 3D Navigation Controls buttons | |
| const btnOrbit = document.getElementById('btnOrbit'); | |
| if(btnOrbit){ | |
| btnOrbit.addEventListener('click', function(){ | |
| isFlying = false; | |
| const btnFlyElem = document.getElementById('btnFly'); | |
| if(btnFlyElem){ | |
| btnFlyElem.style.borderColor = 'var(--line)'; | |
| btnFlyElem.style.background = 'var(--panel2)'; | |
| btnFlyElem.style.color = 'var(--fg-dim)'; | |
| } | |
| document.querySelector('.tab[data-tab="3d"]').click(); | |
| }); | |
| } | |
| const btnFly = document.getElementById('btnFly'); | |
| if(btnFly){ | |
| btnFly.addEventListener('click', function(){ | |
| document.querySelector('.tab[data-tab="3d"]').click(); | |
| isFlying = !isFlying; | |
| btnFly.style.borderColor = isFlying ? '#22c55e' : 'var(--line)'; | |
| btnFly.style.background = isFlying ? 'rgba(34, 197, 94, 0.15)' : 'var(--panel2)'; | |
| btnFly.style.color = isFlying ? '#22c55e' : 'var(--fg-dim)'; | |
| }); | |
| } | |
| const btnMeasure = document.getElementById('btnMeasure'); | |
| const measureHud = document.getElementById('measureHud'); | |
| const measureClear = document.getElementById('measureClear'); | |
| if(btnMeasure){ | |
| btnMeasure.addEventListener('click', function(){ | |
| document.querySelector('.tab[data-tab="3d"]').click(); | |
| isMeasuring = !isMeasuring; | |
| isFlying = false; | |
| const btnFlyElem = document.getElementById('btnFly'); | |
| if(btnFlyElem){ | |
| btnFlyElem.style.borderColor = 'var(--line)'; | |
| btnFlyElem.style.background = 'var(--panel2)'; | |
| btnFlyElem.style.color = 'var(--fg-dim)'; | |
| } | |
| btnMeasure.style.borderColor = isMeasuring ? '#22c55e' : 'var(--line)'; | |
| btnMeasure.style.background = isMeasuring ? 'rgba(34, 197, 94, 0.15)' : 'var(--panel2)'; | |
| btnMeasure.style.color = isMeasuring ? '#22c55e' : 'var(--fg-dim)'; | |
| if(measureHud) measureHud.style.display = isMeasuring ? 'flex' : 'none'; | |
| if(!isMeasuring) clear3DMeasurement(); | |
| }); | |
| } | |
| if(measureClear){ | |
| measureClear.addEventListener('click', function(e){ | |
| e.stopPropagation(); | |
| clear3DMeasurement(); | |
| }); | |
| } | |
| // Handle 3D Point-to-Point Measurement Click | |
| if(webglCanvas){ | |
| webglCanvas.addEventListener('click', function(e){ | |
| if(!isMeasuring || !terrainMesh || !threeCamera || !lastResult) return; | |
| const r = webglCanvas.getBoundingClientRect(); | |
| const clickMouse = new THREE.Vector2( | |
| ((e.clientX - r.left) / r.width) * 2 - 1, | |
| -((e.clientY - r.top) / r.height) * 2 + 1 | |
| ); | |
| const clickRay = new THREE.Raycaster(); | |
| clickRay.setFromCamera(clickMouse, threeCamera); | |
| const targetObj = (current3DTexMode === 'points' && pointCloudMesh) ? pointCloudMesh : terrainMesh; | |
| const hits = clickRay.intersectObject(targetObj); | |
| if(hits.length > 0){ | |
| const pt = hits[0].point; | |
| if(measurePts.length >= 2){ | |
| clear3DMeasurement(); | |
| } | |
| measurePts.push(pt.clone()); | |
| // Create Sphere marker | |
| const markerGeom = new THREE.SphereGeometry(0.25, 16, 16); | |
| const markerMat = new THREE.MeshBasicMaterial({ color: 0x22c55e }); | |
| const marker = new THREE.Mesh(markerGeom, markerMat); | |
| marker.position.copy(pt); | |
| threeScene.add(marker); | |
| measureMarkers.push(marker); | |
| if(measurePts.length === 2){ | |
| const p1 = measurePts[0]; | |
| const p2 = measurePts[1]; | |
| // Draw 3D measurement line | |
| const lineGeom = new THREE.BufferGeometry().setFromPoints([p1, p2]); | |
| const lineMat = new THREE.LineBasicMaterial({ color: 0x22c55e, linewidth: 2 }); | |
| measureLine = new THREE.Line(lineGeom, lineMat); | |
| threeScene.add(measureLine); | |
| // Calculate metric distance | |
| const dxWorld = p2.x - p1.x; | |
| const dzWorld = p2.z - p1.z; | |
| const dyWorld = p2.y - p1.y; | |
| const gsdX = (lastResult.metadata && lastResult.metadata.gsd_x) || 1.0; | |
| const gsdY = (lastResult.metadata && lastResult.metadata.gsd_y) || 1.0; | |
| const horizDistScene = Math.sqrt(dxWorld * dxWorld + dzWorld * dzWorld); | |
| const horizDistMetric = (horizDistScene / cachedPlaneW) * (cachedGridW * gsdX); | |
| const hSpan = (lastResult.height_max - lastResult.height_min) || 1.0; | |
| const vertDeltaMetric = (dyWorld / (cachedBaseScale * (parseFloat(heightSlider ? heightSlider.value : '1.0')))) * hSpan; | |
| const total3DDist = Math.sqrt(horizDistMetric * horizDistMetric + vertDeltaMetric * vertDeltaMetric); | |
| const pitchDeg = Math.abs(Math.atan2(vertDeltaMetric, Math.max(0.01, horizDistMetric)) * (180.0 / Math.PI)); | |
| const unit = lastResult.height_unit || 'm'; | |
| const mText = document.getElementById('measureText'); | |
| if(mText){ | |
| mText.innerHTML = `<strong>3D Dist:</strong> ${total3DDist.toFixed(2)} ${unit} | <strong>ΔZ:</strong> ${(vertDeltaMetric >= 0 ? '+' : '') + vertDeltaMetric.toFixed(2)} ${unit} | <strong>Pitch:</strong> ${pitchDeg.toFixed(1)}°`; | |
| } | |
| } | |
| if(threeRenderer && threeScene && threeCamera) threeRenderer.render(threeScene, threeCamera); | |
| } | |
| }); | |
| } | |
| // 3D Model Export Buttons | |
| const btnExportPly = document.getElementById('btnExportPly'); | |
| if(btnExportPly){ | |
| btnExportPly.addEventListener('click', function(){ | |
| if(lastResult && lastResult.export_ply_url){ | |
| window.location.href = lastResult.export_ply_url; | |
| } else { | |
| alert('Please process an image first to export the 3D Point Cloud (.ply).'); | |
| } | |
| }); | |
| } | |
| const btnExportObj = document.getElementById('btnExportObj'); | |
| if(btnExportObj){ | |
| btnExportObj.addEventListener('click', function(){ | |
| if(lastResult && lastResult.export_obj_url){ | |
| window.location.href = lastResult.export_obj_url; | |
| } else { | |
| alert('Please process an image first to export the 3D Mesh (.obj).'); | |
| } | |
| }); | |
| } | |
| const btnZoom = document.getElementById('btnZoom'); | |
| if(btnZoom){ | |
| btnZoom.addEventListener('click', function(){ | |
| if(threeControls){ | |
| threeControls.targetRadius = Math.max(threeControls.minDistance, threeControls.targetRadius * 0.82); | |
| } | |
| }); | |
| } | |
| const btnSnap = document.getElementById('btnSnap'); | |
| if(btnSnap){ | |
| btnSnap.addEventListener('click', function(){ | |
| const activeC = (currentTab === '3d' && webglCanvas) ? webglCanvas : mainCanvas; | |
| const link = document.createElement('a'); | |
| link.download = 'depthwizard_snapshot.png'; | |
| link.href = activeC.toDataURL('image/png'); | |
| link.click(); | |
| }); | |
| } | |
| // Viewport Zoom / Pan / Fullscreen Controls | |
| const vcBtns = document.querySelectorAll('.vc-btn'); | |
| if(vcBtns.length >= 4){ | |
| // Zoom In | |
| vcBtns[0].addEventListener('click', function(){ | |
| if(currentTab === '3d' && threeControls){ | |
| threeControls.targetRadius = Math.max(threeControls.minDistance, threeControls.targetRadius * 0.82); | |
| } | |
| }); | |
| // Zoom Out | |
| vcBtns[1].addEventListener('click', function(){ | |
| if(currentTab === '3d' && threeControls){ | |
| threeControls.targetRadius = Math.min(threeControls.maxDistance, threeControls.targetRadius * 1.18); | |
| } | |
| }); | |
| // Reset View | |
| vcBtns[2].addEventListener('click', function(){ | |
| if(currentTab === '3d' && threeControls){ | |
| threeControls.reset(); | |
| } | |
| }); | |
| // Fullscreen | |
| vcBtns[3].addEventListener('click', function(){ | |
| if(!document.fullscreenElement){ | |
| mainView.requestFullscreen().catch(()=>{}); | |
| } else { | |
| document.exitFullscreen().catch(()=>{}); | |
| } | |
| }); | |
| } | |
| async function renderActiveTab(){ | |
| initThreeJS(); | |
| if(currentTab === '3d'){ | |
| mainCanvas.style.display = 'none'; | |
| if(webglCanvas){ | |
| webglCanvas.style.display = 'block'; | |
| const r = mainView.getBoundingClientRect(); | |
| if(threeRenderer && threeCamera){ | |
| threeCamera.aspect = r.width / r.height; | |
| threeCamera.updateProjectionMatrix(); | |
| threeRenderer.setSize(r.width, r.height); | |
| } | |
| } | |
| if(lastResult && !terrainMesh) build3DMesh(lastResult); | |
| return; | |
| } | |
| if(webglCanvas) webglCanvas.style.display = 'none'; | |
| mainCanvas.style.display = 'block'; | |
| if(currentTab === 'overview' && currentImages.rgb){ | |
| await drawBase64ToCanvas(mainCanvas, currentImages.rgb); | |
| } else if(currentTab === 'depth' && currentImages.depth){ | |
| await drawBase64ToCanvas(mainCanvas, currentImages.depth); | |
| } else if(currentTab === 'dsm' && currentImages.dsm){ | |
| await drawBase64ToCanvas(mainCanvas, currentImages.dsm); | |
| } else if(currentTab === 'error' && currentImages.error){ | |
| await drawBase64ToCanvas(mainCanvas, currentImages.error); | |
| } else { | |
| var ctx = fitCanvas(mainCanvas); | |
| ctx.fillStyle = '#111'; | |
| ctx.fillRect(0, 0, mainCanvas.width, mainCanvas.height); | |
| ctx.fillStyle = '#555'; | |
| ctx.font = '14px monospace'; | |
| ctx.fillText('Awaiting processing...', 20, mainCanvas.height / 2); | |
| } | |
| } | |
| function renderSubPanels(r){ | |
| drawBase64ToCanvas(document.getElementById('depthCanvas'), r.depth_preview); | |
| drawBase64ToCanvas(document.getElementById('dsmCanvas'), r.dsm_preview); | |
| var dsmTags = document.getElementById('dsmCanvas').parentElement.querySelectorAll('.legend-tag'); | |
| if(dsmTags.length >= 2){ | |
| var unit = r.height_unit || 'm'; | |
| dsmTags[0].innerHTML = 'High<br>' + (r.height_max !== undefined ? r.height_max.toFixed(1) + ' ' + unit : '35 m'); | |
| dsmTags[1].innerHTML = 'Low<br>' + (r.height_min !== undefined ? r.height_min.toFixed(1) + ' ' + unit : '0 m'); | |
| } | |
| var errTags = document.getElementById('errCanvas').parentElement.querySelectorAll('.legend-tag'); | |
| if(r.error_preview){ | |
| drawBase64ToCanvas(document.getElementById('errCanvas'), r.error_preview); | |
| if(errTags.length >= 2){ | |
| var unit = r.height_unit || 'm'; | |
| var maxErr = (r.validation && r.validation.max_error) ? (r.validation.max_error.toFixed(2) + ' ' + unit) : 'High'; | |
| errTags[0].innerHTML = 'High<br>' + maxErr; | |
| errTags[1].innerHTML = 'Low<br>0.0 ' + unit; | |
| } | |
| } else { | |
| var c = document.getElementById('errCanvas'); | |
| var ctx = fitCanvas(c); | |
| ctx.fillStyle = '#111'; | |
| ctx.fillRect(0, 0, c.width, c.height); | |
| ctx.fillStyle = '#555'; | |
| ctx.font = '11px monospace'; | |
| ctx.fillText('No error data', 10, c.height / 2); | |
| } | |
| } | |
| function showPlaceholderCanvases(){ | |
| var canvases = ['mainCanvas', 'depthCanvas', 'dsmCanvas', 'errCanvas']; | |
| canvases.forEach(function(id){ | |
| var c = document.getElementById(id); | |
| var ctx = fitCanvas(c); | |
| ctx.fillStyle = '#0a0a0a'; | |
| ctx.fillRect(0, 0, c.width, c.height); | |
| ctx.fillStyle = '#333'; | |
| ctx.font = '12px monospace'; | |
| ctx.fillText('Awaiting processing...', 20, c.height / 2); | |
| }); | |
| var ec = document.getElementById('elevChart'); | |
| if(ec){ | |
| var rect = ec.getBoundingClientRect(); | |
| var dpr = window.devicePixelRatio || 1; | |
| ec.width = rect.width * dpr; | |
| ec.height = rect.height * dpr; | |
| var ectx = ec.getContext('2d'); | |
| ectx.scale(dpr, dpr); | |
| ectx.fillStyle = '#0a0a0a'; | |
| ectx.fillRect(0, 0, rect.width, rect.height); | |
| } | |
| } | |
| async function processImage(){ | |
| if(!selectedFile) return; | |
| processBtn.disabled = true; | |
| statusText.textContent = 'Uploading...'; | |
| setStep(0, 0); | |
| var formData = new FormData(); | |
| formData.append('file', selectedFile); | |
| formData.append('use_shadows', 'true'); | |
| formData.append('visual_size', '256'); | |
| var delay = 400; | |
| var stepIdx = 0; | |
| var iv = setInterval(function(){ | |
| stepIdx++; | |
| if(stepIdx <= 4){ | |
| setStep(stepIdx, stepIdx); | |
| if(stepIdx === 1) statusText.textContent = 'Loading model...'; | |
| else if(stepIdx === 2) statusText.textContent = 'Estimating depth...'; | |
| else if(stepIdx === 3) statusText.textContent = 'Calibrating...'; | |
| else if(stepIdx === 4) statusText.textContent = 'Generating DSM...'; | |
| } | |
| }, delay); | |
| try { | |
| // demo.html is served directly by FastAPI at the Space origin. | |
| // /process is also on the same FastAPI app, so a relative path always works. | |
| var resp = await fetch('/process', { method: 'POST', body: formData }); | |
| // Fallback to /api/process if needed | |
| if (!resp || resp.status === 404) { | |
| try { resp = await fetch('/api/process', { method: 'POST', body: formData }); } catch(e2){} | |
| } | |
| clearInterval(iv); | |
| if(!resp || !resp.ok){ | |
| var errorMsg = resp ? 'Server Error (' + resp.status + ')' : 'Network Error'; | |
| if(resp){ | |
| try { | |
| var errBody = await resp.json(); | |
| errorMsg = errBody.detail || errBody.error || errBody.message || errorMsg; | |
| } catch(e) { | |
| try { | |
| var errText = await resp.text(); | |
| if(errText) errorMsg = errText.substring(0, 150); | |
| } catch(e2) {} | |
| } | |
| } | |
| throw new Error(errorMsg); | |
| } | |
| var r = await resp.json(); | |
| lastResult = r; | |
| setStep(4, 5); | |
| statusText.textContent = 'Complete (' + r.mode + ')'; | |
| currentImages = { | |
| rgb: r.rgb, | |
| depth: r.depth_preview, | |
| dsm: r.dsm_preview, | |
| error: r.error_preview, | |
| slope: r.slope_preview, | |
| confidence: r.confidence_preview | |
| }; | |
| populateFileCardFromResponse(r); | |
| updateAnalysisStats(r); | |
| updateCalibrationSources(r); | |
| updateMethodComparison(r); | |
| updateValidation(r); | |
| updateExportBtn(r); | |
| currentTab = '3d'; | |
| document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); }); | |
| var tab3d = document.querySelector('.tab[data-tab="3d"]'); | |
| if(tab3d) tab3d.classList.add('active'); | |
| document.getElementById('viewLabel').textContent = '3D TERRAIN'; | |
| initThreeJS(); | |
| build3DMesh(r); | |
| await renderActiveTab(); | |
| renderSubPanels(r); | |
| drawElevChart(r.elevation_profile); | |
| processBtn.disabled = false; | |
| } catch(err){ | |
| clearInterval(iv); | |
| setStep(0, 0); | |
| statusText.textContent = 'Failed: ' + err.message; | |
| processBtn.disabled = false; | |
| console.error('Process error:', err); | |
| alert('Processing failed: ' + err.message); | |
| } | |
| } | |
| showPlaceholderCanvases(); | |
| setStep(0, 0); | |
| })(); | |
| </script></body></html> | |