Forráskód Böngészése

Add "Combine to 3MF" for slicing several STLs on one plate (#3162)

adman234 14 órája
szülő
commit
b67c9b572c
100 módosított fájl, 1721 hozzáadás és 102 törlés
  1. 1 0
      CHANGELOG.md
  2. 88 1
      backend/app/api/routes/library.py
  3. 20 0
      backend/app/schemas/library.py
  4. 50 2
      backend/app/services/archive.py
  5. 361 0
      backend/app/services/mesh_combine.py
  6. 117 88
      backend/app/services/stl_thumbnail.py
  7. 171 0
      backend/tests/integration/test_library_combine.py
  8. 28 0
      backend/tests/integration/test_ownership_permissions.py
  9. 74 0
      backend/tests/unit/services/test_archive_service.py
  10. 205 0
      backend/tests/unit/test_mesh_combine.py
  11. 97 0
      frontend/src/__tests__/components/CombineFilesModal.test.tsx
  12. 24 0
      frontend/src/__tests__/components/SliceModal.test.tsx
  13. 5 0
      frontend/src/api/client.ts
  14. 186 0
      frontend/src/components/CombineFilesModal.tsx
  15. 5 2
      frontend/src/components/SliceModal.tsx
  16. 16 0
      frontend/src/i18n/locales/de.ts
  17. 16 0
      frontend/src/i18n/locales/en.ts
  18. 16 0
      frontend/src/i18n/locales/es.ts
  19. 16 0
      frontend/src/i18n/locales/fr.ts
  20. 16 0
      frontend/src/i18n/locales/it.ts
  21. 16 0
      frontend/src/i18n/locales/ja.ts
  22. 16 0
      frontend/src/i18n/locales/ko.ts
  23. 16 0
      frontend/src/i18n/locales/nl.ts
  24. 16 0
      frontend/src/i18n/locales/pt-BR.ts
  25. 16 0
      frontend/src/i18n/locales/ru.ts
  26. 16 0
      frontend/src/i18n/locales/sv.ts
  27. 16 0
      frontend/src/i18n/locales/tr.ts
  28. 16 0
      frontend/src/i18n/locales/uk.ts
  29. 16 0
      frontend/src/i18n/locales/zh-CN.ts
  30. 16 0
      frontend/src/i18n/locales/zh-TW.ts
  31. 49 0
      frontend/src/pages/FileManagerPage.tsx
  32. 0 0
      static/assets/ImagePreviewModal-yOK-BUmg.js
  33. 0 1
      static/assets/PdfPreviewModal-B_LYIkkC.js
  34. 0 1
      static/assets/SpreadsheetPreviewModal-BEobv_FV.js
  35. 0 1
      static/assets/chunk-aKtaBQYM.js
  36. 0 1
      static/assets/index-Bh3F0vXZ.js
  37. 0 1
      static/assets/index-CT9srI0L.css
  38. BIN
      static/assets/occt-import-js-BhHfLpto.wasm
  39. 0 0
      static/assets/papaparse.min-CR43Mv99.js
  40. 0 0
      static/assets/pdf-Dh_3UNyL.js
  41. 0 0
      static/assets/pdf.worker.min-0idpi3xP.js
  42. 0 1
      static/assets/pdf.worker.min-DpOxPwGP.js
  43. BIN
      static/assets/pdfjs/cmaps/78-EUC-H.bcmap
  44. BIN
      static/assets/pdfjs/cmaps/78-EUC-V.bcmap
  45. BIN
      static/assets/pdfjs/cmaps/78-H.bcmap
  46. BIN
      static/assets/pdfjs/cmaps/78-RKSJ-H.bcmap
  47. BIN
      static/assets/pdfjs/cmaps/78-RKSJ-V.bcmap
  48. BIN
      static/assets/pdfjs/cmaps/78-V.bcmap
  49. BIN
      static/assets/pdfjs/cmaps/78ms-RKSJ-H.bcmap
  50. BIN
      static/assets/pdfjs/cmaps/78ms-RKSJ-V.bcmap
  51. BIN
      static/assets/pdfjs/cmaps/83pv-RKSJ-H.bcmap
  52. BIN
      static/assets/pdfjs/cmaps/90ms-RKSJ-H.bcmap
  53. BIN
      static/assets/pdfjs/cmaps/90ms-RKSJ-V.bcmap
  54. BIN
      static/assets/pdfjs/cmaps/90msp-RKSJ-H.bcmap
  55. BIN
      static/assets/pdfjs/cmaps/90msp-RKSJ-V.bcmap
  56. BIN
      static/assets/pdfjs/cmaps/90pv-RKSJ-H.bcmap
  57. BIN
      static/assets/pdfjs/cmaps/90pv-RKSJ-V.bcmap
  58. BIN
      static/assets/pdfjs/cmaps/Add-H.bcmap
  59. BIN
      static/assets/pdfjs/cmaps/Add-RKSJ-H.bcmap
  60. BIN
      static/assets/pdfjs/cmaps/Add-RKSJ-V.bcmap
  61. BIN
      static/assets/pdfjs/cmaps/Add-V.bcmap
  62. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-0.bcmap
  63. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-1.bcmap
  64. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-2.bcmap
  65. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-3.bcmap
  66. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-4.bcmap
  67. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-5.bcmap
  68. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-6.bcmap
  69. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-UCS2.bcmap
  70. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-0.bcmap
  71. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-1.bcmap
  72. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-2.bcmap
  73. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-3.bcmap
  74. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-4.bcmap
  75. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-5.bcmap
  76. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-UCS2.bcmap
  77. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-0.bcmap
  78. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-1.bcmap
  79. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-2.bcmap
  80. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-3.bcmap
  81. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-4.bcmap
  82. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-5.bcmap
  83. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-6.bcmap
  84. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap
  85. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-0.bcmap
  86. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-1.bcmap
  87. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-2.bcmap
  88. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-UCS2.bcmap
  89. BIN
      static/assets/pdfjs/cmaps/B5-H.bcmap
  90. BIN
      static/assets/pdfjs/cmaps/B5-V.bcmap
  91. BIN
      static/assets/pdfjs/cmaps/B5pc-H.bcmap
  92. BIN
      static/assets/pdfjs/cmaps/B5pc-V.bcmap
  93. BIN
      static/assets/pdfjs/cmaps/CNS-EUC-H.bcmap
  94. BIN
      static/assets/pdfjs/cmaps/CNS-EUC-V.bcmap
  95. BIN
      static/assets/pdfjs/cmaps/CNS1-H.bcmap
  96. BIN
      static/assets/pdfjs/cmaps/CNS1-V.bcmap
  97. BIN
      static/assets/pdfjs/cmaps/CNS2-H.bcmap
  98. 0 3
      static/assets/pdfjs/cmaps/CNS2-V.bcmap
  99. BIN
      static/assets/pdfjs/cmaps/ETHK-B5-H.bcmap
  100. BIN
      static/assets/pdfjs/cmaps/ETHK-B5-V.bcmap

+ 1 - 0
CHANGELOG.md

@@ -56,6 +56,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Reading a 3MF's details no longer loads all of its geometry into memory** — To find the title, designer and MakerWorld link, Bambuddy read the whole `3D/3dmodel.model` entry into memory. A plain 3MF keeps its meshes in that same entry, so a large one cost hundreds of MB just to read a few metadata lines; a combined plate at the size limits pushed memory from 1.0 to 1.7 GB. The mesh data is now streamed past, and saving a 3MF to the library parses it without holding up other requests.
 - **The queue starts jobs in the order you put them, whether they're pinned to a printer or queued for "Any <model>" (#3200, reported by @bgrr74)** — With a job pinned to a printer dragged above two "Any P2S" jobs, the printer finished and started one of the "Any" jobs from further down, while the pinned job sat at the top showing "Busy". The scheduler sorted the queue by target first and position second, so position only counted among jobs with the same target. Which job won a printer both wanted then came down to the database: on SQLite "Any" jobs always won, and on PostgreSQL pinned jobs did. The scheduler now follows the order the queue page shows, and so does Shortest Job First, whose starvation guard now also protects a pinned job that an "Any" job jumped, and the other way round. New jobs, batch copies, virtual printer and webhook uploads go to the end of the whole queue, not the end of their printer's part of it, and "add to top" puts them at the top of the whole queue. Likely also the cause of #1808.
 - **Settings tabs keep their icons when a label is long (#3191, reported and contributed by @Thomansky in #3192)** — In German the Notifications tab lost its bell: "Benachrichtigungen" plus the provider count didn't fit the tab list on wide screens, and the icon was squeezed to nothing instead of the word. The tab list is a little wider now, and tab icons, counts and status dots always keep their size, so a long label can only wrap. "Virtueller Drucker" also fits on one line again.
 - **"Print labels…" starts from the spools you ticked (#2980, reported by @apizz)** — With spools ticked in the inventory list, the header's **Print labels…** button still opened the label picker with every visible spool selected, so you had to find your spools again. It now starts from the ticked spools, and from every visible spool only when none is ticked.

+ 88 - 1
backend/app/api/routes/library.py

@@ -50,6 +50,7 @@ from backend.app.schemas.library import (
     BulkDeleteRequest,
     BulkDeleteResponse,
     ClientThumbnailResponse,
+    CombineFilesRequest,
     ExternalFolderCreate,
     FileDuplicate,
     FileListResponse,
@@ -657,8 +658,10 @@ async def save_3mf_bytes_to_library(
     thumbnail_path: str | None = None
     if ext == ".3mf":
         try:
+            # Off the event loop: the parser still decompresses the whole model
+            # entry, which for a combined plate is hundreds of MB (#3162).
             parser = ThreeMFParser(str(file_path))
-            raw_metadata = parser.parse()
+            raw_metadata = await asyncio.to_thread(parser.parse)
             thumb_data = raw_metadata.get("_thumbnail_data")
             thumb_ext = raw_metadata.get("_thumbnail_ext", ".png")
             if thumb_data:
@@ -2917,6 +2920,90 @@ def is_sliced_file(filename: str) -> bool:
     return lower.endswith(".gcode") or ".gcode." in lower
 
 
+@router.post("/files/combine", response_model=FileUploadResponse)
+async def combine_files(
+    request: CombineFilesRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
+):
+    """Combine STL library files into one multi-object 3MF.
+
+    The slicer sidecar takes one model per slice, so putting several separate
+    STLs (or several copies of one, #2999) on one plate means building that
+    file first. The result is a new library file that slices like any other
+    3MF; with auto-arrange on, the slicer lays the objects out on the bed.
+    The sources are left untouched.
+    """
+    from backend.app.services.mesh_combine import CombinePart, MeshCombineError, combine_parts_to_3mf
+
+    filename = request.filename.strip()
+    if not filename.lower().endswith(".3mf"):
+        filename = f"{filename}.3mf"
+    try:
+        validate_print_filename(filename)
+    except InvalidFilenameError as e:
+        raise HTTPException(status_code=400, detail=str(e)) from e
+
+    if request.folder_id is not None:
+        folder = (
+            await db.execute(select(LibraryFolder).where(LibraryFolder.id == request.folder_id))
+        ).scalar_one_or_none()
+        if folder is None:
+            raise HTTPException(status_code=404, detail="Folder not found")
+
+    # Same per-row visibility the slice route applies: a READ_OWN caller must
+    # not be able to pull another user's model into their own file by raw id.
+    can_read_all = current_user is None or current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
+
+    # The same file listed twice is one object with the copies added up, so
+    # its mesh is loaded and stored once. Order follows first appearance.
+    copies_by_id: dict[int, int] = {}
+    for item in request.items:
+        copies_by_id[item.file_id] = copies_by_id.get(item.file_id, 0) + item.copies
+
+    rows = (await db.execute(LibraryFile.active().where(LibraryFile.id.in_(copies_by_id)))).scalars().all()
+    by_id = {row.id: row for row in rows}
+
+    # Gate every source before touching any of them on disk, so the answer for
+    # a file the caller can't see is the same 404 whatever else is in the list.
+    sources = [_ensure_library_file_visible(by_id.get(file_id), current_user, can_read_all) for file_id in copies_by_id]
+
+    parts: list[CombinePart] = []
+    for lib_file in sources:
+        if not lib_file.filename.lower().endswith(".stl"):
+            raise HTTPException(status_code=400, detail=f"Only STL files can be combined: {lib_file.filename}")
+        src_path = _resolve_source_disk_path(lib_file)
+        if src_path is None or not src_path.exists():
+            raise HTTPException(status_code=404, detail=f"Source file missing on disk: {lib_file.filename}")
+        parts.append(CombinePart(name=lib_file.filename, path=src_path, copies=copies_by_id[lib_file.id]))
+
+    try:
+        content = await asyncio.to_thread(combine_parts_to_3mf, parts)
+    except MeshCombineError as e:
+        raise HTTPException(status_code=400, detail=str(e)) from e
+
+    # The preview is embedded as Metadata/thumbnail.png by combine_parts_to_3mf,
+    # so ThreeMFParser picks it up here like any other 3MF's thumbnail. Loading
+    # the combined file back to render one would expand every copy.
+    library_file, _ = await save_3mf_bytes_to_library(
+        db,
+        file_bytes=content,
+        filename=filename,
+        folder_id=request.folder_id,
+        source_type="combined",
+        owner_id=current_user.id if current_user else None,
+    )
+
+    return FileUploadResponse(
+        id=library_file.id,
+        filename=library_file.filename,
+        file_type=library_file.file_type,
+        file_size=library_file.file_size,
+        thumbnail_path=library_file.thumbnail_path,
+        metadata=library_file.file_metadata,
+    )
+
+
 @router.post("/files/add-to-queue", response_model=AddToQueueResponse)
 async def add_files_to_queue(
     request: AddToQueueRequest,

+ 20 - 0
backend/app/schemas/library.py

@@ -326,6 +326,26 @@ class FileUploadResponse(BaseModel):
     metadata: dict | None = None
 
 
+# ============ Combine ============
+
+
+class CombineItem(BaseModel):
+    """One source model in a combine request."""
+
+    file_id: int
+    copies: int = Field(default=1, ge=1, le=100)
+
+
+class CombineFilesRequest(BaseModel):
+    """Combine STL library files into one multi-object 3MF on a single plate."""
+
+    items: list[CombineItem] = Field(..., min_length=1, max_length=100)
+    # Name of the new library file; ``.3mf`` is appended when missing.
+    filename: str = Field(..., min_length=1, max_length=255)
+    # Destination folder; None = library root.
+    folder_id: int | None = None
+
+
 # ============ Bulk Operations ============
 
 

+ 50 - 2
backend/app/services/archive.py

@@ -150,6 +150,54 @@ def swap_plate_suffix(name: str | None, target_plate: int) -> str | None:
     return f"{base}{separator}{target_plate}"
 
 
+_MESH_OPEN = b"<mesh"
+_MESH_CLOSE = b"</mesh>"
+_MODEL_READ_CHUNK = 1024 * 1024
+
+
+def read_3dmodel_without_meshes(zf: zipfile.ZipFile, model_path: str = "3D/3dmodel.model") -> str:
+    """Return the model XML with every ``<mesh>...</mesh>`` body left out.
+
+    The metadata scans only want ``<metadata>`` elements and the URLs inside
+    them, but a plain 3MF keeps its geometry inline in this same entry: a
+    combined plate's is hundreds of MB of vertex and triangle rows (#3162).
+    Streaming past the meshes keeps memory to the non-geometry part. The
+    entry can't simply be cut at ``<resources>``: BambuStudio writes its
+    MakerWorld metadata after ``</build>``. Mesh bodies hold only numbers, and
+    a ``<`` inside a metadata value is escaped, so nothing a scan looks for is
+    dropped.
+    """
+    out = bytearray()
+    pending = b""
+    in_mesh = False
+    with zf.open(model_path) as fh:
+        while chunk := fh.read(_MODEL_READ_CHUNK):
+            data = pending + chunk
+            pos = 0
+            while True:
+                if in_mesh:
+                    end = data.find(_MESH_CLOSE, pos)
+                    if end == -1:
+                        # Keep enough of the tail to match a tag split across chunks.
+                        pending = data[max(pos, len(data) - len(_MESH_CLOSE) + 1) :]
+                        break
+                    pos = end + len(_MESH_CLOSE)
+                    in_mesh = False
+                else:
+                    start = data.find(_MESH_OPEN, pos)
+                    if start == -1:
+                        keep = max(pos, len(data) - len(_MESH_OPEN) + 1)
+                        out += data[pos:keep]
+                        pending = data[keep:]
+                        break
+                    out += data[pos:start]
+                    pos = start + len(_MESH_OPEN)
+                    in_mesh = True
+    if not in_mesh:
+        out += pending
+    return out.decode("utf-8", errors="ignore")
+
+
 # How much of a plate's G-code to scan for header/config values. The header
 # block ends in the first kilobyte; the CONFIG_BLOCK that follows it carries
 # layer_height 14-25KB in (measured across the sliced 3MFs on hand, Bambu
@@ -550,7 +598,7 @@ class ThreeMFParser:
             if model_path not in zf.namelist():
                 return
 
-            content = zf.read(model_path).decode("utf-8", errors="ignore")
+            content = read_3dmodel_without_meshes(zf, model_path)
 
             # Parse XML metadata elements
             # MakerWorld adds metadata like: <metadata name="Designer">username</metadata>
@@ -799,7 +847,7 @@ class ProjectPageParser:
                 # Parse 3D/3dmodel.model for metadata
                 model_path = "3D/3dmodel.model"
                 if model_path in zf.namelist():
-                    content = zf.read(model_path).decode("utf-8", errors="ignore")
+                    content = read_3dmodel_without_meshes(zf, model_path)
 
                     # Extract metadata elements using regex
                     # Format: <metadata name="Key">Value</metadata> or <metadata name="Key" />

+ 361 - 0
backend/app/services/mesh_combine.py

@@ -0,0 +1,361 @@
+"""Combine several STL files into one multi-object 3MF.
+
+The slicer sidecar takes exactly one model file per slice, so slicing several
+separate STLs onto one plate means building that one file first. A plain 3MF
+(core spec, no Bambu/Orca ``Metadata/`` entries) is enough: both CLIs load it
+as a project with one object per build item, and ``--arrange`` lays them out
+on the target bed.
+
+Each source mesh is written once as a 3MF ``<object>`` and every copy of it is
+a ``<build><item>`` pointing at that object with its own transform, so ten
+copies of a 5 MB STL cost 5 MB, not 50. trimesh's own 3MF exporter duplicates
+the mesh per scene node, which is why this writes the XML directly, streamed
+into the zip entry so the model never exists as one string in memory.
+
+Copies are pre-placed on a simple shelf grid with a gap between footprints.
+The slicer re-arranges them anyway when arrange is on, but a grid means the
+file also opens sensibly in a desktop slicer and renders a readable
+thumbnail, rather than every object stacked on the origin.
+
+The preview is rendered here, from the meshes already in memory, and embedded
+as ``Metadata/thumbnail.png``. Loading the finished 3MF back to render it would
+expand every build item into its own copy of the mesh: 100 copies of a
+327k-face STL peaked at 13.6 GB that way. Here each source is simplified once,
+to a share of a fixed face budget, before its copies are placed.
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+import math
+import re
+import zipfile
+from dataclasses import dataclass
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Hard cap on build items (sum of copies). Every item is a full object for the
+# slicer to arrange, support and slice; past this a single plate cannot hold
+# them anyway and the request is more likely a typo than a real print.
+MAX_COMBINE_INSTANCES = 100
+
+# Caps on the sources themselves, so a request of many large STLs can't hold
+# the whole lot in memory. Bytes are checked from the file sizes before
+# anything is loaded; faces after each load, so the request stops at the first
+# model that crosses the line. A typical printable STL is 1 to 20 MB; a binary
+# STL spends 50 bytes per face, so the two caps sit close to each other.
+MAX_COMBINE_SOURCE_BYTES = 300 * 1024 * 1024
+MAX_COMBINE_SOURCE_FACES = 5_000_000
+
+# Faces drawn in the embedded preview, shared across every copy on the plate.
+# Each source is simplified once to its per-copy share; the floor keeps a
+# 100-copy plate recognisable (100 x 1500 faces stays inside the budget).
+THUMBNAIL_FACE_BUDGET = 200_000
+THUMBNAIL_MIN_FACES_PER_COPY = 1_500
+THUMBNAIL_PATH = "Metadata/thumbnail.png"
+
+# Space between neighbouring footprints in the pre-placed grid, in mm.
+LAYOUT_GAP_MM = 5.0
+
+# Rows per chunk written to the model entry.
+_XML_CHUNK_ROWS = 20_000
+
+_CORE_NS = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
+
+_CONTENT_TYPES_XML = (
+    '<?xml version="1.0" encoding="UTF-8"?>\n'
+    '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
+    '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
+    '<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>'
+    '<Default Extension="png" ContentType="image/png"/>'
+    "</Types>"
+)
+
+_MODEL_REL = (
+    '<Relationship Target="/3D/3dmodel.model" Id="rel0" '
+    'Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>'
+)
+# The OPC package thumbnail relationship: how a 3MF names its preview image.
+_THUMBNAIL_REL = (
+    f'<Relationship Target="/{THUMBNAIL_PATH}" Id="rel1" '
+    'Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/>'
+)
+
+
+def _rels_xml(with_thumbnail: bool) -> str:
+    return (
+        '<?xml version="1.0" encoding="UTF-8"?>\n'
+        '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+        + _MODEL_REL
+        + (_THUMBNAIL_REL if with_thumbnail else "")
+        + "</Relationships>"
+    )
+
+
+class MeshCombineError(ValueError):
+    """A source could not be combined (unreadable, empty, or over the limits).
+
+    The message is user-facing: the route returns it as the 400 detail.
+    """
+
+
+@dataclass(frozen=True)
+class CombinePart:
+    """One source model and how many copies of it go on the plate."""
+
+    name: str
+    path: Path
+    copies: int = 1
+
+
+@dataclass(frozen=True)
+class _LoadedPart:
+    name: str
+    vertices: object  # numpy (N, 3) float array, footprint min at (0, 0), z min at 0
+    faces: object  # numpy (M, 3) int array
+    width: float
+    depth: float
+    copies: int
+
+
+def _object_name(filename: str) -> str:
+    """Object name shown in the slicer's object list: the filename sans extension."""
+    stem = Path(filename).stem or "object"
+    # Control characters are not valid in XML attribute values.
+    return re.sub(r"[\x00-\x1f]", "", stem)[:200] or "object"
+
+
+def _load_part(part: CombinePart) -> _LoadedPart:
+    import trimesh
+
+    try:
+        mesh = trimesh.load(str(part.path), force="mesh")
+    except Exception as exc:
+        raise MeshCombineError(f"Could not read {part.name}: {exc}") from exc
+    if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0 or len(mesh.faces) == 0:
+        raise MeshCombineError(f"{part.name} contains no printable geometry")
+
+    vertices = mesh.vertices.copy()
+    lo = vertices.min(axis=0)
+    hi = vertices.max(axis=0)
+    # Normalise so the footprint starts at the origin and the model sits on
+    # the bed; the layout below then only has to translate in X/Y.
+    vertices -= lo
+    return _LoadedPart(
+        name=_object_name(part.name),
+        vertices=vertices,
+        faces=mesh.faces,
+        width=float(hi[0] - lo[0]),
+        depth=float(hi[1] - lo[1]),
+        copies=part.copies,
+    )
+
+
+def _check_source_bytes(parts: list[CombinePart]) -> None:
+    total = 0
+    for part in parts:
+        try:
+            total += part.path.stat().st_size
+        except OSError as exc:
+            raise MeshCombineError(f"Could not read {part.name}: {exc}") from exc
+    if total > MAX_COMBINE_SOURCE_BYTES:
+        raise MeshCombineError(
+            f"The selected models are too large to combine: {total / 1024**2:.0f} MB, "
+            f"at most {MAX_COMBINE_SOURCE_BYTES // 1024**2} MB in total"
+        )
+
+
+def _load_parts(parts: list[CombinePart]) -> list[_LoadedPart]:
+    loaded: list[_LoadedPart] = []
+    faces = 0
+    for part in parts:
+        item = _load_part(part)
+        faces += len(item.faces)
+        if faces > MAX_COMBINE_SOURCE_FACES:
+            raise MeshCombineError(
+                f"The selected models are too detailed to combine: more than "
+                f"{MAX_COMBINE_SOURCE_FACES:,} triangles in total"
+            )
+        loaded.append(item)
+    return loaded
+
+
+def layout_offsets(footprints: list[tuple[float, float]], gap: float = LAYOUT_GAP_MM) -> list[tuple[float, float]]:
+    """Shelf-pack footprints ``(width, depth)``; return each one's min-corner offset.
+
+    Rows are filled left to right up to a width that keeps the overall layout
+    roughly square, tallest footprints first so rows don't waste depth. The
+    result is centred on the origin. Offsets come back in input order.
+    """
+    if not footprints:
+        return []
+    area = sum((w + gap) * (d + gap) for w, d in footprints)
+    row_limit = max(max(w for w, _ in footprints), math.sqrt(area))
+
+    order = sorted(range(len(footprints)), key=lambda i: footprints[i][1], reverse=True)
+    offsets: list[tuple[float, float]] = [(0.0, 0.0)] * len(footprints)
+    x = y = row_depth = 0.0
+    total_w = 0.0
+    for i in order:
+        w, d = footprints[i]
+        if x > 0 and x + w > row_limit:
+            y += row_depth + gap
+            x = row_depth = 0.0
+        offsets[i] = (x, y)
+        total_w = max(total_w, x + w)
+        x += w + gap
+        row_depth = max(row_depth, d)
+    total_d = y + row_depth
+
+    cx, cy = total_w / 2, total_d / 2
+    return [(ox - cx, oy - cy) for ox, oy in offsets]
+
+
+def thumbnail_faces_per_copy(total_copies: int) -> int:
+    """Face budget for one copy in the preview."""
+    return max(THUMBNAIL_MIN_FACES_PER_COPY, THUMBNAIL_FACE_BUDGET // max(total_copies, 1))
+
+
+def _preview_mesh(loaded: list[_LoadedPart], owners: list[int], offsets: list[tuple[float, float]]):
+    """The plate as one mesh for the thumbnail, within the face budget.
+
+    Each source is simplified once, before its copies are placed, so the
+    work and the memory scale with the budget rather than with copies times
+    source faces.
+    """
+    import numpy as np
+    import trimesh
+
+    per_copy = thumbnail_faces_per_copy(len(owners))
+    reduced = []
+    for part in loaded:
+        mesh = trimesh.Trimesh(vertices=part.vertices, faces=part.faces, process=False)
+        if len(mesh.faces) > per_copy:
+            try:
+                mesh = mesh.simplify_quadric_decimation(face_count=per_copy)
+            except Exception as exc:
+                # Without simplification the budget can't be held, so no preview.
+                logger.warning("Thumbnail skipped: could not simplify %s: %s", part.name, exc)
+                return None
+        reduced.append(mesh)
+
+    vertex_blocks = []
+    face_blocks = []
+    base = 0
+    for owner, (ox, oy) in zip(owners, offsets, strict=True):
+        mesh = reduced[owner]
+        vertex_blocks.append(mesh.vertices + np.array([ox, oy, 0.0]))
+        face_blocks.append(mesh.faces + base)
+        base += len(mesh.vertices)
+    return trimesh.Trimesh(vertices=np.vstack(vertex_blocks), faces=np.vstack(face_blocks), process=False)
+
+
+def _render_thumbnail(loaded: list[_LoadedPart], owners: list[int], offsets: list[tuple[float, float]]) -> bytes | None:
+    """PNG preview of the plate, or None. Never fails the combine."""
+    from backend.app.services.stl_thumbnail import render_mesh_png
+
+    try:
+        mesh = _preview_mesh(loaded, owners, offsets)
+        if mesh is None:
+            return None
+        return render_mesh_png(mesh, label="combined 3MF")
+    except Exception as exc:
+        logger.warning("Could not render the combined 3MF thumbnail: %s", exc, exc_info=True)
+        return None
+
+
+def _fmt(value: float) -> str:
+    # 6 significant decimals is well below slicer resolution and keeps the
+    # XML compact; strip trailing zeros so "10.000000" becomes "10".
+    text = f"{value:.6f}".rstrip("0").rstrip(".")
+    return "0" if text in ("", "-0") else text
+
+
+def _attr(value: str) -> str:
+    return value.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
+
+
+def _write_rows(out, rows, render) -> None:
+    """Write ``rows`` (a numpy array) through ``render`` in fixed-size chunks."""
+    for start in range(0, len(rows), _XML_CHUNK_ROWS):
+        chunk = rows[start : start + _XML_CHUNK_ROWS].tolist()
+        out.write("".join(render(*row) for row in chunk).encode("utf-8"))
+
+
+def _write_model(out, loaded: list[_LoadedPart], owners: list[int], offsets: list[tuple[float, float]]) -> None:
+    """Stream the 3MF model XML into the open zip entry ``out``."""
+    out.write(
+        (
+            '<?xml version="1.0" encoding="UTF-8"?>\n'
+            f'<model unit="millimeter" xml:lang="en-US" xmlns="{_CORE_NS}">'
+            '<metadata name="Application">Bambuddy</metadata>'
+            "<resources>"
+        ).encode()
+    )
+    for idx, part in enumerate(loaded):
+        out.write(f'<object id="{idx + 1}" name="{_attr(part.name)}" type="model"><mesh><vertices>'.encode())
+        _write_rows(out, part.vertices, lambda x, y, z: f'<vertex x="{_fmt(x)}" y="{_fmt(y)}" z="{_fmt(z)}"/>')
+        out.write(b"</vertices><triangles>")
+        _write_rows(out, part.faces, lambda a, b, c: f'<triangle v1="{a}" v2="{b}" v3="{c}"/>')
+        out.write(b"</triangles></mesh></object>")
+    out.write(b"</resources><build>")
+    out.write(
+        "".join(
+            f'<item objectid="{owner + 1}" transform="1 0 0 0 1 0 0 0 1 {_fmt(ox)} {_fmt(oy)} 0"/>'
+            for owner, (ox, oy) in zip(owners, offsets, strict=True)
+        ).encode("utf-8")
+    )
+    out.write(b"</build></model>")
+
+
+def combine_parts_to_3mf(parts: list[CombinePart]) -> bytes:
+    """Build a plain multi-object 3MF from ``parts``; return the zip bytes.
+
+    Blocking (mesh parsing, the preview render and XML writing are CPU-bound):
+    call it through ``asyncio.to_thread`` from request handlers.
+
+    Raises:
+        MeshCombineError: no parts, a copy count outside 1..MAX, too many
+            instances in total, sources over the size or face caps, or a
+            source that can't be read as a mesh.
+    """
+    if not parts:
+        raise MeshCombineError("Select at least one model to combine")
+    for part in parts:
+        if part.copies < 1:
+            raise MeshCombineError(f"{part.name}: copies must be at least 1")
+    total = sum(p.copies for p in parts)
+    if total > MAX_COMBINE_INSTANCES:
+        raise MeshCombineError(f"Too many objects: {total} requested, at most {MAX_COMBINE_INSTANCES} per plate")
+
+    _check_source_bytes(parts)
+    loaded = _load_parts(parts)
+
+    footprints: list[tuple[float, float]] = []
+    owners: list[int] = []
+    for idx, part in enumerate(loaded):
+        for _ in range(part.copies):
+            footprints.append((part.width, part.depth))
+            owners.append(idx)
+    offsets = layout_offsets(footprints)
+
+    thumbnail = _render_thumbnail(loaded, owners, offsets)
+
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr("[Content_Types].xml", _CONTENT_TYPES_XML)
+        zf.writestr("_rels/.rels", _rels_xml(with_thumbnail=thumbnail is not None))
+        with zf.open("3D/3dmodel.model", "w", force_zip64=True) as out:
+            _write_model(out, loaded, owners, offsets)
+        if thumbnail is not None:
+            zf.writestr(THUMBNAIL_PATH, thumbnail)
+    logger.info(
+        "Combined %d model(s) into %d object(s) on one plate (%d bytes, thumbnail %s)",
+        len(loaded),
+        total,
+        buf.tell(),
+        "embedded" if thumbnail is not None else "skipped",
+    )
+    return buf.getvalue()

+ 117 - 88
backend/app/services/stl_thumbnail.py

@@ -179,96 +179,64 @@ def _shade_kwargs(poly3d, LightSource) -> dict:
     }
 
 
-def generate_stl_thumbnail(
-    stl_path: Path,
-    thumbnails_dir: Path,
-    size: int = 256,
-) -> str | None:
-    """Generate a thumbnail image from an STL file.
+def _import_renderer():
+    """Import matplotlib (headless) and the mplot3d pieces the renderer uses."""
+    # Must precede the matplotlib import: MPLCONFIGDIR is read at
+    # matplotlib import time, not on subsequent attribute access.
+    _configure_matplotlib_cache()
 
-    Args:
-        stl_path: Path to the STL file
-        thumbnails_dir: Directory to save the thumbnail
-        size: Thumbnail size in pixels (default 256x256)
+    import matplotlib
 
-    Returns:
-        Path to the generated thumbnail, or None on failure
-    """
-    # Callers historically pass either Path or str; coerce so the `thumbnails_dir
-    # / thumb_filename` join at the end of this function can't fail with the
-    # str-divided-by-str TypeError (see #1299).
-    stl_path = Path(stl_path)
-    thumbnails_dir = Path(thumbnails_dir)
+    # Use Agg backend for headless rendering
+    matplotlib.use("Agg")
+    import matplotlib.pyplot as plt
+    from matplotlib.colors import LightSource
+    from mpl_toolkits.mplot3d import Axes3D  # noqa: F401
+    from mpl_toolkits.mplot3d.art3d import Poly3DCollection
 
-    try:
-        # Must precede the matplotlib import — MPLCONFIGDIR is read at
-        # matplotlib import time, not on subsequent attribute access.
-        _configure_matplotlib_cache()
+    return plt, LightSource, Poly3DCollection
 
-        import matplotlib
-        import trimesh
 
-        # Use Agg backend for headless rendering
-        matplotlib.use("Agg")
-        import matplotlib.pyplot as plt
-        from matplotlib.colors import LightSource
-        from mpl_toolkits.mplot3d import Axes3D  # noqa: F401
-        from mpl_toolkits.mplot3d.art3d import Poly3DCollection
+def render_mesh_png(mesh, size: int = 256, label: str = "mesh") -> bytes:
+    """Render ``mesh`` (a ``trimesh.Trimesh``) to PNG bytes.
 
-        # Load the STL file
-        mesh = trimesh.load(str(stl_path), force="mesh")
+    The caller owns the face budget: this draws every face it is given, so a
+    large mesh has to be simplified first (see ``generate_stl_thumbnail`` and
+    ``mesh_combine``). Raises on failure; callers decide whether a missing
+    preview is an error.
+    """
+    import io
 
-        if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
-            # Demoted from warning to debug: this is a per-file content
-            # observation (the STL is empty / stub / corrupted), not an
-            # actionable error. The caller proceeds correctly with no
-            # thumbnail. The call sites also pre-skip files below
-            # MIN_USABLE_STL_BYTES so the common stub-STL case never gets
-            # this far — this branch now catches only the rare "large
-            # enough but trimesh still can't parse it" case.
-            logger.debug("Failed to load STL or empty mesh: %s", stl_path)
-            return None
+    import trimesh
 
-        # Simplify large meshes for performance
-        if len(mesh.vertices) > MAX_VERTICES:
-            logger.info("Simplifying mesh from %s vertices", len(mesh.vertices))
-            try:
-                # Calculate reduction ratio (0-1 range)
-                # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
-                keep_ratio = MAX_VERTICES / len(mesh.vertices)
-                target_reduction = 1.0 - keep_ratio
-                # Clamp to valid range (0.01 to 0.99)
-                target_reduction = max(0.01, min(0.99, target_reduction))
-                mesh = mesh.simplify_quadric_decimation(target_reduction)
-                logger.info("Simplified mesh to %s vertices", len(mesh.vertices))
-            except Exception as e:
-                logger.warning("Mesh simplification failed, using original: %s", e)
+    plt, LightSource, Poly3DCollection = _import_renderer()
 
-        # Wind every face the same way, and outward, or the shading turns the
-        # model into camouflage. See ``_repair_winding``; it must run before the
-        # vertices below are read, since a future repair step could move them.
-        try:
-            _repair_winding(mesh, trimesh, str(stl_path))
-        except Exception as e:  # best-effort: a flat render beats no thumbnail
-            logger.debug("Winding repair skipped (%s): %s", e, stl_path)
-
-        # Get mesh bounds and center it
-        vertices = mesh.vertices
-        bounds_min = vertices.min(axis=0)
-        bounds_max = vertices.max(axis=0)
-        center = (bounds_min + bounds_max) / 2
-        vertices_centered = vertices - center
-
-        # Scale to fit in view
-        max_extent = (bounds_max - bounds_min).max()
-        if max_extent > 0:
-            scale = 1.0 / max_extent
-            vertices_scaled = vertices_centered * scale
-        else:
-            vertices_scaled = vertices_centered
-
-        # Create figure with dark background
-        fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
+    # Wind every face the same way, and outward, or the shading turns the
+    # model into camouflage. See ``_repair_winding``; it must run before the
+    # vertices below are read, since a future repair step could move them.
+    try:
+        _repair_winding(mesh, trimesh, label)
+    except Exception as e:  # best-effort: a flat render beats no thumbnail
+        logger.debug("Winding repair skipped (%s): %s", e, label)
+
+    # Get mesh bounds and center it
+    vertices = mesh.vertices
+    bounds_min = vertices.min(axis=0)
+    bounds_max = vertices.max(axis=0)
+    center = (bounds_min + bounds_max) / 2
+    vertices_centered = vertices - center
+
+    # Scale to fit in view
+    max_extent = (bounds_max - bounds_min).max()
+    if max_extent > 0:
+        scale = 1.0 / max_extent
+        vertices_scaled = vertices_centered * scale
+    else:
+        vertices_scaled = vertices_centered
+
+    # Create figure with dark background
+    fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
+    try:
         fig.patch.set_facecolor(BACKGROUND_COLOR)
 
         ax = fig.add_subplot(111, projection="3d")
@@ -276,7 +244,7 @@ def generate_stl_thumbnail(
 
         # Create polygon collection from mesh faces
         # Index with the face array rather than building a list of lists. Same
-        # data, and Poly3DCollection accepts it directly — but shading walks this
+        # data, and Poly3DCollection accepts it directly, but shading walks this
         # structure to generate normals, and on an 82k-face mesh the list form
         # costs ~0.19s against ~0.007s for the ndarray. It speeds up the unshaded
         # path too.
@@ -311,12 +279,9 @@ def generate_stl_thumbnail(
         # Remove margins
         plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
 
-        # Save thumbnail
-        thumb_filename = f"{uuid.uuid4().hex}.png"
-        thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
-
+        buf = io.BytesIO()
         fig.savefig(
-            thumb_path,
+            buf,
             format="png",
             facecolor=BACKGROUND_COLOR,
             edgecolor="none",
@@ -324,8 +289,72 @@ def generate_stl_thumbnail(
             pad_inches=0.05,
             dpi=100,
         )
+        return buf.getvalue()
+    finally:
         plt.close(fig)
 
+
+def generate_stl_thumbnail(
+    stl_path: Path,
+    thumbnails_dir: Path,
+    size: int = 256,
+) -> str | None:
+    """Generate a thumbnail image from an STL file.
+
+    Args:
+        stl_path: Path to the STL file
+        thumbnails_dir: Directory to save the thumbnail
+        size: Thumbnail size in pixels (default 256x256)
+
+    Returns:
+        Path to the generated thumbnail, or None on failure
+    """
+    # Callers historically pass either Path or str; coerce so the `thumbnails_dir
+    # / thumb_filename` join at the end of this function can't fail with the
+    # str-divided-by-str TypeError (see #1299).
+    stl_path = Path(stl_path)
+    thumbnails_dir = Path(thumbnails_dir)
+
+    try:
+        _import_renderer()
+        import trimesh
+
+        # Load the STL file
+        mesh = trimesh.load(str(stl_path), force="mesh")
+
+        if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
+            # Demoted from warning to debug: this is a per-file content
+            # observation (the STL is empty / stub / corrupted), not an
+            # actionable error. The caller proceeds correctly with no
+            # thumbnail. The call sites also pre-skip files below
+            # MIN_USABLE_STL_BYTES so the common stub-STL case never gets
+            # this far; this branch now catches only the rare "large
+            # enough but trimesh still can't parse it" case.
+            logger.debug("Failed to load STL or empty mesh: %s", stl_path)
+            return None
+
+        # Simplify large meshes for performance
+        if len(mesh.vertices) > MAX_VERTICES:
+            logger.info("Simplifying mesh from %s vertices", len(mesh.vertices))
+            try:
+                # Calculate reduction ratio (0-1 range)
+                # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
+                keep_ratio = MAX_VERTICES / len(mesh.vertices)
+                target_reduction = 1.0 - keep_ratio
+                # Clamp to valid range (0.01 to 0.99)
+                target_reduction = max(0.01, min(0.99, target_reduction))
+                mesh = mesh.simplify_quadric_decimation(target_reduction)
+                logger.info("Simplified mesh to %s vertices", len(mesh.vertices))
+            except Exception as e:
+                logger.warning("Mesh simplification failed, using original: %s", e)
+
+        png = render_mesh_png(mesh, size=size, label=str(stl_path))
+
+        # Save thumbnail
+        thumb_filename = f"{uuid.uuid4().hex}.png"
+        thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
+        thumb_path.write_bytes(png)
+
         logger.info("Generated STL thumbnail: %s", thumb_path)
         return str(thumb_path)
 
@@ -336,7 +365,7 @@ def generate_stl_thumbnail(
         # Log the traceback, not just the message: a bare
         # "unsupported operand type(s) for /: 'str' and 'str'" gives no clue
         # which line failed, and the fault is data-/environment-specific
-        # enough that it can't be reproduced from a clean STL — the traceback
+        # enough that it can't be reproduced from a clean STL; the traceback
         # in the next support bundle is what pinpoints it (#1480).
         logger.warning("Failed to generate STL thumbnail for %s: %s", stl_path, e, exc_info=True)
         return None

+ 171 - 0
backend/tests/integration/test_library_combine.py

@@ -0,0 +1,171 @@
+"""Integration tests for POST /library/files/combine (STLs -> one multi-object 3MF)."""
+
+import io
+import re
+import zipfile
+
+import pytest
+import trimesh
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.library import LibraryFile
+
+
+def _stl(mesh) -> bytes:
+    return mesh.export(file_type="stl")
+
+
+async def _upload(client: AsyncClient, name: str, content: bytes, folder_id: int | None = None) -> int:
+    params = {"generate_stl_thumbnails": "false"}
+    if folder_id is not None:
+        params["folder_id"] = str(folder_id)
+    resp = await client.post(
+        "/api/v1/library/files",
+        files={"file": (name, content, "application/octet-stream")},
+        params=params,
+    )
+    assert resp.status_code == 200, resp.text
+    return resp.json()["id"]
+
+
+async def _download(client: AsyncClient, file_id: int) -> bytes:
+    resp = await client.get(f"/api/v1/library/files/{file_id}/download")
+    assert resp.status_code == 200, resp.text
+    return resp.content
+
+
+class TestCombineFiles:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_combines_stls_into_new_3mf(self, async_client: AsyncClient, db_session):
+        box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((30, 20, 10))))
+        cyl_id = await _upload(async_client, "cyl.stl", _stl(trimesh.creation.cylinder(radius=12, height=25)))
+
+        resp = await async_client.post(
+            "/api/v1/library/files/combine",
+            json={"items": [{"file_id": box_id, "copies": 2}, {"file_id": cyl_id}], "filename": "Plate"},
+        )
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        assert body["filename"] == "Plate.3mf"
+        assert body["file_type"] == "3mf"
+        assert body["thumbnail_path"]
+
+        row = (await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["id"]))).scalar_one()
+        assert row.source_type == "combined"
+
+        content = await _download(async_client, body["id"])
+        with zipfile.ZipFile(io.BytesIO(content)) as zf:
+            xml = zf.read("3D/3dmodel.model").decode()
+        assert re.findall(r'<item objectid="(\d+)"', xml) == ["1", "1", "2"]
+
+        # Sources are left alone.
+        for src in (box_id, cyl_id):
+            assert (await async_client.get(f"/api/v1/library/files/{src}")).status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_same_file_twice_is_one_object_with_the_copies_added(self, async_client: AsyncClient):
+        box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((10, 10, 10))))
+        cyl_id = await _upload(async_client, "cyl.stl", _stl(trimesh.creation.cylinder(radius=5, height=8)))
+
+        resp = await async_client.post(
+            "/api/v1/library/files/combine",
+            json={
+                "items": [
+                    {"file_id": box_id, "copies": 2},
+                    {"file_id": cyl_id},
+                    {"file_id": box_id, "copies": 3},
+                ],
+                "filename": "dupes",
+            },
+        )
+        assert resp.status_code == 200, resp.text
+
+        content = await _download(async_client, resp.json()["id"])
+        with zipfile.ZipFile(io.BytesIO(content)) as zf:
+            xml = zf.read("3D/3dmodel.model").decode()
+        # The box mesh is stored once (object 1, first appearance) with 5 copies.
+        assert re.findall(r'<object id="(\d+)" name="([^"]+)"', xml) == [("1", "box"), ("2", "cyl")]
+        assert re.findall(r'<item objectid="(\d+)"', xml) == ["1"] * 5 + ["2"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_lands_in_requested_folder(self, async_client: AsyncClient, db_session):
+        folder = (await async_client.post("/api/v1/library/folders", json={"name": "Combos"})).json()
+        box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((10, 10, 10))))
+        resp = await async_client.post(
+            "/api/v1/library/files/combine",
+            json={"items": [{"file_id": box_id, "copies": 4}], "filename": "four.3mf", "folder_id": folder["id"]},
+        )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["filename"] == "four.3mf"
+        row = (await db_session.execute(select(LibraryFile).where(LibraryFile.id == resp.json()["id"]))).scalar_one()
+        assert row.folder_id == folder["id"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_non_stl_source(self, async_client: AsyncClient):
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w") as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+        three_mf_id = await _upload(async_client, "project.3mf", buf.getvalue())
+        resp = await async_client.post(
+            "/api/v1/library/files/combine",
+            json={"items": [{"file_id": three_mf_id}], "filename": "x"},
+        )
+        assert resp.status_code == 400
+        assert "Only STL" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_unknown_file(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/library/files/combine",
+            json={"items": [{"file_id": 999999}], "filename": "x"},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_unknown_folder(self, async_client: AsyncClient):
+        box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((10, 10, 10))))
+        resp = await async_client.post(
+            "/api/v1/library/files/combine",
+            json={"items": [{"file_id": box_id}], "filename": "x", "folder_id": 999999},
+        )
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "Folder not found"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_invalid_filename(self, async_client: AsyncClient):
+        box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((10, 10, 10))))
+        resp = await async_client.post(
+            "/api/v1/library/files/combine",
+            json={"items": [{"file_id": box_id}], "filename": "a/b"},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_too_many_objects(self, async_client: AsyncClient):
+        box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((10, 10, 10))))
+        resp = await async_client.post(
+            "/api/v1/library/files/combine",
+            json={"items": [{"file_id": box_id, "copies": 60}, {"file_id": box_id, "copies": 60}], "filename": "x"},
+        )
+        assert resp.status_code == 400
+        assert "Too many objects" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_empty_mesh(self, async_client: AsyncClient):
+        stub_id = await _upload(async_client, "stub.stl", b"solid stub\nendsolid stub\n")
+        resp = await async_client.post(
+            "/api/v1/library/files/combine",
+            json={"items": [{"file_id": stub_id}], "filename": "x"},
+        )
+        assert resp.status_code == 400
+        assert "stub.stl" in resp.json()["detail"]

