feat: add 3MF file upload and slicing support#22
Conversation
Users can now upload 3MF files in addition to STL files. The slicing pipeline handles both formats: 3MF files are passed directly to the OrcaSlicer API for estimation, while the multi-part bundler extracts meshes from 3MF files to support mixed STL+3MF print jobs. - Extend form validation to accept .3mf extension - Add Part.is_3mf property for format detection - Add extract_meshes_from_3mf() to parse 3MF mesh data - Update create_3mf_bundle() to handle both STL and 3MF inputs - Bypass re-bundling for single-part 3MF estimation - Update user-facing labels from "STL" to "Model File" - Show download link instead of 3D viewer for 3MF files - Add 4 new tests (458 total, all passing) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds first-class 3MF support alongside STL across part upload, estimation, job slicing/bundling, and UI presentation so users can work with either “model file” type.
Changes:
- Allow
.3mfuploads inPartFormand addPart.is_3mfto branch behavior. - Update slicing/estimation pipeline to accept 3MF directly for estimation and to extract meshes for mixed STL+3MF job bundles.
- Update templates and user-facing messages from “STL” to “Model File” and disable the 3D viewer for 3MF.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| core/views/print_jobs.py | Updates user-facing messages (and related slicing validation wording) to “model file”. |
| core/views/parts.py | Updates estimation warning message to “model file”. |
| core/tests/test_forms.py | Adds tests for accepting .3mf uploads and name derivation from 3MF filenames. |
| core/tests/test_estimation.py | Adds a 3MF-related test (currently only exercises is_3mf). |
| core/templates/core/project_detail.html | Updates tooltip text from “STL” to “Model file”. |
| core/templates/core/part_detail.html | Disables STL viewer for 3MF and shows a download link; updates labels to “Model File”. |
| core/services/threemf.py | Adds 3MF mesh extraction and extends bundle creation to accept STL and 3MF inputs. |
| core/services/slicing_worker.py | Uses uploaded 3MF bytes directly for estimation; otherwise bundles via create_3mf_bundle. |
| core/models/parts.py | Adds is_3mf property to detect 3MF uploads stored in stl_file. |
| core/forms/parts.py | Extends upload validation to allow .stl and .3mf and updates related error text. |
| from core.models import Part | ||
|
|
There was a problem hiding this comment.
test_3mf_estimation_bypasses_bundle_creation imports Part but never uses it. With Ruff/pyflakes (F401) enabled in this repo, this unused import will fail lint/CI; remove the import or use it meaningfully in the test.
| from core.models import Part |
| def test_3mf_estimation_bypasses_bundle_creation(self): | ||
| """3MF files should be passed directly to the slicer, not re-bundled.""" | ||
| from core.models import Part | ||
|
|
||
| self.part.stl_file = SimpleUploadedFile("model.3mf", b"PK\x03\x04fake") | ||
| self.part.print_preset = self.preset | ||
| self.part.save() | ||
|
|
||
| # Verify the is_3mf property works | ||
| self.part.refresh_from_db() | ||
| self.assertTrue(self.part.is_3mf) | ||
|
|
||
| # Verify STL files are not detected as 3MF | ||
| self.part.stl_file = SimpleUploadedFile("model.stl", b"solid test") | ||
| self.part.save() | ||
| self.part.refresh_from_db() | ||
| self.assertFalse(self.part.is_3mf) |
There was a problem hiding this comment.
This test is named/described as verifying that 3MF estimation bypasses bundle creation, but it only asserts the Part.is_3mf property. To actually cover the new behavior, assert that _estimate_part_in_background (or the slicer call path) uses the uploaded 3MF bytes and does not call create_3mf_bundle when part.is_3mf is true.
| self.assertTrue(form.is_valid()) | ||
|
|
||
| def test_3mf_file_valid(self): | ||
| threemf = SimpleUploadedFile("model.3mf", b"PK\x03\x04", content_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml") |
There was a problem hiding this comment.
The SimpleUploadedFile(...) line with the long 3MF MIME type is likely to exceed the repo’s 120-char Ruff limit (E501), which will fail lint/CI. Please wrap this call across multiple lines (or assign the MIME type string to a constant) to keep lines <= 120.
| threemf = SimpleUploadedFile("model.3mf", b"PK\x03\x04", content_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml") | |
| threemf = SimpleUploadedFile( | |
| "model.3mf", | |
| b"PK\x03\x04", | |
| content_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml", | |
| ) |
| try: | ||
| with zipfile.ZipFile(io.BytesIO(data), "r") as zf: | ||
| # Find the 3D model file | ||
| model_path = None | ||
| for name in zf.namelist(): | ||
| if name.lower().endswith(".model") and "3d/" in name.lower(): | ||
| model_path = name | ||
| break | ||
| if model_path is None: | ||
| raise ThreeMFError("No 3D model file found in 3MF archive") | ||
|
|
||
| model_xml = zf.read(model_path) | ||
| except zipfile.BadZipFile as exc: | ||
| raise ThreeMFError(f"Invalid 3MF file: {exc}") from exc |
There was a problem hiding this comment.
extract_meshes_from_3mf reads an entry out of the ZIP with zf.read(model_path) without any guard on decompressed size. Since uploads are user-controlled, a crafted 3MF can be a ZIP bomb and cause excessive memory/CPU usage in the worker. Consider checking ZipInfo.file_size (and total uncompressed size) against a reasonable cap before reading/parsing, and reject archives with suspicious compression ratios.
| # Find the 3D model file | ||
| model_path = None | ||
| for name in zf.namelist(): | ||
| if name.lower().endswith(".model") and "3d/" in name.lower(): | ||
| model_path = name | ||
| break | ||
| if model_path is None: |
There was a problem hiding this comment.
The docstring says this reads 3D/3dmodel.model, but the implementation selects the first *.model under any 3d/ path. That can pick the wrong model if the archive contains multiple .model files. Prefer looking specifically for 3D/3dmodel.model (case-insensitive), and only fall back to a broader search if that exact path is missing.
| # Find the 3D model file | |
| model_path = None | |
| for name in zf.namelist(): | |
| if name.lower().endswith(".model") and "3d/" in name.lower(): | |
| model_path = name | |
| break | |
| if model_path is None: | |
| # Prefer the standard 3MF model path first, case-insensitively. | |
| names = zf.namelist() | |
| names_by_lower = {name.lower(): name for name in names} | |
| model_path = names_by_lower.get("3d/3dmodel.model") | |
| # Fall back to the first .model file under a 3d/ path for | |
| # non-standard archives that do not contain the standard path. | |
| if model_path is None: | |
| for name in names: | |
| lower_name = name.lower() | |
| if lower_name.endswith(".model") and "3d/" in lower_name: | |
| model_path = name | |
| break | |
| if model_path is None: |
| meshes = [] | ||
| for obj in root.iter(f"{ns}object"): | ||
| mesh = obj.find(f"{ns}mesh") | ||
| if mesh is None: | ||
| continue | ||
|
|
||
| vertices_elem = mesh.find(f"{ns}vertices") | ||
| triangles_elem = mesh.find(f"{ns}triangles") | ||
| if vertices_elem is None or triangles_elem is None: | ||
| continue |
There was a problem hiding this comment.
This extraction logic only reads <object><mesh>…</mesh></object> and ignores common 3MF constructs like <components> and <build><item transform=...>. For many real 3MFs, that will drop geometry and/or lose transforms, producing incorrect bundled output. Consider either implementing components/build handling (including transforms) or explicitly rejecting 3MFs that use these constructs with a clear ThreeMFError.
| def create_3mf_bundle(parts: list[tuple[Path, int]]) -> bytes: | ||
| """Create a 3MF package containing multiple STL models. | ||
| """Create a 3MF package containing multiple model files. | ||
|
|
||
| Each STL file is parsed, deduplicated, and added as a separate | ||
| ``<object>`` in the 3MF model. The ``quantity`` determines how | ||
| many ``<item>`` references are created in the ``<build>`` section. | ||
| Accepts both STL and 3MF files. Each file is parsed into mesh | ||
| data and added as ``<object>`` elements in the output 3MF model. | ||
| The ``quantity`` determines how many ``<item>`` references are | ||
| created in the ``<build>`` section. | ||
|
|
||
| Args: | ||
| parts: List of (stl_path, quantity) tuples. | ||
| parts: List of (model_path, quantity) tuples. | ||
|
|
There was a problem hiding this comment.
create_3mf_bundle now accepts both STL and 3MF (and callers pass str paths), but the type hint is still list[tuple[Path, int]]. Updating this to Path | str (and reflecting it in the docstring) will make the API accurate and prevent type-checking drift as this functionality grows.
|
|
||
| cleaned_data["name"] = PurePosixPath(self.instance.stl_file.name).stem | ||
| else: | ||
| self.add_error("name", "Name is required when no STL file is uploaded.") | ||
| self.add_error("name", "Name is required when no model file is uploaded.") |
There was a problem hiding this comment.
Although the form now accepts STL or 3MF, clean()’s docstring still says it auto-fills the name from the uploaded “STL” filename. Since this change touches the same validation flow, please update that docstring to say “model file” for accuracy.
| if not eligible: | ||
| messages.warning(request, "No eligible parts found (all printed or missing STL).") | ||
| messages.warning(request, "No eligible parts found (all printed or missing model file).") | ||
| return redirect("core:project_detail", pk=project.pk) |
There was a problem hiding this comment.
The docstrings/comments in this module still describe skipping/collecting “STL” files (e.g., CreateJobsFromProjectView and PrintJobSliceView), but the logic now treats Part.stl_file as a generic model file (STL or 3MF). Please update those docstrings/comments to avoid misleading future maintenance and debugging.
| # Build 3MF with single copy (or use uploaded 3MF directly) | ||
| if part.is_3mf: | ||
| threemf_content = FSPath(part.stl_file.path).read_bytes() | ||
| else: | ||
| stl_path = FSPath(part.stl_file.path) | ||
| threemf_content = create_3mf_bundle([(stl_path, 1)]) |
There was a problem hiding this comment.
_estimate_part_in_background still logs “no STL file” when part.stl_file is missing, but stl_file now represents a generic model file (STL or 3MF). Please update the log message (and nearby comments) to say “model file” to keep logs accurate for troubleshooting 3MF-related issues.
Extend the Three.js viewer to load 3MF files using ThreeMFLoader. The viewer auto-detects the format from the URL and uses the appropriate loader. Applies default material to 3MF meshes that don't have colors defined. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use defusedxml.ElementTree.fromstring instead of stdlib xml (S314) - Add defusedxml to requirements.txt - Remove unused Part import in test (F401) - Break long line in test_forms.py (E501) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Preview image readyBy tag: By digest: Commit: |
Summary
create_3mf_bundle()extracts meshes from 3MF files and bundles them alongside STL partsTest plan
.3mffile as a Part — should succeed.stlfile — should still work as before🤖 Generated with Claude Code