Initial commit: repo-vis — 3D codebase visualization
Rust (axum) backend with git clone / zip upload / SQLite cache. Three.js frontend with D3 treemap layout and semantic zoom. Docker deployment with musl static binary.
This commit is contained in:
181
web/src/app.js
Normal file
181
web/src/app.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { computeLayout } from "./layout.js";
|
||||
import { RepoRenderer } from "./renderer.js";
|
||||
|
||||
const landing = document.getElementById("landing");
|
||||
const loading = document.getElementById("loading");
|
||||
const loadingText = document.getElementById("loading-text");
|
||||
const viewport = document.getElementById("viewport");
|
||||
const controlsHint = document.getElementById("controls-hint");
|
||||
const gitUrlInput = document.getElementById("git-url");
|
||||
const btnClone = document.getElementById("btn-clone");
|
||||
const dropZone = document.getElementById("drop-zone");
|
||||
const fileInput = document.getElementById("file-input");
|
||||
const historyEl = document.getElementById("history");
|
||||
const historyList = document.getElementById("history-list");
|
||||
|
||||
function showLoading(msg) {
|
||||
landing.style.display = "none";
|
||||
loading.classList.add("active");
|
||||
loadingText.textContent = msg;
|
||||
}
|
||||
|
||||
function showVisualization() {
|
||||
loading.classList.remove("active");
|
||||
viewport.classList.add("active");
|
||||
controlsHint.classList.add("active");
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
loading.classList.remove("active");
|
||||
landing.style.display = "";
|
||||
alert(msg);
|
||||
}
|
||||
|
||||
async function visualize(tree) {
|
||||
showLoading("Building layout...");
|
||||
|
||||
// Wait for fonts to load so canvas renders them correctly
|
||||
await document.fonts.ready;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const { leaves, totalWidth, totalHeight } = computeLayout(tree);
|
||||
|
||||
if (leaves.length === 0) {
|
||||
showError("No source files found in repository.");
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(`Rendering ${leaves.length} files...`);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
showVisualization();
|
||||
const renderer = new RepoRenderer(viewport);
|
||||
renderer.load(leaves, totalWidth, totalHeight);
|
||||
}
|
||||
|
||||
// --- History ---
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const res = await fetch("/api/repos");
|
||||
if (!res.ok) return;
|
||||
const repos = await res.json();
|
||||
if (repos.length === 0) return;
|
||||
|
||||
historyEl.classList.add("has-items");
|
||||
historyList.innerHTML = "";
|
||||
|
||||
for (const repo of repos) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "history-item";
|
||||
item.innerHTML = `
|
||||
<span class="name">${escapeHtml(repo.name)}</span>
|
||||
<span class="meta">${repo.file_count} files</span>
|
||||
`;
|
||||
item.addEventListener("click", () => loadCachedRepo(repo.cache_key, repo.name));
|
||||
historyList.appendChild(item);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCachedRepo(key, name) {
|
||||
showLoading(`Loading ${name}...`);
|
||||
try {
|
||||
const res = await fetch(`/api/repos/${key}`);
|
||||
if (!res.ok) throw new Error("Cache expired");
|
||||
const tree = await res.json();
|
||||
await visualize(tree);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load history on page load
|
||||
loadHistory();
|
||||
|
||||
// --- Git clone ---
|
||||
btnClone.addEventListener("click", async () => {
|
||||
const url = gitUrlInput.value.trim();
|
||||
if (!url) return;
|
||||
|
||||
btnClone.disabled = true;
|
||||
showLoading("Cloning repository...");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/scan-git", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Clone failed");
|
||||
}
|
||||
|
||||
const tree = await res.json();
|
||||
await visualize(tree);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
btnClone.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
gitUrlInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") btnClone.click();
|
||||
});
|
||||
|
||||
// --- Zip upload ---
|
||||
dropZone.addEventListener("click", () => fileInput.click());
|
||||
|
||||
dropZone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add("dragover");
|
||||
});
|
||||
|
||||
dropZone.addEventListener("dragleave", () => {
|
||||
dropZone.classList.remove("dragover");
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("dragover");
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) uploadZip(file);
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files[0]) uploadZip(fileInput.files[0]);
|
||||
});
|
||||
|
||||
async function uploadZip(file) {
|
||||
showLoading("Uploading and scanning zip...");
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
||||
const res = await fetch("/api/scan-zip", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Upload failed");
|
||||
}
|
||||
|
||||
const tree = await res.json();
|
||||
await visualize(tree);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
50
web/src/layout.js
Normal file
50
web/src/layout.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Treemap layout using d3-hierarchy.
|
||||
* Each file rectangle is sized by: lines × maxLen (the "true" code area).
|
||||
* Returns a flat array of positioned file nodes.
|
||||
*/
|
||||
|
||||
import { treemap, treemapSquarify, hierarchy } from "d3-hierarchy";
|
||||
|
||||
/**
|
||||
* @param {object} tree - The scanned repo tree from scanner.js
|
||||
* @param {number} charW - Character width in world units
|
||||
* @param {number} charH - Character height (line height) in world units
|
||||
* @param {number} padding - Padding between files in world units
|
||||
* @returns {Array<{path, name, x, z, w, h, lines, maxLen, content}>}
|
||||
*/
|
||||
export function computeLayout(tree, charW = 0.15, charH = 0.3, padding = 2) {
|
||||
const root = hierarchy(tree)
|
||||
.sum((d) => (d.content != null ? d.lines * d.maxLen : 0))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
const totalValue = root.value;
|
||||
// Scale factor: we want roughly sqrt(totalValue) * charW on each side
|
||||
const scale = Math.sqrt(totalValue) * charW * 1.2;
|
||||
|
||||
const tm = treemap()
|
||||
.tile(treemapSquarify.ratio(1))
|
||||
.size([scale, scale])
|
||||
.paddingInner(padding)
|
||||
.paddingOuter(padding);
|
||||
|
||||
tm(root);
|
||||
|
||||
const leaves = [];
|
||||
for (const leaf of root.leaves()) {
|
||||
if (!leaf.data.content) continue;
|
||||
leaves.push({
|
||||
path: leaf.data.path,
|
||||
name: leaf.data.name,
|
||||
x: leaf.x0,
|
||||
z: leaf.y0,
|
||||
w: leaf.x1 - leaf.x0,
|
||||
h: leaf.y1 - leaf.y0,
|
||||
lines: leaf.data.lines,
|
||||
maxLen: leaf.data.maxLen,
|
||||
content: leaf.data.content,
|
||||
});
|
||||
}
|
||||
|
||||
return { leaves, totalWidth: scale, totalHeight: scale };
|
||||
}
|
||||
373
web/src/renderer.js
Normal file
373
web/src/renderer.js
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Three.js renderer for the repo visualization.
|
||||
* - Files are flat planes on the XZ ground plane
|
||||
* - 3D OrbitControls camera
|
||||
* - Semantic zoom: color blocks → code texture based on camera distance
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
// Color palette by file extension
|
||||
const EXT_COLORS = {
|
||||
".js": 0xf7df1e,
|
||||
".jsx": 0x61dafb,
|
||||
".ts": 0x3178c6,
|
||||
".tsx": 0x61dafb,
|
||||
".py": 0x3572a5,
|
||||
".go": 0x00add8,
|
||||
".rs": 0xdea584,
|
||||
".c": 0x555555,
|
||||
".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;
|
||||
|
||||
function getColor(filename) {
|
||||
const dot = filename.lastIndexOf(".");
|
||||
if (dot === -1) return DEFAULT_COLOR;
|
||||
const ext = filename.substring(dot).toLowerCase();
|
||||
return EXT_COLORS[ext] ?? DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render code text onto a Canvas and return it as a texture.
|
||||
*/
|
||||
// Monospace font stack with CJK fallback
|
||||
const CODE_FONT = 'Terminus, "LXGW WenKai Mono", "Noto Sans Mono CJK SC", "Microsoft YaHei", monospace';
|
||||
|
||||
function createCodeTexture(content, lines, maxLen) {
|
||||
const fontSize = 28; // Higher base resolution for sharp zoom
|
||||
const charW = fontSize * 0.6;
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvasW = Math.ceil(maxLen * charW) + 20;
|
||||
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 {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.tiles = [];
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.mouse = new THREE.Vector2();
|
||||
this.hoveredTile = null;
|
||||
|
||||
this._initScene();
|
||||
this._initControls();
|
||||
this._initEvents();
|
||||
this._animate();
|
||||
}
|
||||
|
||||
_initScene() {
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x11111b);
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(
|
||||
60,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1,
|
||||
50000
|
||||
);
|
||||
this.camera.position.set(0, 200, 200);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
|
||||
this.renderer3d = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.renderer3d.setSize(window.innerWidth, window.innerHeight);
|
||||
this.renderer3d.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.container.appendChild(this.renderer3d.domElement);
|
||||
|
||||
// Ambient + directional light
|
||||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.7));
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.5);
|
||||
dirLight.position.set(100, 200, 100);
|
||||
this.scene.add(dirLight);
|
||||
}
|
||||
|
||||
_initControls() {
|
||||
this.controls = new OrbitControls(this.camera, this.renderer3d.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.1;
|
||||
this.controls.maxPolarAngle = Math.PI / 2.1; // Don't go below the ground
|
||||
this.controls.minDistance = 1;
|
||||
this.controls.maxDistance = 10000;
|
||||
this.controls.zoomSpeed = 1.5;
|
||||
this.controls.target.set(0, 0, 0);
|
||||
|
||||
// Google Maps-style touch: 1 finger pan, 2 finger pinch zoom + rotate
|
||||
this.controls.touches = {
|
||||
ONE: THREE.TOUCH.PAN,
|
||||
TWO: THREE.TOUCH.DOLLY_ROTATE,
|
||||
};
|
||||
}
|
||||
|
||||
_initEvents() {
|
||||
window.addEventListener("resize", () => {
|
||||
this.camera.aspect = window.innerWidth / window.innerHeight;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer3d.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
|
||||
// Hover for tooltip
|
||||
this.renderer3d.domElement.addEventListener("mousemove", (e) => {
|
||||
this.mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
this.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
});
|
||||
|
||||
// Double-click to zoom into a file
|
||||
this.renderer3d.domElement.addEventListener("dblclick", () => {
|
||||
if (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) {
|
||||
const startPos = this.camera.position.clone();
|
||||
const startTarget = this.controls.target.clone();
|
||||
const startTime = performance.now();
|
||||
|
||||
const step = () => {
|
||||
const elapsed = performance.now() - startTime;
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
// Smooth ease
|
||||
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);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load layout data and create meshes.
|
||||
*/
|
||||
load(leaves, totalWidth, totalHeight) {
|
||||
// Center the layout around origin
|
||||
const offsetX = -totalWidth / 2;
|
||||
const offsetZ = -totalHeight / 2;
|
||||
|
||||
for (const leaf of leaves) {
|
||||
const color = getColor(leaf.name);
|
||||
|
||||
// Block texture (far LOD)
|
||||
const blockTex = createBlockTexture(leaf.name, color);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
map: blockTex,
|
||||
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
|
||||
);
|
||||
|
||||
this.scene.add(mesh);
|
||||
|
||||
// Adjust leaf coords to world space for camera targeting
|
||||
leaf.x += offsetX;
|
||||
leaf.z += offsetZ;
|
||||
|
||||
this.tiles.push({
|
||||
mesh,
|
||||
data: leaf,
|
||||
codeTexture: null,
|
||||
blockTexture: blockTex,
|
||||
showingCode: false,
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
// Set initial camera to see the whole scene
|
||||
const maxDim = Math.max(totalWidth, totalHeight);
|
||||
this.camera.position.set(0, maxDim * 0.7, maxDim * 0.5);
|
||||
this.controls.target.set(0, 0, 0);
|
||||
|
||||
// Tooltip element
|
||||
this.tooltip = document.getElementById("tooltip");
|
||||
}
|
||||
|
||||
_updateLOD() {
|
||||
const camPos = this.camera.position;
|
||||
|
||||
for (const tile of this.tiles) {
|
||||
const cx = tile.data.x + tile.data.w / 2;
|
||||
const cz = tile.data.z + tile.data.h / 2;
|
||||
const dist = Math.sqrt(
|
||||
(camPos.x - cx) ** 2 + camPos.y ** 2 + (camPos.z - cz) ** 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;
|
||||
tile.mesh.material.needsUpdate = true;
|
||||
tile.showingCode = true;
|
||||
} else if (dist >= threshold && tile.showingCode) {
|
||||
tile.mesh.material.map = tile.blockTexture;
|
||||
tile.mesh.material.needsUpdate = true;
|
||||
tile.showingCode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_updateHover() {
|
||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||
const meshes = this.tiles.map((t) => t.mesh);
|
||||
const intersects = this.raycaster.intersectObjects(meshes);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const mesh = intersects[0].object;
|
||||
const tile = this.tiles.find((t) => t.mesh === mesh);
|
||||
this.hoveredTile = tile;
|
||||
|
||||
if (this.tooltip && tile) {
|
||||
this.tooltip.style.display = "block";
|
||||
this.tooltip.textContent = `${tile.data.path} (${tile.data.lines} lines)`;
|
||||
}
|
||||
} else {
|
||||
this.hoveredTile = null;
|
||||
if (this.tooltip) this.tooltip.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
_animate() {
|
||||
requestAnimationFrame(() => this._animate());
|
||||
this.controls.update();
|
||||
this._updateLOD();
|
||||
this._updateHover();
|
||||
this.renderer3d.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user