+ 28 - 0
backend/tests/integration/test_ownership_permissions.py

@@ -1880,6 +1880,34 @@ class TestSliceOwnershipPermissions(TestOwnershipPermissionsSetup):
         assert resp.status_code == 404
         assert resp.json()["detail"] == "Source file missing on disk"
 
+    # --- combine to 3MF ----------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_combine_others_library_file(self, async_client, auth_setup, library_file_factory):
+        own = await library_file_factory(filename="own.stl", created_by_id=auth_setup["operator_user"]["id"])
+        other = await library_file_factory(filename="other.stl", created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            "/api/v1/library/files/combine",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"items": [{"file_id": own.id}, {"file_id": other.id}], "filename": "mix"},
+        )
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "File not found"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_combine_any_library_file(self, async_client, auth_setup, library_file_factory):
+        other = await library_file_factory(filename="other.stl", created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            "/api/v1/library/files/combine",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            json={"items": [{"file_id": other.id}], "filename": "mix"},
+        )
+        # Past the ownership gate — only the on-disk source is missing in tests.
+        assert resp.status_code == 404
+        assert resp.json()["detail"].startswith("Source file missing on disk")
+
     # --- archive slice -----------------------------------------------------
 
     @pytest.mark.asyncio

+ 74 - 0
backend/tests/unit/services/test_archive_service.py

