// 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."); } })();