feat(slicer): bundle-aware preview slice for accurate gram estimates

The SliceModal's preview slice runs against unsliced project files to
  discover per-plate AMS slot consumption. Until now it always used
  slice_without_profiles — accurate slot mapping (a model property) but
  gram numbers were derived from the file's embedded process settings,
  which can drift from the triplet the real print will use.

  When the caller provides a bundle id + printer/process/filament preset
  names, get_preview_filaments now routes through slice_with_bundle so
  the preview's gram numbers match what the real print will produce.
  Cache key picks up a bundle-context fingerprint so different bundle
  picks on the same file occupy distinct entries.

  Backend:
  - slice_preview.get_preview_filaments: optional bundle_* params
  - library.py + archives.py: forward params via /filament-requirements

  Frontend (forward-compat for the upcoming SliceModal Bundle tier):
  - api.getLibraryFileFilamentRequirements / getArchiveFilamentRequirements
    accept an optional 4th-arg bundle context object
This commit is contained in:
maziggy
2026-05-06 09:10:35 +02:00
parent 060ba509da
commit 8a31397171
20 changed files with 1464 additions and 511 deletions
+33 -1
View File
@@ -3129,12 +3129,21 @@ async def _try_preview_slice_filaments(
plate_id: int,
file_path: Path,
request_id: str | None = None,
bundle_id: str | None = None,
printer_name: str | None = None,
process_name: str | None = None,
filament_names: list[str] | None = None,
) -> list[dict] | None:
"""Run a preview slice via the user's configured sidecar so the filament
list endpoint can return real per-plate filaments for unsliced project
files. Returns ``None`` on any failure — the caller falls back to the
painted-face heuristic. ``request_id`` flows through to the sidecar
for live progress on the SliceModal's inline spinner + toast."""
for live progress on the SliceModal's inline spinner + toast.
Bundle context (id + preset names) is forwarded to the preview helper
so the preview can mirror the real-print profile triplet when supplied
— see ``slice_preview.get_preview_filaments`` for the full contract.
"""
from backend.app.api.routes.settings import get_setting
from backend.app.services.slice_preview import get_preview_filaments
@@ -3162,6 +3171,10 @@ async def _try_preview_slice_filaments(
file_name=file_path.name,
api_url=api_url,
request_id=request_id,
bundle_id=bundle_id,
printer_name=printer_name,
process_name=process_name,
filament_names=filament_names,
)
@@ -3170,6 +3183,10 @@ async def get_filament_requirements(
archive_id: int,
plate_id: int | None = None,
request_id: str | None = None,
bundle_id: str | None = None,
printer_name: str | None = None,
process_name: str | None = None,
filament_names: str | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
@@ -3181,6 +3198,12 @@ async def get_filament_requirements(
Args:
archive_id: The archive ID
plate_id: Optional plate index to filter filaments for (for multi-plate files)
bundle_id / printer_name / process_name / filament_names: Optional
bundle context. When all four are supplied, the preview slice
(run for unsliced project files) uses ``slice_with_bundle``
against the named preset triplet instead of the embedded-
settings fallback. ``filament_names`` is comma- or semicolon-
separated.
"""
import defusedxml.ElementTree as ET
@@ -3284,6 +3307,11 @@ async def get_filament_requirements(
project_filaments = extract_project_filaments_from_3mf(zf)
used_slot_ids: set[int] = set()
if project_filaments and plate_id is not None:
parsed_filament_names: list[str] | None = None
if filament_names:
parsed_filament_names = [
n.strip() for n in filament_names.replace(";", ",").split(",") if n.strip()
] or None
preview = await _try_preview_slice_filaments(
db,
kind="archive",
@@ -3291,6 +3319,10 @@ async def get_filament_requirements(
plate_id=plate_id,
file_path=file_path,
request_id=request_id,
bundle_id=bundle_id,
printer_name=printer_name,
process_name=process_name,
filament_names=parsed_filament_names,
)
if preview is not None:
used_slot_ids = {f["slot_id"] for f in preview}
+37
View File
@@ -2363,6 +2363,10 @@ async def _try_preview_slice_filaments(
plate_id: int,
file_path: Path,
request_id: str | None = None,
bundle_id: str | None = None,
printer_name: str | None = None,
process_name: str | None = None,
filament_names: list[str] | None = None,
) -> list[dict] | None:
"""Run a preview slice via the user's configured sidecar. Same shape as
the matching helper in archives.py — see that module for rationale.
@@ -2370,6 +2374,13 @@ async def _try_preview_slice_filaments(
``request_id``: when supplied, forwarded to the sidecar so the
SliceModal's inline spinner + toast can poll the matching progress
endpoint and show "Generating G-code (45%)" for the preview as well.
``bundle_id`` / ``printer_name`` / ``process_name`` / ``filament_names``:
when all are supplied, the preview uses ``slice_with_bundle`` against
the named bundle's preset triplet so the preview's gram numbers reflect
the same profiles the real print will use. Partial context falls back
to the embedded-settings path so a half-completed Bundle-tier selection
in the modal doesn't error out.
"""
from backend.app.api.routes.settings import get_setting
from backend.app.services.slice_preview import get_preview_filaments
@@ -2398,6 +2409,10 @@ async def _try_preview_slice_filaments(
file_name=file_path.name,
api_url=api_url,
request_id=request_id,
bundle_id=bundle_id,
printer_name=printer_name,
process_name=process_name,
filament_names=filament_names,
)
@@ -2406,6 +2421,10 @@ async def get_library_file_filament_requirements(
file_id: int,
plate_id: int | None = None,
request_id: str | None = None,
bundle_id: str | None = None,
printer_name: str | None = None,
process_name: str | None = None,
filament_names: str | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
):
@@ -2417,6 +2436,12 @@ async def get_library_file_filament_requirements(
Args:
file_id: The library file ID
plate_id: Optional plate index to get filaments for a specific plate
bundle_id / printer_name / process_name / filament_names: Optional
bundle context. When all four are supplied, the preview slice
(run for unsliced project files) uses ``slice_with_bundle``
against the named preset triplet instead of the embedded-
settings fallback. ``filament_names`` is comma- or semicolon-
separated to mirror the slice route's multi-color form.
"""
import defusedxml.ElementTree as ET
@@ -2536,6 +2561,14 @@ async def get_library_file_filament_requirements(
project_filaments = extract_project_filaments_from_3mf(zf)
used_slot_ids: set[int] = set()
if project_filaments and plate_id is not None:
# Bundle context flows through optional query params so
# callers without a Bundle-tier selection (the common
# case) hit the same path as before.
parsed_filament_names: list[str] | None = None
if filament_names:
parsed_filament_names = [
n.strip() for n in filament_names.replace(";", ",").split(",") if n.strip()
] or None
preview = await _try_preview_slice_filaments(
db,
kind="library_file",
@@ -2543,6 +2576,10 @@ async def get_library_file_filament_requirements(
plate_id=plate_id,
file_path=file_path,
request_id=request_id,
bundle_id=bundle_id,
printer_name=printer_name,
process_name=process_name,
filament_names=parsed_filament_names,
)
if preview is not None:
used_slot_ids = {f["slot_id"] for f in preview}
+97 -25
View File
@@ -6,14 +6,26 @@ the ``/filament-requirements`` endpoint can read it directly. For unsliced
project files it doesn't exist yet — only the slicer can produce it, since
Bambu Studio applies its own pruning to painted-face data at slice time.
This module wraps the sidecar's ``slice_without_profiles`` call so the
endpoint can run a preview slice with the project's embedded settings,
parse the result's slice_info, and return the actual filament list. Results
are cached by ``(kind, source_id, plate_id, content_hash)`` so repeat
opens of the modal on the same plate are instant; LRU eviction keeps the
cache bounded. Hash invalidation handles in-place file replacement; no TTL
is used because preview-slice output is deterministic for a given file
content.
This module wraps the sidecar's slice call so the endpoint can run a preview
slice, parse the result's slice_info, and return the actual filament list.
Two slice modes are supported:
* "embedded settings" mode (default) — calls ``slice_without_profiles`` so
the slicer falls back on the file's own ``Metadata/project_settings.config``.
Used when the SliceModal opens before the user has picked a profile
triplet and we just want the slot-mapping (which is a model property,
independent of process settings).
* "bundle" mode — when the caller passes a bundle id + per-category preset
names, calls ``slice_with_bundle`` so the preview reflects the same
triplet the real print will use. More accurate gram numbers; same slot
mapping. Used after the SliceModal's Bundle tier resolves.
Results are cached by ``(kind, source_id, plate_id, content_hash, bundle_key)``
so different bundle picks on the same file don't collide and repeat opens
on the same plate + same bundle are instant. LRU eviction keeps the cache
bounded. Hash invalidation handles in-place file replacement; no TTL is
used because preview-slice output is deterministic for a given input.
"""
from __future__ import annotations
@@ -35,22 +47,46 @@ from backend.app.services.slicer_api import (
logger = logging.getLogger(__name__)
_PREVIEW_CACHE_MAX = 256
# Cache key includes a bundle-context fingerprint (or "" when no bundle was
# supplied) so a "preview without profiles" result and a "preview with
# bundle X" result for the same file/plate occupy distinct entries instead
# of clobbering each other.
_PreviewCacheKey = tuple[str, int, int, str, str]
# Cache values: list[dict] on success, [] on parsed-but-empty (slicer
# returned a 3MF without filament data for this plate — caching the negative
# avoids burning 30s+ per modal open on a known-bad input).
_preview_cache: OrderedDict[tuple[str, int, int, str], list[dict]] = OrderedDict()
# Per-key locks prevent N concurrent modal opens on the same (file, plate)
# from launching N redundant preview slices — only the first one runs, the
# rest wait and read from the cache. Locks are evicted alongside cache
# entries to keep the dict bounded; we do NOT cache transient sidecar
_preview_cache: OrderedDict[_PreviewCacheKey, list[dict]] = OrderedDict()
# Per-key locks prevent N concurrent modal opens on the same (file, plate,
# bundle) from launching N redundant preview slices — only the first one
# runs, the rest wait and read from the cache. Locks are evicted alongside
# cache entries to keep the dict bounded; we do NOT cache transient sidecar
# failures (network errors etc.) so those retry naturally on next request.
_preview_locks: dict[tuple[str, int, int, str], asyncio.Lock] = {}
_preview_locks: dict[_PreviewCacheKey, asyncio.Lock] = {}
def _content_hash(file_bytes: bytes) -> str:
return hashlib.sha256(file_bytes).hexdigest()[:16]
def _bundle_context_fingerprint(
bundle_id: str | None,
printer_name: str | None,
process_name: str | None,
filament_names: list[str] | None,
) -> str:
"""Derive a stable cache-key fragment for the bundle context. Empty
string when no bundle is supplied — preserves cache compatibility with
the no-bundle ("embedded settings") path so existing entries remain
valid. SHA-256 prefix keeps the key short while collision-resistant
enough for a 256-entry LRU.
"""
if not (bundle_id and printer_name and process_name and filament_names):
return ""
parts = [bundle_id, printer_name, process_name, *filament_names]
raw = "\x1f".join(parts).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:12]
async def get_preview_filaments(
*,
kind: str,
@@ -60,16 +96,33 @@ async def get_preview_filaments(
file_name: str,
api_url: str,
request_id: str | None = None,
bundle_id: str | None = None,
printer_name: str | None = None,
process_name: str | None = None,
filament_names: list[str] | None = None,
) -> list[dict] | None:
"""Run a preview slice for ``plate_id`` using the file's embedded settings,
parse the resulting slice_info, and return the per-plate filament list.
"""Run a preview slice for ``plate_id``, parse the resulting slice_info,
and return the per-plate filament list.
By default uses the file's embedded settings (``slice_without_profiles``).
When all four ``bundle_*`` params are provided, uses ``slice_with_bundle``
so the preview matches the profile triplet the real print will use —
same slot mapping, more-accurate gram numbers. Partial bundle context
(e.g. id without preset names) falls back to the embedded path rather
than failing, so an in-progress modal selection doesn't surface errors.
Returns ``None`` when the preview slice fails — the caller should fall
back to whatever heuristic it has (typically the project_filaments +
painted-face approach in ``threemf_tools``).
"""
h = _content_hash(file_bytes)
key = (kind, source_id, plate_id, h)
bundle_fp = _bundle_context_fingerprint(
bundle_id,
printer_name,
process_name,
filament_names,
)
key: _PreviewCacheKey = (kind, source_id, plate_id, h, bundle_fp)
cached = _preview_cache.get(key)
if cached is not None:
_preview_cache.move_to_end(key)
@@ -86,19 +139,38 @@ async def get_preview_filaments(
try:
async with SlicerApiService(base_url=api_url) as svc:
result = await svc.slice_without_profiles(
model_bytes=file_bytes,
model_filename=file_name,
plate=plate_id,
export_3mf=True,
request_id=request_id,
)
if bundle_fp:
# All four bundle params present (guaranteed non-None by
# _bundle_context_fingerprint returning non-empty);
# the type-checker can't see that, so assert for narrowing.
assert bundle_id and printer_name and process_name
assert filament_names is not None
result = await svc.slice_with_bundle(
model_bytes=file_bytes,
model_filename=file_name,
bundle_id=bundle_id,
printer_name=printer_name,
process_name=process_name,
filament_names=filament_names,
plate=plate_id,
export_3mf=True,
request_id=request_id,
)
else:
result = await svc.slice_without_profiles(
model_bytes=file_bytes,
model_filename=file_name,
plate=plate_id,
export_3mf=True,
request_id=request_id,
)
except SlicerApiError as e:
logger.warning(
"Preview slice failed for %s/%s plate %s: %s",
"Preview slice failed for %s/%s plate %s (bundle=%s): %s",
kind,
source_id,
plate_id,
bundle_id or "-",
e,
)
return None
@@ -72,7 +72,18 @@ class _StubService:
return False
async def slice_without_profiles(self, **kw):
self.calls.append(kw)
self.calls.append({"method": "slice_without_profiles", **kw})
if self.raise_exc is not None:
raise self.raise_exc
return SliceResult(
content=self.response_bytes or b"",
print_time_seconds=0,
filament_used_g=0.0,
filament_used_mm=0.0,
)
async def slice_with_bundle(self, **kw):
self.calls.append({"method": "slice_with_bundle", **kw})
if self.raise_exc is not None:
raise self.raise_exc
return SliceResult(
@@ -254,3 +265,174 @@ class TestGetPreviewFilaments:
assert len(slice_preview._preview_cache) == _PREVIEW_CACHE_MAX
# Lock dict is also pruned (no leak): same size as cache.
assert len(slice_preview._preview_locks) == _PREVIEW_CACHE_MAX
# ---------------------------------------------------------------------------
# Bundle-aware preview path — when bundle context is supplied, the preview
# routes through `slice_with_bundle` so its gram numbers reflect the same
# triplet the real print will use. Cache must distinguish between bundle
# picks so a fresh selection doesn't re-serve a prior preview's output.
# ---------------------------------------------------------------------------
class TestBundleAwarePreview:
@pytest.mark.asyncio
async def test_full_bundle_context_uses_slice_with_bundle(self):
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
stub = _StubService(response_bytes=body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
result = await get_preview_filaments(
kind="library_file",
source_id=42,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
bundle_id="abc123",
printer_name="# Bambu Lab H2D 0.4 nozzle",
process_name="# 0.20mm Standard @BBL H2D",
filament_names=["# Bambu PLA Basic @BBL H2D"],
)
assert result is not None
assert result[0]["slot_id"] == 1
# The bundle path engaged — slice_with_bundle was called, not the
# embedded-settings fallback.
assert len(stub.calls) == 1
assert stub.calls[0]["method"] == "slice_with_bundle"
assert stub.calls[0]["bundle_id"] == "abc123"
assert stub.calls[0]["filament_names"] == ["# Bambu PLA Basic @BBL H2D"]
@pytest.mark.asyncio
async def test_partial_bundle_context_falls_back_to_embedded(self):
# Modal-in-progress case: user picked a bundle id but hasn't yet
# picked the filament. Falling back to embedded settings keeps
# the preview's slot mapping fresh while gram numbers will firm
# up once the selection completes.
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
stub = _StubService(response_bytes=body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
await get_preview_filaments(
kind="library_file",
source_id=42,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
bundle_id="abc123",
printer_name="# Bambu Lab H2D 0.4 nozzle",
process_name="# 0.20mm Standard @BBL H2D",
# filament_names missing
)
assert len(stub.calls) == 1
assert stub.calls[0]["method"] == "slice_without_profiles"
@pytest.mark.asyncio
async def test_empty_filament_names_list_falls_back(self):
# Empty list (vs None) is treated as "incomplete context" since
# passing `[]` to slice_with_bundle would yield no
# --load-filaments arg and confuse the CLI.
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
stub = _StubService(response_bytes=body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
await get_preview_filaments(
kind="library_file",
source_id=42,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
bundle_id="abc123",
printer_name="P",
process_name="Q",
filament_names=[],
)
assert stub.calls[0]["method"] == "slice_without_profiles"
@pytest.mark.asyncio
async def test_cache_separates_bundle_picks(self):
# Same file/plate, two different bundle picks → two distinct cache
# entries → two slices run. Without the bundle-fingerprint cache key,
# the second call would erroneously serve the first's output.
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
stub = _StubService(response_bytes=body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
await get_preview_filaments(
kind="library_file",
source_id=42,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
bundle_id="bundleA",
printer_name="P",
process_name="Q",
filament_names=["F"],
)
await get_preview_filaments(
kind="library_file",
source_id=42,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
bundle_id="bundleB",
printer_name="P",
process_name="Q",
filament_names=["F"],
)
assert len(stub.calls) == 2
assert stub.calls[0]["bundle_id"] == "bundleA"
assert stub.calls[1]["bundle_id"] == "bundleB"
@pytest.mark.asyncio
async def test_cache_separates_bundle_vs_embedded(self):
# Same file/plate, one call without bundle and one with bundle →
# both must run. The embedded-settings cache entry must NOT be
# served as the bundle-picked result (gram numbers would be wrong).
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
stub = _StubService(response_bytes=body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
await get_preview_filaments(
kind="library_file",
source_id=42,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
)
await get_preview_filaments(
kind="library_file",
source_id=42,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
bundle_id="bundleA",
printer_name="P",
process_name="Q",
filament_names=["F"],
)
methods = [c["method"] for c in stub.calls]
assert methods == ["slice_without_profiles", "slice_with_bundle"]
@pytest.mark.asyncio
async def test_bundle_repeat_call_hits_cache(self):
# Sanity check that the new cache key is otherwise stable: same
# bundle pick on the same file → cache hit on second call.
body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
stub = _StubService(response_bytes=body)
with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
for _ in range(2):
await get_preview_filaments(
kind="library_file",
source_id=42,
plate_id=1,
file_bytes=b"abc",
file_name="x.3mf",
api_url="http://sidecar",
bundle_id="bundleA",
printer_name="P",
process_name="Q",
filament_names=["F"],
)
assert len(stub.calls) == 1
@@ -0,0 +1,214 @@
/**
* Tests for the SlicerBundlesPanel — Settings panel for managing
* BambuStudio Printer Preset Bundles (.bbscfg) on the slicer sidecar.
*
* Coverage:
* - Empty state when the sidecar has no bundles imported yet.
* - List rendering with summary line (process / filament counts).
* - Upload happy path → success toast + list invalidation.
* - Upload error → error toast.
* - Delete with confirmation → success toast + list invalidation.
* - Delete error → error toast.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { api } from '../../api/client';
import { SlicerBundlesPanel } from '../../components/SlicerBundlesPanel';
vi.mock('../../api/client', async () => {
const actual: typeof import('../../api/client') = await vi.importActual(
'../../api/client',
);
return {
...actual,
api: {
...actual.api,
listSlicerBundles: vi.fn(),
importSlicerBundle: vi.fn(),
deleteSlicerBundle: vi.fn(),
},
getAuthToken: vi.fn(() => null),
};
});
const SAMPLE_BUNDLE = {
id: 'abc123def456abcd',
printer_preset_name: '# Bambu Lab H2D 0.4 nozzle',
printer: ['# Bambu Lab H2D 0.4 nozzle'],
process: [
'# 0.20mm Standard @BBL H2D',
'# 0.16mm Standard @BBL H2D',
],
filament: [
'# Bambu PLA Basic @BBL H2D',
'# Bambu PETG HF @BBL H2D 0.4 nozzle',
'# Bambu ABS @BBL H2D',
],
version: '02.06.00.50',
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('SlicerBundlesPanel — empty state', () => {
it('renders the empty-state message when no bundles exist', async () => {
vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([]);
render(<SlicerBundlesPanel />);
await waitFor(() =>
expect(api.listSlicerBundles).toHaveBeenCalled(),
);
expect(
await screen.findByText(/no bundles imported yet/i),
).toBeInTheDocument();
});
});
describe('SlicerBundlesPanel — list rendering', () => {
it('renders bundle name + summary (process and filament counts)', async () => {
vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([SAMPLE_BUNDLE]);
render(<SlicerBundlesPanel />);
expect(
await screen.findByText('# Bambu Lab H2D 0.4 nozzle'),
).toBeInTheDocument();
// Summary should reflect 2 process + 3 filament from the fixture.
expect(
await screen.findByText(/2 process · 3 filament/i),
).toBeInTheDocument();
// Version suffix appended after the summary.
expect(screen.getByText(/v02\.06\.00\.50/)).toBeInTheDocument();
});
});
describe('SlicerBundlesPanel — upload flow', () => {
it('imports a selected file and refreshes the list on success', async () => {
// First listing call returns empty so the test can detect the post-import
// re-fetch (second call) returning the new bundle.
vi.mocked(api.listSlicerBundles)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([SAMPLE_BUNDLE]);
vi.mocked(api.importSlicerBundle).mockResolvedValueOnce(SAMPLE_BUNDLE);
const { container } = render(<SlicerBundlesPanel />);
// The file input is hidden (display: none for styling); grab it directly.
const fileInput = container.querySelector(
'input[type="file"]',
) as HTMLInputElement;
expect(fileInput).toBeTruthy();
const file = new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], 'H2D.bbscfg', {
type: 'application/zip',
});
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() =>
expect(api.importSlicerBundle).toHaveBeenCalledWith(file),
);
// After the success, the list call should fire a second time (cache
// invalidation by react-query).
await waitFor(() =>
expect(api.listSlicerBundles).toHaveBeenCalledTimes(2),
);
// The newly imported bundle should now be visible in the list.
expect(
await screen.findByText('# Bambu Lab H2D 0.4 nozzle'),
).toBeInTheDocument();
});
it('shows an error and does not refresh on upload failure', async () => {
vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([]);
vi.mocked(api.importSlicerBundle).mockRejectedValueOnce(
new Error('Bundle is missing bundle_structure.json'),
);
const { container } = render(<SlicerBundlesPanel />);
await waitFor(() => expect(api.listSlicerBundles).toHaveBeenCalled());
const fileInput = container.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const file = new File([new Uint8Array([0])], 'bad.bbscfg', {
type: 'application/zip',
});
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() =>
expect(api.importSlicerBundle).toHaveBeenCalled(),
);
// Listing should NOT be re-called on failure — only the initial load.
expect(api.listSlicerBundles).toHaveBeenCalledTimes(1);
// Empty state still showing.
expect(
screen.getByText(/no bundles imported yet/i),
).toBeInTheDocument();
});
});
describe('SlicerBundlesPanel — delete flow', () => {
it('deletes a bundle after confirmation and refreshes the list', async () => {
vi.mocked(api.listSlicerBundles)
.mockResolvedValueOnce([SAMPLE_BUNDLE])
.mockResolvedValueOnce([]);
vi.mocked(api.deleteSlicerBundle).mockResolvedValueOnce(undefined);
render(<SlicerBundlesPanel />);
// Wait for the bundle to render.
await screen.findByText('# Bambu Lab H2D 0.4 nozzle');
// Click the trash button (aria-label="Delete").
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
// ConfirmModal should appear with the bundle name in the message.
const confirmMessage = await screen.findByText(
/Slice requests that reference "# Bambu Lab H2D 0.4 nozzle" will fail/i,
);
expect(confirmMessage).toBeInTheDocument();
// The modal renders its own "Delete" button — there are now two buttons
// matching /delete/i. Click the one inside the dialog (last in document
// order, since the modal portal renders after the panel).
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
fireEvent.click(deleteButtons[deleteButtons.length - 1]);
await waitFor(() =>
expect(api.deleteSlicerBundle).toHaveBeenCalledWith(
'abc123def456abcd',
),
);
// Cache invalidation should re-fire the list query.
await waitFor(() =>
expect(api.listSlicerBundles).toHaveBeenCalledTimes(2),
);
});
it('keeps the bundle in the list when the user cancels the delete dialog', async () => {
vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([SAMPLE_BUNDLE]);
render(<SlicerBundlesPanel />);
await screen.findByText('# Bambu Lab H2D 0.4 nozzle');
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
// Cancel by clicking the "Cancel" button on the ConfirmModal.
const cancelButton = await screen.findByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
// Delete API never called, list never re-fetched.
expect(api.deleteSlicerBundle).not.toHaveBeenCalled();
expect(api.listSlicerBundles).toHaveBeenCalledTimes(1);
// Bundle still rendered.
expect(
screen.getByText('# Bambu Lab H2D 0.4 nozzle'),
).toBeInTheDocument();
});
});
+84 -2
View File
@@ -1171,6 +1171,20 @@ export interface SliceRequest {
export_3mf?: boolean;
}
// GET /api/v1/slicer/bundles — Printer Preset Bundles imported from
// BambuStudio's "File → Export → Export Preset Bundle" dialog. Each bundle
// is a .bbscfg zip the user uploads once per printer, after which the
// SliceModal can pick its inner presets by name (no re-upload per slice).
// Backend: backend/app/api/routes/slicer_presets.py — bundle endpoints.
export interface SlicerBundle {
id: string;
printer_preset_name: string;
printer: string[];
process: string[];
filament: string[];
version: string | null;
}
// GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
export type SlicerCloudStatus = 'ok' | 'not_authenticated' | 'expired' | 'unreachable';
export interface UnifiedPreset {
@@ -3776,10 +3790,31 @@ export const api = {
},
getArchivePlates: (archiveId: number) =>
request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
getArchiveFilamentRequirements: (archiveId: number, plateId?: number, requestId?: string) => {
getArchiveFilamentRequirements: (
archiveId: number,
plateId?: number,
requestId?: string,
// Optional bundle context: when supplied, the backend's preview slice
// (run for unsliced project files) uses slice_with_bundle so gram
// numbers reflect the same triplet the real print will use. All four
// fields must be set for the bundle path to engage; partial context
// falls back to the embedded-settings preview without erroring.
bundle?: {
bundle_id: string;
printer_name: string;
process_name: string;
filament_names: string[];
},
) => {
const qs = new URLSearchParams();
if (plateId !== undefined) qs.set('plate_id', String(plateId));
if (requestId) qs.set('request_id', requestId);
if (bundle) {
qs.set('bundle_id', bundle.bundle_id);
qs.set('printer_name', bundle.printer_name);
qs.set('process_name', bundle.process_name);
qs.set('filament_names', bundle.filament_names.join(';'));
}
return request<{
archive_id: number;
filename: string;
@@ -5141,10 +5176,28 @@ export const api = {
}),
getLibraryFilePlates: (fileId: number) =>
request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
getLibraryFileFilamentRequirements: (fileId: number, plateId?: number, requestId?: string) => {
getLibraryFileFilamentRequirements: (
fileId: number,
plateId?: number,
requestId?: string,
// Optional bundle context — see getArchiveFilamentRequirements above
// for the contract. Same shape so callers can share a builder helper.
bundle?: {
bundle_id: string;
printer_name: string;
process_name: string;
filament_names: string[];
},
) => {
const qs = new URLSearchParams();
if (plateId !== undefined) qs.set('plate_id', String(plateId));
if (requestId) qs.set('request_id', requestId);
if (bundle) {
qs.set('bundle_id', bundle.bundle_id);
qs.set('printer_name', bundle.printer_name);
qs.set('process_name', bundle.process_name);
qs.set('filament_names', bundle.filament_names.join(';'));
}
return request<{
file_id: number;
filename: string;
@@ -5269,6 +5322,35 @@ export const api = {
getSlicerPresets: () =>
request<UnifiedPresetsResponse>('/slicer/presets'),
// Slicer Bundles (.bbscfg) — Printer Preset Bundles imported from BambuStudio.
// Settings → Slicer Bundles uploads/lists/deletes; the SliceModal picks
// presets by name from a chosen bundle (separate follow-up).
listSlicerBundles: () =>
request<SlicerBundle[]>('/slicer/bundles'),
importSlicerBundle: (file: File) => {
// The /slicer/bundles upload accepts multipart with field name "file"
// (matches the FastAPI route's UploadFile parameter). Bypass `request`
// because it always JSON-stringifies the body — multipart needs the
// browser to set the boundary in the Content-Type header.
const fd = new FormData();
fd.append('file', file);
return fetch(`${API_BASE}/slicer/bundles`, {
method: 'POST',
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
body: fd,
}).then(async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json() as Promise<SlicerBundle>;
});
},
deleteSlicerBundle: (bundleId: string) =>
request<void>(`/slicer/bundles/${encodeURIComponent(bundleId)}`, {
method: 'DELETE',
}),
// Local Presets (OrcaSlicer imports)
getLocalPresets: () =>
request<LocalPresetsResponse>('/local-presets/'),
@@ -0,0 +1,198 @@
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Package, Trash2, Upload } from 'lucide-react';
import { api, type SlicerBundle } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
import { useToast } from '../contexts/ToastContext';
// Settings panel for managing BambuStudio "Printer Preset Bundles"
// (.bbscfg) on the slicer sidecar. Sits below the slicer-API URL panel
// in SettingsPage and is hidden when use_slicer_api is off — without a
// configured sidecar there's nowhere to upload bundles to.
//
// Backend wiring: backend/app/api/routes/slicer_presets.py exposes
// /api/v1/slicer/bundles (POST/GET/DELETE). The list call returns []
// when no sidecar is configured, so an empty render is the natural
// "first-run" state for users who haven't enabled the sidecar yet.
export function SlicerBundlesPanel() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingDelete, setPendingDelete] = useState<SlicerBundle | null>(null);
const { data: bundles, isLoading } = useQuery({
queryKey: ['slicer-bundles'],
queryFn: api.listSlicerBundles,
});
const importMutation = useMutation({
mutationFn: (file: File) => api.importSlicerBundle(file),
onSuccess: (bundle) => {
queryClient.invalidateQueries({ queryKey: ['slicer-bundles'] });
showToast(
t('settings.slicerBundles.uploadSuccess', {
defaultValue: 'Imported {{name}}',
name: bundle.printer_preset_name,
}),
'success',
);
// Reset the file input so the same file can be re-selected after a
// failed retry. (Without this, a second click on the same file
// doesn't trigger onChange and looks like the panel is broken.)
if (fileInputRef.current) fileInputRef.current.value = '';
},
onError: (err: Error) => {
showToast(
t('settings.slicerBundles.uploadError', {
defaultValue: 'Bundle upload failed: {{message}}',
message: err.message,
}),
'error',
);
if (fileInputRef.current) fileInputRef.current.value = '';
},
});
const deleteMutation = useMutation({
mutationFn: (bundleId: string) => api.deleteSlicerBundle(bundleId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['slicer-bundles'] });
setPendingDelete(null);
showToast(
t('settings.slicerBundles.deleteSuccess', {
defaultValue: 'Bundle removed',
}),
'success',
);
},
onError: (err: Error) => {
showToast(
t('settings.slicerBundles.deleteError', {
defaultValue: 'Bundle delete failed: {{message}}',
message: err.message,
}),
'error',
);
setPendingDelete(null);
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
importMutation.mutate(file);
};
return (
<Card>
<CardHeader>
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Package className="w-4 h-4 text-bambu-green" />
{t('settings.slicerBundles.title', { defaultValue: 'Slicer Bundles' })}
</h3>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-bambu-gray">
{t('settings.slicerBundles.description', {
defaultValue:
'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
})}
</p>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".bbscfg,.zip,application/zip"
onChange={handleFileChange}
className="hidden"
disabled={importMutation.isPending}
/>
<Button
variant="primary"
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
>
{importMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('settings.slicerBundles.uploading', { defaultValue: 'Uploading…' })}
</>
) : (
<>
<Upload className="w-4 h-4" />
{t('settings.slicerBundles.uploadButton', { defaultValue: 'Upload bundle' })}
</>
)}
</Button>
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-bambu-gray">
<Loader2 className="w-4 h-4 animate-spin" />
{t('settings.slicerBundles.loading', { defaultValue: 'Loading bundles…' })}
</div>
) : bundles && bundles.length > 0 ? (
<ul className="divide-y divide-bambu-dark-tertiary border border-bambu-dark-tertiary rounded-lg">
{bundles.map((b) => (
<li
key={b.id}
className="flex items-center justify-between px-3 py-2 hover:bg-bambu-dark-tertiary/30"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-white truncate">{b.printer_preset_name}</p>
<p className="text-xs text-bambu-gray mt-0.5">
{t('settings.slicerBundles.summary', {
defaultValue:
'{{processCount}} process · {{filamentCount}} filament presets',
processCount: b.process.length,
filamentCount: b.filament.length,
})}
{b.version && ` · v${b.version}`}
</p>
</div>
<button
type="button"
onClick={() => setPendingDelete(b)}
disabled={deleteMutation.isPending}
className="ml-3 p-1.5 text-bambu-gray hover:text-red-400 disabled:opacity-40"
aria-label={t('settings.slicerBundles.delete', { defaultValue: 'Delete' })}
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
) : (
<p className="text-sm text-bambu-gray italic">
{t('settings.slicerBundles.empty', {
defaultValue: 'No bundles imported yet.',
})}
</p>
)}
</CardContent>
{pendingDelete && (
<ConfirmModal
title={t('settings.slicerBundles.confirmDeleteTitle', {
defaultValue: 'Remove this bundle?',
})}
message={t('settings.slicerBundles.confirmDeleteMessage', {
defaultValue:
'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
name: pendingDelete.printer_preset_name,
})}
confirmText={t('common.delete', { defaultValue: 'Delete' })}
variant="danger"
isLoading={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate(pendingDelete.id)}
onCancel={() => setPendingDelete(null)}
/>
)}
</Card>
);
}
+16
View File
@@ -1852,6 +1852,22 @@ export default {
orcaslicerApiUrl: 'OrcaSlicer Sidecar-URL',
bambuStudioApiUrl: 'Bambu Studio Sidecar-URL',
slicerApiUrlDescription: 'URL des Slicer-API-Sidecar-Containers. Leer lassen, um die SLICER_API_URL- bzw. BAMBU_STUDIO_API_URL-Umgebungsvariablen zu nutzen.',
slicerBundles: {
title: 'Slicer-Bundles',
description: 'Importiere ein Drucker-Voreinstellungspaket (.bbscfg), das aus BambuStudio exportiert wurde (Datei → Exportieren → Voreinstellungspaket exportieren → "Drucker-Voreinstellungspaket"). Nach dem Import können Slice-Anfragen Voreinstellungen aus dem Bundle per Name auswählen, ohne das JSON-Profil-Trio erneut hochzuladen.',
uploadButton: 'Bundle hochladen',
uploading: 'Hochladen…',
loading: 'Bundles werden geladen…',
empty: 'Noch keine Bundles importiert.',
summary: '{{processCount}} Prozess · {{filamentCount}} Filament-Voreinstellungen',
delete: 'Löschen',
uploadSuccess: '{{name}} importiert',
uploadError: 'Bundle-Upload fehlgeschlagen: {{message}}',
deleteSuccess: 'Bundle entfernt',
deleteError: 'Löschen des Bundles fehlgeschlagen: {{message}}',
confirmDeleteTitle: 'Dieses Bundle entfernen?',
confirmDeleteMessage: 'Slice-Anfragen, die "{{name}}" referenzieren, schlagen fehl, bis das Bundle erneut importiert wird.',
},
externalCameras: 'Externe Kameras',
costTracking: 'Kostenverfolgung',
printsOnly: 'Nur Drucke',
+16
View File
@@ -1855,6 +1855,22 @@ export default {
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
slicerBundles: {
title: 'Slicer Bundles',
description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
uploadButton: 'Upload bundle',
uploading: 'Uploading…',
loading: 'Loading bundles…',
empty: 'No bundles imported yet.',
summary: '{{processCount}} process · {{filamentCount}} filament presets',
delete: 'Delete',
uploadSuccess: 'Imported {{name}}',
uploadError: 'Bundle upload failed: {{message}}',
deleteSuccess: 'Bundle removed',
deleteError: 'Bundle delete failed: {{message}}',
confirmDeleteTitle: 'Remove this bundle?',
confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
},
externalCameras: 'External Cameras',
costTracking: 'Cost Tracking',
printsOnly: 'Prints Only',
+16
View File
@@ -1809,6 +1809,22 @@ export default {
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
slicerBundles: {
title: 'Slicer Bundles',
description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
uploadButton: 'Upload bundle',
uploading: 'Uploading…',
loading: 'Loading bundles…',
empty: 'No bundles imported yet.',
summary: '{{processCount}} process · {{filamentCount}} filament presets',
delete: 'Delete',
uploadSuccess: 'Imported {{name}}',
uploadError: 'Bundle upload failed: {{message}}',
deleteSuccess: 'Bundle removed',
deleteError: 'Bundle delete failed: {{message}}',
confirmDeleteTitle: 'Remove this bundle?',
confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
},
externalCameras: 'Caméras externes',
costTracking: 'Suivi des coûts',
printsOnly: 'Impressions uniquement',
+16
View File
@@ -1809,6 +1809,22 @@ export default {
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
slicerBundles: {
title: 'Slicer Bundles',
description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
uploadButton: 'Upload bundle',
uploading: 'Uploading…',
loading: 'Loading bundles…',
empty: 'No bundles imported yet.',
summary: '{{processCount}} process · {{filamentCount}} filament presets',
delete: 'Delete',
uploadSuccess: 'Imported {{name}}',
uploadError: 'Bundle upload failed: {{message}}',
deleteSuccess: 'Bundle removed',
deleteError: 'Bundle delete failed: {{message}}',
confirmDeleteTitle: 'Remove this bundle?',
confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
},
externalCameras: 'Camere esterne',
costTracking: 'Tracciamento costi',
printsOnly: 'Solo stampe',
+16
View File
@@ -1851,6 +1851,22 @@ export default {
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
slicerBundles: {
title: 'Slicer Bundles',
description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
uploadButton: 'Upload bundle',
uploading: 'Uploading…',
loading: 'Loading bundles…',
empty: 'No bundles imported yet.',
summary: '{{processCount}} process · {{filamentCount}} filament presets',
delete: 'Delete',
uploadSuccess: 'Imported {{name}}',
uploadError: 'Bundle upload failed: {{message}}',
deleteSuccess: 'Bundle removed',
deleteError: 'Bundle delete failed: {{message}}',
confirmDeleteTitle: 'Remove this bundle?',
confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
},
externalCameras: '外部カメラ',
costTracking: 'コスト追跡',
printsOnly: '印刷のみ',
+16
View File
@@ -1809,6 +1809,22 @@ export default {
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
slicerBundles: {
title: 'Slicer Bundles',
description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
uploadButton: 'Upload bundle',
uploading: 'Uploading…',
loading: 'Loading bundles…',
empty: 'No bundles imported yet.',
summary: '{{processCount}} process · {{filamentCount}} filament presets',
delete: 'Delete',
uploadSuccess: 'Imported {{name}}',
uploadError: 'Bundle upload failed: {{message}}',
deleteSuccess: 'Bundle removed',
deleteError: 'Bundle delete failed: {{message}}',
confirmDeleteTitle: 'Remove this bundle?',
confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
},
externalCameras: 'Câmeras Externas',
costTracking: 'Rastreamento de Custos',
printsOnly: 'Apenas Impressões',
+16
View File
@@ -1853,6 +1853,22 @@ export default {
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
slicerBundles: {
title: 'Slicer Bundles',
description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
uploadButton: 'Upload bundle',
uploading: 'Uploading…',
loading: 'Loading bundles…',
empty: 'No bundles imported yet.',
summary: '{{processCount}} process · {{filamentCount}} filament presets',
delete: 'Delete',
uploadSuccess: 'Imported {{name}}',
uploadError: 'Bundle upload failed: {{message}}',
deleteSuccess: 'Bundle removed',
deleteError: 'Bundle delete failed: {{message}}',
confirmDeleteTitle: 'Remove this bundle?',
confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
},
externalCameras: '外部摄像头',
costTracking: '成本追踪',
printsOnly: '仅打印',
+16
View File
@@ -1853,6 +1853,22 @@ export default {
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
slicerBundles: {
title: 'Slicer Bundles',
description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
uploadButton: 'Upload bundle',
uploading: 'Uploading…',
loading: 'Loading bundles…',
empty: 'No bundles imported yet.',
summary: '{{processCount}} process · {{filamentCount}} filament presets',
delete: 'Delete',
uploadSuccess: 'Imported {{name}}',
uploadError: 'Bundle upload failed: {{message}}',
deleteSuccess: 'Bundle removed',
deleteError: 'Bundle delete failed: {{message}}',
confirmDeleteTitle: 'Remove this bundle?',
confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
},
externalCameras: '外部攝影機',
costTracking: '成本追蹤',
printsOnly: '僅列印',
+7
View File
@@ -8,6 +8,7 @@ import { formatDateOnly } from '../utils/date';
import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
import { CameraTokensSection } from './CameraTokensPage';
import { Collapsible } from '../components/Collapsible';
import { Button } from '../components/Button';
@@ -4262,6 +4263,12 @@ export function SettingsPage() {
</CardContent>
</Card>
{/* Slicer Preset Bundles — only meaningful when the sidecar is in use,
since uploads / lists round-trip through it. Hide it entirely when
use_slicer_api is off so the Settings page doesn't show a panel that
can't do anything. */}
{(localSettings.use_slicer_api ?? false) && <SlicerBundlesPanel />}
{/* Auto-Drying */}
<Card>
<CardHeader>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
<script type="module" crossorigin src="./assets/index-DA6QDhrM.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-N3WJn-iA.css">
<script type="module" crossorigin src="./assets/index-B2mJSviN.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CzFVl3Rw.css">
</head>
<body>
<div id="root"></div>