Spaces:
Running
on
Zero
Running
on
Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>3D Point Cloud Visualizer</title> | |
| <style> | |
| :root { | |
| --primary: #9b59b6; /* Brighter purple for dark mode */ | |
| --primary-light: #3a2e4a; | |
| --secondary: #a86add; | |
| --accent: #ff6e6e; | |
| --bg: #1a1a1a; | |
| --surface: #2c2c2c; | |
| --text: #e0e0e0; | |
| --text-secondary: #a0a0a0; | |
| --border: #444444; | |
| --shadow: rgba(0, 0, 0, 0.2); | |
| --shadow-hover: rgba(0, 0, 0, 0.3); | |
| --space-sm: 16px; | |
| --space-md: 24px; | |
| --space-lg: 32px; | |
| } | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: 'Inter', sans-serif; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| #canvas-container { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #ui-container { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| z-index: 10; | |
| } | |
| #status-bar { | |
| position: absolute; | |
| top: 16px; | |
| left: 16px; | |
| background: rgba(30, 30, 30, 0.9); | |
| padding: 8px 16px; | |
| border-radius: 8px; | |
| pointer-events: auto; | |
| box-shadow: 0 4px 6px var(--shadow); | |
| backdrop-filter: blur(4px); | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| transition: opacity 0.5s ease, transform 0.5s ease; | |
| font-weight: 500; | |
| } | |
| #status-bar.hidden { | |
| opacity: 0; | |
| transform: translateY(-20px); | |
| pointer-events: none; | |
| } | |
| #control-panel { | |
| position: absolute; | |
| bottom: 16px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(44, 44, 44, 0.95); | |
| padding: 6px 8px; | |
| border-radius: 6px; | |
| display: flex; | |
| gap: 8px; | |
| align-items: center; | |
| justify-content: space-between; | |
| pointer-events: auto; | |
| box-shadow: 0 4px 10px var(--shadow); | |
| backdrop-filter: blur(4px); | |
| border: 1px solid var(--border); | |
| } | |
| #timeline { | |
| width: 150px; | |
| height: 4px; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 2px; | |
| position: relative; | |
| cursor: pointer; | |
| } | |
| #progress { | |
| position: absolute; | |
| height: 100%; | |
| background: var(--primary); | |
| border-radius: 2px; | |
| width: 0%; | |
| } | |
| #playback-controls { | |
| display: flex; | |
| gap: 4px; | |
| align-items: center; | |
| } | |
| button { | |
| background: rgba(255, 255, 255, 0.08); | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| padding: 4px 6px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: background 0.2s, transform 0.2s; | |
| font-family: 'Inter', sans-serif; | |
| font-weight: 500; | |
| font-size: 6px; | |
| } | |
| button:hover { | |
| background: rgba(255, 255, 255, 0.15); | |
| transform: translateY(-1px); | |
| } | |
| button.active { | |
| background: var(--primary); | |
| color: white; | |
| box-shadow: 0 2px 8px rgba(155, 89, 182, 0.4); | |
| } | |
| select, input { | |
| background: rgba(255, 255, 255, 0.08); | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| padding: 4px 6px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| font-family: 'Inter', sans-serif; | |
| font-size: 6px; | |
| } | |
| .icon { | |
| width: 10px; | |
| height: 10px; | |
| fill: currentColor; | |
| } | |
| .tooltip { | |
| position: absolute; | |
| bottom: 100%; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: var(--surface); | |
| color: var(--text); | |
| padding: 3px 6px; | |
| border-radius: 3px; | |
| font-size: 7px; | |
| white-space: nowrap; | |
| margin-bottom: 4px; | |
| opacity: 0; | |
| transition: opacity 0.2s; | |
| pointer-events: none; | |
| box-shadow: 0 2px 4px var(--shadow); | |
| border: 1px solid var(--border); | |
| } | |
| button:hover .tooltip { | |
| opacity: 1; | |
| } | |
| #settings-panel { | |
| position: absolute; | |
| top: 16px; | |
| right: 16px; | |
| background: rgba(44, 44, 44, 0.98); | |
| padding: 10px; | |
| border-radius: 6px; | |
| width: 195px; | |
| max-height: calc(100vh - 40px); | |
| overflow-y: auto; | |
| pointer-events: auto; | |
| box-shadow: 0 4px 15px var(--shadow); | |
| backdrop-filter: blur(4px); | |
| border: 1px solid var(--border); | |
| display: block; | |
| opacity: 1; | |
| scrollbar-width: thin; | |
| scrollbar-color: var(--primary-light) transparent; | |
| transition: transform 0.35s ease-in-out, opacity 0.3s ease-in-out; | |
| } | |
| #settings-panel.is-hidden { | |
| transform: translateX(calc(100% + 20px)); | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| #settings-panel::-webkit-scrollbar { | |
| width: 3px; | |
| } | |
| #settings-panel::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| #settings-panel::-webkit-scrollbar-thumb { | |
| background-color: var(--primary-light); | |
| border-radius: 3px; | |
| } | |
| @media (max-height: 700px) { | |
| #settings-panel { | |
| max-height: calc(100vh - 40px); | |
| } | |
| } | |
| @media (max-width: 768px) { | |
| #control-panel { | |
| width: 90%; | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| } | |
| #timeline { | |
| width: 100%; | |
| order: 3; | |
| margin-top: 10px; | |
| } | |
| #settings-panel { | |
| width: 140px; | |
| right: 10px; | |
| top: 10px; | |
| max-height: calc(100vh - 20px); | |
| } | |
| } | |
| .settings-group { | |
| margin-bottom: 8px; | |
| } | |
| .settings-group h3 { | |
| margin: 0 0 6px 0; | |
| font-size: 10px; | |
| font-weight: 500; | |
| color: var(--text-secondary); | |
| } | |
| .slider-container { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| width: 100%; | |
| } | |
| .slider-container label { | |
| min-width: 60px; | |
| font-size: 10px; | |
| flex-shrink: 0; | |
| } | |
| input[type="range"] { | |
| flex: 1; | |
| height: 2px; | |
| -webkit-appearance: none; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 1px; | |
| min-width: 0; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: var(--primary); | |
| cursor: pointer; | |
| } | |
| .toggle-switch { | |
| position: relative; | |
| display: inline-block; | |
| width: 20px; | |
| height: 10px; | |
| } | |
| .toggle-switch input { | |
| opacity: 0; | |
| width: 0; | |
| height: 0; | |
| } | |
| .toggle-slider { | |
| position: absolute; | |
| cursor: pointer; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: rgba(255, 255, 255, 0.1); | |
| transition: .4s; | |
| border-radius: 10px; | |
| } | |
| .toggle-slider:before { | |
| position: absolute; | |
| content: ""; | |
| height: 8px; | |
| width: 8px; | |
| left: 1px; | |
| bottom: 1px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| transition: .4s; | |
| border-radius: 50%; | |
| } | |
| input:checked + .toggle-slider { | |
| background: var(--primary); | |
| } | |
| input:checked + .toggle-slider:before { | |
| transform: translateX(10px); | |
| } | |
| .checkbox-container { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| margin-bottom: 4px; | |
| } | |
| .checkbox-container label { | |
| font-size: 10px; | |
| cursor: pointer; | |
| } | |
| #loading-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: var(--bg); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 100; | |
| transition: opacity 0.5s; | |
| } | |
| #loading-overlay.fade-out { | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| .spinner { | |
| width: 50px; | |
| height: 50px; | |
| border: 5px solid rgba(155, 89, 182, 0.2); | |
| border-radius: 50%; | |
| border-top-color: var(--primary); | |
| animation: spin 1s ease-in-out infinite; | |
| margin-bottom: 16px; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| #loading-text { | |
| margin-top: 16px; | |
| font-size: 18px; | |
| color: var(--text); | |
| font-weight: 500; | |
| } | |
| #frame-counter { | |
| color: var(--text-secondary); | |
| font-size: 7px; | |
| font-weight: 500; | |
| min-width: 60px; | |
| text-align: center; | |
| padding: 0 4px; | |
| } | |
| .control-btn { | |
| background: rgba(255, 255, 255, 0.08); | |
| border: 1px solid var(--border); | |
| padding: 4px 6px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.2s ease; | |
| font-size: 6px; | |
| } | |
| .control-btn:hover { | |
| background: rgba(255, 255, 255, 0.15); | |
| transform: translateY(-1px); | |
| } | |
| .control-btn.active { | |
| background: var(--primary); | |
| color: white; | |
| } | |
| .control-btn.active:hover { | |
| background: var(--primary); | |
| box-shadow: 0 2px 8px rgba(155, 89, 182, 0.4); | |
| } | |
| #settings-toggle-btn { | |
| position: relative; | |
| border-radius: 6px; | |
| z-index: 20; | |
| } | |
| #settings-toggle-btn.active { | |
| background: var(--primary); | |
| color: white; | |
| } | |
| #status-bar, | |
| #control-panel, | |
| #settings-panel, | |
| button, | |
| input, | |
| select, | |
| .toggle-switch { | |
| pointer-events: auto; | |
| } | |
| h2 { | |
| font-size: 0.9rem; | |
| font-weight: 600; | |
| margin-top: 0; | |
| margin-bottom: 12px; | |
| color: var(--primary); | |
| cursor: move; | |
| user-select: none; | |
| display: flex; | |
| align-items: center; | |
| } | |
| .drag-handle { | |
| font-size: 10px; | |
| margin-right: 4px; | |
| opacity: 0.6; | |
| } | |
| h2:hover .drag-handle { | |
| opacity: 1; | |
| } | |
| .loading-subtitle { | |
| font-size: 7px; | |
| color: var(--text-secondary); | |
| margin-top: 4px; | |
| } | |
| #reset-view-btn { | |
| background: var(--primary-light); | |
| color: var(--primary); | |
| border: 1px solid rgba(155, 89, 182, 0.2); | |
| font-weight: 600; | |
| font-size: 9px; | |
| padding: 4px 6px; | |
| transition: all 0.2s; | |
| } | |
| #reset-view-btn:hover { | |
| background: var(--primary); | |
| color: white; | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 8px rgba(155, 89, 182, 0.3); | |
| } | |
| #show-settings-btn { | |
| position: absolute; | |
| top: 16px; | |
| right: 16px; | |
| z-index: 15; | |
| display: none; | |
| } | |
| #settings-panel.visible { | |
| display: block; | |
| opacity: 1; | |
| animation: slideIn 0.3s ease forwards; | |
| } | |
| @keyframes slideIn { | |
| from { | |
| transform: translateY(20px); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateY(0); | |
| opacity: 1; | |
| } | |
| } | |
| .dragging { | |
| opacity: 0.9; | |
| box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15) ; | |
| transition: none ; | |
| } | |
| /* Tooltip for draggable element */ | |
| .tooltip-drag { | |
| position: absolute; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: var(--primary); | |
| color: white; | |
| font-size: 9px; | |
| padding: 2px 4px; | |
| border-radius: 2px; | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: opacity 0.3s; | |
| white-space: nowrap; | |
| bottom: 100%; | |
| margin-bottom: 4px; | |
| } | |
| h2:hover .tooltip-drag { | |
| opacity: 1; | |
| } | |
| .btn-group { | |
| display: flex; | |
| margin-top: 8px; | |
| } | |
| #reset-settings-btn { | |
| background: var(--primary-light); | |
| color: var(--primary); | |
| border: 1px solid rgba(155, 89, 182, 0.2); | |
| font-weight: 600; | |
| font-size: 9px; | |
| padding: 4px 6px; | |
| transition: all 0.2s; | |
| } | |
| #reset-settings-btn:hover { | |
| background: var(--primary); | |
| color: white; | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 8px rgba(155, 89, 182, 0.3); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <div id="canvas-container"></div> | |
| <div id="ui-container"> | |
| <div id="status-bar">Initializing...</div> | |
| <div id="control-panel"> | |
| <button id="play-pause-btn" class="control-btn"> | |
| <svg class="icon" viewBox="0 0 24 24"> | |
| <path id="play-icon" d="M8 5v14l11-7z"/> | |
| <path id="pause-icon" d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" style="display: none;"/> | |
| </svg> | |
| <span class="tooltip">Play/Pause</span> | |
| </button> | |
| <div id="timeline"> | |
| <div id="progress"></div> | |
| </div> | |
| <div id="frame-counter">Frame 0 / 0</div> | |
| <div id="playback-controls"> | |
| <button id="speed-btn" class="control-btn">1x</button> | |
| </div> | |
| </div> | |
| <div id="settings-panel"> | |
| <h2> | |
| <span class="drag-handle">☰</span> | |
| Visualization Settings | |
| <button id="hide-settings-btn" class="control-btn" style="margin-left: auto; padding: 2px;" title="Hide Panel"> | |
| <svg class="icon" viewBox="0 0 24 24" style="width: 9px; height: 9px;"> | |
| <path d="M14.59 7.41L18.17 11H4v2h14.17l-3.58 3.59L16 18l6-6-6-6-1.41 1.41z"/> | |
| </svg> | |
| </button> | |
| </h2> | |
| <div class="settings-group"> | |
| <h3>Point Cloud</h3> | |
| <div class="slider-container"> | |
| <label for="point-size">Size</label> | |
| <input type="range" id="point-size" min="0.005" max="0.1" step="0.005" value="0.03"> | |
| </div> | |
| <div class="slider-container"> | |
| <label for="point-opacity">Opacity</label> | |
| <input type="range" id="point-opacity" min="0.1" max="1" step="0.05" value="1"> | |
| </div> | |
| <div class="slider-container"> | |
| <label for="max-depth">Max Depth</label> | |
| <input type="range" id="max-depth" min="0.1" max="10" step="0.2" value="100"> | |
| </div> | |
| </div> | |
| <div class="settings-group"> | |
| <h3>Trajectory</h3> | |
| <div class="checkbox-container"> | |
| <label class="toggle-switch"> | |
| <input type="checkbox" id="show-trajectory" checked> | |
| <span class="toggle-slider"></span> | |
| </label> | |
| <label for="show-trajectory">Show Trajectory</label> | |
| </div> | |
| <div class="checkbox-container"> | |
| <label class="toggle-switch"> | |
| <input type="checkbox" id="enable-rich-trail"> | |
| <span class="toggle-slider"></span> | |
| </label> | |
| <label for="enable-rich-trail">Visual-Rich Trail</label> | |
| </div> | |
| <div class="slider-container"> | |
| <label for="trajectory-line-width">Line Width</label> | |
| <input type="range" id="trajectory-line-width" min="0.5" max="5" step="0.5" value="1.5"> | |
| </div> | |
| <div class="slider-container"> | |
| <label for="trajectory-ball-size">Ball Size</label> | |
| <input type="range" id="trajectory-ball-size" min="0.005" max="0.05" step="0.001" value="0.02"> | |
| </div> | |
| <div class="slider-container"> | |
| <label for="trajectory-history">History Frames</label> | |
| <input type="range" id="trajectory-history" min="1" max="500" step="1" value="30"> | |
| </div> | |
| <div class="slider-container" id="tail-opacity-container" style="display: none;"> | |
| <label for="trajectory-fade">Tail Opacity</label> | |
| <input type="range" id="trajectory-fade" min="0" max="1" step="0.05" value="0.0"> | |
| </div> | |
| </div> | |
| <div class="settings-group"> | |
| <h3>Camera</h3> | |
| <div class="checkbox-container"> | |
| <label class="toggle-switch"> | |
| <input type="checkbox" id="show-camera-frustum" checked> | |
| <span class="toggle-slider"></span> | |
| </label> | |
| <label for="show-camera-frustum">Show Camera Frustum</label> | |
| </div> | |
| <div class="slider-container"> | |
| <label for="frustum-size">Size</label> | |
| <input type="range" id="frustum-size" min="0.02" max="0.5" step="0.01" value="0.2"> | |
| </div> | |
| </div> | |
| <div class="settings-group"> | |
| <h3>Keep History</h3> | |
| <div class="checkbox-container"> | |
| <label class="toggle-switch"> | |
| <input type="checkbox" id="enable-keep-history"> | |
| <span class="toggle-slider"></span> | |
| </label> | |
| <label for="enable-keep-history">Enable Keep History</label> | |
| </div> | |
| <div class="slider-container"> | |
| <label for="history-stride">Stride</label> | |
| <select id="history-stride"> | |
| <option value="1">1</option> | |
| <option value="2">2</option> | |
| <option value="5" selected>5</option> | |
| <option value="10">10</option> | |
| <option value="20">20</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="settings-group"> | |
| <h3>Background</h3> | |
| <div class="checkbox-container"> | |
| <label class="toggle-switch"> | |
| <input type="checkbox" id="white-background"> | |
| <span class="toggle-slider"></span> | |
| </label> | |
| <label for="white-background">White Background</label> | |
| </div> | |
| </div> | |
| <div class="settings-group"> | |
| <div class="btn-group"> | |
| <button id="reset-view-btn" style="flex: 1; margin-right: 5px;">Reset View</button> | |
| <button id="reset-settings-btn" style="flex: 1; margin-left: 5px;">Reset Settings</button> | |
| </div> | |
| </div> | |
| </div> | |
| <button id="show-settings-btn" class="control-btn" title="Show Settings"> | |
| <svg class="icon" viewBox="0 0 24 24"> | |
| <path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.04,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div id="loading-overlay"> | |
| <!-- <div class="spinner"></div> --> | |
| <div id="loading-text"></div> | |
| <div class="loading-subtitle" style="font-size: medium;">Interactive Viewer of 3D Tracking</div> | |
| </div> | |
| <!-- Libraries --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/build/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/build/dat.gui.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/lines/LineSegmentsGeometry.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/lines/LineGeometry.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/lines/LineMaterial.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/lines/LineSegments2.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/lines/Line2.js"></script> | |
| <script> | |
| class PointCloudVisualizer { | |
| constructor() { | |
| this.data = null; | |
| this.config = {}; | |
| this.currentFrame = 0; | |
| this.isPlaying = false; | |
| this.playbackSpeed = 1; | |
| this.lastFrameTime = 0; | |
| this.defaultSettings = null; | |
| this.ui = { | |
| statusBar: document.getElementById('status-bar'), | |
| playPauseBtn: document.getElementById('play-pause-btn'), | |
| speedBtn: document.getElementById('speed-btn'), | |
| timeline: document.getElementById('timeline'), | |
| progress: document.getElementById('progress'), | |
| settingsPanel: document.getElementById('settings-panel'), | |
| loadingOverlay: document.getElementById('loading-overlay'), | |
| loadingText: document.getElementById('loading-text'), | |
| settingsToggleBtn: document.getElementById('settings-toggle-btn'), | |
| frameCounter: document.getElementById('frame-counter'), | |
| pointSize: document.getElementById('point-size'), | |
| pointOpacity: document.getElementById('point-opacity'), | |
| maxDepth: document.getElementById('max-depth'), | |
| showTrajectory: document.getElementById('show-trajectory'), | |
| enableRichTrail: document.getElementById('enable-rich-trail'), | |
| trajectoryLineWidth: document.getElementById('trajectory-line-width'), | |
| trajectoryBallSize: document.getElementById('trajectory-ball-size'), | |
| trajectoryHistory: document.getElementById('trajectory-history'), | |
| trajectoryFade: document.getElementById('trajectory-fade'), | |
| tailOpacityContainer: document.getElementById('tail-opacity-container'), | |
| resetViewBtn: document.getElementById('reset-view-btn'), | |
| showCameraFrustum: document.getElementById('show-camera-frustum'), | |
| frustumSize: document.getElementById('frustum-size'), | |
| hideSettingsBtn: document.getElementById('hide-settings-btn'), | |
| showSettingsBtn: document.getElementById('show-settings-btn'), | |
| enableKeepHistory: document.getElementById('enable-keep-history'), | |
| historyStride: document.getElementById('history-stride'), | |
| whiteBackground: document.getElementById('white-background') | |
| }; | |
| this.scene = null; | |
| this.camera = null; | |
| this.renderer = null; | |
| this.controls = null; | |
| this.pointCloud = null; | |
| this.trajectories = []; | |
| this.cameraFrustum = null; | |
| // Keep History functionality | |
| this.historyPointClouds = []; | |
| this.historyTrajectories = []; | |
| this.historyFrames = []; | |
| this.maxHistoryFrames = 20; | |
| this.initThreeJS(); | |
| this.loadDefaultSettings().then(() => { | |
| this.initEventListeners(); | |
| this.loadData(); | |
| }); | |
| } | |
| async loadDefaultSettings() { | |
| try { | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const dataPath = urlParams.get('data') || ''; | |
| const defaultSettings = { | |
| pointSize: 0.03, | |
| pointOpacity: 1.0, | |
| showTrajectory: true, | |
| trajectoryLineWidth: 2.5, | |
| trajectoryBallSize: 0.015, | |
| trajectoryHistory: 0, | |
| showCameraFrustum: true, | |
| frustumSize: 0.2 | |
| }; | |
| if (!dataPath) { | |
| this.defaultSettings = defaultSettings; | |
| this.applyDefaultSettings(); | |
| return; | |
| } | |
| // Try to extract dataset and videoId from the data path | |
| // Expected format: demos/datasetname/videoid.bin | |
| const pathParts = dataPath.split('/'); | |
| if (pathParts.length < 3) { | |
| this.defaultSettings = defaultSettings; | |
| this.applyDefaultSettings(); | |
| return; | |
| } | |
| const datasetName = pathParts[pathParts.length - 2]; | |
| let videoId = pathParts[pathParts.length - 1].replace('.bin', ''); | |
| // Load settings from data.json | |
| const response = await fetch('./data.json'); | |
| if (!response.ok) { | |
| this.defaultSettings = defaultSettings; | |
| this.applyDefaultSettings(); | |
| return; | |
| } | |
| const settingsData = await response.json(); | |
| // Check if this dataset and video exist | |
| if (settingsData[datasetName] && settingsData[datasetName][videoId]) { | |
| this.defaultSettings = settingsData[datasetName][videoId]; | |
| } else { | |
| this.defaultSettings = defaultSettings; | |
| } | |
| this.applyDefaultSettings(); | |
| } catch (error) { | |
| console.error("Error loading default settings:", error); | |
| this.defaultSettings = { | |
| pointSize: 0.03, | |
| pointOpacity: 1.0, | |
| showTrajectory: true, | |
| trajectoryLineWidth: 2.5, | |
| trajectoryBallSize: 0.015, | |
| trajectoryHistory: 0, | |
| showCameraFrustum: true, | |
| frustumSize: 0.2 | |
| }; | |
| this.applyDefaultSettings(); | |
| } | |
| } | |
| applyDefaultSettings() { | |
| if (!this.defaultSettings) return; | |
| if (this.ui.pointSize) { | |
| this.ui.pointSize.value = this.defaultSettings.pointSize; | |
| } | |
| if (this.ui.pointOpacity) { | |
| this.ui.pointOpacity.value = this.defaultSettings.pointOpacity; | |
| } | |
| if (this.ui.maxDepth) { | |
| this.ui.maxDepth.value = this.defaultSettings.maxDepth || 100.0; | |
| } | |
| if (this.ui.showTrajectory) { | |
| this.ui.showTrajectory.checked = this.defaultSettings.showTrajectory; | |
| } | |
| if (this.ui.trajectoryLineWidth) { | |
| this.ui.trajectoryLineWidth.value = this.defaultSettings.trajectoryLineWidth; | |
| } | |
| if (this.ui.trajectoryBallSize) { | |
| this.ui.trajectoryBallSize.value = this.defaultSettings.trajectoryBallSize; | |
| } | |
| if (this.ui.trajectoryHistory) { | |
| this.ui.trajectoryHistory.value = this.defaultSettings.trajectoryHistory; | |
| } | |
| if (this.ui.showCameraFrustum) { | |
| this.ui.showCameraFrustum.checked = this.defaultSettings.showCameraFrustum; | |
| } | |
| if (this.ui.frustumSize) { | |
| this.ui.frustumSize.value = this.defaultSettings.frustumSize; | |
| } | |
| } | |
| initThreeJS() { | |
| this.scene = new THREE.Scene(); | |
| this.scene.background = new THREE.Color(0x1a1a1a); | |
| this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 10000); | |
| this.camera.position.set(0, 0, 0); | |
| this.renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| this.renderer.setPixelRatio(window.devicePixelRatio); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| document.getElementById('canvas-container').appendChild(this.renderer.domElement); | |
| this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement); | |
| this.controls.enableDamping = true; | |
| this.controls.dampingFactor = 0.05; | |
| this.controls.target.set(0, 0, 0); | |
| this.controls.minDistance = 0.1; | |
| this.controls.maxDistance = 1000; | |
| this.controls.update(); | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); | |
| this.scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| directionalLight.position.set(1, 1, 1); | |
| this.scene.add(directionalLight); | |
| } | |
| initEventListeners() { | |
| window.addEventListener('resize', () => this.onWindowResize()); | |
| this.ui.playPauseBtn.addEventListener('click', () => this.togglePlayback()); | |
| this.ui.timeline.addEventListener('click', (e) => { | |
| const rect = this.ui.timeline.getBoundingClientRect(); | |
| const pos = (e.clientX - rect.left) / rect.width; | |
| this.seekTo(pos); | |
| }); | |
| this.ui.speedBtn.addEventListener('click', () => this.cyclePlaybackSpeed()); | |
| this.ui.pointSize.addEventListener('input', () => this.updatePointCloudSettings()); | |
| this.ui.pointOpacity.addEventListener('input', () => this.updatePointCloudSettings()); | |
| this.ui.maxDepth.addEventListener('input', () => this.updatePointCloudSettings()); | |
| this.ui.showTrajectory.addEventListener('change', () => { | |
| this.trajectories.forEach(trajectory => { | |
| trajectory.visible = this.ui.showTrajectory.checked; | |
| }); | |
| }); | |
| this.ui.enableRichTrail.addEventListener('change', () => { | |
| this.ui.tailOpacityContainer.style.display = this.ui.enableRichTrail.checked ? 'flex' : 'none'; | |
| this.updateTrajectories(this.currentFrame); | |
| }); | |
| this.ui.trajectoryLineWidth.addEventListener('input', () => this.updateTrajectorySettings()); | |
| this.ui.trajectoryBallSize.addEventListener('input', () => this.updateTrajectorySettings()); | |
| this.ui.trajectoryHistory.addEventListener('input', () => { | |
| this.updateTrajectories(this.currentFrame); | |
| }); | |
| this.ui.trajectoryFade.addEventListener('input', () => { | |
| this.updateTrajectories(this.currentFrame); | |
| }); | |
| this.ui.resetViewBtn.addEventListener('click', () => this.resetView()); | |
| const resetSettingsBtn = document.getElementById('reset-settings-btn'); | |
| if (resetSettingsBtn) { | |
| resetSettingsBtn.addEventListener('click', () => this.resetSettings()); | |
| } | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape' && this.ui.settingsPanel.classList.contains('visible')) { | |
| this.ui.settingsPanel.classList.remove('visible'); | |
| this.ui.settingsToggleBtn.classList.remove('active'); | |
| } | |
| }); | |
| if (this.ui.settingsToggleBtn) { | |
| this.ui.settingsToggleBtn.addEventListener('click', () => { | |
| const isVisible = this.ui.settingsPanel.classList.toggle('visible'); | |
| this.ui.settingsToggleBtn.classList.toggle('active', isVisible); | |
| if (isVisible) { | |
| const panelRect = this.ui.settingsPanel.getBoundingClientRect(); | |
| const viewportHeight = window.innerHeight; | |
| if (panelRect.bottom > viewportHeight) { | |
| this.ui.settingsPanel.style.bottom = 'auto'; | |
| this.ui.settingsPanel.style.top = '80px'; | |
| } | |
| } | |
| }); | |
| } | |
| if (this.ui.frustumSize) { | |
| this.ui.frustumSize.addEventListener('input', () => this.updateFrustumDimensions()); | |
| } | |
| if (this.ui.hideSettingsBtn && this.ui.showSettingsBtn && this.ui.settingsPanel) { | |
| this.ui.hideSettingsBtn.addEventListener('click', () => { | |
| this.ui.settingsPanel.classList.add('is-hidden'); | |
| this.ui.showSettingsBtn.style.display = 'flex'; | |
| }); | |
| this.ui.showSettingsBtn.addEventListener('click', () => { | |
| this.ui.settingsPanel.classList.remove('is-hidden'); | |
| this.ui.showSettingsBtn.style.display = 'none'; | |
| }); | |
| } | |
| // Keep History event listeners | |
| if (this.ui.enableKeepHistory) { | |
| this.ui.enableKeepHistory.addEventListener('change', () => { | |
| if (!this.ui.enableKeepHistory.checked) { | |
| this.clearHistory(); | |
| } | |
| }); | |
| } | |
| if (this.ui.historyStride) { | |
| this.ui.historyStride.addEventListener('change', () => { | |
| this.clearHistory(); | |
| }); | |
| } | |
| // Background toggle event listener | |
| if (this.ui.whiteBackground) { | |
| this.ui.whiteBackground.addEventListener('change', () => { | |
| this.toggleBackground(); | |
| }); | |
| } | |
| } | |
| makeElementDraggable(element) { | |
| let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; | |
| const dragHandle = element.querySelector('h2'); | |
| if (dragHandle) { | |
| dragHandle.onmousedown = dragMouseDown; | |
| dragHandle.title = "Drag to move panel"; | |
| } else { | |
| element.onmousedown = dragMouseDown; | |
| } | |
| function dragMouseDown(e) { | |
| e = e || window.event; | |
| e.preventDefault(); | |
| pos3 = e.clientX; | |
| pos4 = e.clientY; | |
| document.onmouseup = closeDragElement; | |
| document.onmousemove = elementDrag; | |
| element.classList.add('dragging'); | |
| } | |
| function elementDrag(e) { | |
| e = e || window.event; | |
| e.preventDefault(); | |
| pos1 = pos3 - e.clientX; | |
| pos2 = pos4 - e.clientY; | |
| pos3 = e.clientX; | |
| pos4 = e.clientY; | |
| const newTop = element.offsetTop - pos2; | |
| const newLeft = element.offsetLeft - pos1; | |
| const viewportWidth = window.innerWidth; | |
| const viewportHeight = window.innerHeight; | |
| const panelRect = element.getBoundingClientRect(); | |
| const maxTop = viewportHeight - 50; | |
| const maxLeft = viewportWidth - 50; | |
| element.style.top = Math.min(Math.max(newTop, 0), maxTop) + "px"; | |
| element.style.left = Math.min(Math.max(newLeft, 0), maxLeft) + "px"; | |
| // Remove bottom/right settings when dragging | |
| element.style.bottom = 'auto'; | |
| element.style.right = 'auto'; | |
| } | |
| function closeDragElement() { | |
| document.onmouseup = null; | |
| document.onmousemove = null; | |
| element.classList.remove('dragging'); | |
| } | |
| } | |
| async loadData() { | |
| try { | |
| // this.ui.loadingText.textContent = "Loading binary data..."; | |
| let arrayBuffer; | |
| if (window.embeddedBase64) { | |
| // Base64 embedded path | |
| const binaryString = atob(window.embeddedBase64); | |
| const len = binaryString.length; | |
| const bytes = new Uint8Array(len); | |
| for (let i = 0; i < len; i++) { | |
| bytes[i] = binaryString.charCodeAt(i); | |
| } | |
| arrayBuffer = bytes.buffer; | |
| } else { | |
| // Default fetch path (fallback) | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const dataPath = urlParams.get('data') || 'data.bin'; | |
| const response = await fetch(dataPath); | |
| if (!response.ok) throw new Error(`Failed to load ${dataPath}`); | |
| arrayBuffer = await response.arrayBuffer(); | |
| } | |
| const dataView = new DataView(arrayBuffer); | |
| const headerLen = dataView.getUint32(0, true); | |
| const headerText = new TextDecoder("utf-8").decode(arrayBuffer.slice(4, 4 + headerLen)); | |
| const header = JSON.parse(headerText); | |
| const compressedBlob = new Uint8Array(arrayBuffer, 4 + headerLen); | |
| const decompressed = pako.inflate(compressedBlob).buffer; | |
| const arrays = {}; | |
| for (const key in header) { | |
| if (key === "meta") continue; | |
| const meta = header[key]; | |
| const { dtype, shape, offset, length } = meta; | |
| const slice = decompressed.slice(offset, offset + length); | |
| let typedArray; | |
| switch (dtype) { | |
| case "uint8": typedArray = new Uint8Array(slice); break; | |
| case "uint16": typedArray = new Uint16Array(slice); break; | |
| case "float32": typedArray = new Float32Array(slice); break; | |
| case "float64": typedArray = new Float64Array(slice); break; | |
| default: throw new Error(`Unknown dtype: ${dtype}`); | |
| } | |
| arrays[key] = { data: typedArray, shape: shape }; | |
| } | |
| this.data = arrays; | |
| this.config = header.meta; | |
| this.initCameraWithCorrectFOV(); | |
| this.ui.loadingText.textContent = "Creating point cloud..."; | |
| this.initPointCloud(); | |
| this.initTrajectories(); | |
| setTimeout(() => { | |
| this.ui.loadingOverlay.classList.add('fade-out'); | |
| this.ui.statusBar.classList.add('hidden'); | |
| this.startAnimation(); | |
| }, 500); | |
| } catch (error) { | |
| console.error("Error loading data:", error); | |
| this.ui.statusBar.textContent = `Error: ${error.message}`; | |
| // this.ui.loadingText.textContent = `Error loading data: ${error.message}`; | |
| } | |
| } | |
| initPointCloud() { | |
| const numPoints = this.config.resolution[0] * this.config.resolution[1]; | |
| const positions = new Float32Array(numPoints * 3); | |
| const colors = new Float32Array(numPoints * 3); | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3).setUsage(THREE.DynamicDrawUsage)); | |
| geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3).setUsage(THREE.DynamicDrawUsage)); | |
| const pointSize = parseFloat(this.ui.pointSize.value) || this.defaultSettings.pointSize; | |
| const pointOpacity = parseFloat(this.ui.pointOpacity.value) || this.defaultSettings.pointOpacity; | |
| const material = new THREE.PointsMaterial({ | |
| size: pointSize, | |
| vertexColors: true, | |
| transparent: true, | |
| opacity: pointOpacity, | |
| sizeAttenuation: true | |
| }); | |
| this.pointCloud = new THREE.Points(geometry, material); | |
| this.scene.add(this.pointCloud); | |
| } | |
| initTrajectories() { | |
| if (!this.data.trajectories) return; | |
| this.trajectories.forEach(trajectory => { | |
| if (trajectory.userData.lineSegments) { | |
| trajectory.userData.lineSegments.forEach(segment => { | |
| segment.geometry.dispose(); | |
| segment.material.dispose(); | |
| }); | |
| } | |
| this.scene.remove(trajectory); | |
| }); | |
| this.trajectories = []; | |
| const shape = this.data.trajectories.shape; | |
| if (!shape || shape.length < 2) return; | |
| const [totalFrames, numTrajectories] = shape; | |
| const palette = this.createColorPalette(numTrajectories); | |
| const resolution = new THREE.Vector2(window.innerWidth, window.innerHeight); | |
| const maxHistory = 500; // Max value of the history slider, for the object pool | |
| for (let i = 0; i < numTrajectories; i++) { | |
| const trajectoryGroup = new THREE.Group(); | |
| const ballSize = parseFloat(this.ui.trajectoryBallSize.value); | |
| const sphereGeometry = new THREE.SphereGeometry(ballSize, 16, 16); | |
| const sphereMaterial = new THREE.MeshBasicMaterial({ color: palette[i], transparent: true }); | |
| const positionMarker = new THREE.Mesh(sphereGeometry, sphereMaterial); | |
| trajectoryGroup.add(positionMarker); | |
| // High-Performance Line (default) | |
| const simpleLineGeometry = new THREE.BufferGeometry(); | |
| const simpleLinePositions = new Float32Array(maxHistory * 3); | |
| simpleLineGeometry.setAttribute('position', new THREE.BufferAttribute(simpleLinePositions, 3).setUsage(THREE.DynamicDrawUsage)); | |
| const simpleLine = new THREE.Line(simpleLineGeometry, new THREE.LineBasicMaterial({ color: palette[i] })); | |
| simpleLine.frustumCulled = false; | |
| trajectoryGroup.add(simpleLine); | |
| // High-Quality Line Segments (for rich trail) | |
| const lineSegments = []; | |
| const lineWidth = parseFloat(this.ui.trajectoryLineWidth.value); | |
| // Create a pool of line segment objects | |
| for (let j = 0; j < maxHistory - 1; j++) { | |
| const lineGeometry = new THREE.LineGeometry(); | |
| lineGeometry.setPositions([0, 0, 0, 0, 0, 0]); | |
| const lineMaterial = new THREE.LineMaterial({ | |
| color: palette[i], | |
| linewidth: lineWidth, | |
| resolution: resolution, | |
| transparent: true, | |
| depthWrite: false, // Correctly handle transparency | |
| opacity: 0 | |
| }); | |
| const segment = new THREE.Line2(lineGeometry, lineMaterial); | |
| segment.frustumCulled = false; | |
| segment.visible = false; // Start with all segments hidden | |
| trajectoryGroup.add(segment); | |
| lineSegments.push(segment); | |
| } | |
| trajectoryGroup.userData = { | |
| marker: positionMarker, | |
| simpleLine: simpleLine, | |
| lineSegments: lineSegments, | |
| color: palette[i] | |
| }; | |
| this.scene.add(trajectoryGroup); | |
| this.trajectories.push(trajectoryGroup); | |
| } | |
| const showTrajectory = this.ui.showTrajectory.checked; | |
| this.trajectories.forEach(trajectory => trajectory.visible = showTrajectory); | |
| } | |
| createColorPalette(count) { | |
| const colors = []; | |
| const hueStep = 360 / count; | |
| for (let i = 0; i < count; i++) { | |
| const hue = (i * hueStep) % 360; | |
| const color = new THREE.Color().setHSL(hue / 360, 0.8, 0.6); | |
| colors.push(color); | |
| } | |
| return colors; | |
| } | |
| updatePointCloud(frameIndex) { | |
| if (!this.data || !this.pointCloud) return; | |
| const positions = this.pointCloud.geometry.attributes.position.array; | |
| const colors = this.pointCloud.geometry.attributes.color.array; | |
| const rgbVideo = this.data.rgb_video; | |
| const depthsRgb = this.data.depths_rgb; | |
| const intrinsics = this.data.intrinsics; | |
| const invExtrinsics = this.data.inv_extrinsics; | |
| const width = this.config.resolution[0]; | |
| const height = this.config.resolution[1]; | |
| const numPoints = width * height; | |
| const K = this.get3x3Matrix(intrinsics.data, intrinsics.shape, frameIndex); | |
| const fx = K[0][0], fy = K[1][1], cx = K[0][2], cy = K[1][2]; | |
| const invExtrMat = this.get4x4Matrix(invExtrinsics.data, invExtrinsics.shape, frameIndex); | |
| const transform = this.getTransformElements(invExtrMat); | |
| const rgbFrame = this.getFrame(rgbVideo.data, rgbVideo.shape, frameIndex); | |
| const depthFrame = this.getFrame(depthsRgb.data, depthsRgb.shape, frameIndex); | |
| const maxDepth = parseFloat(this.ui.maxDepth.value) || 10.0; | |
| let validPointCount = 0; | |
| for (let i = 0; i < numPoints; i++) { | |
| const xPix = i % width; | |
| const yPix = Math.floor(i / width); | |
| const d0 = depthFrame[i * 3]; | |
| const d1 = depthFrame[i * 3 + 1]; | |
| const depthEncoded = d0 | (d1 << 8); | |
| const depthValue = (depthEncoded / ((1 << 16) - 1)) * | |
| (this.config.depthRange[1] - this.config.depthRange[0]) + | |
| this.config.depthRange[0]; | |
| if (depthValue === 0 || depthValue > maxDepth) { | |
| continue; | |
| } | |
| const X = ((xPix - cx) * depthValue) / fx; | |
| const Y = ((yPix - cy) * depthValue) / fy; | |
| const Z = depthValue; | |
| const tx = transform.m11 * X + transform.m12 * Y + transform.m13 * Z + transform.m14; | |
| const ty = transform.m21 * X + transform.m22 * Y + transform.m23 * Z + transform.m24; | |
| const tz = transform.m31 * X + transform.m32 * Y + transform.m33 * Z + transform.m34; | |
| const index = validPointCount * 3; | |
| positions[index] = tx; | |
| positions[index + 1] = -ty; | |
| positions[index + 2] = -tz; | |
| colors[index] = rgbFrame[i * 3] / 255; | |
| colors[index + 1] = rgbFrame[i * 3 + 1] / 255; | |
| colors[index + 2] = rgbFrame[i * 3 + 2] / 255; | |
| validPointCount++; | |
| } | |
| this.pointCloud.geometry.setDrawRange(0, validPointCount); | |
| this.pointCloud.geometry.attributes.position.needsUpdate = true; | |
| this.pointCloud.geometry.attributes.color.needsUpdate = true; | |
| this.pointCloud.geometry.computeBoundingSphere(); // Important for camera culling | |
| this.updateTrajectories(frameIndex); | |
| // Keep History management | |
| this.updateHistory(frameIndex); | |
| const progress = (frameIndex + 1) / this.config.totalFrames; | |
| this.ui.progress.style.width = `${progress * 100}%`; | |
| if (this.ui.frameCounter && this.config.totalFrames) { | |
| this.ui.frameCounter.textContent = `Frame ${frameIndex} / ${this.config.totalFrames - 1}`; | |
| } | |
| this.updateCameraFrustum(frameIndex); | |
| } | |
| updateTrajectories(frameIndex) { | |
| if (!this.data.trajectories || this.trajectories.length === 0) return; | |
| const trajectoryData = this.data.trajectories.data; | |
| const [totalFrames, numTrajectories] = this.data.trajectories.shape; | |
| const historyFrames = parseInt(this.ui.trajectoryHistory.value); | |
| const tailOpacity = parseFloat(this.ui.trajectoryFade.value); | |
| const isRichMode = this.ui.enableRichTrail.checked; | |
| for (let i = 0; i < numTrajectories; i++) { | |
| const trajectoryGroup = this.trajectories[i]; | |
| const { marker, simpleLine, lineSegments } = trajectoryGroup.userData; | |
| const currentPos = new THREE.Vector3(); | |
| const currentOffset = (frameIndex * numTrajectories + i) * 3; | |
| currentPos.x = trajectoryData[currentOffset]; | |
| currentPos.y = -trajectoryData[currentOffset + 1]; | |
| currentPos.z = -trajectoryData[currentOffset + 2]; | |
| marker.position.copy(currentPos); | |
| marker.material.opacity = 1.0; | |
| const historyToShow = Math.min(historyFrames, frameIndex + 1); | |
| if (isRichMode) { | |
| // --- High-Quality Mode --- | |
| simpleLine.visible = false; | |
| for (let j = 0; j < lineSegments.length; j++) { | |
| const segment = lineSegments[j]; | |
| if (j < historyToShow - 1) { | |
| const headFrame = frameIndex - j; | |
| const tailFrame = frameIndex - j - 1; | |
| const headOffset = (headFrame * numTrajectories + i) * 3; | |
| const tailOffset = (tailFrame * numTrajectories + i) * 3; | |
| const positions = [ | |
| trajectoryData[headOffset], -trajectoryData[headOffset + 1], -trajectoryData[headOffset + 2], | |
| trajectoryData[tailOffset], -trajectoryData[tailOffset + 1], -trajectoryData[tailOffset + 2] | |
| ]; | |
| segment.geometry.setPositions(positions); | |
| const headOpacity = 1.0; | |
| const normalizedAge = j / Math.max(1, historyToShow - 2); | |
| const alpha = headOpacity - (headOpacity - tailOpacity) * normalizedAge; | |
| segment.material.opacity = Math.max(0, alpha); | |
| segment.visible = true; | |
| } else { | |
| segment.visible = false; | |
| } | |
| } | |
| } else { | |
| // --- Performance Mode --- | |
| lineSegments.forEach(s => s.visible = false); | |
| simpleLine.visible = true; | |
| const positions = simpleLine.geometry.attributes.position.array; | |
| for (let j = 0; j < historyToShow; j++) { | |
| const historyFrame = Math.max(0, frameIndex - j); | |
| const offset = (historyFrame * numTrajectories + i) * 3; | |
| positions[j * 3] = trajectoryData[offset]; | |
| positions[j * 3 + 1] = -trajectoryData[offset + 1]; | |
| positions[j * 3 + 2] = -trajectoryData[offset + 2]; | |
| } | |
| simpleLine.geometry.setDrawRange(0, historyToShow); | |
| simpleLine.geometry.attributes.position.needsUpdate = true; | |
| } | |
| } | |
| } | |
| updateTrajectorySettings() { | |
| if (!this.trajectories || this.trajectories.length === 0) return; | |
| const ballSize = parseFloat(this.ui.trajectoryBallSize.value); | |
| const lineWidth = parseFloat(this.ui.trajectoryLineWidth.value); | |
| this.trajectories.forEach(trajectoryGroup => { | |
| const { marker, lineSegments } = trajectoryGroup.userData; | |
| marker.geometry.dispose(); | |
| marker.geometry = new THREE.SphereGeometry(ballSize, 16, 16); | |
| // Line width only affects rich mode | |
| lineSegments.forEach(segment => { | |
| if (segment.material) { | |
| segment.material.linewidth = lineWidth; | |
| } | |
| }); | |
| }); | |
| this.updateTrajectories(this.currentFrame); | |
| } | |
| getDepthColor(normalizedDepth) { | |
| const hue = (1 - normalizedDepth) * 240 / 360; | |
| const color = new THREE.Color().setHSL(hue, 1.0, 0.5); | |
| return color; | |
| } | |
| getFrame(typedArray, shape, frameIndex) { | |
| const [T, H, W, C] = shape; | |
| const frameSize = H * W * C; | |
| const offset = frameIndex * frameSize; | |
| return typedArray.subarray(offset, offset + frameSize); | |
| } | |
| get3x3Matrix(typedArray, shape, frameIndex) { | |
| const frameSize = 9; | |
| const offset = frameIndex * frameSize; | |
| const K = []; | |
| for (let i = 0; i < 3; i++) { | |
| const row = []; | |
| for (let j = 0; j < 3; j++) { | |
| row.push(typedArray[offset + i * 3 + j]); | |
| } | |
| K.push(row); | |
| } | |
| return K; | |
| } | |
| get4x4Matrix(typedArray, shape, frameIndex) { | |
| const frameSize = 16; | |
| const offset = frameIndex * frameSize; | |
| const M = []; | |
| for (let i = 0; i < 4; i++) { | |
| const row = []; | |
| for (let j = 0; j < 4; j++) { | |
| row.push(typedArray[offset + i * 4 + j]); | |
| } | |
| M.push(row); | |
| } | |
| return M; | |
| } | |
| getTransformElements(matrix) { | |
| return { | |
| m11: matrix[0][0], m12: matrix[0][1], m13: matrix[0][2], m14: matrix[0][3], | |
| m21: matrix[1][0], m22: matrix[1][1], m23: matrix[1][2], m24: matrix[1][3], | |
| m31: matrix[2][0], m32: matrix[2][1], m33: matrix[2][2], m34: matrix[2][3] | |
| }; | |
| } | |
| togglePlayback() { | |
| this.isPlaying = !this.isPlaying; | |
| const playIcon = document.getElementById('play-icon'); | |
| const pauseIcon = document.getElementById('pause-icon'); | |
| if (this.isPlaying) { | |
| playIcon.style.display = 'none'; | |
| pauseIcon.style.display = 'block'; | |
| this.lastFrameTime = performance.now(); | |
| } else { | |
| playIcon.style.display = 'block'; | |
| pauseIcon.style.display = 'none'; | |
| } | |
| } | |
| cyclePlaybackSpeed() { | |
| const speeds = [0.5, 1, 2, 4, 8]; | |
| const speedRates = speeds.map(s => s * this.config.baseFrameRate); | |
| let currentIndex = 0; | |
| const normalizedSpeed = this.playbackSpeed / this.config.baseFrameRate; | |
| for (let i = 0; i < speeds.length; i++) { | |
| if (Math.abs(normalizedSpeed - speeds[i]) < Math.abs(normalizedSpeed - speeds[currentIndex])) { | |
| currentIndex = i; | |
| } | |
| } | |
| const nextIndex = (currentIndex + 1) % speeds.length; | |
| this.playbackSpeed = speedRates[nextIndex]; | |
| this.ui.speedBtn.textContent = `${speeds[nextIndex]}x`; | |
| if (speeds[nextIndex] === 1) { | |
| this.ui.speedBtn.classList.remove('active'); | |
| } else { | |
| this.ui.speedBtn.classList.add('active'); | |
| } | |
| } | |
| seekTo(position) { | |
| const frameIndex = Math.floor(position * this.config.totalFrames); | |
| this.currentFrame = Math.max(0, Math.min(frameIndex, this.config.totalFrames - 1)); | |
| this.updatePointCloud(this.currentFrame); | |
| } | |
| updatePointCloudSettings() { | |
| if (!this.pointCloud) return; | |
| const size = parseFloat(this.ui.pointSize.value); | |
| const opacity = parseFloat(this.ui.pointOpacity.value); | |
| this.pointCloud.material.size = size; | |
| this.pointCloud.material.opacity = opacity; | |
| this.pointCloud.material.needsUpdate = true; | |
| this.updatePointCloud(this.currentFrame); | |
| } | |
| updateControls() { | |
| if (!this.controls) return; | |
| this.controls.update(); | |
| } | |
| resetView() { | |
| if (!this.camera || !this.controls) return; | |
| // Reset camera position | |
| this.camera.position.set(0, 0, this.config.cameraZ || 0); | |
| // Reset controls | |
| this.controls.reset(); | |
| // Set target slightly in front of camera | |
| this.controls.target.set(0, 0, -1); | |
| this.controls.update(); | |
| // Show status message | |
| this.ui.statusBar.textContent = "View reset"; | |
| this.ui.statusBar.classList.remove('hidden'); | |
| // Hide status message after a few seconds | |
| setTimeout(() => { | |
| this.ui.statusBar.classList.add('hidden'); | |
| }, 3000); | |
| } | |
| onWindowResize() { | |
| if (!this.camera || !this.renderer) return; | |
| const windowAspect = window.innerWidth / window.innerHeight; | |
| this.camera.aspect = windowAspect; | |
| this.camera.updateProjectionMatrix(); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| if (this.trajectories && this.trajectories.length > 0) { | |
| const resolution = new THREE.Vector2(window.innerWidth, window.innerHeight); | |
| this.trajectories.forEach(trajectory => { | |
| const { lineSegments } = trajectory.userData; | |
| if (lineSegments && lineSegments.length > 0) { | |
| lineSegments.forEach(segment => { | |
| if (segment.material && segment.material.resolution) { | |
| segment.material.resolution.copy(resolution); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| if (this.cameraFrustum) { | |
| const resolution = new THREE.Vector2(window.innerWidth, window.innerHeight); | |
| this.cameraFrustum.children.forEach(line => { | |
| if (line.material && line.material.resolution) { | |
| line.material.resolution.copy(resolution); | |
| } | |
| }); | |
| } | |
| } | |
| startAnimation() { | |
| this.isPlaying = true; | |
| this.lastFrameTime = performance.now(); | |
| this.camera.position.set(0, 0, this.config.cameraZ || 0); | |
| this.controls.target.set(0, 0, -1); | |
| this.controls.update(); | |
| this.playbackSpeed = this.config.baseFrameRate; | |
| document.getElementById('play-icon').style.display = 'none'; | |
| document.getElementById('pause-icon').style.display = 'block'; | |
| this.animate(); | |
| } | |
| animate() { | |
| requestAnimationFrame(() => this.animate()); | |
| if (this.controls) { | |
| this.controls.update(); | |
| } | |
| if (this.isPlaying && this.data) { | |
| const now = performance.now(); | |
| const delta = (now - this.lastFrameTime) / 1000; | |
| const framesToAdvance = Math.floor(delta * this.config.baseFrameRate * this.playbackSpeed); | |
| if (framesToAdvance > 0) { | |
| this.currentFrame = (this.currentFrame + framesToAdvance) % this.config.totalFrames; | |
| this.lastFrameTime = now; | |
| this.updatePointCloud(this.currentFrame); | |
| } | |
| } | |
| if (this.renderer && this.scene && this.camera) { | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| } | |
| initCameraWithCorrectFOV() { | |
| const fov = this.config.fov || 60; | |
| const windowAspect = window.innerWidth / window.innerHeight; | |
| this.camera = new THREE.PerspectiveCamera( | |
| fov, | |
| windowAspect, | |
| 0.1, | |
| 10000 | |
| ); | |
| this.controls.object = this.camera; | |
| this.controls.update(); | |
| this.initCameraFrustum(); | |
| } | |
| initCameraFrustum() { | |
| this.cameraFrustum = new THREE.Group(); | |
| this.scene.add(this.cameraFrustum); | |
| this.initCameraFrustumGeometry(); | |
| const showCameraFrustum = this.ui.showCameraFrustum ? this.ui.showCameraFrustum.checked : (this.defaultSettings ? this.defaultSettings.showCameraFrustum : false); | |
| this.cameraFrustum.visible = showCameraFrustum; | |
| } | |
| initCameraFrustumGeometry() { | |
| const fov = this.config.fov || 60; | |
| const originalAspect = this.config.original_aspect_ratio || 1.33; | |
| const size = parseFloat(this.ui.frustumSize.value) || this.defaultSettings.frustumSize; | |
| const halfHeight = Math.tan(THREE.MathUtils.degToRad(fov / 2)) * size; | |
| const halfWidth = halfHeight * originalAspect; | |
| const vertices = [ | |
| new THREE.Vector3(0, 0, 0), | |
| new THREE.Vector3(-halfWidth, -halfHeight, size), | |
| new THREE.Vector3(halfWidth, -halfHeight, size), | |
| new THREE.Vector3(halfWidth, halfHeight, size), | |
| new THREE.Vector3(-halfWidth, halfHeight, size) | |
| ]; | |
| const resolution = new THREE.Vector2(window.innerWidth, window.innerHeight); | |
| const linePairs = [ | |
| [1, 2], [2, 3], [3, 4], [4, 1], | |
| [0, 1], [0, 2], [0, 3], [0, 4] | |
| ]; | |
| const colors = { | |
| edge: new THREE.Color(0x3366ff), | |
| ray: new THREE.Color(0x33cc66) | |
| }; | |
| linePairs.forEach((pair, index) => { | |
| const positions = [ | |
| vertices[pair[0]].x, vertices[pair[0]].y, vertices[pair[0]].z, | |
| vertices[pair[1]].x, vertices[pair[1]].y, vertices[pair[1]].z | |
| ]; | |
| const lineGeometry = new THREE.LineGeometry(); | |
| lineGeometry.setPositions(positions); | |
| let color = index < 4 ? colors.edge : colors.ray; | |
| const lineMaterial = new THREE.LineMaterial({ | |
| color: color, | |
| linewidth: 2, | |
| resolution: resolution, | |
| dashed: false | |
| }); | |
| const line = new THREE.Line2(lineGeometry, lineMaterial); | |
| this.cameraFrustum.add(line); | |
| }); | |
| } | |
| updateCameraFrustum(frameIndex) { | |
| if (!this.cameraFrustum || !this.data) return; | |
| const invExtrinsics = this.data.inv_extrinsics; | |
| if (!invExtrinsics) return; | |
| const invExtrMat = this.get4x4Matrix(invExtrinsics.data, invExtrinsics.shape, frameIndex); | |
| const matrix = new THREE.Matrix4(); | |
| matrix.set( | |
| invExtrMat[0][0], invExtrMat[0][1], invExtrMat[0][2], invExtrMat[0][3], | |
| invExtrMat[1][0], invExtrMat[1][1], invExtrMat[1][2], invExtrMat[1][3], | |
| invExtrMat[2][0], invExtrMat[2][1], invExtrMat[2][2], invExtrMat[2][3], | |
| invExtrMat[3][0], invExtrMat[3][1], invExtrMat[3][2], invExtrMat[3][3] | |
| ); | |
| const position = new THREE.Vector3(); | |
| position.setFromMatrixPosition(matrix); | |
| const rotMatrix = new THREE.Matrix4().extractRotation(matrix); | |
| const coordinateCorrection = new THREE.Matrix4().makeRotationX(Math.PI); | |
| const finalRotation = new THREE.Matrix4().multiplyMatrices(coordinateCorrection, rotMatrix); | |
| const quaternion = new THREE.Quaternion(); | |
| quaternion.setFromRotationMatrix(finalRotation); | |
| position.y = -position.y; | |
| position.z = -position.z; | |
| this.cameraFrustum.position.copy(position); | |
| this.cameraFrustum.quaternion.copy(quaternion); | |
| const showCameraFrustum = this.ui.showCameraFrustum ? this.ui.showCameraFrustum.checked : this.defaultSettings.showCameraFrustum; | |
| if (this.cameraFrustum.visible !== showCameraFrustum) { | |
| this.cameraFrustum.visible = showCameraFrustum; | |
| } | |
| const resolution = new THREE.Vector2(window.innerWidth, window.innerHeight); | |
| this.cameraFrustum.children.forEach(line => { | |
| if (line.material && line.material.resolution) { | |
| line.material.resolution.copy(resolution); | |
| } | |
| }); | |
| } | |
| updateFrustumDimensions() { | |
| if (!this.cameraFrustum) return; | |
| while(this.cameraFrustum.children.length > 0) { | |
| const child = this.cameraFrustum.children[0]; | |
| if (child.geometry) child.geometry.dispose(); | |
| if (child.material) child.material.dispose(); | |
| this.cameraFrustum.remove(child); | |
| } | |
| this.initCameraFrustumGeometry(); | |
| this.updateCameraFrustum(this.currentFrame); | |
| } | |
| // Keep History methods | |
| updateHistory(frameIndex) { | |
| if (!this.ui.enableKeepHistory.checked || !this.data) return; | |
| const stride = parseInt(this.ui.historyStride.value); | |
| const newHistoryFrames = this.calculateHistoryFrames(frameIndex, stride); | |
| // Check if history frames changed | |
| if (this.arraysEqual(this.historyFrames, newHistoryFrames)) return; | |
| this.clearHistory(); | |
| this.historyFrames = newHistoryFrames; | |
| // Create history point clouds and trajectories | |
| this.historyFrames.forEach(historyFrame => { | |
| if (historyFrame !== frameIndex) { | |
| this.createHistoryPointCloud(historyFrame); | |
| this.createHistoryTrajectories(historyFrame); | |
| } | |
| }); | |
| } | |
| calculateHistoryFrames(currentFrame, stride) { | |
| const frames = []; | |
| let frame = 1; // Start from frame 1 | |
| while (frame <= currentFrame && frames.length < this.maxHistoryFrames) { | |
| frames.push(frame); | |
| frame += stride; | |
| } | |
| // Always include current frame | |
| if (!frames.includes(currentFrame)) { | |
| frames.push(currentFrame); | |
| } | |
| return frames.sort((a, b) => a - b); | |
| } | |
| createHistoryPointCloud(frameIndex) { | |
| const numPoints = this.config.resolution[0] * this.config.resolution[1]; | |
| const positions = new Float32Array(numPoints * 3); | |
| const colors = new Float32Array(numPoints * 3); | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| const material = new THREE.PointsMaterial({ | |
| size: parseFloat(this.ui.pointSize.value), | |
| vertexColors: true, | |
| transparent: true, | |
| opacity: 0.5, // Transparent for history | |
| sizeAttenuation: true | |
| }); | |
| const historyPointCloud = new THREE.Points(geometry, material); | |
| this.scene.add(historyPointCloud); | |
| this.historyPointClouds.push(historyPointCloud); | |
| // Update the history point cloud with data | |
| this.updateHistoryPointCloud(historyPointCloud, frameIndex); | |
| } | |
| updateHistoryPointCloud(pointCloud, frameIndex) { | |
| const positions = pointCloud.geometry.attributes.position.array; | |
| const colors = pointCloud.geometry.attributes.color.array; | |
| const rgbVideo = this.data.rgb_video; | |
| const depthsRgb = this.data.depths_rgb; | |
| const intrinsics = this.data.intrinsics; | |
| const invExtrinsics = this.data.inv_extrinsics; | |
| const width = this.config.resolution[0]; | |
| const height = this.config.resolution[1]; | |
| const numPoints = width * height; | |
| const K = this.get3x3Matrix(intrinsics.data, intrinsics.shape, frameIndex); | |
| const fx = K[0][0], fy = K[1][1], cx = K[0][2], cy = K[1][2]; | |
| const invExtrMat = this.get4x4Matrix(invExtrinsics.data, invExtrinsics.shape, frameIndex); | |
| const transform = this.getTransformElements(invExtrMat); | |
| const rgbFrame = this.getFrame(rgbVideo.data, rgbVideo.shape, frameIndex); | |
| const depthFrame = this.getFrame(depthsRgb.data, depthsRgb.shape, frameIndex); | |
| const maxDepth = parseFloat(this.ui.maxDepth.value) || 10.0; | |
| let validPointCount = 0; | |
| for (let i = 0; i < numPoints; i++) { | |
| const xPix = i % width; | |
| const yPix = Math.floor(i / width); | |
| const d0 = depthFrame[i * 3]; | |
| const d1 = depthFrame[i * 3 + 1]; | |
| const depthEncoded = d0 | (d1 << 8); | |
| const depthValue = (depthEncoded / ((1 << 16) - 1)) * | |
| (this.config.depthRange[1] - this.config.depthRange[0]) + | |
| this.config.depthRange[0]; | |
| if (depthValue === 0 || depthValue > maxDepth) { | |
| continue; | |
| } | |
| const X = ((xPix - cx) * depthValue) / fx; | |
| const Y = ((yPix - cy) * depthValue) / fy; | |
| const Z = depthValue; | |
| const tx = transform.m11 * X + transform.m12 * Y + transform.m13 * Z + transform.m14; | |
| const ty = transform.m21 * X + transform.m22 * Y + transform.m23 * Z + transform.m24; | |
| const tz = transform.m31 * X + transform.m32 * Y + transform.m33 * Z + transform.m34; | |
| const index = validPointCount * 3; | |
| positions[index] = tx; | |
| positions[index + 1] = -ty; | |
| positions[index + 2] = -tz; | |
| colors[index] = rgbFrame[i * 3] / 255; | |
| colors[index + 1] = rgbFrame[i * 3 + 1] / 255; | |
| colors[index + 2] = rgbFrame[i * 3 + 2] / 255; | |
| validPointCount++; | |
| } | |
| pointCloud.geometry.setDrawRange(0, validPointCount); | |
| pointCloud.geometry.attributes.position.needsUpdate = true; | |
| pointCloud.geometry.attributes.color.needsUpdate = true; | |
| } | |
| createHistoryTrajectories(frameIndex) { | |
| if (!this.data.trajectories) return; | |
| const trajectoryData = this.data.trajectories.data; | |
| const [totalFrames, numTrajectories] = this.data.trajectories.shape; | |
| const palette = this.createColorPalette(numTrajectories); | |
| const historyTrajectoryGroup = new THREE.Group(); | |
| for (let i = 0; i < numTrajectories; i++) { | |
| const ballSize = parseFloat(this.ui.trajectoryBallSize.value); | |
| const sphereGeometry = new THREE.SphereGeometry(ballSize, 16, 16); | |
| const sphereMaterial = new THREE.MeshBasicMaterial({ | |
| color: palette[i], | |
| transparent: true, | |
| opacity: 0.3 // Transparent for history | |
| }); | |
| const positionMarker = new THREE.Mesh(sphereGeometry, sphereMaterial); | |
| const currentOffset = (frameIndex * numTrajectories + i) * 3; | |
| positionMarker.position.set( | |
| trajectoryData[currentOffset], | |
| -trajectoryData[currentOffset + 1], | |
| -trajectoryData[currentOffset + 2] | |
| ); | |
| historyTrajectoryGroup.add(positionMarker); | |
| } | |
| this.scene.add(historyTrajectoryGroup); | |
| this.historyTrajectories.push(historyTrajectoryGroup); | |
| } | |
| clearHistory() { | |
| // Clear history point clouds | |
| this.historyPointClouds.forEach(pointCloud => { | |
| if (pointCloud.geometry) pointCloud.geometry.dispose(); | |
| if (pointCloud.material) pointCloud.material.dispose(); | |
| this.scene.remove(pointCloud); | |
| }); | |
| this.historyPointClouds = []; | |
| // Clear history trajectories | |
| this.historyTrajectories.forEach(trajectoryGroup => { | |
| trajectoryGroup.children.forEach(child => { | |
| if (child.geometry) child.geometry.dispose(); | |
| if (child.material) child.material.dispose(); | |
| }); | |
| this.scene.remove(trajectoryGroup); | |
| }); | |
| this.historyTrajectories = []; | |
| this.historyFrames = []; | |
| } | |
| arraysEqual(a, b) { | |
| if (a.length !== b.length) return false; | |
| for (let i = 0; i < a.length; i++) { | |
| if (a[i] !== b[i]) return false; | |
| } | |
| return true; | |
| } | |
| toggleBackground() { | |
| const isWhiteBackground = this.ui.whiteBackground.checked; | |
| if (isWhiteBackground) { | |
| // Switch to white background | |
| document.body.style.backgroundColor = '#ffffff'; | |
| this.scene.background = new THREE.Color(0xffffff); | |
| // Update UI elements for white background | |
| document.documentElement.style.setProperty('--bg', '#ffffff'); | |
| document.documentElement.style.setProperty('--text', '#333333'); | |
| document.documentElement.style.setProperty('--text-secondary', '#666666'); | |
| document.documentElement.style.setProperty('--border', '#cccccc'); | |
| document.documentElement.style.setProperty('--surface', '#f5f5f5'); | |
| document.documentElement.style.setProperty('--shadow', 'rgba(0, 0, 0, 0.1)'); | |
| document.documentElement.style.setProperty('--shadow-hover', 'rgba(0, 0, 0, 0.2)'); | |
| // Update status bar and control panel backgrounds | |
| this.ui.statusBar.style.background = 'rgba(245, 245, 245, 0.9)'; | |
| this.ui.statusBar.style.color = '#333333'; | |
| const controlPanel = document.getElementById('control-panel'); | |
| if (controlPanel) { | |
| controlPanel.style.background = 'rgba(245, 245, 245, 0.95)'; | |
| } | |
| const settingsPanel = document.getElementById('settings-panel'); | |
| if (settingsPanel) { | |
| settingsPanel.style.background = 'rgba(245, 245, 245, 0.98)'; | |
| } | |
| } else { | |
| // Switch back to dark background | |
| document.body.style.backgroundColor = '#1a1a1a'; | |
| this.scene.background = new THREE.Color(0x1a1a1a); | |
| // Restore original dark theme variables | |
| document.documentElement.style.setProperty('--bg', '#1a1a1a'); | |
| document.documentElement.style.setProperty('--text', '#e0e0e0'); | |
| document.documentElement.style.setProperty('--text-secondary', '#a0a0a0'); | |
| document.documentElement.style.setProperty('--border', '#444444'); | |
| document.documentElement.style.setProperty('--surface', '#2c2c2c'); | |
| document.documentElement.style.setProperty('--shadow', 'rgba(0, 0, 0, 0.2)'); | |
| document.documentElement.style.setProperty('--shadow-hover', 'rgba(0, 0, 0, 0.3)'); | |
| // Restore original UI backgrounds | |
| this.ui.statusBar.style.background = 'rgba(30, 30, 30, 0.9)'; | |
| this.ui.statusBar.style.color = '#e0e0e0'; | |
| const controlPanel = document.getElementById('control-panel'); | |
| if (controlPanel) { | |
| controlPanel.style.background = 'rgba(44, 44, 44, 0.95)'; | |
| } | |
| const settingsPanel = document.getElementById('settings-panel'); | |
| if (settingsPanel) { | |
| settingsPanel.style.background = 'rgba(44, 44, 44, 0.98)'; | |
| } | |
| } | |
| // Show status message | |
| this.ui.statusBar.textContent = isWhiteBackground ? "Switched to white background" : "Switched to dark background"; | |
| this.ui.statusBar.classList.remove('hidden'); | |
| setTimeout(() => { | |
| this.ui.statusBar.classList.add('hidden'); | |
| }, 2000); | |
| } | |
| resetSettings() { | |
| if (!this.defaultSettings) return; | |
| this.applyDefaultSettings(); | |
| // Reset background to dark theme | |
| if (this.ui.whiteBackground) { | |
| this.ui.whiteBackground.checked = false; | |
| this.toggleBackground(); | |
| } | |
| this.updatePointCloudSettings(); | |
| this.updateTrajectorySettings(); | |
| this.updateFrustumDimensions(); | |
| // Clear history when resetting settings | |
| this.clearHistory(); | |
| this.ui.statusBar.textContent = "Settings reset to defaults"; | |
| this.ui.statusBar.classList.remove('hidden'); | |
| setTimeout(() => { | |
| this.ui.statusBar.classList.add('hidden'); | |
| }, 3000); | |
| } | |
| } | |
| window.addEventListener('DOMContentLoaded', () => { | |
| new PointCloudVisualizer(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |