Add syntax highlighting, OSD, search, perf optimizations, and UX improvements
- troika-three-text MSDF rendering for resolution-independent code text - highlight.js syntax highlighting with Catppuccin Mocha colors - Lazy text pool: max 25 concurrent code meshes, created on demand - LOD throttled to every 3 frames, OSD every 10 - 45° tiled watermark (repo/path/filename) behind code - OSD: breadcrumb, file stats, zoom level - Search: / or Ctrl+F to find and fly to files - Keybindings: WASD pan, Q/E rotate, Z/C zoom, Space overview, ? help modal - Mouse wheel zoom vs trackpad pan detection via event frequency - Zip GBK filename encoding fallback for Chinese filenames - Docker volume persistence for SQLite cache
This commit is contained in:
1
server/Cargo.lock
generated
1
server/Cargo.lock
generated
@@ -900,6 +900,7 @@ name = "repo-vis-server"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"encoding_rs",
|
||||||
"hex",
|
"hex",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ walkdir = "2"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
zip = "2"
|
zip = "2"
|
||||||
|
encoding_rs = "0.8"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
|
|||||||
@@ -183,9 +183,40 @@ async fn scan_zip(
|
|||||||
let mut archive =
|
let mut archive =
|
||||||
zip::ZipArchive::new(cursor).map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid zip: {e}")))?;
|
zip::ZipArchive::new(cursor).map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid zip: {e}")))?;
|
||||||
|
|
||||||
archive
|
// Extract manually to handle non-UTF-8 filenames (GBK from Windows zips)
|
||||||
.extract(tmp.path())
|
for i in 0..archive.len() {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Extract failed: {e}")))?;
|
let mut file = archive
|
||||||
|
.by_index_raw(i)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Zip entry error: {e}")))?;
|
||||||
|
|
||||||
|
let raw_name = file.name_raw();
|
||||||
|
// Try UTF-8 first, then GBK fallback
|
||||||
|
let name = match std::str::from_utf8(raw_name) {
|
||||||
|
Ok(s) => s.to_string(),
|
||||||
|
Err(_) => {
|
||||||
|
let (decoded, _, _) = encoding_rs::GBK.decode(raw_name);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sanitize: skip entries that try to escape
|
||||||
|
if name.contains("..") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let out_path = tmp.path().join(&name);
|
||||||
|
if file.is_dir() {
|
||||||
|
std::fs::create_dir_all(&out_path).ok();
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = out_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
let mut outfile = std::fs::File::create(&out_path)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Create file: {e}")))?;
|
||||||
|
std::io::copy(&mut file, &mut outfile)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write file: {e}")))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let entries: Vec<_> = std::fs::read_dir(tmp.path())
|
let entries: Vec<_> = std::fs::read_dir(tmp.path())
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
|||||||
167
web/index.html
167
web/index.html
@@ -186,15 +186,150 @@
|
|||||||
background: rgba(30, 30, 46, 0.8);
|
background: rgba(30, 30, 46, 0.8);
|
||||||
border: 1px solid #313244;
|
border: 1px solid #313244;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: #585b70;
|
color: #585b70;
|
||||||
line-height: 1.6;
|
line-height: 1.8;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#controls-hint.active { display: block; }
|
#controls-hint.active { display: block; }
|
||||||
|
|
||||||
|
/* OSD info bar (bottom-left) */
|
||||||
|
#osd-info {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(30, 30, 46, 0.8);
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c7086;
|
||||||
|
z-index: 50;
|
||||||
|
display: none;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#osd-info.active { display: block; }
|
||||||
|
|
||||||
|
/* Breadcrumb (top-left) */
|
||||||
|
#osd-breadcrumb {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(30, 30, 46, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #89b4fa;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search overlay */
|
||||||
|
#search-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 480px;
|
||||||
|
max-width: 90vw;
|
||||||
|
margin-top: 60px;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #585b70;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
background: rgba(30, 30, 46, 0.95);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: monospace;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input:focus { border-color: #cba6f7; }
|
||||||
|
#search-input::placeholder { color: #585b70; }
|
||||||
|
|
||||||
|
#search-results {
|
||||||
|
background: rgba(30, 30, 46, 0.95);
|
||||||
|
border: 1px solid #585b70;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #cdd6f4;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item:hover { background: rgba(203, 166, 247, 0.1); }
|
||||||
|
|
||||||
|
/* Help modal */
|
||||||
|
#help-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 300;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content {
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px 36px;
|
||||||
|
min-width: 360px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #cba6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content table { width: 100%; border-collapse: collapse; }
|
||||||
|
|
||||||
|
.help-content td {
|
||||||
|
padding: 5px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content td:first-child {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #89b4fa;
|
||||||
|
padding-right: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content td:last-child { color: #a6adc8; }
|
||||||
|
|
||||||
|
.help-close {
|
||||||
|
margin-top: 16px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #585b70;
|
||||||
|
}
|
||||||
|
|
||||||
/* History */
|
/* History */
|
||||||
#history {
|
#history {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -279,8 +414,34 @@
|
|||||||
|
|
||||||
<div id="viewport"></div>
|
<div id="viewport"></div>
|
||||||
<div id="tooltip"></div>
|
<div id="tooltip"></div>
|
||||||
|
<div id="osd-breadcrumb"></div>
|
||||||
|
<div id="osd-info"></div>
|
||||||
<div id="controls-hint">
|
<div id="controls-hint">
|
||||||
LMB drag — rotate | RMB drag — pan | scroll — zoom | double-click — focus file
|
WASD move · Q/E rotate · Z/C zoom<br>
|
||||||
|
Space file view / overview · Esc reset<br>
|
||||||
|
/ search · dbl-click focus · scroll pan
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="search-overlay">
|
||||||
|
<input type="text" id="search-input" placeholder="Search files... (/ or Ctrl+F)">
|
||||||
|
<div id="search-results"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="help-modal">
|
||||||
|
<div class="help-content">
|
||||||
|
<h2>Keyboard Shortcuts</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>W A S D</td><td>Pan (move on XZ plane)</td></tr>
|
||||||
|
<tr><td>Q / E</td><td>Rotate view left / right</td></tr>
|
||||||
|
<tr><td>Z / C</td><td>Zoom in / out</td></tr>
|
||||||
|
<tr><td>Space</td><td>File overview → project overview</td></tr>
|
||||||
|
<tr><td>Esc</td><td>Reset to project overview</td></tr>
|
||||||
|
<tr><td>/ or Ctrl+F</td><td>Search files</td></tr>
|
||||||
|
<tr><td>?</td><td>Toggle this help</td></tr>
|
||||||
|
<tr><td>Double-click</td><td>Focus on file</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="help-close">Press ? or Esc to close</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/src/app.js"></script>
|
<script type="module" src="/src/app.js"></script>
|
||||||
|
|||||||
60
web/package-lock.json
generated
60
web/package-lock.json
generated
@@ -9,7 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3-hierarchy": "^3.1.2",
|
"d3-hierarchy": "^3.1.2",
|
||||||
"three": "^0.170.0"
|
"highlight.js": "^11.11.1",
|
||||||
|
"three": "^0.170.0",
|
||||||
|
"troika-three-text": "^0.52.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
@@ -762,6 +764,14 @@
|
|||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-hierarchy": {
|
"node_modules/d3-hierarchy": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||||
@@ -842,6 +852,14 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/highlight.js": {
|
||||||
|
"version": "11.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -906,6 +924,14 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
@@ -980,6 +1006,33 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/troika-three-text": {
|
||||||
|
"version": "0.52.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
|
||||||
|
"integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
|
||||||
|
"dependencies": {
|
||||||
|
"bidi-js": "^1.0.2",
|
||||||
|
"troika-three-utils": "^0.52.4",
|
||||||
|
"troika-worker-utils": "^0.52.0",
|
||||||
|
"webgl-sdf-generator": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": ">=0.125.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/troika-three-utils": {
|
||||||
|
"version": "0.52.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
|
||||||
|
"integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": ">=0.125.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/troika-worker-utils": {
|
||||||
|
"version": "0.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
|
||||||
|
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw=="
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||||
@@ -1053,6 +1106,11 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webgl-sdf-generator": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3-hierarchy": "^3.1.2",
|
"d3-hierarchy": "^3.1.2",
|
||||||
"three": "^0.170.0"
|
"highlight.js": "^11.11.1",
|
||||||
|
"three": "^0.170.0",
|
||||||
|
"troika-three-text": "^0.52.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function showError(msg) {
|
|||||||
alert(msg);
|
alert(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function visualize(tree) {
|
async function visualize(tree, repoName) {
|
||||||
showLoading("Building layout...");
|
showLoading("Building layout...");
|
||||||
|
|
||||||
// Wait for fonts to load so canvas renders them correctly
|
// Wait for fonts to load so canvas renders them correctly
|
||||||
@@ -49,8 +49,9 @@ async function visualize(tree) {
|
|||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
showVisualization();
|
showVisualization();
|
||||||
const renderer = new RepoRenderer(viewport);
|
document.getElementById("osd-info").classList.add("active");
|
||||||
renderer.load(leaves, totalWidth, totalHeight);
|
const renderer = new RepoRenderer(viewport, repoName || tree.name);
|
||||||
|
await renderer.load(leaves, totalWidth, totalHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- History ---
|
// --- History ---
|
||||||
@@ -85,7 +86,7 @@ async function loadCachedRepo(key, name) {
|
|||||||
const res = await fetch(`/api/repos/${key}`);
|
const res = await fetch(`/api/repos/${key}`);
|
||||||
if (!res.ok) throw new Error("Cache expired");
|
if (!res.ok) throw new Error("Cache expired");
|
||||||
const tree = await res.json();
|
const tree = await res.json();
|
||||||
await visualize(tree);
|
await visualize(tree, name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err.message);
|
showError(err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
191
web/src/highlight.js
Normal file
191
web/src/highlight.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Syntax highlighting via highlight.js → troika colorRanges.
|
||||||
|
* Returns an object { charIndex: 0xRRGGBB } for troika Text.colorRanges.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import hljs from "highlight.js/lib/core";
|
||||||
|
|
||||||
|
// Register common languages (keep bundle small — add more as needed)
|
||||||
|
import javascript from "highlight.js/lib/languages/javascript";
|
||||||
|
import typescript from "highlight.js/lib/languages/typescript";
|
||||||
|
import python from "highlight.js/lib/languages/python";
|
||||||
|
import rust from "highlight.js/lib/languages/rust";
|
||||||
|
import go from "highlight.js/lib/languages/go";
|
||||||
|
import java from "highlight.js/lib/languages/java";
|
||||||
|
import cpp from "highlight.js/lib/languages/cpp";
|
||||||
|
import c from "highlight.js/lib/languages/c";
|
||||||
|
import css from "highlight.js/lib/languages/css";
|
||||||
|
import xml from "highlight.js/lib/languages/xml";
|
||||||
|
import json from "highlight.js/lib/languages/json";
|
||||||
|
import yaml from "highlight.js/lib/languages/yaml";
|
||||||
|
import bash from "highlight.js/lib/languages/bash";
|
||||||
|
import sql from "highlight.js/lib/languages/sql";
|
||||||
|
import ruby from "highlight.js/lib/languages/ruby";
|
||||||
|
import php from "highlight.js/lib/languages/php";
|
||||||
|
import swift from "highlight.js/lib/languages/swift";
|
||||||
|
import kotlin from "highlight.js/lib/languages/kotlin";
|
||||||
|
import scala from "highlight.js/lib/languages/scala";
|
||||||
|
import lua from "highlight.js/lib/languages/lua";
|
||||||
|
import markdown from "highlight.js/lib/languages/markdown";
|
||||||
|
|
||||||
|
hljs.registerLanguage("javascript", javascript);
|
||||||
|
hljs.registerLanguage("typescript", typescript);
|
||||||
|
hljs.registerLanguage("python", python);
|
||||||
|
hljs.registerLanguage("rust", rust);
|
||||||
|
hljs.registerLanguage("go", go);
|
||||||
|
hljs.registerLanguage("java", java);
|
||||||
|
hljs.registerLanguage("cpp", cpp);
|
||||||
|
hljs.registerLanguage("c", c);
|
||||||
|
hljs.registerLanguage("css", css);
|
||||||
|
hljs.registerLanguage("xml", xml);
|
||||||
|
hljs.registerLanguage("json", json);
|
||||||
|
hljs.registerLanguage("yaml", yaml);
|
||||||
|
hljs.registerLanguage("bash", bash);
|
||||||
|
hljs.registerLanguage("sql", sql);
|
||||||
|
hljs.registerLanguage("ruby", ruby);
|
||||||
|
hljs.registerLanguage("php", php);
|
||||||
|
hljs.registerLanguage("swift", swift);
|
||||||
|
hljs.registerLanguage("kotlin", kotlin);
|
||||||
|
hljs.registerLanguage("scala", scala);
|
||||||
|
hljs.registerLanguage("lua", lua);
|
||||||
|
hljs.registerLanguage("markdown", markdown);
|
||||||
|
|
||||||
|
// Catppuccin Mocha-inspired color scheme
|
||||||
|
const TOKEN_COLORS = {
|
||||||
|
keyword: 0xcba6f7, // mauve
|
||||||
|
built_in: 0xf38ba8, // red
|
||||||
|
type: 0xf9e2af, // yellow
|
||||||
|
literal: 0xfab387, // peach
|
||||||
|
number: 0xfab387,
|
||||||
|
string: 0xa6e3a1, // green
|
||||||
|
regexp: 0xa6e3a1,
|
||||||
|
title: 0x89b4fa, // blue (function names, classes)
|
||||||
|
"title.class_": 0xf9e2af,
|
||||||
|
"title.function_": 0x89b4fa,
|
||||||
|
params: 0xcdd6f4, // text (default)
|
||||||
|
comment: 0x6c7086, // overlay0
|
||||||
|
doctag: 0x6c7086,
|
||||||
|
meta: 0xf2cdcd, // flamingo
|
||||||
|
attr: 0xf9e2af,
|
||||||
|
attribute: 0xf9e2af,
|
||||||
|
name: 0x89b4fa,
|
||||||
|
selector: 0xa6e3a1,
|
||||||
|
tag: 0xf38ba8,
|
||||||
|
variable: 0xcdd6f4,
|
||||||
|
operator: 0x94e2d5, // teal
|
||||||
|
punctuation:0x9399b2, // overlay2
|
||||||
|
property: 0x89dceb, // sapphire
|
||||||
|
symbol: 0xf5c2e7, // pink
|
||||||
|
addition: 0xa6e3a1,
|
||||||
|
deletion: 0xf38ba8,
|
||||||
|
section: 0x89b4fa,
|
||||||
|
bullet: 0x94e2d5,
|
||||||
|
emphasis: 0xcdd6f4,
|
||||||
|
strong: 0xcdd6f4,
|
||||||
|
formula: 0x94e2d5,
|
||||||
|
link: 0x89b4fa,
|
||||||
|
quote: 0xa6e3a1,
|
||||||
|
subst: 0xcdd6f4,
|
||||||
|
"template-variable": 0xcdd6f4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_COLOR = 0xcdd6f4; // catppuccin text
|
||||||
|
|
||||||
|
// Map file extension to hljs language name
|
||||||
|
const EXT_TO_LANG = {
|
||||||
|
js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript",
|
||||||
|
ts: "typescript", tsx: "typescript",
|
||||||
|
py: "python", pyw: "python",
|
||||||
|
rs: "rust",
|
||||||
|
go: "go",
|
||||||
|
java: "java",
|
||||||
|
c: "c", h: "c",
|
||||||
|
cpp: "cpp", hpp: "cpp", cc: "cpp",
|
||||||
|
css: "css", scss: "css", less: "css",
|
||||||
|
html: "xml", htm: "xml", xml: "xml", svg: "xml", vue: "xml",
|
||||||
|
json: "json",
|
||||||
|
yaml: "yaml", yml: "yaml",
|
||||||
|
sh: "bash", bash: "bash", zsh: "bash",
|
||||||
|
sql: "sql",
|
||||||
|
rb: "ruby",
|
||||||
|
php: "php",
|
||||||
|
swift: "swift",
|
||||||
|
kt: "kotlin",
|
||||||
|
scala: "scala",
|
||||||
|
lua: "lua",
|
||||||
|
md: "markdown",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLanguage(filename) {
|
||||||
|
const dot = filename.lastIndexOf(".");
|
||||||
|
if (dot === -1) return null;
|
||||||
|
const ext = filename.substring(dot + 1).toLowerCase();
|
||||||
|
return EXT_TO_LANG[ext] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveColor(classes) {
|
||||||
|
// classes is like "hljs-keyword" or "hljs-title hljs-function_"
|
||||||
|
// Try most specific first
|
||||||
|
for (const cls of classes) {
|
||||||
|
const name = cls.replace("hljs-", "");
|
||||||
|
if (TOKEN_COLORS[name] != null) return TOKEN_COLORS[name];
|
||||||
|
}
|
||||||
|
// Try compound: "title.function_"
|
||||||
|
if (classes.length > 1) {
|
||||||
|
const compound = classes.map((c) => c.replace("hljs-", "")).join(".");
|
||||||
|
if (TOKEN_COLORS[compound] != null) return TOKEN_COLORS[compound];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the hljs emitter tree and collect { charIndex: color } ranges.
|
||||||
|
*/
|
||||||
|
function walkTree(node, colorRanges, pos) {
|
||||||
|
if (typeof node === "string") {
|
||||||
|
return pos + node.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const classes = node.scope ? node.scope.split(".").map((s) => `hljs-${s}`) : [];
|
||||||
|
const color = resolveColor(classes);
|
||||||
|
|
||||||
|
if (color != null) {
|
||||||
|
colorRanges[pos] = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = pos;
|
||||||
|
for (const child of node.children) {
|
||||||
|
cursor = walkTree(child, colorRanges, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to default after this scope
|
||||||
|
if (color != null) {
|
||||||
|
colorRanges[cursor] = DEFAULT_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute colorRanges for troika Text from source code.
|
||||||
|
* @param {string} content - source code
|
||||||
|
* @param {string} filename - for language detection
|
||||||
|
* @returns {object|null} colorRanges object, or null if no highlighting available
|
||||||
|
*/
|
||||||
|
export function computeColorRanges(content, filename) {
|
||||||
|
const lang = getLanguage(filename);
|
||||||
|
if (!lang) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = hljs.highlight(content, { language: lang, ignoreIllegals: true });
|
||||||
|
const colorRanges = { 0: DEFAULT_COLOR };
|
||||||
|
walkTree(result._emitter.rootNode, colorRanges, 0);
|
||||||
|
return colorRanges;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,177 +1,75 @@
|
|||||||
/**
|
/**
|
||||||
* Three.js renderer for the repo visualization.
|
* Three.js renderer for repo-vis.
|
||||||
* - Files are flat planes on the XZ ground plane
|
* troika-three-text (MSDF) for text. Lazy text pool for performance.
|
||||||
* - 3D OrbitControls camera
|
|
||||||
* - Semantic zoom: color blocks → code texture based on camera distance
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||||
|
import { Text } from "troika-three-text";
|
||||||
|
import { computeColorRanges } from "./highlight.js";
|
||||||
|
|
||||||
// Color palette by file extension
|
// ---------- colors ----------
|
||||||
const EXT_COLORS = {
|
const EXT_COLORS = {
|
||||||
".js": 0xf7df1e,
|
".js": 0xf7df1e, ".jsx": 0x61dafb, ".ts": 0x3178c6, ".tsx": 0x61dafb,
|
||||||
".jsx": 0x61dafb,
|
".py": 0x3572a5, ".go": 0x00add8, ".rs": 0xdea584,
|
||||||
".ts": 0x3178c6,
|
".c": 0x555555, ".h": 0x555555, ".cpp": 0xf34b7d, ".hpp": 0xf34b7d,
|
||||||
".tsx": 0x61dafb,
|
".java": 0xb07219, ".rb": 0xcc342d, ".php": 0x4f5d95,
|
||||||
".py": 0x3572a5,
|
".html": 0xe34c26, ".css": 0x563d7c, ".scss": 0xc6538c,
|
||||||
".go": 0x00add8,
|
".json": 0x40b5a4, ".yaml": 0xcb171e, ".yml": 0xcb171e,
|
||||||
".rs": 0xdea584,
|
".md": 0x083fa1, ".sh": 0x89e051, ".sql": 0xe38c00,
|
||||||
".c": 0x555555,
|
".vue": 0x41b883, ".svelte": 0xff3e00,
|
||||||
".h": 0x555555,
|
|
||||||
".cpp": 0xf34b7d,
|
|
||||||
".hpp": 0xf34b7d,
|
|
||||||
".java": 0xb07219,
|
|
||||||
".rb": 0xcc342d,
|
|
||||||
".php": 0x4f5d95,
|
|
||||||
".html": 0xe34c26,
|
|
||||||
".css": 0x563d7c,
|
|
||||||
".scss": 0xc6538c,
|
|
||||||
".json": 0x40b5a4,
|
|
||||||
".yaml": 0xcb171e,
|
|
||||||
".yml": 0xcb171e,
|
|
||||||
".md": 0x083fa1,
|
|
||||||
".sh": 0x89e051,
|
|
||||||
".sql": 0xe38c00,
|
|
||||||
".vue": 0x41b883,
|
|
||||||
".svelte": 0xff3e00,
|
|
||||||
};
|
};
|
||||||
const DEFAULT_COLOR = 0x8899aa;
|
const DEFAULT_COLOR = 0x8899aa;
|
||||||
|
const MAX_VISIBLE_CODE = 25; // max concurrent code text meshes
|
||||||
|
|
||||||
function getColor(filename) {
|
function getColor(fn) {
|
||||||
const dot = filename.lastIndexOf(".");
|
const i = fn.lastIndexOf(".");
|
||||||
if (dot === -1) return DEFAULT_COLOR;
|
return i < 0 ? DEFAULT_COLOR : (EXT_COLORS[fn.substring(i).toLowerCase()] ?? DEFAULT_COLOR);
|
||||||
const ext = filename.substring(dot).toLowerCase();
|
|
||||||
return EXT_COLORS[ext] ?? DEFAULT_COLOR;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Build a tiled watermark string that fills a rectangular area at 45°. */
|
||||||
* Render code text onto a Canvas and return it as a texture.
|
function buildWatermark(text, cols, rows) {
|
||||||
*/
|
const padded = text + " ";
|
||||||
// Monospace font stack with CJK fallback
|
const line = padded.repeat(Math.ceil(cols / padded.length)).substring(0, cols);
|
||||||
const CODE_FONT = 'Terminus, "LXGW WenKai Mono", "Noto Sans Mono CJK SC", "Microsoft YaHei", monospace';
|
const lines = [];
|
||||||
|
for (let i = 0; i < rows; i++) {
|
||||||
function createCodeTexture(content, lines, maxLen) {
|
// Offset each line to create diagonal illusion
|
||||||
const fontSize = 28; // Higher base resolution for sharp zoom
|
const shift = (i * 3) % padded.length;
|
||||||
const charW = fontSize * 0.6;
|
lines.push(line.substring(shift) + line.substring(0, shift));
|
||||||
const lineH = fontSize * 1.4;
|
|
||||||
|
|
||||||
// Wrap lines that exceed maxLen
|
|
||||||
const rawLines = content.split("\n");
|
|
||||||
const wrappedLines = [];
|
|
||||||
for (const line of rawLines) {
|
|
||||||
if (line.length <= maxLen) {
|
|
||||||
wrappedLines.push(line);
|
|
||||||
} else {
|
|
||||||
for (let j = 0; j < line.length; j += maxLen) {
|
|
||||||
wrappedLines.push(line.substring(j, j + maxLen));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvasW = Math.ceil(maxLen * charW) + 20;
|
// ---------- renderer ----------
|
||||||
const canvasH = Math.ceil(wrappedLines.length * lineH) + 20;
|
|
||||||
|
|
||||||
// Cap canvas size — 8192 for sharper textures on modern GPUs
|
|
||||||
const maxDim = 8192;
|
|
||||||
const scaleX = canvasW > maxDim ? maxDim / canvasW : 1;
|
|
||||||
const scaleY = canvasH > maxDim ? maxDim / canvasH : 1;
|
|
||||||
const scale = Math.min(scaleX, scaleY);
|
|
||||||
|
|
||||||
const w = Math.ceil(canvasW * scale);
|
|
||||||
const h = Math.ceil(canvasH * scale);
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = w;
|
|
||||||
canvas.height = h;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
|
|
||||||
// Dark background
|
|
||||||
ctx.fillStyle = "#1e1e2e";
|
|
||||||
ctx.fillRect(0, 0, w, h);
|
|
||||||
|
|
||||||
ctx.scale(scale, scale);
|
|
||||||
ctx.font = `${fontSize}px ${CODE_FONT}`;
|
|
||||||
ctx.fillStyle = "#cdd6f4";
|
|
||||||
ctx.textBaseline = "top";
|
|
||||||
|
|
||||||
for (let i = 0; i < wrappedLines.length; i++) {
|
|
||||||
ctx.fillText(wrappedLines[i], 10, 10 + i * lineH);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tex = new THREE.CanvasTexture(canvas);
|
|
||||||
tex.minFilter = THREE.LinearMipmapLinearFilter;
|
|
||||||
tex.magFilter = THREE.LinearFilter;
|
|
||||||
tex.anisotropy = 8;
|
|
||||||
return tex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a simple color block texture with the file name.
|
|
||||||
*/
|
|
||||||
function createBlockTexture(name, color) {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = 256;
|
|
||||||
canvas.height = 256;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
|
|
||||||
const hex = "#" + color.toString(16).padStart(6, "0");
|
|
||||||
ctx.fillStyle = hex;
|
|
||||||
ctx.fillRect(0, 0, 256, 256);
|
|
||||||
|
|
||||||
// Slightly darker border
|
|
||||||
ctx.strokeStyle = "rgba(0,0,0,0.3)";
|
|
||||||
ctx.lineWidth = 4;
|
|
||||||
ctx.strokeRect(2, 2, 252, 252);
|
|
||||||
|
|
||||||
// File name
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
ctx.font = "bold 18px sans-serif";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
|
|
||||||
// Truncate long names
|
|
||||||
const label = name.length > 28 ? name.substring(0, 25) + "..." : name;
|
|
||||||
ctx.fillText(label, 128, 128);
|
|
||||||
|
|
||||||
const tex = new THREE.CanvasTexture(canvas);
|
|
||||||
tex.minFilter = THREE.LinearFilter;
|
|
||||||
return tex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} FileTile
|
|
||||||
* @property {THREE.Mesh} mesh
|
|
||||||
* @property {object} data - layout data (path, name, content, etc.)
|
|
||||||
* @property {THREE.Texture|null} codeTexture - lazily created
|
|
||||||
* @property {THREE.Texture} blockTexture
|
|
||||||
* @property {boolean} showingCode
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class RepoRenderer {
|
export class RepoRenderer {
|
||||||
constructor(container) {
|
constructor(container, repoName) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
|
this.repoName = repoName || "repo";
|
||||||
this.tiles = [];
|
this.tiles = [];
|
||||||
|
this.bgMeshes = [];
|
||||||
this.raycaster = new THREE.Raycaster();
|
this.raycaster = new THREE.Raycaster();
|
||||||
this.mouse = new THREE.Vector2();
|
this.mouse = new THREE.Vector2();
|
||||||
this.hoveredTile = null;
|
this.hoveredTile = null;
|
||||||
|
this.initialCamPos = null;
|
||||||
|
this.initialCamTarget = null;
|
||||||
|
this.totalFiles = 0;
|
||||||
|
this.totalLines = 0;
|
||||||
|
this.frameCount = 0;
|
||||||
|
|
||||||
|
// Tiles currently showing code (sorted by distance, closest first)
|
||||||
|
this.activeTiles = [];
|
||||||
|
|
||||||
this._initScene();
|
this._initScene();
|
||||||
this._initControls();
|
this._initControls();
|
||||||
this._initEvents();
|
this._initEvents();
|
||||||
|
this._initOSD();
|
||||||
this._animate();
|
this._animate();
|
||||||
}
|
}
|
||||||
|
|
||||||
_initScene() {
|
_initScene() {
|
||||||
this.scene = new THREE.Scene();
|
this.scene = new THREE.Scene();
|
||||||
this.scene.background = new THREE.Color(0x11111b);
|
this.scene.background = new THREE.Color(0x11111b);
|
||||||
|
this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 50000);
|
||||||
this.camera = new THREE.PerspectiveCamera(
|
|
||||||
60,
|
|
||||||
window.innerWidth / window.innerHeight,
|
|
||||||
0.1,
|
|
||||||
50000
|
|
||||||
);
|
|
||||||
this.camera.position.set(0, 200, 200);
|
this.camera.position.set(0, 200, 200);
|
||||||
this.camera.lookAt(0, 0, 0);
|
this.camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
@@ -180,28 +78,54 @@ export class RepoRenderer {
|
|||||||
this.renderer3d.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
this.renderer3d.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
this.container.appendChild(this.renderer3d.domElement);
|
this.container.appendChild(this.renderer3d.domElement);
|
||||||
|
|
||||||
// Ambient + directional light
|
this.scene.add(new THREE.AmbientLight(0xffffff, 1.0));
|
||||||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.7));
|
const d = new THREE.DirectionalLight(0xffffff, 0.5);
|
||||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.5);
|
d.position.set(100, 300, 100);
|
||||||
dirLight.position.set(100, 200, 100);
|
this.scene.add(d);
|
||||||
this.scene.add(dirLight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_initControls() {
|
_initControls() {
|
||||||
this.controls = new OrbitControls(this.camera, this.renderer3d.domElement);
|
const c = this.controls = new OrbitControls(this.camera, this.renderer3d.domElement);
|
||||||
this.controls.enableDamping = true;
|
c.enableDamping = true; c.dampingFactor = 0.1;
|
||||||
this.controls.dampingFactor = 0.1;
|
c.maxPolarAngle = Math.PI / 2.1;
|
||||||
this.controls.maxPolarAngle = Math.PI / 2.1; // Don't go below the ground
|
c.minDistance = 0.5; c.maxDistance = 10000;
|
||||||
this.controls.minDistance = 1;
|
c.target.set(0, 0, 0);
|
||||||
this.controls.maxDistance = 10000;
|
c.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_ROTATE };
|
||||||
this.controls.zoomSpeed = 1.5;
|
|
||||||
this.controls.target.set(0, 0, 0);
|
|
||||||
|
|
||||||
// Google Maps-style touch: 1 finger pan, 2 finger pinch zoom + rotate
|
// Wheel: trackpad vs mouse detection
|
||||||
this.controls.touches = {
|
c.enableZoom = false;
|
||||||
ONE: THREE.TOUCH.PAN,
|
let wheelEvents = 0, lastWheelT = 0, trackpadMode = false;
|
||||||
TWO: THREE.TOUCH.DOLLY_ROTATE,
|
this.renderer3d.domElement.addEventListener("wheel", (e) => {
|
||||||
};
|
e.preventDefault();
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - lastWheelT < 80) { wheelEvents++; if (wheelEvents > 2) trackpadMode = true; }
|
||||||
|
else { wheelEvents = 1; trackpadMode = false; }
|
||||||
|
lastWheelT = now;
|
||||||
|
if (e.deltaMode >= 1) trackpadMode = false;
|
||||||
|
|
||||||
|
if (e.ctrlKey || !trackpadMode) {
|
||||||
|
const z = e.deltaY > 0 ? 1.06 : 1 / 1.06;
|
||||||
|
this.camera.position.sub(c.target).multiplyScalar(z).add(c.target);
|
||||||
|
} else {
|
||||||
|
const s = this.camera.position.y * 0.002;
|
||||||
|
const fwd = new THREE.Vector3(); this.camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize();
|
||||||
|
const rt = new THREE.Vector3().crossVectors(fwd, new THREE.Vector3(0, 1, 0)).normalize();
|
||||||
|
const d = rt.clone().multiplyScalar(e.deltaX * s).add(fwd.clone().multiplyScalar(-e.deltaY * s));
|
||||||
|
this.camera.position.add(d); c.target.add(d);
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
this.keys = {};
|
||||||
|
this.moveSpeed = 0.8;
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
if (e.target.id === "search-input") return;
|
||||||
|
this.keys[e.code] = true;
|
||||||
|
if (e.key === "?" || (e.shiftKey && e.code === "Slash")) { e.preventDefault(); this._toggleHelp(); return; }
|
||||||
|
if (e.code === "Escape") this._resetCamera();
|
||||||
|
if (e.code === "Space") { e.preventDefault(); this._spaceZoomOut(); }
|
||||||
|
if (e.code === "Slash" || (e.ctrlKey && e.code === "KeyF")) { e.preventDefault(); this._toggleSearch(); }
|
||||||
|
});
|
||||||
|
window.addEventListener("keyup", (e) => { this.keys[e.code] = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
_initEvents() {
|
_initEvents() {
|
||||||
@@ -210,164 +134,298 @@ export class RepoRenderer {
|
|||||||
this.camera.updateProjectionMatrix();
|
this.camera.updateProjectionMatrix();
|
||||||
this.renderer3d.setSize(window.innerWidth, window.innerHeight);
|
this.renderer3d.setSize(window.innerWidth, window.innerHeight);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hover for tooltip
|
|
||||||
this.renderer3d.domElement.addEventListener("mousemove", (e) => {
|
this.renderer3d.domElement.addEventListener("mousemove", (e) => {
|
||||||
this.mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
this.mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||||
this.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
this.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Double-click to zoom into a file
|
|
||||||
this.renderer3d.domElement.addEventListener("dblclick", () => {
|
this.renderer3d.domElement.addEventListener("dblclick", () => {
|
||||||
if (this.hoveredTile) {
|
if (this.hoveredTile) this.flyToTile(this.hoveredTile);
|
||||||
const t = this.hoveredTile;
|
|
||||||
const d = t.data;
|
|
||||||
const targetX = d.x + d.w / 2;
|
|
||||||
const targetZ = d.z + d.h / 2;
|
|
||||||
const viewDist = Math.max(d.w, d.h) * 0.8;
|
|
||||||
|
|
||||||
// Animate camera
|
|
||||||
this._animateTo(
|
|
||||||
new THREE.Vector3(targetX, viewDist, targetZ + viewDist * 0.5),
|
|
||||||
new THREE.Vector3(targetX, 0, targetZ)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_animateTo(position, target, duration = 800) {
|
_initOSD() {
|
||||||
const startPos = this.camera.position.clone();
|
this.osdInfo = document.getElementById("osd-info");
|
||||||
const startTarget = this.controls.target.clone();
|
this.osdBreadcrumb = document.getElementById("osd-breadcrumb");
|
||||||
const startTime = performance.now();
|
this.searchOverlay = document.getElementById("search-overlay");
|
||||||
|
this.searchInput = document.getElementById("search-input");
|
||||||
|
this.searchResults = document.getElementById("search-results");
|
||||||
|
if (this.searchInput) {
|
||||||
|
this.searchInput.addEventListener("input", () => this._onSearch());
|
||||||
|
this.searchInput.addEventListener("keydown", (e) => {
|
||||||
|
if (e.code === "Escape") this._toggleSearch(false);
|
||||||
|
if (e.code === "Enter") this._searchSelect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- helpers --------
|
||||||
|
_toggleHelp() { const el = document.getElementById("help-modal"); if (el) el.style.display = el.style.display === "none" ? "flex" : "none"; }
|
||||||
|
|
||||||
|
_animateTo(pos, tgt, dur = 800) {
|
||||||
|
const sp = this.camera.position.clone(), st = this.controls.target.clone(), t0 = performance.now();
|
||||||
const step = () => {
|
const step = () => {
|
||||||
const elapsed = performance.now() - startTime;
|
const t = Math.min((performance.now() - t0) / dur, 1);
|
||||||
const t = Math.min(elapsed / duration, 1);
|
const e = t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2;
|
||||||
// Smooth ease
|
this.camera.position.lerpVectors(sp, pos, e); this.controls.target.lerpVectors(st, tgt, e);
|
||||||
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
||||||
|
|
||||||
this.camera.position.lerpVectors(startPos, position, ease);
|
|
||||||
this.controls.target.lerpVectors(startTarget, target, ease);
|
|
||||||
|
|
||||||
if (t < 1) requestAnimationFrame(step);
|
if (t < 1) requestAnimationFrame(step);
|
||||||
};
|
};
|
||||||
requestAnimationFrame(step);
|
requestAnimationFrame(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
_resetCamera() { if (this.initialCamPos) this._animateTo(this.initialCamPos.clone(), this.initialCamTarget.clone(), 600); }
|
||||||
* Load layout data and create meshes.
|
|
||||||
*/
|
_spaceZoomOut() {
|
||||||
load(leaves, totalWidth, totalHeight) {
|
if (this.hoveredTile) this.flyToTile(this.hoveredTile);
|
||||||
// Center the layout around origin
|
else this._resetCamera();
|
||||||
const offsetX = -totalWidth / 2;
|
}
|
||||||
const offsetZ = -totalHeight / 2;
|
|
||||||
|
flyToTile(tile) {
|
||||||
|
const d = tile.data, cx = d.x + d.w/2, cz = d.z + d.h/2, dist = Math.max(d.w, d.h) * 0.8;
|
||||||
|
this._animateTo(new THREE.Vector3(cx, dist, cz + dist*0.5), new THREE.Vector3(cx, 0, cz));
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleSearch(show) {
|
||||||
|
if (!this.searchOverlay) return;
|
||||||
|
const vis = show ?? this.searchOverlay.style.display === "none";
|
||||||
|
this.searchOverlay.style.display = vis ? "flex" : "none";
|
||||||
|
if (vis) { this.searchInput.value = ""; this.searchInput.focus(); this.searchResults.innerHTML = ""; }
|
||||||
|
}
|
||||||
|
_onSearch() {
|
||||||
|
const q = this.searchInput.value.toLowerCase().trim(); this.searchResults.innerHTML = "";
|
||||||
|
if (q.length < 2) return;
|
||||||
|
for (const t of this.tiles.filter(t => t.data.path.toLowerCase().includes(q)).slice(0, 12)) {
|
||||||
|
const el = document.createElement("div"); el.className = "search-item"; el.textContent = t.data.path;
|
||||||
|
el.addEventListener("click", () => { this.flyToTile(t); this._toggleSearch(false); });
|
||||||
|
this.searchResults.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_searchSelect() { const f = this.searchResults.querySelector(".search-item"); if (f) f.click(); }
|
||||||
|
|
||||||
|
// -------- load --------
|
||||||
|
async load(leaves, totalWidth, totalHeight) {
|
||||||
|
const ox = -totalWidth / 2, oz = -totalHeight / 2;
|
||||||
|
this.totalFiles = leaves.length;
|
||||||
|
this.totalLines = leaves.reduce((s, l) => s + l.lines, 0);
|
||||||
|
|
||||||
|
// Shared dark material
|
||||||
|
const darkMat = new THREE.MeshBasicMaterial({ color: 0x1e1e2e });
|
||||||
|
|
||||||
for (const leaf of leaves) {
|
for (const leaf of leaves) {
|
||||||
const color = getColor(leaf.name);
|
const color = getColor(leaf.name);
|
||||||
|
leaf.x += ox; leaf.z += oz;
|
||||||
|
|
||||||
// Block texture (far LOD)
|
// --- BG plane (always visible) ---
|
||||||
const blockTex = createBlockTexture(leaf.name, color);
|
const bgMesh = new THREE.Mesh(
|
||||||
const material = new THREE.MeshStandardMaterial({
|
new THREE.PlaneGeometry(leaf.w, leaf.h),
|
||||||
map: blockTex,
|
new THREE.MeshStandardMaterial({ color, roughness: 0.9, metalness: 0.05 })
|
||||||
roughness: 0.8,
|
|
||||||
metalness: 0.1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const geometry = new THREE.PlaneGeometry(leaf.w, leaf.h);
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
|
|
||||||
// Lay flat on XZ plane, slight Y offset to avoid z-fighting
|
|
||||||
mesh.rotation.x = -Math.PI / 2;
|
|
||||||
mesh.position.set(
|
|
||||||
offsetX + leaf.x + leaf.w / 2,
|
|
||||||
0.01,
|
|
||||||
offsetZ + leaf.z + leaf.h / 2
|
|
||||||
);
|
);
|
||||||
|
bgMesh.rotation.x = -Math.PI / 2;
|
||||||
|
bgMesh.position.set(leaf.x + leaf.w/2, 0.01, leaf.z + leaf.h/2);
|
||||||
|
this.scene.add(bgMesh);
|
||||||
|
|
||||||
this.scene.add(mesh);
|
// --- Label (always visible, cheap — one per file) ---
|
||||||
|
const label = new Text();
|
||||||
|
label.text = leaf.name;
|
||||||
|
label.fontSize = Math.min(leaf.w, leaf.h) * 0.15;
|
||||||
|
label.color = 0xffffff;
|
||||||
|
label.anchorX = "center"; label.anchorY = "middle";
|
||||||
|
label.rotation.x = -Math.PI / 2;
|
||||||
|
label.position.set(leaf.x + leaf.w/2, 0.5, leaf.z + leaf.h/2);
|
||||||
|
label.renderOrder = 10;
|
||||||
|
this.scene.add(label);
|
||||||
|
label.sync();
|
||||||
|
|
||||||
// Adjust leaf coords to world space for camera targeting
|
// Pre-compute syntax highlight ranges (cheap, no GPU)
|
||||||
leaf.x += offsetX;
|
const colorRanges = computeColorRanges(leaf.content, leaf.name);
|
||||||
leaf.z += offsetZ;
|
|
||||||
|
|
||||||
this.tiles.push({
|
this.tiles.push({
|
||||||
mesh,
|
bgMesh, label, darkMat, colorRanges,
|
||||||
data: leaf,
|
codeMesh: null, watermark: null, darkMesh: null,
|
||||||
codeTexture: null,
|
data: leaf, showingCode: false, color, dist: Infinity
|
||||||
blockTexture: blockTex,
|
|
||||||
showingCode: false,
|
|
||||||
color,
|
|
||||||
});
|
});
|
||||||
|
this.bgMeshes.push(bgMesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set initial camera to see the whole scene
|
|
||||||
const maxDim = Math.max(totalWidth, totalHeight);
|
const maxDim = Math.max(totalWidth, totalHeight);
|
||||||
this.camera.position.set(0, maxDim * 0.7, maxDim * 0.5);
|
this.camera.position.set(0, maxDim * 0.7, maxDim * 0.5);
|
||||||
this.controls.target.set(0, 0, 0);
|
this.controls.target.set(0, 0, 0);
|
||||||
|
this.initialCamPos = this.camera.position.clone();
|
||||||
// Tooltip element
|
this.initialCamTarget = this.controls.target.clone();
|
||||||
this.tooltip = document.getElementById("tooltip");
|
this.tooltip = document.getElementById("tooltip");
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateLOD() {
|
// -------- lazy code/watermark creation --------
|
||||||
const camPos = this.camera.position;
|
_showCode(tile) {
|
||||||
|
const d = tile.data;
|
||||||
|
|
||||||
for (const tile of this.tiles) {
|
// Dark bg
|
||||||
const cx = tile.data.x + tile.data.w / 2;
|
if (!tile.darkMesh) {
|
||||||
const cz = tile.data.z + tile.data.h / 2;
|
tile.darkMesh = new THREE.Mesh(new THREE.PlaneGeometry(d.w, d.h), tile.darkMat);
|
||||||
const dist = Math.sqrt(
|
tile.darkMesh.rotation.x = -Math.PI / 2;
|
||||||
(camPos.x - cx) ** 2 + camPos.y ** 2 + (camPos.z - cz) ** 2
|
tile.darkMesh.position.set(d.x + d.w/2, 0.8, d.z + d.h/2);
|
||||||
);
|
|
||||||
|
|
||||||
const fileSize = Math.max(tile.data.w, tile.data.h);
|
|
||||||
// Switch to code texture when close enough to read
|
|
||||||
const threshold = fileSize * 3;
|
|
||||||
|
|
||||||
if (dist < threshold && !tile.showingCode) {
|
|
||||||
// Create code texture lazily
|
|
||||||
if (!tile.codeTexture) {
|
|
||||||
tile.codeTexture = createCodeTexture(
|
|
||||||
tile.data.content,
|
|
||||||
tile.data.lines,
|
|
||||||
tile.data.maxLen
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
tile.mesh.material.map = tile.codeTexture;
|
this.scene.add(tile.darkMesh);
|
||||||
tile.mesh.material.needsUpdate = true;
|
|
||||||
|
// Watermark — tiled path text, 45° rotated, slightly larger than code font
|
||||||
|
if (!tile.watermark) {
|
||||||
|
const codeFontSize = (d.h / d.lines) * 0.65;
|
||||||
|
const wmFontSize = codeFontSize * 2.5;
|
||||||
|
const wmLabel = `${this.repoName}/${d.path}`;
|
||||||
|
// Estimate how many repetitions to fill the area
|
||||||
|
const charsPerLine = Math.ceil(Math.max(d.w, d.h) * 1.5 / (wmFontSize * 0.5));
|
||||||
|
const lineCount = Math.ceil(Math.max(d.w, d.h) * 1.5 / (wmFontSize * 1.5));
|
||||||
|
const wmContent = buildWatermark(wmLabel, charsPerLine, lineCount);
|
||||||
|
|
||||||
|
const wm = new Text();
|
||||||
|
wm.text = wmContent;
|
||||||
|
wm.fontSize = wmFontSize;
|
||||||
|
wm.lineHeight = 1.5;
|
||||||
|
wm.color = 0x585b70;
|
||||||
|
wm.fillOpacity = 0.08;
|
||||||
|
wm.anchorX = "center"; wm.anchorY = "middle";
|
||||||
|
wm.rotation.x = -Math.PI / 2;
|
||||||
|
wm.rotation.z = -Math.PI / 4;
|
||||||
|
wm.position.set(d.x + d.w/2, 0.85, d.z + d.h/2);
|
||||||
|
wm.renderOrder = 15;
|
||||||
|
wm.clipRect = [d.x, -(d.z + d.h), d.x + d.w, -d.z]; // clip to file bounds
|
||||||
|
tile.watermark = wm;
|
||||||
|
wm.sync();
|
||||||
|
}
|
||||||
|
this.scene.add(tile.watermark);
|
||||||
|
|
||||||
|
// Code text
|
||||||
|
if (!tile.codeMesh) {
|
||||||
|
const codeFontSize = (d.h / d.lines) * 0.65;
|
||||||
|
const cm = new Text();
|
||||||
|
cm.text = d.content;
|
||||||
|
cm.fontSize = codeFontSize;
|
||||||
|
cm.lineHeight = 1.35;
|
||||||
|
cm.color = 0xcdd6f4;
|
||||||
|
cm.anchorX = "left"; cm.anchorY = "top";
|
||||||
|
cm.whiteSpace = "pre-wrap";
|
||||||
|
cm.maxWidth = d.w * 0.96;
|
||||||
|
cm.overflowWrap = "break-word";
|
||||||
|
cm.rotation.x = -Math.PI / 2;
|
||||||
|
cm.position.set(d.x + d.w * 0.02, 1.0, d.z + d.h * 0.02);
|
||||||
|
cm.renderOrder = 20;
|
||||||
|
if (tile.colorRanges) cm.colorRanges = tile.colorRanges;
|
||||||
|
tile.codeMesh = cm;
|
||||||
|
cm.sync();
|
||||||
|
}
|
||||||
|
this.scene.add(tile.codeMesh);
|
||||||
|
|
||||||
|
tile.label.visible = false;
|
||||||
tile.showingCode = true;
|
tile.showingCode = true;
|
||||||
} else if (dist >= threshold && tile.showingCode) {
|
}
|
||||||
tile.mesh.material.map = tile.blockTexture;
|
|
||||||
tile.mesh.material.needsUpdate = true;
|
_hideCode(tile) {
|
||||||
|
if (tile.codeMesh) this.scene.remove(tile.codeMesh);
|
||||||
|
if (tile.watermark) this.scene.remove(tile.watermark);
|
||||||
|
if (tile.darkMesh) this.scene.remove(tile.darkMesh);
|
||||||
|
tile.label.visible = true;
|
||||||
tile.showingCode = false;
|
tile.showingCode = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------- LOD (throttled) --------
|
||||||
|
_updateLOD() {
|
||||||
|
const cam = this.camera.position;
|
||||||
|
|
||||||
|
// Compute distances
|
||||||
|
for (const t of this.tiles) {
|
||||||
|
const d = t.data;
|
||||||
|
const cx = d.x + d.w/2, cz = d.z + d.h/2;
|
||||||
|
t.dist = Math.sqrt((cam.x-cx)**2 + cam.y**2 + (cam.z-cz)**2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which tiles should show code (closest N within threshold)
|
||||||
|
const candidates = this.tiles
|
||||||
|
.filter(t => t.dist < Math.max(t.data.w, t.data.h) * 4)
|
||||||
|
.sort((a, b) => a.dist - b.dist)
|
||||||
|
.slice(0, MAX_VISIBLE_CODE);
|
||||||
|
|
||||||
|
const shouldShow = new Set(candidates);
|
||||||
|
|
||||||
|
// Hide tiles that are no longer candidates
|
||||||
|
for (const t of this.tiles) {
|
||||||
|
if (t.showingCode && !shouldShow.has(t)) {
|
||||||
|
this._hideCode(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show new candidates
|
||||||
|
for (const t of candidates) {
|
||||||
|
if (!t.showingCode) this._showCode(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeTiles = candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- hover --------
|
||||||
_updateHover() {
|
_updateHover() {
|
||||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||||
const meshes = this.tiles.map((t) => t.mesh);
|
const hits = this.raycaster.intersectObjects(this.bgMeshes);
|
||||||
const intersects = this.raycaster.intersectObjects(meshes);
|
if (hits.length > 0) {
|
||||||
|
const tile = this.tiles.find(t => t.bgMesh === hits[0].object);
|
||||||
if (intersects.length > 0) {
|
|
||||||
const mesh = intersects[0].object;
|
|
||||||
const tile = this.tiles.find((t) => t.mesh === mesh);
|
|
||||||
this.hoveredTile = tile;
|
this.hoveredTile = tile;
|
||||||
|
|
||||||
if (this.tooltip && tile) {
|
if (this.tooltip && tile) {
|
||||||
this.tooltip.style.display = "block";
|
this.tooltip.style.display = "block";
|
||||||
this.tooltip.textContent = `${tile.data.path} (${tile.data.lines} lines)`;
|
this.tooltip.textContent = `${tile.data.path} (${tile.data.lines} lines)`;
|
||||||
}
|
}
|
||||||
|
if (this.osdBreadcrumb && tile) {
|
||||||
|
this.osdBreadcrumb.textContent = `${this.repoName}/${tile.data.path}`;
|
||||||
|
this.osdBreadcrumb.style.display = "block";
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.hoveredTile = null;
|
this.hoveredTile = null;
|
||||||
if (this.tooltip) this.tooltip.style.display = "none";
|
if (this.tooltip) this.tooltip.style.display = "none";
|
||||||
|
if (this.osdBreadcrumb) this.osdBreadcrumb.style.display = "none";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_updateOSD() {
|
||||||
|
if (!this.osdInfo) return;
|
||||||
|
const h = this.camera.position.y;
|
||||||
|
const zoomPct = Math.max(0, Math.min(100, 100 - (h / (this.initialCamPos?.y || 200)) * 100));
|
||||||
|
const showing = this.activeTiles.length;
|
||||||
|
this.osdInfo.innerHTML =
|
||||||
|
`<span>${this.totalFiles} files</span> · <span>${(this.totalLines/1000).toFixed(1)}k lines</span> · ` +
|
||||||
|
`<span>zoom ${zoomPct.toFixed(0)}%</span>` + (showing > 0 ? ` · <span>${showing} in view</span>` : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateMovement() {
|
||||||
|
const speed = this.moveSpeed * (this.camera.position.y * 0.01 + 0.5);
|
||||||
|
const fwd = new THREE.Vector3(); this.camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize();
|
||||||
|
const rt = new THREE.Vector3().crossVectors(fwd, new THREE.Vector3(0,1,0)).normalize();
|
||||||
|
|
||||||
|
const d = new THREE.Vector3();
|
||||||
|
if (this.keys["KeyW"]) d.add(fwd.clone().multiplyScalar(speed));
|
||||||
|
if (this.keys["KeyS"]) d.add(fwd.clone().multiplyScalar(-speed));
|
||||||
|
if (this.keys["KeyA"]) d.add(rt.clone().multiplyScalar(-speed));
|
||||||
|
if (this.keys["KeyD"]) d.add(rt.clone().multiplyScalar(speed));
|
||||||
|
if (this.keys["KeyQ"] || this.keys["KeyE"]) {
|
||||||
|
const angle = (this.keys["KeyQ"] ? 1 : -1) * 0.03;
|
||||||
|
const offset = this.camera.position.clone().sub(this.controls.target);
|
||||||
|
offset.applyAxisAngle(new THREE.Vector3(0,1,0), angle);
|
||||||
|
this.camera.position.copy(this.controls.target).add(offset);
|
||||||
|
}
|
||||||
|
if (this.keys["KeyZ"] || this.keys["KeyC"]) {
|
||||||
|
const f = this.keys["KeyZ"] ? 0.97 : 1.03;
|
||||||
|
this.camera.position.sub(this.controls.target).multiplyScalar(f).add(this.controls.target);
|
||||||
|
}
|
||||||
|
if (d.lengthSq() > 0) { this.camera.position.add(d); this.controls.target.add(d); }
|
||||||
|
}
|
||||||
|
|
||||||
_animate() {
|
_animate() {
|
||||||
requestAnimationFrame(() => this._animate());
|
requestAnimationFrame(() => this._animate());
|
||||||
|
this._updateMovement();
|
||||||
this.controls.update();
|
this.controls.update();
|
||||||
this._updateLOD();
|
|
||||||
|
// Throttle heavy updates: LOD every 3 frames, OSD every 10
|
||||||
|
this.frameCount++;
|
||||||
|
if (this.frameCount % 3 === 0) this._updateLOD();
|
||||||
|
if (this.frameCount % 10 === 0) this._updateOSD();
|
||||||
this._updateHover();
|
this._updateHover();
|
||||||
|
|
||||||
this.renderer3d.render(this.scene, this.camera);
|
this.renderer3d.render(this.scene, this.camera);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user