diff --git a/CHANGELOG.md b/CHANGELOG.md index b01d2197f..805088b19 100644 --- a/CHANGELOG.md +++ b/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 " (#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. diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 4d41351d6..9bf941015 100644 --- a/backend/app/api/routes/library.py +++ b/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, diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py index ec4300299..1a4dc9d00 100644 --- a/backend/app/schemas/library.py +++ b/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 ============ diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index cc782efa3..9661c42cd 100644 --- a/backend/app/services/archive.py +++ b/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"" +_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 ``...`` body left out. + + The metadata scans only want ```` 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 ````: BambuStudio writes its + MakerWorld metadata after ````. 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: username @@ -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: Value or diff --git a/backend/app/services/mesh_combine.py b/backend/app/services/mesh_combine.py new file mode 100644 index 000000000..c6e06dfcf --- /dev/null +++ b/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 ```` and every copy of it is +a ```` 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 = ( + '\n' + '' + '' + '' + '' + "" +) + +_MODEL_REL = ( + '' +) +# The OPC package thumbnail relationship: how a 3MF names its preview image. +_THUMBNAIL_REL = ( + f'' +) + + +def _rels_xml(with_thumbnail: bool) -> str: + return ( + '\n' + '' + + _MODEL_REL + + (_THUMBNAIL_REL if with_thumbnail else "") + + "" + ) + + +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("&", "&").replace('"', """).replace("<", "<").replace(">", ">") + + +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( + ( + '\n' + f'' + 'Bambuddy' + "" + ).encode() + ) + for idx, part in enumerate(loaded): + out.write(f''.encode()) + _write_rows(out, part.vertices, lambda x, y, z: f'') + out.write(b"") + _write_rows(out, part.faces, lambda a, b, c: f'') + out.write(b"") + out.write(b"") + out.write( + "".join( + f'' + for owner, (ox, oy) in zip(owners, offsets, strict=True) + ).encode("utf-8") + ) + out.write(b"") + + +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() diff --git a/backend/app/services/stl_thumbnail.py b/backend/app/services/stl_thumbnail.py index a883935ee..01c624e6a 100644 --- a/backend/app/services/stl_thumbnail.py +++ b/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 + # 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 + + return plt, LightSource, Poly3DCollection + + +def render_mesh_png(mesh, size: int = 256, label: str = "mesh") -> bytes: + """Render ``mesh`` (a ``trimesh.Trimesh``) to PNG bytes. + + 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. """ - # 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) + import io + import trimesh + + 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: - # Must precede the matplotlib import — MPLCONFIGDIR is read at - # matplotlib import time, not on subsequent attribute access. - _configure_matplotlib_cache() + _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) - import matplotlib - import trimesh + # 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 - # 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 + # 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 - # 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) - - # 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) + # 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 diff --git a/backend/tests/integration/test_library_combine.py b/backend/tests/integration/test_library_combine.py new file mode 100644 index 000000000..17973e635 --- /dev/null +++ b/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'") + 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"] diff --git a/backend/tests/integration/test_ownership_permissions.py b/backend/tests/integration/test_ownership_permissions.py index 79daf7ab9..b07471338 100644 --- a/backend/tests/integration/test_ownership_permissions.py +++ b/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 diff --git a/backend/tests/unit/services/test_archive_service.py b/backend/tests/unit/services/test_archive_service.py index e6df6d405..b992b0ba8 100644 --- a/backend/tests/unit/services/test_archive_service.py +++ b/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 ```` 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 ````, where + BambuStudio writes its MakerWorld fields, must still be found.""" + + MESH = ( + '' + '' + "" + ) + + def _model(self) -> str: + return ( + '\n' + 'Before & mesh' + f'{self.MESH}' + f'{self.MESH}' + '' + 'After build' + 'https://makerworld.com/en/models/130726' + "" + ) + + 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 "`` + 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.""" diff --git a/backend/tests/unit/test_mesh_combine.py b/backend/tests/unit/test_mesh_combine.py new file mode 100644 index 000000000..a0778f718 --- /dev/null +++ b/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' 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( + + + , + ); +} + +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).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).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)); + }); +}); diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx index 126c6bfa3..70f2e07ba 100644 --- a/frontend/src/__tests__/components/SliceModal.test.tsx +++ b/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, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6ecd4dc5e..f257a660a 100644 --- a/frontend/src/api/client.ts +++ b/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('/library/files/combine', { + method: 'POST', + body: JSON.stringify({ items, filename, folder_id: folderId }), + }), addLibraryFilesToQueue: (fileIds: number[]) => request('/library/files/add-to-queue', { method: 'POST', diff --git a/frontend/src/components/CombineFilesModal.tsx b/frontend/src/components/CombineFilesModal.tsx new file mode 100644 index 000000000..a1c94822c --- /dev/null +++ b/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>(() => + Object.fromEntries(files.map((f) => [f.id, 1])), + ); + const [name, setName] = useState(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 ( +
+
!combineMutation.isPending && onClose()} /> +
+
+

+ + {t('fileManager.combine.title')} +

+ +
+ +

{t('fileManager.combine.description')}

+ +
+ +
+ 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" + /> + .3mf +
+
+ +
+
+ {t('fileManager.combine.model')} + {t('fileManager.combine.copies')} +
+
    + {files.map((f) => ( +
  • + + {f.filename} + + { + 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" + /> +
  • + ))} +
+
+ +
+
+ {tooMany + ? t('fileManager.combine.tooMany', { max: MAX_OBJECTS }) + : t('fileManager.combine.total', { count: totalObjects })} +
+ {canSlice && ( + + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/SliceModal.tsx b/frontend/src/components/SliceModal.tsx index b09d83413..30e19a27e 100644 --- a/frontend/src/components/SliceModal.tsx +++ b/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 diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 0d43939b7..b819bae18 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 73c537633..414ade01b 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index abe7d3243..c9df7e1f1 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 15d81cc39..1e34040ed 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 803553642..7a0833eb9 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 700870a34..4a9e8bacd 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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: 'バージョンとしてグループ化', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 31dfb7498..79c3e7356 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/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: '버전으로 그룹화', diff --git a/frontend/src/i18n/locales/nl.ts b/frontend/src/i18n/locales/nl.ts index c0bf40ac2..925cfce58 100644 --- a/frontend/src/i18n/locales/nl.ts +++ b/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', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index c740097bf..414602f05 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 801e05445..339f08ee3 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/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: 'Сгруппировать как версии', diff --git a/frontend/src/i18n/locales/sv.ts b/frontend/src/i18n/locales/sv.ts index 788734e18..c5d16c878 100644 --- a/frontend/src/i18n/locales/sv.ts +++ b/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', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index eee4a1747..87625fbb1 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index b06ce9440..8d185180a 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/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: 'Згрупувати як версії', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 19abf8fbb..d39d0d488 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/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: '归为版本组', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 165718bc1..fb7b12e5e 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/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: '歸為版本群組', diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index 6241eff1b..8268b1cb4 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/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(null); const [sliceFile, setSliceFile] = useState(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(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() { {t('fileManager.variants.groupAction')} )} + {selectedStlFiles.length >= 1 && ( + + )}