Skip to content

feat: add 3MF file upload and slicing support#22

Open
peterus wants to merge 6 commits into
mainfrom
feat/3mf-upload-support
Open

feat: add 3MF file upload and slicing support#22
peterus wants to merge 6 commits into
mainfrom
feat/3mf-upload-support

Conversation

@peterus
Copy link
Copy Markdown
Owner

@peterus peterus commented Apr 3, 2026

Summary

  • Users can now upload 3MF files in addition to STL files for parts
  • 3MF files are passed directly to OrcaSlicer API for estimation (no re-bundling)
  • Mixed STL + 3MF print jobs are supported: create_3mf_bundle() extracts meshes from 3MF files and bundles them alongside STL parts
  • User-facing labels updated from "STL" to "Model File"
  • 3D preview gracefully degrades for 3MF (shows download link instead of viewer)

Test plan

  • All 458 tests pass (4 new tests added)
  • Upload a .3mf file as a Part — should succeed
  • Upload a .stl file — should still work as before
  • Create a Part with 3MF, verify estimation runs and completes
  • Create a PrintJob with mixed STL + 3MF parts, slice it
  • Part detail page: STL files show 3D viewer, 3MF files show download link

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings April 3, 2026 20:20
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 .3mf uploads in PartForm and add Part.is_3mf to 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.

Comment thread core/tests/test_estimation.py Outdated
Comment on lines +182 to +183
from core.models import Part

Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
from core.models import Part

Copilot uses AI. Check for mistakes.
Comment on lines +180 to +196
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)
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Comment thread core/tests/test_forms.py Outdated
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")
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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",
)

Copilot uses AI. Check for mistakes.
Comment thread core/services/threemf.py
Comment on lines +182 to +195
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
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread core/services/threemf.py
Comment on lines +184 to +190
# 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:
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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:

Copilot uses AI. Check for mistakes.
Comment thread core/services/threemf.py
Comment on lines +204 to +213
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
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread core/services/threemf.py
Comment on lines 241 to 251
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.

Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread core/forms/parts.py
Comment on lines 63 to +66

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.")
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread core/views/print_jobs.py
Comment on lines 297 to 299
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)
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +260 to +265
# 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)])
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
peterus and others added 5 commits April 4, 2026 06:51
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>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 4, 2026

Preview image ready

By tag:

docker pull ghcr.io/peterus/layernexus:pr-22

By digest:

docker pull ghcr.io/peterus/layernexus@sha256:70b379e5382a4dedb7754659812b1a688c00c9c14263bb315a9e6faf7a9020c8

Commit: 05c3bd0c6f3c542be0ce97bc1c36696e895b08d3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants