feat(slice): cross-printer re-slicing — drop the gate, the banner, and the dead plumbing

Step 0 empirical test on 2026-05-20 disproved the "CLI cannot re-slice a
  3MF for a different printer" assumption: feeding an 18-color H2D-bound
  Trent900.3mf to the X1C bundle via /slice produces valid X1C G-code in
  1.8s, with bed (256x256), kinematics, nozzle count, machine_start_gcode,
  and bed_exclude_area all coming from the target bundle.

  - SliceModal: drop !printerMismatch from isReady; remove the banner and
    the sourcePrinterModel / printerProfileName / printerMismatch state
    entirely. Cross-printer slicing is now indistinguishable from a normal
    slice; the picker already shows the target printer.
  - Remove slice.printerMismatch from all 8 locales.
  - API cleanup: drop source_printer_model from /library/files/{id}/plates
    and /archives/{id}/plates responses, drop the field from
    frontend/src/types/plates.ts (PlateMetadata + LibraryFilePlatesResponse),
    delete extract_source_printer_model_from_3mf from threemf_tools.py and
    its 6 unit tests. Zero remaining consumers.
  - i18n discipline cleanup in SliceModal.tsx (same drop): strip every
    inline English defaultValue / positional fallback from t() calls (22
    sites). Add slice.bundle / slice.bundleNone / slice.bundleAllRequired
    to all 8 locales — they had no entry in any locale file and were being
    served from the inline English fallback for every non-English user.
  - Tests: rewrite the mismatch-warning test to assert "no banner, Slice
    enabled" when models differ (regression guard); delete 2 obsolete
    tests covering gate states that no longer exist.
