// STL 3D Preview for Forgejo
// Based on Codeberg's stlview.js implementation
// Uses Three.js r160 ESM modules
console.log("[stlview] Script loaded");
function updateLoading(text) {
var el = document.getElementById('stl-loading');
if (el) el.textContent = text;
}
(async function () {
// Check if current page is an STL file view
var path = window.location.pathname;
if (!path.toLowerCase().endsWith(".stl")) {
console.log("[stlview] Not an STL page, skipping");
return;
}
console.log("[stlview] STL page detected:", path);
// Build raw URL by replacing /src/ with /raw/
var raw_url = path.replace(/\/src\//, '/raw/');
console.log("[stlview] Raw URL:", raw_url);
// Find the file content container to replace
var container = document.querySelector('.file-view');
if (!container) {
console.log("[stlview] No .file-view container found, trying alternatives...");
container = document.querySelector('.non-diff-file-content .ui.bottom.attached.segment');
}
if (!container) {
console.error("[stlview] No suitable container found to inject viewer");
return;
}
console.log("[stlview] Found container:", container.className);
// Remove stl-page CSS class (hides raw content / shows ::before loading message)
document.documentElement.classList.remove('stl-page');
container.innerHTML = '
Loading 3D view\u2026
';
try {
console.log("[stlview] Loading Three.js modules...");
var THREE = await import("./three.js/build/three.module.min.js");
var STL = await import("./three.js/examples/jsm/loaders/STLLoader.js");
var TBC = await import("./three.js/examples/jsm/controls/TrackballControls.js");
console.log("[stlview] Three.js modules loaded successfully");
updateLoading("Loading 3D model\u2026");
var GL = new THREE.WebGLRenderer({ antialias: true, alpha: true });
if (!GL) {
console.error("[stlview] Failed initializing WebGL");
return;
}
console.log("[stlview] WebGL renderer created");
console.log("[stlview] Loading STL model from:", raw_url);
(new STL.STLLoader()).load(raw_url, function (geometry) {
console.log("[stlview] STL model loaded, vertices:", geometry.attributes.position.count);
var center = new THREE.Vector3();
var scene = new THREE.Scene();
scene.background = new THREE.Color(0x72645b);
var material = undefined;
if (geometry.hasColors) {
material = new THREE.MeshPhongMaterial({ opacity: geometry.alpha, vertexColors: true });
} else {
material = new THREE.MeshPhongMaterial({ color: 0xf0cc33, specular: 200, shininess: 50 });
}
geometry.computeBoundingBox();
geometry.boundingBox.getCenter(center);
var largestDimension = Math.max(
geometry.boundingBox.max.x - geometry.boundingBox.min.x,
geometry.boundingBox.max.y - geometry.boundingBox.min.y,
geometry.boundingBox.max.z - geometry.boundingBox.min.z
);
console.log("[stlview] Bounding box largest dimension:", largestDimension);
var mesh = new THREE.Mesh(geometry, material);
mesh.position.x = -center.x;
mesh.position.y = -center.y;
mesh.position.z = -center.z;
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
scene.add(new THREE.HemisphereLight(0x404040, 0x111122));
addShadowedLight(1000, 1000, 1000, 0xffffcc, 0.6);
addShadowedLight(-1000, 1000, -1000, 0xffffcc, 0.8);
addShadowedLight(-1000, -1000, 1000, 0xffffcc, 0.6);
GL.shadowMap.enabled = true;
var width = container.clientWidth;
var height = window.innerHeight;
console.log("[stlview] Renderer size:", width, "x", height);
GL.setSize(width, height);
GL.setPixelRatio(window.devicePixelRatio);
var camera = new THREE.PerspectiveCamera(45, width / height, 0.01 * largestDimension, 100 * largestDimension);
var controls = new TBC.TrackballControls(camera, container);
controls.dynamicDampingFactor = 0.1;
controls.target.set(0, 0, 0);
window.addEventListener('resize', function () {
GL.setSize(container.clientWidth, container.clientHeight);
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
controls.handleResize();
}, false);
camera.position.z = largestDimension * 1.5;
controls.update();
function reset() {
controls.reset();
camera.position.z = largestDimension * 1.5;
animate();
}
container.addEventListener("dblclick", function (e) {
reset();
});
var timeout = null;
container.addEventListener('touchend', function (e) {
if (e.touches.length > 0)
return;
if (timeout) {
clearTimeout(timeout);
timeout = null;
reset();
e.preventDefault();
} else {
timeout = setTimeout(function () { clearTimeout(timeout); timeout = null; }, 500);
}
});
function animate() {
requestAnimationFrame(animate);
controls.update();
GL.render(scene, camera);
}
// Replace loading indicator with the canvas
container.innerHTML = '';
container.appendChild(GL.domElement);
GL.domElement.style.width = '100%';
GL.domElement.style.height = '100%';
animate();
console.log("[stlview] Viewer ready");
function addShadowedLight(x, y, z, color, intensity) {
var directionalLight = new THREE.DirectionalLight(color, intensity);
var d = 1;
directionalLight.position.set(x, y, z);
directionalLight.castShadow = true;
directionalLight.shadow.camera.left = -d;
directionalLight.shadow.camera.right = d;
directionalLight.shadow.camera.top = d;
directionalLight.shadow.camera.bottom = -d;
directionalLight.shadow.camera.near = 1;
directionalLight.shadow.camera.far = 4;
directionalLight.shadow.bias = -0.002;
scene.add(directionalLight);
}
}, function (progress) {
if (progress.total) {
var pct = Math.round(progress.loaded / progress.total * 100);
console.log("[stlview] Loading progress:", pct + "%");
updateLoading("Loading 3D model\u2026 " + pct + "%");
}
}, function (error) {
console.error("[stlview] Failed to load STL model:", error);
updateLoading("Failed to load 3D model.");
});
} catch (error) {
console.error("[stlview] Error creating viewer:", error);
updateLoading("Error loading 3D viewer.");
}
})();