Files are copied verbatim from the running deployment at git.nakama.town,
except footer.tmpl, which uses {{AppSubUrl}} rather than a hardcoded /assets
path (identical output when Forgejo is served at the root).
The two three.js example modules are patched to import the build by relative
path instead of the bare 'three' specifier, which browsers cannot resolve
without an import map.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
6.6 KiB
JavaScript
182 lines
6.6 KiB
JavaScript
// 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 = '<div id="stl-loading" style="padding:3em;text-align:center;color:#888;font-size:1.2em">Loading 3D view\u2026</div>';
|
|
|
|
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.");
|
|
}
|
|
})();
|