This commit is contained in:
maziggy
2026-05-20 12:21:33 +02:00
parent 787ce9e146
commit ed27b27adb
18 changed files with 127 additions and 340 deletions
+3
View File
@@ -7,6 +7,9 @@ All notable changes to Bambuddy will be documented in this file.
### Added
- **Currency: Belize Dollars (BZD) added to the Settings → Cost currency dropdown (#1454, requested by @PLGuerraDesigns)** — Reporter accurately tracks 3D-printing filament costs in his local currency and BZD wasn't selectable, forcing a manual 2:1 mental conversion from USD. Added `BZD: 'BZ$'` to `frontend/src/utils/currency.ts` next to MXN (Americas dollar-prefix grouping); `getCurrencySymbol('BZD')` returns `'BZ$'` and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added in `frontend/src/__tests__/utils/currency.test.ts` covering the symbol lookup and presence in SUPPORTED_CURRENCIES; entry-count assertion bumped to 30 so any future additions/removals are caught immediately. 14 currency tests green; frontend build clean.
### Changed
- **Slice modal: cross-printer 3MFs now re-slice transparently, banner removed, modal fully i18n'd** — Previous behaviour disabled the Slice button whenever the source 3MF's bound printer model didn't match the user's picked printer profile, on the theory that the slicer CLI "cannot re-slice a 3MF for a different printer" and would silently fall back to embedded settings to produce a wrong-printer file. Step 0 empirical test on 2026-05-20 disproved that: an 18-color H2D-bound `Trent900.3mf` sliced via the X1C bundle (`POST /slice` with `bundle=cb…X1C, printerName=# Bambu Lab X1 Carbon 0.4 nozzle`) produced 2.3 MB of genuinely X1C-compatible G-code in 1.8 s — `printer_model` overridden to `Bambu Lab X1 Carbon`, `printable_area` to 256×256 (X1C bed, not H2D's 350×320), `printable_height` 250 (vs 325), `bed_exclude_area` populated with X1C's 18×28 corner zone, `nozzle_diameter` single 0.4 (vs H2D's dual `0.4,0.4`), and the full X1C `machine_start_gcode` sequence baked in. The sidecar takes printer / process / first-N filament names from the picked bundle and only inherits embedded values for unused trailing slots — bed size, kinematics, start sequence all come from the target. **Behavioural change**: dropped `!printerMismatch` from the SliceModal `isReady` predicate so the Slice button stays enabled when models differ. The amber banner was first softened to an info message, then removed entirely — re-slicing across printers is now just a normal slice, the picker UI already shows which printer was picked, no second confirmation needed. **Dead-code removal (same drop)**: with no banner, the `source_printer_model` field on the `/library/files/{id}/plates` and `/archives/{id}/plates` responses had zero consumers; the `extract_source_printer_model_from_3mf` helper in `threemf_tools.py` (which opened the 3MF zip and read `Metadata/project_settings.config` on every plate request) had zero callers. Removed both response keys, both backend extractions, both `threemf_tools` imports, the helper itself, its 6 unit tests, the `source_printer_model` field from `frontend/src/types/plates.ts` (PlateMetadata + LibraryFilePlatesResponse), and 2 obsolete SliceModal tests that exercised the now-impossible matched-printer / legacy-archive paths. **i18n discipline cleanup (same drop, per [[feedback_no_followups]] + [[feedback_translate_dont_fallback]])**: every t() callsite in SliceModal.tsx had an inline English `defaultValue:` or positional-second-arg English fallback — 22 sites in total. With 8 locales shipped, those fallbacks are dead weight at best, and an actual i18n-violation when the key is missing because non-English users would silently see English. Audit found 3 keys (`slice.bundle`, `slice.bundleNone`, `slice.bundleAllRequired`) that had **no** corresponding entry in any locale file — they were being served from the inline English fallback exclusively, meaning every non-English user was already seeing those three labels in English. Added all 3 to all 8 locales with real translations, then stripped the English fallback from every t() call in SliceModal.tsx. The `slice.printerMismatch` key was removed from all 8 locales (banner is gone). **Why this matters**: a recurring pain point for users importing MakerWorld project files where the original creator's printer often differs from the user's; previously they had to round-trip through BambuStudio's "convert project" flow to re-export. Now Bambuddy re-slices in-place with no UI friction. **Tests**: the existing SliceModal "shows mismatch warning AND disables Slice" test was rewritten to assert "does not surface any cross-printer banner AND keeps Slice enabled when models differ" (regression guard against the gate being re-added); 2 obsolete tests deleted. 32 SliceModal tests green (was 34, -2 dead tests); 49 threemf_tools tests green (was 55, -6 helper tests); 24 plates-route tests green; frontend build clean; backend ruff clean; i18n parity check passes 4858 keys × 8 locales (net +2 vs pre-fix: +3 bundle keys, -1 printerMismatch).
### Fixed
- **Scheduler: queue items with `force_color_match` filament overrides now produce a correct AMS mapping at dispatch (#1437, fixed by external PR #1440 from @Person2099)** — Contributor's own bug report and fix. He had a queue item with `filament_overrides: [{slot_id: 1, type: "PLA", color: "#CBC6B8", force_color_match: true}]` and `ams_mapping: null`, expecting Bambuddy to translate the override into a slot mapping at dispatch time. Instead the scheduler dispatched with `ams_mapping: null` and the P1S fell back to type-only AMS matching, picking the wrong-colour slot. **Two-layer root cause** he traced end-to-end. **(1) `backend/app/services/filament_requirements.py:69`**: `extract_filament_requirements(file_path, plate_id=None)` fell through to `_collect_filaments(root, filaments)` whose XPath `./filament` only matches direct children of `<config>`. Modern BambuStudio 3MFs wrap filaments inside `<plate>` elements, so this XPath returned `[]` on every modern multi-plate 3MF when no specific plate was targeted — which is the standard scheduler call shape for queue items without a pinned plate. The downstream "no AMS mapping" cascade ALL flowed from this empty filament_reqs result. Fix walks `<plate>` elements first, dedupes by `slot_id` (highest `used_grams` wins on ties — sane because BambuStudio slots are project-wide and the entry that extruded the most is the most representative for AMS planning), and preserves the old `./filament` XPath as a fallback when no `<plate>` elements are present, so legacy 3MFs continue to parse unchanged. **(2) `backend/app/services/print_scheduler.py:792` — defence in depth**: even with (1) in place, edge cases exist where `_get_filament_requirements` can still return None (3MF missing `slice_info.config` entirely, IO failure during ZIP extraction, etc). New `_build_override_direct_mapping(force_overrides, status)` helper kicks in at exactly that moment when `force_color_match` overrides are present — builds the requirement list directly from the overrides (`slot_id`, `type`, `color`, empty `tray_info_idx`) and delegates to the existing `_match_filaments_to_slots()` cascade against the printer's loaded AMS state. Wrong-colour slot credit via the cascade's type-only fallback is impossible-by-construction because the upstream `_get_missing_force_color_slots()` printer-eligibility gate at `:590` already requires an exact `(type, normalised colour)` pair to be loaded *before* the printer is even considered for the job, so by the time `_build_override_direct_mapping` runs the exact match is guaranteed in the loaded set and the cascade's `exact_match` branch wins (colour normalisation is identical on both sides — `tray_color.replace("#", "").lower()[:6]`). Pref-only overrides (without `force_color_match`) intentionally do NOT trigger the fallback — they keep the pre-PR "no mapping, printer picks defaults" behaviour, so the new fallback is strictly opt-in via `force_color_match: true`. **Backwards-compat triple-checked**: legacy 3MF format unchanged (preserved fallback path); `plate_id != None` branch untouched (entire fix is inside the `else` of `if plate_id is not None`); `filament_overrides=None` / `[]` / no-force-entries all preserve the existing `return None` path; malformed JSON in `filament_overrides` is caught by the existing try/except, logged, and still returns None. **Tests** (22 new across two files; all pass on `pytest -n 30`): `backend/tests/unit/services/test_filament_requirements.py` — 4 tests covering the `plate_id=None` modern-format path, multi-plate collection, slot-dedup-by-highest-grams, and single-plate-modern-format. `backend/tests/unit/test_scheduler_force_color_ams_fallback.py` — 18 tests across `TestBuildOverrideDirectMapping` (single override matches AMS slot, empty AMS returns None, no colour match still produces a mapping length, multi-override produces multi-element mapping, external spool match yields global_tray_id 254, `tray_info_idx` is cleared) and `TestComputeAmsMappingFallback` (fallback used when reqs empty + force overrides present, fallback NOT used when no force_color flag, fallback NOT used when overrides None, normal path still used when reqs available, printer-status-unavailable returns None gracefully). 5079 backend tests + ruff + frontend build all clean post-merge; #1457/#1459/#1440 verified non-interacting (different services, different code paths, different timings). External-PR-checklist (per [[feedback_pr_changelog_required]]): contributor doesn't add CHANGELOG, this entry added by Martin post-merge.
-9
View File
@@ -33,7 +33,6 @@ from backend.app.utils.http import build_content_disposition
from backend.app.utils.threemf_tools import (
extract_nozzle_mapping_from_3mf,
extract_project_filaments_from_3mf,
extract_source_printer_model_from_3mf,
)
logger = logging.getLogger(__name__)
@@ -3316,20 +3315,12 @@ async def get_archive_plates(
# to preview gcode — the viewer, skip-objects — can gate on this instead of
# 404-ing on every plate request.
has_gcode = bool(gcode_files)
# SliceModal pre-check signal — see library.py for rationale.
source_printer_model: str | None = None
try:
with zipfile.ZipFile(file_path, "r") as zf:
source_printer_model = extract_source_printer_model_from_3mf(zf)
except (zipfile.BadZipFile, OSError):
pass
return {
"archive_id": archive_id,
"filename": archive.filename,
"plates": plates,
"is_multi_plate": len(plates) > 1,
"has_gcode": has_gcode,
"source_printer_model": source_printer_model,
}
-12
View File
@@ -67,7 +67,6 @@ from backend.app.services.stl_thumbnail import generate_stl_thumbnail
from backend.app.utils.threemf_tools import (
extract_nozzle_mapping_from_3mf,
extract_project_filaments_from_3mf,
extract_source_printer_model_from_3mf,
)
logger = logging.getLogger(__name__)
@@ -2419,22 +2418,11 @@ async def get_library_file_plates(
except Exception as e:
logger.warning("Failed to parse plates from library file %s: %s", file_id, e)
# SliceModal pre-check signal: the source 3MF's bound printer model. The
# CLI cannot re-slice for a different printer; surface this so the modal
# can warn the user before they pick a mismatched profile.
source_printer_model: str | None = None
try:
with zipfile.ZipFile(file_path, "r") as zf:
source_printer_model = extract_source_printer_model_from_3mf(zf)
except (zipfile.BadZipFile, OSError):
pass
return {
"file_id": file_id,
"filename": lib_file.filename,
"plates": plates,
"is_multi_plate": len(plates) > 1,
"source_printer_model": source_printer_model,
}
-32
View File
@@ -609,38 +609,6 @@ def inject_gcode_into_3mf(
return None
def extract_source_printer_model_from_3mf(zf: zipfile.ZipFile) -> str | None:
"""Source 3MF's bound printer model from ``Metadata/project_settings.config``.
Returns e.g. ``"Bambu Lab A1"`` when the project was built for an A1, or
``None`` when the file lacks the metadata or the field is absent. The
SliceModal uses this to warn the user before slicing if the chosen
printer profile targets a different model — the slicer CLI rejects
cross-printer slicing with rc=-16 and the result, when the strip + load
fallback masks it, is a misleadingly-tagged archive.
"""
if "Metadata/project_settings.config" not in zf.namelist():
return None
try:
proj = json.loads(zf.read("Metadata/project_settings.config").decode())
except (ValueError, OSError):
return None
if not isinstance(proj, dict):
return None
model = proj.get("printer_model")
if isinstance(model, str) and model.strip():
return model.strip()
# Some older Bambu Studio exports stored the model under
# ``printer_settings_id`` (e.g. "Bambu Lab A1 0.4 nozzle"); strip the
# nozzle suffix to get the canonical model name. Best-effort — if the
# field doesn't follow the convention we leave it as-is.
settings_id = proj.get("printer_settings_id")
if isinstance(settings_id, str) and settings_id.strip():
# Drop trailing " 0.4 nozzle" / " 0.2 nozzle" / etc.
return re.sub(r"\s+0\.\d+\s+nozzle$", "", settings_id.strip())
return None
def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
"""Project-wide AMS slot config from ``Metadata/project_settings.config``.
-41
View File
@@ -13,7 +13,6 @@ from backend.app.utils.threemf_tools import (
extract_filament_usage_from_3mf,
extract_plate_extruder_set_from_3mf,
extract_project_filaments_from_3mf,
extract_source_printer_model_from_3mf,
get_cumulative_usage_at_layer,
mm_to_grams,
parse_gcode_layer_filament_usage,
@@ -646,43 +645,3 @@ class TestExtractPlateExtruderSetFrom3mf:
# Top-level metadata still works; missing component model file
# is silently skipped without crashing.
assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
# ---------------------------------------------------------------------------
# Tests for extract_source_printer_model_from_3mf — feeds the SliceModal's
# pre-slice mismatch warning. The CLI cannot re-slice a 3MF for a different
# printer, so warning the user up-front avoids producing wrong-printer output.
# ---------------------------------------------------------------------------
class TestExtractSourcePrinterModelFrom3mf:
def test_returns_none_when_project_settings_missing(self):
with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
assert extract_source_printer_model_from_3mf(zf) is None
def test_reads_printer_model_directly(self):
proj = {"printer_model": "Bambu Lab H2D"}
with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
assert extract_source_printer_model_from_3mf(zf) == "Bambu Lab H2D"
def test_falls_back_to_printer_settings_id_with_nozzle_strip(self):
# Older Bambu Studio exports stored the printer under
# printer_settings_id, often with a "0.4 nozzle" suffix the helper
# must strip to match the canonical model name.
proj = {"printer_settings_id": "Bambu Lab A1 0.4 nozzle"}
with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
assert extract_source_printer_model_from_3mf(zf) == "Bambu Lab A1"
def test_settings_id_without_nozzle_suffix_returned_as_is(self):
proj = {"printer_settings_id": "Bambu Lab P1S"}
with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
assert extract_source_printer_model_from_3mf(zf) == "Bambu Lab P1S"
def test_corrupt_json_returns_none_no_exception(self):
with _make_3mf_with({"Metadata/project_settings.config": b"{broken"}) as zf:
assert extract_source_printer_model_from_3mf(zf) is None
def test_empty_string_treated_as_missing(self):
proj = {"printer_model": "", "printer_settings_id": ""}
with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
assert extract_source_printer_model_from_3mf(zf) is None
@@ -764,18 +764,16 @@ describe('SliceModal', () => {
});
});
// Pre-slice printer-mismatch warning. The slicer CLI cannot re-slice a
// 3MF for a different printer model — clicking Slice in that state
// would silently fall back to the embedded settings and produce a
// wrong-printer file. The modal surfaces a warning and disables Slice
// when the source's source_printer_model doesn't match the picked
// printer profile.
it('shows a printer-mismatch warning and disables Slice when models differ', async () => {
// Cross-printer re-slicing is a normal, supported operation as of
// 2026-05-20 (Step 0 empirical test: sidecar overrides printer / process
// / bed / kinematics from the picked bundle, producing valid target-
// printer G-code). No banner, no warning — the picker UI already shows
// which printer the user picked, and that's enough.
it('does not surface any cross-printer banner and keeps Slice enabled when models differ', async () => {
mockApi.getLibraryFilePlates.mockResolvedValue({
file_id: 100,
filename: 'A1Original.3mf',
is_multi_plate: false,
source_printer_model: 'A1',
plates: [
{
index: 1,
@@ -807,86 +805,7 @@ describe('SliceModal', () => {
expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined(),
);
// Warning banner is visible (role=alert) and references both models.
const alert = await screen.findByRole('alert');
expect(alert.textContent).toMatch(/A1/);
expect(alert.textContent).toMatch(/X1 Carbon/);
// Slice button is disabled while the warning is up.
const sliceButton = screen.getByRole('button', { name: /^Slice$/ }) as HTMLButtonElement;
expect(sliceButton.disabled).toBe(true);
});
it('keeps Slice enabled when the picked profile matches the source printer model', async () => {
mockApi.getLibraryFilePlates.mockResolvedValue({
file_id: 100,
filename: 'X1COriginal.3mf',
is_multi_plate: false,
source_printer_model: 'X1 Carbon',
plates: [
{
index: 1,
name: 'Plate 1',
objects: [],
has_thumbnail: false,
thumbnail_url: null,
print_time_seconds: null,
filament_used_grams: null,
filaments: [],
},
],
});
mockApi.getSlicerPresets.mockResolvedValue(makeUnified({
standard: {
printer: [{ id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }],
process: [{ id: '0.20mm Standard', name: '0.20mm Standard', source: 'standard' }],
filament: [{ id: 'Bambu PLA Basic', name: 'Bambu PLA Basic', source: 'standard' }],
},
}));
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'X1COriginal.3mf' },
onClose: vi.fn(),
});
await waitFor(() =>
expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined(),
);
// No mismatch warning.
expect(screen.queryByRole('alert')).toBeNull();
const sliceButton = screen.getByRole('button', { name: /^Slice$/ }) as HTMLButtonElement;
expect(sliceButton.disabled).toBe(false);
});
it('keeps Slice enabled when source_printer_model is unknown (legacy archives)', async () => {
// Older 3MFs without project_settings.printer_model fall through to
// no-warning — we don't have enough info to gate the user.
mockApi.getLibraryFilePlates.mockResolvedValue({
file_id: 100,
filename: 'Legacy.3mf',
is_multi_plate: false,
source_printer_model: null,
plates: [
{
index: 1,
name: 'Plate 1',
objects: [],
has_thumbnail: false,
thumbnail_url: null,
print_time_seconds: null,
filament_used_grams: null,
filaments: [],
},
],
});
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Legacy.3mf' },
onClose: vi.fn(),
});
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
// No banner, no alert — re-slicing across printers is just a normal slice now.
expect(screen.queryByRole('alert')).toBeNull();
const sliceButton = screen.getByRole('button', { name: /^Slice$/ }) as HTMLButtonElement;
expect(sliceButton.disabled).toBe(false);
+31 -82
View File
@@ -189,7 +189,7 @@ function FilamentAnalysisSpinner({
} else {
showPersistentToast(
toastId,
t('slice.previewToast', 'Analyzing {{name}} — {{elapsed}}', {
t('slice.previewToast', {
name: prettyName,
elapsed: elapsedStr,
}),
@@ -206,7 +206,7 @@ function FilamentAnalysisSpinner({
const inlineLabel =
stage && typeof percent === 'number' && percent > 0
? `${stage} (${Math.min(100, Math.max(0, Math.round(percent)))}%)`
: t('slice.analyzingPlateFilaments', 'Analyzing plate filaments…');
: t('slice.analyzingPlateFilaments');
return (
<div className="flex flex-col gap-1 text-bambu-gray text-sm py-2">
<div className="flex items-center gap-2">
@@ -425,7 +425,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
bundleFilamentNames.length === 0 ||
bundleFilamentNames.some((n) => n == null)
) {
throw new Error(t('slice.bundleAllRequired', 'Bundle process and every filament slot must be picked'));
throw new Error(t('slice.bundleAllRequired'));
}
const bundleSpec: SliceBundleSpec = {
bundle_id: selectedBundle.id,
@@ -447,7 +447,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
filamentPresets.length === 0 ||
filamentPresets.some((r) => r == null)
) {
throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
throw new Error(t('slice.allPresetsRequired'));
}
body = {
printer_preset: printerPreset,
@@ -480,55 +480,20 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
},
});
// Pre-slice compatibility check: the slicer CLI (both OrcaSlicer and
// BambuStudio) cannot re-slice a 3MF for a printer different from the one
// it was originally bound to — the cross-printer "convert project" flow
// is desktop-Studio only. If we can match the source's printer model to a
// SliceModal-known model and the user's chosen printer profile names a
// different model, surface a warning before they click Slice.
//
// For bundle mode, the bundle's printer_preset_name plays the same role
// as the picked PresetRef's resolved name in preset mode.
const sourcePrinterModel = platesQuery.data?.source_printer_model ?? null;
const printerProfileName = isBundleMode
? selectedBundle?.printer_preset_name.replace(/^# /, '') ?? null
: printerPreset
? presetsQuery.data?.[printerPreset.source].printer.find((p) => p.id === printerPreset.id)?.name
: null;
// Profile names follow `<model> <nozzle> nozzle` (e.g. "Bambu Lab H2D 0.4
// nozzle"). The CLI compat check uses the model prefix; substring match
// catches both standard and locally-imported user-named profiles that
// include the model in the name. Cloud presets with arbitrary names
// (e.g. "My Custom X1C") fall through to no-warning, which is a
// reasonable default — the user picked it knowingly.
const printerMismatch =
!!sourcePrinterModel &&
!!printerProfileName &&
!printerProfileName.toLowerCase().includes(sourcePrinterModel.toLowerCase());
// Slice button stays disabled until *all* of these hold:
// - the preview slice / embedded-metadata read has succeeded so we know
// the per-plate filament slot list is final
// (filamentReqsQuery.isSuccess). Without this gate the synthetic
// single-slot fallback would auto-enable the button on opaque
// defaults, before the slicer has even returned the real slot map.
// - printer + process picked, every filament slot has a profile (the
// auto-pick fills these once filamentSlots arrives)
// - no printer-mismatch warning is up (clicking would silently fall
// back to embedded settings and produce a wrong-printer file)
// Slice button stays disabled until the preview slice / embedded-metadata
// read has succeeded (filamentReqsQuery.isSuccess) and every filament slot
// has a picked profile.
const isReady = isBundleMode
? selectedBundle != null &&
bundleProcessName != null &&
filamentReqsQuery.isSuccess &&
bundleFilamentNames.length > 0 &&
bundleFilamentNames.every((n) => n != null) &&
!printerMismatch
bundleFilamentNames.every((n) => n != null)
: printerPreset != null &&
processPreset != null &&
filamentReqsQuery.isSuccess &&
filamentPresets.length > 0 &&
filamentPresets.every((r) => r != null) &&
!printerMismatch;
filamentPresets.every((r) => r != null);
const isEnqueuing = enqueueMutation.isPending;
// Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
@@ -563,7 +528,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
<div className="min-w-0">
<h3 className="text-white font-medium flex items-center gap-2">
<Cog className="w-4 h-4" />
{t('slice.title', 'Slice model')}
{t('slice.title')}
</h3>
<p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
{source.filename}
@@ -576,7 +541,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
onClick={onClose}
disabled={isEnqueuing}
className="flex-shrink-0 text-bambu-gray hover:text-white transition-colors disabled:opacity-50"
aria-label={t('common.close', 'Close')}
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
@@ -590,7 +555,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
{(platesQuery.isLoading || presetsQuery.isLoading) && (
<div className="flex items-center gap-2 text-bambu-gray text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
{t('slice.loadingPresets', 'Loading presets…')}
{t('slice.loadingPresets')}
</div>
)}
@@ -624,7 +589,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
{!isBundleMode && (
<>
<PresetDropdown
label={t('slice.printer', 'Printer profile')}
label={t('slice.printer')}
slot="printer"
data={presetsQuery.data}
value={printerPreset}
@@ -632,7 +597,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
disabled={isEnqueuing}
/>
<PresetDropdown
label={t('slice.process', 'Process profile')}
label={t('slice.process')}
slot="process"
data={presetsQuery.data}
value={processPreset}
@@ -648,14 +613,14 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
verify the printer they're slicing for. */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('slice.printer', 'Printer profile')}
{t('slice.printer')}
</label>
<div className="px-3 py-2 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary text-white text-sm">
{selectedBundle.printer_preset_name}
</div>
</div>
<BundleStringDropdown
label={t('slice.process', 'Process profile')}
label={t('slice.process')}
options={selectedBundle.process}
value={bundleProcessName}
onChange={setBundleProcessName}
@@ -691,12 +656,11 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
? t('slice.filamentSlot', {
index: idx + 1,
type: slot.type,
defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
})
: t('slice.filament', 'Filament profile');
: t('slice.filament');
const label = isUsed
? baseLabel
: `${baseLabel} ${t('slice.notUsedByPlate', '— not used by this plate')}`;
: `${baseLabel} ${t('slice.notUsedByPlate')}`;
return (
<BundleStringDropdown
key={`bundle-filament-${idx}`}
@@ -732,12 +696,11 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
? t('slice.filamentSlot', {
index: idx + 1,
type: slot.type,
defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
})
: t('slice.filament', 'Filament profile');
: t('slice.filament');
const label = isUsed
? baseLabel
: `${baseLabel} ${t('slice.notUsedByPlate', '— not used by this plate')}`;
: `${baseLabel} ${t('slice.notUsedByPlate')}`;
return (
<PresetDropdown
key={`filament-${idx}`}
@@ -763,20 +726,6 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
</>
)}
{printerMismatch && (
<div
className="text-sm text-amber-200 bg-amber-900/20 border border-amber-700/40 rounded p-2"
role="alert"
>
{t('slice.printerMismatch', {
source: sourcePrinterModel,
target: printerProfileName,
defaultValue:
'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
})}
</div>
)}
{errorMessage && (
<div className="text-sm text-red-400 bg-red-900/20 border border-red-900/40 rounded p-2" role="alert">
{errorMessage}
@@ -792,7 +741,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
disabled={isEnqueuing}
className="px-3 py-1.5 text-sm rounded-md border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray transition-colors disabled:opacity-50"
>
{t('common.cancel', 'Cancel')}
{t('common.cancel')}
</button>
<button
type="button"
@@ -806,10 +755,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
{isEnqueuing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('slice.enqueuing', 'Submitting slice job…')}
{t('slice.enqueuing')}
</>
) : (
t('slice.action', 'Slice')
t('slice.action')
)}
</button>
</div>
@@ -885,7 +834,7 @@ function BedTypeDropdown({
return (
<label className="block">
<span className="block text-xs text-bambu-gray mb-1">
{t('slice.bedType.label', 'Build plate')}
{t('slice.bedType.label')}
</span>
<select
value={value ?? ''}
@@ -893,7 +842,7 @@ function BedTypeDropdown({
disabled={disabled}
className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
>
<option value="">{t('slice.bedType.auto', 'Auto (use process preset)')}</option>
<option value="">{t('slice.bedType.auto')}</option>
{BED_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey, opt.fallback)}
@@ -959,8 +908,8 @@ function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchCo
>
<option value="">
{totalEntries === 0
? t('slice.noPresetsForSlot', 'No presets available')
: t('slice.selectPreset', '— Select a preset —')}
? t('slice.noPresetsForSlot')
: t('slice.selectPreset')}
</option>
{sections.map((section) => (
<optgroup key={section.tierLabel} label={section.tierLabel}>
@@ -992,7 +941,7 @@ function BundlePicker({ bundles, selectedId, onChange, disabled }: BundlePickerP
<label className="block">
<span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
<Package className="w-3.5 h-3.5" />
{t('slice.bundle', 'Slicer bundle')}
{t('slice.bundle')}
</span>
<select
value={selectedId ?? ''}
@@ -1001,7 +950,7 @@ function BundlePicker({ bundles, selectedId, onChange, disabled }: BundlePickerP
className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
>
<option value="">
{t('slice.bundleNone', '— None (pick presets individually) —')}
{t('slice.bundleNone')}
</option>
{bundles.map((b) => (
<option key={b.id} value={b.id}>
@@ -1056,8 +1005,8 @@ function BundleStringDropdown({
>
<option value="">
{options.length === 0
? t('slice.noPresetsForSlot', 'No presets available')
: t('slice.selectPreset', '— Select a preset —')}
? t('slice.noPresetsForSlot')
: t('slice.selectPreset')}
</option>
{options.map((name) => (
<option key={name} value={name}>
+3 -1
View File
@@ -3442,10 +3442,12 @@ export default {
previewToast: '{{name}} wird analysiert — {{elapsed}}',
previewWithProgress: '{{name}} wird analysiert — {{stage}} ({{percent}}%) — {{elapsed}}',
notUsedByPlate: '— wird von dieser Platte nicht verwendet',
printerMismatch: 'Dieses 3MF wurde für {{source}} gesliced, du hast aber {{target}} ausgewählt. Der Slicer-CLI kann ein 3MF nicht für einen anderen Drucker neu slicen — öffne die Quelle in Bambu Studio, ändere den Drucker und exportiere neu.',
noPresetsForSlot: 'Keine Profile verfügbar',
presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.',
allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
bundle: 'Slicer-Bundle',
bundleNone: '— Keines (Profile einzeln auswählen) —',
bundleAllRequired: 'Bundle-Prozess und jeder Filament-Slot müssen ausgewählt sein',
enqueuing: 'Slice-Auftrag wird übermittelt…',
queued: 'In Warteschlange…',
failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
+3 -1
View File
@@ -3445,10 +3445,12 @@ export default {
previewToast: 'Analyzing {{name}} — {{elapsed}}',
previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
notUsedByPlate: '— not used by this plate',
printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
noPresetsForSlot: 'No presets available',
presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
allPresetsRequired: 'All presets must be selected',
bundle: 'Slicer bundle',
bundleNone: '— None (pick presets individually) —',
bundleAllRequired: 'Bundle process and every filament slot must be picked',
enqueuing: 'Submitting slice job…',
queued: 'Queued…',
failed: 'Slicing failed. Check the slicer sidecar logs.',
+3 -1
View File
@@ -3431,10 +3431,12 @@ export default {
previewToast: 'Analyse de {{name}} – {{elapsed}}',
previewWithProgress: 'Analyse de {{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
notUsedByPlate: '— non utilisé par cette plaque',
printerMismatch: 'Ce 3MF a été découpé pour {{source}}, mais vous avez choisi {{target}}. La CLI du slicer ne peut pas re-découper un 3MF pour une autre imprimante — ouvrez la source dans Bambu Studio, changez d\'imprimante et réexportez.',
noPresetsForSlot: 'Aucun préréglage disponible',
presetsLoadFailed: 'Échec du chargement des préréglages. Ouvrez Paramètres → Profils pour les importer d\'abord.',
allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
bundle: 'Pack de découpage',
bundleNone: '— Aucun (choisir les préréglages individuellement) —',
bundleAllRequired: 'Le processus du pack et chaque emplacement de filament doivent être choisis',
enqueuing: 'Envoi du travail de découpage…',
queued: 'En file d\'attente…',
failed: 'Échec du découpage. Vérifiez les journaux du sidecar.',
+3 -1
View File
@@ -3430,10 +3430,12 @@ export default {
previewToast: 'Analisi di {{name}} – {{elapsed}}',
previewWithProgress: 'Analisi di {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
notUsedByPlate: '— non usato da questo piano',
printerMismatch: 'Questo 3MF è stato sezionato per {{source}}, ma hai scelto {{target}}. La CLI del slicer non può ri-sezionare un 3MF per una stampante diversa — apri la sorgente in Bambu Studio, cambia stampante e ri-esporta.',
noPresetsForSlot: 'Nessun preset disponibile',
presetsLoadFailed: 'Caricamento preset fallito. Apri Impostazioni → Profili per importarli prima.',
allPresetsRequired: 'Tutti i preset devono essere selezionati',
bundle: 'Bundle slicer',
bundleNone: '— Nessuno (scegli i preset singolarmente) —',
bundleAllRequired: 'Devi scegliere il processo del bundle e ogni slot filamento',
enqueuing: 'Invio lavoro di slicing…',
queued: 'In coda…',
failed: 'Slicing fallito. Controlla i log del sidecar.',
+3 -1
View File
@@ -3442,10 +3442,12 @@ export default {
previewToast: '{{name}}を分析中 – {{elapsed}}',
previewWithProgress: '{{name}}を分析中 – {{stage}} ({{percent}}%) – {{elapsed}}',
notUsedByPlate: '— このプレートでは使用しない',
printerMismatch: 'この3MFは{{source}}用にスライスされていますが、{{target}}を選択しました。スライサーCLIは異なるプリンター用に3MFを再スライスできません — Bambu Studioでソースを開き、プリンターを変更して再エクスポートしてください。',
noPresetsForSlot: 'プリセットなし',
presetsLoadFailed: 'プリセットの読み込みに失敗。先に設定 → プロファイルからインポートしてください。',
allPresetsRequired: 'すべてのプリセットを選択する必要があります',
bundle: 'スライサーバンドル',
bundleNone: '— なし(プリセットを個別に選択)—',
bundleAllRequired: 'バンドルのプロセスとすべてのフィラメントスロットを選択してください',
enqueuing: 'スライスジョブを送信中…',
queued: '待機中…',
failed: 'スライスに失敗。サイドカーのログを確認してください。',
+3 -1
View File
@@ -3430,10 +3430,12 @@ export default {
previewToast: 'Analisando {{name}} – {{elapsed}}',
previewWithProgress: 'Analisando {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
notUsedByPlate: '— não usado por esta mesa',
printerMismatch: 'Este 3MF foi fatiado para {{source}}, mas você escolheu {{target}}. A CLI do fatiador não pode re-fatiar um 3MF para outra impressora — abra a origem no Bambu Studio, troque a impressora e re-exporte.',
noPresetsForSlot: 'Nenhuma predefinição disponível',
presetsLoadFailed: 'Falha ao carregar predefinições. Abra Configurações → Perfis para importá-las primeiro.',
allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
bundle: 'Pacote do fatiador',
bundleNone: '— Nenhum (escolher predefinições individualmente) —',
bundleAllRequired: 'O processo do pacote e cada slot de filamento devem ser escolhidos',
enqueuing: 'Enviando trabalho de fatiamento…',
queued: 'Na fila…',
failed: 'Falha ao fatiar. Verifique os logs do sidecar.',
+3 -1
View File
@@ -3430,10 +3430,12 @@ export default {
previewToast: '分析 {{name}} — {{elapsed}}',
previewWithProgress: '分析 {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
notUsedByPlate: '— 此打印板未使用',
printerMismatch: '此 3MF 是为 {{source}} 切片的,但您选择了 {{target}}。切片器 CLI 无法为不同的打印机重新切片 3MF — 请在 Bambu Studio 中打开源文件,更改打印机并重新导出。',
noPresetsForSlot: '无可用预设',
presetsLoadFailed: '加载预设失败。请先打开设置 → 配置文件以导入。',
allPresetsRequired: '必须选择所有预设',
bundle: '切片器套装',
bundleNone: '— 无(单独选择预设)—',
bundleAllRequired: '必须选择套装的工艺和每个耗材槽',
enqueuing: '提交切片任务中…',
queued: '已排队…',
failed: '切片失败。请检查切片器 sidecar 日志。',
+3 -1
View File
@@ -3430,10 +3430,12 @@ export default {
previewToast: '分析 {{name}} — {{elapsed}}',
previewWithProgress: '分析 {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
notUsedByPlate: '— 此列印板未使用',
printerMismatch: '此 3MF 是為 {{source}} 切片的,但您選擇了 {{target}}。切片器 CLI 無法為不同的印表機重新切片 3MF — 請在 Bambu Studio 中開啟原始檔案,變更印表機並重新匯出。',
noPresetsForSlot: '無可用預設',
presetsLoadFailed: '載入預設失敗。請先開啟設定 → 設定檔以匯入。',
allPresetsRequired: '必須選擇所有預設',
bundle: '切片器套裝',
bundleNone: '— 無(單獨選擇預設)—',
bundleAllRequired: '必須選擇套裝的製程和每個耗材槽',
enqueuing: '提交切片任務中…',
queued: '已排隊…',
failed: '切片失敗。請檢查切片器 sidecar 日誌。',
-6
View File
@@ -33,11 +33,6 @@ export interface ArchivePlatesResponse {
plates: PlateMetadata[];
is_multi_plate: boolean;
has_gcode?: boolean;
// Bound printer model from the source 3MF's project_settings.config (e.g.
// "Bambu Lab A1"). Used by the SliceModal to warn before slicing if the
// user picks a profile for a different printer — the slicer CLI can't
// convert a 3MF across printer models.
source_printer_model?: string | null;
}
export interface LibraryFilePlatesResponse {
@@ -45,7 +40,6 @@ export interface LibraryFilePlatesResponse {
filename: string;
plates: PlateMetadata[];
is_multi_plate: boolean;
source_printer_model?: string | null;
}
export interface ViewerPlateSelectionState {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BIzFPmFB.js"></script>
<script type="module" crossorigin src="/assets/index-CHKJFwmW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
</head>
<body>