@@ -318,6 +318,80 @@ class TestThreeMFMetadataHTMLUnescape:
         assert parsed.get("print_name") == "Benchy"
 
 
+class TestModelMetadataSkipsMeshes:
+    """The metadata scans stream past ``<mesh>`` bodies instead of reading the
+    whole model entry: a combined plate keeps hundreds of MB of geometry inline
+    in 3D/3dmodel.model (#3162). Metadata after ``</build>``, where
+    BambuStudio writes its MakerWorld fields, must still be found."""
+
+    MESH = (
+        '<mesh><vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/>'
+        '<vertex x="0" y="1" z="0"/></vertices><triangles><triangle v1="0" v2="1" v3="2"/>'
+        "</triangles></mesh>"
+    )
+
+    def _model(self) -> str:
+        return (
+            '<?xml version="1.0" encoding="UTF-8"?>\n'
+            '<model><metadata name="Title">Before &amp; mesh</metadata><resources>'
+            f'<object id="1" type="model">{self.MESH}</object>'
+            f'<object id="2" type="model">{self.MESH}</object>'
+            '</resources><build><item objectid="1"/></build>'
+            '<metadata name="Designer">After build</metadata>'
+            '<metadata name="CopyRight">https://makerworld.com/en/models/130726</metadata>'
+            "</model>"
+        )
+
+    def _write(self, tmp_path, model: str):
+        import zipfile
+
+        path = tmp_path / "plate.3mf"
+        with zipfile.ZipFile(path, "w") as zf:
+            zf.writestr("3D/3dmodel.model", model)
+        return path
+
+    def test_meshes_are_dropped_and_everything_else_kept(self, tmp_path):
+        import zipfile
+
+        from backend.app.services.archive import read_3dmodel_without_meshes
+
+        model = self._model()
+        with zipfile.ZipFile(self._write(tmp_path, model)) as zf:
+            text = read_3dmodel_without_meshes(zf)
+
+        assert "<vertex" not in text and "<triangle" not in text
+        assert text == model.replace(self.MESH, "")
+
+    def test_tags_split_across_chunks(self, tmp_path, monkeypatch):
+        """Every chunk size from 1 byte up puts ``<mesh`` and ``</mesh>``
+        across a boundary somewhere; the result must not change."""
+        import zipfile
+
+        from backend.app.services import archive
+
+        model = self._model()
+        path = self._write(tmp_path, model)
+        expected = model.replace(self.MESH, "")
+        for chunk in range(1, 40):
+            monkeypatch.setattr(archive, "_MODEL_READ_CHUNK", chunk)
+            with zipfile.ZipFile(path) as zf:
+                assert archive.read_3dmodel_without_meshes(zf) == expected, chunk
+
+    def test_parsers_still_find_metadata_after_the_build(self, tmp_path):
+        from backend.app.services.archive import ProjectPageParser, ThreeMFParser
+
+        path = self._write(tmp_path, self._model())
+
+        parsed = ThreeMFParser(str(path)).parse()
+        assert parsed.get("print_name") == "Before & mesh"
+        assert parsed.get("designer") == "After build"
+        assert parsed.get("makerworld_model_id") == "130726"
+
+        page = ProjectPageParser(str(path)).parse(archive_id=1)
+        assert page["title"] == "Before & mesh"
+        assert page["designer"] == "After build"
+
+
 class TestPrintableObjectsExtraction:
     """Tests for extracting printable objects count from 3MF files."""
 

