mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Adding 3mf file viewer to FilePreview.
This commit is contained in:
parent
575a1b2a80
commit
5dd5933451
3 changed files with 225 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue