Adding 3mf file viewer to FilePreview.

This commit is contained in:
Roland 2026-04-15 13:37:05 -06:00 • committed by lrnselfreliance
parent 575a1b2a80
commit 5dd5933451
3 changed files with 225 additions and 0 deletions

View file

@ -127,6 +127,182 @@ function getAudioPreviewModal(previewFile) {
</React.Fragment>
}
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 <React.Fragment>
<Modal.Header>{name}</Modal.Header>
<Modal.Content>
{dimensions && <div style={{marginBottom: '1em', textAlign: 'center'}}>
<b>Width:</b> {dimensions.width.toFixed(2)}
{' | '}
<b>Height:</b> {dimensions.height.toFixed(2)}
{' | '}
<b>Length:</b> {dimensions.length.toFixed(2)}
{' | '}
<b>Bounding Radius:</b> {dimensions.boundingRadius.toFixed(2)}
</div>}
{error && <div style={{color: 'red', textAlign: 'center'}}>{error}</div>}
<InlineErrorBoundary>
<div className='preview-fit'>
<div ref={containerRef} style={{height: '100%', width: '100%'}}/>
</div>
</InlineErrorBoundary>
</Modal.Content>
</React.Fragment>
}
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(<STLPreviewModal previewFile={previewFile}/>, null, downloadURL, path, taggable);
} else if (mimetype.startsWith('model/3mf')) {
setModalContent(<ThreeMFPreviewModal previewFile={previewFile}/>, 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(<STLPreviewModal previewFile={previewFile}/>, null, downloadURL, path, taggable);
} else if ((mimetype.startsWith('application/octet-stream') || mimetype === 'application/zip')
&& lowerPath.endsWith('.3mf')) {
setModalContent(<ThreeMFPreviewModal previewFile={previewFile}/>, 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(<Modal.Content><CbzViewer path={path}/></Modal.Content>, null, downloadURL, path, taggable);

View file

@ -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':

View file

@ -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',
'<?xml version="1.0" encoding="UTF-8"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>'
'</Types>')
zf.writestr('3D/3dmodel.model',
'<?xml version="1.0" encoding="UTF-8"?>'
'<model unit="millimeter" xml:lang="en-US"'
' xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">'
'<resources/><build/></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',