+ 205 - 0
backend/tests/unit/test_mesh_combine.py

@@ -0,0 +1,205 @@
+"""Unit tests for combining STLs into one multi-object 3MF."""
+
+import io
+import re
+import zipfile
+
+import pytest
+import trimesh
+
+from backend.app.services import mesh_combine, stl_thumbnail
+from backend.app.services.mesh_combine import (
+    LAYOUT_GAP_MM,
+    MAX_COMBINE_INSTANCES,
+    THUMBNAIL_FACE_BUDGET,
+    THUMBNAIL_PATH,
+    CombinePart,
+    MeshCombineError,
+    combine_parts_to_3mf,
+    layout_offsets,
+    thumbnail_faces_per_copy,
+)
+
+
+@pytest.fixture
+def box_stl(tmp_path):
+    path = tmp_path / "box.stl"
+    path.write_bytes(trimesh.creation.box((30, 20, 10)).export(file_type="stl"))
+    return path
+
+
+@pytest.fixture
+def cyl_stl(tmp_path):
+    path = tmp_path / "cyl.stl"
+    path.write_bytes(trimesh.creation.cylinder(radius=12, height=25).export(file_type="stl"))
+    return path
+
+
+def _model_xml(data: bytes) -> str:
+    with zipfile.ZipFile(io.BytesIO(data)) as zf:
+        assert {"[Content_Types].xml", "_rels/.rels", "3D/3dmodel.model"} <= set(zf.namelist())
+        return zf.read("3D/3dmodel.model").decode("utf-8")
+
+
+def test_one_object_per_source_one_item_per_copy(box_stl, cyl_stl):
+    data = combine_parts_to_3mf([CombinePart("box.stl", box_stl, 2), CombinePart("cyl.stl", cyl_stl, 3)])
+    xml = _model_xml(data)
+
+    # Geometry is stored once per source; copies are build items.
+    assert re.findall(r'<object id="(\d+)" name="([^"]+)"', xml) == [("1", "box"), ("2", "cyl")]
+    assert re.findall(r'<item objectid="(\d+)"', xml) == ["1", "1", "2", "2", "2"]
+
+
+def test_output_reloads_with_every_copy_and_sits_on_the_bed(box_stl, cyl_stl):
+    data = combine_parts_to_3mf([CombinePart("box.stl", box_stl, 2), CombinePart("cyl.stl", cyl_stl, 1)])
+    scene = trimesh.load(io.BytesIO(data), file_type="3mf")
+    meshes = scene.dump()
+    assert len(meshes) == 3
+    for mesh in meshes:
+        assert mesh.bounds[0][2] == pytest.approx(0.0, abs=1e-6)
+
+    # Pre-placed copies must not overlap each other.
+    boxes = [m.bounds for m in meshes]
+    for i, a in enumerate(boxes):
+        for b in boxes[i + 1 :]:
+            separated = a[1][0] <= b[0][0] or b[1][0] <= a[0][0] or a[1][1] <= b[0][1] or b[1][1] <= a[0][1]
+            assert separated
+
+
+def test_layout_is_centred_and_gapped():
+    offsets = layout_offsets([(10.0, 10.0), (10.0, 10.0)])
+    (ax, ay), (bx, by) = offsets
+    assert abs(bx - ax) == pytest.approx(10.0 + LAYOUT_GAP_MM) or abs(by - ay) == pytest.approx(10.0 + LAYOUT_GAP_MM)
+    xs = [x for x, _ in offsets] + [x + 10.0 for x, _ in offsets]
+    ys = [y for _, y in offsets] + [y + 10.0 for _, y in offsets]
+    assert (min(xs) + max(xs)) / 2 == pytest.approx(0.0)
+    assert (min(ys) + max(ys)) / 2 == pytest.approx(0.0)
+
+
+def test_layout_empty():
+    assert layout_offsets([]) == []
+
+
+def test_object_names_are_xml_escaped(box_stl):
+    # The name is the library filename, not the on-disk path.
+    xml = _model_xml(combine_parts_to_3mf([CombinePart('a&b "q".stl', box_stl, 1)]))
+    assert 'name="a&amp;b &quot;q&quot;"' in xml
+
+
+def test_rejects_no_parts():
+    with pytest.raises(MeshCombineError):
+        combine_parts_to_3mf([])
+
+
+def test_rejects_too_many_instances(box_stl):
+    with pytest.raises(MeshCombineError, match="Too many objects"):
+        combine_parts_to_3mf([CombinePart("box.stl", box_stl, MAX_COMBINE_INSTANCES + 1)])
+
+
+def test_rejects_zero_copies(box_stl):
+    with pytest.raises(MeshCombineError):
+        combine_parts_to_3mf([CombinePart("box.stl", box_stl, 0)])
+
+
+def test_rejects_empty_mesh(tmp_path):
+    empty = tmp_path / "empty.stl"
+    empty.write_bytes(b"solid empty\nendsolid empty\n")
+    with pytest.raises(MeshCombineError, match="empty.stl"):
+        combine_parts_to_3mf([CombinePart("empty.stl", empty, 1)])
+
+
+# --- embedded thumbnail --------------------------------------------------
+
+
+def test_thumbnail_is_embedded_and_found_by_the_library_parser(tmp_path, box_stl, cyl_stl):
+    from backend.app.services.archive import ThreeMFParser
+
+    data = combine_parts_to_3mf([CombinePart("box.stl", box_stl, 2), CombinePart("cyl.stl", cyl_stl, 1)])
+    with zipfile.ZipFile(io.BytesIO(data)) as zf:
+        png = zf.read(THUMBNAIL_PATH)
+        rels = zf.read("_rels/.rels").decode()
+        types = zf.read("[Content_Types].xml").decode()
+    assert png.startswith(b"\x89PNG")
+    assert f'Target="/{THUMBNAIL_PATH}"' in rels and "relationships/metadata/thumbnail" in rels
+    assert 'Extension="png"' in types
+
+    out = tmp_path / "combined.3mf"
+    out.write_bytes(data)
+    assert ThreeMFParser(out).parse().get("_thumbnail_data") == png
+
+
+def test_preview_stays_within_the_face_budget_however_many_copies(tmp_path, monkeypatch):
+    # 20,480 faces per source: 100 copies would be 2M faces unsimplified.
+    ball = tmp_path / "ball.stl"
+    ball.write_bytes(trimesh.creation.icosphere(subdivisions=5).export(file_type="stl"))
+    drawn = []
+    monkeypatch.setattr(stl_thumbnail, "render_mesh_png", lambda mesh, **_: drawn.append(len(mesh.faces)) or b"png")
+
+    combine_parts_to_3mf([CombinePart("ball.stl", ball, 100)])
+
+    assert drawn and drawn[0] <= THUMBNAIL_FACE_BUDGET
+    assert thumbnail_faces_per_copy(100) * 100 <= THUMBNAIL_FACE_BUDGET
+
+
+def test_preview_does_not_reload_the_combined_file(tmp_path, box_stl, monkeypatch):
+    # The expensive path was loading the finished 3MF back, which expands
+    # every build item. Only the source STL may be loaded.
+    loaded = []
+    real_load = trimesh.load
+    monkeypatch.setattr(trimesh, "load", lambda path, **kw: loaded.append(str(path)) or real_load(path, **kw))
+
+    combine_parts_to_3mf([CombinePart("box.stl", box_stl, 5)])
+
+    assert loaded == [str(box_stl)]
+
+
+def test_a_failed_preview_does_not_fail_the_combine(box_stl, monkeypatch):
+    def boom(*_, **__):
+        raise RuntimeError("renderer unavailable")
+
+    monkeypatch.setattr(stl_thumbnail, "render_mesh_png", boom)
+    data = combine_parts_to_3mf([CombinePart("box.stl", box_stl, 1)])
+    with zipfile.ZipFile(io.BytesIO(data)) as zf:
+        assert THUMBNAIL_PATH not in zf.namelist()
+        assert "thumbnail" not in zf.read("_rels/.rels").decode()
+    assert "<item " in _model_xml(data)
+
+
+# --- source caps ---------------------------------------------------------
+
+
+def test_rejects_sources_over_the_byte_cap_before_loading(box_stl, cyl_stl, monkeypatch):
+    monkeypatch.setattr(mesh_combine, "MAX_COMBINE_SOURCE_BYTES", box_stl.stat().st_size)
+    monkeypatch.setattr(mesh_combine, "_load_part", lambda part: pytest.fail("loaded despite the byte cap"))
+    with pytest.raises(MeshCombineError, match="too large"):
+        combine_parts_to_3mf([CombinePart("box.stl", box_stl, 1), CombinePart("cyl.stl", cyl_stl, 1)])
+
+
+def test_rejects_sources_over_the_face_cap(box_stl, cyl_stl, monkeypatch):
+    monkeypatch.setattr(mesh_combine, "MAX_COMBINE_SOURCE_FACES", 20)
+    with pytest.raises(MeshCombineError, match="too detailed"):
+        combine_parts_to_3mf([CombinePart("box.stl", box_stl, 1), CombinePart("cyl.stl", cyl_stl, 1)])
+
+
+def test_copies_do_not_count_against_the_face_cap(box_stl, monkeypatch):
+    # A box has 12 faces; the mesh is stored once however many copies there are.
+    monkeypatch.setattr(mesh_combine, "MAX_COMBINE_SOURCE_FACES", 12)
+    combine_parts_to_3mf([CombinePart("box.stl", box_stl, 50)])
+
+
+# --- streamed model XML --------------------------------------------------
+
+
+def test_streamed_model_spans_chunks_intact(tmp_path, monkeypatch):
+    monkeypatch.setattr(mesh_combine, "_XML_CHUNK_ROWS", 7)
+    ball = tmp_path / "ball.stl"
+    source = trimesh.creation.icosphere(subdivisions=2)
+    ball.write_bytes(source.export(file_type="stl"))
+
+    data = combine_parts_to_3mf([CombinePart("ball.stl", ball, 2)])
+
+    xml = _model_xml(data)
+    assert xml.count("<triangle ") == len(source.faces)
+    assert xml.count("<vertex ") == len(trimesh.load(ball, force="mesh").vertices)
+    reloaded = trimesh.load(io.BytesIO(data), file_type="3mf").dump()
+    assert [len(m.faces) for m in reloaded] == [len(source.faces)] * 2

