From 5dd5933451561aa02ca4015f789fc91f062d56fe Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 15 Apr 2026 13:37:05 -0600 Subject: [PATCH] Adding 3mf file viewer to FilePreview. --- app/src/components/FilePreview.js | 181 ++++++++++++++++++++++++++++++ wrolpi/files/lib.py | 6 + wrolpi/files/test/test_lib.py | 38 +++++++ 3 files changed, 225 insertions(+) diff --git a/app/src/components/FilePreview.js b/app/src/components/FilePreview.js index a73ecd2b..90142921 100644 --- a/app/src/components/FilePreview.js +++ b/app/src/components/FilePreview.js @@ -127,6 +127,182 @@ function getAudioPreviewModal(previewFile) { } +function ThreeMFPreviewModal({previewFile}) { + const containerRef = React.useRef(null); + const [dimensions, setDimensions] = React.useState(null); + const [error, setError] = React.useState(null); + const url = getMediaPathURL(previewFile); + const path = previewFile.primary_path ?? previewFile.path; + const name = path.replace(/^.*[\\\/]/, ''); + + React.useEffect(() => { + // Imperative three.js viewer — mirrors react-stl-viewer behavior for .3mf files. + let cancelled = false; + const container = containerRef.current; + if (!container) return; + + let renderer, scene, camera, controls, animationId, resizeObserver, loadedGroup; + let THREE; + + (async () => { + try { + THREE = await import('three'); + const {OrbitControls} = await import('three/examples/jsm/controls/OrbitControls.js'); + const {ThreeMFLoader} = await import('three/examples/jsm/loaders/3MFLoader.js'); + if (cancelled) return; + + scene = new THREE.Scene(); + // Transparent background so the modal/theme color shows through, + // matching react-stl-viewer's behavior. + + const width = container.clientWidth || 1; + const height = container.clientHeight || 1; + + camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 10000); + camera.position.set(0, 0, 100); + + renderer = new THREE.WebGLRenderer({antialias: true, alpha: true}); + renderer.setClearColor(0x000000, 0); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(width, height); + container.appendChild(renderer.domElement); + + scene.add(new THREE.AmbientLight(0xffffff, 0.6)); + const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); + dirLight.position.set(1, 1, 1); + scene.add(dirLight); + const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4); + dirLight2.position.set(-1, -1, -1); + scene.add(dirLight2); + + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + + const loader = new ThreeMFLoader(); + loader.load( + url, + (group) => { + if (cancelled) return; + loadedGroup = group; + + // Match react-stl-viewer's violet so 3MFs without authored + // materials are visible against either theme's background. + // 3MF geometry often lacks vertex normals — compute them so + // MeshPhongMaterial can shade faces instead of rendering flat black. + group.traverse((obj) => { + if (obj.isMesh) { + if (obj.geometry && !obj.geometry.attributes.normal) { + obj.geometry.computeVertexNormals(); + } + obj.material = new THREE.MeshPhongMaterial({ + color: 0x7353a8, + flatShading: false, + }); + } + }); + + scene.add(group); + + // Frame the model: center it, then pull camera back along Z by bounding radius. + const box = new THREE.Box3().setFromObject(group); + const size = new THREE.Vector3(); + const center = new THREE.Vector3(); + box.getSize(size); + box.getCenter(center); + group.position.sub(center); + + const boundingRadius = Math.max(size.x, size.y, size.z) / 2 || 1; + const fitDistance = boundingRadius / Math.tan((Math.PI * camera.fov) / 360); + camera.position.set(0, 0, fitDistance * 2.2); + camera.near = Math.max(0.1, fitDistance / 1000); + camera.far = fitDistance * 100; + camera.updateProjectionMatrix(); + controls.target.set(0, 0, 0); + controls.update(); + + setDimensions({ + width: size.x, + height: size.y, + length: size.z, + boundingRadius: size.length() / 2, + }); + }, + undefined, + (err) => { + console.error('Failed to load 3MF', err); + if (!cancelled) setError('Failed to load 3MF file.'); + }, + ); + + const animate = () => { + animationId = requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + }; + animate(); + + resizeObserver = new ResizeObserver(() => { + const w = container.clientWidth || 1; + const h = container.clientHeight || 1; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); + }); + resizeObserver.observe(container); + } catch (err) { + console.error('3MF viewer init failed', err); + if (!cancelled) setError('Failed to initialize 3MF viewer.'); + } + })(); + + return () => { + cancelled = true; + if (animationId) cancelAnimationFrame(animationId); + if (resizeObserver) resizeObserver.disconnect(); + if (controls) controls.dispose(); + if (loadedGroup && THREE) { + loadedGroup.traverse((obj) => { + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + const materials = Array.isArray(obj.material) ? obj.material : [obj.material]; + materials.forEach((m) => { + if (m.map) m.map.dispose(); + m.dispose(); + }); + } + }); + } + if (renderer) { + renderer.dispose(); + if (renderer.domElement && renderer.domElement.parentNode) { + renderer.domElement.parentNode.removeChild(renderer.domElement); + } + } + }; + }, [url]); + + return + {name} + + {dimensions &&
+ Width: {dimensions.width.toFixed(2)} + {' | '} + Height: {dimensions.height.toFixed(2)} + {' | '} + Length: {dimensions.length.toFixed(2)} + {' | '} + Bounding Radius: {dimensions.boundingRadius.toFixed(2)} +
} + {error &&
{error}
} + +
+
+
+ + + +} + function STLPreviewModal({previewFile}) { const [dimensions, setDimensions] = React.useState(null); const url = getMediaPathURL(previewFile); @@ -369,10 +545,15 @@ export function FilePreviewProvider({children}) { setModalContent(getImagePreviewModal(previewFile), url, null, path, taggable); } else if (mimetype.startsWith('model/stl')) { setModalContent(, null, downloadURL, path, taggable); + } else if (mimetype.startsWith('model/3mf')) { + setModalContent(, null, downloadURL, path, taggable); } else if (mimetype.startsWith('application/octet-stream') && lowerPath.endsWith('.mp3')) { setModalContent(getAudioPreviewModal(previewFile), url, downloadURL, path, taggable); } else if (mimetype.startsWith('application/octet-stream') && lowerPath.endsWith('.stl')) { setModalContent(, null, downloadURL, path, taggable); + } else if ((mimetype.startsWith('application/octet-stream') || mimetype === 'application/zip') + && lowerPath.endsWith('.3mf')) { + setModalContent(, null, downloadURL, path, taggable); } else if (mimetype.includes('cbz') || mimetype.includes('cbr') || mimetype.includes('comicbook+zip') || mimetype.includes('comicbook-rar') || lowerPath.endsWith('.cbz') || lowerPath.endsWith('.cbr') || lowerPath.endsWith('.cbt') || lowerPath.endsWith('.cb7')) { setModalContent(, null, downloadURL, path, taggable); diff --git a/wrolpi/files/lib.py b/wrolpi/files/lib.py index b2583443..c4620388 100644 --- a/wrolpi/files/lib.py +++ b/wrolpi/files/lib.py @@ -289,6 +289,8 @@ def _mimetype_suffix_map(path: Path, mimetype: str): return MOBI_MIMETYPE if suffix.endswith('.stl'): return 'model/stl' + if suffix.endswith('.3mf'): + return 'model/3mf' if suffix.endswith('.mp4'): return 'video/mp4' if suffix.endswith('.hgt'): @@ -304,6 +306,8 @@ def _mimetype_suffix_map(path: Path, mimetype: str): return 'text/html' if suffix.endswith('.stl'): return 'model/stl' + if suffix.endswith('.3mf'): + return 'model/3mf' if suffix.endswith('.scad'): return 'application/x-openscad' if suffix.endswith('.js'): @@ -325,6 +329,8 @@ def _mimetype_suffix_map(path: Path, mimetype: str): return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' if mimetype == 'application/zip' and suffix == '.cbz': return 'application/x-cbz' + if mimetype == 'application/zip' and suffix == '.3mf': + return 'model/3mf' if mimetype == 'application/zip' and suffix == '.odt': return 'application/vnd.oasis.opendocument.text' if mimetype in ('application/x-rar', 'application/x-rar-compressed') and suffix == '.cbr': diff --git a/wrolpi/files/test/test_lib.py b/wrolpi/files/test/test_lib.py index cfa92caf..e874842a 100644 --- a/wrolpi/files/test/test_lib.py +++ b/wrolpi/files/test/test_lib.py @@ -642,6 +642,44 @@ def test_get_mimetype(example_epub, example_mobi, example_pdf, image_file, video assert lib.get_mimetype(video_file) == 'video/mp4' +def test_get_mimetype_stl(test_directory): + """.stl files should resolve to model/stl regardless of magic's initial guess.""" + stl_path = test_directory / 'example.stl' + # Minimal ASCII STL body; python-magic reports this as text/plain. + stl_path.write_text( + 'solid cube\n' + 'facet normal 0 0 1\n' + ' outer loop\n' + ' vertex 0 0 0\n' + ' vertex 1 0 0\n' + ' vertex 0 1 0\n' + ' endloop\n' + 'endfacet\n' + 'endsolid cube\n' + ) + lib.get_mimetype.cache_clear() + assert lib.get_mimetype(stl_path) == 'model/stl' + + +def test_get_mimetype_3mf(test_directory): + """.3mf files are zipped XML; ensure they resolve to model/3mf.""" + import zipfile + threemf_path = test_directory / 'example.3mf' + with zipfile.ZipFile(threemf_path, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.writestr('[Content_Types].xml', + '' + '' + '' + '') + zf.writestr('3D/3dmodel.model', + '' + '' + '') + lib.get_mimetype.cache_clear() + assert lib.get_mimetype(threemf_path) == 'model/3mf' + + def test_group_files_by_stem(make_files_structure, test_directory): make_files_structure([ 'foo.mp4',