+ 97 - 0
frontend/src/__tests__/components/CombineFilesModal.test.tsx

@@ -0,0 +1,97 @@
+/**
+ * Tests for CombineFilesModal: STLs -> one multi-object 3MF (#2999).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { CombineFilesModal } from '../../components/CombineFilesModal';
+import { api, type LibraryFileListItem } from '../../api/client';
+
+const mockShowToast = vi.fn();
+const mockOnClose = vi.fn();
+const mockOnCombined = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    combineLibraryFiles: vi.fn(),
+  },
+}));
+
+vi.mock('../../contexts/ToastContext', () => ({
+  useToast: () => ({ showToast: mockShowToast }),
+}));
+
+function file(id: number, filename: string): LibraryFileListItem {
+  return { id, filename, file_type: 'stl' } as LibraryFileListItem;
+}
+
+function renderModal(files: LibraryFileListItem[], canSlice = true) {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return render(
+    <QueryClientProvider client={client}>
+      <CombineFilesModal
+        files={files}
+        folderId={7}
+        canSlice={canSlice}
+        onClose={mockOnClose}
+        onCombined={mockOnCombined}
+      />
+    </QueryClientProvider>,
+  );
+}
+
+describe('CombineFilesModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('suggests a name from the first file and lists every model', () => {
+    renderModal([file(1, 'bracket.stl'), file(2, 'clip.stl'), file(3, 'knob.stl')]);
+    expect(screen.getByLabelText('File name')).toHaveValue('bracket + 2 more');
+    expect(screen.getByText('clip.stl')).toBeInTheDocument();
+    expect(screen.getByText('Objects on the plate: 3')).toBeInTheDocument();
+  });
+
+  it('sends copies, name and folder, then hands the new file on for slicing', async () => {
+    const result = { id: 99, filename: 'bracket x4.3mf', file_type: '3mf', file_size: 1, thumbnail_path: null, duplicate_of: null, metadata: null };
+    (api.combineLibraryFiles as ReturnType<typeof vi.fn>).mockResolvedValue(result);
+    const user = userEvent.setup();
+    renderModal([file(1, 'bracket.stl')]);
+
+    const copies = screen.getByLabelText('Copies of bracket.stl');
+    await user.clear(copies);
+    await user.type(copies, '4');
+    expect(screen.getByLabelText('File name')).toHaveValue('bracket x4');
+
+    await user.click(screen.getByRole('button', { name: 'Combine' }));
+
+    await waitFor(() => {
+      expect(api.combineLibraryFiles).toHaveBeenCalledWith([{ file_id: 1, copies: 4 }], 'bracket x4', 7);
+    });
+    expect(mockOnCombined).toHaveBeenCalledWith(result, true);
+  });
+
+  it('blocks more objects than one plate allows', async () => {
+    const user = userEvent.setup();
+    renderModal([file(1, 'a.stl'), file(2, 'b.stl')]);
+
+    const copies = screen.getByLabelText('Copies of a.stl');
+    await user.clear(copies);
+    await user.type(copies, '100');
+
+    expect(screen.getByText('At most 100 objects per plate')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Combine' })).toBeDisabled();
+  });
+
+  it('hides the slice option when in-app slicing is unavailable', async () => {
+    (api.combineLibraryFiles as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 5, filename: 'x.3mf' });
+    const user = userEvent.setup();
+    renderModal([file(1, 'a.stl')], false);
+
+    expect(screen.queryByText('Open the slicer when done')).not.toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: 'Combine' }));
+    await waitFor(() => expect(mockOnCombined).toHaveBeenCalledWith({ id: 5, filename: 'x.3mf' }, false));
+  });
+});

+ 24 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -765,6 +765,30 @@ describe('SliceModal', () => {
     });
   });
 
+  it('starts with auto-arrange ticked for a freshly combined file', async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Combined.3mf' },
+      onClose: vi.fn(),
+      defaultAutoArrange: true,
+    });
+
+    await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+    expect(screen.getByRole('checkbox', { name: /Auto-arrange/ })).toBeChecked();
+
+    await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      const [, body] = vi.mocked(mockApi.sliceLibraryFile).mock.calls[0];
+      expect(body).toHaveProperty('auto_arrange', true);
+    });
+  });
+
   it('omits both layout flags when neither box is ticked (#2548)', async () => {
     mockApi.sliceLibraryFile.mockResolvedValue({
       job_id: 42,

+ 5 - 0
frontend/src/api/client.ts

@@ -7811,6 +7811,11 @@ export const api = {
       method: 'POST',
       body: JSON.stringify(options),
     }),
+  combineLibraryFiles: (items: { file_id: number; copies: number }[], filename: string, folderId: number | null) =>
+    request<LibraryFileUploadResponse>('/library/files/combine', {
+      method: 'POST',
+      body: JSON.stringify({ items, filename, folder_id: folderId }),
+    }),
   addLibraryFilesToQueue: (fileIds: number[]) =>
     request<AddToQueueResponse>('/library/files/add-to-queue', {
       method: 'POST',

+ 186 - 0
frontend/src/components/CombineFilesModal.tsx

@@ -0,0 +1,186 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Combine, Loader2, X } from 'lucide-react';
+
+import { api, type LibraryFileListItem, type LibraryFileUploadResponse } from '../api/client';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+// Mirrors MAX_COMBINE_INSTANCES in backend/app/services/mesh_combine.py.
+const MAX_OBJECTS = 100;
+
+interface CombineFilesModalProps {
+  files: LibraryFileListItem[];
+  folderId: number | null;
+  // Offer "open the slicer next" only where in-app slicing is available.
+  canSlice: boolean;
+  onClose: () => void;
+  onCombined: (file: LibraryFileUploadResponse, sliceNext: boolean) => void;
+}
+
+function stem(filename: string): string {
+  const dot = filename.lastIndexOf('.');
+  return dot > 0 ? filename.slice(0, dot) : filename;
+}
+
+/**
+ * Combine selected STLs (or copies of one, #2999) into a single multi-object
+ * 3MF in the library, ready to slice onto one plate with auto-arrange. The
+ * sidecar slices one file at a time, so this is how several separate models
+ * end up on the same plate without a desktop slicer.
+ */
+export function CombineFilesModal({ files, folderId, canSlice, onClose, onCombined }: CombineFilesModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [copies, setCopies] = useState<Record<number, number>>(() =>
+    Object.fromEntries(files.map((f) => [f.id, 1])),
+  );
+  const [name, setName] = useState<string | null>(null);
+  const [sliceNext, setSliceNext] = useState(canSlice);
+
+  const totalObjects = files.reduce((sum, f) => sum + (copies[f.id] || 0), 0);
+  // Until the user types a name, keep suggesting one that describes the plate.
+  const defaultName = useMemo(() => {
+    if (files.length === 1) return `${stem(files[0].filename)} x${copies[files[0].id] || 1}`;
+    return t('fileManager.combine.defaultName', { name: stem(files[0]?.filename ?? ''), count: files.length - 1 });
+  }, [files, copies, t]);
+  const effectiveName = (name ?? defaultName).trim();
+
+  const combineMutation = useMutation({
+    mutationFn: () =>
+      api.combineLibraryFiles(
+        files.map((f) => ({ file_id: f.id, copies: copies[f.id] || 1 })),
+        effectiveName,
+        folderId,
+      ),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-stats'] });
+      showToast(t('fileManager.combine.done', { filename: result.filename, count: totalObjects }), 'success');
+      onCombined(result, sliceNext && canSlice);
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  useEffect(() => {
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !combineMutation.isPending) onClose();
+    };
+    document.addEventListener('keydown', onKey);
+    return () => document.removeEventListener('keydown', onKey);
+  }, [onClose, combineMutation.isPending]);
+
+  const tooMany = totalObjects > MAX_OBJECTS;
+  const invalidCopies = files.some((f) => !copies[f.id] || copies[f.id] < 1);
+  const titleId = 'combine-files-title';
+
+  return (
+    <div className="fixed inset-0 z-[60] flex items-center justify-center">
+      <div className="absolute inset-0 bg-black/60" onClick={() => !combineMutation.isPending && onClose()} />
+      <div
+        className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby={titleId}
+      >
+        <div className="flex items-center justify-between gap-4 px-5 py-4 border-b border-bambu-dark-tertiary">
+          <h3 id={titleId} className="text-base font-semibold text-white flex items-center gap-2">
+            <Combine className="w-4 h-4 text-bambu-green" />
+            {t('fileManager.combine.title')}
+          </h3>
+          <button
+            type="button"
+            className="p-1.5 text-bambu-gray hover:text-white rounded"
+            onClick={onClose}
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <p className="px-5 pt-3 text-sm text-bambu-gray">{t('fileManager.combine.description')}</p>
+
+        <div className="px-5 py-3">
+          <label htmlFor="combine-name" className="block text-xs text-bambu-gray mb-1">
+            {t('fileManager.combine.nameLabel')}
+          </label>
+          <div className="flex items-center gap-1">
+            <input
+              id="combine-name"
+              type="text"
+              value={name ?? defaultName}
+              onChange={(e) => setName(e.target.value)}
+              maxLength={200}
+              className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+            />
+            <span className="text-sm text-bambu-gray">.3mf</span>
+          </div>
+        </div>
+
+        <div className="overflow-y-auto flex-1 max-h-[20rem] border-y border-bambu-dark-tertiary">
+          <div className="flex justify-between px-5 py-2 text-xs text-bambu-gray">
+            <span>{t('fileManager.combine.model')}</span>
+            <span>{t('fileManager.combine.copies')}</span>
+          </div>
+          <ul className="divide-y divide-bambu-dark-tertiary/40">
+            {files.map((f) => (
+              <li key={f.id} className="flex items-center gap-3 px-5 py-2">
+                <span className="text-sm text-white truncate flex-1" title={f.filename}>
+                  {f.filename}
+                </span>
+                <input
+                  type="number"
+                  min={1}
+                  max={MAX_OBJECTS}
+                  value={copies[f.id] || ''}
+                  onChange={(e) => {
+                    const n = parseInt(e.target.value, 10);
+                    setCopies((prev) => ({ ...prev, [f.id]: Number.isNaN(n) ? 0 : n }));
+                  }}
+                  aria-label={t('fileManager.combine.copiesFor', { filename: f.filename })}
+                  className="w-20 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white text-right focus:outline-none focus:border-bambu-green"
+                />
+              </li>
+            ))}
+          </ul>
+        </div>
+
+        <div className="px-5 py-3 space-y-2 text-sm">
+          <div className={tooMany ? 'text-red-400' : 'text-bambu-gray'}>
+            {tooMany
+              ? t('fileManager.combine.tooMany', { max: MAX_OBJECTS })
+              : t('fileManager.combine.total', { count: totalObjects })}
+          </div>
+          {canSlice && (
+            <label className="flex items-center gap-2 cursor-pointer">
+              <input
+                type="checkbox"
+                checked={sliceNext}
+                onChange={(e) => setSliceNext(e.target.checked)}
+                className="accent-bambu-green"
+              />
+              <span className="text-white">{t('fileManager.combine.sliceNext')}</span>
+            </label>
+          )}
+        </div>
+
+        <div className="px-5 py-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={combineMutation.isPending}>
+            {t('common.cancel')}
+          </Button>
+          <Button
+            type="button"
+            onClick={() => combineMutation.mutate()}
+            disabled={combineMutation.isPending || tooMany || invalidCopies || !effectiveName}
+          >
+            {combineMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
+            {t('fileManager.combine.submit')}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 5 - 2
frontend/src/components/SliceModal.tsx

@@ -43,6 +43,9 @@ export type SliceSource =
 interface SliceModalProps {
   source: SliceSource;
   onClose: () => void;
+  // Start with auto-arrange ticked. Set when the source was just built by
+  // "Combine to 3MF": its objects only become a sensible plate once arranged.
+  defaultAutoArrange?: boolean;
 }
 
 function toRefValue(ref: PresetRef | null): string {
@@ -207,7 +210,7 @@ function colourInputValue(raw: string | null | undefined): string {
     : SLICER_DEFAULT_COLOUR;
 }
 
-export function SliceModal({ source, onClose }: SliceModalProps) {
+export function SliceModal({ source, onClose, defaultAutoArrange = false }: SliceModalProps) {
   const { t } = useTranslation();
   const { trackJob } = useSliceJobTracker();
   const queryClient = useQueryClient();
@@ -268,7 +271,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // enabled in embedded mode, unlike the process-level options around
   // them — these act on the geometry, whichever config drives the slice.
   const [autoOrient, setAutoOrient] = useState(false);
-  const [autoArrange, setAutoArrange] = useState(false);
+  const [autoArrange, setAutoArrange] = useState(defaultAutoArrange);
 
   // #2622: process settings the designer changed away from the stock preset,
   // carried onto the picked process profile so a cross-printer re-slice keeps

+ 16 - 0
frontend/src/i18n/locales/de.ts

@@ -3909,6 +3909,22 @@ export default {
 
   // File manager
   fileManager: {
+    combine: {
+      action: 'Zu 3MF kombinieren',
+      tooltip: 'Die ausgewählten STLs zu einer 3MF kombinieren, um sie auf einer Platte zu slicen',
+      title: 'Zu 3MF kombinieren',
+      description: 'Erstellt eine 3MF mit allen unten aufgeführten Modellen, bereit zum Slicen auf einer Platte. Aktiviere beim Slicen das automatische Anordnen, um sie auf dem Druckbett zu verteilen.',
+      nameLabel: 'Dateiname',
+      defaultName: '{{name}} + {{count}} weitere',
+      model: 'Modell',
+      copies: 'Kopien',
+      copiesFor: 'Kopien von {{filename}}',
+      total: 'Objekte auf der Platte: {{count}}',
+      tooMany: 'Höchstens {{max}} Objekte pro Platte',
+      sliceNext: 'Danach den Slicer öffnen',
+      submit: 'Kombinieren',
+      done: '{{filename}} mit {{count}} Objekten erstellt',
+    },
     variants: {
       badge: '{{count}} Versionen',
       groupAction: 'Als Versionen gruppieren',

+ 16 - 0
frontend/src/i18n/locales/en.ts

@@ -3939,6 +3939,22 @@ export default {
 
   // File manager
   fileManager: {
+    combine: {
+      action: 'Combine to 3MF',
+      tooltip: 'Combine the selected STLs into one 3MF so they can be sliced on a single plate',
+      title: 'Combine to 3MF',
+      description: 'Builds one 3MF with every model below, ready to slice on a single plate. Turn on auto-arrange when slicing to lay them out on the bed.',
+      nameLabel: 'File name',
+      defaultName: '{{name}} + {{count}} more',
+      model: 'Model',
+      copies: 'Copies',
+      copiesFor: 'Copies of {{filename}}',
+      total: 'Objects on the plate: {{count}}',
+      tooMany: 'At most {{max}} objects per plate',
+      sliceNext: 'Open the slicer when done',
+      submit: 'Combine',
+      done: 'Created {{filename}} with {{count}} objects',
+    },
     variants: {
       badge: '{{count}} versions',
       groupAction: 'Group as versions',

+ 16 - 0
frontend/src/i18n/locales/es.ts

@@ -3911,6 +3911,22 @@ export default {
 
   // File manager
   fileManager: {
+    combine: {
+      action: 'Combinar en 3MF',
+      tooltip: 'Combina los STL seleccionados en un 3MF para laminarlos en una sola placa',
+      title: 'Combinar en 3MF',
+      description: 'Crea un 3MF con todos los modelos de abajo, listo para laminar en una sola placa. Activa la organización automática al laminar para distribuirlos en la cama.',
+      nameLabel: 'Nombre del archivo',
+      defaultName: '{{name}} + {{count}} más',
+      model: 'Modelo',
+      copies: 'Copias',
+      copiesFor: 'Copias de {{filename}}',
+      total: 'Objetos en la placa: {{count}}',
+      tooMany: 'Como máximo {{max}} objetos por placa',
+      sliceNext: 'Abrir el laminador al terminar',
+      submit: 'Combinar',
+      done: 'Se creó {{filename}} con {{count}} objetos',
+    },
     variants: {
       badge: '{{count}} versiones',
       groupAction: 'Agrupar como versiones',

+ 16 - 0
frontend/src/i18n/locales/fr.ts

@@ -3897,6 +3897,22 @@ export default {
 
   // File manager
   fileManager: {
+    combine: {
+      action: 'Combiner en 3MF',
+      tooltip: 'Combiner les STL sélectionnés en un seul 3MF pour les trancher sur un seul plateau',
+      title: 'Combiner en 3MF',
+      description: 'Crée un 3MF contenant tous les modèles ci-dessous, prêt à être tranché sur un seul plateau. Activez l\'agencement automatique lors du tranchage pour les répartir sur le plateau.',
+      nameLabel: 'Nom du fichier',
+      defaultName: '{{name}} + {{count}} autres',
+      model: 'Modèle',
+      copies: 'Copies',
+      copiesFor: 'Copies de {{filename}}',
+      total: 'Objets sur le plateau : {{count}}',
+      tooMany: '{{max}} objets maximum par plateau',
+      sliceNext: 'Ouvrir le trancheur ensuite',
+      submit: 'Combiner',
+      done: '{{filename}} créé avec {{count}} objets',
+    },
     variants: {
       badge: 'Versions : {{count}}',
       groupAction: 'Grouper comme versions',

+ 16 - 0
frontend/src/i18n/locales/it.ts

@@ -3896,6 +3896,22 @@ export default {
 
   // File manager
   fileManager: {
+    combine: {
+      action: 'Combina in 3MF',
+      tooltip: 'Combina gli STL selezionati in un unico 3MF per slicerli su un solo piatto',
+      title: 'Combina in 3MF',
+      description: 'Crea un 3MF con tutti i modelli qui sotto, pronto per lo slicing su un solo piatto. Attiva la disposizione automatica durante lo slicing per distribuirli sul piano.',
+      nameLabel: 'Nome file',
+      defaultName: '{{name}} + altri {{count}}',
+      model: 'Modello',
+      copies: 'Copie',
+      copiesFor: 'Copie di {{filename}}',
+      total: 'Oggetti sul piatto: {{count}}',
+      tooMany: 'Al massimo {{max}} oggetti per piatto',
+      sliceNext: 'Apri lo slicer al termine',
+      submit: 'Combina',
+      done: 'Creato {{filename}} con {{count}} oggetti',
+    },
     variants: {
       badge: '{{count}} versioni',
       groupAction: 'Raggruppa come versioni',

+ 16 - 0
frontend/src/i18n/locales/ja.ts

@@ -3909,6 +3909,22 @@ export default {
 
   // File manager
   fileManager: {
+    combine: {
+      action: '3MFに結合',
+      tooltip: '選択したSTLを1つの3MFに結合し、1枚のプレートでスライスできるようにします',
+      title: '3MFに結合',
+      description: '下のすべてのモデルを含む3MFを1つ作成し、1枚のプレートでスライスできるようにします。スライス時に自動配置をオンにすると、ベッド上に並べられます。',
+      nameLabel: 'ファイル名',
+      defaultName: '{{name}} ほか{{count}}件',
+      model: 'モデル',
+      copies: '個数',
+      copiesFor: '{{filename}} の個数',
+      total: 'プレート上のオブジェクト: {{count}}',
+      tooMany: '1プレートあたり最大{{max}}オブジェクト',
+      sliceNext: '完了後にスライサーを開く',
+      submit: '結合',
+      done: '{{filename}} を作成しました({{count}}オブジェクト)',
+    },
     variants: {
       badge: '{{count}}個のバージョン',
       groupAction: 'バージョンとしてグループ化',

+ 16 - 0
frontend/src/i18n/locales/ko.ts

@@ -3718,6 +3718,22 @@ export default {
     bundleStepBuild: '지원 번들 ZIP 빌드 중'
   },
   fileManager: {
+    combine: {
+      action: '3MF로 결합',
+      tooltip: '선택한 STL을 하나의 3MF로 결합해 한 플레이트에서 슬라이스합니다',
+      title: '3MF로 결합',
+      description: '아래 모든 모델을 담은 3MF 하나를 만들어 한 플레이트에서 슬라이스할 수 있게 합니다. 슬라이스할 때 자동 배치를 켜면 베드 위에 배치됩니다.',
+      nameLabel: '파일 이름',
+      defaultName: '{{name}} 외 {{count}}개',
+      model: '모델',
+      copies: '개수',
+      copiesFor: '{{filename}} 개수',
+      total: '플레이트의 객체: {{count}}',
+      tooMany: '플레이트당 최대 {{max}}개 객체',
+      sliceNext: '완료 후 슬라이서 열기',
+      submit: '결합',
+      done: '{{filename}} 생성됨 (객체 {{count}}개)',
+    },
     variants: {
       badge: '버전 {{count}}개',
       groupAction: '버전으로 그룹화',

+ 16 - 0
frontend/src/i18n/locales/nl.ts

@@ -3939,6 +3939,22 @@ export default {
 
   // File manager
   fileManager: {
+    combine: {
+      action: 'Combineren tot 3MF',
+      tooltip: 'Combineer de geselecteerde STL\'s tot één 3MF om ze op één plaat te slicen',
+      title: 'Combineren tot 3MF',
+      description: 'Maakt één 3MF met alle onderstaande modellen, klaar om op één plaat te slicen. Zet automatisch schikken aan bij het slicen om ze over het bed te verdelen.',
+      nameLabel: 'Bestandsnaam',
+      defaultName: '{{name}} + {{count}} andere',
+      model: 'Model',
+      copies: 'Kopieën',
+      copiesFor: 'Kopieën van {{filename}}',
+      total: 'Objecten op de plaat: {{count}}',
+      tooMany: 'Maximaal {{max}} objecten per plaat',
+      sliceNext: 'Slicer openen na afloop',
+      submit: 'Combineren',
+      done: '{{filename}} gemaakt met {{count}} objecten',
+    },
     variants: {
       badge: '{{count}} versies',
       groupAction: 'Als versies groeperen',

+ 16 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3896,6 +3896,22 @@ export default {
 
   // File manager
   fileManager: {
+    combine: {
+      action: 'Combinar em 3MF',
+      tooltip: 'Combina os STLs selecionados em um 3MF para fatiá-los em uma única placa',
+      title: 'Combinar em 3MF',
+      description: 'Cria um 3MF com todos os modelos abaixo, pronto para fatiar em uma única placa. Ative o arranjo automático ao fatiar para distribuí-los na mesa.',
+      nameLabel: 'Nome do arquivo',
+      defaultName: '{{name}} + {{count}} outros',
+      model: 'Modelo',
+      copies: 'Cópias',
+      copiesFor: 'Cópias de {{filename}}',
+      total: 'Objetos na placa: {{count}}',
+      tooMany: 'No máximo {{max}} objetos por placa',
+      sliceNext: 'Abrir o fatiador ao concluir',
+      submit: 'Combinar',
+      done: '{{filename}} criado com {{count}} objetos',
+    },
     variants: {
       badge: '{{count}} versões',
       groupAction: 'Agrupar como versões',

+ 16 - 0
frontend/src/i18n/locales/ru.ts

@@ -3710,6 +3710,22 @@ export default {
     bundleStepBuild: "Создание ZIP-пакета для поддержки",
   },
   fileManager: {
+    combine: {
+      action: 'Объединить в 3MF',
+      tooltip: 'Объединить выбранные STL в один 3MF, чтобы нарезать их на одной пластине',
+      title: 'Объединить в 3MF',
+      description: 'Создаёт один 3MF со всеми моделями ниже, готовый к нарезке на одной пластине. Включите авторасстановку при нарезке, чтобы разместить их на столе.',
+      nameLabel: 'Имя файла',
+      defaultName: '{{name}} и ещё {{count}}',
+      model: 'Модель',
+      copies: 'Копии',
+      copiesFor: 'Копии {{filename}}',
+      total: 'Объектов на пластине: {{count}}',
+      tooMany: 'Не более {{max}} объектов на пластину',
+      sliceNext: 'Открыть слайсер после объединения',
+      submit: 'Объединить',
+      done: 'Создан {{filename}}, объектов: {{count}}',
+    },
     variants: {
       badge: 'Версий: {{count}}',
       groupAction: 'Сгруппировать как версии',

+ 16 - 0
frontend/src/i18n/locales/sv.ts

@@ -3938,6 +3938,22 @@ errors: {
 
   // File manager
   fileManager: {
+    combine: {
+      action: 'Kombinera till 3MF',
+      tooltip: 'Kombinera de valda STL-filerna till en 3MF så att de kan slicas på en platta',
+      title: 'Kombinera till 3MF',
+      description: 'Skapar en 3MF med alla modeller nedan, redo att slicas på en platta. Slå på automatisk placering vid slicing för att fördela dem på bädden.',
+      nameLabel: 'Filnamn',
+      defaultName: '{{name}} + {{count}} till',
+      model: 'Modell',
+      copies: 'Kopior',
+      copiesFor: 'Kopior av {{filename}}',
+      total: 'Objekt på plattan: {{count}}',
+      tooMany: 'Högst {{max}} objekt per platta',
+      sliceNext: 'Öppna slicern när det är klart',
+      submit: 'Kombinera',
+      done: 'Skapade {{filename}} med {{count}} objekt',
+    },
     variants: {
       badge: '{{count}} versioner',
       groupAction: 'Gruppera som versioner',

+ 16 - 0
frontend/src/i18n/locales/tr.ts

@@ -3903,6 +3903,22 @@ export default {
 
   // Dosya yöneticisi
   fileManager: {
+    combine: {
+      action: '3MF olarak birleştir',
+      tooltip: 'Seçili STL dosyalarını tek bir plakada dilimlemek için tek bir 3MF\'de birleştirin',
+      title: '3MF olarak birleştir',
+      description: 'Aşağıdaki tüm modelleri içeren, tek plakada dilimlenmeye hazır bir 3MF oluşturur. Tablaya yerleştirmek için dilimlerken otomatik yerleştirmeyi açın.',
+      nameLabel: 'Dosya adı',
+      defaultName: '{{name}} + {{count}} tane daha',
+      model: 'Model',
+      copies: 'Kopya',
+      copiesFor: '{{filename}} kopya sayısı',
+      total: 'Plakadaki nesneler: {{count}}',
+      tooMany: 'Plaka başına en fazla {{max}} nesne',
+      sliceNext: 'Bitince dilimleyiciyi aç',
+      submit: 'Birleştir',
+      done: '{{filename}} oluşturuldu ({{count}} nesne)',
+    },
     variants: {
       badge: '{{count}} sürüm',
       groupAction: 'Sürüm olarak grupla',

+ 16 - 0
frontend/src/i18n/locales/uk.ts

@@ -3936,6 +3936,22 @@ export default {
 
   // File manager
   fileManager: {
+    combine: {
+      action: 'Об\'єднати в 3MF',
+      tooltip: 'Об\'єднати вибрані STL в один 3MF, щоб нарізати їх на одній пластині',
+      title: 'Об\'єднати в 3MF',
+      description: 'Створює один 3MF з усіма моделями нижче, готовий до нарізки на одній пластині. Увімкніть авторозстановку під час нарізки, щоб розмістити їх на столі.',
+      nameLabel: 'Назва файлу',
+      defaultName: '{{name}} і ще {{count}}',
+      model: 'Модель',
+      copies: 'Копії',
+      copiesFor: 'Копії {{filename}}',
+      total: 'Об\'єктів на пластині: {{count}}',
+      tooMany: 'Не більше {{max}} об\'єктів на пластину',
+      sliceNext: 'Відкрити слайсер після об\'єднання',
+      submit: 'Об\'єднати',
+      done: 'Створено {{filename}}, об\'єктів: {{count}}',
+    },
     variants: {
       badge: 'Версій: {{count}}',
       groupAction: 'Згрупувати як версії',

+ 16 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3896,6 +3896,22 @@ export default {
 
   // File manager
   fileManager: {
+    combine: {
+      action: '合并为 3MF',
+      tooltip: '将所选 STL 合并为一个 3MF,以便在同一个盘上切片',
+      title: '合并为 3MF',
+      description: '创建一个包含下方所有模型的 3MF,可直接在同一个盘上切片。切片时开启自动排列即可将它们摆放在热床上。',
+      nameLabel: '文件名',
+      defaultName: '{{name}} 等 {{count}} 个',
+      model: '模型',
+      copies: '数量',
+      copiesFor: '{{filename}} 的数量',
+      total: '盘上对象:{{count}}',
+      tooMany: '每个盘最多 {{max}} 个对象',
+      sliceNext: '完成后打开切片器',
+      submit: '合并',
+      done: '已创建 {{filename}},共 {{count}} 个对象',
+    },
     variants: {
       badge: '{{count}} 个版本',
       groupAction: '归为版本组',

+ 16 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3896,6 +3896,22 @@ export default {
 
   // File manager
   fileManager: {
+    combine: {
+      action: '合併為 3MF',
+      tooltip: '將所選 STL 合併為一個 3MF,以便在同一個盤上切片',
+      title: '合併為 3MF',
+      description: '建立一個包含下方所有模型的 3MF,可直接在同一個盤上切片。切片時開啟自動排列即可將它們擺放在熱床上。',
+      nameLabel: '檔案名稱',
+      defaultName: '{{name}} 等 {{count}} 個',
+      model: '模型',
+      copies: '數量',
+      copiesFor: '{{filename}} 的數量',
+      total: '盤上物件:{{count}}',
+      tooMany: '每個盤最多 {{max}} 個物件',
+      sliceNext: '完成後開啟切片器',
+      submit: '合併',
+      done: '已建立 {{filename}},共 {{count}} 個物件',
+    },
     variants: {
       badge: '{{count}} 個版本',
       groupAction: '歸為版本群組',

+ 49 - 0
frontend/src/pages/FileManagerPage.tsx

@@ -52,6 +52,7 @@ import {
   StickyNote,
   Camera,
   Eye,
+  Combine,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type {
@@ -70,6 +71,7 @@ import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { SliceModal } from '../components/SliceModal';
+import { CombineFilesModal } from '../components/CombineFilesModal';
 import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
 import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
 import { FileUploadModal } from '../components/FileUploadModal';
@@ -1184,6 +1186,9 @@ export function FileManagerPage() {
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [sliceFile, setSliceFile] = useState<LibraryFileListItem | null>(null);
+  const [showCombineModal, setShowCombineModal] = useState(false);
+  // A file just built by "Combine to 3MF", handed straight to the SliceModal.
+  const [sliceCombined, setSliceCombined] = useState<{ id: number; filename: string } | null>(null);
   // Slicer Pipelines (#1425 PR B) — file gets "Run with pipeline" action.
   const [runPipelineFile, setRunPipelineFile] = useState<LibraryFileListItem | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
@@ -1773,6 +1778,14 @@ export function FileManagerPage() {
     return files.filter(f => selectedFiles.includes(f.id) && isSlicedLibraryFile(f));
   }, [files, selectedFiles]);
 
+  // "Combine to 3MF" is offered only when every selected file is an STL, so
+  // the action never silently drops part of the selection.
+  const selectedStlFiles = useMemo(() => {
+    if (!files) return [];
+    const stls = files.filter(f => selectedFiles.includes(f.id) && f.filename.toLowerCase().endsWith('.stl'));
+    return stls.length === selectedFiles.length ? stls : [];
+  }, [files, selectedFiles]);
+
   // The clicked file's variant group, so printing one member offers the rest
   // without the user re-selecting them (#2570).
   const { data: printFileGroup } = useQuery({
@@ -2549,6 +2562,18 @@ export function FileManagerPage() {
                         <span className="hidden sm:inline">{t('fileManager.variants.groupAction')}</span>
                       </Button>
                     )}
+                    {selectedStlFiles.length >= 1 && (
+                      <Button
+                        variant="secondary"
+                        size="sm"
+                        onClick={() => setShowCombineModal(true)}
+                        disabled={!hasPermission('library:upload')}
+                        title={t('fileManager.combine.tooltip')}
+                      >
+                        <Combine className="w-4 h-4 sm:mr-1" />
+                        <span className="hidden sm:inline">{t('fileManager.combine.action')}</span>
+                      </Button>
+                    )}
                     <Button
                       variant="secondary"
                       size="sm"
@@ -3139,6 +3164,30 @@ export function FileManagerPage() {
         />
       )}
 
+      {showCombineModal && selectedStlFiles.length > 0 && (
+        <CombineFilesModal
+          files={selectedStlFiles}
+          folderId={selectedFolderId}
+          // canSlice() also covers the desktop-slicer handoff; "open the
+          // slicer next" means the in-app SliceModal, so the sidecar must be on.
+          canSlice={!!settings?.use_slicer_api && canSlice()}
+          onClose={() => setShowCombineModal(false)}
+          onCombined={(result, sliceNext) => {
+            setShowCombineModal(false);
+            setSelectedFiles([]);
+            if (sliceNext) setSliceCombined({ id: result.id, filename: result.filename });
+          }}
+        />
+      )}
+
+      {sliceCombined && (
+        <SliceModal
+          source={{ kind: 'libraryFile', id: sliceCombined.id, filename: sliceCombined.filename }}
+          onClose={() => setSliceCombined(null)}
+          defaultAutoArrange
+        />
+      )}
+
       {runPipelineFile && (
         <RunWithPipelineModal
           source={{ kind: 'libraryFile', id: runPipelineFile.id, filename: runPipelineFile.filename }}

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
static/assets/ImagePreviewModal-yOK-BUmg.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 1
static/assets/PdfPreviewModal-B_LYIkkC.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 1
static/assets/SpreadsheetPreviewModal-BEobv_FV.js


+ 0 - 1
static/assets/chunk-aKtaBQYM.js

@@ -1 +0,0 @@
-var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n)),u=(e=>typeof require<`u`?require:typeof Proxy<`u`?new Proxy(e,{get:(e,t)=>(typeof require<`u`?require:e)[t]}):e)(function(e){if(typeof require<`u`)return require.apply(this,arguments);throw Error('Calling `require` for "'+e+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});export{l as i,s as n,u as r,o as t};

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 1
static/assets/index-Bh3F0vXZ.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 1
static/assets/index-CT9srI0L.css


BIN
static/assets/occt-import-js-BhHfLpto.wasm


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
static/assets/papaparse.min-CR43Mv99.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
static/assets/pdf-Dh_3UNyL.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
static/assets/pdf.worker.min-0idpi3xP.js


+ 0 - 1
static/assets/pdf.worker.min-DpOxPwGP.js

@@ -1 +0,0 @@
-var e=`/assets/pdf.worker.min-0idpi3xP.js`;export{e as default};

BIN
static/assets/pdfjs/cmaps/78-EUC-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-EUC-V.bcmap


BIN
static/assets/pdfjs/cmaps/78-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/78-V.bcmap


BIN
static/assets/pdfjs/cmaps/78ms-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/78ms-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/83pv-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90ms-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90ms-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/90msp-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90msp-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/90pv-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90pv-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/Add-H.bcmap


BIN
static/assets/pdfjs/cmaps/Add-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/Add-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/Add-V.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-6.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-6.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/B5-H.bcmap


BIN
static/assets/pdfjs/cmaps/B5-V.bcmap


BIN
static/assets/pdfjs/cmaps/B5pc-H.bcmap


BIN
static/assets/pdfjs/cmaps/B5pc-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS-EUC-H.bcmap


BIN
static/assets/pdfjs/cmaps/CNS-EUC-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS1-H.bcmap


BIN
static/assets/pdfjs/cmaps/CNS1-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS2-H.bcmap


+ 0 - 3
static/assets/pdfjs/cmaps/CNS2-V.bcmap

@@ -1,3 +0,0 @@
-àRCopyright 1990-2009 Adobe Systems Incorporated.
-All rights reserved.
-See ./LICENSEáCNS2-H

BIN
static/assets/pdfjs/cmaps/ETHK-B5-H.bcmap


BIN
static/assets/pdfjs/cmaps/ETHK-B5-V.bcmap


Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott