Selaa lähdekoodia

Merge branch 'dev' into feature/queue-keep-warm-chamber-history

MartinNYHC 3 viikkoa sitten
vanhempi
sitoutus
37b0e25a2b
100 muutettua tiedostoa jossa 9081 lisäystä ja 1640 poistoa
  1. 4 1
      .gitignore
  2. 3 4
      .pre-commit-config.yaml
  3. 1 0
      CHANGELOG.md
  4. 0 9
      Dockerfile
  5. 0 5
      Dockerfile.test
  6. 44 12
      backend/app/api/routes/auth.py
  7. 11 0
      backend/app/api/routes/groups.py
  8. 26 7
      backend/app/api/routes/library.py
  9. 65 0
      backend/app/api/routes/slicer_presets.py
  10. 34 1
      backend/app/api/routes/users.py
  11. 9 7
      backend/app/api/routes/webhook.py
  12. 146 10
      backend/app/core/auth.py
  13. 6 0
      backend/app/core/permissions.py
  14. 5 63
      backend/app/main.py
  15. 16 0
      backend/app/schemas/auth.py
  16. 14 0
      backend/app/schemas/settings.py
  17. 14 1
      backend/app/schemas/slicer.py
  18. 187 1
      backend/app/services/bambu_mqtt.py
  19. 154 34
      backend/app/services/print_scheduler.py
  20. 6 0
      backend/app/services/printer_manager.py
  21. 109 0
      backend/app/services/process_overrides.py
  22. 182 6
      backend/app/services/slicer_api.py
  23. 35 0
      backend/app/utils/printer_models.py
  24. 47 0
      backend/app/utils/threemf_tools.py
  25. 240 0
      backend/tests/integration/test_api_key_owner_authority_1894.py
  26. 131 7
      backend/tests/integration/test_auth_api.py
  27. 0 346
      backend/tests/integration/test_gcode_viewer.py
  28. 42 1
      backend/tests/integration/test_library_slice_api.py
  29. 9 1
      backend/tests/integration/test_queue_creation_attribution.py
  30. 3 2
      backend/tests/integration/test_security_headers.py
  31. 180 0
      backend/tests/integration/test_users_slim_1894.py
  32. 1 0
      backend/tests/unit/services/test_printer_manager.py
  33. 1 1
      backend/tests/unit/test_launcher_shutdown_config.py
  34. 272 0
      backend/tests/unit/test_nozzle_rack_mapping_2800.py
  35. 79 0
      backend/tests/unit/test_process_overrides.py
  36. 0 2
      backend/tests/unit/test_route_auth_coverage.py
  37. 155 4
      backend/tests/unit/test_scheduler_auto_drying.py
  38. 213 0
      backend/tests/unit/test_scheduler_drying_plate_hold_2801.py
  39. 97 0
      backend/tests/unit/test_slicer_preset_values.py
  40. 245 0
      backend/tests/unit/test_slicer_upload_size_rejection.py
  41. 1 1
      backend/tests/unit/test_systemd_backup_paths.py
  42. 4 1
      frontend/eslint.config.js
  43. 0 23
      frontend/package-lock.json
  44. 0 1
      frontend/package.json
  45. 4 0
      frontend/scripts/check-i18n-parity.mjs
  46. 142 0
      frontend/scripts/generate-slicer-schema.mjs
  47. 4 57
      frontend/src/__tests__/components/ModelViewerModal.test.tsx
  48. 293 21
      frontend/src/__tests__/components/SliceModal.test.tsx
  49. 480 0
      frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx
  50. 1 0
      frontend/src/__tests__/mocks/handlers.ts
  51. 100 81
      frontend/src/__tests__/pages/GCodeViewerPage.test.tsx
  52. 73 0
      frontend/src/__tests__/pages/StatsPageUserFilter1894.test.tsx
  53. 466 0
      frontend/src/__tests__/utils/gcodeToolpath.test.ts
  54. 58 0
      frontend/src/__tests__/utils/slicerPrinterMatch.test.ts
  55. 53 0
      frontend/src/__tests__/utils/slicerStepGating.test.ts
  56. 137 0
      frontend/src/__tests__/utils/slicerToggle.test.ts
  57. 64 1
      frontend/src/api/client.ts
  58. 2 2
      frontend/src/components/FileManagerModal.tsx
  59. 592 0
      frontend/src/components/GcodeToolpathViewer.tsx
  60. 0 276
      frontend/src/components/GcodeViewer.tsx
  61. 141 27
      frontend/src/components/ModelViewer.tsx
  62. 13 37
      frontend/src/components/ModelViewerModal.tsx
  63. 213 85
      frontend/src/components/SliceModal.tsx
  64. 672 0
      frontend/src/components/SlicerSettingsPanel.tsx
  65. 0 0
      frontend/src/data/slicer/process-schema.json
  66. 0 0
      frontend/src/data/slicer/process-toggle-rules.json
  67. 0 0
      frontend/src/data/slicer/process-ui-tree.json
  68. 37 0
      frontend/src/hooks/useIsWideLayout.ts
  69. 62 13
      frontend/src/i18n/locales/de.ts
  70. 62 13
      frontend/src/i18n/locales/en.ts
  71. 62 13
      frontend/src/i18n/locales/es.ts
  72. 62 13
      frontend/src/i18n/locales/fr.ts
  73. 62 13
      frontend/src/i18n/locales/it.ts
  74. 62 13
      frontend/src/i18n/locales/ja.ts
  75. 62 13
      frontend/src/i18n/locales/ko.ts
  76. 62 13
      frontend/src/i18n/locales/pt-BR.ts
  77. 62 13
      frontend/src/i18n/locales/ru.ts
  78. 62 13
      frontend/src/i18n/locales/tr.ts
  79. 62 13
      frontend/src/i18n/locales/uk.ts
  80. 62 13
      frontend/src/i18n/locales/zh-CN.ts
  81. 62 13
      frontend/src/i18n/locales/zh-TW.ts
  82. 471 0
      frontend/src/lib/gcodeToolpath.ts
  83. 75 0
      frontend/src/lib/sliceEngines.ts
  84. 130 0
      frontend/src/lib/slicerSettings.ts
  85. 469 0
      frontend/src/lib/slicerToggle.ts
  86. 106 0
      frontend/src/lib/vendor/toolpathRenderer.d.ts
  87. 406 0
      frontend/src/lib/vendor/toolpathRenderer.js
  88. 3 2
      frontend/src/pages/ArchivesPage.tsx
  89. 2 2
      frontend/src/pages/CameraTokensPage.tsx
  90. 10 9
      frontend/src/pages/FileManagerPage.tsx
  91. 6 3
      frontend/src/pages/FinancePage.tsx
  92. 86 118
      frontend/src/pages/GCodeViewerPage.tsx
  93. 35 0
      frontend/src/pages/SettingsPage.tsx
  94. 5 2
      frontend/src/pages/StatsPage.tsx
  95. 64 0
      frontend/src/types/slicerSettings.ts
  96. 0 65
      frontend/src/utils/framing.ts
  97. 34 0
      frontend/src/utils/slicer.ts
  98. 36 5
      frontend/src/utils/slicerPrinterMatch.ts
  99. 1 67
      frontend/vite.config.ts
  100. 0 60
      gcode_viewer/VENDORED.md

+ 4 - 1
.gitignore

@@ -60,7 +60,10 @@ firmware/
 # Node modules
 node_modules/
 
-data/
+# Runtime data dir (db, archives, backups). Anchored to the repo root on
+# purpose: a bare `data/` also matches frontend/src/data and
+# backend/app/data, which are source, not runtime state.
+/data/
 
 # Local-dev runtime caches (matplotlib MPLCONFIGDIR lands here when DATA_DIR
 # is unset, so base_dir resolves to the repo root). In Docker this sits

+ 3 - 4
.pre-commit-config.yaml

@@ -19,12 +19,11 @@ repos:
     rev: v5.0.0
     hooks:
       - id: trailing-whitespace
-        # Exclude static/ (build output) and gcode_viewer/ (vendored third-party
-        # assets — see gcode_viewer/VENDORED.md) so whitespace normalisation
+        # Exclude static/ (build output) so whitespace normalisation
         # doesn't drift the files away from upstream.
-        exclude: ^(static/|gcode_viewer/)
+        exclude: ^static/
       - id: end-of-file-fixer
-        exclude: ^(static/|gcode_viewer/)
+        exclude: ^static/
       - id: check-yaml
       - id: check-json
         exclude: ^(static/|frontend/tsconfig\.)

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 0
CHANGELOG.md


+ 0 - 9
Dockerfile

@@ -72,15 +72,6 @@ COPY .git/HEAD ./.git/HEAD
 # Copy built frontend from builder stage
 COPY --from=frontend-builder /app/static ./static
 
-# Copy embedded GCode viewer static assets (PrettyGCode + Bambuddy adapter).
-# Served by the explicit @app.get("/gcode-viewer/{...}") routes in main.py,
-# which resolve files under (static_dir.parent / "gcode_viewer") = /app/gcode_viewer/.
-# Without this COPY the routes return a bare 404 at request time and the 3D
-# Preview iframe shows {"detail":"Not Found"} (see #1218). The directory is
-# vendored third-party JS — the Vite build does NOT stage it into static/,
-# the dev server serves it via a configureServer middleware that's dev-only.
-COPY gcode_viewer/ ./gcode_viewer/
-
 # Create data directories. Ownership is normalised at startup by the
 # entrypoint (chowns to PUID:PGID and drops privileges via gosu before
 # exec'ing the app), so we don't need a chmod 777 hack here — that was

+ 0 - 5
Dockerfile.test

@@ -23,11 +23,6 @@ RUN --mount=type=cache,target=/root/.cache/pip \
 COPY backend/ ./backend/
 COPY pyproject.toml ./
 
-# Embedded GCode viewer assets — required so the @app.get("/gcode-viewer/...")
-# packaging-regression test in tests/integration/test_gcode_viewer.py actually
-# runs instead of pytest-skipping with "index.html not present". Path matches
-# the production Dockerfile (static_dir.parent / "gcode_viewer" = /app/gcode_viewer/).
-COPY gcode_viewer/ ./gcode_viewer/
 
 # Create necessary directories
 RUN mkdir -p /app/data /app/logs /app/archive

+ 44 - 12
backend/app/api/routes/auth.py

@@ -21,6 +21,7 @@ from backend.app.core.auth import (
     RequirePermissionIfAuthEnabled,
     _is_token_fresh,
     _validate_api_key,
+    apikey_effective_permissions,
     authenticate_user,
     authenticate_user_by_email,
     create_access_token,
@@ -30,13 +31,13 @@ from backend.app.core.auth import (
     get_user_by_email,
     get_user_by_username,
     is_jti_revoked,
+    resolve_apikey_owner,
     resolve_session_max_minutes,
     revoke_jti,
     security,
 )
 from backend.app.core.database import async_session, get_db
 from backend.app.core.oidc_env import env_bool
-from backend.app.core.permissions import ALL_PERMISSIONS
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
@@ -89,17 +90,47 @@ def _user_to_response(user: User) -> UserResponse:
     )
 
 
-def _api_key_to_user_response(api_key) -> UserResponse:
-    """Create a synthetic admin UserResponse for a valid API key."""
+async def _api_key_to_user_response(db: AsyncSession, api_key) -> UserResponse:
+    """Describe a valid API key as the identity it actually carries (#1894).
+
+    Until 0.2.5 this returned a synthetic admin: ``id=0``, ``role="admin"``,
+    ``is_admin=True`` and every permission in the enum. That was wrong in both
+    directions. A key cannot perform administrative operations at all --
+    ``_check_apikey_permissions`` denies every permission that is not in the
+    scope allowlist -- so a client that builds its UI from this response (which
+    is exactly what a native client does) rendered admin actions that 403 on
+    use, and had no way to learn the id its own prints are filed under.
+
+    Now: identity comes from the key's owner, and ``permissions`` is the set the
+    key can genuinely exercise. ``is_admin`` is always False because no key can
+    reach an administrative route regardless of who owns it.
+
+    Legacy keys predating per-user ownership (``user_id IS NULL``) have no
+    identity to report, so they keep ``id=0`` and the ``api-key:`` username --
+    but they stop claiming admin. ``created_at`` describes the credential in
+    both branches, unchanged.
+    """
+    # Same resolution the permission gate uses, so what is reported here and
+    # what is enforced there cannot drift -- including the 403 when the owner
+    # has been deactivated, which makes the key dead rather than anonymous.
+    owner = await resolve_apikey_owner(db, api_key)
     return UserResponse(
-        id=0,
-        username=f"api-key:{api_key.key_prefix}",
+        id=owner.id if owner else 0,
+        username=owner.username if owner else f"api-key:{api_key.key_prefix}",
+        # Withheld on purpose: the owner's email is not needed to resolve
+        # identity, and this response is reachable by anyone holding the key.
         email=None,
-        role="admin",
+        # Deprecated free-text field; "user" is the existing value meaning
+        # "not an admin". Inventing an "api_key" role here would put a third
+        # value into a field callers compare against string literals.
+        role="user",
         is_active=True,
-        is_admin=True,
+        is_admin=False,
+        auth_source=getattr(owner, "auth_source", "local") if owner else "local",
+        # The key is not a group member -- listing the owner's groups would
+        # imply capabilities the key does not inherit.
         groups=[],
-        permissions=sorted(ALL_PERMISSIONS),
+        permissions=apikey_effective_permissions(api_key, owner),
         created_at=api_key.created_at.isoformat(),
     )
 
@@ -637,8 +668,9 @@ async def get_current_user_info(
     """Get current user information.
 
     Accepts JWT tokens (via Authorization: Bearer header) and API keys
-    (via X-API-Key header or Authorization: Bearer bb_xxx).
-    API keys return a synthetic admin user with all permissions.
+    (via X-API-Key header or Authorization: Bearer bb_xxx). API keys report
+    their owner's identity and the permissions the key can actually exercise
+    -- see ``_api_key_to_user_response``.
     """
     import jwt
     from jwt.exceptions import PyJWTError as JWTError
@@ -647,7 +679,7 @@ async def get_current_user_info(
     if x_api_key:
         api_key = await _validate_api_key(db, x_api_key)
         if api_key:
-            return _api_key_to_user_response(api_key)
+            return await _api_key_to_user_response(db, api_key)
 
     # Check for Bearer token (could be JWT or API key)
     if credentials is not None:
@@ -656,7 +688,7 @@ async def get_current_user_info(
         if token.startswith("bb_"):
             api_key = await _validate_api_key(db, token)
             if api_key:
-                return _api_key_to_user_response(api_key)
+                return await _api_key_to_user_response(db, api_key)
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Invalid API key",

+ 11 - 0
backend/app/api/routes/groups.py

@@ -28,8 +28,19 @@ from backend.app.schemas.group import (
 router = APIRouter(prefix="/groups", tags=["groups"])
 
 
+# Permissions whose derived label would misdescribe what is being granted.
+# The derived form for USERS_READ_SLIM is "Read Slim Users", which reads as a
+# property of the users rather than of the response -- and an admin ticking a
+# box in the group editor has nothing else to go on (#1894).
+_PERMISSION_LABEL_OVERRIDES: dict[Permission, str] = {
+    Permission.USERS_READ_SLIM: "List User Names (id + username only)",
+}
+
+
 def _permission_label(perm: Permission) -> str:
     """Convert permission enum to human-readable label."""
+    if perm in _PERMISSION_LABEL_OVERRIDES:
+        return _PERMISSION_LABEL_OVERRIDES[perm]
     # e.g., "printers:read" -> "Read Printers"
     parts = perm.value.split(":")
     if len(parts) == 2:

+ 26 - 7
backend/app/api/routes/library.py

@@ -70,6 +70,7 @@ from backend.app.services.design_settings import (
     overrides_from_config,
 )
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+from backend.app.services.process_overrides import apply_process_overrides
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
 from backend.app.utils.threemf_tools import (
@@ -3734,6 +3735,14 @@ async def _run_slicer_with_fallback(
                 request.design_overrides,
             )
 
+    # The user's own edits from the slice modal's settings panel. Applied last
+    # and for every model type (not just 3MF): unlike the two patches above this
+    # doesn't read anything out of the source file, it is what the user typed.
+    # Last write wins, so an explicit choice beats both the carried support
+    # config (#1881) and the designer's tweaks (#2622).
+    if request.process_overrides:
+        presets["process"] = apply_process_overrides(presets["process"], request.process_overrides)
+
     used_embedded_settings = False
     # "Slice as designed" (#2611): honour the file's embedded
     # project_settings.config instead of the picked profile triplet. Only
@@ -4455,13 +4464,23 @@ async def slice_library_file(
     lib_file = _ensure_library_file_visible(lib_file, current_user, can_read_all)
 
     src_lower = (lib_file.filename or "").lower()
-    if not (
-        src_lower.endswith(".stl")
-        or src_lower.endswith(".3mf")
-        or src_lower.endswith(".step")
-        or src_lower.endswith(".stp")
-    ):
-        raise HTTPException(status_code=400, detail="Source file must be STL, 3MF, or STEP")
+    if src_lower.endswith(".step") or src_lower.endswith(".stp"):
+        # Neither slicer's CLI can load STEP: OrcaSlicer 2.4.2 and BambuStudio
+        # 02.07.01.62 both answer "Unknown file format. Input file must have
+        # .stl, .obj, .amf(.xml) extension." Accepting the job here meant
+        # reading the file, converting it and uploading it before the sidecar
+        # rejected it as unparseable -- which reads as a corrupt model rather
+        # than an unsupported format. Say so before any of that happens.
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                "STEP files cannot be sliced. The OrcaSlicer and Bambu Studio command-line "
+                "slicers load only STL and 3MF -- open the STEP in your slicer and export it "
+                "as one of those first."
+            ),
+        )
+    if not (src_lower.endswith(".stl") or src_lower.endswith(".3mf")):
+        raise HTTPException(status_code=400, detail="Source file must be STL or 3MF")
 
     src_path = Path(app_settings.base_dir) / lib_file.file_path
     if not src_path.exists():

+ 65 - 0
backend/app/api/routes/slicer_presets.py

@@ -32,6 +32,7 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.user import User
+from backend.app.schemas.slicer import PresetRef
 from backend.app.schemas.slicer_presets import (
     UnifiedPreset,
     UnifiedPresetsBySlot,
@@ -46,9 +47,11 @@ from backend.app.services.orca_cloud import (
     OrcaCloudAuthError,
     OrcaCloudError,
 )
+from backend.app.services.preset_resolver import resolve_preset_ref
 from backend.app.services.slicer_api import (
     SlicerApiError,
     SlicerApiService,
+    SlicerApiUnavailableError,
 )
 from backend.app.utils.printer_models import PRINTER_MODEL_MAP
 
@@ -539,6 +542,68 @@ def list_printer_models() -> dict[str, str]:
     return dict(PRINTER_MODEL_MAP)
 
 
+@router.get("/preset-values")
+async def get_preset_values(
+    source: str = Query(..., description="Preset tier: 'local', 'cloud', 'orca_cloud' or 'standard'."),
+    id: str = Query(..., description="Preset id within that tier."),
+    slot: str = Query("process", description="Preset slot. Only 'process' is supported today."),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+) -> dict:
+    """Effective values of a preset, with its ``inherits:`` chain flattened.
+
+    Drives the slice modal's process-settings panel: without this the panel can
+    only show the option schema's compiled-in defaults, so a preset that sets a
+    0.42mm line width appears as the C++ default of 0.
+
+    The flattening is done by the *sidecar*, deliberately. A "Standard" pick is
+    only a ``{inherits: "<name>"}`` stub on our side, and even local/cloud
+    presets are deltas — the values live in the profile tree bundled inside the
+    running sidecar image. Bambuddy's own ``orca_profiles`` resolver walks
+    OrcaSlicer's published tree instead, which can disagree with what actually
+    slices; showing numbers from it would be confidently wrong.
+
+    Returns ``{"resolved": false, "values": {}, "reason": "..."}`` rather than
+    an error whenever the values can't be obtained. ``reason`` is what makes
+    the fallback actionable: a Bambuddy install pulls its sidecar as
+    ``SIDECAR_TAG:-latest`` regardless of its own release channel, so the
+    overwhelmingly common cause is a sidecar older than the endpoint — which
+    the user fixes by pulling a newer image, if we tell them that instead of
+    "could not read the values".
+    """
+    if slot != "process":
+        raise HTTPException(status_code=400, detail="Only the 'process' slot is supported")
+
+    ref = PresetRef(source=source, id=id)
+
+    def unresolved(reason: str) -> dict:
+        return {"resolved": False, "values": {}, "reason": reason}
+
+    try:
+        profile_json = await resolve_preset_ref(db, current_user, ref, slot)
+    except HTTPException:
+        # A preset the caller can't resolve is not a reason to break the panel;
+        # the slice itself will report it properly if they go ahead.
+        logger.info("Could not resolve %s preset %s for value lookup", slot, id)
+        return unresolved("preset_unresolved")
+
+    api_url = await _resolve_slicer_api_url(db)
+    if not api_url:
+        return unresolved("not_configured")
+
+    service = SlicerApiService(api_url)
+    try:
+        resolved = await service.resolve_profile(profile_json, "process")
+    except SlicerApiUnavailableError:
+        return unresolved("sidecar_unavailable")
+    finally:
+        await service.close()
+
+    if resolved.values is None:
+        return unresolved(resolved.reason)
+    return {"resolved": True, "values": resolved.values, "reason": "ok"}
+
+
 @router.get("/presets", response_model=UnifiedPresetsResponse)
 async def list_unified_presets(
     db: AsyncSession = Depends(get_db),

+ 34 - 1
backend/app/api/routes/users.py

@@ -13,6 +13,7 @@ from backend.app.core.auth import (
     ALGORITHM,
     SECRET_KEY,
     RequireAdminIfAuthEnabled,
+    RequireAnyPermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     get_current_user_optional,
     get_password_hash,
@@ -34,7 +35,14 @@ from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user_otp_code import UserOTPCode
 from backend.app.models.user_totp import UserTOTP
-from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
+from backend.app.schemas.auth import (
+    ChangePasswordRequest,
+    GroupBrief,
+    UserCreate,
+    UserResponse,
+    UserSlim,
+    UserUpdate,
+)
 from backend.app.services.email_service import (
     create_welcome_email_from_template,
     generate_secure_password,
@@ -190,6 +198,31 @@ async def create_user(
     return _user_to_response(new_user)
 
 
+@router.get("/slim", response_model=list[UserSlim])
+async def list_users_slim(
+    _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.USERS_READ_SLIM, Permission.USERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """List users as ``{id, username}`` only (#1894).
+
+    Exists so an API key -- or a group that should not see emails, roles and
+    permission sets -- can turn the ``created_by_id`` values it already gets
+    back from archives, stats and the queue into names.
+
+    ``USERS_READ`` is accepted alongside ``USERS_READ_SLIM`` because it is
+    strictly broader; groups that already hold it keep working without a
+    permission backfill. For API keys only the slim permission resolves (the
+    full one is unmapped = administrative), so a key reaches this and not the
+    listing above.
+
+    Declared before ``/{user_id}`` on purpose: FastAPI matches in declaration
+    order, and the reverse order would parse "slim" as the int path parameter
+    and answer 422.
+    """
+    result = await db.execute(select(User.id, User.username).order_by(User.username))
+    return [UserSlim(id=row.id, username=row.username) for row in result.all()]
+
+
 @router.get("/{user_id}", response_model=UserResponse)
 async def get_user(
     user_id: int,

+ 9 - 7
backend/app/api/routes/webhook.py

@@ -5,7 +5,7 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import check_permission, check_printer_access, get_api_key
+from backend.app.core.auth import check_printer_access, check_webhook_permission, get_api_key
 from backend.app.core.database import get_db
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
@@ -68,7 +68,7 @@ async def webhook_add_to_queue(
 
     Requires 'can_queue' permission.
     """
-    check_permission(api_key, "queue")
+    await check_webhook_permission(db, api_key, "queue")
     check_printer_access(api_key, data.printer_id)
 
     # Verify archive exists
@@ -153,7 +153,7 @@ async def webhook_start_print(
 
     Requires 'can_control_printer' permission.
     """
-    check_permission(api_key, "control_printer")
+    await check_webhook_permission(db, api_key, "control_printer")
     check_printer_access(api_key, printer_id)
 
     # Get printer
@@ -191,12 +191,13 @@ async def webhook_start_print(
 async def webhook_stop_print(
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
+    db: AsyncSession = Depends(get_db),
 ):
     """Stop the current print on a printer.
 
     Requires 'can_control_printer' permission.
     """
-    check_permission(api_key, "control_printer")
+    await check_webhook_permission(db, api_key, "control_printer")
     check_printer_access(api_key, printer_id)
 
     status = printer_manager.get_status(printer_id)
@@ -222,12 +223,13 @@ async def webhook_stop_print(
 async def webhook_cancel_print(
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
+    db: AsyncSession = Depends(get_db),
 ):
     """Cancel the current print on a printer.
 
     Requires 'can_control_printer' permission.
     """
-    check_permission(api_key, "control_printer")
+    await check_webhook_permission(db, api_key, "control_printer")
     check_printer_access(api_key, printer_id)
 
     status = printer_manager.get_status(printer_id)
@@ -257,7 +259,7 @@ async def webhook_get_printer_status(
 
     Requires 'can_read_status' permission.
     """
-    check_permission(api_key, "read_status")
+    await check_webhook_permission(db, api_key, "read_status")
     check_printer_access(api_key, printer_id)
 
     # Get printer
@@ -293,7 +295,7 @@ async def webhook_get_queue_status(
 
     Requires 'can_read_status' permission.
     """
-    check_permission(api_key, "read_status")
+    await check_webhook_permission(db, api_key, "read_status")
 
     # Get printers
     if printer_id:

+ 146 - 10
backend/app/core/auth.py

@@ -49,8 +49,15 @@ logger = logging.getLogger(__name__)
 # The denylist is retained for documentation / drift-detection only — its
 # entries also satisfy "not in the allowlist", so they fail closed regardless.
 #
+# #1894 follow-on: the allowlist is a ceiling, not a grant. A key is also
+# narrowed to what its owner may do, so a user who can create keys cannot mint
+# themselves authority they do not have, and deactivating a user disables their
+# keys. Legacy ownerless keys (``user_id IS NULL``) have no owner to narrow
+# against and remain governed by the scope flags alone.
+#
 # Mapping rationale (see wiki/features/api-keys.md):
 #   can_read_status       → every ``*_READ`` + camera + stats + system + websocket
+#                           + the slim id/username user listing (NOT ``users:read``)
 #   can_queue             → queue write ops + archive reprint
 #   can_control_printer   → physical printer + smart-plug control
 #   can_manage_library    → library upload/own + MakerWorld import (separate
@@ -94,6 +101,14 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     Permission.PRINTER_SENSOR_HISTORY_READ: "can_read_status",
     Permission.STATS_READ: "can_read_status",
     Permission.STATS_FILTER_BY_USER: "can_read_status",
+    # USERS_READ_SLIM grants no data an API key could not already reach (#1894):
+    # for API-keyed requests the permission deps return None as ``current_user``,
+    # so ``_validate_user_filter_permission`` in routes/archives.py short-circuits
+    # and ``?created_by_id=N`` is already honoured for every N. Without a way to
+    # discover the ids, that filter is only addressable by brute force. The slim
+    # listing makes it usable; the full USERS_READ listing (emails, roles, group
+    # membership, permission sets) stays unmapped = admin-only.
+    Permission.USERS_READ_SLIM: "can_read_status",
     Permission.SYSTEM_READ: "can_read_status",
     # SETTINGS_READ stays allowed via read-status so SpoolBuddy kiosks keep
     # working (they need the UI-language setting via API key).
@@ -293,13 +308,89 @@ def _resolve_apikey_scope(perm_string: str) -> str | None:
     return _APIKEY_SCOPE_BY_PERMISSION.get(perm)
 
 
-def _check_apikey_permissions(api_key: APIKey, perm_strings: list[str], *, require_any: bool = False) -> None:
+def apikey_effective_permissions(api_key: APIKey, owner: User | None = None) -> list[str]:
+    """Return the permissions ``api_key`` can actually exercise, sorted.
+
+    This is the exact set ``_check_apikey_permissions`` will let through: every
+    mapped permission whose scope flag is True on the key, further narrowed to
+    what ``owner`` may do. Unmapped permissions are administrative and never
+    resolve for a key, so they are absent.
+
+    ``owner=None`` means a legacy ownerless key, where the scope flags are the
+    whole of the key's authority -- not "skip the owner check". Callers holding
+    an owned key must pass the owner, or ``/auth/me`` will over-report and drift
+    from the gate, which is the defect #1894 was about.
+    """
+    return sorted(
+        perm.value
+        for perm, scope_attr in _APIKEY_SCOPE_BY_PERMISSION.items()
+        if getattr(api_key, scope_attr, False) and (owner is None or owner.has_permission(perm.value))
+    )
+
+
+async def resolve_apikey_owner(db: AsyncSession, api_key: APIKey) -> User | None:
+    """Load the owner of ``api_key`` for an authorization decision.
+
+    Distinct from ``_user_from_api_key``, which answers "who is this, if
+    anyone" and returns None for both the legacy and the broken case. Here
+    those two must not be conflated:
+
+    - ``user_id IS NULL`` -- a key predating per-user ownership. There is no
+      owner to narrow against, so the scope flags stand alone. Returns None.
+    - ``user_id`` set but the row is missing or deactivated -- the key's
+      authority came from a user who no longer has any. Raises 403 rather than
+      returning None, because returning None here would fail open: deactivating
+      a user would leave their keys working with full scope authority.
+
+    Groups are eager-loaded because ``has_permission`` walks them, and a lazy
+    load inside the permission check would raise MissingGreenlet.
+    """
+    if api_key.user_id is None:
+        return None
+    result = await db.execute(select(User).where(User.id == api_key.user_id).options(selectinload(User.groups)))
+    owner = result.scalar_one_or_none()
+    if owner is None or not owner.is_active:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="API key owner is deactivated or no longer exists",
+        )
+    return owner
+
+
+async def authorize_api_key(
+    db: AsyncSession,
+    api_key: APIKey,
+    perm_strings: list[str],
+    *,
+    require_any: bool = False,
+) -> None:
+    """Resolve the key's owner and run the full permission gate. Raises 403."""
+    owner = await resolve_apikey_owner(db, api_key)
+    _check_apikey_permissions(api_key, perm_strings, owner=owner, require_any=require_any)
+
+
+def _check_apikey_permissions(
+    api_key: APIKey,
+    perm_strings: list[str],
+    *,
+    owner: User | None = None,
+    require_any: bool = False,
+) -> None:
     """Raise 403 unless ``api_key`` is allowed to use ``perm_strings``.
 
     Allowlist semantics: every requested permission MUST be present in
     ``_APIKEY_SCOPE_BY_PERMISSION`` AND its scope flag must be True on
     ``api_key``. Unmapped permissions = administrative = 403.
 
+    A key must not out-rank the user it belongs to, so when ``owner`` is given
+    the permission must additionally be one the owner holds. Scope flags are
+    chosen at creation time by whoever holds ``api_keys:create``; that is
+    admin-only in the default groups, but a custom group can grant it, and
+    without this check such a user could mint themselves a key with
+    ``can_control_printer`` and act through it beyond their own permissions.
+    ``owner=None`` is only correct for legacy ownerless keys -- see
+    ``resolve_apikey_owner``.
+
     By default ALL requested permissions must pass (mirrors
     ``require_permission`` / ``require_permission_if_auth_enabled``).
     When ``require_any=True``, only one needs to pass (mirrors
@@ -327,6 +418,11 @@ def _check_apikey_permissions(api_key: APIKey, perm_strings: list[str], *, requi
                 status_code=status.HTTP_403_FORBIDDEN,
                 detail=f"API key does not have '{scope_attr}' permission",
             )
+        elif owner is not None and not owner.has_permission(perm_str):
+            failure = HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail=f"API key owner does not have '{perm_str}' permission",
+            )
         else:
             failure = None
 
@@ -384,6 +480,12 @@ def require_energy_cost_update():
                         detail="Invalid API key",
                         headers={"WWW-Authenticate": "Bearer"},
                     )
+                # Fails closed if the owner has been deactivated. The scope
+                # flag itself is not narrowed against the owner's permissions
+                # the way the general gate is: this door exists precisely
+                # because no user permission maps to it (SETTINGS_UPDATE stays
+                # denied for keys even when the owner is an administrator).
+                await resolve_apikey_owner(db, api_key)
                 if not api_key.can_update_energy_cost:
                     raise HTTPException(
                         status_code=status.HTTP_403_FORBIDDEN,
@@ -1133,10 +1235,14 @@ async def require_auth_if_enabled(
         if not auth_enabled:
             return None
 
-        # Check for API key first (X-API-Key header)
+        # Check for API key first (X-API-Key header). The owner is resolved
+        # purely for its side effect: a key whose owner has been deactivated
+        # must be dead everywhere, not just on the permission-gated routes.
+        # There is no permission to check here -- this dep is auth-only.
         if x_api_key:
             api_key = await _validate_api_key(db, x_api_key)
             if api_key:
+                await resolve_apikey_owner(db, api_key)
                 return None  # API key valid, allow access
 
         # Check for Bearer token (could be JWT or API key)
@@ -1146,6 +1252,7 @@ async def require_auth_if_enabled(
             if token.startswith("bb_"):
                 api_key = await _validate_api_key(db, token)
                 if api_key:
+                    await resolve_apikey_owner(db, api_key)
                     return None  # API key valid, allow access
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1424,6 +1531,35 @@ def check_permission(api_key: APIKey, permission: str) -> None:
         )
 
 
+# The coarse webhook permission names predate the Permission enum. Each maps to
+# the enum member that best represents it, so the owner can be held to the same
+# standard here as on the modern routes.
+_WEBHOOK_PERMISSION_EQUIVALENT: dict[str, Permission] = {
+    "queue": Permission.QUEUE_CREATE,
+    "control_printer": Permission.PRINTERS_CONTROL,
+    "read_status": Permission.PRINTERS_READ,
+}
+
+
+async def check_webhook_permission(db: AsyncSession, api_key: APIKey, permission: str) -> None:
+    """``check_permission`` plus the owner checks the modern routes apply.
+
+    ``/webhook/*`` reaches its scope flags through ``check_permission`` rather
+    than ``_check_apikey_permissions``, so it does not pick up the owner
+    narrowing automatically. Without this it would be the way around the gate:
+    the same key that is refused printer control on ``/printers/{id}/print/stop``
+    could stop the print through ``/webhook/printer/{id}/stop``.
+    """
+    check_permission(api_key, permission)
+    owner = await resolve_apikey_owner(db, api_key)
+    equivalent = _WEBHOOK_PERMISSION_EQUIVALENT.get(permission)
+    if owner is not None and equivalent is not None and not owner.has_permission(equivalent.value):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail=f"API key owner does not have '{equivalent.value}' permission",
+        )
+
+
 def check_printer_access(api_key: APIKey, printer_id: int) -> None:
     """Check if API key has access to the specified printer.
 
@@ -1481,7 +1617,7 @@ def require_permission(*permissions: str | Permission):
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    _check_apikey_permissions(api_key, perm_strings)
+                    await authorize_api_key(db, api_key, perm_strings)
                     return None  # API key valid, allow access
 
             credentials_exception = HTTPException(
@@ -1498,7 +1634,7 @@ def require_permission(*permissions: str | Permission):
             if token.startswith("bb_"):
                 api_key = await _validate_api_key(db, token)
                 if api_key:
-                    _check_apikey_permissions(api_key, perm_strings)
+                    await authorize_api_key(db, api_key, perm_strings)
                     return None  # API key valid, allow access
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1571,7 +1707,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    _check_apikey_permissions(api_key, perm_strings)
+                    await authorize_api_key(db, api_key, perm_strings)
                     return None  # API key valid, allow access
 
             # Check for Bearer token (could be JWT or API key)
@@ -1581,7 +1717,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
-                        _check_apikey_permissions(api_key, perm_strings)
+                        await authorize_api_key(db, api_key, perm_strings)
                         return None  # API key valid, allow access
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1674,7 +1810,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
                     # GHSA-r2qv-8222-hqg3: previously returned None unconditionally,
                     # letting any valid API key satisfy admin "any-of" route
                     # dependencies. require_any → at-least-one must pass the scope check.
-                    _check_apikey_permissions(api_key, perm_strings, require_any=True)
+                    await authorize_api_key(db, api_key, perm_strings, require_any=True)
                     return None
 
             if credentials is not None:
@@ -1682,7 +1818,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
-                        _check_apikey_permissions(api_key, perm_strings, require_any=True)
+                        await authorize_api_key(db, api_key, perm_strings, require_any=True)
                         return None
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1873,7 +2009,7 @@ def require_ownership_permission(
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    _check_apikey_permissions(api_key, [all_perm])
+                    await authorize_api_key(db, api_key, [all_perm])
                     return None, True
 
             # Check for Bearer token (could be JWT or API key)
@@ -1883,7 +2019,7 @@ def require_ownership_permission(
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
-                        _check_apikey_permissions(api_key, [all_perm])
+                        await authorize_api_key(db, api_key, [all_perm])
                         return None, True
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,

+ 6 - 0
backend/app/core/permissions.py

@@ -174,6 +174,11 @@ class Permission(StrEnum):
 
     # Users (admin-level)
     USERS_READ = "users:read"
+    # Narrow read: id + username only, no emails/roles/groups/permissions (#1894).
+    # Exists so an id -> name mapping can be resolved without handing out the
+    # full user objects. Pairs with STATS_FILTER_BY_USER, which is useless
+    # without a way to discover the ids it filters on.
+    USERS_READ_SLIM = "users:read_slim"
     USERS_CREATE = "users:create"
     USERS_UPDATE = "users:update"
     USERS_DELETE = "users:delete"
@@ -346,6 +351,7 @@ PERMISSION_CATEGORIES = {
     ],
     "User Management": [
         Permission.USERS_READ,
+        Permission.USERS_READ_SLIM,
         Permission.USERS_CREATE,
         Permission.USERS_UPDATE,
         Permission.USERS_DELETE,

+ 5 - 63
backend/app/main.py

@@ -1,7 +1,6 @@
 import asyncio
 import json
 import logging
-import mimetypes as _mimetypes
 import os
 import posixpath
 import secrets
@@ -8063,7 +8062,8 @@ def _frame_ancestors(default_value: str) -> str:
 
     ``default_value`` is the strict directive used when the operator has not
     configured ``TRUSTED_FRAME_ORIGINS`` — typically ``'none'`` (catch-all and
-    docs) or ``'self'`` (gcode-viewer, served same-origin). When trusted origins
+    docs) or ``'self'`` (the streaming overlay, embedded same-origin by the
+    Settings URL builder's preview). When trusted origins
     are configured, ``'self'`` is always included so same-origin embedding never
     breaks even if an operator forgets to add their own origin to the list.
     """
@@ -8103,23 +8103,7 @@ async def security_headers_middleware(request, call_next):
     #   - img-src data: / blob:: base64 thumbnails and Blob-URL timelapse previews.
     #   - media-src blob:: timelapse video player uses Blob URLs.
     #   - font-src data:: some icon fonts are embedded as data URIs.
-    if request.url.path.startswith("/gcode-viewer"):
-        # The gcode viewer is embedded in an iframe served by this same origin,
-        # so frame-ancestors must allow 'self'.  prettygcode.js also uses eval()
-        # internally, so script-src needs 'unsafe-eval'.
-        response.headers["Content-Security-Policy"] = (
-            "default-src 'self'; "
-            "script-src 'self' 'unsafe-eval'; "
-            "style-src 'self' 'unsafe-inline'; "
-            "img-src 'self' data: blob:; "
-            "media-src 'self' blob:; "
-            "connect-src 'self' ws: wss:; "
-            "font-src 'self' data:; "
-            "object-src 'none'; "
-            "base-uri 'self'; "
-            "frame-src 'self' http: https:; " + _frame_ancestors("'self'")
-        )
-    elif request.url.path in ("/docs", "/redoc", "/docs/oauth2-redirect"):
+    if request.url.path in ("/docs", "/redoc", "/docs/oauth2-redirect"):
         # FastAPI's built-in Swagger UI / ReDoc pages load assets from
         # cdn.jsdelivr.net and bootstrap with an inline <script>, so the
         # default CSP would render a blank page.
@@ -8136,8 +8120,8 @@ async def security_headers_middleware(request, call_next):
         )
     else:
         # The streaming overlay is embedded same-origin by the URL builder's
-        # preview in Settings (#1422) — the same reason /gcode-viewer allows
-        # 'self' above. Embedding from anywhere else is still refused: 'self'
+        # preview in Settings (#1422), so this branch allows 'self'.
+        # Embedding from anywhere else is still refused: 'self'
         # only permits a framer on this origin, which is Bambuddy's own UI, so
         # a clickjacking page on another host is blocked exactly as before.
         # (The overlay draws status over a camera feed and its only interactive
@@ -8507,48 +8491,6 @@ async def serve_sw_register():
 
 
 # ── GCode viewer static files ────────────────────────────────────────────────
-# Served via explicit routes so ordering is guaranteed (app.mount() loses
-# to the /{full_path:path} catch-all in some Starlette versions).
-_gcode_viewer_dir = (app_settings.static_dir.parent / "gcode_viewer").resolve()
-
-# Surface packaging gaps at startup instead of as silent runtime 404s. If the
-# directory is missing the explicit @app.get("/gcode-viewer/...") routes below
-# return bare HTTPException(404) which renders as {"detail":"Not Found"} in
-# the 3D Preview iframe (#1218) — easy to miss in normal operation, easy to
-# spot if the operator scans the startup log or a support bundle.
-if not (_gcode_viewer_dir / "index.html").is_file():
-    logging.getLogger(__name__).error(
-        "Embedded GCode viewer assets missing at %s — /gcode-viewer/ will return 404 "
-        "and 3D Preview will fail. This indicates a packaging bug; the gcode_viewer/ "
-        "directory must be present alongside static/.",
-        _gcode_viewer_dir,
-    )
-
-
-def _gcode_viewer_response(rel: str) -> FileResponse:
-    from fastapi import HTTPException as _HTTPException
-
-    safe = (_gcode_viewer_dir / rel).resolve()
-    if not safe.is_relative_to(_gcode_viewer_dir):
-        raise _HTTPException(status_code=403)
-    if safe.is_file():
-        mt, _ = _mimetypes.guess_type(str(safe))
-        return FileResponse(str(safe), media_type=mt or "application/octet-stream")
-    raise _HTTPException(status_code=404)
-
-
-@app.get("/gcode-viewer/")
-async def serve_gcode_viewer_index() -> FileResponse:
-    """Raw PrettyGCode viewer for the iframe. The bare ``/gcode-viewer``
-    (no trailing slash) intentionally falls through to the SPA catch-all so a
-    full-page reload re-enters the React layout instead of serving the iframe
-    contents standalone."""
-    return _gcode_viewer_response("index.html")
-
-
-@app.get("/gcode-viewer/{file_path:path}")
-async def serve_gcode_viewer_file(file_path: str) -> FileResponse:
-    return _gcode_viewer_response(file_path)
 
 
 # Catch-all route for React Router (must be last)

+ 16 - 0
backend/app/schemas/auth.py

@@ -93,6 +93,22 @@ class UserResponse(BaseModel):
         from_attributes = True
 
 
+class UserSlim(BaseModel):
+    """Just enough to resolve a user id to a display name (#1894).
+
+    Deliberately narrower than ``UserResponse``: no email, role, auth source,
+    group membership or permission set. Adding a field here widens what every
+    ``can_read_status`` API key can read about every account, so treat this
+    shape as the contract rather than a starting point.
+    """
+
+    id: int
+    username: str
+
+    class Config:
+        from_attributes = True
+
+
 class LDAPSearchResultResponse(BaseModel):
     """One match from GET /auth/ldap/search — surfaced in the admin UI."""
 

+ 14 - 0
backend/app/schemas/settings.py

@@ -286,6 +286,19 @@ class AppSettings(BaseModel):
         ),
     )
 
+    # Where slicing runs. Orthogonal to ``preferred_slicer``, which only says
+    # *which slicer binary* the sidecar drives: a browser engine is a different
+    # execution site, not a different binary choice. Kept as its own key so the
+    # two never have to encode impossible combinations.
+    #
+    # Only "sidecar" is implemented today; the slice modal offers a per-job
+    # choice when more than one engine is available, and hides the control
+    # entirely while there is only one.
+    slice_engine: str = Field(
+        default="sidecar",
+        description="Default execution site for slicing: 'sidecar' (server-side API) or 'browser'",
+    )
+
     # Slicer dispatch mode: when True, "Slice" actions open the in-app
     # SliceModal and call the slicer-API sidecar. When False (default), they
     # hand off to the user's local desktop slicer via URI scheme — preserving
@@ -674,6 +687,7 @@ class AppSettingsUpdate(BaseModel):
     camera_view_mode: str | None = None
     preferred_slicer: str | None = None
     open_in_slicer: str | None = None
+    slice_engine: str | None = None
     use_slicer_api: bool | None = None
     orcaslicer_api_url: str | None = None
     bambu_studio_api_url: str | None = None

+ 14 - 1
backend/app/schemas/slicer.py

@@ -1,6 +1,6 @@
 """Pydantic schemas for slice requests."""
 
-from typing import Literal
+from typing import Any, Literal
 
 from pydantic import BaseModel, Field, model_validator
 
@@ -93,6 +93,19 @@ class SliceRequest(BaseModel):
             "else is ignored. ``None``/empty means a plain profile slice."
         ),
     )
+    process_overrides: dict[str, Any] | None = Field(
+        default=None,
+        description=(
+            "The user's own process-setting edits from the slice modal's settings "
+            "panel, as a sparse ``{option_key: value}`` map (layer height, wall "
+            "count, supports, speeds — OrcaSlicer's process parameter set). Written "
+            "into the process JSON *after* the source's support settings and the "
+            "designer's carried tweaks, so an explicit choice here wins over both. "
+            "Values are normalised to the string forms a process preset stores; "
+            "keys that aren't valid config keys are dropped rather than failing "
+            "the slice. ``None``/empty leaves the picked preset untouched."
+        ),
+    )
     use_embedded_settings: bool = Field(
         default=False,
         description=(

+ 187 - 1
backend/app/services/bambu_mqtt.py

@@ -274,6 +274,99 @@ def apply_tray_exist_bits(
     return cleared
 
 
+# --- H2C nozzle-rack dispatch mapping (#2800) -------------------------------
+#
+# Physical nozzle IDs the H2C reports for its six rack slots. The two hotend
+# carriage positions are 0 and 1 in the same namespace, which is why a rack
+# position can never be confused with an extruder index by value.
+_RACK_NOZZLE_IDS = frozenset(range(16, 22))
+
+# BambuStudio dispatches a fixed-length nozzle_mapping on rack models: one
+# physical nozzle ID per filament slot, -1 for slots the plate does not print.
+_RACK_WIRE_SLOTS = 32
+
+# The extruder the rack feeds. On the H2C the swappable hotend sits on the
+# right carriage, which the slicer's physical_extruder_map numbers 0 (left is
+# 1) -- so a slot assigned extruder 0 is a slot that prints from whichever
+# rack nozzle is currently mounted.
+#
+# This is the one value here taken from a single hardware observation (#2800)
+# rather than from something the printer reports. It is safe to be wrong about
+# for a job that prints entirely from one side: if the rack were really on
+# extruder 1, no slot would match and the mapping would simply be omitted,
+# which is the behaviour that existed before any of this. Only a job that
+# prints from both nozzles at once could be actively harmed by a flip, and
+# that is what a second hardware capture needs to confirm.
+_RACK_EXTRUDER_ID = 0
+
+
+def resolve_rack_nozzle_mapping(
+    slot_extruders: list[int],
+    rack_nozzle_id: int | None,
+) -> list[int] | None:
+    """Expand a per-slot extruder mapping into an H2C physical nozzle_mapping.
+
+    ``slot_extruders`` is the compact form stored on the queue item: MQTT
+    extruder index per filament slot (index 0 = slot 1), -1 for a slot the
+    plate does not print. ``rack_nozzle_id`` is the rack position the printer
+    reports as live.
+
+    Returns a ``_RACK_WIRE_SLOTS``-long list of physical nozzle IDs, or None
+    when the mapping cannot be resolved with confidence -- in which case the
+    caller omits the field entirely and the firmware falls back to its own
+    nozzle pick, exactly as it did before this translation existed. Omitting
+    is deliberately the failure mode: a *wrong* physical ID makes the printer
+    level with one nozzle and print with another several millimetres off the
+    bed, which is far worse than letting the firmware choose.
+
+    Returns None specifically when:
+
+    - a slot needs the rack but the printer has not reported a live rack
+      position (mid-swap, or a stale connection);
+    - no slot needs the rack at all. The non-rack hotend's own physical ID is
+      not yet confirmed against a known-good BambuStudio capture, and this
+      code will not guess one. Such a job dispatches as it does today.
+    - the plate needs more slots than the wire format carries;
+    - the input is not a list of whole numbers.
+
+    Total by construction: it raises nothing, because the only caller is
+    building an MQTT print command with no exception handler above it and the
+    queue item has already been committed as `printing` by then. An
+    unparseable input has to degrade to "let the firmware pick", not to a job
+    wedged in a state no print will ever leave.
+    """
+    if not isinstance(slot_extruders, list) or not slot_extruders:
+        return None
+    if len(slot_extruders) > _RACK_WIRE_SLOTS:
+        return None
+    if not isinstance(rack_nozzle_id, int) or isinstance(rack_nozzle_id, bool):
+        return None
+    if rack_nozzle_id not in _RACK_NOZZLE_IDS:
+        return None
+
+    # Normalise first so the checks below, and the values that reach the wire,
+    # are known ints. bool is an int subclass and would otherwise serialise as
+    # a JSON `true`; None means "slot not printed" and is folded into -1.
+    normalised: list[int] = []
+    for extruder in slot_extruders:
+        if extruder is None:
+            normalised.append(-1)
+        elif isinstance(extruder, int) and not isinstance(extruder, bool):
+            normalised.append(extruder)
+        else:
+            return None
+
+    if _RACK_EXTRUDER_ID not in normalised:
+        return None
+
+    wire = [-1] * _RACK_WIRE_SLOTS
+    for index, extruder in enumerate(normalised):
+        if extruder < 0:
+            continue
+        wire[index] = rack_nozzle_id if extruder == _RACK_EXTRUDER_ID else extruder
+    return wire
+
+
 @dataclass
 class MQTTLogEntry:
     """Log entry for MQTT message debugging."""
@@ -490,6 +583,14 @@ class PrinterState:
     h2d_extruder_snow: dict = field(default_factory=dict)
     # H2C nozzle rack: full device.nozzle.info array for tool-changer printers (>2 nozzles)
     nozzle_rack: list = field(default_factory=list)
+    # H2C rack position currently mounted / being moved to, from
+    # device.nozzle.src_id / tar_id. These are PHYSICAL nozzle IDs (16-21 for
+    # the six rack slots), not extruder indices, and they are what the
+    # dispatch `nozzle_mapping` array has to carry (#2800). Only the printer
+    # can tell us which hotend is in the carriage right now, so this is read
+    # live rather than derived from the queued job.
+    nozzle_rack_src_id: int | None = None
+    nozzle_rack_tar_id: int | None = None
     # Timestamp of last AMS data update (for RFID refresh detection)
     last_ams_update: float = 0.0
     # Printable objects for skip object functionality: {identify_id: object_name}
@@ -4285,6 +4386,36 @@ class BambuMQTTClient:
         if "device" in data and isinstance(data["device"], dict):
             device = data["device"]
             nozzle_data = device.get("nozzle", {})
+
+            # H2C rack position (#2800). `tar_id` is where the carriage is
+            # headed, `src_id` where it came from; mid-swap they differ, so
+            # dispatch prefers tar_id and falls back to src_id. Both are
+            # sticky — the field is only pushed when it changes, so an
+            # absent key must leave the last known value alone rather than
+            # reset it to None.
+            if isinstance(nozzle_data, dict):
+                for key, attr in (("src_id", "nozzle_rack_src_id"), ("tar_id", "nozzle_rack_tar_id")):
+                    if key not in nozzle_data:
+                        continue
+                    try:
+                        parsed_id = int(nozzle_data[key])
+                    except (TypeError, ValueError):
+                        continue
+                    if getattr(self.state, attr) != parsed_id:
+                        setattr(self.state, attr, parsed_id)
+                        # DEBUG, not INFO: these move on every tool change, so
+                        # a long multi-material print would otherwise write
+                        # thousands of lines. The dispatch log records both
+                        # values once per print, which is where triage needs
+                        # them. Same reasoning as the one-shot `nozzle_info`
+                        # log below.
+                        logger.debug(
+                            "[%s] Nozzle rack %s -> %s",
+                            self.serial_number,
+                            key,
+                            parsed_id,
+                        )
+
             nozzle_info = nozzle_data.get("info", [])
             if isinstance(nozzle_info, list):
                 # H2 series: nozzle_info contains extended nozzle data (wear, serial,
@@ -4946,6 +5077,7 @@ class BambuMQTTClient:
         use_ams: bool = True,
         nozzle_offset_cali: str = "auto",
         nozzle_mapping: str | None = None,
+        nozzle_slot_extruders: str | None = None,
     ):
         """Start a print job on the printer.
 
@@ -4972,6 +5104,14 @@ class BambuMQTTClient:
                 firmware honours the user's slicer pick instead of falling
                 back to "last matching nozzle" auto-pick. Silently ignored
                 on single-nozzle printers.
+            nozzle_slot_extruders: Opaque JSON string of per-filament-slot
+                MQTT extruder indices, derived from the 3MF when no
+                BambuStudio capture exists (#2800). Consulted only on
+                nozzle-rack models (H2C) and only when `nozzle_mapping` did
+                not already supply one; resolved here into physical rack
+                positions using the live `device.nozzle` state. When it
+                cannot be resolved the field is omitted and the firmware
+                picks, as it did before this existed.
 
         Returns True when the start command was published, False otherwise
         (not connected, or the printer is already busy — see the run-state
@@ -5017,7 +5157,7 @@ class BambuMQTTClient:
             # model name for the brief window after connect before push data
             # arrives. _is_dual_nozzle only ever flips False→True, so it's safe
             # as the primary signal.
-            from backend.app.utils.printer_models import is_dual_nozzle_model
+            from backend.app.utils.printer_models import is_dual_nozzle_model, is_nozzle_rack_model
 
             is_dual_nozzle = self._is_dual_nozzle or is_dual_nozzle_model(self.model)
 
@@ -5227,6 +5367,52 @@ class BambuMQTTClient:
                         nozzle_mapping,
                     )
 
+            # Nozzle-rack fallback (#2800). Only consulted when BambuStudio
+            # never saw the job, so it can never override a real capture. The
+            # queue stores extruder indices per filament slot; the physical
+            # rack position they resolve to is only knowable here, because the
+            # mounted hotend can change between queueing and dispatch.
+            if is_nozzle_rack_model(self.model) and nozzle_slot_extruders and "nozzle_mapping" not in command["print"]:
+                try:
+                    slot_extruders = json.loads(nozzle_slot_extruders)
+                except (json.JSONDecodeError, TypeError):
+                    # TypeError covers a caller handing us the list itself
+                    # rather than its JSON — the field is opaque by contract,
+                    # and a print must not die over the difference.
+                    slot_extruders = None
+                    logger.warning(
+                        "[%s] Invalid nozzle_slot_extruders JSON on dispatch, "
+                        "omitting nozzle_mapping (firmware will auto-pick): %r",
+                        self.serial_number,
+                        nozzle_slot_extruders,
+                    )
+
+                if isinstance(slot_extruders, list):
+                    rack_nozzle_id = (
+                        self.state.nozzle_rack_tar_id
+                        if self.state.nozzle_rack_tar_id in _RACK_NOZZLE_IDS
+                        else self.state.nozzle_rack_src_id
+                    )
+                    resolved = resolve_rack_nozzle_mapping(slot_extruders, rack_nozzle_id)
+                    if resolved is None:
+                        logger.info(
+                            "[%s] Nozzle rack slots %s not resolvable (tar_id=%s src_id=%s); "
+                            "omitting nozzle_mapping so the firmware picks",
+                            self.serial_number,
+                            slot_extruders,
+                            self.state.nozzle_rack_tar_id,
+                            self.state.nozzle_rack_src_id,
+                        )
+                    else:
+                        logger.info(
+                            "[%s] Nozzle rack mapping: slots=%s rack_id=%s -> %s",
+                            self.serial_number,
+                            slot_extruders,
+                            rack_nozzle_id,
+                            resolved,
+                        )
+                        command["print"]["nozzle_mapping"] = resolved
+
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             # Record what we dispatched so /cover can pick the right plate

+ 154 - 34
backend/app/services/print_scheduler.py

@@ -56,7 +56,12 @@ from backend.app.services.printer_manager import (
 )
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.filename import derive_remote_filename
-from backend.app.utils.printer_models import is_gcode_compatible, normalize_printer_model
+from backend.app.utils.printer_models import (
+    is_gcode_compatible,
+    is_nozzle_rack_model,
+    normalize_printer_model,
+)
+from backend.app.utils.threemf_tools import extract_slot_extruders_from_3mf
 
 logger = logging.getLogger(__name__)
 
@@ -802,7 +807,7 @@ class PrintScheduler:
                 # timeout expired.
                 self._sweep_keep_warm(active_candidates=set(), dispatched=set())
                 inflight_printers = {pid for (_task, pid) in self._inflight.values() if pid is not None}
-                await self._check_auto_drying(db, [], inflight_printers, require_plate_clear=require_plate_clear)
+                await self._check_auto_drying(db, [], inflight_printers)
                 return bool(self._inflight)
 
             logger.info(
@@ -847,6 +852,23 @@ class PrintScheduler:
                 if inflight_pid is not None:
                     busy_printers.add(inflight_pid)
 
+            # Snapshot taken here, before the item loop adds anything (#2801).
+            #
+            # The three sources above all mean the same thing: a print on this
+            # printer is running or imminent. Everything the loop adds below
+            # means only "the queue could not dispatch to it this pass", which
+            # is a different statement -- a printer waiting on a plate-clear
+            # acknowledgment, an offline printer, one with no matching file.
+            #
+            # Auto-drying must only see the first kind. Reading the whole set
+            # as "is currently printing" is what put a plate-held printer down
+            # the mid-print path: it capped the drying temperature, logged the
+            # cycle as (mid-print), and skipped the very gate that was supposed
+            # to hold it. The interlock block below already documents the same
+            # hazard and works around it by staying out of busy_printers; this
+            # generalises that workaround instead of repeating it per case.
+            dispatching_printers: set[int] = set(busy_printers)
+
             # Printers held by a Home Assistant sensor interlock (#1148) — an
             # enclosure door left open, say. The fixed-printer branch turns
             # this into a waiting_reason the user can act on; the model-based
@@ -1006,24 +1028,17 @@ class PrintScheduler:
 
                     # Check if printer is idle (busy with another print)
                     if not printer_idle:
-                        # If printer is drying (not truly busy), handle based on queue_drying_block
-                        if self._drying_in_progress.get(item.printer_id):
-                            block_for_drying = await self._get_bool_setting(db, "queue_drying_block")
-                            if block_for_drying:
-                                # Drying blocks queue — skip this printer
-                                busy_printers.add(item.printer_id)
-                                continue
-                            else:
-                                # Print takes priority — stop drying
-                                await self._stop_drying(item.printer_id)
-                                # Re-check idle after stopping drying
-                                printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
-                                if not printer_idle:
-                                    busy_printers.add(item.printer_id)
-                                    continue
-                        else:
-                            busy_printers.add(item.printer_id)
-                            continue
+                        busy_printers.add(item.printer_id)
+                        continue
+
+                    # Drying blocks the queue, if the user asked it to. A hold
+                    # is a skip like any other, so it belongs here with the
+                    # rest of the availability checks.
+                    if self._drying_in_progress.get(item.printer_id) and await self._get_bool_setting(
+                        db, "queue_drying_block"
+                    ):
+                        busy_printers.add(item.printer_id)
+                        continue
 
                     # Check condition (previous print success)
                     if item.require_previous_success:
@@ -1071,6 +1086,28 @@ class PrintScheduler:
                         busy_printers.add(item.printer_id)
                         continue
 
+                    # Print takes priority: stop a cycle Bambuddy armed, now
+                    # that this item is definitely going out.
+                    #
+                    # Placement is the whole point (#2801). This used to sit up
+                    # with the availability checks, inside the not-idle branch
+                    # -- so it fired only on the passes where the print was NOT
+                    # going to start, and never on the ones where it was.
+                    # Drying is not one of the things `_is_printer_idle` looks
+                    # at, so a stop could never have unblocked that printer
+                    # anyway; the cycle was spent for nothing, auto-drying
+                    # re-armed on the next tick, and a plate left
+                    # unacknowledged turned that into a loop on the scheduler
+                    # interval. Every skip between there and here -- a failed
+                    # previous print, an unmappable item, a filament deficit, a
+                    # contested library row -- is another way to lose a cycle
+                    # for a print that never happens, which is why this waits
+                    # until the decision is actually made.
+                    if self._drying_in_progress.get(
+                        item.printer_id
+                    ) and not await self._drying_may_continue_through_print(db, item.printer_id):
+                        await self._stop_drying(item.printer_id)
+
                     # Queue the dispatch instead of running it here — see
                     # _dispatch_selected(). busy_printers still gets the printer
                     # immediately, so nothing else in this pass can target it.
@@ -1369,7 +1406,7 @@ class PrintScheduler:
                 self._launch_uploads(dispatch_ids, item_printers, upload_limit)
 
             # Auto-drying: start drying on idle printers that have no pending queue items
-            await self._check_auto_drying(db, items, busy_printers, require_plate_clear=require_plate_clear)
+            await self._check_auto_drying(db, items, dispatching_printers)
 
             # Keep the loop on the fast interval while any upload is in flight so
             # a slot freed mid-tick refills within seconds rather than after the
@@ -3193,9 +3230,7 @@ class PrintScheduler:
         self,
         db: AsyncSession,
         queue_items: list[PrintQueueItem],
-        busy_printers: set[int],
-        *,
-        require_plate_clear: bool = True,
+        dispatching_printers: set[int],
     ):
         """Start drying on idle printers based on humidity.
 
@@ -3268,12 +3303,20 @@ class PrintScheduler:
             model = printer_manager.get_model(pid)
             firmware = state.firmware_version
 
-            mid_print = (
-                pid in busy_printers and print_drying_enabled and supports_drying_while_printing(model, firmware)
-            )
-
-            if pid in busy_printers and not mid_print:
-                logger.debug("Auto-drying: printer %d skipped — busy", pid)
+            # "Mid-print" has to mean the printer is actually printing (#2801).
+            # It used to be inferred from the dispatch set, which also holds
+            # printers that merely could not be dispatched to -- so a printer
+            # sitting in FINISH behind an unacknowledged plate was treated as
+            # printing, had its drying temperature capped by the mid-print
+            # spool protection, and was logged as (mid-print) while idle.
+            is_printing = state.state in _ACTIVE_PRINT_STATES
+            mid_print = is_printing and print_drying_enabled and supports_drying_while_printing(model, firmware)
+
+            # A printer whose print is running or imminent is left alone unless
+            # it can dry through it. `dispatching_printers` is deliberately the
+            # narrow set: running, held post-dispatch, or mid-upload.
+            if (is_printing or pid in dispatching_printers) and not mid_print:
+                logger.debug("Auto-drying: printer %d skipped — printing or about to", pid)
                 continue
 
             if not mid_print:
@@ -3292,7 +3335,14 @@ class PrintScheduler:
             if not printer_manager.is_connected(pid):
                 logger.debug("Auto-drying: printer %d skipped — not connected", pid)
                 continue
-            if not mid_print and not self._is_printer_idle(pid, require_plate_clear):
+            # Plate-clear is deliberately ignored here (#2801). It answers
+            # "is the bed ready for the next job", which says nothing about
+            # whether the AMS may heat -- and the gap between a finished print
+            # and the acknowledgment is exactly when drying is most useful,
+            # because the printer is free and nobody is waiting on it. Leaving
+            # the plate unacknowledged is also how people hold the queue by
+            # hand, and that hold should not cost them their drying.
+            if not mid_print and not self._is_printer_idle(pid, require_plate_clear=False):
                 logger.debug("Auto-drying: printer %d skipped — not idle", pid)
                 continue
 
@@ -3422,7 +3472,18 @@ class PrintScheduler:
                             humidity,
                             humidity_threshold,
                         )
-                    self._auto_dry_units.pop(unit_key, None)
+                    # Clear the judgement, keep the clock (#2801). Dropping the
+                    # whole entry also dropped `ended_at`, and with it the
+                    # 30-minute cooldown -- so a reading that dips to the
+                    # threshold as the AMS cools and comes back above it once
+                    # warm wiped its own history and re-armed immediately. That
+                    # oscillation is the very thing #2770's cooldown exists to
+                    # ride out, and it is worst at exactly the margin that makes
+                    # a unit dry repeatedly: a point or two above the threshold.
+                    if unit_state is not None:
+                        unit_state.pop("suspended", None)
+                        unit_state.pop("unproductive", None)
+                        unit_state.pop("best_end_humidity", None)
                     logger.debug(
                         "Auto-drying: printer %d AMS %d skipped — humidity %s <= threshold %d",
                         pid,
@@ -3615,8 +3676,36 @@ class PrintScheduler:
         for key in [k for k in self._auto_dry_units if printer_manager.get_status(k[0]) is None]:
             self._auto_dry_units.pop(key, None)
 
+    async def _drying_may_continue_through_print(self, db: AsyncSession, printer_id: int) -> bool:
+        """True when a running cycle can be left alone while the next print runs.
+
+        Some hardware dries happily through a print and #2758 settled that we
+        should not tear those cycles down: the X2D there was refusing to
+        *start* a job, which is a different problem, and stopping drying before
+        every dispatch would throw away cycles the printer was content to run.
+        Where the model cannot do it, or the user has not enabled it, the print
+        takes priority and the cycle stops -- which is what the queue_drying_block
+        setting has always promised in its off position.
+        """
+        if not await self._get_bool_setting(db, "print_drying_enabled"):
+            return False
+        status = printer_manager.get_status(printer_id)
+        return supports_drying_while_printing(
+            printer_manager.get_model(printer_id),
+            status.firmware_version if status else None,
+        )
+
     async def _stop_drying(self, printer_id: int):
-        """Stop all active drying on a printer (print takes priority)."""
+        """Stop drying cycles Bambuddy armed on a printer (print takes priority).
+
+        Scoped to units in ``_auto_dry_units``. It used to send a stop to every
+        AMS reporting ``dry_time > 0``, which meant one auto-dried unit was
+        enough to kill a cycle the user had started by hand on a *different*
+        unit of the same printer (#2801). That contradicted the contract
+        ``_sync_drying_state`` already documents -- the entry gate deliberately
+        only knows about cycles Bambuddy began, so the action must not reach
+        past them either.
+        """
         state = printer_manager.get_status(printer_id)
         if not state:
             self._drying_in_progress.pop(printer_id, None)
@@ -3627,6 +3716,13 @@ class PrintScheduler:
             dry_time = int(ams_data.get("dry_time") or 0)
             if dry_time > 0:
                 ams_id = int(ams_data.get("id", 0))
+                if (printer_id, ams_id) not in self._auto_dry_units:
+                    logger.debug(
+                        "Auto-drying: leaving printer %d AMS %d alone — not a cycle Bambuddy started",
+                        printer_id,
+                        ams_id,
+                    )
+                    continue
                 logger.info(
                     "Auto-drying: stopping drying on printer %d AMS %d — print takes priority",
                     printer_id,
@@ -5362,11 +5458,34 @@ class PrintScheduler:
         # FINISH-state fallback — no need to force a video.
         effective_timelapse = bool(item.timelapse)
 
+        # Nozzle-rack fallback (#2800). A job that never passed through the
+        # Virtual Printer carries no Bambu Studio nozzle pick, and an H2C then
+        # dispatches with no nozzle field at all and chooses for itself — which
+        # is how a print levelled on one hotend and then printed on another,
+        # millimetres above the plate. Derive the per-slot extruder assignment
+        # from the file being dispatched.
+        #
+        # Done here rather than at queue time because this is the first point
+        # that knows both the actual printer and the actual file: an item can
+        # be created without a printer (model-based assignment), reassigned
+        # afterwards, or have its file swapped for a G-code-injected copy just
+        # above. Every queue-creation path — the print dialog, a bulk library
+        # add, the webhook, a pipeline run — is covered by the one call.
+        # Skipped when the item already carries a Bambu Studio capture: that
+        # one wins downstream anyway, so reading the 3MF again would be work
+        # thrown away on every dispatch.
+        nozzle_slot_extruders = None
+        if not item.nozzle_mapping and file_path is not None and is_nozzle_rack_model(printer.model):
+            slot_extruders = extract_slot_extruders_from_3mf(file_path)
+            if slot_extruders:
+                nozzle_slot_extruders = json.dumps(slot_extruders)
+
         # Start the print with AMS mapping, plate_id and print options.
         # nozzle_mapping rides through verbatim — JSON string captured from
         # Bambu Studio's project_file on VP intake (#1780); the MQTT layer
         # parses + injects it only for dual-nozzle models so a null on every
-        # other model is a transparent pass-through.
+        # other model is a transparent pass-through. The rack fallback is
+        # resolved down there too, where the live rack position is known.
         started = printer_manager.start_print(
             item.printer_id,
             remote_filename,
@@ -5380,6 +5499,7 @@ class PrintScheduler:
             use_ams=item.use_ams,
             nozzle_offset_cali=item.nozzle_offset_cali,
             nozzle_mapping=item.nozzle_mapping,
+            nozzle_slot_extruders=nozzle_slot_extruders,
         )
 
         if started:

+ 6 - 0
backend/app/services/printer_manager.py

@@ -871,6 +871,7 @@ class PrinterManager:
         use_ams: bool = True,
         nozzle_offset_cali: str = "auto",
         nozzle_mapping: str | None = None,
+        nozzle_slot_extruders: str | None = None,
     ) -> bool:
         """Start a print on a connected printer.
 
@@ -878,6 +879,10 @@ class PrinterManager:
         project_file MQTT command (H2C rack-swap slicer pick preservation,
         #1780). It rides through to the MQTT client untouched; the dispatch
         builder there parses + injects it only on dual-nozzle models.
+
+        ``nozzle_slot_extruders`` is the fallback for a job that never passed
+        through BambuStudio (#2800): per-slot extruder indices the MQTT layer
+        resolves into physical rack positions, and only on rack models.
         """
         caller = traceback.extract_stack(limit=3)[0]
         logger.info(
@@ -901,6 +906,7 @@ class PrinterManager:
                 use_ams=use_ams,
                 nozzle_offset_cali=nozzle_offset_cali,
                 nozzle_mapping=nozzle_mapping,
+                nozzle_slot_extruders=nozzle_slot_extruders,
             )
         return False
 

+ 109 - 0
backend/app/services/process_overrides.py

@@ -0,0 +1,109 @@
+"""Apply the user's own process-setting choices to an outgoing slice.
+
+Bambuddy's slice modal can edit OrcaSlicer's full process parameter set (layer
+height, wall count, supports, speeds — the same tree the desktop slicer shows
+under Print Settings). Those edits arrive as a sparse ``{key: value}`` map and
+are written into the process JSON that goes out as ``--load-settings``, using
+the same mechanism ``_patch_process_support_settings`` (#1881) and
+``apply_design_overrides`` (#2622) already use.
+
+Precedence is deliberate and is the reason this runs last: the picked preset is
+the base, the source 3MF's support configuration and the designer's own tweaks
+layer on top, and an explicit choice the user made in the modal beats all of
+them. Anything else would silently discard a setting the user just typed.
+
+Values are normalised to the string forms a process preset actually stores
+(``"1"`` for a bool, ``"20%"`` for a percent, a list of strings for the
+per-extruder vector options). The frontend already serialises through the option
+schema, so this is a second line of defence for clients that don't — the slicer
+CLI validates far more strictly than the GUI and a wrongly-typed value fails the
+whole slice rather than being coerced.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+
+logger = logging.getLogger(__name__)
+
+# Config keys are lowercase identifiers. Anything else did not come from the
+# option schema, so it cannot be a real process setting.
+_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*$")
+
+# A process JSON is a flat string map; nesting a structure inside it produces a
+# file the CLI rejects outright.
+_ScalarTypes = (str, int, float, bool)
+
+
+def _normalise_scalar(value: object) -> str | None:
+    """Render one scalar the way a process preset stores it, or ``None`` if it
+    is not a value a process setting can hold."""
+    if isinstance(value, bool):
+        # Checked before int on purpose — bool is a subclass of int, and a
+        # process JSON spells booleans "1"/"0", never "True"/"False".
+        return "1" if value else "0"
+    if isinstance(value, (int, float)):
+        return str(value)
+    if isinstance(value, str):
+        return value
+    return None
+
+
+def normalise_process_overrides(overrides: dict[str, object]) -> dict[str, str | list[str]]:
+    """Filter and normalise a client-supplied override map.
+
+    Keys that don't look like config keys, and values that a process preset
+    cannot hold, are dropped with a warning rather than failing the slice: the
+    user's other settings are still worth applying, and a hard failure here
+    would be reported as "slicing failed" with no clue which field caused it.
+    """
+    clean: dict[str, str | list[str]] = {}
+    for key, value in overrides.items():
+        if not isinstance(key, str) or not _KEY_RE.match(key):
+            logger.warning("Ignoring process override with unusable key: %r", key)
+            continue
+
+        if isinstance(value, list):
+            parts = [_normalise_scalar(v) for v in value]
+            if any(p is None for p in parts):
+                logger.warning("Ignoring process override %s: list contains a non-scalar entry", key)
+                continue
+            clean[key] = [p for p in parts if p is not None]
+            continue
+
+        scalar = _normalise_scalar(value)
+        if scalar is None:
+            logger.warning("Ignoring process override %s: unsupported value type %s", key, type(value).__name__)
+            continue
+        clean[key] = scalar
+
+    return clean
+
+
+def apply_process_overrides(process_json: str, overrides: dict[str, object]) -> str:
+    """Write the user's process settings into the outgoing process JSON.
+
+    Returns ``process_json`` unchanged when there is nothing to apply or the
+    JSON is unparseable, so a bad input degrades to a slice with the picked
+    preset rather than failing it — matching ``apply_design_overrides``.
+    """
+    if not overrides:
+        return process_json
+
+    clean = normalise_process_overrides(overrides)
+    if not clean:
+        return process_json
+
+    try:
+        process_cfg = json.loads(process_json)
+    except json.JSONDecodeError:
+        logger.warning("Process preset JSON is unparseable; skipping %d user override(s)", len(clean))
+        return process_json
+    if not isinstance(process_cfg, dict):
+        return process_json
+
+    process_cfg.update(clean)
+    logger.info("Applying %d user process override(s): %s", len(clean), sorted(clean))
+    return json.dumps(process_cfg)

+ 182 - 6
backend/app/services/slicer_api.py

@@ -10,6 +10,7 @@ under the hood, response body is raw G-code or 3MF with metadata in the
 
 import asyncio
 import io
+import json
 import logging
 import time
 import zipfile
@@ -53,6 +54,18 @@ class SlicerTimeoutError(SlicerApiError):
     """
 
 
+class ResolvedProfile(NamedTuple):
+    """A preset's effective values, or why they are unavailable.
+
+    ``reason`` is one of ``ok`` / ``sidecar_outdated`` / ``sidecar_unavailable``
+    / ``preset_unresolved``. It exists so the UI can say something actionable
+    instead of one generic "could not read the values" for four causes.
+    """
+
+    values: dict | None
+    reason: str
+
+
 class SliceResult(NamedTuple):
     """Result of a slice operation."""
 
@@ -119,7 +132,84 @@ def _format_sidecar_error(response: httpx.Response) -> str:
     return (message or details or response.text)[:500]
 
 
-def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> SliceResult:
+def _transport_error_reason(exc: httpx.RequestError) -> str:
+    """Describe a transport failure, even when the exception carries no message.
+
+    Several ``httpx.RequestError`` subclasses are raised with no args, so
+    ``str(exc)`` is the empty string — which is how three lines of the #2802
+    reporter's support package came to read ``Slicer sidecar unreachable:``
+    with nothing after the colon. The class name is not much, but it
+    distinguishes a refused connection from a protocol error, and a log line
+    that names nothing is worth less than one that names the exception type.
+    """
+    return str(exc) or type(exc).__name__
+
+
+# How the sidecar says "your model is bigger than my cap", across versions.
+# Images built before the cap became configurable answer with multer's raw
+# ``LIMIT_FILE_SIZE`` text under a **500** — ``MulterError`` is not the
+# sidecar's ``AppError``, so its handler falls through to the default status —
+# while current ones send a 413 naming the limit and the env var that raises
+# it. Matching on text rather than status covers both, and matters because a
+# 500 otherwise reads as a slicer crash and sends people off tuning reverse
+# proxies that were never in the path (#2802).
+#
+# Deliberately specific: a proxy's own "413 Request Entity Too Large" must NOT
+# match, because that one really is fixed at the proxy and gets its own advice.
+_UPLOAD_TOO_LARGE_MARKERS = (
+    "file too large",
+    "upload limit",
+    "max_model_upload_mb",
+)
+
+# A sidecar that says which knob raises the cap is new enough to have one.
+# Older ones only ever emit multer's bare "File too large", and for those the
+# advice has to be "update the image" — there is no env var to set.
+_CONFIGURABLE_CAP_MARKERS = ("upload limit", "max_model_upload_mb")
+
+
+def _upload_size_rejection(response: httpx.Response, model_size_bytes: int | None) -> str | None:
+    """Return an explanation if the sidecar refused the upload as oversized.
+
+    The 500 case is matched strictly — the body has to be *only* multer's
+    message — because a 500 is also how a genuine CLI failure arrives, and
+    those must keep reaching the embedded-settings fallback. A CLI failure
+    always carries the slicer's stderr in ``details``, so it never reduces to
+    the bare string on its own.
+    """
+    detail = _format_sidecar_error(response)
+    lowered = detail.lower()
+    if response.status_code >= 500:
+        if lowered.strip() != "file too large":
+            return None
+    elif not any(marker in lowered for marker in _UPLOAD_TOO_LARGE_MARKERS):
+        return None
+
+    size = f"{model_size_bytes / (1024 * 1024):.0f} MB " if model_size_bytes else ""
+    # Shared preamble: both variants must rule out the layers people reach for
+    # first, because those are the ones that look like they should apply.
+    common = (
+        f"The slicer sidecar refused the {size}model file as too large. The limit lives inside "
+        "the sidecar container, so it is neither a Bambuddy setting nor a reverse-proxy one — "
+        "raising 'client_max_body_size' or a proxy body limit will not change it."
+    )
+
+    if any(marker in lowered for marker in _CONFIGURABLE_CAP_MARKERS):
+        return (
+            f"{common} Raise it by setting MAX_MODEL_UPLOAD_MB on the slicer-api service and "
+            f"restarting it. Sidecar said: {detail}"
+        )
+    return (
+        f"{common} This sidecar image predates the configurable cap and is fixed at 100 MB — "
+        "update it with 'cd slicer-api/ && docker compose pull && docker compose up -d', which "
+        "raises the default and adds MAX_MODEL_UPLOAD_MB for going higher still. "
+        f"Sidecar said: {detail}"
+    )
+
+
+def _handle_slice_response(
+    response: httpx.Response, *, export_3mf: bool, model_size_bytes: int | None = None
+) -> SliceResult:
     """Turn a sidecar ``/slice`` HTTP response into a validated ``SliceResult``.
 
     Shared by ``slice_with_profiles`` / ``slice_without_profiles`` so the status
@@ -138,6 +228,14 @@ def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> Sli
         SlicerInputError: 4xx from the sidecar (bad input / proxy body limit).
         SlicerApiServerError: 5xx, or a 2xx whose body is not a valid 3MF.
     """
+    # Checked ahead of the status branches because the same rejection arrives
+    # as a 500 from older sidecars and a 413 from newer ones, and because
+    # raising SlicerInputError (rather than SlicerApiServerError) is what stops
+    # the library route retrying the identical oversized upload with embedded
+    # settings — a second 25-second conversion for a guaranteed same answer.
+    oversized = _upload_size_rejection(response, model_size_bytes)
+    if oversized:
+        raise SlicerInputError(oversized)
     if response.status_code == 413:
         # A 413 almost never comes from the slicer itself — it's a reverse proxy
         # (nginx/SWAG/Traefik) or a CDN capping the multipart upload (model +
@@ -304,11 +402,70 @@ class SlicerApiService:
         try:
             response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
         except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
         if response.status_code >= 400:
             raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
         return response.json()
 
+    async def resolve_profile(self, profile_json: str, category: str) -> "ResolvedProfile":
+        """POST /profiles/resolve — flatten a preset's ``inherits:`` chain.
+
+        Returns the effective key/value map the slicer would actually use, so
+        the slice modal's settings panel can show a preset's real values rather
+        than the option schema's compiled-in defaults (a "Standard" pick is
+        only a ``{inherits: ...}`` stub on our side; everything else it sets
+        lives in the sidecar's bundled profiles).
+
+        This deliberately asks the sidecar rather than resolving locally.
+        Bambuddy has its own ``inherits:`` resolver in ``orca_profiles``, but it
+        walks OrcaSlicer's *published* profile tree, which is not necessarily
+        the one baked into the running sidecar image — values from it would look
+        authoritative and could quietly disagree with what gets sliced.
+
+        Returns a :class:`ResolvedProfile` whose ``reason`` distinguishes *why*
+        values are missing. That matters more than it looks: the common case in
+        practice is a sidecar older than this endpoint, because a Bambuddy
+        install pulls ``SIDECAR_TAG:-latest`` independently of its own release
+        channel. "Could not read the values" sends that user hunting; "your
+        sidecar image is older than this feature" is a one-line fix. Genuine
+        transport failures still raise.
+        """
+        try:
+            payload = json.loads(profile_json)
+        except json.JSONDecodeError:
+            logger.warning("Cannot resolve %s preset: content is not valid JSON", category)
+            return ResolvedProfile(None, "preset_unresolved")
+
+        try:
+            response = await self._client.post(
+                f"{self.base_url}/profiles/resolve",
+                json={"category": category, "profile": payload},
+                timeout=15.0,
+            )
+        except httpx.RequestError as exc:
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
+
+        if response.status_code == 404:
+            # Sidecar predates the endpoint. Not an error, and specifically not
+            # the same as a broken one — this is the case that has a fix the
+            # user can act on.
+            logger.info("Slicer sidecar has no /profiles/resolve; falling back to schema defaults")
+            return ResolvedProfile(None, "sidecar_outdated")
+        if response.status_code >= 400:
+            logger.warning(
+                "Slicer sidecar /profiles/resolve returned %s: %s",
+                response.status_code,
+                _format_sidecar_error(response),
+            )
+            return ResolvedProfile(None, "sidecar_unavailable")
+
+        body = response.json()
+        resolved = body.get("profile") if isinstance(body, dict) else None
+        if not isinstance(resolved, dict):
+            logger.warning("Slicer sidecar /profiles/resolve returned no profile object")
+            return ResolvedProfile(None, "sidecar_unavailable")
+        return ResolvedProfile(resolved, "ok")
+
     async def list_bundled_profiles(self) -> dict:
         """GET /profiles/bundled — return the slicer's stock profiles by slot.
 
@@ -325,7 +482,7 @@ class SlicerApiService:
         try:
             response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
         except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
         if response.status_code >= 400:
             raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
         return response.json()
@@ -458,7 +615,7 @@ class SlicerApiService:
         try:
             return post_task.result()
         except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
 
     async def slice_with_profiles(
         self,
@@ -540,8 +697,9 @@ class SlicerApiService:
         # and surfaces structured updates via on_progress. Uses a
         # short-tick poll (1s) since the slicer emits stage changes
         # several times per minute on complex models.
+        _log_slice_request(model_filename, model_bytes, plate=plate, profiles=len(filament_profile_jsons) + 2)
         response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
-        return _handle_slice_response(response, export_3mf=export_3mf)
+        return _handle_slice_response(response, export_3mf=export_3mf, model_size_bytes=len(model_bytes))
 
     async def slice_without_profiles(
         self,
@@ -596,8 +754,26 @@ class SlicerApiService:
         # embedded-settings fallback path triggered by an Orca/Bambu CLI
         # segfault on complex H2D models — both want to keep updating
         # the user's toast through the slow operation.
+        _log_slice_request(model_filename, model_bytes, plate=plate, profiles=0)
         response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
-        return _handle_slice_response(response, export_3mf=export_3mf)
+        return _handle_slice_response(response, export_3mf=export_3mf, model_size_bytes=len(model_bytes))
+
+
+def _log_slice_request(filename: str, model_bytes: bytes, *, plate: int | None, profiles: int) -> None:
+    """Record what is being sent to the sidecar, size included.
+
+    Nothing used to log the payload size, so a support package from a slice
+    that failed on an upload cap looked identical to one that failed on a bad
+    profile — #2802 had to be sized by probing a sidecar by hand. One line per
+    slice is cheap next to the operation it describes.
+    """
+    logger.info(
+        "Slicing %s (%.1f MB) plate=%s with %d profile(s)",
+        filename,
+        len(model_bytes) / (1024 * 1024),
+        "all" if plate is None else plate,
+        profiles,
+    )
 
 
 def _add_layout_flags(data: dict[str, str], *, arrange: bool, orient: bool) -> None:

+ 35 - 0
backend/app/utils/printer_models.py

@@ -234,6 +234,28 @@ DUAL_NOZZLE_MODELS = frozenset(
 )
 
 
+# Printers with a swappable nozzle rack ("Vortek"): the H2C carries six
+# hotends in a rack and mounts one of them on its right extruder at a time.
+#
+# Why this needs its own set rather than reusing DUAL_NOZZLE_MODELS: on every
+# other dual-nozzle printer the dispatch `nozzle_mapping` values ARE the MQTT
+# extruder indices (0 = right, 1 = left). On a rack model the wire wants the
+# *physical* nozzle position, and the rack positions are reported by the
+# firmware as IDs 16-21 — see `device.nozzle.info` handling in bambu_mqtt.
+# Sending an extruder index where a rack position is expected makes the
+# printer clean and level with one nozzle and then print with another, at the
+# wrong Z (#2800).
+NOZZLE_RACK_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "H2C",
+        # Internal codes
+        "O1C",  # H2C
+        "O1C2",  # H2C (dual nozzle variant)
+    ]
+)
+
+
 # Models where Bambu's own firmware/UI names the enclosure fan (big_fan2 /
 # airduct part id 3) "Exhaust" rather than "Chamber". On these the printer's
 # touchscreen and Bambu Studio both call it the exhaust fan, and on the P2S it
@@ -322,6 +344,19 @@ def is_dual_nozzle_model(model: str | None) -> bool:
     return normalized in DUAL_NOZZLE_MODELS
 
 
+def is_nozzle_rack_model(model: str | None) -> bool:
+    """Return True if the model mounts its nozzles from a swappable rack (H2C).
+
+    Accepts both the display name and the internal SSDP code, because
+    ``BambuMQTTClient.model`` carries whichever the printer row happens to
+    hold — the same reason the P2S dispatch tweak checks ``("P2S", "N7")``.
+    """
+    if not model:
+        return False
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    return normalized in NOZZLE_RACK_MODELS
+
+
 def supports_nozzle_flow_type(model: str | None) -> bool:
     """Return True if the model offers a Standard / High Flow nozzle choice.
 

+ 47 - 0
backend/app/utils/threemf_tools.py

@@ -310,6 +310,53 @@ def extract_embedded_presets_from_3mf(zf: zipfile.ZipFile) -> dict[str, str | No
     return result
 
 
+# Ceiling on the dense per-slot form below. Deliberately larger than the 32
+# entries a print command carries, so a legitimate file is never silently
+# truncated at the limit -- it is either usable or rejected outright.
+_MAX_DENSE_FILAMENT_SLOTS = 64
+
+
+def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None:
+    """Per-slot extruder assignment as a dense list, or None (#2800).
+
+    Same data as :func:`extract_nozzle_mapping_from_3mf`, reshaped for the
+    dispatcher: index 0 is filament slot 1, and a slot this file does not
+    print is ``-1``. Nozzle-rack printers (H2C) need it to build the physical
+    ``nozzle_mapping`` the firmware expects — without one they fall back to
+    picking a nozzle themselves, which can level with one hotend and print
+    with another, several millimetres off the bed.
+
+    Takes a path rather than an open archive because the dispatcher is
+    handling the file, not the zip, and a broken file there must not take the
+    print down: an unreadable or non-3MF path returns None, and the caller
+    dispatches exactly as it did before this existed.
+    """
+    try:
+        with zipfile.ZipFile(file_path) as zf:
+            by_slot = extract_nozzle_mapping_from_3mf(zf)
+    except (zipfile.BadZipFile, OSError) as exc:
+        logger.warning("Failed to read nozzle mapping from %s: %s", file_path, exc)
+        return None
+
+    if not by_slot:
+        return None
+
+    # The slot IDs are whatever the file says, so the dense form has to be
+    # bounded before it is built: a corrupt or hostile 3MF declaring
+    # `filament id="50000000"` would otherwise allocate a fifty-million-entry
+    # list here, on the dispatch path. Nothing above 32 is usable anyway --
+    # that is the length of the array the printer is sent.
+    highest_slot = max(by_slot)
+    if highest_slot < 1 or highest_slot > _MAX_DENSE_FILAMENT_SLOTS:
+        logger.warning(
+            "Ignoring nozzle mapping from %s: highest filament slot %s is out of range",
+            file_path,
+            highest_slot,
+        )
+        return None
+    return [by_slot.get(slot, -1) for slot in range(1, highest_slot + 1)]
+
+
 def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
     """Extract per-slot nozzle/extruder mapping from a 3MF file.
 

+ 240 - 0
backend/tests/integration/test_api_key_owner_authority_1894.py

@@ -0,0 +1,240 @@
+"""An API key must not out-rank the user it belongs to (#1894 follow-on).
+
+``_check_apikey_permissions`` gated purely on the scope flags stored on the key
+row and never looked at the owner. Scope flags are chosen at creation time by
+whoever holds ``api_keys:create`` -- admin-only in the default groups, but a
+custom group can grant it -- so a user with, say, queue permissions could mint
+themselves a key with ``can_control_printer`` and stop other people's prints
+through it. Deactivating that user did not help either: their keys kept working
+with full scope authority, because nothing re-checked the owner.
+
+The gate now narrows the scope flags to what the owner may do. Two cases must
+NOT be conflated, and each has a test below:
+
+- ``user_id IS NULL`` -- legacy key from before per-user ownership. No owner
+  exists to narrow against, so the flags stand alone and the key keeps working.
+- ``user_id`` set but the row is gone or deactivated -- the key's authority came
+  from a user who has none. Fails closed.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.core.auth import generate_api_key, get_password_hash
+from backend.app.models.api_key import APIKey
+from backend.app.models.group import Group
+from backend.app.models.user import User
+
+# A route gated on PRINTERS_READ (can_read_status) and one gated on
+# PRINTERS_CONTROL (can_control_printer). Both scope flags are set on every key
+# built below, so any denial comes from the owner check rather than the flags.
+READ_ROUTE = "/api/v1/printers/"
+CONTROL_ROUTE = "/api/v1/printers/1/print/stop"
+
+
+async def _setup(async_client: AsyncClient) -> None:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "owneradmin", "admin_password": "OwnerPass1!"},
+    )
+
+
+async def _key_for(db_session, owner: User | None, **scopes) -> str:
+    defaults = {"can_read_status": True, "can_control_printer": True, "can_queue": True}
+    defaults.update(scopes)
+    full_key, key_hash, key_prefix = generate_api_key()
+    db_session.add(
+        APIKey(
+            name=f"key-{owner.username if owner else 'legacy'}",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            enabled=True,
+            user_id=owner.id if owner else None,
+            **defaults,
+        )
+    )
+    await db_session.commit()
+    return full_key
+
+
+async def _user(db_session, username: str, permissions: list[str], *, is_active: bool = True) -> User:
+    group = Group(name=f"grp-{username}", description="t", permissions=permissions, is_system=False)
+    db_session.add(group)
+    await db_session.flush()
+    user = User(
+        username=username,
+        password_hash=get_password_hash("Whatever1!"),  # noqa: S106
+        role="user",
+        is_active=is_active,
+        groups=[group],
+    )
+    db_session.add(user)
+    await db_session.commit()
+    return user
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_admin_owned_key_keeps_full_scope_authority(async_client: AsyncClient, db_session):
+    """The common case must not regress -- almost every key is admin-owned."""
+    await _setup(async_client)
+    admin = (await db_session.execute(select(User).where(User.username == "owneradmin"))).scalar_one()
+    key = await _key_for(db_session, admin)
+
+    response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
+
+    assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_legacy_ownerless_key_still_works(async_client: AsyncClient, db_session):
+    """No owner to narrow against is not the same as a failed owner lookup."""
+    await _setup(async_client)
+    key = await _key_for(db_session, None)
+
+    response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
+
+    assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_key_cannot_exceed_its_owners_permissions(async_client: AsyncClient, db_session):
+    """The escalation: control flags ticked, owner who may not control."""
+    await _setup(async_client)
+    owner = await _user(db_session, "readonly", ["printers:read"])
+    key = await _key_for(db_session, owner)
+
+    allowed = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
+    denied = await async_client.post(CONTROL_ROUTE, headers={"X-API-Key": key})
+
+    assert allowed.status_code == 200
+    assert denied.status_code == 403
+    assert "owner does not have" in denied.json()["detail"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_deactivating_the_owner_disables_the_key(async_client: AsyncClient, db_session):
+    """Previously the key kept working -- nothing re-checked the owner."""
+    await _setup(async_client)
+    owner = await _user(db_session, "gone", ["printers:read"], is_active=False)
+    key = await _key_for(db_session, owner)
+
+    response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
+
+    assert response.status_code == 403
+    assert "deactivated" in response.json()["detail"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_deleted_owner_does_not_fall_back_to_anonymous(async_client: AsyncClient, db_session):
+    """The dangling-row case fails closed rather than reverting to flags-only.
+
+    CASCADE should prevent this, but "should" is not a gate -- if the row is
+    ever orphaned the key must not silently regain full scope authority.
+    """
+    await _setup(async_client)
+    owner = await _user(db_session, "doomed", ["printers:read"])
+    key = await _key_for(db_session, owner)
+    api_key = (await db_session.execute(select(APIKey).where(APIKey.user_id == owner.id))).scalar_one()
+    api_key.user_id = 999999  # owner row that does not exist
+    await db_session.commit()
+
+    response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
+
+    assert response.status_code == 403
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_bearer_path_is_gated_the_same_as_the_header(async_client: AsyncClient, db_session):
+    """Both credential paths run the same gate; only one was ever tested."""
+    await _setup(async_client)
+    owner = await _user(db_session, "bearer-readonly", ["printers:read"])
+    key = await _key_for(db_session, owner)
+
+    denied = await async_client.post(CONTROL_ROUTE, headers={"Authorization": f"Bearer {key}"})
+
+    assert denied.status_code == 403
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_webhook_routes_are_not_a_way_around_the_owner_check(async_client: AsyncClient, db_session):
+    """/webhook/* reaches its scope flags by a different route than the rest.
+
+    It gates on ``check_permission``, not ``_check_apikey_permissions``, so it
+    does not inherit the owner narrowing for free. If it is missed, the same key
+    that is refused on /printers/{id}/print/stop simply stops the print here
+    instead, and the whole gate is decorative.
+    """
+    await _setup(async_client)
+    owner = await _user(db_session, "webhook-readonly", ["printers:read"])
+    key = await _key_for(db_session, owner)
+
+    denied = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
+
+    assert denied.status_code == 403
+    assert "owner does not have" in denied.json()["detail"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_webhook_still_works_for_a_permitted_owner(async_client: AsyncClient, db_session):
+    """The narrowing must not simply break every webhook caller."""
+    await _setup(async_client)
+    owner = await _user(db_session, "webhook-operator", ["printers:read", "printers:control"])
+    key = await _key_for(db_session, owner)
+
+    response = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
+
+    # There is no connected printer 1, so the handler itself fails. What
+    # matters is that the request got that far: neither gate rejected it.
+    assert response.status_code != 403
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_webhook_rejects_a_deactivated_owner(async_client: AsyncClient, db_session):
+    """Fail-closed applies on this path too."""
+    await _setup(async_client)
+    owner = await _user(db_session, "webhook-gone", ["printers:read", "printers:control"], is_active=False)
+    key = await _key_for(db_session, owner)
+
+    response = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
+
+    assert response.status_code == 403
+    assert "deactivated" in response.json()["detail"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_me_reports_the_narrowed_set(async_client: AsyncClient, db_session):
+    """/auth/me and the gate must agree, including about the owner."""
+    await _setup(async_client)
+    owner = await _user(db_session, "narrow", ["printers:read"])
+    key = await _key_for(db_session, owner)
+
+    result = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": key})).json()
+
+    assert result["id"] == owner.id
+    assert "printers:read" in result["permissions"]
+    # can_control_printer is ticked on the key, but the owner cannot control.
+    assert "printers:control" not in result["permissions"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_me_is_rejected_once_the_owner_is_deactivated(async_client: AsyncClient, db_session):
+    """A dead key identifies as nothing, rather than as an anonymous key."""
+    await _setup(async_client)
+    owner = await _user(db_session, "me-gone", ["printers:read"], is_active=False)
+    key = await _key_for(db_session, owner)
+
+    response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": key})
+
+    assert response.status_code == 403

+ 131 - 7
backend/tests/integration/test_auth_api.py

@@ -232,8 +232,8 @@ class TestAuthMeAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_me_with_api_key_bearer(self, async_client: AsyncClient, db_session):
-        """Verify /me returns synthetic admin user when using API key via Bearer token."""
+    async def test_me_with_ownerless_api_key_bearer(self, async_client: AsyncClient, db_session):
+        """A legacy key has no identity to report, but no longer claims admin (#1894)."""
         from backend.app.core.auth import generate_api_key
         from backend.app.models.api_key import APIKey
 
@@ -253,15 +253,18 @@ class TestAuthMeAPI:
         result = response.json()
         assert result["id"] == 0
         assert result["username"].startswith("api-key:")
-        assert result["role"] == "admin"
-        assert result["is_admin"] is True
+        assert result["role"] != "admin"
+        assert result["is_admin"] is False
         assert result["is_active"] is True
+        # can_read_status defaults True, so the scope-derived set is non-empty
+        # -- but it is a set, not "every permission there is".
         assert len(result["permissions"]) > 0
+        assert "users:create" not in result["permissions"]
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_me_with_api_key_header(self, async_client: AsyncClient, db_session):
-        """Verify /me returns synthetic admin user when using X-API-Key header."""
+    async def test_me_with_ownerless_api_key_header(self, async_client: AsyncClient, db_session):
+        """Same as above via the X-API-Key header rather than Bearer."""
         from backend.app.core.auth import generate_api_key
         from backend.app.models.api_key import APIKey
 
@@ -279,7 +282,7 @@ class TestAuthMeAPI:
         result = response.json()
         assert result["id"] == 0
         assert result["username"].startswith("api-key:")
-        assert result["is_admin"] is True
+        assert result["is_admin"] is False
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -292,6 +295,127 @@ class TestAuthMeAPI:
 
         assert response.status_code == 401
 
+    async def _owned_key(self, async_client: AsyncClient, db_session, **scopes):
+        """Set up auth and return (owner, full_key) for a key with ``scopes``.
+
+        The owner is given an email and a group explicitly rather than relying
+        on what /auth/setup happens to seed, so the assertions about what /me
+        withholds cannot pass vacuously.
+        """
+        from sqlalchemy import select
+        from sqlalchemy.orm import selectinload
+
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+        from backend.app.models.group import Group
+        from backend.app.models.user import User
+
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "keyowner",
+                "admin_password": "KeyPass1!",
+            },
+        )
+        owner = (
+            await db_session.execute(select(User).where(User.username == "keyowner").options(selectinload(User.groups)))
+        ).scalar_one()
+        owner.email = "keyowner@example.invalid"
+        group = Group(name="key-owner-group", description="t", permissions=["printers:read"], is_system=False)
+        db_session.add(group)
+        await db_session.flush()
+        owner.groups.append(group)
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(name="owned", key_hash=key_hash, key_prefix=key_prefix, enabled=True, user_id=owner.id, **scopes)
+        )
+        await db_session.commit()
+        return owner, full_key
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_reports_the_key_owner_not_a_synthetic_admin(self, async_client: AsyncClient, db_session):
+        """The id is the point of #1894 -- it is what created_by_id filters on."""
+        owner, full_key = await self._owned_key(async_client, db_session)
+
+        response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == owner.id
+        assert result["username"] == "keyowner"
+        # The owner is an admin; the key still is not, because no key reaches
+        # an administrative route regardless of who owns it.
+        assert result["is_admin"] is False
+        assert result["role"] != "admin"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_withholds_owner_email_and_groups(self, async_client: AsyncClient, db_session):
+        """Identity, not the owner's profile -- anyone holding the key sees this."""
+        owner, full_key = await self._owned_key(async_client, db_session)
+        assert owner.email is not None and owner.groups  # the helper made both non-empty
+
+        result = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})).json()
+
+        assert result["email"] is None
+        assert result["groups"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_permissions_track_the_key_scopes_not_the_owner(self, async_client: AsyncClient, db_session):
+        """A key owned by an admin still reports only what its flags allow."""
+        _, full_key = await self._owned_key(
+            async_client,
+            db_session,
+            can_read_status=True,
+            can_control_printer=False,
+            can_queue=False,
+        )
+
+        perms = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})).json()["permissions"]
+
+        assert "printers:read" in perms  # can_read_status
+        assert "printers:control" not in perms  # can_control_printer is off
+        assert "queue:create" not in perms  # can_queue is off
+        assert "users:create" not in perms  # administrative: unmapped for keys
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_permissions_are_exactly_what_the_gate_admits(self, async_client: AsyncClient, db_session):
+        """/me must not drift from _check_apikey_permissions.
+
+        The whole defect in #1894 was a /me response that described a different
+        credential than the one the gate enforces, so pin them to each other
+        rather than to a hand-written list that can rot. The owner is threaded
+        through both sides for the same reason -- the gate narrows to the
+        owner's permissions, so a check that skipped the owner would stop
+        catching drift the moment the owner is not an administrator.
+        """
+        from fastapi import HTTPException
+        from sqlalchemy import select
+
+        from backend.app.core.auth import _check_apikey_permissions, resolve_apikey_owner
+        from backend.app.core.permissions import ALL_PERMISSIONS
+        from backend.app.models.api_key import APIKey
+
+        _, full_key = await self._owned_key(async_client, db_session, can_read_status=True, can_control_printer=False)
+
+        response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})
+        reported = set(response.json()["permissions"])
+
+        key = (await db_session.execute(select(APIKey).where(APIKey.name == "owned"))).scalar_one()
+        owner = await resolve_apikey_owner(db_session, key)
+        for perm in ALL_PERMISSIONS:
+            try:
+                _check_apikey_permissions(key, [perm], owner=owner)
+            except HTTPException:
+                assert perm not in reported, f"/me reports '{perm}' but the gate denies it"
+            else:
+                assert perm in reported, f"the gate admits '{perm}' but /me omits it"
+
 
 class TestUsersAPI:
     """Integration tests for /api/v1/users/ endpoints."""

+ 0 - 346
backend/tests/integration/test_gcode_viewer.py

@@ -1,346 +0,0 @@
-"""Integration tests for the /gcode-viewer static-file routes.
-
-Covers two behaviours added by the GCode viewer PR:
-
-1. Route ordering — /gcode-viewer/* is served by explicit @app.get routes
-   that are registered before the /{full_path:path} SPA catch-all, so the
-   GCode viewer is never accidentally served the React app HTML.
-
-2. Path-traversal guard — requests for paths that escape gcode_viewer/
-   (e.g. /gcode-viewer/../main.py) must return 403, not the file contents.
-
-Plus tests for the archive G-code endpoint behaviour the viewer depends on:
-``?plate=N`` resolution including zero-padded filenames, and the ``has_gcode``
-flag on the plates endpoint that gates the frontend plate picker.
-"""
-
-import zipfile
-from pathlib import Path
-
-import pytest
-from httpx import AsyncClient
-
-
-class TestGCodeViewerRouteOrdering:
-    """Verify the /gcode-viewer routes are reachable and distinct from the SPA."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_gcode_viewer_index_does_not_fall_through_to_spa(self, async_client: AsyncClient):
-        """GET /gcode-viewer/ must not return the React SPA index.html.
-
-        If route ordering is broken the SPA catch-all returns 200 with
-        Content-Type: text/html and a <div id="root"> body.  The correct
-        response is either 200 (gcode_viewer/index.html present) or 404
-        (directory absent in CI) — never the SPA shell.
-        """
-        response = await async_client.get("/gcode-viewer/")
-        # 200 or 404 are both acceptable depending on whether gcode_viewer/
-        # exists in the test environment; the SPA catch-all always returns 200.
-        assert response.status_code in (200, 404)
-        # If a body came back it must NOT be the React SPA shell.
-        assert b'<div id="root">' not in response.content
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_gcode_viewer_index_served_when_assets_present(self, async_client: AsyncClient):
-        """GET /gcode-viewer/ must return the PrettyGCode index when the
-        vendored assets are on disk.
-
-        Regression guard for #1218: the production Dockerfile has to copy
-        ``gcode_viewer/`` into the image alongside ``static/``. The previous
-        ``test_gcode_viewer_index_does_not_fall_through_to_spa`` accepted
-        404 unconditionally so a missing COPY never failed CI. This test
-        only runs when the directory is actually present (so it stays a
-        no-op in unit-test environments where the assets are intentionally
-        absent), but when it does run it asserts 200 + a non-empty HTML
-        body so a future packaging regression fails loudly.
-        """
-        from backend.app.main import _gcode_viewer_dir
-
-        index = _gcode_viewer_dir / "index.html"
-        if not index.is_file():
-            pytest.skip(f"gcode_viewer/index.html not present at {index} — skipping packaging assertion")
-        response = await async_client.get("/gcode-viewer/")
-        assert response.status_code == 200, (
-            f"gcode_viewer/index.html exists at {index} but /gcode-viewer/ returned "
-            f"{response.status_code} — route or response wiring is broken."
-        )
-        assert b"PrettyGCode" in response.content or b"<!doctype html>" in response.content.lower()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_gcode_viewer_no_trailing_slash_falls_through_to_spa(self, async_client: AsyncClient):
-        """GET /gcode-viewer (no trailing slash) must fall through to the SPA.
-
-        Only /gcode-viewer/ (trailing slash) should serve the raw viewer — that
-        form is what the iframe in GCodeViewerPage requests. The bare path is
-        the SPA route the user navigates to; reloading it must re-enter the
-        React layout rather than serve the iframe contents standalone.
-        """
-        response = await async_client.get("/gcode-viewer", follow_redirects=False)
-        # SPA catch-all serves 200 with the React index.html (which contains
-        # <div id="root">). If the build output isn't present the catch-all
-        # may 404 — both outcomes are acceptable here; the key invariant is
-        # that we do NOT serve the standalone PrettyGCode index.html (which
-        # starts with <!doctype html> and contains "PrettyGCode").
-        assert response.status_code in (200, 404)
-        if response.status_code == 200:
-            assert b"PrettyGCode" not in response.content
-
-
-class TestGCodeViewerPathTraversal:
-    """Verify the path-traversal guard on /gcode-viewer/{file_path:path}.
-
-    HTTP clients (and servers) normalise plain `..` segments before the
-    request reaches a route handler, so `/gcode-viewer/../x` becomes `/x`
-    and hits the SPA catch-all rather than our guard — that normalisation is
-    itself a defence layer.  The actual at-risk form is URL-encoded dots
-    (`%2E%2E`) which survive normalisation and land in {file_path:path} as
-    the literal string `../x`.  We test that form here.
-    """
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_encoded_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
-        """GET /gcode-viewer/%2E%2E/main.py must return 403.
-
-        %2E%2E URL-decodes to .. which is not normalised away by httpx/
-        Starlette, so it reaches _gcode_viewer_response as '../main.py'.
-        Path.is_relative_to(gcode_viewer_dir) then blocks it with 403.
-        """
-        response = await async_client.get("/gcode-viewer/%2E%2E/main.py")
-        assert response.status_code == 403
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_encoded_nested_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
-        """GET /gcode-viewer/js/%2E%2E/%2E%2E/main.py must return 403."""
-        response = await async_client.get("/gcode-viewer/js/%2E%2E/%2E%2E/main.py")
-        assert response.status_code == 403
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_nonexistent_safe_path_returns_404(self, async_client: AsyncClient):
-        """A safe but nonexistent path returns 404, not 403."""
-        response = await async_client.get("/gcode-viewer/does-not-exist.js")
-        assert response.status_code == 404
-
-
-def _write_3mf(
-    path: Path,
-    plate_gcode: dict[int, str] | None = None,
-    plate_filenames: dict[int, str] | None = None,
-    include_png_for: list[int] | None = None,
-) -> None:
-    """Write a synthetic Bambu-style 3MF zip at *path*.
-
-    Parameters let a single test pin one specific shape:
-
-    - ``plate_gcode`` — {plate_index: gcode_text} written at
-      ``Metadata/plate_{index}.gcode``. Use for the normal (sliced) case.
-    - ``plate_filenames`` — {plate_index: custom_filename} written with the
-      raw filename verbatim. Use for zero-padded names (plate_01.gcode) etc.
-    - ``include_png_for`` — plate indices to add PNG stubs for. Use to
-      simulate source-only archives (PNG/JSON present, no .gcode).
-
-    Leaving all three empty produces an archive that the plates endpoint
-    will parse as empty (no plates).
-    """
-    plate_gcode = plate_gcode or {}
-    plate_filenames = plate_filenames or {}
-    include_png_for = include_png_for or []
-    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
-        for idx, text in plate_gcode.items():
-            zf.writestr(f"Metadata/plate_{idx}.gcode", text)
-        for idx, filename in plate_filenames.items():
-            zf.writestr(f"Metadata/{filename}", f"; stub for plate {idx}\n")
-        for idx in include_png_for:
-            zf.writestr(f"Metadata/plate_{idx}.png", b"\x89PNG\r\n\x1a\n")
-            zf.writestr(f"Metadata/plate_{idx}.json", b'{"bbox_objects": []}')
-
-
-@pytest.fixture
-def _patch_archive_base_dir(monkeypatch, tmp_path):
-    """Point archive file_path resolution at *tmp_path* for this test."""
-    from backend.app.core.config import settings
-
-    monkeypatch.setattr(settings, "base_dir", tmp_path)
-    return tmp_path
-
-
-class TestArchiveGcodePlateParam:
-    """The viewer passes ``?plate=N`` for multi-plate archives."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_plate_param_returns_that_plate(
-        self,
-        async_client: AsyncClient,
-        archive_factory,
-        printer_factory,
-        _patch_archive_base_dir,
-    ):
-        """GET /archives/{id}/gcode?plate=2 returns Metadata/plate_2.gcode."""
-        tmp = _patch_archive_base_dir
-        threemf = tmp / "multi.3mf"
-        _write_3mf(
-            threemf,
-            plate_gcode={1: "G0 ; plate 1\n", 2: "G1 X0 Y0 ; plate 2\n"},
-        )
-        printer = await printer_factory()
-        archive = await archive_factory(printer.id, filename="multi.3mf", file_path="multi.3mf")
-
-        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=2")
-
-        assert response.status_code == 200
-        assert "plate 2" in response.text
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_plate_param_zero_padded_filename_resolves(
-        self,
-        async_client: AsyncClient,
-        archive_factory,
-        printer_factory,
-        _patch_archive_base_dir,
-    ):
-        """plate_01.gcode reports as plate 1 from /plates — /gcode?plate=1 must find it.
-
-        Regression: the original exact-string match on ``Metadata/plate_1.gcode``
-        missed zero-padded filenames exported by some slicers, so the picker
-        showed plate 1 as selectable but the viewer 404'd on selection.
-        """
-        tmp = _patch_archive_base_dir
-        threemf = tmp / "padded.3mf"
-        with zipfile.ZipFile(threemf, "w", zipfile.ZIP_DEFLATED) as zf:
-            zf.writestr("Metadata/plate_01.gcode", "G0 ; padded plate\n")
-        printer = await printer_factory()
-        archive = await archive_factory(printer.id, filename="padded.3mf", file_path="padded.3mf")
-
-        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
-
-        assert response.status_code == 200
-        assert "padded plate" in response.text
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_missing_plate_returns_404(
-        self,
-        async_client: AsyncClient,
-        archive_factory,
-        printer_factory,
-        _patch_archive_base_dir,
-    ):
-        """Requesting a plate index the archive doesn't contain returns 404."""
-        tmp = _patch_archive_base_dir
-        threemf = tmp / "only_plate_2.3mf"
-        _write_3mf(threemf, plate_gcode={2: "G0\n"})
-        printer = await printer_factory()
-        archive = await archive_factory(printer.id, filename="only_plate_2.3mf", file_path="only_plate_2.3mf")
-
-        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
-
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_no_plate_param_returns_first_plate(
-        self,
-        async_client: AsyncClient,
-        archive_factory,
-        printer_factory,
-        _patch_archive_base_dir,
-    ):
-        """Omitting ?plate falls back to the first gcode in the archive.
-
-        Preserves the pre-plate-param behaviour — existing callers that don't
-        know about plates still get something sensible back.
-        """
-        tmp = _patch_archive_base_dir
-        threemf = tmp / "single.3mf"
-        _write_3mf(threemf, plate_gcode={1: "G0 ; only plate\n"})
-        printer = await printer_factory()
-        archive = await archive_factory(printer.id, filename="single.3mf", file_path="single.3mf")
-
-        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode")
-
-        assert response.status_code == 200
-        assert "only plate" in response.text
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_plate_param_rejects_zero_and_negative(
-        self,
-        async_client: AsyncClient,
-        archive_factory,
-        printer_factory,
-        _patch_archive_base_dir,
-    ):
-        """``?plate=0`` or negative must 400 — not silently fall through."""
-        tmp = _patch_archive_base_dir
-        threemf = tmp / "any.3mf"
-        _write_3mf(threemf, plate_gcode={1: "G0\n"})
-        printer = await printer_factory()
-        archive = await archive_factory(printer.id, filename="any.3mf", file_path="any.3mf")
-
-        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=0")
-
-        assert response.status_code == 400
-
-
-class TestArchivePlatesHasGcode:
-    """The ``has_gcode`` flag on /plates gates the frontend plate picker."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_has_gcode_true_when_gcode_files_present(
-        self,
-        async_client: AsyncClient,
-        archive_factory,
-        printer_factory,
-        _patch_archive_base_dir,
-    ):
-        """Sliced multi-plate 3MF → has_gcode=true."""
-        tmp = _patch_archive_base_dir
-        threemf = tmp / "sliced.3mf"
-        _write_3mf(threemf, plate_gcode={1: "G0\n", 2: "G1\n"})
-        printer = await printer_factory()
-        archive = await archive_factory(printer.id, filename="sliced.3mf", file_path="sliced.3mf")
-
-        response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
-
-        assert response.status_code == 200
-        data = response.json()
-        assert data["has_gcode"] is True
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_has_gcode_false_for_source_only_archive(
-        self,
-        async_client: AsyncClient,
-        archive_factory,
-        printer_factory,
-        _patch_archive_base_dir,
-    ):
-        """Source-only 3MF (PNG/JSON only, no gcode) → has_gcode=false.
-
-        Regression for the archive-69 bug: the PNG/JSON fallback path made the
-        plates endpoint report plate indices that the gcode endpoint couldn't
-        actually serve, so every viewer preview 404'd. The frontend now uses
-        has_gcode to suppress the picker + show a toast instead.
-        """
-        tmp = _patch_archive_base_dir
-        threemf = tmp / "project.3mf"
-        _write_3mf(threemf, include_png_for=[1, 2, 3])  # no .gcode at all
-        printer = await printer_factory()
-        archive = await archive_factory(printer.id, filename="project.3mf", file_path="project.3mf")
-
-        response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
-
-        assert response.status_code == 200
-        data = response.json()
-        assert data["has_gcode"] is False
-        # The endpoint still reports plates (from JSON/PNG) — the flag is what
-        # the frontend keys on, not an empty plate list.
-        assert len(data["plates"]) == 3

+ 42 - 1
backend/tests/integration/test_library_slice_api.py

@@ -205,7 +205,48 @@ class TestSliceValidation:
             },
         )
         assert response.status_code == 400
-        assert "STL, 3MF, or STEP" in response.json()["detail"]
+        assert "STL or 3MF" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_step_source_is_refused_with_an_explanation(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        """STEP was accepted here and then failed at the sidecar.
+
+        Neither slicer's CLI can load STEP -- it answers "Unknown file format"
+        and exits 250 -- so the job was read, converted and uploaded only to
+        come back as "The input model file to the slicer can not be parsed",
+        which reads as a corrupt model rather than an unsupported format.
+        """
+        from backend.app.models.library import LibraryFile
+
+        step_path = slice_test_setup["tmp_path"] / "part.step"
+        step_path.write_bytes(b"ISO-10303-21;\n")
+        sfile = LibraryFile(
+            filename="part.step",
+            file_path=str(step_path.relative_to(slice_test_setup["tmp_path"])),
+            file_type="step",
+            file_size=14,
+        )
+        db_session.add(sfile)
+        await db_session.commit()
+        await db_session.refresh(sfile)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{sfile.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+
+        assert response.status_code == 400
+        detail = response.json()["detail"]
+        assert "STEP" in detail
+        # Naming the way out matters more than the refusal.
+        assert "export" in detail.lower()
 
 
 # ---------------------------------------------------------------------------

+ 9 - 1
backend/tests/integration/test_queue_creation_attribution.py

@@ -30,6 +30,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
 from backend.app.core.auth import generate_api_key
 from backend.app.core.config import settings as app_settings
 from backend.app.models.api_key import APIKey
+from backend.app.models.group import Group
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.user import User
 
@@ -173,7 +174,14 @@ class TestWebhookQueueAddAttribution:
     async def test_credits_the_key_owner(self, async_client: AsyncClient, db_session, test_engine, printer_and_archive):
         printer, archive = printer_and_archive
 
-        owner = User(username="keyowner", password_hash="x", is_active=True)
+        # The owner needs queue:create in their own right: a key is capped by
+        # its owner's permissions (#1894), so a bare account with no groups
+        # cannot queue through a key however its scope flags are set. This test
+        # is about who the row is credited to, not about the gate.
+        group = Group(name="queue-writers", description="t", permissions=["queue:create"], is_system=False)
+        db_session.add(group)
+        await db_session.flush()
+        owner = User(username="keyowner", password_hash="x", is_active=True, groups=[group])
         db_session.add(owner)
         await db_session.commit()
         await db_session.refresh(owner)

+ 3 - 2
backend/tests/integration/test_security_headers.py

@@ -1,8 +1,9 @@
 """Integration tests for security_headers_middleware (#1191).
 
 Default behaviour is strict: ``X-Frame-Options: SAMEORIGIN`` plus
-``frame-ancestors 'none'`` on the catch-all route, ``frame-ancestors 'self'``
-on /gcode-viewer/. Operators can opt into iframe embedding from trusted
+``frame-ancestors 'none'`` on the catch-all route, and ``frame-ancestors
+'self'`` on the streaming overlay, which the Settings URL builder previews
+same-origin. Operators can opt into iframe embedding from trusted
 origins (e.g. Home Assistant on a different port) via the
 ``TRUSTED_FRAME_ORIGINS`` env var; when set, X-Frame-Options is dropped and
 ``frame-ancestors`` includes the allowlist.

+ 180 - 0
backend/tests/integration/test_users_slim_1894.py

@@ -0,0 +1,180 @@
+"""GET /api/v1/users/slim -- the id -> username mapping for API clients (#1894).
+
+An API key could already read global archive stats and filter them by
+``created_by_id`` (for API-keyed requests the permission deps return None as
+``current_user``, so the ``stats:filter_by_user`` guard short-circuits), but
+had no way to discover which id belonged to whom: the full listing is gated on
+``users:read``, which is unmapped in the API-key scope allowlist and therefore
+administrative.
+
+The slim listing closes that gap without handing keys the full user objects.
+These tests pin both halves: that it answers for a key, and that it stays
+narrow while the full listing stays admin-only.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.models.group import Group
+from backend.app.models.user import User
+
+
+async def _setup_and_login(async_client: AsyncClient) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "slimadmin", "admin_password": "SlimPass1!"},
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": "slimadmin", "password": "SlimPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+async def _add_key(db_session, *, user_id: int | None = None, **scopes) -> str:
+    full_key, key_hash, key_prefix = generate_api_key()
+    db_session.add(
+        APIKey(name="slim-test", key_hash=key_hash, key_prefix=key_prefix, enabled=True, user_id=user_id, **scopes)
+    )
+    await db_session.commit()
+    return full_key
+
+
+async def _add_user(db_session, username: str, **kwargs) -> User:
+    from backend.app.core.auth import get_password_hash
+
+    user = User(
+        username=username,
+        password_hash=get_password_hash("Whatever1!"),
+        email=f"{username}@example.invalid",
+        role="user",
+        is_active=True,
+        **kwargs,
+    )
+    db_session.add(user)
+    await db_session.commit()
+    return user
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_slim_returns_only_id_and_username(async_client: AsyncClient, db_session):
+    """The response shape is the contract -- no emails, roles, or permissions."""
+    token = await _setup_and_login(async_client)
+    await _add_user(db_session, "bob")
+
+    response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"})
+
+    assert response.status_code == 200
+    rows = response.json()
+    assert rows, "expected at least the admin created by setup"
+    for row in rows:
+        assert set(row) == {"id", "username"}
+    assert "bob" in [row["username"] for row in rows]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_slim_is_reachable_with_an_api_key(async_client: AsyncClient, db_session):
+    """The point of the issue: a key can resolve the ids it already filters on."""
+    await _setup_and_login(async_client)
+    owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one()
+    full_key = await _add_key(db_session, user_id=owner.id, can_read_status=True)
+
+    header = await async_client.get("/api/v1/users/slim", headers={"X-API-Key": full_key})
+    bearer = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {full_key}"})
+
+    assert header.status_code == 200
+    assert bearer.status_code == 200
+    assert {row["username"] for row in header.json()} == {"slimadmin"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_slim_needs_can_read_status(async_client: AsyncClient, db_session):
+    """A key without the read scope gets nothing, same as any other read route."""
+    await _setup_and_login(async_client)
+    owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one()
+    full_key = await _add_key(db_session, user_id=owner.id, can_read_status=False)
+
+    response = await async_client.get("/api/v1/users/slim", headers={"X-API-Key": full_key})
+
+    assert response.status_code == 403
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_full_listing_stays_admin_only_for_api_keys(async_client: AsyncClient, db_session):
+    """Regression guard: widening the slim route must not widen the full one.
+
+    ``users:read`` returns emails, group membership and the complete permission
+    set for every account. It has to stay unmapped in the scope allowlist.
+    """
+    await _setup_and_login(async_client)
+    owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one()
+    full_key = await _add_key(db_session, user_id=owner.id, can_read_status=True)
+
+    response = await async_client.get("/api/v1/users", headers={"X-API-Key": full_key})
+
+    assert response.status_code == 403
+    assert "administrative" in response.json()["detail"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_slim_is_not_parsed_as_a_user_id(async_client: AsyncClient, db_session):
+    """Route ordering. Declared after /{user_id}, "slim" would 422 as an int."""
+    token = await _setup_and_login(async_client)
+
+    response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"})
+
+    assert response.status_code != 422
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_group_with_only_users_read_still_reaches_slim(async_client: AsyncClient, db_session):
+    """``users:read`` is strictly broader, so it must pass the any-of gate.
+
+    Without this, every existing custom group holding ``users:read`` would need
+    a permission backfill before the frontend could ever move to this route.
+    """
+    await _setup_and_login(async_client)
+    group = Group(name="readers", description="t", permissions=["users:read"], is_system=False)
+    db_session.add(group)
+    await db_session.flush()
+    await _add_user(db_session, "reader", groups=[group])
+
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": "reader", "password": "Whatever1!"},
+    )
+    token = login.json()["access_token"]
+
+    response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"})
+
+    assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_group_with_only_slim_cannot_read_the_full_listing(async_client: AsyncClient, db_session):
+    """The narrow grant has to actually be narrower for JWT users too."""
+    await _setup_and_login(async_client)
+    group = Group(name="slim-only", description="t", permissions=["users:read_slim"], is_system=False)
+    db_session.add(group)
+    await db_session.flush()
+    await _add_user(db_session, "slimonly", groups=[group])
+
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": "slimonly", "password": "Whatever1!"},
+    )
+    token = login.json()["access_token"]
+    headers = {"Authorization": f"Bearer {token}"}
+
+    assert (await async_client.get("/api/v1/users/slim", headers=headers)).status_code == 200
+    assert (await async_client.get("/api/v1/users", headers=headers)).status_code == 403

+ 1 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -395,6 +395,7 @@ class TestPrinterManager:
             use_ams=True,
             nozzle_offset_cali="auto",
             nozzle_mapping=None,
+            nozzle_slot_extruders=None,
         )
         assert result is True
 

+ 1 - 1
backend/tests/unit/test_launcher_shutdown_config.py

@@ -36,7 +36,7 @@ FLAG = "--timeout-graceful-shutdown"
 
 # These pin repo-root launcher files (Dockerfile, compose, service units,
 # install scripts) that the Docker test image deliberately does not ship —
-# Dockerfile.test copies only backend/, pyproject.toml, gcode_viewer/ and
+# Dockerfile.test copies only backend/, pyproject.toml and
 # requirements. In a source checkout the files are always present and the
 # guard below is live (a moved/deleted launcher still fails loudly on every
 # `test_backend.sh` run); inside the stripped test image there is nothing to

+ 272 - 0
backend/tests/unit/test_nozzle_rack_mapping_2800.py

@@ -0,0 +1,272 @@
+"""Nozzle-rack (H2C) dispatch mapping — #2800.
+
+The H2C mounts one of six rack hotends on its right carriage. Dispatch has to
+name the *physical* rack position, not the extruder index every other
+dual-nozzle printer uses; get it wrong and the printer cleans and levels with
+one nozzle, then prints with another several millimetres off the bed.
+
+Nothing in the queue knew the rack position, so these jobs shipped with no
+`nozzle_mapping` at all and the firmware picked for itself.
+"""
+
+import json
+import zipfile
+
+import pytest
+
+from backend.app.services.bambu_mqtt import (
+    _RACK_WIRE_SLOTS,
+    BambuMQTTClient,
+    resolve_rack_nozzle_mapping,
+)
+from backend.app.utils.printer_models import is_nozzle_rack_model
+from backend.app.utils.threemf_tools import extract_slot_extruders_from_3mf
+
+
+class TestIsNozzleRackModel:
+    @pytest.mark.parametrize("model", ["H2C", "h2c", " H2C ", "O1C", "O1C2"])
+    def test_h2c_spellings_and_codes(self, model):
+        """The printer row may hold either the display name or the SSDP code."""
+        assert is_nozzle_rack_model(model) is True
+
+    @pytest.mark.parametrize("model", ["H2D", "H2D Pro", "H2S", "X2D", "P1S", "O1D", "N6", "", None])
+    def test_everything_else_is_not_a_rack_model(self, model):
+        """Other dual-nozzle printers must keep the plain extruder-index wire."""
+        assert is_nozzle_rack_model(model) is False
+
+
+class TestResolveRackNozzleMapping:
+    def test_rack_slot_takes_the_live_rack_position(self):
+        mapping = resolve_rack_nozzle_mapping([0], rack_nozzle_id=17)
+        assert mapping is not None
+        assert len(mapping) == _RACK_WIRE_SLOTS
+        assert mapping[0] == 17
+        assert set(mapping[1:]) == {-1}
+
+    def test_non_rack_slots_keep_their_extruder_index(self):
+        """Only the rack extruder is substituted; the fixed hotend is untouched."""
+        mapping = resolve_rack_nozzle_mapping([1, 0], rack_nozzle_id=21)
+        assert mapping[:2] == [1, 21]
+
+    def test_unprinted_slots_stay_unset(self):
+        mapping = resolve_rack_nozzle_mapping([0, -1, 0], rack_nozzle_id=16)
+        assert mapping[:3] == [16, -1, 16]
+
+    @pytest.mark.parametrize("rack_id", [None, 0, 1, 15, 22, 255])
+    def test_no_usable_rack_position_omits_the_field(self, rack_id):
+        """Mid-swap or stale state must fall back to the firmware's own pick.
+
+        Guessing here is what prints in mid-air, so returning None (and
+        omitting nozzle_mapping) is the intended failure mode.
+        """
+        assert resolve_rack_nozzle_mapping([0], rack_nozzle_id=rack_id) is None
+
+    def test_job_that_never_uses_the_rack_is_left_alone(self):
+        """The fixed hotend's own physical ID is not confirmed by a capture yet."""
+        assert resolve_rack_nozzle_mapping([1, 1], rack_nozzle_id=17) is None
+
+    @pytest.mark.parametrize(
+        "bad_slots",
+        [
+            ["a", 0],  # non-numeric
+            [{}, 0],  # nested object
+            [[0], 0],  # nested list
+            [0.5, 0],  # fractional
+            [True, 0],  # bool would reach the wire as JSON `true`
+            "0",  # not a list at all
+        ],
+    )
+    def test_junk_input_returns_none_and_never_raises(self, bad_slots):
+        """Nothing above this raises: `start_print` builds the MQTT command
+        with no exception handler, and by then the queue item is already
+        committed as `printing`. A bad value has to degrade to "firmware
+        picks", not wedge the item in a state no print will leave."""
+        assert resolve_rack_nozzle_mapping(bad_slots, rack_nozzle_id=17) is None
+
+    @pytest.mark.parametrize("bad_rack", [[17], {"id": 17}, "17", 17.0, True])
+    def test_junk_rack_position_returns_none_and_never_raises(self, bad_rack):
+        assert resolve_rack_nozzle_mapping([0], rack_nozzle_id=bad_rack) is None
+
+    def test_none_entries_read_as_unprinted(self):
+        assert resolve_rack_nozzle_mapping([None, 0], rack_nozzle_id=17)[:2] == [-1, 17]
+
+    def test_a_flipped_rack_side_would_omit_rather_than_misfire(self):
+        """Guards the one assumption taken from a single hardware capture.
+
+        If the rack turned out to feed the other extruder, a job printing
+        entirely from one side matches nothing and falls back to the
+        firmware's own pick — the pre-#2800 behaviour — instead of naming a
+        nozzle confidently and wrongly.
+        """
+        assert resolve_rack_nozzle_mapping([1, 1], rack_nozzle_id=17) is None
+
+    def test_more_slots_than_the_wire_carries(self):
+        assert resolve_rack_nozzle_mapping([0] * (_RACK_WIRE_SLOTS + 1), rack_nozzle_id=17) is None
+
+    def test_empty_mapping(self):
+        assert resolve_rack_nozzle_mapping([], rack_nozzle_id=17) is None
+
+
+class TestRackPositionFromMqtt:
+    @pytest.fixture
+    def client(self):
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST-H2C",
+            access_code="12345678",
+            model="H2C",
+        )
+
+    def test_src_and_tar_are_captured(self, client):
+        client._update_state({"device": {"nozzle": {"src_id": 16, "tar_id": 19}}})
+        assert client.state.nozzle_rack_src_id == 16
+        assert client.state.nozzle_rack_tar_id == 19
+
+    def test_absent_key_does_not_clear_the_last_known_value(self, client):
+        """The firmware only pushes these when they change."""
+        client._update_state({"device": {"nozzle": {"src_id": 16, "tar_id": 19}}})
+        client._update_state({"device": {"nozzle": {"info": []}}})
+        assert client.state.nozzle_rack_tar_id == 19
+
+    def test_unparseable_value_is_ignored(self, client):
+        client._update_state({"device": {"nozzle": {"tar_id": 19}}})
+        client._update_state({"device": {"nozzle": {"tar_id": "nonsense"}}})
+        assert client.state.nozzle_rack_tar_id == 19
+
+    def test_starts_unknown(self, client):
+        assert client.state.nozzle_rack_src_id is None
+        assert client.state.nozzle_rack_tar_id is None
+
+
+class TestDispatch:
+    """What actually reaches the wire."""
+
+    def _client(self, model):
+        from unittest.mock import MagicMock
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST-DISPATCH",
+            access_code="12345678",
+            model=model,
+        )
+        client._client = MagicMock()
+        client.state.connected = True
+        client._is_dual_nozzle = True
+        return client
+
+    def _print_cmd(self, client):
+        return json.loads(client._client.publish.call_args[0][1])["print"]
+
+    def test_rack_model_resolves_slot_extruders(self):
+        client = self._client("H2C")
+        client.state.nozzle_rack_tar_id = 18
+        client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0, -1, 0]))
+        cmd = self._print_cmd(client)
+        assert cmd["nozzle_mapping"][:3] == [18, -1, 18]
+
+    def test_src_id_used_when_tar_id_is_not_a_rack_position(self):
+        """Between swaps the printer can report a settled src_id and nothing else."""
+        client = self._client("H2C")
+        client.state.nozzle_rack_src_id = 20
+        client.state.nozzle_rack_tar_id = 0
+        client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0]))
+        assert self._print_cmd(client)["nozzle_mapping"][0] == 20
+
+    def test_unknown_rack_position_omits_the_field(self):
+        client = self._client("H2C")
+        client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0]))
+        assert "nozzle_mapping" not in self._print_cmd(client)
+
+    def test_studio_capture_is_never_overridden(self):
+        """A real capture is authoritative; the derived fallback must stand down."""
+        client = self._client("H2C")
+        client.state.nozzle_rack_tar_id = 18
+        client.start_print(
+            "job.3mf",
+            nozzle_mapping=json.dumps([16, -1, -1, 1]),
+            nozzle_slot_extruders=json.dumps([0, -1, 0]),
+        )
+        assert self._print_cmd(client)["nozzle_mapping"] == [16, -1, -1, 1]
+
+    def test_other_dual_nozzle_models_are_untouched(self):
+        """H2D has no rack: its extruder indices are already the wire values."""
+        client = self._client("H2D")
+        client.state.nozzle_rack_tar_id = 18
+        client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0, 1]))
+        assert "nozzle_mapping" not in self._print_cmd(client)
+
+    def test_malformed_slot_extruders_is_logged_and_omitted(self, caplog):
+        client = self._client("H2C")
+        client.state.nozzle_rack_tar_id = 18
+        with caplog.at_level("WARNING"):
+            client.start_print("job.3mf", nozzle_slot_extruders="not json {")
+        assert "nozzle_mapping" not in self._print_cmd(client)
+        assert any("Invalid nozzle_slot_extruders" in rec.message for rec in caplog.records)
+
+    def test_absent_slot_extruders_changes_nothing(self):
+        client = self._client("H2C")
+        client.state.nozzle_rack_tar_id = 18
+        client.start_print("job.3mf")
+        assert "nozzle_mapping" not in self._print_cmd(client)
+
+
+def _write_dual_nozzle_3mf(path, group_by_slot):
+    """Minimal 3MF carrying just what the nozzle extractor reads.
+
+    physical_extruder_map is [1, 0] as Bambu ships it: slicer group 0 is the
+    left extruder (MQTT index 1) and group 1 the right (index 0) — the right
+    being the one the H2C rack feeds.
+    """
+    filaments = "".join(f'<filament id="{slot}" group_id="{group}"/>' for slot, group in group_by_slot.items())
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr(
+            "Metadata/project_settings.config",
+            json.dumps(
+                {
+                    "physical_extruder_map": [1, 0],
+                    "extruder_nozzle_stats": ["Standard#1", "Standard#1"],
+                }
+            ),
+        )
+        zf.writestr("Metadata/slice_info.config", f"<config><plate>{filaments}</plate></config>")
+    return path
+
+
+class TestSlotExtrudersFromFile:
+    def test_derives_dense_per_slot_extruders(self, tmp_path):
+        """Slots 1 and 3 print from the right (rack) extruder; slot 2 is unused."""
+        source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 1, 3: 1})
+        assert extract_slot_extruders_from_3mf(source) == [0, -1, 0]
+
+    def test_end_to_end_reaches_the_rack_position(self, tmp_path):
+        """The reported failure: a two-slot job that must print from the rack."""
+        source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 1, 3: 1})
+        wire = resolve_rack_nozzle_mapping(extract_slot_extruders_from_3mf(source), rack_nozzle_id=17)
+        assert wire[:3] == [17, -1, 17]
+
+    def test_both_extruders(self, tmp_path):
+        source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 0, 2: 1})
+        assert extract_slot_extruders_from_3mf(source) == [1, 0]
+
+    def test_single_nozzle_file_yields_nothing(self, tmp_path):
+        path = tmp_path / "single.3mf"
+        with zipfile.ZipFile(path, "w") as zf:
+            zf.writestr("Metadata/project_settings.config", json.dumps({"physical_extruder_map": [0]}))
+        assert extract_slot_extruders_from_3mf(path) is None
+
+    def test_unreadable_file_is_not_fatal(self, tmp_path):
+        path = tmp_path / "broken.3mf"
+        path.write_bytes(b"not a zip")
+        assert extract_slot_extruders_from_3mf(path) is None
+
+    @pytest.mark.parametrize("slot_id", [50000000, 65, 0, -3])
+    def test_out_of_range_slot_ids_are_rejected(self, tmp_path, slot_id):
+        """Slot IDs are whatever the file claims, and this builds a dense list.
+
+        Without a ceiling a corrupt or hostile 3MF declaring
+        `filament id="50000000"` allocates a fifty-million-entry list on the
+        dispatch path.
+        """
+        source = _write_dual_nozzle_3mf(tmp_path / f"s{abs(slot_id)}.3mf", {slot_id: 1})
+        assert extract_slot_extruders_from_3mf(source) is None

+ 79 - 0
backend/tests/unit/test_process_overrides.py

@@ -0,0 +1,79 @@
+"""Unit tests for the slice modal's process-setting overrides."""
+
+import json
+
+from backend.app.services.process_overrides import (
+    apply_process_overrides,
+    normalise_process_overrides,
+)
+
+
+def _process(**values: str) -> str:
+    return json.dumps({"inherits": "0.20mm Standard @BBL X1C", **values})
+
+
+class TestNormaliseProcessOverrides:
+    def test_bools_become_the_one_zero_strings_a_preset_stores(self):
+        assert normalise_process_overrides({"enable_support": True}) == {"enable_support": "1"}
+        assert normalise_process_overrides({"enable_support": False}) == {"enable_support": "0"}
+
+    def test_numbers_become_strings(self):
+        assert normalise_process_overrides({"wall_loops": 4, "layer_height": 0.16}) == {
+            "wall_loops": "4",
+            "layer_height": "0.16",
+        }
+
+    def test_strings_pass_through_including_the_percent_sign(self):
+        # The frontend serialises percents with the sign; stripping it here
+        # would silently change the value the slicer sees.
+        assert normalise_process_overrides({"sparse_infill_density": "35%"}) == {"sparse_infill_density": "35%"}
+
+    def test_vector_options_keep_their_list_shape(self):
+        assert normalise_process_overrides({"default_acceleration": [500, 300]}) == {
+            "default_acceleration": ["500", "300"]
+        }
+
+    def test_keys_that_are_not_config_identifiers_are_dropped(self):
+        result = normalise_process_overrides({"wall_loops": 2, "Wall Loops": 3, "__proto__": 1, "a-b": 1, "": 1})
+        assert result == {"wall_loops": "2"}
+
+    def test_values_a_preset_cannot_hold_are_dropped_not_serialised(self):
+        result = normalise_process_overrides({"wall_loops": 2, "nested": {"a": 1}, "none": None})
+        assert result == {"wall_loops": "2"}
+
+    def test_a_list_containing_a_non_scalar_drops_the_whole_key(self):
+        # Half-applying a per-extruder vector would send a shorter list than the
+        # printer has extruders, which is worse than not setting it at all.
+        assert normalise_process_overrides({"default_acceleration": [500, {"a": 1}]}) == {}
+
+
+class TestApplyProcessOverrides:
+    def test_writes_the_users_values_into_the_process_json(self):
+        result = apply_process_overrides(_process(), {"wall_loops": 4, "enable_support": True})
+        assert json.loads(result)["wall_loops"] == "4"
+        assert json.loads(result)["enable_support"] == "1"
+
+    def test_keeps_the_inherits_stub_so_the_preset_still_resolves(self):
+        # A "standard" preset pick is a {inherits: ...} stub; dropping that key
+        # would leave the slicer with a handful of orphaned values.
+        result = apply_process_overrides(_process(), {"wall_loops": 4})
+        assert json.loads(result)["inherits"] == "0.20mm Standard @BBL X1C"
+
+    def test_user_value_wins_over_one_already_in_the_preset(self):
+        result = apply_process_overrides(_process(wall_loops="2"), {"wall_loops": 6})
+        assert json.loads(result)["wall_loops"] == "6"
+
+    def test_empty_overrides_leave_the_json_untouched(self):
+        original = _process(wall_loops="2")
+        assert apply_process_overrides(original, {}) == original
+
+    def test_overrides_that_all_get_dropped_leave_the_json_untouched(self):
+        original = _process(wall_loops="2")
+        assert apply_process_overrides(original, {"Bad Key": 1}) == original
+
+    def test_unparseable_process_json_degrades_to_a_plain_slice(self):
+        # Better a slice with the picked preset than a failed one.
+        assert apply_process_overrides("not json", {"wall_loops": 4}) == "not json"
+
+    def test_non_object_process_json_degrades_to_a_plain_slice(self):
+        assert apply_process_overrides("[1, 2]", {"wall_loops": 4}) == "[1, 2]"

+ 0 - 2
backend/tests/unit/test_route_auth_coverage.py

@@ -114,8 +114,6 @@ _PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset(
         ("GET", "/manifest.json"),
         ("GET", "/sw-register.js"),
         ("GET", "/sw.js"),
-        ("GET", "/gcode-viewer/"),
-        ("GET", "/gcode-viewer/{file_path:path}"),
         # SPA catch-all — serves index.html for client-side routing. No backend data path.
         ("GET", "/{full_path:path}"),
         # ---- WebSocket routes ----

+ 155 - 4
backend/tests/unit/test_scheduler_auto_drying.py

@@ -199,6 +199,105 @@ class TestSyncDryingState:
         assert 1 not in scheduler._drying_in_progress
 
 
+class TestPlateHoldDoesNotGateDrying:
+    """#2801 — an unacknowledged plate must not stop the AMS heating.
+
+    Plate-clear answers "is the bed ready for the next job". It says nothing
+    about whether filament may be dried, and the gap between a finished print
+    and the acknowledgment is exactly when drying is most useful: the printer
+    is free and nobody is waiting on it. Leaving the plate unacknowledged is
+    also how people hold the queue by hand.
+
+    Before this, such a printer landed in the dispatch set, was read as
+    "currently printing", took the mid-print path -- capped temperature,
+    (mid-print) in the log -- and bypassed the very gate that was meant to
+    hold it, while the queue loop tore the cycle down once a tick.
+    """
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @staticmethod
+    def _finished_printer_state():
+        state = MagicMock()
+        state.state = "FINISH"
+        state.firmware_version = "01.03.00.00"
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": 0,
+                    "humidity_raw": "75",
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        return state
+
+    def _db(self):
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=TestAmbientDrying._make_db_side_effect(
+                {
+                    "queue_drying_enabled": TestAmbientDrying._make_setting("false"),
+                    "ambient_drying_enabled": TestAmbientDrying._make_setting("true"),
+                    "print_drying_enabled": TestAmbientDrying._make_setting("true"),
+                    "ams_humidity_fair": TestAmbientDrying._make_setting("60"),
+                    "queue_drying_block": TestAmbientDrying._make_setting("false"),
+                    "drying_presets": None,
+                }
+            )
+        )
+        return db
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
+    async def test_finished_printer_with_dirty_plate_dries_at_full_temperature(self, mock_sd, mock_pm, scheduler):
+        mock_pm.get_status.return_value = self._finished_printer_state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "P2S"
+        mock_pm.send_drying_command.return_value = True
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+
+        await scheduler._check_auto_drying(self._db(), [], set())
+
+        # 45 degC is the uncapped PLA preset: mid-print would have sent 40.
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
+    async def test_idleness_is_judged_without_the_plate_gate(self, mock_sd, mock_pm, scheduler):
+        mock_pm.get_status.return_value = self._finished_printer_state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "P2S"
+        mock_pm.send_drying_command.return_value = True
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+
+        await scheduler._check_auto_drying(self._db(), [], set())
+
+        scheduler._is_printer_idle.assert_called_with(1, require_plate_clear=False)
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
+    async def test_a_printer_about_to_print_is_still_left_alone(self, mock_sd, mock_pm, scheduler):
+        """The narrow set keeps its job: an imminent print must not be dried into."""
+        mock_pm.get_status.return_value = self._finished_printer_state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "P2S"
+        mock_pm.send_drying_command.return_value = True
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+
+        await scheduler._check_auto_drying(self._db(), [], {1})
+
+        assert not mock_pm.send_drying_command.called
+
+
 class TestStopDrying:
     """Test _stop_drying — sends stop commands and clears tracking."""
 
@@ -209,8 +308,10 @@ class TestStopDrying:
     @pytest.mark.asyncio
     @patch("backend.app.services.print_scheduler.printer_manager")
     async def test_stops_all_ams_units(self, mock_pm, scheduler):
-        """Sends stop command to each AMS unit that is drying."""
+        """Sends stop command to each auto-armed AMS unit that is drying."""
         scheduler._drying_in_progress = {1: time.monotonic()}
+        scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
+        scheduler._auto_dry_units[(1, 128)] = {"ended_at": None}
         state = MagicMock()
         state.raw_data = {
             "ams": [
@@ -230,6 +331,46 @@ class TestStopDrying:
         assert calls[1].args == (1, 128, 0, 0)
         assert 1 not in scheduler._drying_in_progress
 
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_leaves_cycles_bambuddy_did_not_start(self, mock_pm, scheduler):
+        """A hand-started dry on another unit survives (#2801).
+
+        One auto-dried unit used to be enough to stop every AMS on the
+        printer reporting dry_time > 0, which took the user's own cycle with
+        it. The entry gate only ever knew about cycles Bambuddy began; the
+        action now matches.
+        """
+        scheduler._drying_in_progress = {1: time.monotonic()}
+        scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
+        state = MagicMock()
+        state.raw_data = {"ams": [{"id": 0, "dry_time": 120}, {"id": 1, "dry_time": 600}]}
+        mock_pm.get_status.return_value = state
+
+        await scheduler._stop_drying(1)
+
+        calls = mock_pm.send_drying_command.call_args_list
+        assert [c.args[1] for c in calls] == [0]
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_stops_nothing_it_cannot_prove_it_started(self, mock_pm, scheduler):
+        """After a restart Bambuddy cannot tell its own cycle from a manual one.
+
+        _sync_drying_state prunes but never adopts, for exactly this reason, so
+        a cycle armed before the restart is left running rather than risking a
+        stop on somebody's manual dry. Tracking is still cleared.
+        """
+        scheduler._drying_in_progress = {1: time.monotonic()}
+        state = MagicMock()
+        state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
+        mock_pm.get_status.return_value = state
+
+        await scheduler._stop_drying(1)
+
+        assert not mock_pm.send_drying_command.called
+        assert 1 not in scheduler._drying_in_progress
+
     @pytest.mark.asyncio
     @patch("backend.app.services.print_scheduler.printer_manager")
     async def test_clears_tracking_when_no_state(self, mock_pm, scheduler):
@@ -417,6 +558,8 @@ class TestAutoStopOnFeatureDisabled:
     async def test_stops_drying_when_disabled(self, mock_pm, scheduler):
         """Disabling auto-drying should send stop commands to all drying printers."""
         scheduler._drying_in_progress = {1: time.monotonic(), 2: time.monotonic()}
+        scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
+        scheduler._auto_dry_units[(2, 0)] = {"ended_at": None}
 
         # Printer 1: drying, Printer 2: drying
         def get_status(pid):
@@ -481,6 +624,7 @@ class TestAutoStopOnNoScheduledItems:
     async def test_stops_when_no_scheduled_items(self, mock_pm, scheduler):
         """Auto-drying stops when queue has no scheduled items (queue mode only)."""
         scheduler._drying_in_progress = {1: time.monotonic()}
+        scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
 
         state = MagicMock()
         state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
@@ -510,6 +654,7 @@ class TestAutoStopOnNoScheduledItems:
     async def test_stops_when_empty_queue(self, mock_pm, scheduler):
         """Auto-drying stops when queue is completely empty (queue mode only)."""
         scheduler._drying_in_progress = {1: time.monotonic()}
+        scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
 
         state = MagicMock()
         state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
@@ -692,6 +837,7 @@ class TestAmbientDrying(_DryingTestBase):
     async def test_ambient_off_stops_drying_without_queue(self, mock_pm, scheduler):
         """Disabling ambient drying stops drying on printers without queue items."""
         scheduler._drying_in_progress = {1: time.monotonic()}
+        scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
 
         state = MagicMock()
         state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
@@ -1059,7 +1205,9 @@ class TestMidPrintDrying(_DryingTestBase):
     @patch("backend.app.services.print_scheduler.printer_manager")
     async def test_running_printer_dries_when_enabled_and_capable(self, mock_pm, scheduler):
         """Toggle ON + capable hardware: running printer dries at capped temp."""
-        mock_pm.get_status.return_value = self._state("01.03.00.00")
+        state = self._state("01.03.00.00")
+        state.state = "RUNNING"
+        mock_pm.get_status.return_value = state
         mock_pm.is_connected.return_value = True
         mock_pm.get_model.return_value = "H2D"
         mock_pm.send_drying_command.return_value = True
@@ -1076,7 +1224,7 @@ class TestMidPrintDrying(_DryingTestBase):
         }
         db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
 
-        # Printer 1 is in busy_printers — would normally be skipped
+        # Actually printing (RUNNING), so the mid-print path applies
         await scheduler._check_auto_drying(db, [], {1})
 
         # PLA preset is 45 degC for n3f; mid-print cap is max(40, 45-5) = 40
@@ -1101,6 +1249,7 @@ class TestMidPrintDrying(_DryingTestBase):
             ]
         }
         state.firmware_version = "01.03.00.00"
+        state.state = "RUNNING"
         mock_pm.get_status.return_value = state
         mock_pm.is_connected.return_value = True
         mock_pm.get_model.return_value = "H2D"
@@ -1347,7 +1496,9 @@ class TestAutoDryRearmGuards(_DryingTestBase):
         }
 
         await self._pass(scheduler, mock_pm, db, 0, self.BELOW)
-        assert (1, 0) not in scheduler._auto_dry_units
+        # The judgement is cleared, but the re-arm clock is kept (#2801).
+        assert not scheduler._auto_dry_units[(1, 0)].get("suspended")
+        assert not scheduler._auto_dry_units[(1, 0)].get("unproductive")
 
         await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
         mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")

+ 213 - 0
backend/tests/unit/test_scheduler_drying_plate_hold_2801.py

@@ -0,0 +1,213 @@
+"""Auto-drying versus the plate-clear hold, from check_queue (#2801).
+
+A printer in FINISH whose plate has not been acknowledged, with something
+pending in its queue, stopped and restarted AMS drying once per scheduler tick
+for as long as the plate stayed unacknowledged -- roughly 2000 state changes
+over ten days on the reporter's P2S. Drying never ran long enough to do
+anything, and manual cycles on other AMS units of the same printer were killed
+with it.
+
+Two ideas were tangled together. Plate-clear answers "is the bed ready for the
+next job"; it is not a statement about whether the AMS may heat. And the
+"print takes priority" stop was reached only when the print was NOT going to
+start, so it spent the drying cycle for nothing.
+
+These tests drive the real check_queue so the wiring is covered end to end,
+not just the predicates.
+"""
+
+from contextlib import ExitStack
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+async def queue_db():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    async with session_maker() as db:
+        db.add(
+            Printer(
+                id=1,
+                name="P2S-1",
+                serial_number="P2S0001",
+                ip_address="10.0.0.1",
+                access_code="x",
+                model="P2S",
+                is_active=True,
+            )
+        )
+        await db.commit()
+
+    try:
+        yield SimpleNamespace(session_maker=session_maker)
+    finally:
+        await engine.dispose()
+
+
+async def _add_item(ctx):
+    async with ctx.session_maker() as db:
+        lib = LibraryFile(
+            filename="job.gcode.3mf",
+            file_path="/library/job.gcode.3mf",
+            file_size=10,
+            file_type="gcode.3mf",
+            file_metadata={"sliced_for_model": "P2S"},
+        )
+        db.add(lib)
+        await db.flush()
+        db.add(
+            PrintQueueItem(
+                status="pending",
+                position=1,
+                printer_id=1,
+                library_file_id=lib.id,
+            )
+        )
+        await db.commit()
+
+
+async def _run(ctx, scheduler, *, idle, stop_drying, drying=None, deficit=False, launched=None):
+    """One check_queue pass with the plate hold expressed through _is_printer_idle."""
+    patches = [
+        patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
+        patch("backend.app.core.database.async_session", ctx.session_maker),
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+        patch(
+            "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
+            AsyncMock(return_value={}),
+        ),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
+            AsyncMock(),
+        ),
+        patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=idle)),
+        patch.object(scheduler, "_check_auto_drying", drying or AsyncMock()),
+        patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
+        patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=deficit)),
+        patch.object(scheduler, "_launch_uploads", launched or MagicMock()),
+        patch.object(scheduler, "_stop_drying", stop_drying),
+    ]
+    with ExitStack() as stack:
+        for p in patches:
+            stack.enter_context(p)
+        return await scheduler.check_queue()
+
+
+@pytest.mark.asyncio
+async def test_drying_is_not_stopped_for_a_print_that_cannot_start(queue_db):
+    """The reported loop. Plate unacknowledged, so nothing dispatches -- and
+    stopping the cycle could not have changed that, because drying is not one
+    of the things _is_printer_idle looks at."""
+    await _add_item(queue_db)
+    scheduler = PrintScheduler()
+    scheduler._drying_in_progress[1] = 1.0
+    stop = AsyncMock()
+
+    await _run(queue_db, scheduler, idle=False, stop_drying=stop)
+
+    stop.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_a_plate_held_printer_is_not_offered_to_drying_as_printing(queue_db):
+    """It lands in busy_printers so the queue leaves it alone, but auto-drying
+    is handed the narrow set and must not see it there -- otherwise it takes
+    the mid-print path, which caps the temperature and skips the idle gate."""
+    await _add_item(queue_db)
+    scheduler = PrintScheduler()
+    drying = AsyncMock()
+
+    await _run(queue_db, scheduler, idle=False, stop_drying=AsyncMock(), drying=drying)
+
+    dispatching = drying.await_args[0][2]
+    assert 1 not in dispatching
+
+
+@pytest.mark.asyncio
+async def test_print_takes_priority_still_stops_drying_when_it_can_dispatch(queue_db):
+    """The setting keeps its meaning: on hardware that cannot dry through a
+    print, a dispatch that is actually going to happen stops the cycle."""
+    await _add_item(queue_db)
+    scheduler = PrintScheduler()
+    scheduler._drying_in_progress[1] = 1.0
+    stop = AsyncMock()
+
+    with patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)):
+        await _run(queue_db, scheduler, idle=True, stop_drying=stop)
+
+    stop.assert_awaited_once_with(1)
+
+
+@pytest.mark.asyncio
+async def test_block_mode_holds_the_print_and_keeps_the_cycle(queue_db):
+    """queue_drying_block on: the print waits, and the cycle is never touched.
+
+    The setting previously had no observable effect on dispatch -- both
+    branches skipped the item anyway, and all it really decided was whether
+    drying got needlessly killed. Now it does what it says.
+    """
+    await _add_item(queue_db)
+    scheduler = PrintScheduler()
+    scheduler._drying_in_progress[1] = 1.0
+    stop = AsyncMock()
+    launched = MagicMock()
+
+    with patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)):
+        await _run(queue_db, scheduler, idle=True, stop_drying=stop, launched=launched)
+
+    stop.assert_not_awaited()
+    assert not launched.called
+
+
+@pytest.mark.asyncio
+async def test_drying_survives_an_item_that_is_skipped_after_the_idle_check(queue_db):
+    """The idle check is not the last thing that can stop a dispatch.
+
+    A failed previous print, an unmappable item, a filament deficit or a
+    contested library row all skip the item further down the loop. Deciding on
+    drying before those is the same defect in a smaller costume: the cycle goes
+    and the print still does not happen.
+    """
+    await _add_item(queue_db)
+    scheduler = PrintScheduler()
+    scheduler._drying_in_progress[1] = 1.0
+    stop = AsyncMock()
+
+    with patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)):
+        # Deficit gate holds the item back, after the printer passed as idle.
+        await _run(queue_db, scheduler, idle=True, stop_drying=stop, deficit=True)
+
+    stop.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_capable_hardware_keeps_drying_through_the_print(queue_db):
+    """#2758's finding stands: where the printer dries happily while printing
+    and the user has allowed it, the cycle is left running."""
+    await _add_item(queue_db)
+    scheduler = PrintScheduler()
+    scheduler._drying_in_progress[1] = 1.0
+    stop = AsyncMock()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=True)),
+    ):
+        await _run(queue_db, scheduler, idle=True, stop_drying=stop)
+
+    stop.assert_not_awaited()

+ 97 - 0
backend/tests/unit/test_slicer_preset_values.py

@@ -0,0 +1,97 @@
+"""Tests for resolving a preset's effective values via the sidecar.
+
+The slice modal's settings panel needs the values a preset actually sets, not
+the option schema's compiled-in defaults. Only the sidecar can answer that: a
+"Standard" pick is a ``{inherits: ...}`` stub on our side, and local/cloud
+presets are deltas whose remainder lives in the sidecar's bundled profile tree.
+"""
+
+import json
+
+import httpx
+import pytest
+
+from backend.app.services.slicer_api import SlicerApiService, SlicerApiUnavailableError
+
+PROCESS_STUB = json.dumps({"inherits": "0.20mm Standard @BBL X1C", "from": "system"})
+
+
+def _service(handler) -> SlicerApiService:
+    transport = httpx.MockTransport(handler)
+    client = httpx.AsyncClient(transport=transport)
+    return SlicerApiService("http://sidecar:3003", client=client)
+
+
+class TestResolveProfile:
+    @pytest.mark.asyncio
+    async def test_returns_the_flattened_values(self):
+        def handler(request: httpx.Request) -> httpx.Response:
+            assert request.url.path == "/profiles/resolve"
+            body = json.loads(request.content)
+            assert body["category"] == "process"
+            # The stub goes out as an object, not a JSON string.
+            assert body["profile"]["inherits"] == "0.20mm Standard @BBL X1C"
+            return httpx.Response(200, json={"profile": {"line_width": "0.42", "wall_loops": "2"}})
+
+        service = _service(handler)
+        result = await service.resolve_profile(PROCESS_STUB, "process")
+        assert result.values == {"line_width": "0.42", "wall_loops": "2"}
+        assert result.reason == "ok"
+
+    @pytest.mark.asyncio
+    async def test_a_sidecar_without_the_endpoint_is_reported_as_outdated(self):
+        # Older images 404 here. This is the dominant case in practice -- an
+        # install pulls SIDECAR_TAG:-latest regardless of its own release
+        # channel -- and it is the one with a fix the user can act on, so it
+        # must not be flattened into the generic failure.
+        service = _service(lambda request: httpx.Response(404, json={"message": "Not Found"}))
+        result = await service.resolve_profile(PROCESS_STUB, "process")
+        assert result.values is None
+        assert result.reason == "sidecar_outdated"
+
+    @pytest.mark.asyncio
+    async def test_a_sidecar_error_is_not_reported_as_outdated(self):
+        # A broken sidecar and an old one call for different advice.
+        service = _service(lambda request: httpx.Response(500, json={"message": "boom"}))
+        result = await service.resolve_profile(PROCESS_STUB, "process")
+        assert result.values is None
+        assert result.reason == "sidecar_unavailable"
+
+    @pytest.mark.asyncio
+    async def test_unreachable_sidecar_still_raises(self):
+        # Distinct from "too old": the caller reports this as slicing being
+        # unavailable rather than silently showing defaults forever.
+        def handler(request: httpx.Request) -> httpx.Response:
+            raise httpx.ConnectError("refused")
+
+        with pytest.raises(SlicerApiUnavailableError):
+            await _service(handler).resolve_profile(PROCESS_STUB, "process")
+
+    @pytest.mark.asyncio
+    async def test_unparseable_preset_content_blames_the_preset(self):
+        service = _service(lambda request: httpx.Response(200, json={"profile": {}}))
+        result = await service.resolve_profile("not json", "process")
+        assert result.values is None
+        assert result.reason == "preset_unresolved"
+
+    @pytest.mark.asyncio
+    async def test_a_response_without_a_profile_object_returns_no_values(self):
+        # Guards against reading a differently-shaped body as if it were values.
+        service = _service(lambda request: httpx.Response(200, json={"ok": True}))
+        result = await service.resolve_profile(PROCESS_STUB, "process")
+        assert result.values is None
+        assert result.reason == "sidecar_unavailable"
+
+    @pytest.mark.asyncio
+    async def test_an_already_flat_preset_round_trips(self):
+        flat = json.dumps({"line_width": "0.45", "type": "process"})
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            body = json.loads(request.content)
+            assert "inherits" not in body["profile"]
+            return httpx.Response(200, json={"profile": json.loads(flat)})
+
+        assert (await _service(handler).resolve_profile(flat, "process")).values == {
+            "line_width": "0.45",
+            "type": "process",
+        }

+ 245 - 0
backend/tests/unit/test_slicer_upload_size_rejection.py

@@ -0,0 +1,245 @@
+"""The sidecar's upload cap, reported as something the user can act on (#2802).
+
+The slicer sidecar bounds the size of the model it will accept. multer raises
+that rejection as a ``MulterError``, which is not the sidecar's ``AppError`` —
+so on every image built before the cap became configurable, the sidecar's error
+handler fell through to its default status and answered:
+
+    HTTP 500 {"message": "File too large"}
+
+A 500 reads as "the slicer crashed". Bambuddy's one good message about request
+size lived behind ``if response.status_code == 413``, so it never fired, and the
+reporter of #2802 spent an evening setting ``MAX_FILE_SIZE``,
+``BODY_PARSER_LIMIT`` and ``EXPRESS_PAYLOAD_LIMIT`` and stopping nginx — none of
+which the sidecar reads, on a proxy that was never in the path.
+
+Two things follow, and both are pinned here:
+
+- The rejection is recognised by its *text*, not its status, so it is handled
+  the same whether the sidecar is old (500) or current (413).
+- It raises ``SlicerInputError`` rather than ``SlicerApiServerError``. That is
+  what stops ``POST /library/files/{id}/slice`` retrying the identical
+  oversized upload "with embedded settings" — a second 25-second 3MF
+  conversion for a guaranteed-identical answer, which the reporter's log shows
+  happening on every attempt.
+"""
+
+import httpx
+import pytest
+
+from backend.app.services.slicer_api import (
+    SlicerApiServerError,
+    SlicerApiService,
+    SlicerApiUnavailableError,
+    SlicerInputError,
+    _transport_error_reason,
+)
+
+SLICE_ARGS = {
+    "model_bytes": b"x" * (3 * 1024 * 1024),
+    "model_filename": "0399 Bidoof.3mf",
+    "printer_profile_json": "{}",
+    "process_profile_json": "{}",
+    "filament_profile_jsons": ["{}"],
+}
+
+
+def _service(handler) -> SlicerApiService:
+    client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+    return SlicerApiService("http://sidecar:3001", client=client)
+
+
+def _responder(status_code: int, payload: dict):
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(status_code, json=payload)
+
+    return handler
+
+
+class TestOversizeUploadIsRecognised:
+    @pytest.mark.asyncio
+    async def test_a_500_file_too_large_is_treated_as_bad_input(self):
+        """The exact shape an un-updated sidecar returns."""
+        svc = _service(_responder(500, {"message": "File too large"}))
+
+        with pytest.raises(SlicerInputError) as excinfo:
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+        assert "too large" in str(excinfo.value)
+
+    @pytest.mark.asyncio
+    async def test_a_413_from_a_current_sidecar_is_handled_the_same(self):
+        """Once the sidecar maps MulterError properly it sends 413 instead."""
+        svc = _service(
+            _responder(
+                413,
+                {
+                    "message": "The model file exceeds this slicer's 512 MB upload limit.",
+                    "details": "Raise it by setting MAX_MODEL_UPLOAD_MB.",
+                },
+            )
+        )
+
+        with pytest.raises(SlicerInputError):
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+    @pytest.mark.asyncio
+    async def test_it_is_not_a_server_error(self):
+        """The distinction the retry logic in library.py branches on.
+
+        ``SlicerApiServerError`` is the "the CLI fell over, try the other
+        request shape" signal. An upload the sidecar never accepted is not
+        that, and retrying it uploads the same too-big file again.
+        """
+        svc = _service(_responder(500, {"message": "File too large"}))
+
+        with pytest.raises(SlicerInputError):
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+        # Belt and braces: SlicerInputError must not be a subclass of the type
+        # the fallback catches, or the branch above is decorative.
+        assert not issubclass(SlicerInputError, SlicerApiServerError)
+
+
+class TestTheMessageIsActionable:
+    @pytest.mark.asyncio
+    async def test_it_names_the_model_size(self):
+        """Support packages carried no size at all; #2802 had to be probed."""
+        svc = _service(_responder(500, {"message": "File too large"}))
+
+        with pytest.raises(SlicerInputError) as excinfo:
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+        assert "3 MB" in str(excinfo.value)
+
+    @pytest.mark.asyncio
+    async def test_it_rules_out_the_layers_the_reporter_tried(self):
+        """Naming the wrong knobs is the point: they were tried first."""
+        svc = _service(_responder(500, {"message": "File too large"}))
+
+        with pytest.raises(SlicerInputError) as excinfo:
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+        message = str(excinfo.value)
+        assert "reverse-proxy" in message
+        assert "client_max_body_size" in message
+
+    @pytest.mark.asyncio
+    async def test_an_old_sidecar_is_told_to_update_not_to_set_a_variable(self):
+        """There is no env var to set on an image that predates the cap.
+
+        Telling that user to set MAX_MODEL_UPLOAD_MB would send them round the
+        loop the reporter already did: change a setting, restart, no effect.
+        """
+        svc = _service(_responder(500, {"message": "File too large"}))
+
+        with pytest.raises(SlicerInputError) as excinfo:
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+        message = str(excinfo.value)
+        assert "docker compose pull" in message
+        assert "100 MB" in message
+
+    @pytest.mark.asyncio
+    async def test_a_current_sidecar_is_told_which_variable_to_set(self):
+        """Once the image is current, the fix is one env var, not another pull."""
+        svc = _service(
+            _responder(
+                413,
+                {"message": "The model file exceeds this slicer's 512 MB upload limit."},
+            )
+        )
+
+        with pytest.raises(SlicerInputError) as excinfo:
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+        message = str(excinfo.value)
+        assert "MAX_MODEL_UPLOAD_MB" in message
+        assert "docker compose pull" not in message
+
+    @pytest.mark.asyncio
+    async def test_it_keeps_what_the_sidecar_said(self):
+        """Never swallow the upstream text — it identifies the sidecar version."""
+        svc = _service(_responder(413, {"message": "The model file exceeds this slicer's 256 MB upload limit."}))
+
+        with pytest.raises(SlicerInputError) as excinfo:
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+        assert "256 MB" in str(excinfo.value)
+
+
+class TestOtherFailuresAreUnaffected:
+    @pytest.mark.asyncio
+    async def test_an_ordinary_cli_failure_is_still_a_server_error(self):
+        """The embedded-settings fallback must keep working for real crashes."""
+        svc = _service(
+            _responder(
+                500,
+                {
+                    "message": "Slicing failed with error from slicer",
+                    "details": "Slicer process failed (exit code 250)",
+                },
+            )
+        )
+
+        with pytest.raises(SlicerApiServerError):
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+    @pytest.mark.asyncio
+    async def test_a_cli_error_that_merely_mentions_a_large_file_is_not_hijacked(self):
+        """A 500 only counts as an upload rejection if that is all it says.
+
+        The slicer's own diagnostics land in ``details``, and treating one of
+        those as a size rejection would rob it of the embedded-settings retry
+        that exists to recover from CLI failures.
+        """
+        svc = _service(
+            _responder(
+                500,
+                {
+                    "message": "Slicing failed with error from slicer",
+                    "details": "stderr: output file too large to write",
+                },
+            )
+        )
+
+        with pytest.raises(SlicerApiServerError):
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+    @pytest.mark.asyncio
+    async def test_a_proxy_413_still_names_the_proxy(self):
+        """A 413 that is *not* the sidecar's own cap is a proxy body limit.
+
+        Those really are fixed with ``client_max_body_size``, so that advice
+        has to survive — the new branch must not swallow every 413.
+        """
+        svc = _service(_responder(413, {"message": "<html>413 Request Entity Too Large</html>"}))
+
+        with pytest.raises(SlicerInputError) as excinfo:
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+        assert "client_max_body_size" in str(excinfo.value)
+
+
+class TestTransportErrorsAlwaysNameSomething:
+    """Three lines of the #2802 support package read "unreachable: " and stop."""
+
+    def test_an_exception_with_no_message_falls_back_to_its_type(self):
+        assert _transport_error_reason(httpx.ConnectError("")) == "ConnectError"
+
+    def test_a_real_message_is_preferred(self):
+        assert _transport_error_reason(httpx.ConnectError("All connection attempts failed")) == (
+            "All connection attempts failed"
+        )
+
+    @pytest.mark.asyncio
+    async def test_the_slice_path_never_reports_an_empty_reason(self):
+        def handler(request: httpx.Request) -> httpx.Response:
+            raise httpx.ReadError("")
+
+        svc = _service(handler)
+
+        with pytest.raises(SlicerApiUnavailableError) as excinfo:
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+        assert str(excinfo.value).strip().endswith("ReadError")

+ 1 - 1
backend/tests/unit/test_systemd_backup_paths.py

@@ -22,7 +22,7 @@ INSTALLERS = ["install/install.sh", "spoolbuddy/install/install.sh"]
 
 # The service unit + install scripts these tests read live at the repo root and
 # are not copied into the Docker test image (Dockerfile.test ships only backend/,
-# pyproject.toml, gcode_viewer/ and requirements). In a source checkout they are
+# pyproject.toml and requirements). In a source checkout they are
 # always present and the guard below is live; in the stripped test image there is
 # nothing to check, so skip rather than fail. `frontend/package.json` exists in
 # every checkout but never in the test image, so it distinguishes the two.

+ 4 - 1
frontend/eslint.config.js

@@ -6,7 +6,10 @@ import tseslint from 'typescript-eslint'
 import { defineConfig, globalIgnores } from 'eslint/config'
 
 export default defineConfig([
-  globalIgnores(['dist', 'coverage']),
+  // src/lib/vendor holds third-party build output copied in verbatim. Linting
+  // it produces findings we must not act on -- editing vendored code makes it
+  // impossible to re-copy on the next upstream release.
+  globalIgnores(['dist', 'coverage', 'src/lib/vendor']),
   {
     files: ['**/*.{ts,tsx}'],
     extends: [

+ 0 - 23
frontend/package-lock.json

@@ -23,7 +23,6 @@
         "@tiptap/starter-kit": "^3.11.1",
         "@types/three": "^0.181.0",
         "dompurify": "^3.4.10",
-        "gcode-preview": "^2.18.0",
         "i18next": "25.6.3",
         "i18next-browser-languagedetector": "^8.2.0",
         "jszip": "^3.10.1",
@@ -4300,22 +4299,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/gcode-preview": {
-      "version": "2.18.0",
-      "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-2.18.0.tgz",
-      "integrity": "sha512-uc9QYciG6ES/A6BWJpUZk4fHxCPvt5EnvDhHIHDbNdR/m3f9VkGvpSMh9HDygXjAXX0x1Lbz/e9ZGlIrYNB29A==",
-      "license": "MIT",
-      "dependencies": {
-        "lil-gui": "^0.19.2",
-        "three": "^0.159.0"
-      }
-    },
-    "node_modules/gcode-preview/node_modules/three": {
-      "version": "0.159.0",
-      "resolved": "https://registry.npmjs.org/three/-/three-0.159.0.tgz",
-      "integrity": "sha512-eCmhlLGbBgucuo4VEA9IO3Qpc7dh8Bd4VKzr7WfW4+8hMcIfoAVi1ev0pJYN9PTTsCslbcKgBwr2wNZ1EvLInA==",
-      "license": "MIT"
-    },
     "node_modules/gensync": {
       "version": "1.0.0-beta.2",
       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -5326,12 +5309,6 @@
         "url": "https://opencollective.com/parcel"
       }
     },
-    "node_modules/lil-gui": {
-      "version": "0.19.2",
-      "resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.19.2.tgz",
-      "integrity": "sha512-nU8j4ND702ouGfQZoaTN4dfXxacvGOAVK0DtmZBVcUYUAeYQXLQAjAN50igMHiba3T5jZyKEjXZU+Ntm1Qs6ZQ==",
-      "license": "MIT"
-    },
     "node_modules/linkify-it": {
       "version": "5.0.2",
       "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",

+ 0 - 1
frontend/package.json

@@ -30,7 +30,6 @@
     "@tiptap/starter-kit": "^3.11.1",
     "@types/three": "^0.181.0",
     "dompurify": "^3.4.10",
-    "gcode-preview": "^2.18.0",
     "i18next": "25.6.3",
     "i18next-browser-languagedetector": "^8.2.0",
     "jszip": "^3.10.1",

+ 4 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -220,6 +220,8 @@ const FR_COGNATES = [
   'Compact',  // cam-wall status overlay mode — same word in French
   'ntfy, Pushover, Discord, etc.',
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
+  'Simple', 'Expert',  // slicer settings visibility tiers — identical words in French
+  'Support',  // same word in French
 ];
 
 // Italian cognates.
@@ -254,6 +256,7 @@ const IT_COGNATES = [
   'Proxy', 'Designer',
   'Off',  // cam-wall status overlay mode — common loanword in Italian UI
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
+  'Skirt / brim',  // Italian slicer UIs keep the English terms
 ];
 
 // Japanese: very few cognates because of script difference. Almost
@@ -365,6 +368,7 @@ const ES_COGNATES = [
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
+  'Simple',  // slicer settings visibility tier — identical word in Spanish
 ];
 
 // Turkish cognates — technical UI labels that Turkish speakers use verbatim

+ 142 - 0
frontend/scripts/generate-slicer-schema.mjs

@@ -0,0 +1,142 @@
+// Regenerates the vendored OrcaSlicer process-settings metadata under
+// src/data/slicer/ from the `three-slicer` npm package.
+//
+// Why vendored and not a runtime dependency: we need three of the package's
+// four data files, trimmed to the *process* tab only, and none of its engine,
+// viewer or React code.
+// Pulling `three-slicer` as a dependency would drag in an 8 MB WASM kernel and
+// a `three@^0.160` peer pin that conflicts with our three@^0.181.
+//
+// Usage:  node scripts/generate-slicer-schema.mjs <path-to-three-slicer-package>
+//
+// The upstream data is AGPL-3.0-or-later, extracted from OrcaSlicer's C++
+// sources — same licence as Bambuddy, so vendoring is clean. Re-run this when
+// bumping to a newer three-slicer release and commit the regenerated output.
+
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+
+const src = process.argv[2];
+if (!src) {
+  console.error('usage: node scripts/generate-slicer-schema.mjs <path-to-three-slicer-package>');
+  process.exit(1);
+}
+
+const OUT_DIR = resolve(import.meta.dirname, '..', 'src', 'data', 'slicer');
+
+const readJson = (p) => JSON.parse(readFileSync(join(src, p), 'utf8'));
+
+const schema = readJson('data/config-schema.json');
+const uiTree = readJson('data/ui-tree.json');
+const toggles = readJson('data/toggle-rules.json');
+
+// --- 1. UI tree, process tab only -----------------------------------------
+// TabPrint::build is the process/print preset — the one whose JSON our slice
+// route patches. Filament and printer presets are separate objects on the
+// sidecar and out of scope for this panel.
+const pages = uiTree['TabPrint::build'];
+if (!Array.isArray(pages)) throw new Error('ui-tree.json has no TabPrint::build array');
+
+// Tab.cpp references that PrintConfig.cpp no longer defines, collected while
+// walking the tree so the run can report them.
+const dropped = [];
+
+// Drop the C++ source line numbers: useful for the extractor, noise for us.
+const trimmedPages = pages.map((page) => ({
+  page: page.page,
+  icon: page.icon,
+  groups: (page.groups ?? []).map((g) => ({
+    group: g.group,
+    options: (g.options ?? []).filter((key) => {
+      if (!schema[key]) {
+        // A handful of Tab.cpp references point at options that no longer
+        // exist in PrintConfig.cpp. Silently dropping them keeps the panel
+        // from rendering a control with no type, label or default.
+        dropped.push(key);
+        return false;
+      }
+      return true;
+    }),
+  })).filter((g) => g.options.length > 0),
+})).filter((p) => p.groups.length > 0);
+
+// --- 2. Schema, trimmed to the options the tree actually references --------
+const referenced = new Set(trimmedPages.flatMap((p) => p.groups.flatMap((g) => g.options)));
+
+// Toggle rules reference options for their *conditions* too (e.g. wall_loops
+// gates have_perimeters). Those must survive the trim or the evaluator reads a
+// default of `undefined` and fails open on a rule it could have decided.
+const CONDITION_KEYS = [
+  'wall_loops', 'sparse_infill_density', 'top_shell_layers', 'bottom_shell_layers',
+  'spiral_mode', 'skirt_loops', 'enable_support', 'raft_layers', 'enable_prime_tower',
+  'support_interface_top_layers', 'support_interface_bottom_layers', 'sparse_infill_pattern',
+  'support_type', 'support_style', 'wall_generator', 'timelapse_type', 'infill_combination',
+  'detect_thin_wall', 'ironing_type', 'default_acceleration', 'adaptive_layer_height',
+];
+for (const k of CONDITION_KEYS) if (schema[k]) referenced.add(k);
+
+// Only the fields the panel renders or the evaluator reads. This is what keeps
+// the vendored payload proportionate: the upstream schema is 384 KB across 907
+// options, most of it source-location bookkeeping we have no use for.
+const KEEP = ['type', 'mode', 'label', 'tooltip', 'sidetext', 'min', 'max', 'enum_values', 'enum_labels', 'default'];
+
+// The extractor reads defaults and bounds straight out of C++ initialisers, so
+// float literals arrive in source form: `0.` stays "0.", `0.3f` stays "0.3f",
+// `100.%` stays "100.%", and `0.f` even splits into [0, "f"]. Rendering those
+// verbatim put a column of "0." in the Line width group. They are literal
+// artefacts, not values, so they are cleaned here — once, in the data — rather
+// than worked around in every place that displays a default.
+function normaliseLiteral(value) {
+  if (Array.isArray(value)) {
+    // `0.f` split across two entries; the stray "f" is not a value.
+    const cleaned = value.filter((v) => v !== 'f').map(normaliseLiteral);
+    return cleaned.length > 0 ? cleaned : [0];
+  }
+  if (typeof value !== 'string') return value;
+
+  let s = value.trim();
+  s = s.replace(/^(-?[\d.]+)f$/, '$1');   // 0.3f -> 0.3,  0.f -> 0.
+  s = s.replace(/^(-?[\d.]*)\.%$/, '$1%'); // 100.% -> 100%
+  s = s.replace(/^(-?[\d.]*)\.$/, '$1');   // 0. -> 0
+  // A literal that was nothing but a dot carried no digits to keep.
+  if (s === '' || s === '-') return value;
+  return s;
+}
+
+const trimmedSchema = {};
+for (const key of [...referenced].sort()) {
+  const opt = schema[key];
+  const out = {};
+  for (const f of KEEP) {
+    if (opt[f] === undefined) continue;
+    out[f] = f === 'default' || f === 'min' || f === 'max' ? normaliseLiteral(opt[f]) : opt[f];
+  }
+  trimmedSchema[key] = out;
+}
+
+// --- 3. Toggle rules, FFF print options only ------------------------------
+// The other rule groups drive the filament and printer tabs, which this panel
+// does not render.
+const fff = toggles['toggle_print_fff_options'] ?? {};
+const trimmedToggles = {
+  locals: fff.locals ?? {},
+  rules: (fff.rules ?? [])
+    .filter((r) => r.enable_if && Array.isArray(r.fields))
+    // A rule whose fields are all outside our trimmed set can never change
+    // anything the panel shows.
+    .map((r) => ({ fields: r.fields.filter((f) => referenced.has(f)), enable_if: r.enable_if }))
+    .filter((r) => r.fields.length > 0),
+};
+
+mkdirSync(OUT_DIR, { recursive: true });
+const write = (name, data) => {
+  const path = join(OUT_DIR, name);
+  writeFileSync(path, JSON.stringify(data, null, 0) + '\n');
+  return `${name}: ${(readFileSync(path).length / 1024).toFixed(1)} KB`;
+};
+
+console.log(write('process-ui-tree.json', trimmedPages));
+console.log(write('process-schema.json', trimmedSchema));
+console.log(write('process-toggle-rules.json', trimmedToggles));
+console.log(`options: ${Object.keys(trimmedSchema).length}, pages: ${trimmedPages.length}, rules: ${trimmedToggles.rules.length}`);
+if (dropped.length) console.log(`dropped (no schema entry): ${dropped.join(', ')}`);

+ 4 - 57
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -12,7 +12,7 @@ import { openInSlicer } from '../../utils/slicer';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
-// Mock ModelViewer and GcodeViewer to avoid WebGL/Three.js issues in tests
+// Mock ModelViewer to avoid WebGL/Three.js issues in tests
 vi.mock('../../components/ModelViewer', () => ({
   ModelViewer: ({ className }: { className?: string }) => (
     <div data-testid="model-viewer" className={className}>
@@ -21,14 +21,6 @@ vi.mock('../../components/ModelViewer', () => ({
   ),
 }));
 
-vi.mock('../../components/GcodeViewer', () => ({
-  GcodeViewer: ({ className }: { className?: string }) => (
-    <div data-testid="gcode-viewer" className={className}>
-      G-code Viewer Mock
-    </div>
-  ),
-}));
-
 // Only the protocol-handler launch is stubbed — it would navigate the jsdom
 // window. Everything else in the module is a pure predicate, so keep the real
 // implementations: re-declaring them here would let the file-type rule these
@@ -155,7 +147,8 @@ describe('ModelViewerModal', () => {
   });
 
   describe('tabs', () => {
-    it('renders 3D Model and G-code tabs', async () => {
+    it('renders the 3D Model tab and no G-code tab', async () => {
+      // G-code has its own full-page viewer; the modal is model-only.
       render(
         <ModelViewerModal
           archiveId={1}
@@ -166,8 +159,8 @@ describe('ModelViewerModal', () => {
 
       await waitFor(() => {
         expect(screen.getByText('3D Model')).toBeInTheDocument();
-        expect(screen.getByText('G-code Preview')).toBeInTheDocument();
       });
+      expect(screen.queryByText('G-code Preview')).not.toBeInTheDocument();
     });
 
     it('shows not available label when model is not available', async () => {
@@ -193,52 +186,6 @@ describe('ModelViewerModal', () => {
       });
     });
 
-    it('shows not sliced label when gcode is not available', async () => {
-      server.use(
-        http.get('/api/v1/archives/:id/capabilities', () => {
-          return HttpResponse.json({
-            ...mockCapabilities,
-            has_gcode: false,
-          });
-        })
-      );
-
-      render(
-        <ModelViewerModal
-          archiveId={1}
-          title="Test Model"
-          onClose={mockOnClose}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('(not sliced)')).toBeInTheDocument();
-      });
-    });
-
-    it('disables tab when capability is not available', async () => {
-      server.use(
-        http.get('/api/v1/archives/:id/capabilities', () => {
-          return HttpResponse.json({
-            ...mockCapabilities,
-            has_gcode: false,
-          });
-        })
-      );
-
-      render(
-        <ModelViewerModal
-          archiveId={1}
-          title="Test Model"
-          onClose={mockOnClose}
-        />
-      );
-
-      await waitFor(() => {
-        const gcodeTab = screen.getByText('G-code Preview').closest('button');
-        expect(gcodeTab).toBeDisabled();
-      });
-    });
   });
 
   describe('fullscreen', () => {

+ 293 - 21
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -8,7 +8,7 @@
  * the tracker — not here.
  */
 
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
@@ -33,6 +33,8 @@ vi.mock('../../api/client', () => ({
     // Slicer Pipelines (#1425)
     listSlicerPipelines: vi.fn(),
     createSlicerPipeline: vi.fn(),
+    getSlicerPrinterModels: vi.fn(),
+    getSlicerPresetValues: vi.fn(),
   },
 }));
 
@@ -47,6 +49,8 @@ const mockApi = api as unknown as {
   getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
   listSlicerPipelines: ReturnType<typeof vi.fn>;
   createSlicerPipeline: ReturnType<typeof vi.fn>;
+  getSlicerPrinterModels: ReturnType<typeof vi.fn>;
+  getSlicerPresetValues: ReturnType<typeof vi.fn>;
 };
 
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
@@ -101,6 +105,7 @@ describe('SliceModal', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
+    mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
     mockApi.getSliceJob.mockResolvedValue({
       job_id: 42,
       status: 'running',
@@ -350,12 +355,27 @@ describe('SliceModal', () => {
     ],
   };
 
+  // The designer's settings are shown inside the process-settings panel now,
+  // against the options they belong to, rather than in a list of their own.
+  // The payload contract below is unchanged: their *values* still travel as
+  // design_overrides keys, read from the file by the backend.
   async function openDesignSection() {
     const user = userEvent.setup();
-    await user.click(await screen.findByText(/Keep the designer's settings/));
+    await user.click(await screen.findByRole('button', { name: /Process settings/ }));
+    await screen.findByPlaceholderText('Search settings');
+    // Every designer key must be reachable, including expert-tier ones.
+    await user.click(screen.getByRole('button', { name: 'Expert' }));
     return user;
   }
 
+  /** The panel's per-option "use the file's value" checkbox, by option key. */
+  function sourceCheckbox(key: string): HTMLInputElement {
+    const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
+    const found = boxes.find((b) => (b.getAttribute('aria-label') ?? '').includes(key));
+    if (!found) throw new Error(`no source checkbox for ${key}`);
+    return found;
+  }
+
   it("carries the design's printer-independent settings by default (#2622)", async () => {
     mockApi.sliceLibraryFile.mockResolvedValue({
       job_id: 42,
@@ -369,8 +389,9 @@ describe('SliceModal', () => {
       onClose: vi.fn(),
     });
 
-    // Two of three pre-selected: the speed key is machine-coupled.
-    expect(await screen.findByText('2 of 3 selected')).toBeInTheDocument();
+    // Two of three pre-selected: the speed key is machine-coupled and is
+    // offered but never pre-ticked.
+    await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled());
 
     const user = userEvent.setup();
     await user.click(screen.getByRole('button', { name: /^Slice$/ }));
@@ -388,15 +409,18 @@ describe('SliceModal', () => {
       onClose: vi.fn(),
     });
 
-    await openDesignSection();
+    const user = await openDesignSection();
+
+    // Carried keys show the designer's value in the option's own control.
+    await user.type(screen.getByPlaceholderText('Search settings'), 'wall loops');
+    await waitFor(() => expect(screen.getByLabelText(/^Wall loops/)).toHaveValue(5));
+    expect(sourceCheckbox('Wall loops').checked).toBe(true);
 
-    expect(screen.getByText('wall_loops')).toBeInTheDocument();
-    expect(screen.getByText('5')).toBeInTheDocument();
-    expect(screen.getByText('sparse_infill_density')).toBeInTheDocument();
-    expect(screen.getByText('100%')).toBeInTheDocument();
-    // The risky one is listed too — visible, explained, just not pre-ticked.
-    expect(screen.getByText('outer_wall_speed')).toBeInTheDocument();
-    expect(screen.getByText('printer-specific')).toBeInTheDocument();
+    await user.clear(screen.getByPlaceholderText('Search settings'));
+    await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed');
+    // The machine-coupled one is present and flagged, just not pre-ticked.
+    await waitFor(() => expect(screen.getAllByText("designer's printer").length).toBeGreaterThan(0));
+    expect(sourceCheckbox('Outer wall').checked).toBe(false);
   });
 
   it('lets the user opt a machine-coupled setting in and a safe one out (#2622)', async () => {
@@ -413,12 +437,15 @@ describe('SliceModal', () => {
     });
 
     const user = await openDesignSection();
-    const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
-    const byKey = (key: string) =>
-      boxes.find((b) => b.closest('label')?.textContent?.includes(key)) as HTMLInputElement;
 
-    await user.click(byKey('outer_wall_speed'));
-    await user.click(byKey('wall_loops'));
+    await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed');
+    await waitFor(() => expect(sourceCheckbox('Outer wall')).toBeInTheDocument());
+    await user.click(sourceCheckbox('Outer wall'));
+
+    await user.clear(screen.getByPlaceholderText('Search settings'));
+    await user.type(screen.getByPlaceholderText('Search settings'), 'wall loops');
+    await waitFor(() => expect(sourceCheckbox('Wall loops')).toBeInTheDocument());
+    await user.click(sourceCheckbox('Wall loops'));
 
     await user.click(screen.getByRole('button', { name: /^Slice$/ }));
 
@@ -441,9 +468,11 @@ describe('SliceModal', () => {
     });
 
     const user = await openDesignSection();
-    const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
-    for (const box of boxes) {
-      if (box.checked) await user.click(box);
+    for (const key of ['Wall loops', 'Sparse infill density']) {
+      await user.clear(screen.getByPlaceholderText('Search settings'));
+      await user.type(screen.getByPlaceholderText('Search settings'), key.toLowerCase());
+      await waitFor(() => expect(sourceCheckbox(key)).toBeInTheDocument());
+      if (sourceCheckbox(key).checked) await user.click(sourceCheckbox(key));
     }
 
     await user.click(screen.getByRole('button', { name: /^Slice$/ }));
@@ -461,7 +490,10 @@ describe('SliceModal', () => {
     });
 
     await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled());
-    expect(screen.queryByText(/Keep the designer's settings/)).toBeNull();
+    // The panel still exists — it is the editor — but nothing is marked as
+    // coming from the file.
+    expect(screen.queryByText('from file')).toBeNull();
+    expect(screen.queryByText("designer's printer")).toBeNull();
   });
 
   it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
@@ -1517,6 +1549,246 @@ describe('SliceModal', () => {
 
 // Pure-function tests for the filament slot picker. Pinned as a separate
 // describe so the contract is visible without needing the modal mount.
+/**
+ * The slice dialog switches to a two-column layout once there is room for it,
+ * and the process-settings panel then owns the right-hand column. The global
+ * test setup pins matchMedia to `matches: false`, so every other test in this
+ * file exercises the narrow single-stack path; these override it.
+ */
+describe('SliceModal — process settings in "slice as designed" mode', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
+    mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.getSlicerPrinterModels.mockResolvedValue({});
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Designed.3mf',
+      plates: [],
+      is_multi_plate: false,
+      embedded_printer: 'Bambu Lab X1 Carbon 0.4 nozzle',
+      embedded_process: '0.20mm Standard',
+    });
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+      file_id: 100, filename: 'Designed.3mf', plate_id: 1, filaments: [],
+    });
+  });
+
+  it('disables the panel rather than removing it', async () => {
+    const user = userEvent.setup();
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    const toggle = (await screen.findByLabelText(/Use the file's built-in settings/)) as HTMLInputElement;
+    const header = await screen.findByRole('button', { name: /Process settings/ });
+    await user.click(header);
+    const search = await screen.findByPlaceholderText('Search settings');
+    expect(search).toBeEnabled();
+
+    await user.click(toggle);
+
+    // Still on screen — hiding it made the dialog look like it had lost a
+    // feature — but nothing in it can be operated, because nothing in it is
+    // sent on this path.
+    expect(screen.getByPlaceholderText('Search settings')).toBeDisabled();
+    expect(screen.getByRole('button', { name: 'Expert' })).toBeDisabled();
+    expect(screen.getByText(/Not used while/)).toBeInTheDocument();
+    expect(screen.getByText('Inactive')).toBeInTheDocument();
+  });
+
+  it('sends no process overrides once the file drives the slice', async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({ job_id: 42, status: 'pending', status_url: '/x' });
+    const user = userEvent.setup();
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    // Edit something, then hand the slice over to the file's own settings.
+    await user.click(await screen.findByRole('button', { name: /Process settings/ }));
+    const input = await screen.findByLabelText(/^Layer height/);
+    await user.clear(input);
+    await user.type(input, '0.16');
+
+    await user.click(screen.getByLabelText(/Use the file's built-in settings/));
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
+    const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as Record<string, unknown>;
+    expect(payload.use_embedded_settings).toBe(true);
+    expect(payload).not.toHaveProperty('process_overrides');
+  });
+});
+
+describe('SliceModal — process settings layout', () => {
+  const setViewport = (wide: boolean) => {
+    Object.defineProperty(window, 'matchMedia', {
+      writable: true,
+      value: (query: string) => ({
+        matches: wide,
+        media: query,
+        onchange: null,
+        addListener: () => {},
+        removeListener: () => {},
+        addEventListener: () => {},
+        removeEventListener: () => {},
+        dispatchEvent: () => true,
+      }),
+    });
+  };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
+    mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Cube.stl',
+      plates: [],
+      is_multi_plate: false,
+    });
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+      file_id: 100,
+      filename: 'Cube.stl',
+      plate_id: 1,
+      filaments: [],
+    });
+  });
+
+  afterEach(() => setViewport(false));
+
+  it('keeps the panel collapsed behind a disclosure in the narrow layout', async () => {
+    setViewport(false);
+    renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn() });
+
+    const header = await screen.findByRole('button', { name: /Process settings/ });
+    expect(header).toBeEnabled();
+    expect(screen.queryByPlaceholderText('Search settings')).not.toBeInTheDocument();
+
+    await userEvent.setup().click(header);
+    await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
+  });
+
+  it('opens the panel without a click once it has a column of its own', async () => {
+    setViewport(true);
+    renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn() });
+
+    // No disclosure to operate: the panel is the column, so its header is
+    // inert rather than offering to collapse something that has room.
+    await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
+    expect(screen.getByRole('button', { name: /Process settings/ })).toBeDisabled();
+  });
+});
+
+/**
+ * Process and filament lists hold back presets that resolve to a *different*
+ * printer, behind a per-slot "Show all". Two things must never be hidden: a
+ * preset whose compatibility is merely unknown, and whatever is currently
+ * selected.
+ */
+describe('SliceModal — presets filtered by the selected printer', () => {
+  const presets: UnifiedPresetsResponse = {
+    cloud: { printer: [], process: [], filament: [] },
+    orca_cloud: { printer: [], process: [], filament: [] },
+    local: { printer: [], process: [], filament: [] },
+    standard: {
+      printer: [
+        { id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' },
+      ],
+      process: [
+        { id: 'p-x1c', name: '0.20mm Standard @BBL X1C', source: 'standard' },
+        { id: 'p-h2d', name: '0.20mm Standard @BBL H2D', source: 'standard' },
+        { id: 'p-a1m', name: '0.20mm Standard @BBL A1M', source: 'standard' },
+        // No printer tag at all — compatibility is unknown, never hidden.
+        { id: 'p-custom', name: 'My own profile', source: 'standard' },
+      ],
+      filament: [{ id: 'f-x1c', name: 'Bambu PLA Basic @BBL X1C', source: 'standard' }],
+    },
+    cloud_status: 'ok',
+    orca_cloud_status: 'ok',
+  } as UnifiedPresetsResponse;
+
+  const processOptionNames = () =>
+    Array.from(presetSelects()[1].options).map((o) => o.textContent);
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.getSlicerPresets.mockResolvedValue(presets);
+    mockApi.getSlicerPrinterModels.mockResolvedValue({ 'Bambu Lab X1 Carbon': 'X1C' });
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100, filename: 'Cube.stl', plates: [], is_multi_plate: false,
+    });
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+      file_id: 100, filename: 'Cube.stl', plate_id: 1, filaments: [],
+    });
+  });
+
+  const open = async () => {
+    renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn() });
+    await waitFor(() => expect(presetSelects().length).toBeGreaterThan(1));
+  };
+
+  it('leaves out presets belonging to another printer', async () => {
+    await open();
+    await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL X1C'));
+    expect(processOptionNames()).not.toContain('0.20mm Standard @BBL H2D');
+    expect(processOptionNames()).not.toContain('0.20mm Standard @BBL A1M');
+  });
+
+  it('keeps a preset whose compatibility cannot be determined', async () => {
+    await open();
+    // An untagged preset carries no evidence either way; hiding it would make
+    // a user's own imported profiles vanish.
+    await waitFor(() => expect(processOptionNames()).toContain('My own profile'));
+  });
+
+  it('says how many it held back and reveals them on request', async () => {
+    const user = userEvent.setup();
+    await open();
+
+    const hidden = await screen.findByText('2 hidden');
+    expect(hidden).toBeInTheDocument();
+
+    await user.click(within(hidden.parentElement as HTMLElement).getByRole('button', { name: 'Show all' }));
+    await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D'));
+    expect(processOptionNames()).toContain('0.20mm Standard @BBL A1M');
+  });
+
+  it('collapses the list again on Show fewer', async () => {
+    const user = userEvent.setup();
+    await open();
+
+    await user.click((await screen.findAllByRole('button', { name: 'Show all' }))[0]);
+    await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D'));
+
+    await user.click(screen.getAllByRole('button', { name: 'Show fewer' })[0]);
+    await waitFor(() => expect(processOptionNames()).not.toContain('0.20mm Standard @BBL H2D'));
+  });
+
+  it('never hides the preset that is currently selected', async () => {
+    const user = userEvent.setup();
+    await open();
+
+    // Reach a cross-printer preset, pick it, then collapse the list again.
+    await user.click((await screen.findAllByRole('button', { name: 'Show all' }))[0]);
+    await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D'));
+    await user.selectOptions(presetSelects()[1], 'standard:p-h2d');
+    await user.click(screen.getAllByRole('button', { name: 'Show fewer' })[0]);
+
+    // Dropping it from the options would blank the select and silently discard
+    // a deliberate cross-printer choice.
+    await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D'));
+    expect(presetSelects()[1].value).toBe('standard:p-h2d');
+    // The one still-hidden preset is counted; the selected one is not.
+    expect(screen.getByText('1 hidden')).toBeInTheDocument();
+  });
+});
+
 describe('pickFilamentForSlot — printer-compat contract (#1851)', () => {
   // Index that recognises @BBL H2C / @BBL A1 tokens via the canonical
   // PRINTER_MODEL_MAP. Real production data comes through

+ 480 - 0
frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx

@@ -0,0 +1,480 @@
+import { describe, it, expect, vi } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
+
+import { render } from '../utils';
+import SlicerSettingsPanel, { type FilamentChoice } from '../../components/SlicerSettingsPanel';
+import type { SettingValue } from '../../types/slicerSettings';
+import type { DesignOverride } from '../../types/plates';
+import type { SlicerPresetValuesReason } from '../../api/client';
+
+/**
+ * The panel is a controlled component: it renders from the `values` prop and
+ * reports edits upward. Driving it with a bare spy would leave every input
+ * frozen at its initial value, so the harness holds state the way SliceModal
+ * does and forwards each call to the spy for assertions.
+ */
+function Harness({
+  initial,
+  onChange,
+  sourceOverrides,
+  initialSelected,
+  filamentChoices,
+  presetValues,
+  presetValuesResolved,
+  presetValuesReason,
+}: {
+  initial: Record<string, SettingValue>;
+  onChange: (v: Record<string, SettingValue>, s: Record<string, string | string[]>) => void;
+  sourceOverrides?: DesignOverride[];
+  initialSelected?: string[];
+  filamentChoices?: FilamentChoice[];
+  presetValues?: Record<string, SettingValue>;
+  presetValuesResolved?: boolean;
+  presetValuesReason?: SlicerPresetValuesReason;
+}) {
+  const [values, setValues] = useState(initial);
+  const [selected, setSelected] = useState(new Set(initialSelected ?? []));
+  return (
+    <SlicerSettingsPanel
+      values={values}
+      onChange={(v, s) => {
+        setValues(v);
+        onChange(v, s);
+      }}
+      filamentChoices={filamentChoices}
+      presetValues={presetValues}
+      presetValuesResolved={presetValuesResolved}
+      presetValuesReason={presetValuesReason}
+      sourceOverrides={sourceOverrides}
+      sourceSelected={selected}
+      onToggleSource={(key, on) =>
+        setSelected((prev) => {
+          const next = new Set(prev);
+          if (on) next.add(key);
+          else next.delete(key);
+          return next;
+        })
+      }
+    />
+  );
+}
+
+/** Renders the panel and waits for its dynamically imported metadata. */
+async function renderPanel(
+  initial: Record<string, SettingValue> = {},
+  extra: {
+    sourceOverrides?: DesignOverride[];
+    initialSelected?: string[];
+    filamentChoices?: FilamentChoice[];
+    presetValues?: Record<string, SettingValue>;
+    presetValuesResolved?: boolean;
+    presetValuesReason?: SlicerPresetValuesReason;
+  } = {},
+) {
+  const onChange = vi.fn();
+  render(<Harness initial={initial} onChange={onChange} {...extra} />);
+  await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
+  return { onChange };
+}
+
+/**
+ * Brings one option on screen regardless of which page or visibility tier it
+ * belongs to. Searching spans every page, which is how a user would reach a
+ * setting they know the name of.
+ */
+async function showOption(user: ReturnType<typeof userEvent.setup>, label: string, search: string) {
+  await user.click(screen.getByRole('button', { name: 'Expert' }));
+  const box = screen.getByPlaceholderText('Search settings');
+  await user.clear(box);
+  await user.type(box, search);
+  return waitFor(() => screen.getByLabelText(new RegExp(`^${label}`)));
+}
+
+describe('SlicerSettingsPanel', () => {
+  it('opens on the first page of the slicer parameter tree', async () => {
+    await renderPanel();
+    expect(screen.getByRole('button', { name: 'Quality' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Strength' })).toBeInTheDocument();
+    expect(screen.getByLabelText(/^Layer height/)).toBeInTheDocument();
+  });
+
+  it('reveals more options as the visibility tier widens', async () => {
+    const user = userEvent.setup();
+    await renderPanel();
+
+    // "Slice gap closing radius" is an advanced-tier Quality option.
+    expect(screen.queryByLabelText(/^Slice gap closing radius/)).not.toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: 'Advanced' }));
+    await waitFor(() => expect(screen.getByLabelText(/^Slice gap closing radius/)).toBeInTheDocument());
+  });
+
+  it('searches across every page rather than only the open one', async () => {
+    const user = userEvent.setup();
+    await renderPanel();
+
+    // Enable support lives on the Support page, not the Quality page shown.
+    await user.type(screen.getByPlaceholderText('Search settings'), 'enable support');
+    await waitFor(() => expect(screen.getByLabelText(/^Enable support/)).toBeInTheDocument());
+  });
+
+  it('reports an edit serialised the way a process preset stores it', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel();
+
+    const input = screen.getByLabelText(/^Layer height/);
+    await user.clear(input);
+    await user.type(input, '0.16');
+
+    await waitFor(() => {
+      const [values, serialized] = onChange.mock.calls.at(-1)!;
+      expect(values.layer_height).toBe('0.16');
+      expect(serialized.layer_height).toBe('0.16');
+    });
+  });
+
+  it('puts the percent sign back on a percent option', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel();
+
+    const input = await showOption(user, 'Sparse infill density', 'sparse infill density');
+    await user.clear(input);
+    await user.type(input, '35');
+
+    // "35" and "35%" are different values to the slicer; the schema decides.
+    await waitFor(() => {
+      const [, serialized] = onChange.mock.calls.at(-1)!;
+      expect(serialized.sparse_infill_density).toBe('35%');
+    });
+  });
+
+  it('sends nothing for a value that equals the preset default', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel();
+
+    // wall_loops defaults to 2 — typing it back is not an override.
+    const input = await showOption(user, 'Wall loops', 'wall loops');
+    await user.clear(input);
+    await user.type(input, '2');
+
+    await waitFor(() => {
+      const [values, serialized] = onChange.mock.calls.at(-1)!;
+      expect(values.wall_loops).toBe('2');
+      expect(serialized).not.toHaveProperty('wall_loops');
+    });
+  });
+
+  it('greys out options the slicer disables at the current settings', async () => {
+    // sparse_infill_density at 0 turns off have_infill, which gates the infill
+    // pattern — the same rule the desktop slicer applies.
+    const user = userEvent.setup();
+    await renderPanel({ sparse_infill_density: '0%' });
+    const pattern = await showOption(user, 'Sparse infill pattern', 'sparse infill pattern');
+    expect(pattern).toBeDisabled();
+  });
+
+  it('keeps an option editable while infill is on', async () => {
+    const user = userEvent.setup();
+    await renderPanel({ sparse_infill_density: '15%' });
+    const pattern = await showOption(user, 'Sparse infill pattern', 'sparse infill pattern');
+    expect(pattern).not.toBeDisabled();
+  });
+
+  it('lets a field be emptied without snapping back to the default', async () => {
+    // Regression: dropping the key on an empty input made the control fall
+    // straight back to the preset default, so clearing a value to retype it
+    // appended to the old one ("0.2" + "0.16" = "0.2016").
+    const user = userEvent.setup();
+    await renderPanel();
+
+    const input = screen.getByLabelText(/^Layer height/);
+    await user.clear(input);
+    expect(input).toHaveValue(null);
+  });
+
+  it('lets a free-text field be emptied too', async () => {
+    // coFloatOrPercent / coString / vector options render as text rather than
+    // number inputs, and the same drop-the-key-on-empty bug lived on that
+    // branch after the number branch was fixed.
+    const user = userEvent.setup();
+    await renderPanel();
+
+    const input = await showOption(user, 'Default', 'line_width');
+    await user.clear(input);
+    expect(input).toHaveValue('');
+  });
+
+  it('clears every override from the header reset', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel({ layer_height: '0.16' });
+
+    await user.click(await screen.findByRole('button', { name: /Reset 1/ }));
+
+    const [values, serialized] = onChange.mock.calls.at(-1)!;
+    expect(values).toEqual({});
+    expect(serialized).toEqual({});
+  });
+
+  it('reverts a single option without touching the others', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel({ layer_height: '0.16', wall_loops: 4 });
+
+    const row = screen.getByLabelText(/^Layer height/).closest('div.group') as HTMLElement;
+    await user.click(within(row).getByRole('button', { name: 'Reset to default' }));
+
+    const [values] = onChange.mock.calls.at(-1)!;
+    expect(values).not.toHaveProperty('layer_height');
+    expect(values.wall_loops).toBe(4);
+  });
+});
+
+describe('SlicerSettingsPanel — search', () => {
+  it('treats underscores and spaces alike so a key can be typed naturally', async () => {
+    // outer_wall_speed's label is only "Outer wall" — the Speed page supplies
+    // the rest — so the key is the only place the full phrase appears.
+    const user = userEvent.setup();
+    await renderPanel();
+    await user.click(screen.getByRole('button', { name: 'Expert' }));
+    await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed');
+    await waitFor(() => expect(screen.getByLabelText(/^Outer wall/)).toBeInTheDocument());
+  });
+
+  it('matches a page or group name, not just option labels', async () => {
+    const user = userEvent.setup();
+    await renderPanel();
+    await user.click(screen.getByRole('button', { name: 'Expert' }));
+    await user.type(screen.getByPlaceholderText('Search settings'), 'ironing');
+    await waitFor(() => expect(screen.getByLabelText(/^Ironing type/)).toBeInTheDocument());
+  });
+});
+
+describe("SlicerSettingsPanel — the source file's own settings", () => {
+  const sourceOverrides: DesignOverride[] = [
+    { key: 'wall_loops', value: '5', printer_coupled: false },
+    { key: 'outer_wall_speed', value: '200', printer_coupled: true },
+    // A key the vendored schema has no entry for. It still applies, so it must
+    // not silently vanish from a panel that claims to show what will be used.
+    { key: 'some_unlisted_key', value: '7', printer_coupled: false },
+  ];
+
+  it("shows the designer's value against the option once switched on", async () => {
+    const user = userEvent.setup();
+    await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
+    const input = await showOption(user, 'Wall loops', 'wall loops');
+    expect(input).toHaveValue(5);
+    expect(screen.getByText('from file')).toBeInTheDocument();
+  });
+
+  it('falls back to the preset value when it is switched off', async () => {
+    const user = userEvent.setup();
+    await renderPanel({}, { sourceOverrides, initialSelected: [] });
+    // wall_loops defaults to 2 in the schema.
+    const input = await showOption(user, 'Wall loops', 'wall loops');
+    expect(input).toHaveValue(2);
+  });
+
+  it("puts the file's tick before the control it qualifies", async () => {
+    // A checkbox that gates a field belongs ahead of it. It used to render
+    // after the unit, out at the row's right edge, reading as unrelated.
+    const user = userEvent.setup();
+    await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
+    const control = await showOption(user, 'Wall loops', 'wall loops');
+
+    const row = control.closest('div.group') as HTMLElement;
+    const tick = within(row).getByRole('checkbox');
+    const controlFollowsTick = tick.compareDocumentPosition(control) & Node.DOCUMENT_POSITION_FOLLOWING;
+    expect(controlFollowsTick).toBeTruthy();
+  });
+
+  it('flags a machine-coupled setting rather than applying it quietly', async () => {
+    const user = userEvent.setup();
+    await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
+    await user.click(screen.getByRole('button', { name: 'Expert' }));
+    await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed');
+    await waitFor(() => expect(screen.getByText("designer's printer")).toBeInTheDocument());
+  });
+
+  it('lists source settings the schema has no entry for', async () => {
+    await renderPanel({}, { sourceOverrides, initialSelected: ['some_unlisted_key'] });
+    await waitFor(() => expect(screen.getByText('Other settings from this file')).toBeInTheDocument());
+    expect(screen.getByText('some_unlisted_key')).toBeInTheDocument();
+    expect(screen.getByText('7')).toBeInTheDocument();
+  });
+
+  it('keeps a typed value ahead of the file\'s', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
+
+    const input = await showOption(user, 'Wall loops', 'wall loops');
+    expect(input).toHaveValue(5);
+    await user.clear(input);
+    await user.type(input, '3');
+
+    // The typed value is what gets sent; the file's tick is unaffected and the
+    // backend applies it first, so last-write-wins leaves 3 in the process JSON.
+    await waitFor(() => {
+      const [, serialized] = onChange.mock.calls.at(-1)!;
+      expect(serialized.wall_loops).toBe('3');
+    });
+  });
+});
+
+describe('SlicerSettingsPanel — filament-slot options', () => {
+  const filamentChoices: FilamentChoice[] = [
+    { index: 1, label: 'Bambu PLA Basic', color: '#FF0000' },
+    { index: 2, label: 'Bambu Support for PLA', color: '#FFFFFF' },
+  ];
+
+  it("follows the slicer's own gating rather than being live regardless", async () => {
+    // The interface picker sits behind have_support_material, so it greys out
+    // with supports off — becoming a dropdown must not exempt it from the
+    // rules every other option obeys.
+    const user = userEvent.setup();
+    await renderPanel({}, { filamentChoices });
+    const off = await showOption(user, 'Support/raft interface', 'support_interface_filament');
+    expect(off).toBeDisabled();
+  });
+
+  it('is operable once supports are switched on', async () => {
+    const user = userEvent.setup();
+    await renderPanel({ enable_support: true }, { filamentChoices });
+    const on = await showOption(user, 'Support/raft interface', 'support_interface_filament');
+    expect(on).toBeEnabled();
+  });
+
+  it('offers the picked filaments instead of a bare number field', async () => {
+    const user = userEvent.setup();
+    await renderPanel({}, { filamentChoices });
+    const control = await showOption(user, 'Support/raft base', 'support_filament');
+
+    expect(control.tagName).toBe('SELECT');
+    const labels = Array.from((control as HTMLSelectElement).options).map((o) => o.textContent);
+    expect(labels).toEqual(['Default', '1: Bambu PLA Basic', '2: Bambu Support for PLA']);
+  });
+
+  it("defaults to the slicer's 0, meaning no specific filament", async () => {
+    const user = userEvent.setup();
+    await renderPanel({}, { filamentChoices });
+    const control = await showOption(user, 'Support/raft base', 'support_filament');
+    expect(control).toHaveValue('0');
+  });
+
+  it('sends the slot index the slicer expects', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel({ enable_support: true }, { filamentChoices });
+    const control = await showOption(user, 'Support/raft interface', 'support_interface_filament');
+
+    await user.selectOptions(control, '2');
+    await waitFor(() => {
+      const [, serialized] = onChange.mock.calls.at(-1)!;
+      expect(serialized.support_interface_filament).toBe('2');
+    });
+  });
+
+  it('stays a plain number field when no filaments have been picked', async () => {
+    // STL sources and the pre-plate-analysis window have no slot list yet;
+    // an empty dropdown would be worse than the number input it replaced.
+    const user = userEvent.setup();
+    await renderPanel({}, { filamentChoices: [] });
+    const control = await showOption(user, 'Support/raft base', 'support_filament');
+    expect(control.tagName).toBe('INPUT');
+  });
+
+  it('leaves unrelated integer options alone', async () => {
+    const user = userEvent.setup();
+    await renderPanel({}, { filamentChoices });
+    const control = await showOption(user, 'Wall loops', 'wall loops');
+    expect(control.tagName).toBe('INPUT');
+  });
+});
+
+describe('SlicerSettingsPanel — the picked preset\'s values', () => {
+  it('shows the preset value rather than the compiled-in default', async () => {
+    // The reported bug: line_width defaults to 0 in OrcaSlicer's C++ (meaning
+    // "derive from the nozzle"), so every Line width field read 0 regardless
+    // of what the chosen preset actually sets.
+    const user = userEvent.setup();
+    await renderPanel({}, { presetValues: { line_width: '0.42' } });
+    const input = await showOption(user, 'Default', 'line_width');
+    expect(input).toHaveValue('0.42');
+  });
+
+  it('does not mark a preset value as a user change', async () => {
+    // Comparing against the schema default would flag every field the preset
+    // moved off the C++ default as edited, and send values nobody typed.
+    const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
+    await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
+    expect(screen.queryByRole('button', { name: /Reset \d/ })).not.toBeInTheDocument();
+    expect(onChange).not.toHaveBeenCalled();
+  });
+
+  it('sends an edit that differs from the preset', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
+    const input = await showOption(user, 'Default', 'line_width');
+
+    await user.clear(input);
+    await user.type(input, '0.5');
+
+    await waitFor(() => {
+      const [, serialized] = onChange.mock.calls.at(-1)!;
+      expect(serialized.line_width).toBe('0.5');
+    });
+  });
+
+  it('sends nothing for a value retyped to match the preset', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
+    const input = await showOption(user, 'Default', 'line_width');
+
+    await user.clear(input);
+    await user.type(input, '0.42');
+
+    await waitFor(() => expect(onChange).toHaveBeenCalled());
+    const [, serialized] = onChange.mock.calls.at(-1)!;
+    expect(serialized).not.toHaveProperty('line_width');
+  });
+
+  it('reverts to the preset value, not the schema default', async () => {
+    const user = userEvent.setup();
+    await renderPanel({ line_width: '0.5' }, { presetValues: { line_width: '0.42' } });
+    const input = await showOption(user, 'Default', 'line_width');
+    expect(input).toHaveValue('0.5');
+
+    const row = input.closest('div.group') as HTMLElement;
+    await user.click(within(row).getByRole('button', { name: 'Reset to default' }));
+    await waitFor(() => expect(screen.getByLabelText(/^Default/)).toHaveValue('0.42'));
+  });
+
+  it('says so when the preset values could not be read', async () => {
+    await renderPanel({}, { presetValuesResolved: false });
+    await waitFor(() => expect(screen.getByText(/Showing slicer defaults/)).toBeInTheDocument());
+  });
+
+  it('names the fix when the sidecar predates the endpoint', async () => {
+    // The dominant case: an install pulls SIDECAR_TAG:-latest regardless of
+    // its own release channel, so a current Bambuddy against an old sidecar is
+    // normal. A generic "could not be read" sends that user hunting.
+    await renderPanel({}, { presetValuesResolved: false, presetValuesReason: 'sidecar_outdated' });
+    await waitFor(() => expect(screen.getByText(/Update the sidecar image/)).toBeInTheDocument());
+  });
+
+  it('distinguishes a sidecar that is missing from one that is merely old', async () => {
+    await renderPanel({}, { presetValuesResolved: false, presetValuesReason: 'not_configured' });
+    await waitFor(() => expect(screen.getByText(/no slicer sidecar is configured/)).toBeInTheDocument());
+    expect(screen.queryByText(/Update the sidecar image/)).not.toBeInTheDocument();
+  });
+
+  it('distinguishes a sidecar that did not answer', async () => {
+    await renderPanel({}, { presetValuesResolved: false, presetValuesReason: 'sidecar_unavailable' });
+    await waitFor(() => expect(screen.getByText(/did not answer/)).toBeInTheDocument());
+    expect(screen.queryByText(/Update the sidecar image/)).not.toBeInTheDocument();
+  });
+
+  it('shows no such notice when they resolved', async () => {
+    await renderPanel({}, { presetValues: { line_width: '0.42' }, presetValuesResolved: true });
+    await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
+    expect(screen.queryByText(/Showing slicer defaults/)).not.toBeInTheDocument();
+  });
+});

+ 1 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -484,6 +484,7 @@ export const handlers = [
   http.get('/api/v1/spoolman/spools/linked', () => HttpResponse.json([])),
   http.get('/api/v1/spoolman/spools/unlinked', () => HttpResponse.json([])),
   http.get('/api/v1/users/', () => HttpResponse.json([])),
+  http.get('/api/v1/users/slim', () => HttpResponse.json([])),
 
   // Status / object endpoints → minimal disabled-state responses
   http.get('/api/v1/archives/purge/settings', () =>

+ 100 - 81
frontend/src/__tests__/pages/GCodeViewerPage.test.tsx

@@ -1,120 +1,139 @@
 /**
- * The G-code viewer's frame, when something refuses to let it be embedded (#2787).
+ * The full-page G-code preview.
  *
- * Sliced files preview through a full-page route whose body is an iframe of
- * /gcode-viewer/; STL and source 3MF use an in-page three.js modal instead. So a
- * proxy that injects a framing header breaks exactly one of the two previews,
- * and all the user sees is the browser's own "refused to connect" page inside
- * our layout shell — no clue what happened, and no hint that the viewer works
- * perfectly well in a tab of its own.
+ * This used to be an iframe onto a vendored copy of PrettyGCode, and most of
+ * the page was machinery for detecting when a proxy refused the embed. It now
+ * renders Bambuddy's own toolpath viewer directly, so what is worth testing is
+ * that the right file reaches it -- including the plate, which a multi-plate
+ * archive needs or the viewer silently shows a different plate than the one
+ * that was picked.
  */
 
-import { describe, it, expect } from 'vitest';
-import { screen, waitFor, within } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
-import { GCodeViewerPage } from '../../pages/GCodeViewerPage';
-import { findFramingRefusal } from '../../utils/framing';
 import { server } from '../mocks/server';
+import { GCodeViewerPage } from '../../pages/GCodeViewerPage';
 
-const ORIGIN = 'https://printers.example.com';
-const OURS = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'self';";
-
-function serveViewer(status: number, headers: Record<string, string> = {}) {
-  server.use(http.get('/gcode-viewer/', () => new HttpResponse(null, { status, headers })));
+// The viewer itself needs WebGL, which jsdom has no answer for. Its own
+// behaviour is covered by the parser and renderer tests; here we only care
+// which URL the page hands it.
+vi.mock('../../components/GcodeToolpathViewer', () => ({
+  GcodeToolpathViewer: ({ gcodeUrl, filamentColors }: { gcodeUrl: string; filamentColors?: string[] }) => (
+    <div data-testid="toolpath-viewer" data-url={gcodeUrl} data-colors={(filamentColors ?? []).join(',')} />
+  ),
+}));
+
+function visit(search: string) {
+  window.history.pushState({}, '', `/gcode-viewer${search}`);
+  render(<GCodeViewerPage />);
 }
 
-describe('findFramingRefusal', () => {
-  it('accepts the headers Bambuddy itself sends', () => {
-    expect(findFramingRefusal('SAMEORIGIN', OURS, ORIGIN)).toBeNull();
-  });
+const viewerUrl = () => screen.getByTestId('toolpath-viewer').getAttribute('data-url');
 
-  it('accepts an origin named explicitly instead of self', () => {
-    const csp = `frame-ancestors ${ORIGIN};`;
-    expect(findFramingRefusal(null, csp, ORIGIN)).toBeNull();
-  });
+describe('GCodeViewerPage', () => {
+  const originalUrl = window.location.href;
 
-  it('reports a proxy-added policy that intersects ours down to none', () => {
-    // Two Content-Security-Policy headers arrive as one comma-joined string.
-    // Both apply, so ours permitting us is not enough.
-    const refusal = findFramingRefusal('SAMEORIGIN', `${OURS}, frame-ancestors 'none'`, ORIGIN);
-    expect(refusal).toBe("Content-Security-Policy: frame-ancestors 'none'");
+  beforeEach(() => {
+    window.history.pushState({}, '', '/gcode-viewer');
   });
 
-  it('reports frame-ancestors listing only somebody else', () => {
-    const refusal = findFramingRefusal(null, "frame-ancestors https://ha.example.com;", ORIGIN);
-    expect(refusal).toContain('ha.example.com');
+  afterEach(() => {
+    window.history.pushState({}, '', originalUrl);
   });
 
-  it('reports X-Frame-Options DENY when no frame-ancestors is present', () => {
-    expect(findFramingRefusal('DENY', null, ORIGIN)).toBe('X-Frame-Options: DENY');
+  it('previews an archive', () => {
+    visit('?archive=82');
+    expect(viewerUrl()).toContain('/archives/82/gcode');
   });
 
-  it('reports a second X-Frame-Options appended to ours', () => {
-    expect(findFramingRefusal('SAMEORIGIN, DENY', null, ORIGIN)).toBe(
-      'X-Frame-Options: SAMEORIGIN, DENY',
-    );
+  it('previews a library file', () => {
+    visit('?library_file=7');
+    expect(viewerUrl()).toContain('/library/files/7/gcode');
   });
 
-  it('ignores X-Frame-Options when frame-ancestors permits us, as browsers do', () => {
-    // CSP supersedes the legacy header outright — flagging this would blame a
-    // header the browser never consulted.
-    expect(findFramingRefusal('DENY', OURS, ORIGIN)).toBeNull();
+  it('carries the plate through for a multi-plate source', () => {
+    // Dropping this shows whichever plate the backend defaults to rather than
+    // the one the user picked, with nothing on screen to say so.
+    visit('?archive=82&plate=3');
+    expect(viewerUrl()).toContain('plate=3');
   });
 
-  it('accepts a response carrying no framing headers at all', () => {
-    expect(findFramingRefusal(null, null, ORIGIN)).toBeNull();
+  it('omits the plate parameter when there is no plate', () => {
+    visit('?archive=82');
+    expect(viewerUrl()).not.toContain('plate=');
   });
-});
-
-describe('GCodeViewerPage', () => {
-  it('embeds the viewer when nothing refuses the frame', async () => {
-    serveViewer(200, { 'X-Frame-Options': 'SAMEORIGIN', 'Content-Security-Policy': OURS });
 
-    render(<GCodeViewerPage />);
-
-    expect(await screen.findByTitle('GCode Viewer')).toBeInTheDocument();
-    // Give the probe a chance to land and prove it changes nothing.
-    await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
-    expect(screen.getByTitle('GCode Viewer')).toBeInTheDocument();
+  it('says so when no file was given rather than rendering an empty viewer', () => {
+    visit('');
+    expect(screen.queryByTestId('toolpath-viewer')).not.toBeInTheDocument();
+    expect(screen.getByText(/No file was given/i)).toBeInTheDocument();
   });
 
-  it('explains a refused frame and offers the viewer in its own tab', async () => {
-    serveViewer(200, { 'Content-Security-Policy': "frame-ancestors 'none';" });
-
-    render(<GCodeViewerPage />);
+  it('offers a way back to where the file came from', () => {
+    visit('?archive=82');
+    expect(screen.getByRole('button', { name: /Back to Print Archives/i })).toBeInTheDocument();
+  });
 
-    const panel = await screen.findByRole('alert');
-    expect(panel).toHaveTextContent(/could not be embedded/i);
-    // Name the header so the operator can go and find it in their proxy.
-    expect(panel).toHaveTextContent(/frame-ancestors 'none'/);
-    // A top-level navigation is not subject to frame-ancestors, so this works.
-    const link = within(panel).getByRole('link', { name: /new tab/i });
-    expect(link).toHaveAttribute('href', '/gcode-viewer/');
-    expect(link).toHaveAttribute('target', '_blank');
-    expect(screen.queryByTitle('GCode Viewer')).not.toBeInTheDocument();
+  it('names the file manager when the source was a library file', () => {
+    visit('?library_file=7');
+    expect(screen.getByRole('button', { name: /Back to File Manager/i })).toBeInTheDocument();
   });
+});
 
-  it('reports missing viewer assets rather than showing raw JSON', async () => {
-    serveViewer(404);
+describe('GCodeViewerPage — filament colours', () => {
+  const originalUrl = window.location.href;
+  afterEach(() => window.history.pushState({}, '', originalUrl));
+
+  it('indexes library-file colours by tool number, not slot number', async () => {
+    // slot_id is 1-based and the G-code's T numbers are 0-based; indexing
+    // straight by slot puts every colour one filament out, so a two-material
+    // print comes out with the wrong body colour.
+    server.use(
+      http.get('/api/v1/library/files/:id/plates', () =>
+        HttpResponse.json({
+          file_id: 7,
+          filename: 'duck.gcode.3mf',
+          is_multi_plate: false,
+          plates: [
+            {
+              index: 1,
+              name: null,
+              objects: [],
+              has_thumbnail: false,
+              thumbnail_url: null,
+              print_time_seconds: null,
+              filament_used_grams: null,
+              filaments: [
+                { slot_id: 1, type: 'PLA', color: '#ffffff', used_grams: 1, used_meters: 1 },
+                { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 1, used_meters: 1 },
+              ],
+            },
+          ],
+        }),
+      ),
+    );
 
+    window.history.pushState({}, '', '/gcode-viewer?library_file=7');
     render(<GCodeViewerPage />);
 
-    const panel = await screen.findByRole('alert');
-    expect(panel).toHaveTextContent(/unavailable/i);
-    expect(panel).toHaveTextContent(/HTTP 404/);
-    expect(screen.queryByTitle('GCode Viewer')).not.toBeInTheDocument();
+    await waitFor(() =>
+      expect(screen.getByTestId('toolpath-viewer')).toHaveAttribute('data-colors', '#ffffff,#000000'),
+    );
   });
 
-  it('keeps the frame when the probe itself fails', async () => {
-    // No evidence either way — the browser's own error page is better than a
-    // guess at a cause we cannot see.
-    server.use(http.get('/gcode-viewer/', () => HttpResponse.error()));
+  it('previews without colours when the plate metadata carries none', async () => {
+    server.use(
+      http.get('/api/v1/library/files/:id/plates', () =>
+        HttpResponse.json({ file_id: 7, filename: 'x', is_multi_plate: false, plates: [] }),
+      ),
+    );
 
+    window.history.pushState({}, '', '/gcode-viewer?library_file=7');
     render(<GCodeViewerPage />);
 
-    expect(await screen.findByTitle('GCode Viewer')).toBeInTheDocument();
-    await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
-    expect(screen.getByTitle('GCode Viewer')).toBeInTheDocument();
+    // The preview must still render; colours are a bonus, not a prerequisite.
+    expect(screen.getByTestId('toolpath-viewer')).toBeInTheDocument();
   });
 });

+ 73 - 0
frontend/src/__tests__/pages/StatsPageUserFilter1894.test.tsx

@@ -0,0 +1,73 @@
+/**
+ * The Stats filter-by-user dropdown sources names from the slim listing (#1894).
+ *
+ * `stats:filter_by_user` is a permission an operator can be granted on its own,
+ * but the dropdown used to be populated from the admin-level `users:read`
+ * listing. An operator who had been granted the filter therefore saw an empty
+ * control -- the filter renders only when the user list is non-empty -- and had
+ * no way to tell whether that meant "no users" or "not allowed to look".
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { StatsPage } from '../../pages/StatsPage';
+import { setAuthToken } from '../../api/client';
+
+function signInAs(permissions: string[]) {
+  setAuthToken('test-token', 'session');
+  server.use(
+    http.get('*/api/v1/auth/status', () =>
+      HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+    ),
+    http.get('*/api/v1/auth/me', () =>
+      HttpResponse.json({ id: 1, username: 'operator', is_admin: false, permissions }),
+    ),
+  );
+}
+
+afterEach(() => {
+  setAuthToken(null);
+});
+
+describe('stats filter-by-user (#1894)', () => {
+  it('populates from /users/slim without the admin-level users:read', async () => {
+    signInAs(['stats:read', 'stats:filter_by_user']);
+    server.use(
+      // The admin listing is exactly what such an operator cannot call.
+      http.get('*/api/v1/users/', () => new HttpResponse(null, { status: 403 })),
+      http.get('*/api/v1/users/slim', () =>
+        HttpResponse.json([
+          { id: 1, username: 'operator' },
+          { id: 2, username: 'colleague' },
+        ]),
+      ),
+    );
+
+    render(<StatsPage />);
+
+    // The control only renders once names have arrived, so its presence is
+    // the assertion -- an empty list leaves it out of the tree entirely.
+    await waitFor(() => {
+      expect(screen.getByText('All Users')).toBeInTheDocument();
+    });
+  });
+
+  it('stays hidden when the user has no filter permission', async () => {
+    signInAs(['stats:read']);
+    server.use(
+      http.get('*/api/v1/users/slim', () =>
+        HttpResponse.json([{ id: 1, username: 'operator' }]),
+      ),
+    );
+
+    render(<StatsPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Quick Stats')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('All Users')).not.toBeInTheDocument();
+  });
+});

+ 466 - 0
frontend/src/__tests__/utils/gcodeToolpath.test.ts

@@ -0,0 +1,466 @@
+import { describe, it, expect } from 'vitest';
+import * as THREE from 'three';
+
+import { parseGcodeToolpath, layersByFilament, filterLayersByType, ToolpathType } from '../../lib/gcodeToolpath';
+// @ts-expect-error -- vendored build output; typed by its sibling .d.ts, which
+// vitest's resolver does not pick up for a bare .js import.
+import { buildSegmentData, makeToolpath, TYPE_COLOR } from '../../lib/vendor/toolpathRenderer.js';
+
+/**
+ * A minimal but realistic slice: two layers, an outer wall square and some
+ * infill on each, with a travel between them. Written the way Bambu and Orca
+ * actually emit -- relative extrusion, `;TYPE:` before each run, `;WIDTH:`,
+ * and a bare Z move for the layer change.
+ */
+const GCODE = `
+;TYPE:Custom
+M83
+G1 Z0.2 F600
+;TYPE:Outer wall
+;WIDTH:0.42
+G1 X0 Y0 F1200
+G1 X10 Y0 E0.5
+G1 X10 Y10 E0.5
+G1 X0 Y10 E0.5
+G1 X0 Y0 E0.5
+;TYPE:Sparse infill
+G1 X2 Y2 F9000
+G1 X8 Y8 E0.3
+G1 Z0.4 F600
+;TYPE:Outer wall
+G1 X0 Y0 F1200
+G1 X10 Y0 E0.5
+G1 X10 Y10 E0.5
+;TYPE:Support
+G1 X20 Y20 E0.4
+`;
+
+describe('parseGcodeToolpath', () => {
+  const parsed = parseGcodeToolpath(GCODE);
+
+  it('splits the file into layers in print order', () => {
+    expect(parsed.layers.length).toBe(2);
+    expect(parsed.layers[0].z).toBeCloseTo(0.2);
+    expect(parsed.layers[1].z).toBeCloseTo(0.4);
+  });
+
+  it('counts extrusions and travels separately', () => {
+    // 4 wall + 1 infill on layer 1; 2 wall + 1 support on layer 2.
+    expect(parsed.segmentCount).toBe(8);
+    // The two repositioning moves before a run, plus the initial Z lift.
+    expect(parsed.travelCount).toBeGreaterThan(0);
+  });
+
+  it('classifies features from the ;TYPE: annotations', () => {
+    const types = (layer: number) => {
+      const p = parsed.layers[layer].paths;
+      const out: number[] = [];
+      for (let i = 0; i < p.length; i += 8) out.push(p[i + 3]);
+      return out;
+    };
+    expect(types(0)).toContain(ToolpathType.wall);
+    expect(types(0)).toContain(ToolpathType.sparseInfill);
+    expect(types(1)).toContain(ToolpathType.support);
+  });
+
+  it('marks non-extruding moves as travel', () => {
+    const p = parsed.layers[0].paths;
+    const travels: number[] = [];
+    for (let i = 0; i < p.length; i += 8) {
+      if (p[i + 3] === ToolpathType.travel) travels.push(i);
+    }
+    expect(travels.length).toBeGreaterThan(0);
+  });
+
+  it('reads the extrusion width out of the file rather than assuming one', () => {
+    expect(parsed.defaultWidth).toBeCloseTo(0.42);
+  });
+
+  it('handles absolute extrusion as well as relative', () => {
+    // Without M83, E values are cumulative; treating them as relative would
+    // make every move after the first look like a huge extrusion, and a
+    // retraction would read as an extrusion rather than a travel.
+    const absolute = parseGcodeToolpath(`
+;TYPE:Outer wall
+M82
+G1 X0 Y0 Z0.2
+G1 X10 Y0 E1.0
+G1 X10 Y10 E2.0
+G1 X0 Y10 E1.5
+`);
+    // Two extrusions (E rising), then one retraction (E falling) as a travel.
+    expect(absolute.segmentCount).toBe(2);
+    expect(absolute.travelCount).toBeGreaterThan(0);
+  });
+
+  it('keeps geometry for an unrecognised feature name', () => {
+    // A type we do not know must never drop the move -- a hole in the preview
+    // is far worse than a wrongly coloured segment.
+    const unknown = parseGcodeToolpath(`
+M83
+;TYPE:Some Future Feature
+G1 X0 Y0 Z0.2
+G1 X5 Y5 E0.4
+`);
+    expect(unknown.segmentCount).toBe(1);
+  });
+
+  it('reports the model bounds', () => {
+    expect(parsed.bounds).not.toBeNull();
+    expect(parsed.bounds!.max[0]).toBeCloseTo(20);
+    expect(parsed.bounds!.max[1]).toBeCloseTo(20);
+  });
+
+  it('returns nothing rather than throwing on a file with no moves', () => {
+    const empty = parseGcodeToolpath('; just a comment\nM104 S200\n');
+    expect(empty.layers).toEqual([]);
+    expect(empty.bounds).toBeNull();
+  });
+});
+
+describe('the vendored libvgcode renderer accepts our parse', () => {
+  const parsed = parseGcodeToolpath(GCODE);
+
+  it('builds segment data from the parsed layers', () => {
+    const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
+    expect(data.nSeg).toBe(parsed.segmentCount);
+    expect(data.layerCount).toBe(2);
+    expect(data.hasNaN).toBe(false);
+    expect(data.bbox).not.toBeNull();
+  });
+
+  it('carries travel moves through as a separate stream', () => {
+    const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
+    expect(data.nTrav).toBe(parsed.travelCount);
+  });
+
+  it('keeps per-vertex feature, width and layer metadata', () => {
+    const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
+    expect(data.meta.vType.length).toBe(data.nV);
+    expect(Array.from(data.meta.vType)).toContain(ToolpathType.wall);
+    expect(Array.from(data.meta.vLayer)).toContain(1);
+    // Width came from ;WIDTH:, not the fallback.
+    expect(Array.from(data.meta.vWidth).some((w) => Math.abs(w - 0.42) < 1e-6)).toBe(true);
+  });
+
+  it('builds a three.js mesh on our own three version', () => {
+    // The whole reason this renderer is usable: it imports no three and takes
+    // the namespace as an argument, so it runs on our 0.181 rather than the
+    // 0.160 its own package pins.
+    const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
+    const handle = makeToolpath(THREE, data);
+
+    expect(handle.mesh).toBeInstanceOf(THREE.Mesh);
+    expect(handle.nSeg).toBe(parsed.segmentCount);
+    expect(handle.layerCount).toBe(2);
+
+    const geometry = handle.mesh.geometry as THREE.BufferGeometry;
+    // The drawn primitive is libvgcode's diamond cross-section: 8 triangles,
+    // 24 indices, instanced once per segment. That single indexed draw is what
+    // keeps a million-segment print to one call.
+    expect(geometry.index?.count).toBe(24);
+    expect(geometry.attributes.seg_id_a_u.count).toBe(parsed.segmentCount);
+    expect(geometry.attributes.seg_layer_u.count).toBe(parsed.segmentCount);
+
+    handle.dispose();
+  });
+
+  it('exposes layer-range and travel controls', () => {
+    const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
+    const handle = makeToolpath(THREE, data);
+
+    expect(() => handle.setLayerRange(0, 0)).not.toThrow();
+    expect(() => handle.setTravelVisible(true)).not.toThrow();
+    expect(handle.travLines.visible).toBe(true);
+    expect(() => handle.setTravelVisible(false)).not.toThrow();
+    expect(handle.travLines.visible).toBe(false);
+
+    handle.dispose();
+  });
+
+  it('ships the libvgcode feature palette', () => {
+    // Sanity that the vendored module is the real thing and not a stub.
+    expect(TYPE_COLOR[ToolpathType.wall]).toHaveLength(3);
+    expect(TYPE_COLOR[ToolpathType.support]).toHaveLength(3);
+  });
+});
+
+/**
+ * BambuStudio's dialect. It does not emit any of the annotations the
+ * OrcaSlicer/PrusaSlicer lineage uses -- no `;TYPE:`, no `;WIDTH:`, no
+ * `;LAYER_CHANGE` -- and reading only those rendered a real Bambu file as one
+ * undifferentiated colour with a layer per travel Z-hop (52 layers came out as
+ * 23,165). Taken from an actual sliced plate.
+ */
+const BAMBU_GCODE = `
+M83
+; CHANGE_LAYER
+; Z_HEIGHT: 0.2
+; LINE_WIDTH: 0.42
+; FEATURE: Outer wall
+G1 X10 Y10 Z0.2 F600
+G1 X20 Y10 E0.5
+G1 X20 Y20 E0.5
+; FEATURE: Sparse infill
+G1 X12 Y12 F9000
+G1 X18 Y18 E0.3
+; a travel Z-hop, which must not start a layer
+G1 Z0.6 F600
+G1 X30 Y30 F9000
+G1 Z0.2 F600
+; FEATURE: Support
+G1 X31 Y31 E0.2
+; CHANGE_LAYER
+; Z_HEIGHT: 0.36
+; FEATURE: Outer wall
+G1 X10 Y10 Z0.36 F600
+G1 X20 Y10 E0.5
+`;
+
+describe('parseGcodeToolpath — BambuStudio dialect', () => {
+  const parsed = parseGcodeToolpath(BAMBU_GCODE);
+
+  it('reads features from "; FEATURE:" rather than ";TYPE:"', () => {
+    const allTypes = parsed.layers.flatMap((layer) => {
+      const out: number[] = [];
+      for (let i = 0; i < layer.paths.length; i += 8) out.push(layer.paths[i + 3]);
+      return out;
+    });
+    expect(allTypes).toContain(ToolpathType.sparseInfill);
+    expect(allTypes).toContain(ToolpathType.support);
+    // Everything falling back to `wall` is the signature of the dialect bug.
+    expect(new Set(allTypes).size).toBeGreaterThan(2);
+  });
+
+  it('uses the explicit layer markers', () => {
+    expect(parsed.layers.length).toBe(2);
+    expect(parsed.layers[1].z).toBeCloseTo(0.36);
+  });
+
+  it('does not start a layer on a travel Z-hop', () => {
+    // The hop to Z0.6 and back sits inside layer one; splitting there is what
+    // multiplied the layer count by four hundred.
+    const firstLayerTypes: number[] = [];
+    const p = parsed.layers[0].paths;
+    for (let i = 0; i < p.length; i += 8) firstLayerTypes.push(p[i + 3]);
+    expect(firstLayerTypes).toContain(ToolpathType.support);
+  });
+
+  it('reads the width from "; LINE_WIDTH:"', () => {
+    expect(parsed.defaultWidth).toBeCloseTo(0.42);
+  });
+
+  it('takes the typical width, not the widest', () => {
+    // Widths in a real file span a 0.09 gap fill to a 1.0 purge line; the max
+    // made every fallback segment absurdly fat.
+    const mixed = parseGcodeToolpath(`
+M83
+; FEATURE: Outer wall
+; LINE_WIDTH: 0.42
+G1 X0 Y0 Z0.2
+G1 X10 Y0 E0.5
+G1 X10 Y10 E0.5
+; LINE_WIDTH: 1.0
+G1 X0 Y10 E0.5
+`);
+    expect(mixed.defaultWidth).toBeCloseTo(0.42);
+  });
+
+  it('does not draw a phantom segment from the origin', () => {
+    // Position is unknown until the first move sets it; extruding from (0,0,0)
+    // drew a stray line across the bed.
+    const p = parsed.layers[0].paths;
+    let touchesOrigin = false;
+    for (let i = 0; i < p.length; i += 8) {
+      if (p[i] === 0 && p[i + 1] === 0 && p[i + 3] !== ToolpathType.travel) touchesOrigin = true;
+    }
+    expect(touchesOrigin).toBe(false);
+  });
+});
+
+describe('arc moves and filament tracking', () => {
+  // BambuStudio has arc fitting on by default. A real plate carried 706
+  // extruding G2/G3 moves against ~7800 linear ones, and dropping them left
+  // holes through curved walls and tree supports -- the "huge gaps in the
+  // support structure" this was reported as.
+  const ARC_GCODE = `
+M83
+; FEATURE: Outer wall
+; LINE_WIDTH: 0.42
+G1 X10 Y0 Z0.2 F600
+G3 X0 Y10 I-10 J0 E1.0
+`;
+
+  it('interpolates an arc into chords rather than dropping it', () => {
+    const parsed = parseGcodeToolpath(ARC_GCODE);
+    // A quarter circle of radius 10 at a 0.02mm chord tolerance is many
+    // segments; the point is that it is neither 0 nor 1.
+    expect(parsed.segmentCount).toBeGreaterThan(5);
+  });
+
+  it('keeps every interpolated chord on the arc', () => {
+    const parsed = parseGcodeToolpath(ARC_GCODE);
+    const centre = { x: 0, y: 0 };
+    for (const layer of parsed.layers) {
+      for (let i = 0; i < layer.paths.length; i += 8) {
+        if (layer.paths[i + 3] === ToolpathType.travel) continue;
+        const r = Math.hypot(layer.paths[i + 4] - centre.x, layer.paths[i + 5] - centre.y);
+        // Every endpoint sits on the radius, within the chord tolerance.
+        expect(Math.abs(r - 10)).toBeLessThan(0.1);
+      }
+    }
+  });
+
+  it('treats an arc with no X or Y as the helical travel lift it is', () => {
+    // "G3 Z0.4 I1.2 J0 P1" is BambuStudio lifting the nozzle in a spiral. It
+    // extrudes nothing and must not be mistaken for geometry.
+    const parsed = parseGcodeToolpath(`
+M83
+; FEATURE: Outer wall
+G1 X10 Y10 Z0.2 F600
+G1 X20 Y10 E0.5
+G3 Z0.6 I1.217 J0 P1 F60000
+`);
+    expect(parsed.segmentCount).toBe(1);
+    expect(parsed.travelCount).toBeGreaterThan(1);
+  });
+
+  it('does not mistake G20 or G28 for an arc', () => {
+    const parsed = parseGcodeToolpath(`
+M83
+G21
+G28
+; FEATURE: Outer wall
+G1 X10 Y10 Z0.2
+G1 X20 Y10 E0.5
+`);
+    expect(parsed.segmentCount).toBe(1);
+  });
+
+  it('tracks the active filament across tool changes', () => {
+    const parsed = parseGcodeToolpath(`
+M83
+; FEATURE: Outer wall
+T0
+G1 X10 Y10 Z0.2 F600
+G1 X20 Y10 E0.5
+T1
+G1 X20 Y20 E0.5
+`);
+    const tools: number[] = [];
+    for (const layer of parsed.layers) {
+      for (let i = 0; i < layer.paths.length; i += 8) {
+        if (layer.paths[i + 3] !== ToolpathType.travel) tools.push(layer.paths[i + 7]);
+      }
+    }
+    expect(tools).toContain(0);
+    expect(tools).toContain(1);
+  });
+
+  it("ignores BambuStudio's sentinel tool numbers", () => {
+    // T65535 / T65279 bracket the slicer's own bookkeeping and are not
+    // filaments; treating them as such would key colours off a nonsense slot.
+    const parsed = parseGcodeToolpath(`
+M83
+; FEATURE: Outer wall
+T0
+G1 X10 Y10 Z0.2 F600
+T65535
+G1 X20 Y10 E0.5
+`);
+    for (const layer of parsed.layers) {
+      for (let i = 0; i < layer.paths.length; i += 8) {
+        expect(layer.paths[i + 7]).toBeLessThanOrEqual(15);
+      }
+    }
+  });
+
+  it('re-keys types to filaments for the filament-coloured view', () => {
+    const parsed = parseGcodeToolpath(`
+M83
+; FEATURE: Support
+T1
+G1 X10 Y10 Z0.2 F600
+G1 X20 Y10 E0.5
+`);
+    const recoloured = layersByFilament(parsed.layers);
+    const typeOf = (layers: typeof parsed.layers) => {
+      for (const layer of layers) {
+        for (let i = 0; i < layer.paths.length; i += 8) {
+          if (layer.paths[i + 3] !== ToolpathType.travel) return layer.paths[i + 3];
+        }
+      }
+      return -1;
+    };
+    expect(typeOf(parsed.layers)).toBe(ToolpathType.support);
+    // Filament 1 becomes type 2 -- offset by one so slot 0 cannot collide
+    // with the travel index.
+    expect(typeOf(recoloured)).toBe(2);
+    // The original must be untouched: both colourings are held at once.
+    expect(typeOf(parsed.layers)).toBe(ToolpathType.support);
+  });
+});
+
+describe('hiding a feature or filament', () => {
+  const GCODE = `
+M83
+; LINE_WIDTH: 0.42
+; FEATURE: Outer wall
+T0
+G1 X10 Y10 Z0.2 F600
+G1 X20 Y10 E0.5
+; FEATURE: Support
+T1
+G1 X30 Y10 E0.5
+G1 X40 Y10 E0.5
+`;
+
+  const typesOf = (layers: ReturnType<typeof parseGcodeToolpath>['layers']) => {
+    const out: number[] = [];
+    for (const layer of layers) {
+      for (let i = 0; i < layer.paths.length; i += 8) out.push(layer.paths[i + 3]);
+    }
+    return out;
+  };
+
+  it('removes the hidden feature and keeps the rest', () => {
+    const parsed = parseGcodeToolpath(GCODE);
+    const filtered = filterLayersByType(parsed.layers, new Set([ToolpathType.support]));
+    expect(typesOf(filtered)).not.toContain(ToolpathType.support);
+    expect(typesOf(filtered)).toContain(ToolpathType.wall);
+  });
+
+  it('keeps emptied layers so the range slider does not renumber', () => {
+    // Dropping a layer that the filter emptied would shift every layer above
+    // it, and the slider would then point at the wrong height.
+    const parsed = parseGcodeToolpath(GCODE);
+    const everything = new Set(typesOf(parsed.layers));
+    const filtered = filterLayersByType(parsed.layers, everything);
+    expect(filtered.length).toBe(parsed.layers.length);
+    expect(typesOf(filtered)).toEqual([]);
+  });
+
+  it('keeps each kept record paired with its own width', () => {
+    // The widths array is indexed in step with the records; dropping one
+    // without dropping its width would smear widths across the rest.
+    const parsed = parseGcodeToolpath(GCODE);
+    const filtered = filterLayersByType(parsed.layers, new Set([ToolpathType.travel]));
+    for (const layer of filtered) {
+      expect(layer.widths.length).toBe(layer.paths.length / 8);
+    }
+  });
+
+  it('hides by filament once the types are re-keyed', () => {
+    const parsed = parseGcodeToolpath(GCODE);
+    const byFilament = layersByFilament(parsed.layers);
+    // Filament 1 is keyed as type 2.
+    const filtered = filterLayersByType(byFilament, new Set([2]));
+    expect(typesOf(filtered)).not.toContain(2);
+    expect(typesOf(filtered)).toContain(1);
+  });
+
+  it('returns the input untouched when nothing is hidden', () => {
+    const parsed = parseGcodeToolpath(GCODE);
+    expect(filterLayersByType(parsed.layers, new Set())).toBe(parsed.layers);
+  });
+});

+ 58 - 0
frontend/src/__tests__/utils/slicerPrinterMatch.test.ts

@@ -485,3 +485,61 @@ describe('presetCompatibility — nozzle-only @<size> tag (#2628 follow-up)', ()
     ).toBe('mismatch');
   });
 });
+
+describe("presetCompatibility — BambuStudio's \"# \" user-clone prefix", () => {
+  const index = buildCompatibilityIndex(PRINTER_MODELS);
+  // Editing a system preset saves a copy under this name; .bbscfg bundle
+  // exports use the same convention. The backend already normalises it in
+  // _canonical_printer_model.
+  const CLONED_X1C = '# Bambu Lab X1 Carbon 0.4 nozzle';
+
+  it('still matches a printer-tagged preset when the printer is a clone', () => {
+    // Regression: the prefix failed the "Bambu Lab …" test, so every preset
+    // came back 'unknown' and the dropdown filter silently did nothing.
+    expect(
+      presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', CLONED_X1C, index),
+    ).toBe('match');
+  });
+
+  it('still rules out another printer when the selected printer is a clone', () => {
+    expect(
+      presetCompatibility({ name: '0.20mm Standard @BBL H2D' }, 'process', CLONED_X1C, index),
+    ).toBe('mismatch');
+  });
+
+  it('matches a cloned preset against an unprefixed printer', () => {
+    expect(
+      presetCompatibility({ name: '# 0.20mm Standard @BBL X1C' }, 'process', X1C, index),
+    ).toBe('match');
+  });
+
+  it('still compares the nozzle size through the prefix', () => {
+    expect(
+      presetCompatibility({ name: '0.20mm Standard @BBL X1C 0.6 nozzle' }, 'process', CLONED_X1C, index),
+    ).toBe('mismatch');
+  });
+
+  it('matches compatible_printers with the prefix on either side', () => {
+    // A preset cloned from a system printer lists the *unprefixed* name; a raw
+    // comparison against the "# " form reads as a mismatch, which now hides
+    // the preset rather than merely demoting it.
+    expect(
+      presetCompatibility({ name: 'My Process', compatible_printers: [X1C] }, 'process', CLONED_X1C, index),
+    ).toBe('match');
+    expect(
+      presetCompatibility({ name: 'My Process', compatible_printers: [CLONED_X1C] }, 'process', X1C, index),
+    ).toBe('match');
+  });
+
+  it('does not let the prefix turn a genuine mismatch into a match', () => {
+    expect(
+      presetCompatibility({ name: 'My Process', compatible_printers: [P2S] }, 'process', CLONED_X1C, index),
+    ).toBe('mismatch');
+  });
+
+  it('leaves an untagged clone unknown rather than guessing', () => {
+    expect(
+      presetCompatibility({ name: '# My own profile' }, 'process', CLONED_X1C, index),
+    ).toBe('unknown');
+  });
+});

+ 53 - 0
frontend/src/__tests__/utils/slicerStepGating.test.ts

@@ -0,0 +1,53 @@
+import { describe, it, expect } from 'vitest';
+import {
+  isApiSliceableFileType,
+  isApiSliceableFilename,
+  isSliceableFileType,
+  isSliceableFilename,
+} from '../../utils/slicer';
+
+/**
+ * STEP splits the two slice paths.
+ *
+ * The desktop slicers open a STEP fine, so "Open in Slicer" must keep offering
+ * it. Their command-line interfaces cannot load one -- OrcaSlicer 2.4.2 and
+ * Bambu Studio 02.07.01.62 both answer "Unknown file format. Input file must
+ * have .stl, .obj, .amf(.xml) extension." -- so the in-app "Slice" button and
+ * the pipeline action, which both post to the sidecar, must not.
+ *
+ * One predicate used to serve both, which is why a STEP got a Slice button
+ * that could only ever fail, several seconds and one upload later.
+ */
+describe('STEP is offered to the desktop slicer but not the sidecar', () => {
+  it.each(['part.step', 'part.stp', 'PART.STEP'])('%s is a desktop handoff', (name) => {
+    expect(isSliceableFilename(name)).toBe(true);
+  });
+
+  it.each(['part.step', 'part.stp', 'PART.STEP'])('%s is not sidecar-sliceable', (name) => {
+    expect(isApiSliceableFilename(name)).toBe(false);
+  });
+
+  it.each(['cube.stl', 'project.3mf'])('%s stays sliceable both ways', (name) => {
+    expect(isSliceableFilename(name)).toBe(true);
+    expect(isApiSliceableFilename(name)).toBe(true);
+  });
+
+  it.each(['out.gcode', 'out.gcode.3mf'])('%s is slicer output, not input', (name) => {
+    expect(isSliceableFilename(name)).toBe(false);
+    expect(isApiSliceableFilename(name)).toBe(false);
+  });
+
+  it('applies the same split to stored file types', () => {
+    expect(isSliceableFileType('step')).toBe(true);
+    expect(isApiSliceableFileType('step')).toBe(false);
+    expect(isApiSliceableFileType('stl')).toBe(true);
+    expect(isApiSliceableFileType('3mf')).toBe(true);
+    expect(isApiSliceableFileType('gcode.3mf')).toBe(false);
+  });
+
+  it('treats a missing type as not sliceable', () => {
+    expect(isApiSliceableFileType(undefined)).toBe(false);
+    expect(isApiSliceableFileType(null)).toBe(false);
+    expect(isApiSliceableFileType('')).toBe(false);
+  });
+});

+ 137 - 0
frontend/src/__tests__/utils/slicerToggle.test.ts

@@ -0,0 +1,137 @@
+import { describe, it, expect } from 'vitest';
+
+import processSchema from '../../data/slicer/process-schema.json';
+import processToggles from '../../data/slicer/process-toggle-rules.json';
+import processTree from '../../data/slicer/process-ui-tree.json';
+import { disabledKeys, makeConfigReader } from '../../lib/slicerToggle';
+import type { ProcessSchema, ProcessUiTree, SettingValue } from '../../types/slicerSettings';
+
+const schema = processSchema as unknown as ProcessSchema;
+const tree = processTree as unknown as ProcessUiTree;
+const toggles = processToggles as { locals: Record<string, string>; rules: Array<{ fields: string[]; enable_if: string }> };
+
+const disabled = (settings: Record<string, SettingValue>) => disabledKeys(settings, schema, toggles);
+
+describe('makeConfigReader', () => {
+  it('falls back to the schema default when the user has set nothing', () => {
+    expect(makeConfigReader({}, schema).get('wall_loops')).toBe(2);
+  });
+
+  it('prefers a user value over the default', () => {
+    expect(makeConfigReader({ wall_loops: 5 }, schema).get('wall_loops')).toBe(5);
+  });
+
+  it('reads the first entry of a per-extruder vector option', () => {
+    // default_acceleration is coFloats with a default of [500].
+    expect(makeConfigReader({}, schema).get('default_acceleration')).toBe(500);
+  });
+
+  it('treats an empty string as unset so a cleared input falls back to the default', () => {
+    expect(makeConfigReader({ wall_loops: '' }, schema).get('wall_loops')).toBe(2);
+  });
+});
+
+describe('disabledKeys', () => {
+  it('disables wall-dependent options when there are no walls', () => {
+    // have_perimeters = config->opt_int("wall_loops") > 0
+    const off = disabled({ wall_loops: 0 });
+    expect(off.has('seam_position')).toBe(true);
+    expect(off.has('detect_thin_wall')).toBe(true);
+  });
+
+  it('leaves wall-dependent options enabled at the default wall count', () => {
+    const off = disabled({});
+    expect(off.has('seam_position')).toBe(false);
+  });
+
+  it('parses a percent value when deciding an infill condition', () => {
+    // have_infill = config->option<ConfigOptionPercent>("sparse_infill_density")->value > 0
+    expect(disabled({ sparse_infill_density: '0%' }).has('sparse_infill_pattern')).toBe(true);
+    expect(disabled({ sparse_infill_density: '15%' }).has('sparse_infill_pattern')).toBe(false);
+  });
+
+  it('resolves a local that is defined in terms of other locals', () => {
+    // have_support_material = config->opt_bool("enable_support") || have_raft,
+    // and have_raft = config->opt_int("raft_layers") > 0.
+    expect(disabled({ enable_support: false, raft_layers: 0 }).has('support_style')).toBe(true);
+    expect(disabled({ enable_support: false, raft_layers: 3 }).has('support_style')).toBe(false);
+    expect(disabled({ enable_support: true, raft_layers: 0 }).has('support_style')).toBe(false);
+  });
+
+  it('matches a C++ enumerator against the option value it serialises to', () => {
+    // has_ironing = config->opt_enum<IroningType>("ironing_type") != IroningType::NoIroning
+    // The enumerator is `NoIroning`; the config value is "no ironing".
+    expect(disabled({ ironing_type: 'no ironing' }).has('ironing_flow')).toBe(true);
+    expect(disabled({ ironing_type: 'top' }).has('ironing_flow')).toBe(false);
+  });
+
+  it('leaves a field enabled when the enumerator matches no declared value', () => {
+    // support_is_organic tests `smsTreeOrganic`, which support_style spells
+    // "organic" — no transliteration reaches that, so the rule must fail open
+    // rather than disable organic-support fields at every setting.
+    const always = disabled({});
+    const flipped = disabled({ support_style: 'organic', enable_support: true });
+    expect(always.has('tree_support_branch_angle_organic')).toBe(false);
+    expect(flipped.has('tree_support_branch_angle_organic')).toBe(false);
+  });
+
+  it('only ever reports keys that exist in the schema', () => {
+    for (const key of disabled({})) expect(schema[key]).toBeDefined();
+  });
+
+  it('decides most of the vendored rules rather than failing open on nearly all', () => {
+    // Guards against a parser regression that silently degrades to "enable
+    // everything" — which would still pass every assertion above. Measured at
+    // 105 of 152 across these two profiles; the rest need settings these
+    // probes don't touch, or reference locals we deliberately cannot resolve.
+    const off = {
+      wall_loops: 0, sparse_infill_density: '0%', enable_support: false, raft_layers: 0,
+      spiral_mode: false, skirt_loops: 0, enable_prime_tower: false,
+      top_shell_layers: 0, bottom_shell_layers: 0, infill_combination: false,
+    } satisfies Record<string, SettingValue>;
+    const a = disabled({});
+    const b = disabled(off);
+    const decided = toggles.rules.filter((rule) => rule.fields.some((f) => a.has(f) || b.has(f)));
+    expect(decided.length).toBeGreaterThanOrEqual(Math.floor(toggles.rules.length * 0.6));
+  });
+});
+
+describe('vendored process schema', () => {
+  // The extractor reads defaults and bounds out of C++ initialisers, so float
+  // literals arrive in source form — `0.`, `0.3f`, `100.%`, and `0.f` split
+  // into [0, "f"]. Rendering those verbatim put a column of "0." in the Line
+  // width group. scripts/generate-slicer-schema.mjs normalises them; this
+  // guards a regeneration that drops that step.
+  const LITERAL_ARTEFACT = /^-?[\d]*\.$|\.%$|^-?[\d.]+f$/;
+
+  const offenders = (field: 'default' | 'min' | 'max') =>
+    Object.entries(schema)
+      .filter(([, opt]) => {
+        const v = opt[field];
+        if (Array.isArray(v)) return v.some((x) => x === 'f');
+        return typeof v === 'string' && LITERAL_ARTEFACT.test(v);
+      })
+      .map(([key]) => `${key}.${field}`);
+
+  it.each(['default', 'min', 'max'] as const)('carries no C++ literal artefacts in %s', (field) => {
+    expect(offenders(field)).toEqual([]);
+  });
+
+  it('renders the line-width defaults as plain numbers', () => {
+    // The reported symptom: every field in this group showed "0."
+    for (const key of ['line_width', 'outer_wall_line_width', 'inner_wall_line_width', 'support_line_width']) {
+      expect(schema[key].default).toBe('0');
+    }
+    expect(schema.bridge_line_width.default).toBe('100%');
+  });
+
+  it('keeps every option the UI tree references', () => {
+    // A trim that drops a referenced key renders a control with no type,
+    // label or default.
+    for (const page of tree) {
+      for (const group of page.groups) {
+        for (const key of group.options) expect(schema[key]).toBeDefined();
+      }
+    }
+  });
+});

+ 64 - 1
frontend/src/api/client.ts

@@ -1298,6 +1298,9 @@ export interface AppSettings {
   // Desktop "Open in Slicer" override (#1329). Null inherits from
   // preferred_slicer so existing installs behave identically.
   open_in_slicer: 'bambu_studio' | 'orcaslicer' | null;
+  // Where slicing runs, independent of which slicer binary the sidecar drives.
+  // Only 'sidecar' is implemented today; see lib/sliceEngines.ts.
+  slice_engine: 'sidecar' | 'browser';
   // Use the slicer-API sidecar for slicing (in-app modal) vs desktop URI scheme
   use_slicer_api: boolean;
   // Per-install sidecar URLs. Empty string falls back to the env defaults.
@@ -1614,6 +1617,30 @@ export interface PresetRef {
   source: PresetSource;
   id: string;
 }
+/**
+ * Why a preset's effective values are unavailable.
+ *
+ * `sidecar_outdated` is the one that matters in practice: an install pulls its
+ * sidecar as `SIDECAR_TAG:-latest` regardless of which Bambuddy channel it is
+ * on, so a user can perfectly well be running a current Bambuddy against a
+ * sidecar that predates this endpoint. That has a one-line fix, and saying so
+ * beats a generic "could not read the values".
+ */
+export type SlicerPresetValuesReason =
+  | 'ok'
+  | 'sidecar_outdated'
+  | 'sidecar_unavailable'
+  | 'not_configured'
+  | 'preset_unresolved';
+
+export interface SlicerPresetValues {
+  /** False when the sidecar could not supply values; `values` is then empty. */
+  resolved: boolean;
+  /** Flattened key -> value map, in the string forms a process preset stores. */
+  values: Record<string, string | string[]>;
+  reason: SlicerPresetValuesReason;
+}
+
 export interface SliceRequest {
   printer_preset_id?: number;
   process_preset_id?: number;
@@ -1639,6 +1666,15 @@ export interface SliceRequest {
   // instead of the picked profile triplet. The preset refs above are still
   // required by the backend validator but go unused on this path.
   use_embedded_settings?: boolean;
+  // Process settings the user edited in the slice modal's settings panel,
+  // already serialised into the string forms a process preset stores ("1" for
+  // a bool, "20%" for a percent, a list for the per-extruder vectors). Patched
+  // onto the resolved process JSON after the designer's carried tweaks, so an
+  // explicit choice here wins. Omitted when the panel is untouched.
+  process_overrides?: Record<string, string | string[]>;
+  // Design settings carried from the source 3MF (#2622) — a list of keys the
+  // file flags as changed from the system preset, not values.
+  design_overrides?: string[];
   // Layout passes the slicer runs before slicing (#2548), both off by
   // default because they move or rotate the objects the user laid out.
   // Unlike the fields above these are CLI actions rather than profile
@@ -3750,7 +3786,7 @@ export type Permission =
   | 'cloud:auth' | 'orca_cloud:auth'
   | 'makerworld:view' | 'makerworld:import'
   | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
-  | 'users:read' | 'users:create' | 'users:update' | 'users:delete'
+  | 'users:read' | 'users:read_slim' | 'users:create' | 'users:update' | 'users:delete'
   | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
   | 'pipelines:read' | 'pipelines:write' | 'pipelines:run'
   | 'websocket:connect';
@@ -3840,6 +3876,17 @@ export interface UserResponse {
   created_at: string;
 }
 
+/**
+ * Just enough to label an owner id (#1894). Backed by GET /users/slim, which
+ * is readable with `users:read_slim` as well as the admin-level `users:read`
+ * -- use it anywhere a screen only needs to turn a `created_by_id` into a
+ * name, so operators are not forced into the full listing to get one.
+ */
+export interface UserSlim {
+  id: number;
+  username: string;
+}
+
 export interface UserCreate {
   username: string;
   password?: string;  // Optional when advanced auth is enabled
@@ -4231,6 +4278,7 @@ export const api = {
 
   // Users
   getUsers: () => request<UserResponse[]>('/users/'),
+  getUsersSlim: () => request<UserSlim[]>('/users/slim'),
   getUser: (id: number) => request<UserResponse>(`/users/${id}`),
   createUser: (data: UserCreate) =>
     request<UserResponse>('/users/', {
@@ -7251,6 +7299,21 @@ export const api = {
   getSlicerPrinterModels: () =>
     request<Record<string, string>>('/slicer/printer-models'),
 
+  /**
+   * Effective values of a process preset, with its `inherits:` chain flattened
+   * by the slicer sidecar. Powers the slice modal's settings panel, which would
+   * otherwise show the option schema's compiled-in defaults (a preset setting a
+   * 0.42mm line width appears as the C++ default of 0).
+   *
+   * `resolved: false` means the values could not be obtained -- sidecar offline,
+   * too old for the endpoint, or slicing not configured -- and the caller should
+   * fall back to schema defaults rather than treat it as a failure.
+   */
+  getSlicerPresetValues: (ref: PresetRef) =>
+    request<SlicerPresetValues>(
+      `/slicer/preset-values?source=${encodeURIComponent(ref.source)}&id=${encodeURIComponent(ref.id)}`,
+    ),
+
   // Local Presets (OrcaSlicer imports)
   getLocalPresets: () =>
     request<LocalPresetsResponse>('/local-presets/'),

+ 2 - 2
frontend/src/components/FileManagerModal.tsx

@@ -27,7 +27,7 @@ import { parseUTCDate } from '../utils/date';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { ModelViewer } from './ModelViewer';
-import { GcodeViewer } from './GcodeViewer';
+import { GcodeToolpathViewer } from './GcodeToolpathViewer';
 import type { PlateMetadata } from '../types/plates';
 import { useToast } from '../contexts/ToastContext';
 import { formatFileSize } from '../utils/file';
@@ -222,7 +222,7 @@ function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: Prin
               </div>
             </div>
           ) : activeTab === 'gcode' && hasGcode ? (
-            <GcodeViewer
+            <GcodeToolpathViewer
               gcodeUrl={api.getPrinterFileGcodeUrl(printerId, filePath)}
               className="w-full h-full"
             />

+ 592 - 0
frontend/src/components/GcodeToolpathViewer.tsx

@@ -0,0 +1,592 @@
+/**
+ * G-code preview drawn the way the desktop slicer draws it.
+ *
+ * The renderer under this is OrcaSlicer's own `libvgcode`, vendored via
+ * `three-slicer` (see `src/lib/vendor/toolpathRenderer.js`): each extrusion is a
+ * diamond-section prism instanced once per segment, so a whole print is a
+ * single indexed draw call and the toolpath occludes itself. The previous
+ * viewer drew screen-space lines, which have no thickness in the scene and
+ * therefore cannot hide the layer behind them -- the reason a sliced model
+ * came out stringy and shimmering.
+ *
+ * The other half of the difference is colour. This colours by *feature* --
+ * wall, infill, support, bridge -- from the `;TYPE:` annotations the slicer
+ * writes, which is what makes a preview readable. Colouring by filament, as
+ * the old viewer did, paints AMS slot colours across the whole print and tells
+ * you nothing about what the printer is doing.
+ */
+
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import * as THREE from 'three';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import { Loader2, FileWarning } from 'lucide-react';
+
+import { getAuthToken } from '../api/client';
+import {
+  parseGcodeToolpath,
+  layersByFilament,
+  filterLayersByType,
+  ToolpathType,
+  type ParsedToolpath,
+} from '../lib/gcodeToolpath';
+// Typed by the sibling toolpathRenderer.d.ts.
+import {
+  buildSegmentData,
+  makeToolpath,
+  computeColors,
+  TYPE_COLOR,
+  DEFAULT_RANGES_COLORS,
+} from '../lib/vendor/toolpathRenderer.js';
+
+interface GcodeToolpathViewerProps {
+  gcodeUrl: string;
+  buildVolume?: { x: number; y: number; z: number };
+  /**
+   * AMS slot colours, in tool order. When supplied the viewer opens on a
+   * filament-coloured view, which is what a multi-material print is usually
+   * being looked at for -- feature colouring answers a different question.
+   */
+  filamentColors?: string[];
+  className?: string;
+}
+
+/**
+ * The colour modes worth offering.
+ *
+ * Upstream also exposes speed, fan and temperature, but its own implementation
+ * derives those from *settings* rather than the toolpath, because its slicing
+ * kernel doesn't expose them per segment. Reading them out of the G-code would
+ * give real values -- `F`, `M106` and `M104` are right there in the file -- so
+ * they are left out until the parser carries them, rather than shipped as
+ * plausible-looking guesses.
+ */
+const VIEW_MODES = ['filament', 'feature', 'height', 'width'] as const;
+type ViewMode = (typeof VIEW_MODES)[number];
+
+/** Feature rows for the legend, in the order the slicer lists them. */
+const LEGEND_ENTRIES: Array<{ type: number; key: string; fallback: string }> = [
+  { type: ToolpathType.wall, key: 'gcodeViewer.feature.wall', fallback: 'Walls' },
+  { type: ToolpathType.sparseInfill, key: 'gcodeViewer.feature.sparseInfill', fallback: 'Sparse infill' },
+  { type: ToolpathType.solidInfill, key: 'gcodeViewer.feature.solidInfill', fallback: 'Solid infill' },
+  { type: ToolpathType.bridge, key: 'gcodeViewer.feature.bridge', fallback: 'Bridge / overhang' },
+  { type: ToolpathType.support, key: 'gcodeViewer.feature.support', fallback: 'Support' },
+  { type: ToolpathType.skirt, key: 'gcodeViewer.feature.skirt', fallback: 'Skirt / brim' },
+  { type: ToolpathType.gapFill, key: 'gcodeViewer.feature.gapFill', fallback: 'Gap fill' },
+  { type: ToolpathType.ironing, key: 'gcodeViewer.feature.ironing', fallback: 'Ironing' },
+  { type: ToolpathType.primeTower, key: 'gcodeViewer.feature.primeTower', fallback: 'Prime tower' },
+];
+
+/**
+ * Pack a CSS hex colour the way the renderer expects.
+ *
+ * It stores colour as a single float holding `r << 16 | g << 8 | b`, which its
+ * shader unpacks. Matching that exactly is what lets filament colours be
+ * applied through the same `setColors` path the built-in views use.
+ */
+function packColor(hex: string): number {
+  const value = hex.replace('#', '');
+  const full = value.length === 3 ? value.split('').map((c) => c + c).join('') : value;
+  const n = Number.parseInt(full.slice(0, 6), 16);
+  return Number.isFinite(n) ? n : 0x00ae42;
+}
+
+const cssColor = (rgb: number[] | undefined): string =>
+  rgb ? `rgb(${rgb.map((c) => Math.round(c * 255)).join(',')})` : 'transparent';
+
+/** The renderer's own blue-to-red ramp, as CSS gradient stops. */
+function rampStops(): string {
+  const colors = DEFAULT_RANGES_COLORS as number[][];
+  return colors
+    .map((rgb, i) => `${cssColor(rgb)} ${((i / (colors.length - 1)) * 100).toFixed(0)}%`)
+    .join(', ');
+}
+
+/** Two decimals for a layer height, none for a large speed-like value. */
+function formatScale(value: number): string {
+  if (!Number.isFinite(value)) return '-';
+  return Math.abs(value) < 10 ? value.toFixed(2) : value.toFixed(0);
+}
+
+export function GcodeToolpathViewer({
+  gcodeUrl,
+  buildVolume = { x: 256, y: 256, z: 256 },
+  filamentColors,
+  className = '',
+}: GcodeToolpathViewerProps) {
+  const { t } = useTranslation();
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const [notSliced, setNotSliced] = useState(false);
+  const [parsed, setParsed] = useState<ParsedToolpath | null>(null);
+
+  const hasFilamentColors = (filamentColors?.length ?? 0) > 0;
+  const [viewMode, setViewMode] = useState<ViewMode>(hasFilamentColors ? 'filament' : 'feature');
+  // Colours are fetched, so they usually arrive after the first render and the
+  // initial state above lands on 'feature'. Adopt filament when they turn up,
+  // unless the user has already picked a mode for themselves.
+  const modeChosenRef = useRef(false);
+  useEffect(() => {
+    if (hasFilamentColors && !modeChosenRef.current) setViewMode('filament');
+  }, [hasFilamentColors]);
+  // Filament and feature colouring merge vertices differently, so they cannot
+  // share one built mesh; the toolpath is rebuilt when crossing between them.
+  const [layerRange, setLayerRange] = useState<[number, number]>([0, 0]);
+  // Hidden types, tracked separately per colour space: in filament view a
+  // "type" is a filament slot, in every other view it is a feature.
+  const [hiddenFeatures, setHiddenFeatures] = useState<ReadonlySet<number>>(new Set());
+  const [hiddenFilaments, setHiddenFilaments] = useState<ReadonlySet<number>>(new Set());
+
+  const filamentView = viewMode === 'filament';
+  const hidden = filamentView ? hiddenFilaments : hiddenFeatures;
+  // A stable key so the toolpath effect re-runs on a change of contents rather
+  // than on every new Set identity.
+  const hiddenKey = [...hidden].sort((a, b) => a - b).join(',');
+
+  const toggleHidden = (type: number) => {
+    const update = (prev: ReadonlySet<number>) => {
+      const next = new Set(prev);
+      if (next.has(type)) next.delete(type);
+      else next.add(type);
+      return next;
+    };
+    if (filamentView) setHiddenFilaments(update);
+    else setHiddenFeatures(update);
+  };
+  const [showTravel, setShowTravel] = useState(false);
+
+  // Kept out of state: these are three.js objects, and re-rendering React on
+  // every camera nudge would be pointless work.
+  const sceneRef = useRef<THREE.Scene | null>(null);
+  const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
+  const controlsRef = useRef<OrbitControls | null>(null);
+  const handleRef = useRef<ReturnType<typeof makeToolpath> | null>(null);
+  const segmentDataRef = useRef<ReturnType<typeof buildSegmentData> | null>(null);
+  // Bumped when a new toolpath is built, so the colour effect re-runs against
+  // it -- the data it colours lives in a ref rather than in state.
+  const [toolpathGeneration, setToolpathGeneration] = useState(0);
+  // Read inside the toolpath effect without making it a dependency: a rebuild
+  // should honour the current controls, not reset them, and re-running on
+  // every slider nudge would rebuild the whole mesh.
+  const showTravelRef = useRef(showTravel);
+  const layerRangeRef = useRef(layerRange);
+  showTravelRef.current = showTravel;
+  layerRangeRef.current = layerRange;
+  // The camera is framed once. Re-framing on a colour-mode switch would yank
+  // the view back from wherever the user had put it.
+  const framedRef = useRef(false);
+
+  // `buildVolume` defaults to an object literal, so without this every render
+  // produced a new identity. That identity was a dependency of the scene
+  // effect, which therefore tore down and rebuilt the WebGL renderer on every
+  // render -- and browsers cap live WebGL contexts at around sixteen, dropping
+  // the oldest, which is why the canvas went blank after a few interactions.
+  const volumeKey = `${buildVolume.x}x${buildVolume.y}x${buildVolume.z}`;
+  const volume = useMemo(
+    () => ({ x: buildVolume.x, y: buildVolume.y, z: buildVolume.z }),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [volumeKey],
+  );
+
+  // --- Fetch and parse -----------------------------------------------------
+  useEffect(() => {
+    let cancelled = false;
+    setLoading(true);
+    setError(null);
+    setNotSliced(false);
+    setParsed(null);
+
+    const headers: HeadersInit = {};
+    const token = getAuthToken();
+    if (token) headers['Authorization'] = `Bearer ${token}`;
+
+    fetch(gcodeUrl, { headers })
+      .then(async (response) => {
+        if (!response.ok) {
+          if (response.status === 404) {
+            const data = await response.json().catch(() => ({}));
+            if (typeof data.detail === 'string' && data.detail.includes('sliced')) {
+              setNotSliced(true);
+              throw new Error('not_sliced');
+            }
+          }
+          throw new Error('Failed to load G-code');
+        }
+        return response.text();
+      })
+      .then((gcode) => {
+        if (cancelled) return;
+        framedRef.current = false;
+        const result = parseGcodeToolpath(gcode);
+        setParsed(result);
+        setLayerRange([0, Math.max(0, result.layers.length - 1)]);
+        setLoading(false);
+      })
+      .catch((err: Error) => {
+        if (cancelled) return;
+        if (err.message !== 'not_sliced') setError(err.message);
+        setLoading(false);
+      });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [gcodeUrl]);
+
+  // --- Scene: created once, never rebuilt ----------------------------------
+  // Deliberately independent of the toolpath. Tearing the renderer down to
+  // recolour would leak WebGL contexts and throw away the camera the user had
+  // positioned.
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container) return;
+
+    const width = container.clientWidth || 1;
+    const height = container.clientHeight || 1;
+
+    const scene = new THREE.Scene();
+    scene.background = new THREE.Color(0x1a1a1a);
+    sceneRef.current = scene;
+
+    const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
+    cameraRef.current = camera;
+
+    const renderer = new THREE.WebGLRenderer({ antialias: true });
+    renderer.setSize(width, height);
+    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+    container.appendChild(renderer.domElement);
+
+    const controls = new OrbitControls(camera, renderer.domElement);
+    controls.enableDamping = true;
+    controls.dampingFactor = 0.05;
+    controlsRef.current = controls;
+
+    // The bed. The toolpath shader lights itself (libvgcode carries its own
+    // light directions), so the scene needs no lights at all.
+    const grid = new THREE.GridHelper(
+      Math.max(volume.x, volume.y),
+      Math.ceil(Math.max(volume.x, volume.y) / 16),
+      0x444444,
+      0x333333,
+    );
+    // The toolpath group is rotated -90 degrees about X to take the slicer's
+    // Z-up space into three's Y-up, which maps (x, y, z) to (x, z, -y) -- so
+    // the bed's +Y runs along world -Z. Placing the grid at +Z left the print
+    // sitting beside its own plate rather than on it.
+    grid.position.set(volume.x / 2, 0, -volume.y / 2);
+    scene.add(grid);
+
+    let frame = 0;
+    const animate = () => {
+      frame = requestAnimationFrame(animate);
+      controls.update();
+      renderer.render(scene, camera);
+    };
+    animate();
+
+    const handleResize = () => {
+      const w = container.clientWidth || 1;
+      const h = container.clientHeight || 1;
+      camera.aspect = w / h;
+      camera.updateProjectionMatrix();
+      renderer.setSize(w, h);
+    };
+    const observer = new ResizeObserver(handleResize);
+    observer.observe(container);
+    window.addEventListener('resize', handleResize);
+
+    return () => {
+      window.removeEventListener('resize', handleResize);
+      observer.disconnect();
+      cancelAnimationFrame(frame);
+      controls.dispose();
+      grid.geometry.dispose();
+      (grid.material as THREE.Material).dispose();
+      renderer.dispose();
+      container.removeChild(renderer.domElement);
+      sceneRef.current = null;
+      cameraRef.current = null;
+      controlsRef.current = null;
+    };
+  }, [volume]);
+
+  // --- Toolpath: rebuilt when the colouring changes its vertex layout ------
+  useEffect(() => {
+    const scene = sceneRef.current;
+    const camera = cameraRef.current;
+    const controls = controlsRef.current;
+    if (!scene || !camera || !controls || !parsed || parsed.layers.length === 0) return;
+
+    // Filament and feature colouring merge adjacent vertices differently, so
+    // they genuinely produce different vertex streams and cannot share a mesh.
+    const keyed = filamentView ? layersByFilament(parsed.layers) : parsed.layers;
+    const sourceLayers = filterLayersByType(keyed, hidden);
+    const data = buildSegmentData(sourceLayers, parsed.defaultWidth);
+    const handle = makeToolpath(THREE, data);
+    segmentDataRef.current = data;
+    handleRef.current = handle;
+
+    const group = new THREE.Group();
+    group.rotation.x = -Math.PI / 2;
+    group.add(handle.mesh);
+    group.add(handle.travLines);
+    scene.add(group);
+
+    handle.setTravelVisible(showTravelRef.current);
+    handle.setLayerRange(layerRangeRef.current[0], layerRangeRef.current[1]);
+    setToolpathGeneration((n) => n + 1);
+
+    // Frame only on first build, so switching colour mode does not yank the
+    // camera back from wherever the user put it.
+    if (!framedRef.current) {
+      framedRef.current = true;
+      // Not Box3.setFromObject: this renderer keeps segment positions in a
+      // data texture, and the geometry attribute is only the 8-vertex diamond
+      // template -- measuring the object reports a few millimetres, so the
+      // camera parked itself far away and the print came out tiny.
+      const b = parsed.bounds;
+      const box = b
+        ? new THREE.Box3(
+            new THREE.Vector3(b.min[0], b.min[2], -b.max[1]),
+            new THREE.Vector3(b.max[0], b.max[2], -b.min[1]),
+          )
+        : new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(volume.x, 1, -volume.y));
+      const center = box.getCenter(new THREE.Vector3());
+      const radius = Math.max(box.getSize(new THREE.Vector3()).length() / 2, 0.001);
+      const vFov = THREE.MathUtils.degToRad(camera.fov);
+      const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
+      const distance = 1.15 * Math.max(radius / Math.sin(vFov / 2), radius / Math.sin(hFov / 2));
+      camera.position.copy(center).addScaledVector(new THREE.Vector3(0.7, 0.5, 0.7).normalize(), distance);
+      camera.near = Math.max(distance / 1000, 0.01);
+      camera.far = distance + radius * 4;
+      camera.updateProjectionMatrix();
+      controls.target.copy(center);
+      controls.update();
+    }
+
+    return () => {
+      scene.remove(group);
+      // The handle owns instanced buffers and a data texture per segment; on a
+      // large print that is a lot of GPU memory to leave behind.
+      handle.dispose();
+      handleRef.current = null;
+      segmentDataRef.current = null;
+    };
+    // hiddenKey rather than the Set, whose identity changes on every toggle.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [parsed, filamentView, volume, hiddenKey]);
+
+  // --- Controls drive the existing handle rather than rebuilding it ---------
+  useEffect(() => {
+    handleRef.current?.setLayerRange(layerRange[0], layerRange[1]);
+  }, [layerRange]);
+
+  useEffect(() => {
+    handleRef.current?.setTravelVisible(showTravel);
+  }, [showTravel]);
+
+  const colorResult = useMemo(() => {
+    const data = segmentDataRef.current;
+    if (!data || !parsed) return null;
+
+    if (filamentView) {
+      // In this mode each vertex's "type" is its filament index + 1, so the
+      // AMS colours can be applied straight from the per-vertex metadata.
+      const colors = new Float32Array(data.nV * 4);
+      for (let v = 0; v < data.nV; v += 1) {
+        const slot = Math.max(0, data.meta.vType[v] - 1);
+        const hex = filamentColors?.[slot] ?? filamentColors?.[0] ?? '#00ae42';
+        colors[v * 4] = packColor(hex);
+      }
+      return { color: colors, min: 0, max: 0, unit: '', cont: false };
+    }
+
+    // feature / height / width never consult the settings context, so an empty
+    // one is honest here; speed / fan / temp would not be, which is why they
+    // are not offered.
+    return computeColors(data, viewMode, {});
+    // toolpathGeneration is a dependency so a rebuilt mesh gets recoloured.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [viewMode, parsed, filamentView, filamentColors, toolpathGeneration]);
+
+  useEffect(() => {
+    if (colorResult) handleRef.current?.setColors(colorResult.color);
+  }, [colorResult]);
+
+  const layerCount = parsed?.layers.length ?? 0;
+
+  if (notSliced) {
+    return (
+      <div className={`flex flex-col items-center justify-center gap-2 text-bambu-gray ${className}`}>
+        <FileWarning className="w-8 h-8" />
+        {t('gcodeViewer.notSliced', 'This file has not been sliced yet.')}
+      </div>
+    );
+  }
+
+  if (error) {
+    return (
+      <div className={`flex flex-col items-center justify-center gap-2 text-bambu-gray ${className}`}>
+        <FileWarning className="w-8 h-8" />
+        {t('gcodeViewer.loadFailed', 'Could not load the G-code for this file.')}
+      </div>
+    );
+  }
+
+  return (
+    <div className={`relative ${className}`}>
+      <div ref={containerRef} className="w-full h-full" />
+
+      {loading && (
+        <div className="absolute inset-0 flex items-center justify-center gap-2 bg-bambu-dark/60 text-sm text-bambu-gray">
+          <Loader2 className="w-4 h-4 animate-spin" />
+          {t('gcodeViewer.loading', 'Reading toolpath...')}
+        </div>
+      )}
+
+      {!loading && layerCount > 0 && (
+        <>
+          {/* Colour mode + travel toggle */}
+          <div className="absolute left-3 top-3 flex flex-col gap-2 rounded border border-bambu-dark-tertiary bg-bambu-dark/85 p-2">
+            <div className="flex overflow-hidden rounded border border-bambu-dark-tertiary">
+              {VIEW_MODES.filter((mode) => mode !== 'filament' || hasFilamentColors).map((mode) => (
+                <button
+                  key={mode}
+                  type="button"
+                  onClick={() => {
+                    modeChosenRef.current = true;
+                    setViewMode(mode);
+                  }}
+                  className={`px-2 py-1 text-xs transition-colors ${
+                    viewMode === mode ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
+                  }`}
+                >
+                  {t(`gcodeViewer.view.${mode}`, mode)}
+                </button>
+              ))}
+            </div>
+
+            <label className="flex cursor-pointer items-center gap-2 text-xs text-bambu-gray">
+              <input
+                type="checkbox"
+                checked={showTravel}
+                onChange={(e) => setShowTravel(e.target.checked)}
+                className="cursor-pointer"
+              />
+              {t('gcodeViewer.showTravel', 'Travel moves')}
+            </label>
+
+            {filamentView ? (
+              <ul className="flex flex-col gap-0.5">
+                {(filamentColors ?? []).map((color, slot) => {
+                  // Filament view keys types as slot + 1; see layersByFilament.
+                  const type = slot + 1;
+                  const isHidden = hidden.has(type);
+                  return (
+                    <li key={slot}>
+                      <button
+                        type="button"
+                        onClick={() => toggleHidden(type)}
+                        aria-pressed={!isHidden}
+                        className={`flex w-full items-center gap-1.5 text-left text-[0.7rem] transition-opacity hover:text-white ${
+                          isHidden ? 'text-bambu-gray/40' : 'text-bambu-gray'
+                        }`}
+                      >
+                        <span
+                          className={`inline-block h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20 ${isHidden ? 'opacity-25' : ''}`}
+                          style={{ backgroundColor: color }}
+                          aria-hidden
+                        />
+                        <span className={isHidden ? 'line-through' : ''}>
+                          {t('gcodeViewer.filamentSlot', 'Filament {{n}}', { n: slot + 1 })}
+                        </span>
+                      </button>
+                    </li>
+                  );
+                })}
+              </ul>
+            ) : viewMode === 'feature' ? (
+              <ul className="flex flex-col gap-0.5">
+                {LEGEND_ENTRIES.map((entry) => {
+                  const isHidden = hidden.has(entry.type);
+                  return (
+                    <li key={entry.type}>
+                      <button
+                        type="button"
+                        onClick={() => toggleHidden(entry.type)}
+                        aria-pressed={!isHidden}
+                        className={`flex w-full items-center gap-1.5 text-left text-[0.7rem] transition-opacity hover:text-white ${
+                          isHidden ? 'text-bambu-gray/40' : 'text-bambu-gray'
+                        }`}
+                      >
+                        <span
+                          className={`inline-block h-2.5 w-2.5 shrink-0 rounded-sm ${isHidden ? 'opacity-25' : ''}`}
+                          style={{ backgroundColor: cssColor(TYPE_COLOR[entry.type]) }}
+                          aria-hidden
+                        />
+                        <span className={isHidden ? 'line-through' : ''}>{t(entry.key, entry.fallback)}</span>
+                      </button>
+                    </li>
+                  );
+                })}
+              </ul>
+            ) : (
+              colorResult && (
+                // Continuous scale. The ramp is drawn from the renderer's own
+                // stops rather than an approximation, and the ends are labelled
+                // -- a bare gradient says nothing about what the colours mean.
+                <div className="flex flex-col gap-1">
+                  <div
+                    className="h-2.5 w-full rounded-sm border border-white/10"
+                    style={{ background: `linear-gradient(to right, ${rampStops()})` }}
+                    aria-hidden
+                  />
+                  <div className="flex items-center justify-between text-[0.7rem] tabular-nums text-bambu-gray">
+                    <span>{formatScale(colorResult.min)}</span>
+                    <span className="text-bambu-gray/70">{colorResult.unit}</span>
+                    <span>{formatScale(colorResult.max)}</span>
+                  </div>
+                </div>
+              )
+            )}
+          </div>
+
+          {/* Layer range. Two ends, because inspecting a print means isolating
+              a band of layers, not just capping the top. */}
+          <div className="absolute right-3 top-3 flex flex-col items-center gap-1 rounded border border-bambu-dark-tertiary bg-bambu-dark/85 p-2">
+            <span className="text-[0.65rem] tabular-nums text-bambu-gray">{layerRange[1] + 1}</span>
+            <input
+              type="range"
+              min={0}
+              max={Math.max(0, layerCount - 1)}
+              value={layerRange[1]}
+              onChange={(e) => {
+                const top = Number(e.target.value);
+                setLayerRange(([bottom]) => [Math.min(bottom, top), top]);
+              }}
+              aria-label={t('gcodeViewer.topLayer', 'Top layer')}
+              className="h-40 w-4 cursor-pointer"
+              style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
+            />
+            <input
+              type="range"
+              min={0}
+              max={Math.max(0, layerCount - 1)}
+              value={layerRange[0]}
+              onChange={(e) => {
+                const bottom = Number(e.target.value);
+                setLayerRange(([, top]) => [bottom, Math.max(bottom, top)]);
+              }}
+              aria-label={t('gcodeViewer.bottomLayer', 'Bottom layer')}
+              className="h-40 w-4 cursor-pointer"
+              style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
+            />
+            <span className="text-[0.65rem] tabular-nums text-bambu-gray">{layerRange[0] + 1}</span>
+          </div>
+        </>
+      )}
+    </div>
+  );
+}

+ 0 - 276
frontend/src/components/GcodeViewer.tsx

@@ -1,276 +0,0 @@
-import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
-import { WebGLPreview } from 'gcode-preview';
-import { Loader2, Layers, ChevronLeft, ChevronRight, FileWarning } from 'lucide-react';
-import { getAuthToken } from '../api/client';
-
-interface GcodeViewerProps {
-  gcodeUrl: string;
-  buildVolume?: { x: number; y: number; z: number };
-  filamentColors?: string[];
-  className?: string;
-}
-
-export function GcodeViewer({
-  gcodeUrl,
-  buildVolume = { x: 256, y: 256, z: 256 },
-  filamentColors,
-  className = ''
-}: GcodeViewerProps) {
-  const canvasRef = useRef<HTMLCanvasElement>(null);
-  const previewRef = useRef<WebGLPreview | null>(null);
-  const renderTimeoutRef = useRef<number | null>(null);
-  const initRef = useRef(false);
-  const [loading, setLoading] = useState(true);
-  const [error, setError] = useState<string | null>(null);
-  const [notSliced, setNotSliced] = useState(false);
-  const [currentLayer, setCurrentLayer] = useState(0);
-  const [totalLayers, setTotalLayers] = useState(0);
-
-  // Memoize colors to prevent re-renders
-  const colorsKey = useMemo(() => JSON.stringify(filamentColors), [filamentColors]);
-
-  useEffect(() => {
-    if (!canvasRef.current || initRef.current) return;
-    initRef.current = true;
-
-    const canvas = canvasRef.current;
-
-    // Set canvas size before creating preview
-    const rect = canvas.parentElement?.getBoundingClientRect();
-    if (rect) {
-      canvas.width = rect.width;
-      canvas.height = rect.height;
-    }
-
-    // Use extrusionColor as array for multi-tool support
-    // Index in array = tool number
-    const hasMultiColor = filamentColors && filamentColors.length > 1;
-    const primaryColor = filamentColors?.[0] || '#00ae42';
-
-    // Create preview
-    const preview = new WebGLPreview({
-      canvas,
-      buildVolume,
-      backgroundColor: 0x1a1a1a,
-      // Pass full color array - library uses index as tool number
-      extrusionColor: hasMultiColor ? filamentColors : primaryColor,
-      disableGradient: true,
-      lineHeight: 0.2,
-      lineWidth: 2,
-      renderTravel: false,
-      renderExtrusion: true,
-    });
-
-    previewRef.current = preview;
-
-    // Fetch and process gcode
-    const headers: HeadersInit = {};
-    const token = getAuthToken();
-    if (token) {
-      headers['Authorization'] = `Bearer ${token}`;
-    }
-
-    fetch(gcodeUrl, { headers })
-      .then(async response => {
-        if (!response.ok) {
-          if (response.status === 404) {
-            const data = await response.json().catch(() => ({}));
-            if (data.detail?.includes('sliced')) {
-              setNotSliced(true);
-              throw new Error('not_sliced');
-            }
-          }
-          throw new Error('Failed to load G-code');
-        }
-        return response.text();
-      })
-      .then(gcode => {
-        // The gcode-preview library only supports T0-T7
-        // We need to remap higher tool numbers to fit within this range
-        // First, find all unique tool numbers used
-        const toolNumbers = new Set<number>();
-        const toolRegex = /^(\s*)T(\d+)(\s*;.*)?$/gim;
-        let match;
-        while ((match = toolRegex.exec(gcode)) !== null) {
-          const toolNum = parseInt(match[2], 10);
-          if (toolNum <= 15) { // Valid tool, not a special command
-            toolNumbers.add(toolNum);
-          }
-        }
-
-        // Create a mapping from original tool numbers to 0-7 range
-        const toolMapping = new Map<number, number>();
-        const sortedTools = Array.from(toolNumbers).sort((a, b) => a - b);
-        sortedTools.forEach((tool, index) => {
-          toolMapping.set(tool, index % 8); // Map to 0-7
-        });
-
-        // Build remapped color array based on the mapping
-        const remappedColors: string[] = [];
-        sortedTools.forEach((originalTool, index) => {
-          const color = filamentColors?.[originalTool] || '#00ae42';
-          remappedColors[index % 8] = color;
-        });
-
-        // Process gcode: filter special commands and remap tool numbers
-        const cleanedGcode = gcode
-          .split('\n')
-          .map(line => {
-            const match = line.match(/^(\s*)T(\d+)(\s*;.*)?$/i);
-            if (match) {
-              const toolNum = parseInt(match[2], 10);
-              if (toolNum > 15) {
-                // Filter out Bambu special commands (T255, T1000, T65535, etc.)
-                return `; FILTERED: ${line.trim()}`;
-              }
-              // Remap tool number to 0-7 range
-              const mappedTool = toolMapping.get(toolNum) ?? 0;
-              return `${match[1]}T${mappedTool}${match[3] || ''}`;
-            }
-            return line;
-          })
-          .join('\n');
-
-        // Update colors for the preview using the remapped array
-        if (remappedColors.length > 0) {
-          (preview as unknown as { extrusionColor: string[] }).extrusionColor = remappedColors;
-        }
-
-        preview.processGCode(cleanedGcode);
-
-        const layers = preview.layers?.length || 0;
-        setTotalLayers(layers);
-        setCurrentLayer(layers);
-
-        preview.render();
-        setLoading(false);
-      })
-      .catch(err => {
-        if (err.message !== 'not_sliced') {
-          setError(err.message);
-        }
-        setLoading(false);
-      });
-
-    // Handle resize
-    const handleResize = () => {
-      if (canvas.parentElement && previewRef.current) {
-        const newRect = canvas.parentElement.getBoundingClientRect();
-        canvas.width = newRect.width;
-        canvas.height = newRect.height;
-        previewRef.current.resize();
-      }
-    };
-
-    window.addEventListener('resize', handleResize);
-
-    return () => {
-      window.removeEventListener('resize', handleResize);
-      if (renderTimeoutRef.current) {
-        cancelAnimationFrame(renderTimeoutRef.current);
-      }
-      if (previewRef.current) {
-        previewRef.current.dispose();
-        previewRef.current = null;
-      }
-      initRef.current = false;
-    };
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [gcodeUrl, colorsKey]); // Intentionally use colorsKey instead of filamentColors, buildVolume rarely changes
-
-  const handleLayerChange = useCallback((layer: number) => {
-    if (!previewRef.current) return;
-    const newLayer = Math.max(1, Math.min(layer, totalLayers));
-    setCurrentLayer(newLayer);
-
-    if (renderTimeoutRef.current) {
-      cancelAnimationFrame(renderTimeoutRef.current);
-    }
-
-    renderTimeoutRef.current = requestAnimationFrame(() => {
-      if (previewRef.current) {
-        previewRef.current.endLayer = newLayer;
-        previewRef.current.render();
-      }
-    });
-  }, [totalLayers]);
-
-  const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
-    handleLayerChange(parseInt(e.target.value, 10));
-  };
-
-  return (
-    <div className={`relative flex flex-col h-full ${className}`}>
-      <div className="flex-1 relative bg-bambu-dark rounded-lg overflow-hidden">
-        <canvas ref={canvasRef} className="w-full h-full" />
-
-        {loading && (
-          <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
-            <div className="text-center">
-              <Loader2 className="w-8 h-8 animate-spin text-bambu-green mx-auto mb-2" />
-              <p className="text-bambu-gray text-sm">Loading G-code...</p>
-            </div>
-          </div>
-        )}
-
-        {notSliced && (
-          <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
-            <div className="text-center max-w-sm px-4">
-              <FileWarning className="w-12 h-12 text-bambu-gray mx-auto mb-3" />
-              <p className="text-white font-medium mb-2">G-code not available</p>
-              <p className="text-bambu-gray text-sm">
-                This file hasn't been sliced yet. G-code preview is only available
-                after slicing in Bambu Studio or Orca Slicer.
-              </p>
-            </div>
-          </div>
-        )}
-
-        {error && !notSliced && (
-          <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
-            <div className="text-center text-red-400">
-              <p className="text-sm">{error}</p>
-            </div>
-          </div>
-        )}
-      </div>
-
-      {!loading && !error && !notSliced && totalLayers > 0 && (
-        <div className="mt-4 px-2">
-          <div className="flex items-center gap-3">
-            <Layers className="w-4 h-4 text-bambu-gray flex-shrink-0" />
-
-            <button
-              onClick={() => handleLayerChange(currentLayer - 1)}
-              disabled={currentLayer <= 1}
-              className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
-            >
-              <ChevronLeft className="w-4 h-4" />
-            </button>
-
-            <input
-              type="range"
-              min={1}
-              max={totalLayers}
-              value={currentLayer}
-              onChange={handleSliderChange}
-              className="flex-1 h-2 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer accent-bambu-green"
-            />
-
-            <button
-              onClick={() => handleLayerChange(currentLayer + 1)}
-              disabled={currentLayer >= totalLayers}
-              className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
-            >
-              <ChevronRight className="w-4 h-4" />
-            </button>
-
-            <span className="text-sm text-bambu-gray min-w-[80px] text-right">
-              {currentLayer} / {totalLayers}
-            </span>
-          </div>
-        </div>
-      )}
-    </div>
-  );
-}

+ 141 - 27
frontend/src/components/ModelViewer.tsx

@@ -4,11 +4,50 @@ import * as THREE from 'three';
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
 import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
 import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
+import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
 import JSZip from 'jszip';
 import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
 import { Button } from './Button';
 import { getAuthToken } from '../api/client';
 
+/**
+ * Frame the camera on a bounding box.
+ *
+ * The previous heuristic was `maxDim * 1.8`, which ignores both the camera's
+ * field of view and the viewport's aspect ratio. In a tall, narrow panel the
+ * horizontal field of view is much narrower than the vertical one, so that
+ * distance pushed the model into the middle of the frame with a screenful of
+ * empty space above it. Solving the distance from the bounding *sphere*
+ * against both fields of view fills the frame at any viewport shape.
+ */
+function fitCameraToBox(
+  camera: THREE.PerspectiveCamera,
+  controls: OrbitControls,
+  box: THREE.Box3,
+  padding = 1.15,
+): void {
+  const size = box.getSize(new THREE.Vector3());
+  const center = box.getCenter(new THREE.Vector3());
+  // Circumscribed sphere: conservative, so the model never crops on rotation.
+  const radius = Math.max(size.length() / 2, 0.001);
+
+  const vFov = THREE.MathUtils.degToRad(camera.fov);
+  const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
+  const distance = padding * Math.max(radius / Math.sin(vFov / 2), radius / Math.sin(hFov / 2));
+
+  // Keep the established three-quarter view; only the distance changes.
+  const direction = new THREE.Vector3(0.7, 0.5, 0.7).normalize();
+  camera.position.copy(center).addScaledVector(direction, distance);
+  // Clip planes scaled to the subject, so a small model doesn't z-fight and a
+  // large one isn't sliced by the far plane.
+  camera.near = Math.max(distance / 1000, 0.01);
+  camera.far = distance + radius * 4;
+  camera.updateProjectionMatrix();
+
+  controls.target.copy(center);
+  controls.update();
+}
+
 interface BuildVolume {
   x: number;
   y: number;
@@ -524,14 +563,21 @@ function buildModelGroup(
   const group = new THREE.Group();
 
   // Create materials for each extruder color
-  const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
+  const getMaterial = (extruder: number): THREE.MeshStandardMaterial => {
     const defaultColor = '#00ae42';
     const colorStr = filamentColors?.[extruder] || defaultColor;
     // Convert hex color string to THREE.js color
     const color = new THREE.Color(colorStr);
-    return new THREE.MeshPhongMaterial({
+    // Matte plastic against the scene's environment map. Phong lit only by
+    // direct lights gave every same-facing surface an identical colour, which
+    // is what flattened models into silhouettes. Roughness is high because
+    // FDM prints are not glossy, but not 1.0 -- a little specular is what
+    // makes layer-scale surface detail legible.
+    return new THREE.MeshStandardMaterial({
       color,
-      shininess: 30,
+      roughness: 0.62,
+      metalness: 0.0,
+      envMapIntensity: 0.55,
       flatShading: false,
     });
   };
@@ -605,6 +651,7 @@ function buildModelGroup(
     if (mergedGeometry) {
       const material = getMaterial(extruder);
       const mesh = new THREE.Mesh(mergedGeometry, material);
+      mesh.castShadow = true;
       group.add(mesh);
     }
 
@@ -632,6 +679,12 @@ export function ModelViewer({
   const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
   const sceneRef = useRef<THREE.Scene | null>(null);
   const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
+  // Held so the environment map and its generator can be released on unmount;
+  // a PMREM render target is GPU memory the garbage collector cannot reclaim.
+  const pmremRef = useRef<THREE.PMREMGenerator | null>(null);
+  const environmentRef = useRef<THREE.Texture | null>(null);
+  const keyLightRef = useRef<THREE.DirectionalLight | null>(null);
+  const shadowCatcherRef = useRef<THREE.Mesh | null>(null);
   const controlsRef = useRef<OrbitControls | null>(null);
   const modelGroupRef = useRef<THREE.Group | null>(null);
   const plateRef = useRef<THREE.Mesh | null>(null);
@@ -661,7 +714,18 @@ export function ModelViewer({
     // Renderer
     const renderer = new THREE.WebGLRenderer({ antialias: true });
     renderer.setSize(width, height);
-    renderer.setPixelRatio(window.devicePixelRatio);
+    // Cap the device pixel ratio: a 3x phone screen quadruples the fragment
+    // load for no visible gain on a model this simple.
+    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+    // Filmic tone mapping keeps the bright side of a saturated filament colour
+    // from clipping to white, which is what made every model read as flat paint.
+    renderer.toneMapping = THREE.ACESFilmicToneMapping;
+    // Deliberately below 1.0: RoomEnvironment is a bright white box, and
+    // anything at or above unity clipped the lit side of a saturated
+    // filament colour to white, draining the hue out of the model.
+    renderer.toneMappingExposure = 0.85;
+    renderer.shadowMap.enabled = true;
+    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
     container.appendChild(renderer.domElement);
     rendererRef.current = renderer;
 
@@ -671,17 +735,40 @@ export function ModelViewer({
     controls.dampingFactor = 0.05;
     controlsRef.current = controls;
 
-    // Lights
-    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
-    scene.add(ambientLight);
-
-    const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
-    directionalLight.position.set(100, 100, 100);
-    scene.add(directionalLight);
-
-    const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
-    directionalLight2.position.set(-100, 50, -100);
-    scene.add(directionalLight2);
+    // Image-based lighting. A generated room gives the model a real light
+    // environment -- soft gradients across curved surfaces, a hint of
+    // reflection -- which is the single biggest difference between this and a
+    // desktop slicer's viewport. Two directional lights on flat ambient could
+    // never produce that; every surface facing the same way got the same
+    // colour, so the model read as a flat silhouette.
+    const pmrem = new THREE.PMREMGenerator(renderer);
+    const environment = pmrem.fromScene(new RoomEnvironment(), 0.04);
+    scene.environment = environment.texture;
+    pmremRef.current = pmrem;
+    environmentRef.current = environment.texture;
+
+    // One key light on top, purely for the contact shadow and a highlight
+    // direction; the environment supplies the fill.
+    // Mostly overhead. An oblique key threw a long shadow across the whole
+    // bed; a print sitting on a plate wants a contact shadow beneath it.
+    const keyLight = new THREE.DirectionalLight(0xffffff, 0.75);
+    keyLight.position.set(60, 260, 90);
+    keyLight.castShadow = true;
+    keyLight.shadow.mapSize.set(2048, 2048);
+    keyLight.shadow.bias = -0.0005;
+    keyLight.shadow.normalBias = 0.02;
+    // Three's default shadow camera is a +/-5 unit box; on a 256mm bed the
+    // model falls entirely outside it and no shadow is drawn at all.
+    const shadowExtent = Math.max(buildVolume.x, buildVolume.y) * 0.75;
+    keyLight.shadow.camera.left = -shadowExtent;
+    keyLight.shadow.camera.right = shadowExtent;
+    keyLight.shadow.camera.top = shadowExtent;
+    keyLight.shadow.camera.bottom = -shadowExtent;
+    keyLight.shadow.camera.near = 1;
+    keyLight.shadow.camera.far = shadowExtent * 6;
+    keyLight.shadow.camera.updateProjectionMatrix();
+    scene.add(keyLight);
+    keyLightRef.current = keyLight;
 
     // Grid - use the larger dimension for the grid size
     const gridSize = Math.max(buildVolume.x, buildVolume.y);
@@ -704,6 +791,21 @@ export function ModelViewer({
     scene.add(plate);
     plateRef.current = plate;
 
+    // Dedicated shadow catcher just above the plate. The plate itself is an
+    // unlit MeshBasicMaterial and cannot receive shadows; ShadowMaterial draws
+    // nothing but the shadow, so the tinted plate shows through unchanged.
+    // Without a contact shadow the model reads as pasted onto the background
+    // rather than resting on the bed.
+    const shadowCatcher = new THREE.Mesh(
+      new THREE.PlaneGeometry(buildVolume.x, buildVolume.y),
+      new THREE.ShadowMaterial({ opacity: 0.22 }),
+    );
+    shadowCatcher.rotation.x = -Math.PI / 2;
+    shadowCatcher.position.y = -0.49;
+    shadowCatcher.receiveShadow = true;
+    scene.add(shadowCatcher);
+    shadowCatcherRef.current = shadowCatcher;
+
     // Animation loop - keep it simple for reliability
     let animationId: number;
     const animate = () => {
@@ -787,11 +889,21 @@ export function ModelViewer({
       resizeObserver.disconnect();
       cancelAnimationFrame(animationId);
       controls.dispose();
+      // The environment map is a render target; disposing the renderer alone
+      // leaves it allocated on the GPU, and this viewer is opened and closed
+      // repeatedly from the file manager.
+      environmentRef.current?.dispose();
+      environmentRef.current = null;
+      pmremRef.current?.dispose();
+      pmremRef.current = null;
+      scene.environment = null;
       renderer.dispose();
       container.removeChild(renderer.domElement);
       modelGroupRef.current = null;
       plateRef.current = null;
       gridRef.current = null;
+      keyLightRef.current = null;
+      shadowCatcherRef.current = null;
     };
   }, [url, buildVolume, fileType, t]);
 
@@ -808,8 +920,14 @@ export function ModelViewer({
     const group = isStlModel
       ? (() => {
           const materialColor = filamentColors?.[0] || '#00ae42';
-          const material = new THREE.MeshPhongMaterial({ color: new THREE.Color(materialColor), shininess: 30 });
+          const material = new THREE.MeshStandardMaterial({
+            color: new THREE.Color(materialColor),
+            roughness: 0.62,
+            metalness: 0.0,
+            envMapIntensity: 0.55,
+          });
           const mesh = new THREE.Mesh(stlGeometry!, material);
+          mesh.castShadow = true;
           const stlGroup = new THREE.Group();
           stlGroup.add(mesh);
           return stlGroup;
@@ -872,21 +990,17 @@ export function ModelViewer({
       gridRef.current.position.z = plateCenterZ;
     }
 
+    // Follows the plate, or the shadow lands on empty space beside the bed.
+    if (shadowCatcherRef.current) {
+      shadowCatcherRef.current.position.x = plateCenterX;
+      shadowCatcherRef.current.position.z = plateCenterZ;
+    }
+
     // Recalculate bounding box after positioning
     const finalBox = new THREE.Box3().setFromObject(group);
-    const finalCenter = finalBox.getCenter(new THREE.Vector3());
-    const finalSize = finalBox.getSize(new THREE.Vector3());
 
     // Adjust camera to fit model
-    const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
-    const cameraDistance = maxDim * 1.8;
-    cameraRef.current.position.set(
-      finalCenter.x + cameraDistance * 0.7,
-      finalCenter.y + cameraDistance * 0.5,
-      finalCenter.z + cameraDistance * 0.7
-    );
-    controlsRef.current.target.copy(finalCenter);
-    controlsRef.current.update();
+    fitCameraToBox(cameraRef.current, controlsRef.current, finalBox);
 
     setLoading(false);
   }, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);

+ 13 - 37
frontend/src/components/ModelViewerModal.tsx

@@ -1,16 +1,16 @@
 import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
+import { X, ExternalLink, Box, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
-import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { api, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
-import { isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
+import { isApiSliceableFileType, isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
-type ViewTab = '3d' | 'gcode';
+// The modal shows the model only; G-code has its own full-page viewer.
+type ViewTab = '3d';
 
 interface ModelViewerModalProps {
   archiveId?: number;
@@ -27,7 +27,6 @@ interface ModelViewerModalProps {
 
 interface Capabilities {
   has_model: boolean;
-  has_gcode: boolean;
   has_source: boolean;
   build_volume: { x: number; y: number; z: number };
   filament_colors: string[];
@@ -173,15 +172,13 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
       // the 3D-tab + g-code-tab gating (#1543).
       const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf';
       const hasModel = isThreeMfFamily || normalizedType === 'stl';
-      const hasGcode = isThreeMfFamily || normalizedType === 'gcode';
       setCapabilities({
         has_model: hasModel,
-        has_gcode: hasGcode,
         has_source: false,
         build_volume: { x: 256, y: 256, z: 256 },
         filament_colors: [],
       });
-      setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
+      setActiveTab(hasModel ? '3d' : null);
       setLoading(false);
       return;
     }
@@ -199,14 +196,12 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
         // Auto-select the first available tab
         if (caps.has_model) {
           setActiveTab('3d');
-        } else if (caps.has_gcode) {
-          setActiveTab('gcode');
         }
         setLoading(false);
       })
       .catch(() => {
         // Fallback to 3D model tab if capabilities check fails
-        setCapabilities({ has_model: true, has_gcode: false, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });
+        setCapabilities({ has_model: true, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });
         setActiveTab('3d');
         setLoading(false);
       });
@@ -373,12 +368,14 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
 
   // Which file types can be handed to a desktop slicer via the URL protocol
-  // handler — and sliced in-app via the sidecar. Shares its list with
-  // `isSliceableFilename()`, which the File Manager's card menu and list row
-  // use, so a file's "Slice" action and its 3D-preview slicer button can no
-  // longer disagree about the same file.
+  // handler. Shares its list with `isSliceableFilename()`, which the File
+  // Manager's card menu and list row use, so a file's "Slice" action and its
+  // 3D-preview slicer button can no longer disagree about the same file.
   const slicerReadyType = isSliceableFileType(fileType);
   const canOpenInSlicer = isLibrary ? slicerReadyType : true;
+  // The sidecar's list is narrower: its CLI cannot load STEP even though the
+  // desktop GUI opens one fine, so in-app slicing is gated separately.
+  const apiSlicerReadyType = isApiSliceableFileType(fileType);
 
   // When the user has the in-app Slicer API enabled (Settings → Workflow →
   // Slicer → Use Slicer API), library-mode previews route the header's slicer
@@ -387,7 +384,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   // the API is off, when no in-app handler is wired (e.g. archive preview),
   // or when the file type can't be sliced (.gcode / .gcode.3mf, etc.).
   const useBambuddySlicer = Boolean(
-    isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && slicerReadyType,
+    isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && apiSlicerReadyType,
   );
 
   const handleOpenInSlicer = async (slicer: SlicerType) => {
@@ -505,21 +502,6 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
               {t('modelViewer.tabs.model')}
               {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
             </button>
-            <button
-              onClick={() => capabilities.has_gcode && setActiveTab('gcode')}
-              disabled={!capabilities.has_gcode}
-              className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
-                activeTab === 'gcode'
-                  ? 'text-bambu-green border-b-2 border-bambu-green'
-                  : capabilities.has_gcode
-                    ? 'text-bambu-gray hover:text-white'
-                    : 'text-bambu-gray/30 cursor-not-allowed'
-              }`}
-            >
-              <Code2 className="w-4 h-4" />
-              {t('modelViewer.tabs.gcode')}
-              {!capabilities.has_gcode && <span className="text-xs">({t('modelViewer.notSliced')})</span>}
-            </button>
           </div>
         )}
 
@@ -761,12 +743,6 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
                   />
               </div>
             </div>
-          ) : activeTab === 'gcode' && capabilities ? (
-            <GcodeViewer
-              gcodeUrl={isLibrary ? api.getLibraryFileGcodeUrl(libraryFileId!) : api.getArchiveGcode(archiveId!)}
-              filamentColors={capabilities.filament_colors}
-              className="w-full h-full"
-            />
           ) : (
             <div className="w-full h-full flex items-center justify-center text-bambu-gray">
               {t('modelViewer.noPreview')}

+ 213 - 85
frontend/src/components/SliceModal.tsx

@@ -1,5 +1,5 @@
 import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useId, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import {
@@ -15,8 +15,11 @@ import {
 } from '../api/client';
 import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
 import { useToast } from '../contexts/ToastContext';
+import { useIsWideLayout } from '../hooks/useIsWideLayout';
 import { PlatePickerModal } from './PlatePickerModal';
+import SlicerSettingsPanel, { type FilamentChoice } from './SlicerSettingsPanel';
 import type { DesignOverride, PlateFilament } from '../types/plates';
+import type { SettingValue } from '../types/slicerSettings';
 import {
   presetCompatibility,
   buildCompatibilityIndex,
@@ -186,16 +189,6 @@ function formatElapsed(seconds: number): string {
   return `${h}h ${remM}m`;
 }
 
-// Render a slicer parameter value for the design-settings list. Bambu's process
-// schema stores everything as strings or arrays of strings, so this only has to
-// flatten arrays and keep scalars readable — no unit or type interpretation,
-// which would rot against every slicer release.
-function formatDesignValue(value: unknown): string {
-  if (Array.isArray(value)) return value.map((v) => String(v)).join(', ');
-  if (value == null) return '';
-  return String(value);
-}
-
 export function SliceModal({ source, onClose }: SliceModalProps) {
   const { t } = useTranslation();
   const { trackJob } = useSliceJobTracker();
@@ -253,7 +246,20 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // accelerations, prime-tower geometry) are listed but start unticked — those
   // were tuned for the designer's printer and can be plain wrong on another.
   const [designKeys, setDesignKeys] = useState<Set<string>>(new Set());
-  const [designExpanded, setDesignExpanded] = useState(false);
+
+  // Process settings the user edited by hand in the settings panel. Two shapes
+  // are kept: the panel's editing values, and the same set serialised into the
+  // string forms a process preset stores. The panel owns the option schema, so
+  // it hands back both rather than making this component re-derive the second.
+  const [processOverrides, setProcessOverrides] = useState<Record<string, SettingValue>>({});
+  const [serializedProcessOverrides, setSerializedProcessOverrides] = useState<Record<string, string | string[]>>({});
+  const [settingsExpanded, setSettingsExpanded] = useState(false);
+  // Wide enough for the two-column layout, where the panel has a column to
+  // itself and so is always open. The disclosure only exists for the narrow
+  // single-stack layout, in which 348 unfolded options would bury the preset
+  // pickers above them.
+  const isWideLayout = useIsWideLayout();
+  const panelOpen = isWideLayout || settingsExpanded;
 
   // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
   // with one pick, or save the current selection as a new pipeline.
@@ -415,6 +421,48 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     [printerModelsQuery.data],
   );
 
+  // The picked process preset's effective values, flattened by the sidecar.
+  // Without this the settings panel shows OrcaSlicer's compiled-in defaults —
+  // a preset with a 0.42mm line width would read 0, which is the C++ default
+  // meaning "derive from the nozzle". Keyed on the preset so switching presets
+  // re-baselines the panel.
+  const presetValuesQuery = useQuery({
+    queryKey: ['slicer-preset-values', processPreset?.source, processPreset?.id],
+    queryFn: () => api.getSlicerPresetValues(processPreset as PresetRef),
+    enabled: processPreset != null,
+    // Preset contents only change when the user edits them in the slicer, and
+    // the modal is short-lived; no need to re-fetch while it is open.
+    staleTime: 5 * 60_000,
+  });
+
+  // A failed fetch is not an error the user must act on — the panel falls back
+  // to schema defaults and says so — so treat "no data yet" as unresolved
+  // rather than blocking the panel on it.
+  const presetValues = presetValuesQuery.data?.values as Record<string, SettingValue> | undefined;
+  const presetValuesResolved = presetValuesQuery.data?.resolved ?? presetValuesQuery.isLoading;
+  // A failed request (rather than a 'resolved: false' answer) means we
+  // never reached the backend, which is the same situation as an
+  // unreachable sidecar as far as the user is concerned.
+  const presetValuesReason = presetValuesQuery.data?.reason ?? (presetValuesQuery.isError ? 'sidecar_unavailable' : undefined);
+
+  // Slot list for the settings panel's filament pickers (support base and
+  // interface, and the Multimaterial page's per-region options). Those store a
+  // plain integer, so without this the user has to map slot numbers onto their
+  // own AMS by hand. Falls back to the slot's material when a slot has no pick
+  // yet, so the list is never a column of blanks.
+  const filamentChoices = useMemo<FilamentChoice[]>(() => {
+    const data = presetsQuery.data;
+    return filamentSlots.map((slot, idx) => {
+      const ref = filamentPresets[idx] ?? null;
+      const preset = data && ref ? findPreset(data, ref, 'filament') : null;
+      return {
+        index: idx + 1,
+        label: preset?.name || slot.type || t('slice.filamentSlotUnset', 'not set'),
+        color: slot.color || undefined,
+      };
+    });
+  }, [filamentSlots, filamentPresets, presetsQuery.data, t]);
+
   // Printer / process preset names the source 3MF was prepared with. The
   // plates query resolves before the presets query (the latter is gated on
   // it), so these are known by the time the pre-pick effects run.
@@ -553,6 +601,12 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
       // which the embedded-settings path never sends — so they are mutually
       // exclusive by construction (#2622).
       ...(!useEmbedded && designKeys.size > 0 ? { design_overrides: [...designKeys] } : {}),
+      // The user's own edits from the settings panel. Like design_overrides
+      // these patch the resolved process JSON, so the embedded-settings path
+      // (which sends no process JSON at all) cannot carry them.
+      ...(!useEmbedded && Object.keys(processOverrides).length > 0
+        ? { process_overrides: serializedProcessOverrides }
+        : {}),
       // Sent only when on. The backend defaults both to false, so omitting
       // them keeps the request identical to what older clients send.
       ...(autoOrient ? { auto_orient: true } : {}),
@@ -598,7 +652,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
       }}
     >
       <div
-        className="w-full max-w-xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
+        className="w-full max-w-xl lg:max-w-5xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
         onClick={(e) => e.stopPropagation()}
       >
         {/* Header */}
@@ -669,6 +723,13 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   status === 'ok' (returns null in that case), but the Refresh
                   button stays visible regardless so users can pick up cloud /
                   bundled changes even when sign-in is healthy. */}
+              {/* Two columns once there is room for them. The left keeps the
+                  "what am I slicing with" decisions together; the right gives
+                  the process-settings panel a column of its own, which is the
+                  only way 348 options are comfortable to work through. Below
+                  lg both collapse back into the original single stack. */}
+              <div className="lg:grid lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-5 lg:items-start">
+                <div className="space-y-4 min-w-0">
               {/* Slicer Pipelines (#1425): apply a saved preset bundle to all
                   four slots, or save the current selection as a pipeline.
                   Pipelines are managed in Settings → Workflow → Pipelines. */}
@@ -827,68 +888,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                 selectedPrinterName={selectedPrinterName}
                 compatIndex={compatIndex}
               />
-              {/* Designer's process tweaks (#2622). BambuStudio records which
-                  keys deviate from the stock preset in the 3MF itself, so a
-                  re-slice for another printer can carry them instead of
-                  flattening them under --load-settings. Hidden entirely when
-                  the source lists none, and disabled in embedded mode where
-                  the process JSON these patch is never sent. */}
-              {designOverrides.length > 0 && (
-                <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 p-3">
-                  <button
-                    type="button"
-                    onClick={() => setDesignExpanded((v) => !v)}
-                    className="flex w-full items-center justify-between gap-2 text-left"
-                  >
-                    <span className="text-sm text-white">
-                      {t('slice.designSettings')}
-                      <span className="block text-xs text-bambu-gray/70">
-                        {t('slice.designSettingsHint', { count: designOverrides.length })}
-                      </span>
-                    </span>
-                    <span className="shrink-0 text-xs text-bambu-gray">
-                      {t('slice.designSettingsSelected', { selected: designKeys.size, total: designOverrides.length })}
-                    </span>
-                  </button>
-                  {designExpanded && (
-                    <div className="mt-3 space-y-1.5 border-t border-bambu-dark-tertiary pt-3">
-                      {designOverrides.map((o) => (
-                        <label
-                          key={o.key}
-                          className={`flex items-start gap-2 text-xs ${useEmbedded ? 'opacity-50' : 'cursor-pointer'}`}
-                        >
-                          <input
-                            type="checkbox"
-                            checked={designKeys.has(o.key)}
-                            disabled={isEnqueuing || useEmbedded}
-                            onChange={(e) => {
-                              setDesignKeys((prev) => {
-                                const next = new Set(prev);
-                                if (e.target.checked) next.add(o.key);
-                                else next.delete(o.key);
-                                return next;
-                              });
-                            }}
-                            className="mt-0.5 shrink-0 cursor-pointer"
-                          />
-                          <span className="min-w-0 flex-1">
-                            <span className="font-mono text-bambu-gray">{o.key}</span>
-                            <span className="ml-1.5 break-all text-white">{formatDesignValue(o.value)}</span>
-                            {o.printer_coupled && (
-                              <span
-                                className="ml-1.5 rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/20 dark:text-amber-400"
-                                title={t('slice.designSettingsPrinterCoupledHint')}
-                              >
-                                {t('slice.designSettingsPrinterCoupled')}
-                              </span>
-                            )}
-                          </span>
-                        </label>
-                      ))}
-                    </div>
-                  )}
-                </div>
-              )}
+
               {/* Bed-type override (#1337). Always visible, always enabled.
                   The backend patches curr_bed_type on the resolved process
                   JSON before forwarding to the sidecar. */}
@@ -990,6 +990,88 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   );
                 })
               )}
+                </div>
+
+                {/* Right column: the settings panel. It owns this column, so
+                    there is nothing to collapse it out of the way of — the
+                    disclosure below lg exists only because the single-column
+                    stack cannot afford 348 options unfolded.
+
+                    Kept on screen in embedded mode but disabled rather than
+                    removed: nothing here is sent on that path (the file's own
+                    settings drive the slice), and dropping the column outright
+                    made the dialog look like it had lost a feature whenever
+                    the toggle was flipped. */}
+                <div className="mt-4 lg:mt-0 min-w-0">
+                  <div
+                    className={`rounded border border-bambu-dark-tertiary p-3 ${useEmbedded ? 'opacity-60' : ''}`}
+                  >
+                    <button
+                      type="button"
+                      onClick={() => setSettingsExpanded((v) => !v)}
+                      aria-expanded={panelOpen}
+                      disabled={isWideLayout}
+                      className="flex w-full items-center justify-between gap-2 text-left lg:cursor-default"
+                    >
+                      <span className="text-sm text-white">
+                        {t('slice.processSettings', 'Process settings')}
+                        <span className="block text-xs text-bambu-gray/70">
+                          {useEmbedded
+                            ? t(
+                                'slice.processSettingsEmbedded',
+                                "Not used while \"Use the file's built-in settings\" is on -- the file's own settings drive this slice.",
+                              )
+                            : t(
+                                'slice.processSettingsHint',
+                                "Adjust the picked preset for this slice. Anything you don't touch stays as the preset defines it.",
+                              )}
+                        </span>
+                      </span>
+                      <span className="shrink-0 text-xs text-bambu-gray">
+                        {useEmbedded
+                          ? t('slice.processSettingsInactive', 'Inactive')
+                          : Object.keys(serializedProcessOverrides).length > 0
+                            ? t('slice.processSettingsChanged', '{{count}} changed', {
+                                count: Object.keys(serializedProcessOverrides).length,
+                              })
+                            : t('slice.processSettingsUnchanged', 'Preset defaults')}
+                      </span>
+                    </button>
+                    {panelOpen && (
+                      <div className="mt-3 border-t border-bambu-dark-tertiary pt-3">
+                        <SlicerSettingsPanel
+                          values={processOverrides}
+                          onChange={(values, serialized) => {
+                            setProcessOverrides(values);
+                            setSerializedProcessOverrides(serialized);
+                          }}
+                          disabled={isEnqueuing || useEmbedded}
+                          // The designer's own deviations (#2622) are shown
+                          // against the options they belong to rather than in
+                          // a list of their own. Only the tick state lives
+                          // here; the values still travel as design_overrides,
+                          // so the backend keeps reading them from the file
+                          // and keys outside the vendored schema stay faithful.
+                          filamentChoices={filamentChoices}
+                          presetValues={presetValues}
+                          presetValuesResolved={presetValuesResolved}
+                          presetValuesReason={presetValuesReason}
+                          sourceOverrides={designOverrides}
+                          sourceSelected={designKeys}
+                          onToggleSource={(key, on) =>
+                            setDesignKeys((prev) => {
+                              const next = new Set(prev);
+                              if (on) next.add(key);
+                              else next.delete(key);
+                              return next;
+                            })
+                          }
+                        />
+                      </div>
+                    )}
+                  </div>
+                </div>
+              </div>
             </>
           )}
 
@@ -1179,8 +1261,8 @@ interface PresetDropdownProps {
   // configuring against the source 3MF's per-slot colour.
   swatchColor?: string;
   // Selected printer context (#1325). When provided for a process / filament
-  // slot, presets that resolve to a different printer (per compatIndex) move
-  // into a trailing "Other printers" group instead of the main tier list.
+  // slot, presets that resolve to a different printer (per compatIndex) are
+  // held back behind a "Show all" link instead of padding out the main list.
   selectedPrinterName?: string | null;
   compatIndex?: PrinterCompatibilityIndex;
 }
@@ -1197,6 +1279,14 @@ function PresetDropdown({
   compatIndex,
 }: PresetDropdownProps) {
   const { t } = useTranslation();
+  // Reveals the other-printer group for this slot only. Per-dropdown rather
+  // than modal-wide: wanting a filament from another printer's library says
+  // nothing about wanting its process profiles too.
+  const [showAll, setShowAll] = useState(false);
+  // Binds the label to the select now that they are siblings rather than
+  // nested. Filament slots render several of these, so the id must be unique
+  // per instance rather than derived from the slot name.
+  const selectId = useId();
 
   // Tier sections (imported → cloud → standard), plus — for a process /
   // filament slot with a selected printer — a trailing group of presets that
@@ -1242,12 +1332,30 @@ function PresetDropdown({
     return { sections: compatSections, otherEntries: other };
   }, [data, slot, t, selectedPrinterName, compatIndex]);
 
+  // Other-printer presets are held back by default so the list shows what is
+  // usable on the selected printer. Two things are never hidden: a preset whose
+  // compatibility is merely *unknown* (it never reaches otherEntries), and the
+  // one currently selected — a pipeline or an auto-pick can land on a
+  // cross-printer preset, and dropping it from the options would blank the
+  // select and silently discard the choice.
+  const selectedRefValue = toRefValue(value);
+  const visibleOther = useMemo(() => {
+    if (showAll) return otherEntries;
+    return otherEntries.filter((p) => `${p.source}:${p.id}` === selectedRefValue);
+  }, [showAll, otherEntries, selectedRefValue]);
+
+  const hiddenCount = otherEntries.length - visibleOther.length;
   const totalEntries =
-    sections.reduce((sum, s) => sum + s.entries.length, 0) + otherEntries.length;
+    sections.reduce((sum, s) => sum + s.entries.length, 0) + visibleOther.length;
 
   return (
-    <label className="block">
-      <span className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
+    // A plain wrapper rather than a <label> around everything: the "Show all"
+    // control is a button, and a button inside a label that also wraps the
+    // select inherits the whole label as its accessible name (screen readers
+    // announced it as "Process profile 2 hidden 0.20mm Standard @BBL X1C") as
+    // well as being invalid HTML. The label is bound to the select by id.
+    <div className="block">
+      <div className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
         {swatchColor && (
           <span
             className="inline-block w-3 h-3 rounded-full border border-bambu-dark-tertiary"
@@ -1255,9 +1363,29 @@ function PresetDropdown({
             aria-hidden
           />
         )}
-        <span>{label}</span>
-      </span>
+        <label htmlFor={selectId}>{label}</label>
+        {(hiddenCount > 0 || showAll) && (
+          <span className="ml-auto flex items-center gap-1.5 font-normal">
+            {hiddenCount > 0 && (
+              <span className="text-bambu-gray/60">
+                {t('slice.presetsHidden', '{{count}} hidden', { count: hiddenCount })}
+              </span>
+            )}
+            <button
+              type="button"
+              onClick={() => setShowAll((v) => !v)}
+              disabled={disabled}
+              className="text-bambu-green hover:underline disabled:opacity-50 disabled:no-underline"
+            >
+              {showAll
+                ? t('slice.showFewerPresets', 'Show fewer')
+                : t('slice.showAllPresets', 'Show all')}
+            </button>
+          </span>
+        )}
+      </div>
       <select
+        id={selectId}
         value={toRefValue(value)}
         onChange={(e) => onChange(fromRefValue(e.target.value))}
         disabled={disabled || totalEntries === 0}
@@ -1277,9 +1405,9 @@ function PresetDropdown({
             ))}
           </optgroup>
         ))}
-        {otherEntries.length > 0 && (
+        {visibleOther.length > 0 && (
           <optgroup label={t('slice.otherPrinters')}>
-            {otherEntries.map((p) => (
+            {visibleOther.map((p) => (
               <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
                 {p.name}
               </option>
@@ -1287,6 +1415,6 @@ function PresetDropdown({
           </optgroup>
         )}
       </select>
-    </label>
+    </div>
   );
 }

+ 672 - 0
frontend/src/components/SlicerSettingsPanel.tsx

@@ -0,0 +1,672 @@
+/**
+ * Process-settings editor mirroring OrcaSlicer's own Print Settings tabs.
+ *
+ * Structure, labels, tooltips, bounds, defaults and enable/disable rules all
+ * come from metadata extracted from OrcaSlicer's C++ sources (see
+ * `src/data/slicer/`), so the pages, groups and ordering match what users see
+ * in the desktop slicer rather than a hand-picked subset.
+ *
+ * Option labels and tooltips are deliberately English-only for now: they are
+ * 348 strings lifted verbatim from `PrintConfig.cpp`, and hand-translating them
+ * into all 13 locales is not viable. The panel's own chrome — mode switch,
+ * search, buttons, empty states — goes through i18n as usual. OrcaSlicer ships
+ * its own translation catalogs for these strings, which is the obvious source
+ * if they are ever picked up.
+ *
+ * Values are held sparsely: only options the user actually changed are tracked
+ * and sent, so a slice with an untouched panel is byte-identical to one from
+ * before this panel existed.
+ */
+
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Search, RotateCcw, Loader2, ChevronDown } from 'lucide-react';
+
+import { disabledKeys, type ToggleRules } from '../lib/slicerToggle';
+import { baselineForDisplay, displaySidetext, isModified, numericBound, serializeOverrides } from '../lib/slicerSettings';
+import type { OptionMode, ProcessOption, ProcessSchema, ProcessUiTree, SettingValue } from '../types/slicerSettings';
+import type { DesignOverride } from '../types/plates';
+import type { SlicerPresetValuesReason } from '../api/client';
+
+interface SlicerData {
+  schema: ProcessSchema;
+  tree: ProcessUiTree;
+  toggles: ToggleRules;
+}
+
+interface Props {
+  values: Record<string, SettingValue>;
+  /**
+   * Reports both the panel's editing state and the same values serialised for
+   * the slice request. Serialising here rather than in the caller keeps the
+   * option schema — the only thing that knows a percent needs its `%` back —
+   * in the one component that has already loaded it.
+   *
+   * `serialized` carries only options that actually differ from their default,
+   * so an untouched panel sends nothing at all.
+   */
+  onChange: (values: Record<string, SettingValue>, serialized: Record<string, string | string[]>) => void;
+  disabled?: boolean;
+  /**
+   * Process settings the source 3MF's designer moved off the stock preset
+   * (#2622), as recorded by BambuStudio in `different_settings_to_system`.
+   *
+   * These are shown inline against the options they belong to rather than in a
+   * list of their own, so there is one place to see what this slice will use.
+   * Their *values* are not routed through this component: the backend reads
+   * them straight out of the file, which keeps settings faithful even for keys
+   * outside the option schema we vendor. All this panel decides is which of
+   * them are switched on.
+   */
+  sourceOverrides?: DesignOverride[];
+  /** Which source-override keys are currently switched on. */
+  sourceSelected?: Set<string>;
+  onToggleSource?: (key: string, on: boolean) => void;
+  /**
+   * The filaments picked on the slice dialog's left-hand side, in slot order.
+   *
+   * A handful of options select *which filament* prints a given feature —
+   * supports, outer walls, infill. The slicer stores those as a plain integer
+   * where 0 means "whatever filament the region already uses" and 1..N is a
+   * slot. A bare number field makes the user count their own AMS slots, so
+   * when this is supplied those options become a dropdown of the actual
+   * picks instead.
+   */
+  filamentChoices?: FilamentChoice[];
+  /**
+   * The picked process preset's effective values, flattened by the sidecar.
+   * Used as the baseline an untouched field shows and a revert returns to.
+   * Empty when unavailable, in which case the panel falls back to the option
+   * schema's compiled-in defaults and says the values are indicative.
+   */
+  presetValues?: Record<string, SettingValue>;
+  /** False when the preset's values could not be fetched. */
+  presetValuesResolved?: boolean;
+  /**
+   * Why they could not be fetched, so the notice can name a fix. Left
+   * unset while the fetch is still in flight.
+   */
+  presetValuesReason?: SlicerPresetValuesReason;
+}
+
+export interface FilamentChoice {
+  /** 1-based slot index, matching the integer the slicer stores. */
+  index: number;
+  /** Preset name, or a fallback when the slot has no pick yet. */
+  label: string;
+  /** Slot colour from the source plate, for the swatch. */
+  color?: string;
+}
+
+/**
+ * Options whose integer value names a filament slot rather than a quantity.
+ * All use the same encoding: 0 = "default / current filament", 1..N = slot.
+ * Support base and interface are the pair on the Support page; the rest are
+ * the Multimaterial page's per-region pickers, which have the same wart.
+ */
+const FILAMENT_SLOT_OPTIONS = new Set([
+  'support_filament',
+  'support_interface_filament',
+  'outer_wall_filament_id',
+  'inner_wall_filament_id',
+  'top_surface_filament_id',
+  'bottom_surface_filament_id',
+  'internal_solid_filament_id',
+  'sparse_infill_filament_id',
+]);
+
+/** Visibility tiers, in increasing order of how much they reveal. */
+const MODES: OptionMode[] = ['simple', 'advanced', 'expert'];
+const MODE_RANK: Record<string, number> = { simple: 0, advanced: 1, expert: 2, develop: 3 };
+
+export default function SlicerSettingsPanel({
+  values,
+  onChange,
+  disabled = false,
+  sourceOverrides = [],
+  sourceSelected,
+  onToggleSource,
+  filamentChoices,
+  presetValues,
+  presetValuesResolved = true,
+  presetValuesReason,
+}: Props) {
+  const { t } = useTranslation();
+  const [data, setData] = useState<SlicerData | null>(null);
+  const [mode, setMode] = useState<OptionMode>('simple');
+  const [page, setPage] = useState<string | null>(null);
+  const [query, setQuery] = useState('');
+
+  // 150 KB of extracted metadata has no business in the main bundle — it is
+  // only needed once someone opens this panel.
+  useEffect(() => {
+    let cancelled = false;
+    Promise.all([
+      import('../data/slicer/process-schema.json'),
+      import('../data/slicer/process-ui-tree.json'),
+      import('../data/slicer/process-toggle-rules.json'),
+    ]).then(([schema, tree, toggles]) => {
+      if (cancelled) return;
+      setData({
+        schema: (schema.default ?? schema) as unknown as ProcessSchema,
+        tree: (tree.default ?? tree) as unknown as ProcessUiTree,
+        toggles: (toggles.default ?? toggles) as unknown as ToggleRules,
+      });
+    });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  const off = useMemo(
+    () => (data ? disabledKeys(values, data.schema, data.toggles) : new Set<string>()),
+    [data, values],
+  );
+
+  const sourceByKey = useMemo(
+    () => new Map(sourceOverrides.map((o) => [o.key, o])),
+    [sourceOverrides],
+  );
+
+  // Source overrides for keys the vendored schema doesn't cover. They still
+  // apply — the backend reads their values from the file — so they get a group
+  // of their own rather than being dropped from view.
+  const unlistedSource = useMemo(() => {
+    if (!data) return [];
+    return sourceOverrides.filter((o) => !data.schema[o.key]);
+  }, [data, sourceOverrides]);
+
+  const emit = (next: Record<string, SettingValue>) => {
+    if (!data) return;
+    // Only genuine deviations are worth sending: an override that equals the
+    // preset's own value is noise in the process JSON and makes the slice
+    // request harder to read when something goes wrong.
+    const changed: Record<string, SettingValue> = {};
+    for (const [k, v] of Object.entries(next)) {
+      if (data.schema[k] && isModified(data.schema[k], v, presetValues?.[k])) changed[k] = v;
+    }
+    onChange(next, serializeOverrides(changed, data.schema));
+  };
+
+  const setValue = (key: string, value: SettingValue | undefined) => {
+    const next = { ...values };
+    if (value === undefined) delete next[key];
+    else next[key] = value;
+    emit(next);
+  };
+
+  // Search cuts across every page; without a query we show the selected page.
+  const visiblePages = useMemo(() => {
+    if (!data) return [];
+    // Underscores and spaces are interchangeable so "outer wall speed" finds
+    // `outer_wall_speed`. That matters more than it looks: several labels are
+    // only meaningful with their group ("Outer wall" under Speed), so the key
+    // is often the only place the full phrase appears.
+    const flatten = (s: string) => s.toLowerCase().replace(/[_\s]+/g, ' ').trim();
+    const needle = flatten(query);
+    const withinMode = (key: string) => MODE_RANK[data.schema[key]?.mode ?? 'expert'] <= MODE_RANK[mode];
+    const matches = (key: string, group: string, page: string) => {
+      if (!needle) return true;
+      const opt = data.schema[key];
+      // Group and page are matched too, so "speed" lists the Speed page's
+      // options rather than only the handful with "speed" in their label.
+      const haystack = [key, opt?.label ?? '', opt?.tooltip ?? '', group, page];
+      return haystack.some((h) => flatten(h).includes(needle));
+    };
+
+    return data.tree
+      .map((p) => ({
+        ...p,
+        groups: p.groups
+          .map((g) => ({
+            ...g,
+            options: g.options.filter((k) => withinMode(k) && matches(k, g.group, p.page)),
+          }))
+          .filter((g) => g.options.length > 0),
+      }))
+      .filter((p) => p.groups.length > 0);
+  }, [data, mode, query]);
+
+  const activePage = useMemo(() => {
+    if (visiblePages.length === 0) return null;
+    if (query.trim()) return null; // Searching shows every match, not one page.
+    return visiblePages.find((p) => p.page === page) ?? visiblePages[0];
+  }, [visiblePages, page, query]);
+
+  const modifiedCount = useMemo(() => {
+    if (!data) return 0;
+    return Object.keys(values).filter((k) => data.schema[k] && isModified(data.schema[k], values[k], presetValues?.[k])).length;
+  }, [data, values, presetValues]);
+
+  if (!data) {
+    return (
+      <div className="flex items-center justify-center gap-2 py-8 text-sm text-bambu-gray">
+        <Loader2 className="w-4 h-4 animate-spin" />
+        {t('slicerSettings.loading', 'Loading slicer settings...')}
+      </div>
+    );
+  }
+
+  const shownPages = activePage ? [activePage] : visiblePages;
+
+  return (
+    <div className="flex flex-col gap-3">
+      <div className="flex flex-wrap items-center gap-2">
+        <div className="flex overflow-hidden rounded border border-bambu-dark-tertiary">
+          {MODES.map((m) => (
+            <button
+              key={m}
+              type="button"
+              onClick={() => setMode(m)}
+              disabled={disabled}
+              className={`px-2.5 py-1 text-xs capitalize transition-colors ${
+                mode === m ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
+              }`}
+            >
+              {t(`slicerSettings.mode.${m}`, m)}
+            </button>
+          ))}
+        </div>
+
+        <div className="relative flex-1 min-w-[10rem]">
+          <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray" />
+          <input
+            type="search"
+            value={query}
+            onChange={(e) => setQuery(e.target.value)}
+            disabled={disabled}
+            placeholder={t('slicerSettings.searchPlaceholder', 'Search settings')}
+            className="w-full rounded border border-bambu-dark-tertiary bg-bambu-dark pl-7 pr-2 py-1 text-xs text-white placeholder:text-bambu-gray/60 focus:border-bambu-green focus:outline-none disabled:opacity-40"
+          />
+        </div>
+
+        {modifiedCount > 0 && (
+          <button
+            type="button"
+            onClick={() => emit({})}
+            disabled={disabled}
+            className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white"
+          >
+            <RotateCcw className="w-3 h-3" />
+            {t('slicerSettings.resetAll', 'Reset {{count}}', { count: modifiedCount })}
+          </button>
+        )}
+      </div>
+
+      {!presetValuesResolved && (
+        <p className="rounded border border-amber-300 bg-amber-50 px-2 py-1 text-[0.7rem] text-amber-800 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
+          {presetValuesReason === 'sidecar_outdated'
+            ? t(
+                'slicerSettings.presetValuesOutdatedSidecar',
+                "Showing slicer defaults: your slicer sidecar is older than this feature and can't report a preset's values. Update the sidecar image to see them. Anything you don't change still uses the preset.",
+              )
+            : presetValuesReason === 'not_configured'
+              ? t(
+                  'slicerSettings.presetValuesNotConfigured',
+                  "Showing slicer defaults: no slicer sidecar is configured, so a preset's values can't be read. Anything you don't change still uses the preset.",
+                )
+              : presetValuesReason === 'sidecar_unavailable'
+                ? t(
+                    'slicerSettings.presetValuesSidecarUnavailable',
+                    "Showing slicer defaults: the slicer sidecar did not answer, so a preset's values can't be read. Anything you don't change still uses the preset.",
+                  )
+                : t(
+                    'slicerSettings.presetValuesUnavailable',
+                    "Showing slicer defaults: the picked preset's own values could not be read. Anything you don't change still uses the preset.",
+                  )}
+        </p>
+      )}
+
+      {!query.trim() && (
+        <div className="flex flex-wrap gap-1">
+          {visiblePages.map((p) => (
+            <button
+              key={p.page}
+              type="button"
+              onClick={() => setPage(p.page)}
+              disabled={disabled}
+              className={`px-2 py-1 text-xs rounded transition-colors ${
+                activePage?.page === p.page ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
+              }`}
+            >
+              {p.page}
+            </button>
+          ))}
+        </div>
+      )}
+
+      {shownPages.length === 0 ? (
+        <p className="py-6 text-center text-xs text-bambu-gray">
+          {t('slicerSettings.noMatches', 'No settings match this search.')}
+        </p>
+      ) : (
+        // Taller once the panel has a column of its own; the narrow cap keeps
+        // it from swallowing the single-column stack on small screens.
+        <div className="flex flex-col gap-4 max-h-[22rem] lg:max-h-[58vh] overflow-y-auto pr-1">
+          {shownPages.map((p) => (
+            <div key={p.page} className="flex flex-col gap-3">
+              {query.trim() && <p className="text-[0.7rem] uppercase tracking-wide text-bambu-gray/70">{p.page}</p>}
+              {p.groups.map((g) => (
+                <fieldset key={`${p.page}:${g.group}`} className="flex flex-col gap-1.5">
+                  <legend className="mb-1 text-xs font-medium text-white">{g.group}</legend>
+                  {g.options.map((key) => (
+                    <OptionRow
+                      key={key}
+                      optionKey={key}
+                      option={data.schema[key]}
+                      value={values[key]}
+                      onChange={(v) => setValue(key, v)}
+                      disabled={disabled || off.has(key)}
+                      disabledBySlicer={off.has(key)}
+                      source={sourceByKey.get(key)}
+                      sourceOn={sourceSelected?.has(key) ?? false}
+                      onToggleSource={onToggleSource}
+                      filamentChoices={FILAMENT_SLOT_OPTIONS.has(key) ? filamentChoices : undefined}
+                      presetValue={presetValues?.[key]}
+                    />
+                  ))}
+                </fieldset>
+              ))}
+            </div>
+          ))}
+
+          {/* Source-file settings the vendored schema has no entry for: they
+              still apply (the backend reads their values from the file), so
+              they get a plain key/value group rather than disappearing from a
+              panel that claims to show what this slice will use. */}
+          {unlistedSource.length > 0 && !query.trim() && (
+            <fieldset className="flex flex-col gap-1.5">
+              <legend className="mb-1 text-xs font-medium text-white">
+                {t('slicerSettings.otherFromFile', 'Other settings from this file')}
+              </legend>
+              {unlistedSource.map((o) => (
+                <label key={o.key} className="flex items-center gap-2 text-xs cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={sourceSelected?.has(o.key) ?? false}
+                    disabled={disabled || !onToggleSource}
+                    onChange={(e) => onToggleSource?.(o.key, e.target.checked)}
+                    className="shrink-0 cursor-pointer disabled:opacity-40"
+                  />
+                  <span className="min-w-0 flex-1 truncate">
+                    <span className="font-mono text-bambu-gray">{o.key}</span>
+                    <span className="ml-1.5 text-white">{formatSourceValue(o.value)}</span>
+                  </span>
+                  {o.printer_coupled && (
+                    <span className="shrink-0 rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/20 dark:text-amber-400">
+                      {t('slicerSettings.fromFilePrinterCoupled', "designer's printer")}
+                    </span>
+                  )}
+                </label>
+              ))}
+            </fieldset>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+interface RowProps {
+  optionKey: string;
+  option: ProcessOption;
+  value: SettingValue | undefined;
+  onChange: (value: SettingValue | undefined) => void;
+  disabled: boolean;
+  /** Greyed because the slicer's own rules turn it off, not because the form is busy. */
+  disabledBySlicer: boolean;
+  /** Set when the source file's designer moved this option off the stock preset. */
+  source?: DesignOverride;
+  sourceOn?: boolean;
+  onToggleSource?: (key: string, on: boolean) => void;
+  /** Set only for options whose integer value names a filament slot. */
+  filamentChoices?: FilamentChoice[];
+  /** The picked preset's value for this option, when known. */
+  presetValue?: SettingValue;
+}
+
+function OptionRow({
+  optionKey,
+  option,
+  value,
+  onChange,
+  disabled,
+  disabledBySlicer,
+  source,
+  sourceOn = false,
+  onToggleSource,
+  filamentChoices,
+  presetValue,
+}: RowProps) {
+  const { t } = useTranslation();
+  const modified = isModified(option, value, presetValue);
+  const unit = displaySidetext(option);
+  // What this slice will actually use, in precedence order: a value typed here
+  // wins, then the designer's value if it is switched on, then the preset's own
+  // (or the schema default when the preset's values are unavailable).
+  const current =
+    value !== undefined
+      ? String(value)
+      : sourceOn && source
+        ? formatSourceValue(source.value)
+        : baselineForDisplay(option, presetValue);
+
+  return (
+    <div className="flex items-center gap-2 group" title={option.tooltip}>
+      {/* Label takes the slack; the control group is a fixed width anchored to
+          the right edge. Fixed widths on the control *and* the unit are what
+          keep that column straight — sizing either to content makes each row's
+          input land at a different x. */}
+      <label
+        htmlFor={`slicer-opt-${optionKey}`}
+        className={`flex min-w-0 flex-1 items-center gap-1 text-xs ${disabledBySlicer ? 'text-bambu-gray/40' : 'text-bambu-gray'}`}
+      >
+        {/* Own title: a fixed column truncates more than the old flex-1 label
+            did, and the row's title carries the tooltip, not the name. */}
+        <span className="truncate" title={option.label || optionKey}>
+          {option.label || optionKey}
+        </span>
+        {modified && <span className="shrink-0 text-bambu-green" aria-hidden="true">•</span>}
+        {source && (
+          <span
+            className={`shrink-0 rounded px-1 py-0.5 text-[10px] ${
+              source.printer_coupled
+                ? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
+                : 'bg-bambu-green/15 text-bambu-green'
+            }`}
+            title={
+              source.printer_coupled
+                ? t('slicerSettings.fromFilePrinterCoupledHint', "Tuned for the printer this file was designed for -- may be wrong or out of range on yours.")
+                : t('slicerSettings.fromFileHint', "The designer changed this in the source file. Its value is {{value}}.", { value: formatSourceValue(source.value) })
+            }
+          >
+            {source.printer_coupled
+              ? t('slicerSettings.fromFilePrinterCoupled', "designer's printer")
+              : t('slicerSettings.fromFile', 'from file')}
+          </span>
+        )}
+      </label>
+
+      <div className="flex shrink-0 items-center gap-1.5">
+        {/* The "use the file's value" tick comes *before* the control it
+            qualifies, as a checkbox that gates a field conventionally does —
+            it used to sit past the unit, out at the right edge, reading as
+            unrelated to the field. The slot is reserved on every row so rows
+            with and without a source override keep the control column
+            straight. */}
+        <span className="flex w-3 shrink-0 justify-center">
+          {source && onToggleSource && (
+            <input
+              type="checkbox"
+              checked={sourceOn}
+              disabled={disabled}
+              onChange={(e) => onToggleSource(optionKey, e.target.checked)}
+              aria-label={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
+                option: option.label || optionKey,
+              })}
+              title={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
+                option: option.label || optionKey,
+              })}
+              className="w-3 h-3 cursor-pointer disabled:opacity-40"
+            />
+          )}
+        </span>
+        <div className="w-40">
+          <OptionControl
+            id={`slicer-opt-${optionKey}`}
+            option={option}
+            current={current}
+            onChange={onChange}
+            disabled={disabled}
+            filamentChoices={filamentChoices}
+          />
+        </div>
+        {/* Fixed width so the control column stays straight, but wide enough
+            for the longest unit in the schema ("mm/s² or %") — a narrower cap
+            truncated those to "mm o...". Rendered even when empty so rows
+            without a unit keep the revert button aligned. */}
+        <span className="w-16 shrink-0 whitespace-nowrap text-[0.65rem] text-bambu-gray/60">{unit ?? ''}</span>
+        <button
+          type="button"
+          onClick={() => onChange(undefined)}
+          disabled={disabled || !modified}
+          aria-label={t('slicerSettings.resetOption', 'Reset to default')}
+          className={`p-0.5 transition-opacity ${modified ? 'text-bambu-gray hover:text-white' : 'opacity-0 pointer-events-none'}`}
+        >
+          <RotateCcw className="w-3 h-3" />
+        </button>
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Render a value read out of the source file. Bambu's process config stores
+ * everything as strings or arrays of strings, so this only has to flatten
+ * arrays — no unit or type interpretation, which would rot against every
+ * slicer release.
+ */
+function formatSourceValue(value: unknown): string {
+  if (Array.isArray(value)) return value.map((v) => String(v)).join(', ');
+  if (value == null) return '';
+  return String(value);
+}
+
+interface ControlProps {
+  id: string;
+  option: ProcessOption;
+  current: string;
+  onChange: (value: SettingValue | undefined) => void;
+  disabled: boolean;
+  filamentChoices?: FilamentChoice[];
+}
+
+function OptionControl({ id, option, current, onChange, disabled, filamentChoices }: ControlProps) {
+  const { t } = useTranslation();
+  // Theme tokens rather than raw black/white: bambu-dark and
+  // bambu-dark-tertiary are CSS variables that follow the active theme, and
+  // `text-white` is remapped to --text-primary in index.css.
+  const inputClass =
+    'w-full rounded border border-bambu-dark-tertiary bg-bambu-dark px-1.5 py-0.5 text-xs text-white focus:border-bambu-green focus:outline-none disabled:opacity-40';
+
+  // Filament-slot pickers come before the generic branches: the value is an
+  // integer, but offering a spinner over "1, 2, 3" makes the user map slot
+  // numbers to their own AMS by hand.
+  if (filamentChoices && filamentChoices.length > 0) {
+    const selected = filamentChoices.find((c) => String(c.index) === current);
+    return (
+      <div className="relative w-full">
+        <select
+          id={id}
+          value={current}
+          onChange={(e) => onChange(e.target.value)}
+          disabled={disabled}
+          className={`${inputClass} w-full cursor-pointer appearance-none pr-5`}
+          // The full name rarely fits in the control, so the hover carries it.
+          title={selected?.label}
+        >
+          {/* 0 is the slicer's "no specific filament — use the region's own". */}
+          <option value="0">{t('slicerSettings.filamentDefault', 'Default')}</option>
+          {filamentChoices.map((choice) => (
+            <option key={choice.index} value={String(choice.index)}>
+              {choice.index}: {choice.label}
+            </option>
+          ))}
+        </select>
+        <ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-bambu-gray" />
+      </div>
+    );
+  }
+
+  if (option.type === 'coBool') {
+    return (
+      <input
+        id={id}
+        type="checkbox"
+        checked={current === '1' || current === 'true'}
+        onChange={(e) => onChange(e.target.checked)}
+        disabled={disabled}
+        className="w-3.5 h-3.5 cursor-pointer disabled:opacity-40"
+      />
+    );
+  }
+
+  if (option.type === 'coEnum' && option.enum_values) {
+    // Native select chrome is replaced the same way as everywhere else in
+    // Bambuddy: appearance-none plus our own chevron, so the control matches
+    // the app in both themes instead of whatever the browser paints.
+    return (
+      <div className="relative w-full">
+        <select
+          id={id}
+          value={current}
+          onChange={(e) => onChange(e.target.value)}
+          disabled={disabled}
+          className={`${inputClass} w-full cursor-pointer appearance-none pr-5`}
+        >
+          {option.enum_values.map((v, i) => (
+            <option key={v} value={v}>
+              {option.enum_labels?.[i] ?? v}
+            </option>
+          ))}
+        </select>
+        <ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-bambu-gray" />
+      </div>
+    );
+  }
+
+  if (option.type === 'coInt' || option.type === 'coFloat' || option.type === 'coPercent') {
+    return (
+      <input
+        id={id}
+        type="number"
+        value={current.replace('%', '')}
+        min={numericBound(option.min)}
+        max={numericBound(option.max)}
+        step={option.type === 'coInt' ? 1 : 'any'}
+        // An empty field is kept as an empty string rather than dropped.
+        // Dropping it would fall the input straight back to the default, so
+        // clearing a value to retype it would silently append to the old one.
+        // Empty never counts as modified, so nothing is sent for it either way;
+        // the revert button is what actually removes the key.
+        onChange={(e) => onChange(e.target.value)}
+        disabled={disabled}
+        className={inputClass}
+      />
+    );
+  }
+
+  // coFloatOrPercent, the vector types and coString all accept free text: they
+  // hold values like "50%", "0.42" or a comma-separated per-extruder list, none
+  // of which a number input can represent.
+  return (
+    <input
+      id={id}
+      type="text"
+      value={current}
+      onChange={(e) => onChange(e.target.value)}
+      disabled={disabled}
+      className={inputClass}
+    />
+  );
+}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
frontend/src/data/slicer/process-schema.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
frontend/src/data/slicer/process-toggle-rules.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
frontend/src/data/slicer/process-ui-tree.json


+ 37 - 0
frontend/src/hooks/useIsWideLayout.ts

@@ -0,0 +1,37 @@
+import { useState, useEffect } from 'react';
+
+/**
+ * Tailwind's `lg`. Kept in sync with the `lg:` classes it is paired with —
+ * components using this hook usually also switch layout via `lg:` utilities,
+ * and the two disagreeing produces a half-applied layout.
+ */
+const WIDE_LAYOUT_BREAKPOINT = 1024;
+
+/**
+ * True when there is room for a side-by-side layout.
+ *
+ * Prefer plain `lg:` classes where CSS alone can do the job. This exists for
+ * the cases where the *behaviour* differs rather than only the styling — a
+ * disclosure that collapses on narrow screens but is permanently open when it
+ * has its own column, for instance, which CSS cannot express on its own.
+ */
+export function useIsWideLayout(): boolean {
+  const [isWide, setIsWide] = useState(() =>
+    typeof window !== 'undefined' ? window.innerWidth >= WIDE_LAYOUT_BREAKPOINT : false
+  );
+
+  useEffect(() => {
+    const mediaQuery = window.matchMedia(`(min-width: ${WIDE_LAYOUT_BREAKPOINT}px)`);
+
+    const handleChange = (e: MediaQueryListEvent) => {
+      setIsWide(e.matches);
+    };
+
+    setIsWide(mediaQuery.matches);
+
+    mediaQuery.addEventListener('change', handleChange);
+    return () => mediaQuery.removeEventListener('change', handleChange);
+  }, []);
+
+  return isWide;
+}

+ 62 - 13
frontend/src/i18n/locales/de.ts

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Slice-Engine',
+    sliceEngineSidecar: 'Server-Sidecar',
+    sliceEngineSidecarHint: 'Das Slicing läuft auf dem Server im Slicer-Sidecar-Container.',
+    sliceEngineBrowser: 'Im Browser',
+    sliceEngineBrowserHint: 'Das Slicing läuft auf diesem Gerät, ohne Server.',
     title: 'Einstellungen',
     general: 'Allgemein',
     // Tab names
@@ -4279,7 +4284,40 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    presetValuesOutdatedSidecar: 'Es werden Slicer-Standardwerte angezeigt: Ihr Slicer-Sidecar ist älter als diese Funktion und kann die Werte eines Profils nicht liefern. Aktualisieren Sie das Sidecar-Image, um sie zu sehen. Alles, was Sie nicht ändern, verwendet weiterhin das Profil.',
+    presetValuesNotConfigured: 'Es werden Slicer-Standardwerte angezeigt: Es ist kein Slicer-Sidecar konfiguriert, daher können die Werte eines Profils nicht gelesen werden. Alles, was Sie nicht ändern, verwendet weiterhin das Profil.',
+    presetValuesSidecarUnavailable: 'Es werden Slicer-Standardwerte angezeigt: Das Slicer-Sidecar hat nicht geantwortet, daher können die Werte eines Profils nicht gelesen werden. Alles, was Sie nicht ändern, verwendet weiterhin das Profil.',
+    presetValuesUnavailable: 'Es werden Slicer-Standardwerte angezeigt: Die Werte des gewählten Profils konnten nicht gelesen werden. Alles, was Sie nicht ändern, verwendet weiterhin das Profil.',
+    filamentDefault: 'Standard',
+    fromFile: 'aus Datei',
+    fromFileHint: 'Der Designer hat dies in der Quelldatei geändert. Wert: {{value}}.',
+    fromFilePrinterCoupled: 'Drucker des Designers',
+    fromFilePrinterCoupledHint: 'Auf den Drucker abgestimmt, für den diese Datei erstellt wurde – auf Ihrem kann der Wert falsch oder außerhalb des Bereichs sein.',
+    useFromFile: 'Wert aus der Quelldatei für {{option}} verwenden',
+    otherFromFile: 'Weitere Einstellungen aus dieser Datei',
+    loading: 'Slicer-Einstellungen werden geladen…',
+    mode: {
+      simple: 'Einfach',
+      advanced: 'Erweitert',
+      expert: 'Experte',
+    },
+    searchPlaceholder: 'Einstellungen suchen',
+    resetAll: '{{count}} zurücksetzen',
+    resetOption: 'Auf Standard zurücksetzen',
+    noMatches: 'Keine Einstellungen passen zu dieser Suche.',
+  },
   slice: {
+    filamentSlotUnset: 'nicht gewählt',
+    processSettingsEmbedded: 'Wird nicht verwendet, solange „Integrierte Einstellungen der Datei verwenden“ aktiv ist – die Einstellungen der Datei bestimmen diesen Slice.',
+    processSettingsInactive: 'Inaktiv',
+    presetsHidden: '{{count}} ausgeblendet',
+    showAllPresets: 'Alle anzeigen',
+    showFewerPresets: 'Weniger anzeigen',
+    processSettings: 'Prozesseinstellungen',
+    processSettingsHint: 'Passen Sie das gewählte Profil für diesen Slice an. Alles, was Sie nicht ändern, bleibt wie im Profil definiert.',
+    processSettingsChanged: '{{count}} geändert',
+    processSettingsUnchanged: 'Profilstandard',
     title: 'Modell slicen',
     action: 'Slicen',
     actionAll: 'Alle {{count}} Plates slicen',
@@ -4309,11 +4347,6 @@ export default {
     autoOrientHint: 'Der Slicer dreht jedes Objekt zuerst auf die am besten druckbare Seite. Überschreibt die Ausrichtung aus der Datei.',
     autoArrange: 'Automatisch auf dem Druckbett anordnen',
     autoArrangeHint: 'Der Slicer verteilt die Objekte so, dass sie sich nicht mehr überlappen. Ersetzt die Anordnung aus der Datei.',
-    designSettings: 'Einstellungen des Erstellers behalten',
-    designSettingsHint: 'Diese Datei ändert {{count}} Druckeinstellung(en) gegenüber dem Standardprofil.',
-    designSettingsSelected: '{{selected}} von {{total}} ausgewählt',
-    designSettingsPrinterCoupled: 'druckerspezifisch',
-    designSettingsPrinterCoupledHint: 'Auf den Drucker abgestimmt, für den die Datei erstellt wurde — auf deinem kann der Wert falsch oder unzulässig sein.',
     enqueuing: 'Slice-Auftrag wird übermittelt…',
     queued: 'In Warteschlange…',
     failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
@@ -5570,10 +5603,8 @@ export default {
     openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
     tabs: {
       model: '3D-Modell',
-      gcode: 'G-Code Vorschau',
     },
     notAvailable: 'nicht verfügbar',
-    notSliced: 'nicht geslicet',
     plates: 'Platten',
     allPlates: 'Alle Platten',
     plateNumber: 'Platte {{number}}',
@@ -7024,12 +7055,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: '3D-Vorschau konnte nicht eingebettet werden',
-    blockedBody: 'Bambuddy erlaubt dieser Seite, den G-Code-Viewer eingebettet anzuzeigen, aber etwas zwischen Ihrem Browser und Bambuddy verweigert das — meist ein Reverse-Proxy oder eine Sicherheitserweiterung, die einen eigenen Frame-Header sendet. Das Öffnen des Viewers in einem eigenen Tab ist davon nicht betroffen.',
-    unavailableTitle: '3D-Vorschau nicht verfügbar',
-    unavailableBody: 'Bambuddy konnte die Dateien des G-Code-Viewers nicht ausliefern. Normalerweise fehlt dann das Verzeichnis gcode_viewer in der Installation; das Startprotokoll weist ebenfalls darauf hin.',
-    problemDetail: 'Meldung des Servers: {{detail}}',
-    openInNewTab: 'Viewer in neuem Tab öffnen',
+    filamentSlot: 'Filament {{n}}',
+    loading: 'Werkzeugweg wird gelesen…',
+    loadFailed: 'Der G-Code für diese Datei konnte nicht geladen werden.',
+    showTravel: 'Leerfahrten',
+    topLayer: 'Oberste Schicht',
+    bottomLayer: 'Unterste Schicht',
+    noSource: 'Es wurde keine Datei zur Vorschau angegeben.',
+    view: {
+      filament: 'Filament',
+      feature: 'Merkmal',
+      height: 'Höhe',
+      width: 'Breite',
+    },
+    feature: {
+      wall: 'Wände',
+      sparseInfill: 'Spärliche Füllung',
+      solidInfill: 'Massive Füllung',
+      bridge: 'Brücke / Überhang',
+      support: 'Stützen',
+      skirt: 'Skirt / Brim',
+      gapFill: 'Lückenfüllung',
+      ironing: 'Bügeln',
+      primeTower: 'Reinigungsturm',
+    },
     back: 'Zurück',
     backToArchives: 'Zurück zum Druckarchiv',
     backToFiles: 'Zurück zum Dateimanager',

+ 62 - 13
frontend/src/i18n/locales/en.ts

@@ -1807,6 +1807,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Slice engine',
+    sliceEngineSidecar: 'Server sidecar',
+    sliceEngineSidecarHint: 'Slicing runs on the server, in the slicer sidecar container.',
+    sliceEngineBrowser: 'In browser',
+    sliceEngineBrowserHint: 'Slicing runs on this device, with no server involved.',
     title: 'Settings',
     general: 'General',
     // Tab names
@@ -4313,7 +4318,40 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    presetValuesOutdatedSidecar: "Showing slicer defaults: your slicer sidecar is older than this feature and can't report a preset's values. Update the sidecar image to see them. Anything you don't change still uses the preset.",
+    presetValuesNotConfigured: "Showing slicer defaults: no slicer sidecar is configured, so a preset's values can't be read. Anything you don't change still uses the preset.",
+    presetValuesSidecarUnavailable: "Showing slicer defaults: the slicer sidecar did not answer, so a preset's values can't be read. Anything you don't change still uses the preset.",
+    presetValuesUnavailable: "Showing slicer defaults: the picked preset's own values could not be read. Anything you don't change still uses the preset.",
+    filamentDefault: 'Default',
+    fromFile: 'from file',
+    fromFileHint: 'The designer changed this in the source file. Its value is {{value}}.',
+    fromFilePrinterCoupled: "designer's printer",
+    fromFilePrinterCoupledHint: 'Tuned for the printer this file was designed for -- may be wrong or out of range on yours.',
+    useFromFile: "Use the source file's value for {{option}}",
+    otherFromFile: 'Other settings from this file',
+    loading: 'Loading slicer settings…',
+    mode: {
+      simple: 'Simple',
+      advanced: 'Advanced',
+      expert: 'Expert',
+    },
+    searchPlaceholder: 'Search settings',
+    resetAll: 'Reset {{count}}',
+    resetOption: 'Reset to default',
+    noMatches: 'No settings match this search.',
+  },
   slice: {
+    filamentSlotUnset: 'not set',
+    processSettingsEmbedded: 'Not used while "Use the file\'s built-in settings" is on -- the file\'s own settings drive this slice.',
+    processSettingsInactive: 'Inactive',
+    presetsHidden: '{{count}} hidden',
+    showAllPresets: 'Show all',
+    showFewerPresets: 'Show fewer',
+    processSettings: 'Process settings',
+    processSettingsHint: "Adjust the picked preset for this slice. Anything you don't touch stays as the preset defines it.",
+    processSettingsChanged: '{{count}} changed',
+    processSettingsUnchanged: 'Preset defaults',
     title: 'Slice model',
     action: 'Slice',
     actionAll: 'Slice all {{count}} plates',
@@ -4343,11 +4381,6 @@ export default {
     autoOrientHint: 'Let the slicer turn each object onto its best printing side first. Overrides the way the model was laid down in the file.',
     autoArrange: 'Auto-arrange on the plate',
     autoArrangeHint: 'Let the slicer position the objects so they no longer overlap. Replaces the layout the file came with.',
-    designSettings: 'Keep the designer\'s settings',
-    designSettingsHint: 'This file changes {{count}} print setting(s) from the stock profile.',
-    designSettingsSelected: '{{selected}} of {{total}} selected',
-    designSettingsPrinterCoupled: 'printer-specific',
-    designSettingsPrinterCoupledHint: 'Tuned for the printer this file was designed for — may be wrong or out of range on yours.',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
@@ -5619,10 +5652,8 @@ export default {
     openInSlicerFailed: 'Could not open in slicer',
     tabs: {
       model: '3D Model',
-      gcode: 'G-code Preview',
     },
     notAvailable: 'not available',
-    notSliced: 'not sliced',
     plates: 'Plates',
     allPlates: 'All Plates',
     plateNumber: 'Plate {{number}}',
@@ -7073,12 +7104,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: 'The 3D preview could not be embedded',
-    blockedBody: 'Bambuddy allows this page to show the G-code viewer inline, but something between your browser and Bambuddy is refusing it — usually a reverse proxy or a security add-on sending its own framing header. Opening the viewer in its own tab is not affected.',
-    unavailableTitle: 'The 3D preview is unavailable',
-    unavailableBody: 'Bambuddy could not serve the G-code viewer\'s files. This normally means the gcode_viewer directory is missing from the installation; the startup log says so too.',
-    problemDetail: 'Reported by the server: {{detail}}',
-    openInNewTab: 'Open the viewer in a new tab',
+    filamentSlot: 'Filament {{n}}',
+    loading: 'Reading toolpath…',
+    loadFailed: 'Could not load the G-code for this file.',
+    showTravel: 'Travel moves',
+    topLayer: 'Top layer',
+    bottomLayer: 'Bottom layer',
+    noSource: 'No file was given to preview.',
+    view: {
+      filament: 'Filament',
+      feature: 'Feature',
+      height: 'Height',
+      width: 'Width',
+    },
+    feature: {
+      wall: 'Walls',
+      sparseInfill: 'Sparse infill',
+      solidInfill: 'Solid infill',
+      bridge: 'Bridge / overhang',
+      support: 'Support',
+      skirt: 'Skirt / brim',
+      gapFill: 'Gap fill',
+      ironing: 'Ironing',
+      primeTower: 'Prime tower',
+    },
     back: 'Back',
     backToArchives: 'Back to Print Archives',
     backToFiles: 'Back to File Manager',

+ 62 - 13
frontend/src/i18n/locales/es.ts

@@ -1791,6 +1791,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Motor de laminado',
+    sliceEngineSidecar: 'Sidecar del servidor',
+    sliceEngineSidecarHint: 'El laminado se ejecuta en el servidor, en el contenedor sidecar del laminador.',
+    sliceEngineBrowser: 'En el navegador',
+    sliceEngineBrowserHint: 'El laminado se ejecuta en este dispositivo, sin servidor.',
     title: 'Ajustes',
     general: 'General',
     // Tab names
@@ -4281,7 +4286,40 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    presetValuesOutdatedSidecar: 'Se muestran los valores predeterminados del laminador: tu sidecar es más antiguo que esta función y no puede informar los valores de un perfil. Actualiza la imagen del sidecar para verlos. Todo lo que no cambies seguirá usando el perfil.',
+    presetValuesNotConfigured: 'Se muestran los valores predeterminados del laminador: no hay ningún sidecar configurado, así que no se pueden leer los valores de un perfil. Todo lo que no cambies seguirá usando el perfil.',
+    presetValuesSidecarUnavailable: 'Se muestran los valores predeterminados del laminador: el sidecar no respondió, así que no se pueden leer los valores de un perfil. Todo lo que no cambies seguirá usando el perfil.',
+    presetValuesUnavailable: 'Se muestran los valores predeterminados del laminador: no se pudieron leer los del perfil seleccionado. Todo lo que no cambies seguirá usando el perfil.',
+    filamentDefault: 'Predeterminado',
+    fromFile: 'del archivo',
+    fromFileHint: 'El diseñador cambió esto en el archivo de origen. Su valor es {{value}}.',
+    fromFilePrinterCoupled: 'impresora del diseñador',
+    fromFilePrinterCoupledHint: 'Ajustado para la impresora para la que se diseñó este archivo: en la tuya puede ser incorrecto o estar fuera de rango.',
+    useFromFile: 'Usar el valor del archivo de origen para {{option}}',
+    otherFromFile: 'Otros ajustes de este archivo',
+    loading: 'Cargando ajustes del laminador…',
+    mode: {
+      simple: 'Simple',
+      advanced: 'Avanzado',
+      expert: 'Experto',
+    },
+    searchPlaceholder: 'Buscar ajustes',
+    resetAll: 'Restablecer {{count}}',
+    resetOption: 'Restablecer al valor predeterminado',
+    noMatches: 'Ningún ajuste coincide con esta búsqueda.',
+  },
   slice: {
+    filamentSlotUnset: 'sin definir',
+    processSettingsEmbedded: 'No se usa mientras «Usar los ajustes integrados del archivo» está activo: los ajustes del propio archivo rigen este corte.',
+    processSettingsInactive: 'Inactivo',
+    presetsHidden: '{{count}} ocultos',
+    showAllPresets: 'Mostrar todos',
+    showFewerPresets: 'Mostrar menos',
+    processSettings: 'Ajustes de proceso',
+    processSettingsHint: 'Ajusta el perfil seleccionado para este corte. Todo lo que no toques se mantiene como lo define el perfil.',
+    processSettingsChanged: '{{count}} cambiados',
+    processSettingsUnchanged: 'Valores del perfil',
     title: 'Laminar modelo',
     action: 'Laminar',
     actionAll: 'Laminar las {{count}} bandejas',
@@ -4311,11 +4349,6 @@ export default {
     autoOrientHint: 'El laminador gira cada objeto hacia su mejor cara de impresión antes de laminar. Sustituye la orientación del archivo.',
     autoArrange: 'Organizar automáticamente en la base',
     autoArrangeHint: 'El laminador coloca los objetos para que dejen de solaparse. Sustituye la disposición del archivo.',
-    designSettings: 'Mantener los ajustes del diseñador',
-    designSettingsHint: 'Este archivo cambia {{count}} ajuste(s) de impresión respecto al perfil estándar.',
-    designSettingsSelected: '{{selected}} de {{total}} seleccionados',
-    designSettingsPrinterCoupled: 'específico de la impresora',
-    designSettingsPrinterCoupledHint: 'Ajustado para la impresora para la que se diseñó el archivo: puede ser incorrecto o quedar fuera de rango en la tuya.',
     enqueuing: 'Enviando el trabajo de laminado…',
     queued: 'En cola…',
     failed: 'Error al laminar. Consulte los registros del contenedor auxiliar del laminador.',
@@ -5578,10 +5611,8 @@ export default {
     openInSlicerFailed: 'No se pudo abrir en el laminador',
     tabs: {
       model: 'Modelo 3D',
-      gcode: 'Vista previa de G-code',
     },
     notAvailable: 'no disponible',
-    notSliced: 'no laminado',
     plates: 'Camas',
     allPlates: 'Todas las camas',
     plateNumber: 'Cama {{number}}',
@@ -7032,12 +7063,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: 'No se pudo incrustar la vista previa 3D',
-    blockedBody: 'Bambuddy permite que esta página muestre el visor de G-code incrustado, pero algo entre su navegador y Bambuddy lo está rechazando — normalmente un proxy inverso o un complemento de seguridad que envía su propia cabecera de marco. Abrir el visor en su propia pestaña no se ve afectado.',
-    unavailableTitle: 'La vista previa 3D no está disponible',
-    unavailableBody: 'Bambuddy no pudo servir los archivos del visor de G-code. Esto suele significar que falta el directorio gcode_viewer en la instalación; el registro de inicio también lo indica.',
-    problemDetail: 'Informado por el servidor: {{detail}}',
-    openInNewTab: 'Abrir el visor en una pestaña nueva',
+    filamentSlot: 'Filamento {{n}}',
+    loading: 'Leyendo la trayectoria…',
+    loadFailed: 'No se pudo cargar el G-code de este archivo.',
+    showTravel: 'Movimientos en vacío',
+    topLayer: 'Capa superior',
+    bottomLayer: 'Capa inferior',
+    noSource: 'No se indicó ningún archivo para previsualizar.',
+    view: {
+      filament: 'Filamento',
+      feature: 'Elemento',
+      height: 'Altura',
+      width: 'Ancho',
+    },
+    feature: {
+      wall: 'Paredes',
+      sparseInfill: 'Relleno disperso',
+      solidInfill: 'Relleno sólido',
+      bridge: 'Puente / voladizo',
+      support: 'Soporte',
+      skirt: 'Falda / borde',
+      gapFill: 'Relleno de huecos',
+      ironing: 'Alisado',
+      primeTower: 'Torre de purga',
+    },
     back: 'Atrás',
     backToArchives: 'Volver a los archivos de impresión',
     backToFiles: 'Volver al gestor de archivos',

+ 62 - 13
frontend/src/i18n/locales/fr.ts

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Moteur de découpage',
+    sliceEngineSidecar: 'Sidecar serveur',
+    sliceEngineSidecarHint: "Le découpage s'exécute sur le serveur, dans le conteneur sidecar du trancheur.",
+    sliceEngineBrowser: 'Dans le navigateur',
+    sliceEngineBrowserHint: "Le découpage s'exécute sur cet appareil, sans serveur.",
     title: 'Paramètres',
     general: 'Général',
     // Tab names
@@ -4268,7 +4273,40 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    presetValuesOutdatedSidecar: "Valeurs par défaut du trancheur affichées : votre sidecar est plus ancien que cette fonctionnalité et ne peut pas fournir les valeurs d'un profil. Mettez à jour l'image du sidecar pour les voir. Tout ce que vous ne modifiez pas utilise toujours le profil.",
+    presetValuesNotConfigured: "Valeurs par défaut du trancheur affichées : aucun sidecar n'est configuré, les valeurs d'un profil ne peuvent donc pas être lues. Tout ce que vous ne modifiez pas utilise toujours le profil.",
+    presetValuesSidecarUnavailable: "Valeurs par défaut du trancheur affichées : le sidecar n'a pas répondu, les valeurs d'un profil ne peuvent donc pas être lues. Tout ce que vous ne modifiez pas utilise toujours le profil.",
+    presetValuesUnavailable: "Valeurs par défaut du trancheur affichées : celles du profil choisi n'ont pas pu être lues. Tout ce que vous ne modifiez pas utilise toujours le profil.",
+    filamentDefault: 'Par défaut',
+    fromFile: 'du fichier',
+    fromFileHint: 'Le concepteur a modifié ce paramètre dans le fichier source. Sa valeur est {{value}}.',
+    fromFilePrinterCoupled: 'imprimante du concepteur',
+    fromFilePrinterCoupledHint: "Réglé pour l'imprimante pour laquelle ce fichier a été conçu — peut être incorrect ou hors plage sur la vôtre.",
+    useFromFile: 'Utiliser la valeur du fichier source pour {{option}}',
+    otherFromFile: 'Autres paramètres de ce fichier',
+    loading: 'Chargement des paramètres du trancheur…',
+    mode: {
+      simple: 'Simple',
+      advanced: 'Avancé',
+      expert: 'Expert',
+    },
+    searchPlaceholder: 'Rechercher un paramètre',
+    resetAll: 'Réinitialiser {{count}}',
+    resetOption: 'Réinitialiser à la valeur par défaut',
+    noMatches: 'Aucun paramètre ne correspond à cette recherche.',
+  },
   slice: {
+    filamentSlotUnset: 'non défini',
+    processSettingsEmbedded: 'Inutilisé tant que « Utiliser les paramètres intégrés du fichier » est activé : ce sont les paramètres du fichier qui pilotent ce découpage.',
+    processSettingsInactive: 'Inactif',
+    presetsHidden: '{{count}} masqués',
+    showAllPresets: 'Tout afficher',
+    showFewerPresets: 'Afficher moins',
+    processSettings: 'Paramètres de process',
+    processSettingsHint: 'Ajustez le profil choisi pour ce découpage. Tout ce que vous ne modifiez pas reste tel que défini par le profil.',
+    processSettingsChanged: '{{count}} modifiés',
+    processSettingsUnchanged: 'Valeurs du profil',
     title: 'Slicer le modèle',
     action: 'Slicer',
     actionAll: 'Slicer les {{count}} plateaux',
@@ -4298,11 +4336,6 @@ export default {
     autoOrientHint: "Le trancheur fait pivoter chaque objet sur sa meilleure face d'impression avant de trancher. Remplace l'orientation enregistrée dans le fichier.",
     autoArrange: 'Disposer automatiquement sur le plateau',
     autoArrangeHint: "Le trancheur place les objets pour qu'ils ne se chevauchent plus. Remplace la disposition du fichier.",
-    designSettings: 'Conserver les réglages du concepteur',
-    designSettingsHint: 'Ce fichier modifie {{count}} réglage(s) d\'impression par rapport au profil standard.',
-    designSettingsSelected: '{{selected}} sur {{total}} sélectionnés',
-    designSettingsPrinterCoupled: 'spécifique à l\'imprimante',
-    designSettingsPrinterCoupledHint: 'Réglé pour l\'imprimante visée par le fichier — peut être incorrect ou hors plage sur la vôtre.',
     enqueuing: 'Envoi du travail de découpage…',
     queued: 'En file d\'attente…',
     failed: 'Échec du découpage. Vérifiez les journaux du sidecar.',
@@ -5560,10 +5593,8 @@ export default {
     openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
     tabs: {
       model: 'Modèle 3D',
-      gcode: 'Aperçu G-code',
     },
     notAvailable: 'indisponible',
-    notSliced: 'pas découpé',
     plates: 'Plateaux',
     allPlates: 'Tous les plateaux',
     plateNumber: 'Plateau {{number}}',
@@ -7013,12 +7044,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: 'L\'aperçu 3D n\'a pas pu être intégré',
-    blockedBody: 'Bambuddy autorise cette page à afficher la visionneuse G-code en ligne, mais quelque chose entre votre navigateur et Bambuddy le refuse — généralement un reverse proxy ou une extension de sécurité qui envoie son propre en-tête de cadre. L\'ouverture de la visionneuse dans un onglet dédié n\'est pas concernée.',
-    unavailableTitle: 'L\'aperçu 3D est indisponible',
-    unavailableBody: 'Bambuddy n\'a pas pu servir les fichiers de la visionneuse G-code. Cela signifie généralement que le répertoire gcode_viewer est absent de l\'installation ; le journal de démarrage l\'indique également.',
-    problemDetail: 'Signalé par le serveur : {{detail}}',
-    openInNewTab: 'Ouvrir la visionneuse dans un nouvel onglet',
+    filamentSlot: 'Filament {{n}}',
+    loading: 'Lecture de la trajectoire…',
+    loadFailed: 'Impossible de charger le G-code de ce fichier.',
+    showTravel: 'Déplacements à vide',
+    topLayer: 'Couche supérieure',
+    bottomLayer: 'Couche inférieure',
+    noSource: 'Aucun fichier à prévisualiser n’a été indiqué.',
+    view: {
+      filament: 'Filament',
+      feature: 'Élément',
+      height: 'Hauteur',
+      width: 'Largeur',
+    },
+    feature: {
+      wall: 'Parois',
+      sparseInfill: 'Remplissage clairsemé',
+      solidInfill: 'Remplissage plein',
+      bridge: 'Pont / surplomb',
+      support: 'Support',
+      skirt: 'Jupe / bordure',
+      gapFill: 'Remplissage des vides',
+      ironing: 'Lissage',
+      primeTower: 'Tour de purge',
+    },
     back: 'Retour',
     backToArchives: 'Retour aux archives d\'impression',
     backToFiles: 'Retour au gestionnaire de fichiers',

+ 62 - 13
frontend/src/i18n/locales/it.ts

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Motore di slicing',
+    sliceEngineSidecar: 'Sidecar del server',
+    sliceEngineSidecarHint: 'Lo slicing viene eseguito sul server, nel container sidecar dello slicer.',
+    sliceEngineBrowser: 'Nel browser',
+    sliceEngineBrowserHint: 'Lo slicing viene eseguito su questo dispositivo, senza server.',
     title: 'Impostazioni',
     general: 'Generale',
     // Tab names
@@ -4267,7 +4272,40 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    presetValuesOutdatedSidecar: "Sono mostrati i valori predefiniti dello slicer: il tuo sidecar è più vecchio di questa funzione e non può fornire i valori di un profilo. Aggiorna l'immagine del sidecar per vederli. Tutto ciò che non modifichi continua a usare il profilo.",
+    presetValuesNotConfigured: 'Sono mostrati i valori predefiniti dello slicer: nessun sidecar è configurato, quindi i valori di un profilo non sono leggibili. Tutto ciò che non modifichi continua a usare il profilo.',
+    presetValuesSidecarUnavailable: 'Sono mostrati i valori predefiniti dello slicer: il sidecar non ha risposto, quindi i valori di un profilo non sono leggibili. Tutto ciò che non modifichi continua a usare il profilo.',
+    presetValuesUnavailable: 'Sono mostrati i valori predefiniti dello slicer: quelli del profilo scelto non sono leggibili. Tutto ciò che non modifichi continua a usare il profilo.',
+    filamentDefault: 'Predefinito',
+    fromFile: 'dal file',
+    fromFileHint: 'Il designer ha modificato questo parametro nel file di origine. Il valore è {{value}}.',
+    fromFilePrinterCoupled: 'stampante del designer',
+    fromFilePrinterCoupledHint: 'Tarato per la stampante per cui è stato progettato questo file: sulla tua può essere errato o fuori intervallo.',
+    useFromFile: 'Usa il valore del file di origine per {{option}}',
+    otherFromFile: 'Altre impostazioni da questo file',
+    loading: 'Caricamento impostazioni dello slicer…',
+    mode: {
+      simple: 'Semplice',
+      advanced: 'Avanzato',
+      expert: 'Esperto',
+    },
+    searchPlaceholder: 'Cerca impostazioni',
+    resetAll: 'Ripristina {{count}}',
+    resetOption: 'Ripristina il valore predefinito',
+    noMatches: 'Nessuna impostazione corrisponde a questa ricerca.',
+  },
   slice: {
+    filamentSlotUnset: 'non impostato',
+    processSettingsEmbedded: 'Non utilizzato finché «Usa le impostazioni integrate del file» è attivo: sono le impostazioni del file a guidare questo slice.',
+    processSettingsInactive: 'Non attivo',
+    presetsHidden: '{{count}} nascosti',
+    showAllPresets: 'Mostra tutti',
+    showFewerPresets: 'Mostra meno',
+    processSettings: 'Impostazioni di processo',
+    processSettingsHint: 'Regola il profilo scelto per questo slice. Tutto ciò che non tocchi resta come definito dal profilo.',
+    processSettingsChanged: '{{count}} modificate',
+    processSettingsUnchanged: 'Valori del profilo',
     title: 'Slicing modello',
     action: 'Slice',
     actionAll: 'Slicia tutti i {{count}} piatti',
@@ -4297,11 +4335,6 @@ export default {
     autoOrientHint: "Lo slicer ruota ogni oggetto sul lato che si stampa meglio prima di affettare. Sostituisce l'orientamento salvato nel file.",
     autoArrange: 'Disponi automaticamente sul piatto',
     autoArrangeHint: 'Lo slicer dispone gli oggetti in modo che non si sovrappongano più. Sostituisce la disposizione del file.',
-    designSettings: 'Mantieni le impostazioni del progettista',
-    designSettingsHint: 'Questo file modifica {{count}} impostazione/i di stampa rispetto al profilo standard.',
-    designSettingsSelected: '{{selected}} di {{total}} selezionate',
-    designSettingsPrinterCoupled: 'specifico della stampante',
-    designSettingsPrinterCoupledHint: 'Tarato per la stampante per cui è stato progettato il file: sulla tua può essere errato o fuori intervallo.',
     enqueuing: 'Invio lavoro di slicing…',
     queued: 'In coda…',
     failed: 'Slicing fallito. Controlla i log del sidecar.',
@@ -5559,10 +5592,8 @@ export default {
     openInSlicerFailed: 'Impossibile aprire nello slicer',
     tabs: {
       model: 'Modello 3D',
-      gcode: 'Anteprima G-code',
     },
     notAvailable: 'non disponibile',
-    notSliced: 'non sezionato',
     plates: 'Piatti',
     allPlates: 'Tutti i piatti',
     plateNumber: 'Piatto {{number}}',
@@ -7012,12 +7043,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: 'Impossibile incorporare l\'anteprima 3D',
-    blockedBody: 'Bambuddy consente a questa pagina di mostrare il visualizzatore G-code incorporato, ma qualcosa tra il browser e Bambuddy lo rifiuta — di solito un reverse proxy o un\'estensione di sicurezza che invia una propria intestazione di frame. L\'apertura del visualizzatore in una scheda dedicata non è interessata.',
-    unavailableTitle: 'Anteprima 3D non disponibile',
-    unavailableBody: 'Bambuddy non è riuscito a servire i file del visualizzatore G-code. Di solito significa che la cartella gcode_viewer manca nell\'installazione; anche il log di avvio lo segnala.',
-    problemDetail: 'Segnalato dal server: {{detail}}',
-    openInNewTab: 'Apri il visualizzatore in una nuova scheda',
+    filamentSlot: 'Filamento {{n}}',
+    loading: 'Lettura del percorso…',
+    loadFailed: 'Impossibile caricare il G-code di questo file.',
+    showTravel: 'Spostamenti a vuoto',
+    topLayer: 'Strato superiore',
+    bottomLayer: 'Strato inferiore',
+    noSource: 'Nessun file da visualizzare è stato indicato.',
+    view: {
+      filament: 'Filamento',
+      feature: 'Elemento',
+      height: 'Altezza',
+      width: 'Larghezza',
+    },
+    feature: {
+      wall: 'Pareti',
+      sparseInfill: 'Riempimento rado',
+      solidInfill: 'Riempimento solido',
+      bridge: 'Ponte / sbalzo',
+      support: 'Supporto',
+      skirt: 'Skirt / brim',
+      gapFill: 'Riempimento vuoti',
+      ironing: 'Stiratura',
+      primeTower: 'Torre di spurgo',
+    },
     back: 'Indietro',
     backToArchives: 'Torna agli archivi di stampa',
     backToFiles: 'Torna al gestore file',

+ 62 - 13
frontend/src/i18n/locales/ja.ts

@@ -1789,6 +1789,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'スライスエンジン',
+    sliceEngineSidecar: 'サーバーサイドカー',
+    sliceEngineSidecarHint: 'スライスはサーバー上のスライサーサイドカーコンテナーで実行されます。',
+    sliceEngineBrowser: 'ブラウザー内',
+    sliceEngineBrowserHint: 'スライスはサーバーを介さず、このデバイス上で実行されます。',
     title: '設定',
     general: '一般',
     // Tab names
@@ -4279,7 +4284,40 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    presetValuesOutdatedSidecar: 'スライサーの既定値を表示しています。スライサーサイドカーがこの機能より古く、プリセットの値を取得できません。値を表示するにはサイドカーのイメージを更新してください。変更しない項目は引き続きプリセットの値が使われます。',
+    presetValuesNotConfigured: 'スライサーの既定値を表示しています。スライサーサイドカーが設定されていないため、プリセットの値を読み取れません。変更しない項目は引き続きプリセットの値が使われます。',
+    presetValuesSidecarUnavailable: 'スライサーの既定値を表示しています。スライサーサイドカーが応答しなかったため、プリセットの値を読み取れません。変更しない項目は引き続きプリセットの値が使われます。',
+    presetValuesUnavailable: 'スライサーの既定値を表示しています。選択したプリセットの値を読み取れませんでした。変更しない項目は引き続きプリセットの値が使われます。',
+    filamentDefault: '既定',
+    fromFile: 'ファイル由来',
+    fromFileHint: 'この項目は元ファイルで設計者が変更しています。値は {{value}} です。',
+    fromFilePrinterCoupled: '設計者のプリンター',
+    fromFilePrinterCoupledHint: 'このファイルが設計されたプリンター向けの値です。お使いのプリンターでは不適切または範囲外になる場合があります。',
+    useFromFile: '{{option}} に元ファイルの値を使用する',
+    otherFromFile: 'このファイルのその他の設定',
+    loading: 'スライサー設定を読み込んでいます…',
+    mode: {
+      simple: 'シンプル',
+      advanced: '詳細',
+      expert: 'エキスパート',
+    },
+    searchPlaceholder: '設定を検索',
+    resetAll: '{{count}} 件をリセット',
+    resetOption: '既定値に戻す',
+    noMatches: 'この検索に一致する設定はありません。',
+  },
   slice: {
+    filamentSlotUnset: '未設定',
+    processSettingsEmbedded: '「ファイル内蔵の設定を使用」が有効な間は使用されません。このスライスはファイル自身の設定で実行されます。',
+    processSettingsInactive: '無効',
+    presetsHidden: '{{count}} 件を非表示',
+    showAllPresets: 'すべて表示',
+    showFewerPresets: '表示を減らす',
+    processSettings: 'プロセス設定',
+    processSettingsHint: 'このスライス用に選択したプリセットを調整します。変更しない項目はプリセットの定義のままです。',
+    processSettingsChanged: '{{count}} 件変更',
+    processSettingsUnchanged: 'プリセットの既定値',
     title: 'モデルをスライス',
     action: 'スライス',
     actionAll: '{{count}} プレートすべてをスライス',
@@ -4309,11 +4347,6 @@ export default {
     autoOrientHint: 'スライスする前に、各オブジェクトを印刷に適した面へ自動で回転させます。ファイルに保存された向きは上書きされます。',
     autoArrange: 'プレート上に自動配置',
     autoArrangeHint: 'オブジェクトが重ならないようにスライサーが並べ直します。ファイルの配置は置き換えられます。',
-    designSettings: '設計者の設定を保持',
-    designSettingsHint: 'このファイルは標準プロファイルから {{count}} 個の印刷設定を変更しています。',
-    designSettingsSelected: '{{total}} 個中 {{selected}} 個を選択',
-    designSettingsPrinterCoupled: 'プリンター固有',
-    designSettingsPrinterCoupledHint: 'このファイルが対象とするプリンター向けに調整された値です。お使いのプリンターでは不適切または範囲外になる場合があります。',
     enqueuing: 'スライスジョブを送信中…',
     queued: '待機中…',
     failed: 'スライスに失敗。サイドカーのログを確認してください。',
@@ -5571,10 +5604,8 @@ export default {
     openInSlicerFailed: 'スライサーで開けませんでした',
     tabs: {
       model: '3Dモデル',
-      gcode: 'G-codeプレビュー',
     },
     notAvailable: '利用不可',
-    notSliced: '未スライス',
     plates: 'プレート',
     allPlates: '全プレート',
     plateNumber: 'プレート {{number}}',
@@ -7024,12 +7055,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: '3Dプレビューを埋め込めませんでした',
-    blockedBody: 'BambuddyはこのページにG-codeビューアーを埋め込んで表示することを許可していますが、ブラウザーとBambuddyの間にある何かがそれを拒否しています。多くの場合、独自のフレームヘッダーを送信するリバースプロキシやセキュリティ拡張が原因です。ビューアーを別のタブで開く場合は影響ありません。',
-    unavailableTitle: '3Dプレビューを利用できません',
-    unavailableBody: 'BambuddyがG-codeビューアーのファイルを配信できませんでした。通常はインストールに gcode_viewer ディレクトリが存在しないことを意味します。起動ログにも記録されています。',
-    problemDetail: 'サーバーからの報告: {{detail}}',
-    openInNewTab: 'ビューアーを新しいタブで開く',
+    filamentSlot: 'フィラメント {{n}}',
+    loading: 'ツールパスを読み込んでいます…',
+    loadFailed: 'このファイルの G コードを読み込めませんでした。',
+    showTravel: '移動(非押出)',
+    topLayer: '上端レイヤー',
+    bottomLayer: '下端レイヤー',
+    noSource: 'プレビューするファイルが指定されていません。',
+    view: {
+      filament: 'フィラメント',
+      feature: '種別',
+      height: '高さ',
+      width: '幅',
+    },
+    feature: {
+      wall: '壁',
+      sparseInfill: '疎インフィル',
+      solidInfill: 'ソリッドインフィル',
+      bridge: 'ブリッジ/オーバーハング',
+      support: 'サポート',
+      skirt: 'スカート/ブリム',
+      gapFill: 'ギャップ充填',
+      ironing: 'アイロン',
+      primeTower: 'プライムタワー',
+    },
     back: '戻る',
     backToArchives: '印刷アーカイブに戻る',
     backToFiles: 'ファイル管理に戻る',

+ 62 - 13
frontend/src/i18n/locales/ko.ts

@@ -1704,6 +1704,11 @@ export default {
     configureSettings: '유지보수 유형 및 간격 설정'
   },
   settings: {
+    sliceEngine: '슬라이스 엔진',
+    sliceEngineSidecar: '서버 사이드카',
+    sliceEngineSidecarHint: '슬라이싱이 서버의 슬라이서 사이드카 컨테이너에서 실행됩니다.',
+    sliceEngineBrowser: '브라우저에서',
+    sliceEngineBrowserHint: '슬라이싱이 서버 없이 이 기기에서 실행됩니다.',
     title: '설정',
     general: '일반',
     tabs: {
@@ -4070,7 +4075,40 @@ export default {
       },
     },
   },
+  slicerSettings: {
+    presetValuesOutdatedSidecar: '슬라이서 기본값을 표시합니다. 슬라이서 사이드카가 이 기능보다 오래되어 프리셋 값을 가져올 수 없습니다. 값을 보려면 사이드카 이미지를 업데이트하세요. 변경하지 않은 항목은 계속 프리셋 값을 사용합니다.',
+    presetValuesNotConfigured: '슬라이서 기본값을 표시합니다. 슬라이서 사이드카가 설정되지 않아 프리셋 값을 읽을 수 없습니다. 변경하지 않은 항목은 계속 프리셋 값을 사용합니다.',
+    presetValuesSidecarUnavailable: '슬라이서 기본값을 표시합니다. 슬라이서 사이드카가 응답하지 않아 프리셋 값을 읽을 수 없습니다. 변경하지 않은 항목은 계속 프리셋 값을 사용합니다.',
+    presetValuesUnavailable: '슬라이서 기본값을 표시합니다. 선택한 프리셋의 값을 읽을 수 없었습니다. 변경하지 않은 항목은 계속 프리셋 값을 사용합니다.',
+    filamentDefault: '기본값',
+    fromFile: '파일에서',
+    fromFileHint: '디자이너가 원본 파일에서 이 항목을 변경했습니다. 값은 {{value}}입니다.',
+    fromFilePrinterCoupled: '디자이너의 프린터',
+    fromFilePrinterCoupledHint: '이 파일이 설계된 프린터에 맞춘 값입니다. 사용 중인 프린터에서는 잘못되거나 범위를 벗어날 수 있습니다.',
+    useFromFile: '{{option}}에 원본 파일의 값 사용',
+    otherFromFile: '이 파일의 기타 설정',
+    loading: '슬라이서 설정을 불러오는 중…',
+    mode: {
+      simple: '간단',
+      advanced: '고급',
+      expert: '전문가',
+    },
+    searchPlaceholder: '설정 검색',
+    resetAll: '{{count}}개 초기화',
+    resetOption: '기본값으로 되돌리기',
+    noMatches: '검색과 일치하는 설정이 없습니다.',
+  },
   slice: {
+    filamentSlotUnset: '미설정',
+    processSettingsEmbedded: "'파일에 포함된 설정 사용'이 켜져 있는 동안에는 사용되지 않습니다. 이 슬라이스는 파일 자체의 설정을 따릅니다.",
+    processSettingsInactive: '비활성',
+    presetsHidden: '{{count}}개 숨김',
+    showAllPresets: '모두 표시',
+    showFewerPresets: '간략히 표시',
+    processSettings: '프로세스 설정',
+    processSettingsHint: '이 슬라이스에 사용할 프리셋을 조정합니다. 건드리지 않은 항목은 프리셋 정의를 그대로 따릅니다.',
+    processSettingsChanged: '{{count}}개 변경됨',
+    processSettingsUnchanged: '프리셋 기본값',
     title: '모델 슬라이싱',
     action: '슬라이싱',
     slicing: '슬라이싱 중…',
@@ -4096,11 +4134,6 @@ export default {
     autoOrientHint: '슬라이싱하기 전에 각 개체를 출력하기 좋은 면으로 회전시킵니다. 파일에 저장된 방향을 덮어씁니다.',
     autoArrange: '플레이트에 자동 배치',
     autoArrangeHint: '개체가 겹치지 않도록 슬라이서가 다시 배치합니다. 파일의 배치를 대체합니다.',
-    designSettings: '디자이너 설정 유지',
-    designSettingsHint: '이 파일은 기본 프로파일에서 {{count}}개의 출력 설정을 변경합니다.',
-    designSettingsSelected: '{{total}}개 중 {{selected}}개 선택됨',
-    designSettingsPrinterCoupled: '프린터 전용',
-    designSettingsPrinterCoupledHint: '이 파일이 대상으로 한 프린터에 맞춘 값입니다. 사용 중인 프린터에서는 잘못되었거나 범위를 벗어날 수 있습니다.',
     enqueuing: '슬라이싱 작업 제출 중…',
     queued: '대기 중…',
     failed: '슬라이싱 실패. 슬라이서 사이드카 로그를 확인하세요.',
@@ -5304,10 +5337,8 @@ export default {
     openInSlicerFailed: '슬라이서에서 열 수 없습니다',
     tabs: {
       model: '3D 모델',
-      gcode: 'G-code 미리보기'
     },
     notAvailable: '사용 불가',
-    notSliced: '슬라이싱되지 않음',
     plates: '플레이트',
     allPlates: '모든 플레이트',
     plateNumber: '플레이트 {{number}}',
@@ -6480,12 +6511,30 @@ export default {
     }
   },
   gcodeViewer: {
-    blockedTitle: '3D 미리보기를 삽입할 수 없습니다',
-    blockedBody: 'Bambuddy는 이 페이지에 G-code 뷰어를 삽입해 표시하도록 허용하지만, 브라우저와 Bambuddy 사이의 무언가가 이를 거부하고 있습니다. 대개 자체 프레임 헤더를 보내는 리버스 프록시나 보안 추가 기능이 원인입니다. 뷰어를 별도 탭에서 여는 것은 영향을 받지 않습니다.',
-    unavailableTitle: '3D 미리보기를 사용할 수 없습니다',
-    unavailableBody: 'Bambuddy가 G-code 뷰어 파일을 제공하지 못했습니다. 보통 설치본에 gcode_viewer 디렉터리가 없다는 뜻이며, 시작 로그에도 기록됩니다.',
-    problemDetail: '서버 보고: {{detail}}',
-    openInNewTab: '새 탭에서 뷰어 열기',
+    filamentSlot: '필라멘트 {{n}}',
+    loading: '툴패스를 읽는 중…',
+    loadFailed: '이 파일의 G코드를 불러올 수 없습니다.',
+    showTravel: '이동 경로',
+    topLayer: '최상단 레이어',
+    bottomLayer: '최하단 레이어',
+    noSource: '미리 볼 파일이 지정되지 않았습니다.',
+    view: {
+      filament: '필라멘트',
+      feature: '유형',
+      height: '높이',
+      width: '너비',
+    },
+    feature: {
+      wall: '벽',
+      sparseInfill: '성긴 채움',
+      solidInfill: '솔리드 채움',
+      bridge: '브리지 / 오버행',
+      support: '서포트',
+      skirt: '스커트 / 브림',
+      gapFill: '틈 채움',
+      ironing: '다림질',
+      primeTower: '프라임 타워',
+    },
     back: '뒤로',
     backToArchives: '인쇄 아카이브로 돌아가기',
     backToFiles: '파일 관리자로 돌아가기'

+ 62 - 13
frontend/src/i18n/locales/pt-BR.ts

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Motor de fatiamento',
+    sliceEngineSidecar: 'Sidecar do servidor',
+    sliceEngineSidecarHint: 'O fatiamento roda no servidor, no contêiner sidecar do fatiador.',
+    sliceEngineBrowser: 'No navegador',
+    sliceEngineBrowserHint: 'O fatiamento roda neste dispositivo, sem envolver o servidor.',
     title: 'Configurações',
     general: 'Geral',
     // Tab names
@@ -4267,7 +4272,40 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    presetValuesOutdatedSidecar: 'Exibindo os padrões do fatiador: seu sidecar é mais antigo que este recurso e não consegue informar os valores de um perfil. Atualize a imagem do sidecar para vê-los. Tudo o que você não alterar continua usando o perfil.',
+    presetValuesNotConfigured: 'Exibindo os padrões do fatiador: nenhum sidecar está configurado, portanto os valores de um perfil não podem ser lidos. Tudo o que você não alterar continua usando o perfil.',
+    presetValuesSidecarUnavailable: 'Exibindo os padrões do fatiador: o sidecar não respondeu, portanto os valores de um perfil não podem ser lidos. Tudo o que você não alterar continua usando o perfil.',
+    presetValuesUnavailable: 'Exibindo os padrões do fatiador: não foi possível ler os valores do perfil escolhido. Tudo o que você não alterar continua usando o perfil.',
+    filamentDefault: 'Padrão',
+    fromFile: 'do arquivo',
+    fromFileHint: 'O designer alterou isto no arquivo de origem. O valor é {{value}}.',
+    fromFilePrinterCoupled: 'impressora do designer',
+    fromFilePrinterCoupledHint: 'Ajustado para a impressora para a qual este arquivo foi projetado — pode estar errado ou fora de faixa na sua.',
+    useFromFile: 'Usar o valor do arquivo de origem para {{option}}',
+    otherFromFile: 'Outras configurações deste arquivo',
+    loading: 'Carregando configurações do fatiador…',
+    mode: {
+      simple: 'Simples',
+      advanced: 'Avançado',
+      expert: 'Especialista',
+    },
+    searchPlaceholder: 'Buscar configurações',
+    resetAll: 'Redefinir {{count}}',
+    resetOption: 'Redefinir para o padrão',
+    noMatches: 'Nenhuma configuração corresponde a esta busca.',
+  },
   slice: {
+    filamentSlotUnset: 'não definido',
+    processSettingsEmbedded: 'Não é usado enquanto “Usar as configurações internas do arquivo” estiver ativo — as configurações do próprio arquivo conduzem este fatiamento.',
+    processSettingsInactive: 'Inativo',
+    presetsHidden: '{{count}} ocultos',
+    showAllPresets: 'Mostrar todos',
+    showFewerPresets: 'Mostrar menos',
+    processSettings: 'Configurações de processo',
+    processSettingsHint: 'Ajuste o perfil escolhido para este fatiamento. Tudo o que você não alterar permanece como o perfil define.',
+    processSettingsChanged: '{{count}} alterados',
+    processSettingsUnchanged: 'Padrões do perfil',
     title: 'Fatiar modelo',
     action: 'Fatiar',
     actionAll: 'Fatiar todas as {{count}} bandejas',
@@ -4297,11 +4335,6 @@ export default {
     autoOrientHint: 'O fatiador gira cada objeto para o lado que imprime melhor antes de fatiar. Substitui a orientação salva no arquivo.',
     autoArrange: 'Organizar automaticamente na mesa',
     autoArrangeHint: 'O fatiador posiciona os objetos para que não se sobreponham. Substitui a disposição do arquivo.',
-    designSettings: 'Manter as configurações do designer',
-    designSettingsHint: 'Este arquivo altera {{count}} configuração(ões) de impressão em relação ao perfil padrão.',
-    designSettingsSelected: '{{selected}} de {{total}} selecionadas',
-    designSettingsPrinterCoupled: 'específico da impressora',
-    designSettingsPrinterCoupledHint: 'Ajustado para a impressora para a qual o arquivo foi projetado — pode estar incorreto ou fora de faixa na sua.',
     enqueuing: 'Enviando trabalho de fatiamento…',
     queued: 'Na fila…',
     failed: 'Falha ao fatiar. Verifique os logs do sidecar.',
@@ -5559,10 +5592,8 @@ export default {
     openInSlicerFailed: 'Não foi possível abrir no fatiador',
     tabs: {
       model: 'Modelo 3D',
-      gcode: 'Pré-visualização G-code',
     },
     notAvailable: 'Não disponível',
-    notSliced: 'Não fatiado',
     plates: 'Placas',
     allPlates: 'Todas as Placas',
     plateNumber: 'Placa {{number}}',
@@ -7012,12 +7043,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: 'Não foi possível incorporar a pré-visualização 3D',
-    blockedBody: 'O Bambuddy permite que esta página mostre o visualizador de G-code incorporado, mas algo entre o seu navegador e o Bambuddy está recusando — normalmente um proxy reverso ou um complemento de segurança que envia o próprio cabeçalho de quadro. Abrir o visualizador em uma aba própria não é afetado.',
-    unavailableTitle: 'A pré-visualização 3D está indisponível',
-    unavailableBody: 'O Bambuddy não conseguiu servir os arquivos do visualizador de G-code. Isso normalmente significa que o diretório gcode_viewer está ausente na instalação; o log de inicialização também informa isso.',
-    problemDetail: 'Informado pelo servidor: {{detail}}',
-    openInNewTab: 'Abrir o visualizador em uma nova aba',
+    filamentSlot: 'Filamento {{n}}',
+    loading: 'Lendo a trajetória…',
+    loadFailed: 'Não foi possível carregar o G-code deste arquivo.',
+    showTravel: 'Movimentos sem extrusão',
+    topLayer: 'Camada superior',
+    bottomLayer: 'Camada inferior',
+    noSource: 'Nenhum arquivo foi indicado para visualização.',
+    view: {
+      filament: 'Filamento',
+      feature: 'Elemento',
+      height: 'Altura',
+      width: 'Largura',
+    },
+    feature: {
+      wall: 'Paredes',
+      sparseInfill: 'Preenchimento esparso',
+      solidInfill: 'Preenchimento sólido',
+      bridge: 'Ponte / saliência',
+      support: 'Suporte',
+      skirt: 'Saia / aba',
+      gapFill: 'Preenchimento de lacunas',
+      ironing: 'Alisamento',
+      primeTower: 'Torre de purga',
+    },
     back: 'Voltar',
     backToArchives: 'Voltar para os arquivos de impressão',
     backToFiles: 'Voltar para o gerenciador de arquivos',

+ 62 - 13
frontend/src/i18n/locales/ru.ts

@@ -1701,6 +1701,11 @@ export default {
     configureSettings: "Настроить виды обслуживания и интервалы",
   },
   settings: {
+    sliceEngine: 'Движок нарезки',
+    sliceEngineSidecar: 'Серверный sidecar',
+    sliceEngineSidecarHint: 'Нарезка выполняется на сервере, в контейнере sidecar слайсера.',
+    sliceEngineBrowser: 'В браузере',
+    sliceEngineBrowserHint: 'Нарезка выполняется на этом устройстве, без участия сервера.',
     title: "Настройки",
     general: "Общие",
     tabs: {
@@ -4062,7 +4067,40 @@ export default {
       },
     },
   },
+  slicerSettings: {
+    presetValuesOutdatedSidecar: 'Показаны значения по умолчанию слайсера: ваш sidecar старше этой функции и не может сообщить значения профиля. Обновите образ sidecar, чтобы увидеть их. Всё, что вы не измените, по-прежнему берётся из профиля.',
+    presetValuesNotConfigured: 'Показаны значения по умолчанию слайсера: sidecar не настроен, поэтому значения профиля прочитать нельзя. Всё, что вы не измените, по-прежнему берётся из профиля.',
+    presetValuesSidecarUnavailable: 'Показаны значения по умолчанию слайсера: sidecar не ответил, поэтому значения профиля прочитать нельзя. Всё, что вы не измените, по-прежнему берётся из профиля.',
+    presetValuesUnavailable: 'Показаны значения по умолчанию слайсера: значения выбранного профиля прочитать не удалось. Всё, что вы не измените, по-прежнему берётся из профиля.',
+    filamentDefault: 'По умолчанию',
+    fromFile: 'из файла',
+    fromFileHint: 'Автор модели изменил этот параметр в исходном файле. Значение: {{value}}.',
+    fromFilePrinterCoupled: 'принтер автора',
+    fromFilePrinterCoupledHint: 'Подобрано под принтер, для которого создан файл, — на вашем значение может быть неверным или вне диапазона.',
+    useFromFile: 'Использовать значение из исходного файла для «{{option}}»',
+    otherFromFile: 'Другие параметры из этого файла',
+    loading: 'Загрузка настроек слайсера…',
+    mode: {
+      simple: 'Простой',
+      advanced: 'Расширенный',
+      expert: 'Эксперт',
+    },
+    searchPlaceholder: 'Поиск параметров',
+    resetAll: 'Сбросить: {{count}}',
+    resetOption: 'Сбросить к значению по умолчанию',
+    noMatches: 'Нет параметров, соответствующих запросу.',
+  },
   slice: {
+    filamentSlotUnset: 'не задано',
+    processSettingsEmbedded: 'Не используется, пока включено «Использовать встроенные настройки файла» — нарезкой управляют настройки самого файла.',
+    processSettingsInactive: 'Не активно',
+    presetsHidden: 'скрыто: {{count}}',
+    showAllPresets: 'Показать все',
+    showFewerPresets: 'Показать меньше',
+    processSettings: 'Параметры процесса',
+    processSettingsHint: 'Настройте выбранный профиль для этой нарезки. Всё, что вы не измените, останется как задано в профиле.',
+    processSettingsChanged: 'изменено: {{count}}',
+    processSettingsUnchanged: 'Значения профиля',
     title: "Нарезка модели",
     action: "Нарезать",
     actionAll: "Нарезать все пластины ({{count}})",
@@ -4092,11 +4130,6 @@ export default {
     autoOrientHint: 'Слайсер повернёт каждый объект на сторону, которая печатается лучше всего. Ориентация из файла будет заменена.',
     autoArrange: 'Автоматически разместить на столе',
     autoArrangeHint: 'Слайсер расставит объекты так, чтобы они не перекрывались. Расположение из файла будет заменено.',
-    designSettings: "Сохранить настройки автора",
-    designSettingsHint: "Этот файл меняет {{count}} настроек печати по сравнению со стандартным профилем.",
-    designSettingsSelected: "Выбрано {{selected}} из {{total}}",
-    designSettingsPrinterCoupled: "зависит от принтера",
-    designSettingsPrinterCoupledHint: "Значение подобрано для принтера, под который создан файл, — на вашем оно может быть неверным или вне допустимого диапазона.",
     enqueuing: "Отправка задания на нарезку…",
     queued: "В очереди…",
     failed: "Ошибка нарезки. Проверьте журналы вспомогательного сервиса слайсера.",
@@ -5292,10 +5325,8 @@ export default {
     openInSlicerFailed: "Не удалось открыть в слайсере",
     tabs: {
       model: "3D-модель",
-      gcode: "Предпросмотр G-code",
     },
     notAvailable: "недоступно",
-    notSliced: "не нарезано",
     plates: "Пластины",
     allPlates: "Все пластины",
     plateNumber: "Пластина {{number}}",
@@ -6649,12 +6680,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: "Не удалось встроить 3D-предпросмотр",
-    blockedBody: "Bambuddy разрешает этой странице показывать просмотрщик G-code встроенным, но что-то между браузером и Bambuddy это запрещает — обычно обратный прокси или расширение безопасности, отправляющее собственный заголовок фрейма. Открытие просмотрщика в отдельной вкладке не затрагивается.",
-    unavailableTitle: "3D-предпросмотр недоступен",
-    unavailableBody: "Bambuddy не смог отдать файлы просмотрщика G-code. Обычно это значит, что в установке отсутствует каталог gcode_viewer; об этом также сообщает журнал запуска.",
-    problemDetail: "Сообщение сервера: {{detail}}",
-    openInNewTab: "Открыть просмотрщик в новой вкладке",
+    filamentSlot: 'Филамент {{n}}',
+    loading: 'Чтение траектории…',
+    loadFailed: 'Не удалось загрузить G-код этого файла.',
+    showTravel: 'Холостые перемещения',
+    topLayer: 'Верхний слой',
+    bottomLayer: 'Нижний слой',
+    noSource: 'Файл для предпросмотра не указан.',
+    view: {
+      filament: 'Филамент',
+      feature: 'Тип',
+      height: 'Высота',
+      width: 'Ширина',
+    },
+    feature: {
+      wall: 'Стенки',
+      sparseInfill: 'Разреженное заполнение',
+      solidInfill: 'Сплошное заполнение',
+      bridge: 'Мост / нависание',
+      support: 'Поддержка',
+      skirt: 'Юбка / кайма',
+      gapFill: 'Заполнение зазоров',
+      ironing: 'Разглаживание',
+      primeTower: 'Башня очистки',
+    },
     back: "Назад",
     backToArchives: "Вернуться в архив печати",
     backToFiles: "Вернуться в файловый менеджер",

+ 62 - 13
frontend/src/i18n/locales/tr.ts

@@ -1792,6 +1792,11 @@ export default {
 
   // Ayarlar sayfası
   settings: {
+    sliceEngine: 'Dilimleme motoru',
+    sliceEngineSidecar: 'Sunucu sidecar',
+    sliceEngineSidecarHint: 'Dilimleme sunucuda, dilimleyici sidecar konteynerinde çalışır.',
+    sliceEngineBrowser: 'Tarayıcıda',
+    sliceEngineBrowserHint: 'Dilimleme sunucu olmadan bu cihazda çalışır.',
     title: 'Ayarlar',
     general: 'Genel',
     // Sekme adları
@@ -4268,7 +4273,40 @@ export default {
   },
 
   // Dilimle (SliceModal ile slicer-API entegrasyonu)
+  slicerSettings: {
+    presetValuesOutdatedSidecar: 'Dilimleyici varsayılanları gösteriliyor: dilimleyici sidecar bu özellikten eski olduğu için bir ön ayarın değerlerini bildiremiyor. Görmek için sidecar imajını güncelleyin. Değiştirmediğiniz her şey yine ön ayarı kullanır.',
+    presetValuesNotConfigured: 'Dilimleyici varsayılanları gösteriliyor: yapılandırılmış bir sidecar olmadığı için ön ayarın değerleri okunamıyor. Değiştirmediğiniz her şey yine ön ayarı kullanır.',
+    presetValuesSidecarUnavailable: 'Dilimleyici varsayılanları gösteriliyor: sidecar yanıt vermediği için ön ayarın değerleri okunamıyor. Değiştirmediğiniz her şey yine ön ayarı kullanır.',
+    presetValuesUnavailable: 'Dilimleyici varsayılanları gösteriliyor: seçilen ön ayarın kendi değerleri okunamadı. Değiştirmediğiniz her şey yine ön ayarı kullanır.',
+    filamentDefault: 'Varsayılan',
+    fromFile: 'dosyadan',
+    fromFileHint: 'Tasarımcı bunu kaynak dosyada değiştirdi. Değeri {{value}}.',
+    fromFilePrinterCoupled: 'tasarımcının yazıcısı',
+    fromFilePrinterCoupledHint: 'Bu dosyanın tasarlandığı yazıcıya göre ayarlanmıştır; sizinkinde yanlış veya aralık dışı olabilir.',
+    useFromFile: '{{option}} için kaynak dosyadaki değeri kullan',
+    otherFromFile: 'Bu dosyadaki diğer ayarlar',
+    loading: 'Dilimleyici ayarları yükleniyor…',
+    mode: {
+      simple: 'Basit',
+      advanced: 'Gelişmiş',
+      expert: 'Uzman',
+    },
+    searchPlaceholder: 'Ayarlarda ara',
+    resetAll: '{{count}} ayarı sıfırla',
+    resetOption: 'Varsayılana sıfırla',
+    noMatches: 'Bu aramayla eşleşen ayar yok.',
+  },
   slice: {
+    filamentSlotUnset: 'ayarlanmadı',
+    processSettingsEmbedded: '“Dosyanın yerleşik ayarlarını kullan” açıkken kullanılmaz — bu dilimlemeyi dosyanın kendi ayarları yönetir.',
+    processSettingsInactive: 'Etkin değil',
+    presetsHidden: '{{count}} gizli',
+    showAllPresets: 'Tümünü göster',
+    showFewerPresets: 'Daha az göster',
+    processSettings: 'İşlem ayarları',
+    processSettingsHint: 'Seçilen ön ayarı bu dilimleme için düzenleyin. Dokunmadığınız her şey ön ayardaki gibi kalır.',
+    processSettingsChanged: '{{count}} değişti',
+    processSettingsUnchanged: 'Ön ayar varsayılanları',
     title: 'Modeli dilimle',
     action: 'Dilimle',
     actionAll: 'Tüm {{count}} plakayı dilimle',
@@ -4298,11 +4336,6 @@ export default {
     autoOrientHint: 'Dilimleyici, dilimlemeden önce her nesneyi en iyi basılan yüzüne çevirir. Dosyada kayıtlı yönlendirmenin yerini alır.',
     autoArrange: 'Tablaya otomatik yerleştir',
     autoArrangeHint: 'Dilimleyici nesneleri üst üste binmeyecek şekilde yerleştirir. Dosyadaki yerleşimin yerini alır.',
-    designSettings: 'Tasarımcının ayarlarını koru',
-    designSettingsHint: 'Bu dosya standart profile göre {{count}} baskı ayarını değiştiriyor.',
-    designSettingsSelected: '{{total}} ayardan {{selected}} tanesi seçili',
-    designSettingsPrinterCoupled: 'yazıcıya özel',
-    designSettingsPrinterCoupledHint: 'Dosyanın tasarlandığı yazıcıya göre ayarlanmış — sizinkinde yanlış veya aralık dışı olabilir.',
     enqueuing: 'Dilimleme işi gönderiliyor…',
     queued: 'Kuyrukta…',
     failed: 'Dilimleme başarısız. Dilimleyici yardımcı bileşen günlüklerini kontrol edin.',
@@ -5534,10 +5567,8 @@ export default {
     openInSlicerFailed: 'Dilimleyicide açılamadı',
     tabs: {
       model: '3B Model',
-      gcode: 'G-kod Önizleme',
     },
     notAvailable: 'mevcut değil',
-    notSliced: 'dilimlenmemiş',
     plates: 'Plakalar',
     allPlates: 'Tüm Plakalar',
     plateNumber: 'Plaka {{number}}',
@@ -6963,12 +6994,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: '3D önizleme gömülemedi',
-    blockedBody: 'Bambuddy bu sayfanın G-code görüntüleyiciyi gömülü göstermesine izin veriyor, ancak tarayıcınızla Bambuddy arasındaki bir şey bunu reddediyor — genellikle kendi çerçeve başlığını gönderen bir ters proxy veya güvenlik eklentisi. Görüntüleyiciyi kendi sekmesinde açmak bundan etkilenmez.',
-    unavailableTitle: '3D önizleme kullanılamıyor',
-    unavailableBody: 'Bambuddy, G-code görüntüleyicinin dosyalarını sunamadı. Bu genellikle kurulumda gcode_viewer dizininin eksik olduğu anlamına gelir; başlangıç günlüğü de bunu belirtir.',
-    problemDetail: 'Sunucunun bildirdiği: {{detail}}',
-    openInNewTab: 'Görüntüleyiciyi yeni sekmede aç',
+    filamentSlot: 'Filament {{n}}',
+    loading: 'Takım yolu okunuyor…',
+    loadFailed: 'Bu dosyanın G-code’u yüklenemedi.',
+    showTravel: 'Boş hareketler',
+    topLayer: 'Üst katman',
+    bottomLayer: 'Alt katman',
+    noSource: 'Önizlenecek dosya belirtilmedi.',
+    view: {
+      filament: 'Filament',
+      feature: 'Öğe',
+      height: 'Yükseklik',
+      width: 'Genişlik',
+    },
+    feature: {
+      wall: 'Duvarlar',
+      sparseInfill: 'Seyrek dolgu',
+      solidInfill: 'Katı dolgu',
+      bridge: 'Köprü / çıkıntı',
+      support: 'Destek',
+      skirt: 'Etek / kenarlık',
+      gapFill: 'Boşluk dolgusu',
+      ironing: 'Ütüleme',
+      primeTower: 'Temizleme kulesi',
+    },
     back: 'Geri',
     backToArchives: 'Baskı Arşivlerine Dön',
     backToFiles: 'Dosya Yöneticisine Dön',

+ 62 - 13
frontend/src/i18n/locales/uk.ts

@@ -1807,6 +1807,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Рушій нарізання',
+    sliceEngineSidecar: 'Серверний sidecar',
+    sliceEngineSidecarHint: 'Нарізання виконується на сервері, у контейнері sidecar слайсера.',
+    sliceEngineBrowser: 'У браузері',
+    sliceEngineBrowserHint: 'Нарізання виконується на цьому пристрої, без сервера.',
     title: "Налаштування",
     general: "Загальні",
     // Tab names
@@ -4312,7 +4317,40 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    presetValuesOutdatedSidecar: 'Показано типові значення слайсера: ваш sidecar старіший за цю функцію і не може повідомити значення профілю. Оновіть образ sidecar, щоб їх побачити. Усе, чого ви не змінюєте, і далі береться з профілю.',
+    presetValuesNotConfigured: 'Показано типові значення слайсера: sidecar не налаштовано, тому значення профілю прочитати не можна. Усе, чого ви не змінюєте, і далі береться з профілю.',
+    presetValuesSidecarUnavailable: 'Показано типові значення слайсера: sidecar не відповів, тому значення профілю прочитати не можна. Усе, чого ви не змінюєте, і далі береться з профілю.',
+    presetValuesUnavailable: 'Показано типові значення слайсера: значення вибраного профілю не вдалося прочитати. Усе, чого ви не змінюєте, і далі береться з профілю.',
+    filamentDefault: 'За замовчуванням',
+    fromFile: 'з файлу',
+    fromFileHint: 'Автор моделі змінив цей параметр у вихідному файлі. Значення: {{value}}.',
+    fromFilePrinterCoupled: 'принтер автора',
+    fromFilePrinterCoupledHint: 'Підібрано під принтер, для якого створено файл, — на вашому значення може бути хибним або поза діапазоном.',
+    useFromFile: 'Використовувати значення з вихідного файлу для «{{option}}»',
+    otherFromFile: 'Інші параметри з цього файлу',
+    loading: 'Завантаження налаштувань слайсера…',
+    mode: {
+      simple: 'Простий',
+      advanced: 'Розширений',
+      expert: 'Експерт',
+    },
+    searchPlaceholder: 'Пошук параметрів',
+    resetAll: 'Скинути: {{count}}',
+    resetOption: 'Скинути до типового значення',
+    noMatches: 'Немає параметрів, що відповідають запиту.',
+  },
   slice: {
+    filamentSlotUnset: 'не задано',
+    processSettingsEmbedded: 'Не використовується, доки увімкнено «Використовувати вбудовані параметри файлу» — нарізанням керують параметри самого файлу.',
+    processSettingsInactive: 'Неактивно',
+    presetsHidden: 'приховано: {{count}}',
+    showAllPresets: 'Показати всі',
+    showFewerPresets: 'Показати менше',
+    processSettings: 'Параметри процесу',
+    processSettingsHint: 'Налаштуйте вибраний профіль для цього нарізання. Усе, чого ви не змінили, лишається як визначено профілем.',
+    processSettingsChanged: 'змінено: {{count}}',
+    processSettingsUnchanged: 'Значення профілю',
     title: "Нарізання моделі",
     action: "Нарізати",
     actionAll: "Нарізати всі пластини ({{count}})",
@@ -4342,11 +4380,6 @@ export default {
     autoOrientHint: "Слайсер поверне кожен об'єкт на бік, який друкується найкраще. Орієнтацію з файлу буде замінено.",
     autoArrange: 'Автоматично розмістити на столі',
     autoArrangeHint: "Слайсер розставить об'єкти так, щоб вони не перекривалися. Розташування з файлу буде замінено.",
-    designSettings: "Зберегти налаштування автора",
-    designSettingsHint: "Цей файл змінює {{count}} налаштувань друку порівняно зі стандартним профілем.",
-    designSettingsSelected: "Вибрано {{selected}} із {{total}}",
-    designSettingsPrinterCoupled: "залежить від принтера",
-    designSettingsPrinterCoupledHint: "Значення підібрано для принтера, під який створено файл, — на вашому воно може бути неправильним або поза допустимим діапазоном.",
     enqueuing: "Надсилання завдання нарізання…",
     queued: "У черзі…",
     failed: "Не вдалося виконати нарізання. Перевірте журнали слайсера.",
@@ -5613,10 +5646,8 @@ export default {
     openInSlicerFailed: "Не вдалося відкрити у слайсері",
     tabs: {
       model: "3D-модель",
-      gcode: "Попередній перегляд G-коду",
     },
     notAvailable: "недоступно",
-    notSliced: "не нарізано",
     plates: "Пластини",
     allPlates: "Усі пластини",
     plateNumber: "Пластина {{number}}",
@@ -7067,12 +7098,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: "Не вдалося вбудувати 3D-перегляд",
-    blockedBody: "Bambuddy дозволяє цій сторінці показувати переглядач G-code вбудованим, але щось між браузером і Bambuddy це відхиляє — зазвичай зворотний проксі або розширення безпеки, яке надсилає власний заголовок фрейму. Відкриття переглядача в окремій вкладці це не зачіпає.",
-    unavailableTitle: "3D-перегляд недоступний",
-    unavailableBody: "Bambuddy не зміг віддати файли переглядача G-code. Зазвичай це означає, що в установці бракує каталогу gcode_viewer; журнал запуску також про це повідомляє.",
-    problemDetail: "Повідомлення сервера: {{detail}}",
-    openInNewTab: "Відкрити переглядач у новій вкладці",
+    filamentSlot: 'Філамент {{n}}',
+    loading: 'Читання траєкторії…',
+    loadFailed: 'Не вдалося завантажити G-код цього файлу.',
+    showTravel: 'Холості переміщення',
+    topLayer: 'Верхній шар',
+    bottomLayer: 'Нижній шар',
+    noSource: 'Файл для перегляду не вказано.',
+    view: {
+      filament: 'Філамент',
+      feature: 'Тип',
+      height: 'Висота',
+      width: 'Ширина',
+    },
+    feature: {
+      wall: 'Стінки',
+      sparseInfill: 'Розріджене заповнення',
+      solidInfill: 'Суцільне заповнення',
+      bridge: 'Міст / навис',
+      support: 'Підтримка',
+      skirt: 'Спідниця / облямівка',
+      gapFill: 'Заповнення проміжків',
+      ironing: 'Розгладжування',
+      primeTower: 'Вежа очищення',
+    },
     back: "Назад",
     backToArchives: "Назад до друку архівів",
     backToFiles: "Назад до файлового менеджера",

+ 62 - 13
frontend/src/i18n/locales/zh-CN.ts

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: '切片引擎',
+    sliceEngineSidecar: '服务器 sidecar',
+    sliceEngineSidecarHint: '切片在服务器上的切片 sidecar 容器中运行。',
+    sliceEngineBrowser: '在浏览器中',
+    sliceEngineBrowserHint: '切片在本设备上运行,不经过服务器。',
     title: '设置',
     general: '通用',
     // Tab names
@@ -4267,7 +4272,40 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    presetValuesOutdatedSidecar: '当前显示切片器默认值:切片 sidecar 版本早于此功能,无法提供预设的实际值。更新 sidecar 镜像即可查看。未改动的项目仍使用预设。',
+    presetValuesNotConfigured: '当前显示切片器默认值:未配置切片 sidecar,无法读取预设的实际值。未改动的项目仍使用预设。',
+    presetValuesSidecarUnavailable: '当前显示切片器默认值:切片 sidecar 未响应,无法读取预设的实际值。未改动的项目仍使用预设。',
+    presetValuesUnavailable: '当前显示切片器默认值:无法读取所选预设的实际值。未改动的项目仍使用预设。',
+    filamentDefault: '默认',
+    fromFile: '来自文件',
+    fromFileHint: '设计者在源文件中修改了此项,其值为 {{value}}。',
+    fromFilePrinterCoupled: '设计者的打印机',
+    fromFilePrinterCoupledHint: '针对该文件设计时所用的打印机调校,在你的打印机上可能不正确或超出范围。',
+    useFromFile: '对 {{option}} 使用源文件中的值',
+    otherFromFile: '此文件中的其他设置',
+    loading: '正在加载切片设置…',
+    mode: {
+      simple: '简单',
+      advanced: '高级',
+      expert: '专家',
+    },
+    searchPlaceholder: '搜索设置',
+    resetAll: '重置 {{count}} 项',
+    resetOption: '恢复默认值',
+    noMatches: '没有与搜索匹配的设置。',
+  },
   slice: {
+    filamentSlotUnset: '未设置',
+    processSettingsEmbedded: '启用“使用文件内置设置”时不生效——本次切片由文件自身的设置决定。',
+    processSettingsInactive: '未启用',
+    presetsHidden: '已隐藏 {{count}} 项',
+    showAllPresets: '显示全部',
+    showFewerPresets: '显示较少',
+    processSettings: '工艺设置',
+    processSettingsHint: '为本次切片调整所选预设。未改动的项目仍按预设定义。',
+    processSettingsChanged: '已更改 {{count}} 项',
+    processSettingsUnchanged: '预设默认值',
     title: '切片模型',
     action: '切片',
     actionAll: '切片全部 {{count}} 个盘面',
@@ -4297,11 +4335,6 @@ export default {
     autoOrientHint: '切片前由切片器把每个模型转到最适合打印的一面,会覆盖文件中保存的朝向。',
     autoArrange: '自动排布在热床上',
     autoArrangeHint: '由切片器重新摆放模型,使其不再重叠,会替换文件自带的布局。',
-    designSettings: '保留设计者的设置',
-    designSettingsHint: '此文件相对标准配置修改了 {{count}} 项打印设置。',
-    designSettingsSelected: '已选择 {{selected}} / {{total}}',
-    designSettingsPrinterCoupled: '与打印机相关',
-    designSettingsPrinterCoupledHint: '该值是为此文件面向的打印机调校的,在你的打印机上可能不正确或超出范围。',
     enqueuing: '提交切片任务中…',
     queued: '已排队…',
     failed: '切片失败。请检查切片器 sidecar 日志。',
@@ -5559,10 +5592,8 @@ export default {
     openInSlicerFailed: '无法在切片软件中打开',
     tabs: {
       model: '3D 模型',
-      gcode: 'G-code 预览',
     },
     notAvailable: '不可用',
-    notSliced: '未切片',
     plates: '板',
     allPlates: '所有板',
     plateNumber: '板 {{number}}',
@@ -7011,12 +7042,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: '无法嵌入 3D 预览',
-    blockedBody: 'Bambuddy 允许此页面内嵌显示 G-code 查看器,但浏览器与 Bambuddy 之间的某个环节拒绝了它 — 通常是发送自有框架标头的反向代理或安全插件。在独立标签页中打开查看器不受影响。',
-    unavailableTitle: '3D 预览不可用',
-    unavailableBody: 'Bambuddy 无法提供 G-code 查看器的文件。这通常表示安装中缺少 gcode_viewer 目录;启动日志中也会有相应记录。',
-    problemDetail: '服务器报告:{{detail}}',
-    openInNewTab: '在新标签页中打开查看器',
+    filamentSlot: '耗材 {{n}}',
+    loading: '正在读取走刀路径…',
+    loadFailed: '无法加载此文件的 G-code。',
+    showTravel: '空驶移动',
+    topLayer: '顶层',
+    bottomLayer: '底层',
+    noSource: '未指定要预览的文件。',
+    view: {
+      filament: '耗材',
+      feature: '类型',
+      height: '高度',
+      width: '宽度',
+    },
+    feature: {
+      wall: '墙体',
+      sparseInfill: '稀疏填充',
+      solidInfill: '实心填充',
+      bridge: '桥接 / 悬垂',
+      support: '支撑',
+      skirt: '裙边 / 边缘',
+      gapFill: '间隙填充',
+      ironing: '熨烫',
+      primeTower: '擦料塔',
+    },
     back: '返回',
     backToArchives: '返回打印归档',
     backToFiles: '返回文件管理器',

+ 62 - 13
frontend/src/i18n/locales/zh-TW.ts

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: '切片引擎',
+    sliceEngineSidecar: '伺服器 sidecar',
+    sliceEngineSidecarHint: '切片在伺服器上的切片 sidecar 容器中執行。',
+    sliceEngineBrowser: '在瀏覽器中',
+    sliceEngineBrowserHint: '切片在本裝置上執行,不經過伺服器。',
     title: '設定',
     general: '通用',
     // Tab names
@@ -4267,7 +4272,40 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    presetValuesOutdatedSidecar: '目前顯示切片器預設值:切片 sidecar 版本早於此功能,無法提供預設的實際值。更新 sidecar 映像即可查看。未變更的項目仍使用預設。',
+    presetValuesNotConfigured: '目前顯示切片器預設值:未設定切片 sidecar,無法讀取預設的實際值。未變更的項目仍使用預設。',
+    presetValuesSidecarUnavailable: '目前顯示切片器預設值:切片 sidecar 未回應,無法讀取預設的實際值。未變更的項目仍使用預設。',
+    presetValuesUnavailable: '目前顯示切片器預設值:無法讀取所選預設的實際值。未變更的項目仍使用預設。',
+    filamentDefault: '預設',
+    fromFile: '來自檔案',
+    fromFileHint: '設計者在來源檔案中修改了此項,其值為 {{value}}。',
+    fromFilePrinterCoupled: '設計者的印表機',
+    fromFilePrinterCoupledHint: '針對該檔案設計時所用的印表機調校,在你的印表機上可能不正確或超出範圍。',
+    useFromFile: '對 {{option}} 使用來源檔案中的值',
+    otherFromFile: '此檔案中的其他設定',
+    loading: '正在載入切片設定…',
+    mode: {
+      simple: '簡易',
+      advanced: '進階',
+      expert: '專家',
+    },
+    searchPlaceholder: '搜尋設定',
+    resetAll: '重設 {{count}} 項',
+    resetOption: '恢復預設值',
+    noMatches: '沒有符合搜尋的設定。',
+  },
   slice: {
+    filamentSlotUnset: '未設定',
+    processSettingsEmbedded: '啟用「使用檔案內建設定」時不生效——本次切片由檔案自身的設定決定。',
+    processSettingsInactive: '未啟用',
+    presetsHidden: '已隱藏 {{count}} 項',
+    showAllPresets: '顯示全部',
+    showFewerPresets: '顯示較少',
+    processSettings: '列印參數',
+    processSettingsHint: '為本次切片調整所選預設。未變更的項目仍依預設定義。',
+    processSettingsChanged: '已變更 {{count}} 項',
+    processSettingsUnchanged: '預設預設值',
     title: '切片模型',
     action: '切片',
     actionAll: '切片全部 {{count}} 個盤面',
@@ -4297,11 +4335,6 @@ export default {
     autoOrientHint: '切片前由切片器將每個模型轉到最適合列印的一面,會覆蓋檔案中儲存的朝向。',
     autoArrange: '自動排列在熱床上',
     autoArrangeHint: '由切片器重新擺放模型,使其不再重疊,會取代檔案自帶的版面配置。',
-    designSettings: '保留設計者的設定',
-    designSettingsHint: '此檔案相對標準設定檔修改了 {{count}} 項列印設定。',
-    designSettingsSelected: '已選擇 {{selected}} / {{total}}',
-    designSettingsPrinterCoupled: '與印表機相關',
-    designSettingsPrinterCoupledHint: '此值是為該檔案面向的印表機調校的,在你的印表機上可能不正確或超出範圍。',
     enqueuing: '提交切片任務中…',
     queued: '已排隊…',
     failed: '切片失敗。請檢查切片器 sidecar 日誌。',
@@ -5559,10 +5592,8 @@ export default {
     openInSlicerFailed: '無法在切片軟體中開啟',
     tabs: {
       model: '3D 模型',
-      gcode: 'G-code 預覽',
     },
     notAvailable: '不可用',
-    notSliced: '未切片',
     plates: '板',
     allPlates: '所有板',
     plateNumber: '板 {{number}}',
@@ -7011,12 +7042,30 @@ export default {
     },
   },
   gcodeViewer: {
-    blockedTitle: '無法嵌入 3D 預覽',
-    blockedBody: 'Bambuddy 允許此頁面內嵌顯示 G-code 檢視器,但瀏覽器與 Bambuddy 之間的某個環節拒絕了它 — 通常是傳送自有框架標頭的反向代理或安全外掛。在獨立分頁中開啟檢視器不受影響。',
-    unavailableTitle: '3D 預覽無法使用',
-    unavailableBody: 'Bambuddy 無法提供 G-code 檢視器的檔案。這通常表示安裝中缺少 gcode_viewer 目錄;啟動記錄中也會有相應紀錄。',
-    problemDetail: '伺服器回報:{{detail}}',
-    openInNewTab: '在新分頁中開啟檢視器',
+    filamentSlot: '耗材 {{n}}',
+    loading: '正在讀取走刀路徑…',
+    loadFailed: '無法載入此檔案的 G-code。',
+    showTravel: '空跑移動',
+    topLayer: '頂層',
+    bottomLayer: '底層',
+    noSource: '未指定要預覽的檔案。',
+    view: {
+      filament: '耗材',
+      feature: '類型',
+      height: '高度',
+      width: '寬度',
+    },
+    feature: {
+      wall: '牆體',
+      sparseInfill: '稀疏填充',
+      solidInfill: '實心填充',
+      bridge: '橋接 / 懸垂',
+      support: '支撐',
+      skirt: '裙邊 / 邊緣',
+      gapFill: '間隙填充',
+      ironing: '熨燙',
+      primeTower: '擦料塔',
+    },
     back: '返回',
     backToArchives: '返回列印歸檔',
     backToFiles: '返回檔案管理器',

+ 471 - 0
frontend/src/lib/gcodeToolpath.ts

@@ -0,0 +1,471 @@
+/**
+ * Parses a G-code file into the per-layer form the vendored libvgcode renderer
+ * consumes (`src/lib/vendor/toolpathRenderer.js`).
+ *
+ * This is the piece that does not exist upstream. `three-slicer` renders its own
+ * slicing kernel's output and ships no G-code parser at all, so a preview of a
+ * *file* -- which is all Bambuddy ever has -- needs the toolpath reconstructed
+ * from the text.
+ *
+ * The renderer's input is one entry per layer:
+ *
+ *     { z, paths: Float32Array (stride 8), widths: number[] }
+ *
+ * where each stride-8 record is `x0, y0, z0, type, x1, y1, z1, _` and `type` is
+ * 0 for a travel move or a feature index otherwise. Layer height is derived by
+ * the renderer from the gaps between consecutive `z` values, so layers must
+ * arrive in print order.
+ *
+ * Deliberately hand-rolled rather than reusing `gcode-preview`'s parser: that
+ * one models moves for a line renderer and keeps `;TYPE:` only as an opaque
+ * comment string, so the feature classification below -- the thing that makes a
+ * preview readable -- would have to be written here anyway.
+ */
+
+/**
+ * Feature indices the renderer's palette is keyed on. Values are fixed by
+ * `TYPE_COLOR` in the vendored module, which took them from libvgcode; changing
+ * one silently recolours the preview.
+ */
+export const ToolpathType = {
+  travel: 0,
+  wall: 1,
+  sparseInfill: 2,
+  solidInfill: 3,
+  skirt: 4,
+  support: 5,
+  raft: 6,
+  gapFill: 7,
+  thinWall: 8,
+  bridge: 9,
+  ironing: 10,
+  primeTower: 11,
+} as const;
+
+/**
+ * `;TYPE:` values as OrcaSlicer and BambuStudio emit them, lowercased.
+ *
+ * Both spell several of these differently across versions ("Overhang wall" vs
+ * "Overhang perimeter"), and PrusaSlicer-lineage names turn up in third-party
+ * files, so the table is deliberately generous. Anything unrecognised falls
+ * back to `wall`, which is visually neutral -- better a mis-coloured segment
+ * than a missing one, since an unknown type must never drop geometry.
+ */
+const FEATURE_BY_COMMENT: Record<string, number> = {
+  'outer wall': ToolpathType.wall,
+  'inner wall': ToolpathType.wall,
+  perimeter: ToolpathType.wall,
+  'external perimeter': ToolpathType.wall,
+  'overhang wall': ToolpathType.bridge,
+  'overhang perimeter': ToolpathType.bridge,
+  'sparse infill': ToolpathType.sparseInfill,
+  'internal infill': ToolpathType.sparseInfill,
+  'solid infill': ToolpathType.solidInfill,
+  'internal solid infill': ToolpathType.solidInfill,
+  'top surface': ToolpathType.solidInfill,
+  'top solid infill': ToolpathType.solidInfill,
+  'bottom surface': ToolpathType.solidInfill,
+  skirt: ToolpathType.skirt,
+  'skirt/brim': ToolpathType.skirt,
+  brim: ToolpathType.skirt,
+  support: ToolpathType.support,
+  'support material': ToolpathType.support,
+  'support interface': ToolpathType.support,
+  'support material interface': ToolpathType.support,
+  'support transition': ToolpathType.support,
+  raft: ToolpathType.raft,
+  'gap fill': ToolpathType.gapFill,
+  'gap infill': ToolpathType.gapFill,
+  'thin wall': ToolpathType.thinWall,
+  // Bambu-only names.
+  'floating vertical shell': ToolpathType.solidInfill,
+  'internal bridge': ToolpathType.bridge,
+  'bottom shell': ToolpathType.solidInfill,
+  bridge: ToolpathType.bridge,
+  'bridge infill': ToolpathType.bridge,
+  'internal bridge infill': ToolpathType.bridge,
+  ironing: ToolpathType.ironing,
+  'prime tower': ToolpathType.primeTower,
+  'wipe tower': ToolpathType.primeTower,
+  custom: ToolpathType.wall,
+};
+
+/** One layer in the shape `buildSegmentData` expects. */
+export interface ToolpathLayer {
+  z: number;
+  paths: Float32Array;
+  widths: number[];
+}
+
+export interface ParsedToolpath {
+  layers: ToolpathLayer[];
+  /** Extruding segments, excluding travels. */
+  segmentCount: number;
+  travelCount: number;
+  /** Nozzle/line width seen in the file, for the renderer's fallback. */
+  defaultWidth: number;
+  bounds: { min: [number, number, number]; max: [number, number, number] } | null;
+}
+
+const RECORD_STRIDE = 8;
+const TAU = Math.PI * 2;
+/** Chord flatness for arc interpolation, in mm. Below an extrusion width. */
+const ARC_TOLERANCE_MM = 0.02;
+/** Ceiling on chords per arc, so a huge radius cannot blow up the buffer. */
+const ARC_MAX_CHORDS = 256;
+/** Tool numbers above this are slicer sentinels, not filaments. */
+const MAX_TOOL = 15;
+
+/** Growable stride-8 record buffer; typed arrays cannot be pushed to. */
+class PathBuffer {
+  private data = new Float32Array(1024 * RECORD_STRIDE);
+  private count = 0;
+  readonly widths: number[] = [];
+
+  push(
+    x0: number, y0: number, z0: number,
+    type: number,
+    x1: number, y1: number, z1: number,
+    width: number,
+    tool: number,
+  ): void {
+    if ((this.count + 1) * RECORD_STRIDE > this.data.length) {
+      const grown = new Float32Array(this.data.length * 2);
+      grown.set(this.data);
+      this.data = grown;
+    }
+    const o = this.count * RECORD_STRIDE;
+    this.data[o] = x0;
+    this.data[o + 1] = y0;
+    this.data[o + 2] = z0;
+    this.data[o + 3] = type;
+    this.data[o + 4] = x1;
+    this.data[o + 5] = y1;
+    this.data[o + 6] = z1;
+    // Slot 7 is unread by the renderer, so the active filament rides along in
+    // it. That is what lets the viewer offer a filament-coloured view without
+    // parsing the file twice: it swaps slot 3 for slot 7 and rebuilds.
+    this.data[o + 7] = tool;
+    this.count += 1;
+    this.widths.push(width);
+  }
+
+  get length(): number {
+    return this.count;
+  }
+
+  /** Trimmed copy — the renderer walks the whole array, so slack would render. */
+  toFloat32Array(): Float32Array {
+    return this.data.slice(0, this.count * RECORD_STRIDE);
+  }
+}
+
+/** Most frequently seen key, or undefined when the tally is empty. */
+function modeOf(tally: Map<number, number>): number | undefined {
+  let best: number | undefined;
+  let bestCount = 0;
+  for (const [value, count] of tally) {
+    if (count > bestCount) {
+      best = value;
+      bestCount = count;
+    }
+  }
+  return best;
+}
+
+/** Reads a named axis out of a `G0`/`G1` line without allocating per token. */
+function readAxis(line: string, axis: string): number | undefined {
+  const at = line.indexOf(axis);
+  if (at < 0) return undefined;
+  // Guard against matching inside a comment or a word ("; X marks").
+  const value = Number.parseFloat(line.slice(at + 1));
+  return Number.isFinite(value) ? value : undefined;
+}
+
+/**
+ * Parse G-code into per-layer toolpath records.
+ *
+ * Relative extrusion (`M83`) and absolute (`M82`) are both handled, because
+ * Bambu writes relative and plenty of third-party files do not. Anything the
+ * parser cannot make sense of is skipped rather than guessed at.
+ */
+export function parseGcodeToolpath(gcode: string): ParsedToolpath {
+  const layers: ToolpathLayer[] = [];
+
+  let x = 0;
+  let y = 0;
+  let z = 0;
+  let e = 0;
+  let relativeExtrusion = false;
+  let feature: number = ToolpathType.wall;
+  let width = 0;
+  // Tally of observed widths. The *typical* one is wanted, not the largest: a
+  // file's widths range from a 0.09 gap fill to a 1.0 purge line, and taking
+  // the max made the fallback wildly too fat.
+  const widthTally = new Map<number, number>();
+  let segmentCount = 0;
+  let travelCount = 0;
+
+  let current = new PathBuffer();
+  let currentZ = 0;
+  // A file with explicit layer markers is trusted; without them, layers are
+  // inferred from the Z at which material is *laid down*.
+  let sawLayerMarker = false;
+  let pendingZ: number | null = null;
+  // Travels share the layer buffer, so "the buffer is empty" is not the same
+  // question as "this layer has laid anything down yet" -- and it is the first
+  // *extrusion* that fixes a layer's height.
+  let layerHasExtrusion = false;
+  // Active filament. BambuStudio also emits sentinel tool numbers
+  // (T65535 / T65279) around its own bookkeeping; those are not filaments.
+  let tool = 0;
+  // Suppresses a phantom segment from the origin: the machine's position is
+  // unknown until the first move sets it, and drawing from (0,0,0) put a stray
+  // line across the bed.
+  let hasPosition = false;
+
+  let minX = Infinity, minY = Infinity, minZ = Infinity;
+  let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
+
+  const flushLayer = () => {
+    if (current.length === 0) return;
+    layers.push({ z: currentZ, paths: current.toFloat32Array(), widths: current.widths });
+    current = new PathBuffer();
+    layerHasExtrusion = false;
+    if (pendingZ !== null) {
+      currentZ = pendingZ;
+      pendingZ = null;
+    }
+  };
+
+  /**
+   * Record one straight run from the current position, updating bounds. Shared
+   * by linear moves and by each chord an arc is flattened into.
+   */
+  const emit = (nx: number, ny: number, nz: number, extruding: boolean) => {
+    if (extruding) {
+      if (!sawLayerMarker && layerHasExtrusion && nz !== currentZ) flushLayer();
+      if (!layerHasExtrusion) {
+        currentZ = nz;
+        pendingZ = null;
+      }
+      layerHasExtrusion = true;
+
+      if (!hasPosition) {
+        hasPosition = true;
+        x = nx; y = ny; z = nz;
+        return;
+      }
+
+      current.push(x, y, z, feature, nx, ny, nz, width, tool);
+      segmentCount += 1;
+      if (nx < minX) minX = nx;
+      if (ny < minY) minY = ny;
+      if (nz < minZ) minZ = nz;
+      if (nx > maxX) maxX = nx;
+      if (ny > maxY) maxY = ny;
+      if (nz > maxZ) maxZ = nz;
+    } else if (hasPosition) {
+      current.push(x, y, z, ToolpathType.travel, nx, ny, nz, 0, tool);
+      travelCount += 1;
+    }
+    x = nx; y = ny; z = nz;
+    hasPosition = true;
+  };
+
+  for (const rawLine of gcode.split('\n')) {
+    const line = rawLine.trim();
+    if (line.length === 0) continue;
+
+    if (line.charCodeAt(0) === 59 /* ; */) {
+      // Slicer annotations, in either dialect. BambuStudio writes
+      // "; FEATURE: Outer wall" and "; CHANGE_LAYER"; OrcaSlicer and the
+      // PrusaSlicer lineage write ";TYPE:Outer wall" and ";LAYER_CHANGE".
+      // Reading only one of them is why an earlier version of this parser
+      // rendered a Bambu file as a single undifferentiated colour.
+      const body = line.slice(1).trimStart();
+      const colon = body.indexOf(':');
+      const key = (colon >= 0 ? body.slice(0, colon) : body).trim().toUpperCase();
+      const value = colon >= 0 ? body.slice(colon + 1).trim() : '';
+
+      if (key === 'FEATURE' || key === 'TYPE') {
+        feature = FEATURE_BY_COMMENT[value.toLowerCase()] ?? ToolpathType.wall;
+      } else if (key === 'LINE_WIDTH' || key === 'WIDTH') {
+        const parsed = Number.parseFloat(value);
+        if (Number.isFinite(parsed) && parsed > 0) {
+          width = parsed;
+          widthTally.set(parsed, (widthTally.get(parsed) ?? 0) + 1);
+        }
+      } else if (key === 'CHANGE_LAYER' || key === 'LAYER_CHANGE') {
+        // An explicit marker is authoritative: it is the only thing that
+        // distinguishes a real layer change from a travel Z-hop.
+        sawLayerMarker = true;
+        flushLayer();
+      } else if (key === 'Z_HEIGHT' || key === 'Z') {
+        const parsed = Number.parseFloat(value);
+        if (Number.isFinite(parsed)) pendingZ = parsed;
+      }
+      continue;
+    }
+
+    if (line.startsWith('M83')) {
+      relativeExtrusion = true;
+      continue;
+    }
+    if (line.startsWith('M82')) {
+      relativeExtrusion = false;
+      continue;
+    }
+    if (line.startsWith('G92')) {
+      const resetE = readAxis(line, 'E');
+      if (resetE !== undefined) e = resetE;
+      continue;
+    }
+    if (line.charCodeAt(0) === 84 /* T */) {
+      // Filament change. Values above the sensible tool range are BambuStudio
+      // sentinels around its own bookkeeping (T65535 / T65279), not filaments.
+      const picked = Number.parseInt(line.slice(1), 10);
+      if (Number.isFinite(picked) && picked >= 0 && picked <= MAX_TOOL) tool = picked;
+      continue;
+    }
+
+    const isArc = line.startsWith('G2 ') || line.startsWith('G3 ') || line.startsWith('G2') || line.startsWith('G3');
+    const isLinear = line.startsWith('G1') || line.startsWith('G0');
+    if (!isLinear && !isArc) continue;
+    // G20/G21/G28 etc. share the G-prefix; only the four move codes above are
+    // handled, and `startsWith('G2')` would otherwise swallow G20/G28.
+    if (isArc && !/^G[23](\s|$)/.test(line)) continue;
+    if (isLinear && !/^G[01](\s|$)/.test(line)) continue;
+
+    const nx = readAxis(line, 'X') ?? x;
+    const ny = readAxis(line, 'Y') ?? y;
+    const nz = readAxis(line, 'Z') ?? z;
+    const rawE = readAxis(line, 'E');
+
+    let extruded = 0;
+    if (rawE !== undefined) {
+      extruded = relativeExtrusion ? rawE : rawE - e;
+      e = rawE;
+    }
+    const extruding = extruded > 0;
+
+    if (isArc) {
+      // Arc move in the XY plane (every file seen uses G17, and I/J rather
+      // than R). Ignoring these dropped 706 extruding moves out of ~8500 in a
+      // single plate -- concentrated on curved walls and tree supports, which
+      // is precisely where the preview came out full of holes.
+      const i = readAxis(line, 'I') ?? 0;
+      const j = readAxis(line, 'J') ?? 0;
+      const cx = x + i;
+      const cy = y + j;
+      const radius = Math.hypot(i, j);
+
+      if (radius > 0) {
+        const startAngle = Math.atan2(y - cy, x - cx);
+        const endAngle = Math.atan2(ny - cy, nx - cx);
+        const clockwise = line.charCodeAt(1) === 50; /* G2 */
+
+        let sweep = endAngle - startAngle;
+        if (clockwise) {
+          while (sweep >= 0) sweep -= TAU;
+          while (sweep < -TAU) sweep += TAU;
+        } else {
+          while (sweep <= 0) sweep += TAU;
+          while (sweep > TAU) sweep -= TAU;
+        }
+        // A move with no X/Y is a full turn -- BambuStudio's helical travel
+        // lift -- and `P` says how many.
+        if (nx === x && ny === y) {
+          const turns = Math.max(1, Math.round(readAxis(line, 'P') ?? 1));
+          sweep = (clockwise ? -TAU : TAU) * turns;
+        }
+
+        // Chord count from a flatness tolerance rather than a fixed step, so a
+        // 40mm arc is not drawn with the same four chords as a 1mm one.
+        const maxStep = 2 * Math.acos(Math.max(-1, Math.min(1, 1 - ARC_TOLERANCE_MM / radius)));
+        const steps = Math.max(1, Math.min(ARC_MAX_CHORDS, Math.ceil(Math.abs(sweep) / Math.max(maxStep, 1e-3))));
+
+        for (let step = 1; step <= steps; step += 1) {
+          const fraction = step / steps;
+          const angle = startAngle + sweep * fraction;
+          emit(
+            cx + radius * Math.cos(angle),
+            cy + radius * Math.sin(angle),
+            z + (nz - z) * fraction,
+            extruding,
+          );
+        }
+        continue;
+      }
+      // Degenerate arc (no radius): fall through and treat it as a straight
+      // move rather than dropping the geometry.
+    }
+
+    if (nx !== x || ny !== y || nz !== z) emit(nx, ny, nz, extruding);
+  }
+
+  flushLayer();
+
+  return {
+    layers,
+    segmentCount,
+    travelCount,
+    defaultWidth: modeOf(widthTally) ?? 0.42,
+    bounds: segmentCount > 0 ? { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] } : null,
+  };
+}
+
+/**
+ * Re-key a parsed toolpath so each record's *type* is its filament rather than
+ * its feature, for a filament-coloured view.
+ *
+ * Cheaper than parsing twice, and it has to be a copy rather than an in-place
+ * edit: the renderer merges adjacent vertices only when their type matches, so
+ * the two colourings genuinely produce different vertex streams and cannot
+ * share one built mesh.
+ *
+ * Travels keep type 0 so they stay travels.
+ */
+export function layersByFilament(layers: ToolpathLayer[]): ToolpathLayer[] {
+  return layers.map((layer) => {
+    const paths = layer.paths.slice();
+    for (let i = 0; i < paths.length; i += RECORD_STRIDE) {
+      if (paths[i + 3] !== ToolpathType.travel) {
+        // +1 so filament 0 does not collide with the travel index.
+        paths[i + 3] = Math.min(paths[i + 7] + 1, MAX_TOOL);
+      }
+    }
+    return { ...layer, paths };
+  });
+}
+
+/**
+ * Drop records whose type is hidden, so they never reach the renderer.
+ *
+ * Hiding has to happen here rather than by recolouring: the shader packs
+ * colour into a single float with no alpha channel, so there is no
+ * "transparent" to set. Removing the records is also what makes hiding
+ * useful -- a hidden support genuinely stops occluding the model behind it.
+ */
+export function filterLayersByType(layers: ToolpathLayer[], hidden: ReadonlySet<number>): ToolpathLayer[] {
+  if (hidden.size === 0) return layers;
+
+  const out: ToolpathLayer[] = [];
+  for (const layer of layers) {
+    const kept = new PathBuffer();
+    for (let i = 0; i < layer.paths.length; i += RECORD_STRIDE) {
+      const type = layer.paths[i + 3];
+      if (hidden.has(type)) continue;
+      kept.push(
+        layer.paths[i], layer.paths[i + 1], layer.paths[i + 2], type,
+        layer.paths[i + 4], layer.paths[i + 5], layer.paths[i + 6],
+        layer.widths[i / RECORD_STRIDE] ?? 0,
+        layer.paths[i + 7],
+      );
+    }
+    // A layer emptied by the filter is still a layer: dropping it would
+    // renumber every layer above it and make the range slider lie.
+    out.push({ ...layer, paths: kept.toFloat32Array(), widths: kept.widths });
+  }
+  return out;
+}

+ 75 - 0
frontend/src/lib/sliceEngines.ts

@@ -0,0 +1,75 @@
+/**
+ * Registry of the available slicing engines.
+ *
+ * "Engine" here means *where slicing runs*, which is a separate axis from the
+ * `preferred_slicer` setting (that one only selects which slicer binary the
+ * server-side sidecar drives). Keeping them apart avoids having to represent
+ * combinations that don't exist — there is no browser build of BambuStudio, so
+ * a single dropdown mixing the two would offer choices that cannot work.
+ *
+ * Today exactly one engine is registered. The registry exists so that adding a
+ * browser/WASM engine is a matter of pushing a second entry: the settings card
+ * and the slice modal both derive their UI from `availableEngines()`, so a
+ * second engine makes the pickers appear without either of them changing.
+ *
+ * Deliberately *not* shipping a disabled "In browser" option in the meantime.
+ * An option a user can see but never pick reads as a broken feature, and there
+ * is nothing behind it yet: the WASM engine returns raw G-code, while dispatch
+ * needs a `.gcode.3mf` container, so browser slicing cannot reach a printer
+ * until that packaging exists.
+ */
+
+export type SliceEngineId = 'sidecar' | 'browser';
+
+export interface SliceEngine {
+  id: SliceEngineId;
+  /** i18n key for the human-readable name. */
+  labelKey: string;
+  /** i18n key for the one-line explanation shown under the picker. */
+  descriptionKey: string;
+  /**
+   * False while an engine is defined but not yet usable. Unavailable engines
+   * are never offered; they exist here so the surrounding code can be written
+   * against the full set rather than special-cased later.
+   */
+  available: boolean;
+}
+
+const ENGINES: SliceEngine[] = [
+  {
+    id: 'sidecar',
+    labelKey: 'settings.sliceEngineSidecar',
+    descriptionKey: 'settings.sliceEngineSidecarHint',
+    available: true,
+  },
+  {
+    id: 'browser',
+    labelKey: 'settings.sliceEngineBrowser',
+    descriptionKey: 'settings.sliceEngineBrowserHint',
+    available: false,
+  },
+];
+
+export const DEFAULT_SLICE_ENGINE: SliceEngineId = 'sidecar';
+
+/** Engines a user can actually pick right now. */
+export function availableEngines(): SliceEngine[] {
+  return ENGINES.filter((e) => e.available);
+}
+
+/** True when there is a real choice to present. */
+export function hasEngineChoice(): boolean {
+  return availableEngines().length > 1;
+}
+
+/**
+ * Resolves a stored or per-job engine id to one that can actually run.
+ *
+ * A setting can outlive the engine it names — an install that had browser
+ * slicing enabled and then loaded a build without it must still be able to
+ * slice, so an unavailable id falls back rather than failing.
+ */
+export function resolveEngine(id: string | null | undefined): SliceEngineId {
+  const match = availableEngines().find((e) => e.id === id);
+  return match?.id ?? DEFAULT_SLICE_ENGINE;
+}

+ 130 - 0
frontend/src/lib/slicerSettings.ts

@@ -0,0 +1,130 @@
+/**
+ * Conversion between the settings panel's editing values and the string forms
+ * OrcaSlicer / BambuStudio write into a process preset JSON.
+ *
+ * This matters more than it looks. The values we send are merged into the
+ * `--load-settings` process JSON, and that JSON is parsed by the slicer CLI,
+ * which validates far more strictly than the GUI: a percent option written as
+ * `"20"` instead of `"20%"` is a different value, and a bare `true` where the
+ * config expects `"1"` fails the parse outright. The panel therefore always
+ * serialises through the schema, never by guessing from the JavaScript type.
+ */
+
+import type { ProcessOption, ProcessSchema, SettingValue } from '../types/slicerSettings';
+
+/** Option types whose config value is a per-extruder vector. */
+const VECTOR_TYPES = new Set(['coBools', 'coFloats', 'coFloatsOrPercents']);
+
+export const isVectorOption = (option: ProcessOption): boolean => VECTOR_TYPES.has(option.type);
+
+/**
+ * Numeric bound from the schema, or `undefined` when it isn't a number at all.
+ * Float literals are normalised by the generator, but a handful of bounds are
+ * unresolved C++ expressions the extractor could not follow, and those must not
+ * reach an input's `min`/`max`.
+ */
+export function numericBound(bound: number | string | undefined): number | undefined {
+  if (typeof bound === 'number') return Number.isFinite(bound) ? bound : undefined;
+  if (typeof bound !== 'string') return undefined;
+  const n = Number.parseFloat(bound);
+  return Number.isFinite(n) ? n : undefined;
+}
+
+/**
+ * A unit suffix worth showing. A few entries carry an unresolved C++ expression
+ * where the extractor could not follow a reference (`def_x->sidetext`); showing
+ * that to a user would be worse than showing no unit at all.
+ */
+export function displaySidetext(option: ProcessOption): string | undefined {
+  const s = option.sidetext;
+  if (!s || s.includes('->') || s.includes('::')) return undefined;
+  return s;
+}
+
+/**
+ * What an untouched field shows.
+ *
+ * The picked preset's own value when we have it, else the option schema's
+ * compiled-in default. The distinction is user-visible: `line_width` defaults
+ * to 0 in OrcaSlicer's C++ (meaning "derive from the nozzle"), while a real
+ * process preset sets something like 0.42 — showing the former for a preset
+ * that sets the latter is simply wrong.
+ */
+export function baselineForDisplay(option: ProcessOption, presetValue?: SettingValue): string {
+  const d = presetValue !== undefined ? presetValue : option.default;
+  if (d === undefined) return '';
+  // Per-extruder vectors render as a comma-separated list. C++ literal
+  // artefacts (`0.`, `0.3f`, `100.%`) are normalised by
+  // scripts/generate-slicer-schema.mjs, so nothing needs unpicking here.
+  if (Array.isArray(d)) return d.map(String).join(', ');
+  if (typeof d === 'boolean') return d ? '1' : '0';
+  return String(d);
+}
+
+/**
+ * Serialises one edited value into its process-JSON form.
+ *
+ * Vector options are written back as arrays because that is how the config
+ * stores them; scalars become strings, which is what every Bambu process preset
+ * uses even for numeric options.
+ */
+export function serializeSetting(option: ProcessOption, value: SettingValue): string | string[] {
+  if (isVectorOption(option)) {
+    const parts = Array.isArray(value) ? value.map(String) : String(value).split(',');
+    return parts.map((p) => p.trim()).filter((p) => p !== '');
+  }
+
+  if (option.type === 'coBool') {
+    if (typeof value === 'boolean') return value ? '1' : '0';
+    return value === '1' || value === 'true' || value === 1 ? '1' : '0';
+  }
+
+  const raw = String(value).trim();
+
+  if (option.type === 'coPercent') {
+    // The config spells percents with the sign; the input edits the number.
+    return raw.endsWith('%') ? raw : `${raw}%`;
+  }
+
+  return raw;
+}
+
+/** Serialises the panel's sparse override map for the slice request. */
+export function serializeOverrides(values: Record<string, SettingValue>, schema: ProcessSchema): Record<string, string | string[]> {
+  const out: Record<string, string | string[]> = {};
+  for (const [key, value] of Object.entries(values)) {
+    const option = schema[key];
+    // A key with no schema entry cannot be serialised correctly, and sending it
+    // raw risks a slice failure that is hard to trace back to this panel.
+    if (!option) continue;
+    out[key] = serializeSetting(option, value);
+  }
+  return out;
+}
+
+/**
+ * True when an edited value differs from the baseline this slice would
+ * otherwise use. Marks modified rows, and decides what is worth sending: an
+ * override equal to what the preset already says is noise in the process JSON.
+ *
+ * The baseline is the preset's value when known. Comparing against the schema
+ * default instead would flag every field the preset moved off the C++ default
+ * as "changed by the user", and would send back values nobody typed.
+ */
+export function isModified(
+  option: ProcessOption,
+  value: SettingValue | undefined,
+  presetValue?: SettingValue,
+): boolean {
+  if (value === undefined || value === '') return false;
+
+  const flatten = (v: SettingValue): string => {
+    const serialized = serializeSetting(option, Array.isArray(v) ? v.map(String).join(', ') : v);
+    return Array.isArray(serialized) ? serialized.join(', ') : serialized;
+  };
+
+  const asString = flatten(value);
+  const baseline = presetValue !== undefined ? presetValue : option.default;
+  if (baseline === undefined) return asString !== '';
+  return asString !== flatten(baseline as SettingValue);
+}

+ 469 - 0
frontend/src/lib/slicerToggle.ts

@@ -0,0 +1,469 @@
+/**
+ * Evaluates OrcaSlicer's `toggle_print_fff_options` enable/disable rules so our
+ * process-settings panel greys out the same fields the real slicer does.
+ *
+ * The vendored `process-toggle-rules.json` carries the rules verbatim from the
+ * C++ source: each rule is a list of option keys plus an `enable_if` expression
+ * written in C++, referencing named locals that are themselves C++ expressions.
+ * Rather than hand-translate a subset (which is what upstream's own evaluator
+ * does — 11 of 68 locals, the rest silently enabled), this interprets the
+ * expressions directly and resolves locals recursively, so a local defined in
+ * terms of three other locals costs nothing extra to support.
+ *
+ * The cardinal rule is **fail open**: anything we cannot decide with certainty
+ * leaves the field enabled. A wrongly-greyed control hides a setting the user
+ * needs and looks like a bug; a wrongly-enabled one merely lets them set
+ * something the slicer will ignore, which is the pre-existing behaviour of every
+ * other settings surface in Bambuddy. Every `undefined` return below is that
+ * rule being applied, not an oversight.
+ *
+ * Deliberately not `eval` / `new Function`: the expressions are vendored data
+ * rather than user input, but the frontend runs under a CSP without
+ * `unsafe-eval` and a 120-line recursive-descent parser is easier to test than
+ * a regex pipeline that rewrites C++ into JavaScript.
+ */
+
+import type { ProcessSchema, SettingValue } from '../types/slicerSettings';
+
+/**
+ * A read of an enum-typed option, carrying the key so a comparison against a
+ * C++ enumerator can be checked against that option's declared values.
+ */
+interface EnumRead {
+  enumKey: string;
+  value: string | undefined;
+}
+
+/** A resolved expression value. `undefined` means "could not determine". */
+type Value = boolean | number | string | EnumRead | undefined;
+
+const isEnumRead = (v: Value): v is EnumRead => typeof v === 'object' && v !== null && 'enumKey' in v;
+
+/** A bare C++ enumerator (`ipGyroid`, `IroningType::NoIroning`) seen in an expression. */
+const ENUM_SYMBOL = 'enum:';
+
+// --- Config access ---------------------------------------------------------
+
+export interface ConfigReader {
+  /** Raw value for a key: the user's override if set, else the schema default. */
+  get(key: string): Value;
+  has(key: string): boolean;
+}
+
+/** Numeric view of a value: "20%" -> 20, [500] -> 500, "0.42" -> 0.42. */
+function asNumber(v: Value): number | undefined {
+  if (typeof v === 'number') return v;
+  if (typeof v === 'boolean') return v ? 1 : 0;
+  if (typeof v !== 'string') return undefined;
+  const n = Number.parseFloat(v);
+  return Number.isFinite(n) ? n : undefined;
+}
+
+function asBoolean(v: Value): boolean | undefined {
+  if (typeof v === 'boolean') return v;
+  if (typeof v === 'number') return v !== 0;
+  if (v === '1' || v === 'true') return true;
+  if (v === '0' || v === 'false') return false;
+  return undefined;
+}
+
+/**
+ * Reads settings with schema defaults behind them. Vector options (`coFloats`
+ * and friends) are per-extruder; every condition in the rule set tests the
+ * first entry, which is what `opt_float_nullable(key, variant_index)` reads for
+ * the active variant.
+ */
+export function makeConfigReader(settings: Record<string, SettingValue>, schema: ProcessSchema): ConfigReader {
+  const read = (key: string): Value => {
+    let v: unknown = settings[key];
+    if (v === undefined || v === '') v = schema[key]?.default;
+    if (Array.isArray(v)) v = v[0];
+    if (typeof v === 'boolean' || typeof v === 'number' || typeof v === 'string') return v;
+    return undefined;
+  };
+  return { get: read, has: (key) => key in schema };
+}
+
+// --- Tokenizer -------------------------------------------------------------
+
+type Token = { kind: 'num'; value: number } | { kind: 'str'; value: string } | { kind: 'id'; value: string } | { kind: 'op'; value: string };
+
+// Longest-first: `->` must be tried before `-`, `<=` before `<`.
+const OPERATORS = ['->', '||', '&&', '==', '!=', '<=', '>=', '(', ')', ',', '<', '>', '!'];
+
+function tokenize(src: string): Token[] | undefined {
+  const tokens: Token[] = [];
+  let i = 0;
+  while (i < src.length) {
+    const c = src[i];
+    if (c === ' ' || c === '\t' || c === '\n') {
+      i += 1;
+      continue;
+    }
+    if (c === '"') {
+      const end = src.indexOf('"', i + 1);
+      if (end < 0) return undefined;
+      tokens.push({ kind: 'str', value: src.slice(i + 1, end) });
+      i = end + 1;
+      continue;
+    }
+    // C++ float literals carry an `f` suffix (`0.3f`) that the extractor left
+    // intact in a few min/max bounds and defaults.
+    const num = /^\d+(\.\d*)?f?/.exec(src.slice(i));
+    if (num && /^[\d]/.test(c)) {
+      tokens.push({ kind: 'num', value: Number.parseFloat(num[0]) });
+      i += num[0].length;
+      continue;
+    }
+    const op = OPERATORS.find((o) => src.startsWith(o, i));
+    if (op) {
+      tokens.push({ kind: 'op', value: op });
+      i += op.length;
+      continue;
+    }
+    // Identifiers, including the `->`, `::`, `<>` decorations of the C++
+    // accessor forms; the parser strips those apart below.
+    const id = /^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)*/.exec(src.slice(i));
+    if (id) {
+      tokens.push({ kind: 'id', value: id[0] });
+      i += id[0].length;
+      continue;
+    }
+    return undefined; // Unknown character — fail open.
+  }
+  return tokens;
+}
+
+// --- Parser / evaluator ----------------------------------------------------
+
+/** Accessor names that read a config key named by their first string argument. */
+const ACCESSORS = new Set([
+  'opt_bool',
+  'opt_int',
+  'opt_float',
+  'opt_float_nullable',
+  'opt_int_nullable',
+  'opt_bool_nullable',
+  'opt_enum',
+  'opt_string',
+  'option',
+  'has',
+]);
+
+class Evaluator {
+  private tokens: Token[] = [];
+  private pos = 0;
+
+  private readonly cfg: ConfigReader;
+  private readonly locals: Record<string, string>;
+  private readonly schema: ProcessSchema;
+  /** Locals currently being resolved — guards the (unlikely) cyclic definition. */
+  private readonly resolving: Set<string>;
+  private readonly memo: Map<string, Value>;
+
+  constructor(cfg: ConfigReader, locals: Record<string, string>, schema: ProcessSchema, resolving: Set<string>, memo: Map<string, Value>) {
+    this.cfg = cfg;
+    this.locals = locals;
+    this.schema = schema;
+    this.resolving = resolving;
+    this.memo = memo;
+  }
+
+  evaluate(expr: string): Value {
+    const tokens = tokenize(expr);
+    if (!tokens || tokens.length === 0) return undefined;
+    this.tokens = tokens;
+    this.pos = 0;
+    const value = this.parseOr();
+    // Trailing tokens mean we misread the grammar; don't trust a partial parse.
+    if (this.pos !== this.tokens.length) return undefined;
+    return value;
+  }
+
+  private peek(): Token | undefined {
+    return this.tokens[this.pos];
+  }
+
+  private eatOp(op: string): boolean {
+    const t = this.peek();
+    if (t && t.kind === 'op' && t.value === op) {
+      this.pos += 1;
+      return true;
+    }
+    return false;
+  }
+
+  private parseOr(): Value {
+    let left = this.parseAnd();
+    while (this.eatOp('||')) {
+      const right = this.parseAnd();
+      const l = asBoolean(left);
+      const r = asBoolean(right);
+      // Short-circuit truth survives an unknown operand: `true || ???` is true.
+      if (l === true || r === true) left = true;
+      else if (l === undefined || r === undefined) left = undefined;
+      else left = l || r;
+    }
+    return left;
+  }
+
+  private parseAnd(): Value {
+    let left = this.parseComparison();
+    while (this.eatOp('&&')) {
+      const right = this.parseComparison();
+      const l = asBoolean(left);
+      const r = asBoolean(right);
+      if (l === false || r === false) left = false;
+      else if (l === undefined || r === undefined) left = undefined;
+      else left = l && r;
+    }
+    return left;
+  }
+
+  private parseComparison(): Value {
+    const left = this.parseUnary();
+    for (const op of ['==', '!=', '<=', '>=', '<', '>']) {
+      if (this.eatOp(op)) {
+        const right = this.parseUnary();
+        return compare(left, right, op, this.schema);
+      }
+    }
+    return left;
+  }
+
+  private parseUnary(): Value {
+    if (this.eatOp('!')) {
+      const v = asBoolean(this.parseUnary());
+      return v === undefined ? undefined : !v;
+    }
+    return this.parsePrimary();
+  }
+
+  private parsePrimary(): Value {
+    const t = this.peek();
+    if (!t) return undefined;
+
+    if (t.kind === 'num') {
+      this.pos += 1;
+      return t.value;
+    }
+    if (t.kind === 'str') {
+      this.pos += 1;
+      return t.value;
+    }
+    if (t.kind === 'op' && t.value === '(') {
+      this.pos += 1;
+      const v = this.parseOr();
+      if (!this.eatOp(')')) return undefined;
+      return v;
+    }
+    if (t.kind !== 'id') return undefined;
+    this.pos += 1;
+
+    if (t.value === 'true') return true;
+    if (t.value === 'false') return false;
+
+    // `config->opt_bool("key")`, `config->option<ConfigOptionFloat>("key")->value`
+    if (t.value === 'config') return this.parseConfigAccess();
+
+    // A bare identifier is either a named local or a C++ enum symbol.
+    const local = this.locals[t.value];
+    if (local !== undefined) return this.resolveLocal(t.value, local);
+    // Not a local, so it is a C++ enumerator; `compare` decides whether it can
+    // be matched against the other side's declared enum values.
+    return `${ENUM_SYMBOL}${t.value}`;
+  }
+
+  /** Consumes the `->accessor<T>("key")` tail after a `config` identifier. */
+  private parseConfigAccess(): Value {
+    if (!this.eatOp('->')) return undefined;
+    const name = this.peek();
+    if (!name || name.kind !== 'id' || !ACCESSORS.has(name.value)) return undefined;
+    this.pos += 1;
+
+    // Optional `<ConfigOptionFloat>` / `<InfillPattern>` template argument.
+    if (this.eatOp('<')) {
+      let depth = 1;
+      while (depth > 0) {
+        const tok = this.peek();
+        if (!tok) return undefined;
+        this.pos += 1;
+        if (tok.kind === 'op' && tok.value === '<') depth += 1;
+        if (tok.kind === 'op' && tok.value === '>') depth -= 1;
+      }
+    }
+
+    if (!this.eatOp('(')) return undefined;
+    const arg = this.peek();
+    if (!arg || arg.kind !== 'str') return undefined;
+    this.pos += 1;
+    const key = arg.value;
+    // Skip any further arguments (`, variant_index`, `, 0`).
+    while (this.eatOp(',')) {
+      let depth = 0;
+      for (;;) {
+        const tok = this.peek();
+        if (!tok) return undefined;
+        if (tok.kind === 'op' && tok.value === '(') depth += 1;
+        if (tok.kind === 'op' && tok.value === ')') {
+          if (depth === 0) break;
+          depth -= 1;
+        }
+        if (tok.kind === 'op' && tok.value === ',' && depth === 0) break;
+        this.pos += 1;
+      }
+    }
+    if (!this.eatOp(')')) return undefined;
+
+    // `config->option<T>("key")->value` — consume the trailing member access.
+    if (this.eatOp('->')) {
+      const member = this.peek();
+      if (!member || member.kind !== 'id') return undefined;
+      this.pos += 1;
+    }
+
+    if (name.value === 'has') return this.cfg.has(key);
+
+    const raw = this.cfg.get(key);
+    // Tag reads of enum options so a comparison against a C++ enumerator can
+    // validate its transliteration against this option's declared values.
+    if (this.schema[key]?.enum_values) {
+      return { enumKey: key, value: typeof raw === 'string' ? raw : undefined };
+    }
+    return raw;
+  }
+
+  private resolveLocal(name: string, source: string): Value {
+    const cached = this.memo.get(name);
+    if (cached !== undefined || this.memo.has(name)) return cached;
+    if (this.resolving.has(name)) return undefined;
+
+    this.resolving.add(name);
+    const nested = new Evaluator(this.cfg, this.locals, this.schema, this.resolving, this.memo);
+    const value = nested.evaluate(source);
+    this.resolving.delete(name);
+
+    this.memo.set(name, value);
+    return value;
+  }
+}
+
+/**
+ * Compares two resolved values.
+ *
+ * The interesting case is an enum option tested against a C++ enumerator —
+ * `config->opt_enum<IroningType>("ironing_type") != IroningType::NoIroning`.
+ * OrcaSlicer's enumerator spellings and its serialised config values are
+ * related but not identical (`btNoBrim` -> `no_brim`, `NoIroning` ->
+ * `no ironing`), so we generate the plausible spellings and only trust the
+ * result when exactly one of them is a value the option actually declares.
+ * A transliteration that matches nothing yields `undefined`, not a confident
+ * `false` that would grey out a field for the wrong reason.
+ */
+function compare(left: Value, right: Value, op: string, schema: ProcessSchema): Value {
+  const symbolSide = typeof left === 'string' && left.startsWith(ENUM_SYMBOL) ? left : typeof right === 'string' && right.startsWith(ENUM_SYMBOL) ? right : undefined;
+
+  if (symbolSide !== undefined) {
+    if (op !== '==' && op !== '!=') return undefined;
+    const other = symbolSide === left ? right : left;
+    if (!isEnumRead(other)) return undefined;
+
+    const declared = schema[other.enumKey]?.enum_values;
+    if (!declared || other.value === undefined) return undefined;
+
+    const matches = enumCandidates(symbolSide.slice(ENUM_SYMBOL.length)).filter((c) => declared.includes(c));
+    if (matches.length !== 1) return undefined;
+
+    const equal = matches[0] === other.value;
+    return op === '==' ? equal : !equal;
+  }
+
+  // An enum read compared against anything else is only meaningful by value.
+  const l0 = isEnumRead(left) ? left.value : left;
+  const r0 = isEnumRead(right) ? right.value : right;
+
+  if (op === '==' || op === '!=') {
+    if (l0 === undefined || r0 === undefined) return undefined;
+    const equal = typeof l0 === 'string' || typeof r0 === 'string' ? String(l0) === String(r0) : asNumber(l0) === asNumber(r0);
+    return op === '==' ? equal : !equal;
+  }
+
+  const l = asNumber(l0);
+  const r = asNumber(r0);
+  if (l === undefined || r === undefined) return undefined;
+  if (op === '<') return l < r;
+  if (op === '<=') return l <= r;
+  if (op === '>') return l > r;
+  if (op === '>=') return l >= r;
+  return undefined;
+}
+
+/**
+ * Plausible config spellings for a C++ enumerator.
+ *
+ * `IroningType::NoIroning` -> ["no_ironing", "no ironing", "noironing"]
+ * `btNoBrim`               -> ["no_brim", "no brim", "nobrim"]
+ */
+function enumCandidates(symbol: string): string[] {
+  const bare = symbol.includes('::') ? symbol.slice(symbol.lastIndexOf('::') + 2) : symbol;
+  // Enumerators are either bare PascalCase or PascalCase behind a lowercase
+  // type tag (ip*, bt*, sms*); try both readings.
+  const cores = [bare, /^[a-z]+([A-Z].*)$/.exec(bare)?.[1]].filter((c): c is string => Boolean(c));
+
+  const out = new Set<string>();
+  for (const core of cores) {
+    const snake = core.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
+    out.add(snake);
+    out.add(snake.replace(/_/g, ' '));
+    out.add(snake.replace(/_/g, ''));
+  }
+  return [...out];
+}
+
+// --- Public API ------------------------------------------------------------
+
+export interface ToggleRules {
+  locals: Record<string, string>;
+  rules: Array<{ fields: string[]; enable_if: string }>;
+}
+
+/**
+ * Returns the set of option keys the current settings disable.
+ *
+ * Only rules that evaluate to a definite `false` contribute; unknown and true
+ * both leave the field enabled.
+ */
+export function disabledKeys(settings: Record<string, SettingValue>, schema: ProcessSchema, toggles: ToggleRules): Set<string> {
+  const cfg = makeConfigReader(settings, schema);
+  const memo = new Map<string, Value>();
+  const disabled = new Set<string>();
+
+  for (const rule of toggles.rules) {
+    // The C++ helper takes `(expr, variant_index)`; only the first part is the
+    // condition, the rest selects which extruder variant to read.
+    const condition = splitCondition(rule.enable_if);
+    if (!condition) continue;
+    const evaluator = new Evaluator(cfg, toggles.locals, schema, new Set(), memo);
+    if (asBoolean(evaluator.evaluate(condition)) === false) {
+      for (const field of rule.fields) disabled.add(field);
+    }
+  }
+  return disabled;
+}
+
+/**
+ * Takes the condition off an `enable_if` payload, dropping a trailing
+ * `variant_index` argument. Only parentheses count towards nesting: every
+ * argument-bearing call in the rule set is parenthesised, while `<` and `>`
+ * appear far more often as comparisons than as template brackets.
+ */
+function splitCondition(expr: string): string | undefined {
+  let depth = 0;
+  for (let i = 0; i < expr.length; i += 1) {
+    const c = expr[i];
+    if (c === '(') depth += 1;
+    else if (c === ')') depth -= 1;
+    else if (c === ',' && depth === 0) return expr.slice(0, i).trim() || undefined;
+  }
+  return expr.trim() || undefined;
+}

+ 106 - 0
frontend/src/lib/vendor/toolpathRenderer.d.ts

@@ -0,0 +1,106 @@
+// Types for ./toolpathRenderer.js -- see that file for provenance and licence.
+// three-slicer/viewer/toolpath — 커널 레이어 → GPU 인스턴싱 툴패스.
+// three 를 import 하지 않는다: makeToolpath 가 THREE 네임스페이스를 인자로 받는다(단일 인스턴스 보장).
+
+/** 8정점 다이아몬드 단면의 24 삼각형 인덱스 (SegmentTemplate.cpp:18 원본) */
+export const VERTEX_DATA: number[]
+
+/** 툴패스 타입 → 색. 0=travel, 1=wall … 11=prime */
+export const TYPE_COLOR: Record<number, number[]>
+export const TYPE_LABEL: Record<number, string>
+
+/** 파랑→빨강 11색 히트맵 (libvgcode ColorRange.hpp:14) */
+export const DEFAULT_RANGES_COLORS: number[][]
+
+export interface ViewTypeDef {
+  key: 'feature' | 'speed' | 'height' | 'width' | 'fan' | 'temp'
+  label: string
+  /** 연속값(히트맵)인가. false 면 고정색. */
+  cont: boolean
+  unit: string
+}
+export const VIEW_TYPES: ViewTypeDef[]
+
+/** per-vertex 부가 정보 */
+export interface SegmentMeta {
+  vType: Uint8Array
+  vWidth: Float32Array
+  vHeight: Float32Array
+  vLayer: Int32Array
+}
+
+export interface SegmentData {
+  position: Float32Array
+  hwa: Float32Array
+  segIndex: Uint32Array
+  nV: number
+  nSeg: number
+  layerSegPrefix: Uint32Array
+  travelPos: Float32Array
+  travelPrefix: Uint32Array
+  nTrav: number
+  layerCount: number
+  maxAbs: number
+  hasNaN: boolean
+  meta: SegmentMeta
+  /** 타입별 총 압출 길이 (index = 툴패스 타입, 길이 16) */
+  typeLengths: Float64Array
+  /** 정점이 하나도 없으면 null */
+  bbox: { min: [number, number, number]; max: [number, number, number] } | null
+}
+
+/** 커널 layers[{z, paths(stride8), widths[]}] → GPU 스트림. */
+export function buildSegmentData(layers: unknown[], defaultLineWidth: number): SegmentData
+
+/** 타입별 길이 비율(%), pct 내림차순. 시간은 커널이 role 별로 노출하지 않아 길이로 근사한다. */
+export function roleRatios(typeLengths: Float64Array | number[]): Array<{
+  type: number
+  label: string
+  pct: number
+  color: number[]
+}>
+
+/**
+ * speed/fan/temp 는 커널 툴패스에 없어 설정값에서 유도한다 — 그 유도에 쓰는 값들.
+ */
+export interface ColorContext {
+  speedByType?: Record<number, number>
+  firstLayerSpeed?: number
+  fanByType?: Record<number, number>
+  fanFirstLayers?: number
+  tempNormal?: number
+  tempFirst?: number
+  closeFanLayers?: number
+}
+
+export interface ColorResult {
+  /** per-vertex RGBA (nV*4). `.r` 에 색이 packed 돼 있다. */
+  color: Float32Array
+  min: number
+  max: number
+  viewType: ViewTypeDef['key']
+  label: string
+  unit: string
+  /** false 면 고정색 — min/max 는 0 이고 범례를 그리면 안 된다. */
+  cont: boolean
+}
+
+export function computeColors(data: SegmentData, viewType: ViewTypeDef['key'], ctx: ColorContext): ColorResult
+
+export interface ToolpathHandle {
+  /** THREE.Mesh — 인자로 넘긴 THREE 네임스페이스의 타입 */
+  mesh: any
+  /** THREE.LineSegments (travel) */
+  travLines: any
+  setLayerRange(lo: number, hi: number): void
+  /** 하위호환 — setLayerRange(0, n-1) 과 같다. */
+  setVisibleLayers(n: number): void
+  setTravelVisible(visible: boolean): void
+  setColors(color: Float32Array): void
+  dispose(): void
+  nSeg: number
+  layerCount: number
+}
+
+/** @param THREE - `import * as THREE from 'three'` 네임스페이스. 소비자 인스턴스를 그대로 쓴다. */
+export function makeToolpath(THREE: any, data: SegmentData): ToolpathHandle

+ 406 - 0
frontend/src/lib/vendor/toolpathRenderer.js

@@ -0,0 +1,406 @@
+/*
+ * GPU-instanced volumetric toolpath renderer.
+ *
+ * Vendored verbatim from `three-slicer@0.1.1` (`three-slicer/viewer/toolpath`,
+ * i.e. viewer/dist/toolpath_gpu.js), which is itself a port of OrcaSlicer's
+ * `libvgcode` -- the renderer the desktop slicer draws its own G-code preview
+ * with. The diamond segment cross-section, the feature palette and the
+ * blue-to-red range ramp all come from there, which is why output built on this
+ * matches Studio rather than approximating it.
+ *
+ *   upstream: https://github.com/kimgh06/Web_Three_Slicer
+ *   licence:  AGPL-3.0-or-later (same as Bambuddy)
+ *
+ * Vendored rather than depended upon: the npm package carries an 8 MB WASM
+ * slicing kernel and pins `three@^0.160`, neither of which we want. This module
+ * imports nothing -- `makeToolpath` takes the THREE namespace as an argument --
+ * so it runs against our own three.js version.
+ *
+ * Do not edit. To update, re-copy from a newer three-slicer release.
+ */
+const le = [
+  0,
+  1,
+  2,
+  0,
+  2,
+  3,
+  // front spike
+  0,
+  3,
+  4,
+  0,
+  4,
+  5,
+  // right/bottom body
+  0,
+  5,
+  6,
+  0,
+  6,
+  1,
+  // left/top body
+  5,
+  4,
+  7,
+  5,
+  7,
+  6
+  // back spike
+], U = {
+  0: [0.42, 0.45, 0.5],
+  1: [0.85, 0.51, 0.17],
+  2: [0.21, 0.45, 0.76],
+  3: [0.35, 0.75, 0.85],
+  4: [0.16, 0.68, 0.4],
+  5: [0.66, 0.42, 0.85],
+  6: [0.55, 0.45, 0.35],
+  7: [0.95, 0.85, 0.25],
+  8: [0.9, 0.35, 0.65],
+  9: [0.9, 0.25, 0.25],
+  10: [0.6, 0.82, 0.55],
+  11: [0.3, 0.72, 0.7]
+};
+function ie(t) {
+  const i = Math.round(t[0] * 255), s = Math.round(t[1] * 255), n = Math.round(t[2] * 255);
+  return i << 16 | s << 8 | n;
+}
+function ue(t, i) {
+  const s = t.length, n = i > 0 ? i : 0.42, l = new Array(s);
+  for (let e = 0; e < s; e++) {
+    const o = t[e].z;
+    l[e] = Math.max(0.02, e === 0 ? o : o - t[e - 1].z);
+  }
+  const a = [], h = [], _ = [], y = [], v = [], r = [], x = [], b = [], A = [], M = [], L = new Float64Array(16), P = [], k = [];
+  let W = -1, $ = 0, R = 0, H = 0, E = -1, c = 0;
+  const d = 1e-4, u = (e, o, g, m, p, w) => (a.push(e), h.push(o), _.push(g), r.push(m), y.push(p), v.push(w), x.push(c), b.push(!1), a.length - 1);
+  for (let e = 0; e < s; e++) {
+    const o = t[e].paths, g = t[e].widths, m = l[e];
+    if (c = e, !!o)
+      for (let p = 0; p < o.length; p += 8) {
+        const w = o[p + 3], F = o[p], I = o[p + 1], G = o[p + 2], S = o[p + 4], V = o[p + 5], B = o[p + 6];
+        if (w === 0) {
+          P.push(F, I, G, S, V, B), k.push(e);
+          continue;
+        }
+        const O = g && g[p / 8] > 0 ? g[p / 8] : n;
+        w < 16 && (L[w] += Math.hypot(S - F, V - I));
+        let D;
+        W >= 0 && E === w && Math.abs($ - F) < d && Math.abs(R - I) < d && Math.abs(H - G) < d ? (D = W, u(S, V, B, w, m, O)) : (D = u(F, I, G, w, m, O), u(S, V, B, w, m, O)), b[D] = !0, W = D + 1, $ = S, R = V, H = B, E = w, A.push(D), M.push(e);
+      }
+  }
+  const f = a.length, C = A.length, z = new Float32Array(f * 4), Y = new Float32Array(f * 4);
+  let ee = 0, ne = !1, j = 1 / 0, T = 1 / 0, X = 1 / 0, Z = -1 / 0, q = -1 / 0, J = -1 / 0;
+  for (let e = 0; e < f; e++) {
+    const o = y[e], g = a[e], m = h[e], p = _[e] - 0.5 * o;
+    z[e * 4] = g, z[e * 4 + 1] = m, z[e * 4 + 2] = p;
+    const w = e > 0 && b[e - 1], F = b[e];
+    let I = 0;
+    if (w || F) {
+      const G = w ? a[e] - a[e - 1] : 0, S = w ? h[e] - h[e - 1] : 0, V = w ? _[e] - _[e - 1] : 0, B = F ? a[e + 1] - a[e] : 0, O = F ? h[e + 1] - h[e] : 0, D = F ? _[e + 1] - _[e] : 0;
+      I = Math.atan2(G * O - S * B, G * B + S * O + V * D);
+    }
+    Y[e * 4] = o, Y[e * 4 + 1] = v[e], Y[e * 4 + 2] = I, Y[e * 4 + 3] = ie(U[r[e]] || U[1]), (!Number.isFinite(g) || !Number.isFinite(m) || !Number.isFinite(p) || !Number.isFinite(I)) && (ne = !0), ee = Math.max(ee, Math.abs(g), Math.abs(m), Math.abs(p)), g < j && (j = g), g > Z && (Z = g), m < T && (T = m), m > q && (q = m), p < X && (X = p), p > J && (J = p);
+  }
+  const te = new Uint32Array(C * 4);
+  for (let e = 0; e < C; e++)
+    te[e * 4] = A[e], te[e * 4 + 1] = M[e];
+  const K = { vType: new Uint8Array(f), vWidth: new Float32Array(f), vHeight: new Float32Array(f), vLayer: new Int32Array(f) };
+  for (let e = 0; e < f; e++)
+    K.vType[e] = r[e], K.vWidth[e] = v[e], K.vHeight[e] = y[e], K.vLayer[e] = x[e];
+  const oe = new Int32Array(s + 1);
+  {
+    let e = 0;
+    for (let o = 0; o < s; o++) {
+      for (; e < C && M[e] === o; ) e++;
+      oe[o + 1] = e;
+    }
+  }
+  const Q = k.length, N = new Float32Array(Q * 6);
+  for (let e = 0; e < N.length; e++) N[e] = P[e];
+  for (let e = 0; e < N.length; e += 3) {
+    const o = N[e], g = N[e + 1], m = N[e + 2];
+    o < j && (j = o), o > Z && (Z = o), g < T && (T = g), g > q && (q = g), m < X && (X = m), m > J && (J = m);
+  }
+  const re = new Int32Array(s + 1);
+  {
+    let e = 0;
+    for (let o = 0; o < s; o++) {
+      for (; e < Q && k[e] === o; ) e++;
+      re[o + 1] = e;
+    }
+  }
+  const se = f + Q > 0 ? { min: [j, T, X], max: [Z, q, J] } : null;
+  return { position: z, hwa: Y, segIndex: te, nV: f, nSeg: C, layerSegPrefix: oe, travelPos: N, travelPrefix: re, nTrav: Q, layerCount: s, maxAbs: ee, hasNaN: ne, meta: K, typeLengths: L, bbox: se };
+}
+const _e = { 1: "벽", 2: "스파스", 3: "솔리드", 4: "스커트", 5: "서포트", 6: "래프트", 7: "갭필", 8: "씬월", 9: "브리지", 10: "아이어닝", 11: "프라임" };
+function ve(t) {
+  let i = 0;
+  for (let n = 1; n < 16; n++) i += t[n] || 0;
+  const s = [];
+  if (i <= 0) return s;
+  for (let n = 1; n < 16; n++) {
+    const l = t[n] || 0;
+    l > 0 && s.push({ type: n, label: _e[n] || "t" + n, pct: 100 * l / i, color: U[n] || U[1] });
+  }
+  return s.sort((n, l) => l.pct - n.pct);
+}
+const ce = [
+  [11, 44, 122],
+  [19, 89, 133],
+  [28, 136, 145],
+  [4, 214, 15],
+  [170, 242, 0],
+  [252, 249, 3],
+  [245, 206, 10],
+  [227, 136, 32],
+  [209, 104, 48],
+  [194, 82, 60],
+  [148, 38, 22]
+].map((t) => [t[0] / 255, t[1] / 255, t[2] / 255]);
+function he(t, i, s, n) {
+  const l = n.length;
+  if (!(s > i)) return n[0];
+  const a = (s - i) / (l - 1), h = (t - i) / a, _ = Math.max(0, Math.min(l - 1, Math.floor(h))), y = Math.max(0, Math.min(l - 1, _ + 1)), v = h - _, r = n[_], x = n[y];
+  return [r[0] + (x[0] - r[0]) * v, r[1] + (x[1] - r[1]) * v, r[2] + (x[2] - r[2]) * v];
+}
+const ae = [
+  { key: "feature", label: "Feature type", cont: !1, unit: "" },
+  { key: "speed", label: "Speed", cont: !0, unit: "mm/s" },
+  { key: "height", label: "Layer Height", cont: !0, unit: "mm" },
+  { key: "width", label: "Line Width", cont: !0, unit: "mm" },
+  { key: "fan", label: "Fan Speed", cont: !0, unit: "%" },
+  { key: "temp", label: "Temperature", cont: !0, unit: "°C" }
+];
+function de(t, i, s, n) {
+  const l = i.vType[s], a = i.vLayer[s], h = a === 0;
+  switch (t) {
+    case "height":
+      return i.vHeight[s];
+    case "width":
+      return i.vWidth[s];
+    case "speed":
+      return h ? n.firstLayerSpeed : n.speedByType[l] ?? n.speedByType[1];
+    case "fan":
+      return a < n.closeFanLayers ? 0 : l === 9 ? 100 : n.fanNormal;
+    case "temp":
+      return h ? n.tempFirst : n.tempNormal;
+    default:
+      return 0;
+  }
+}
+function ge(t, i, s) {
+  const { meta: n, nV: l } = t, a = new Float32Array(l * 4), h = ae.find((r) => r.key === i) || ae[0];
+  if (!h.cont) {
+    for (let r = 0; r < l; r++) a[r * 4] = ie(U[n.vType[r]] || U[1]);
+    return { color: a, min: 0, max: 0, viewType: i, label: h.label, unit: h.unit, cont: !1 };
+  }
+  let _ = 1 / 0, y = -1 / 0;
+  const v = new Float32Array(l);
+  for (let r = 0; r < l; r++) {
+    const x = de(i, n, r, s);
+    v[r] = x, x < _ && (_ = x), x > y && (y = x);
+  }
+  Number.isFinite(_) || (_ = 0, y = 1);
+  for (let r = 0; r < l; r++) {
+    const x = he(v[r], _, y, ce);
+    a[r * 4] = ie(x);
+  }
+  return { color: a, min: _, max: y, viewType: i, label: h.label, unit: h.unit, cont: !0 };
+}
+const fe = `
+precision highp float;
+precision highp int;
+precision highp sampler2D;
+precision highp usampler2D;
+#define POINTY_CAPS
+#define FIX_TWISTING
+const vec3  light_top_dir = vec3(-0.4574957, 0.4574957, 0.7624929);
+const float light_top_diffuse = 0.6 * 0.8;
+const float light_top_specular = 0.6 * 0.125;
+const float light_top_shininess = 20.0;
+const vec3  light_front_dir = vec3(0.6985074, 0.1397015, 0.6985074);
+const float light_front_diffuse = 0.6 * 0.3;
+const float ambient = 0.3;
+const float emission = 0.15;
+const vec3 UP = vec3(0, 0, 1);
+uniform mat4 view_matrix;
+uniform mat4 projection_matrix;
+uniform vec3 camera_position;
+uniform sampler2D position_tex;
+uniform sampler2D height_width_angle_tex;
+uniform int layer_lo;   // 25단계: 이중 슬라이더 하한(레이어). 범위 밖 세그먼트는 셰이더가 O(1) 클립.
+uniform int layer_hi;   //          상한은 instanceCount 로 컷(레이어 순 정렬).
+in float vertex_id_float;
+in uint seg_id_a_u;     // 인스턴스 어트리뷰트(구 segment_index_tex.r) — 어트리뷰트 fetch 가 texelFetch 보다 쌈
+in uint seg_layer_u;    // 인스턴스 어트리뷰트(구 segment_index_tex.g)
+out vec3 color;
+vec3 decode_color(float col) {
+  int c = int(round(col));
+  int r = (c >> 16) & 0xFF;
+  int g = (c >> 8) & 0xFF;
+  int b = (c >> 0) & 0xFF;
+  float f = 1.0 / 255.0;
+  return f * vec3(r, g, b);
+}
+float lighting(vec3 eye_position, vec3 eye_normal) {
+  float top_diffuse = light_top_diffuse * max(dot(eye_normal, light_top_dir), 0.0);
+  float front_diffuse = light_front_diffuse * max(dot(eye_normal, light_front_dir), 0.0);
+  float top_specular = light_top_specular * pow(max(dot(-normalize(eye_position), reflect(-light_top_dir, eye_normal)), 0.0), light_top_shininess);
+  return ambient + top_diffuse + front_diffuse + top_specular + emission;
+}
+ivec2 tex_coord(sampler2D sampler, int id) {
+  ivec2 tex_size = textureSize(sampler, 0);
+  return (tex_size.y == 1) ? ivec2(id, 0) : ivec2(id % tex_size.x, id / tex_size.x);
+}
+void main() {
+  int vertex_id = int(vertex_id_float);
+  int seg_layer = int(seg_layer_u);
+  if (seg_layer < layer_lo || seg_layer > layer_hi) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; }   // 범위 밖 → 클립
+  int id_a = int(seg_id_a_u);
+  int id_b = id_a + 1;
+  vec3 pos_a = texelFetch(position_tex, tex_coord(position_tex, id_a), 0).xyz;
+  vec3 pos_b = texelFetch(position_tex, tex_coord(position_tex, id_b), 0).xyz;
+  vec3 line = pos_b - pos_a;
+  float line_len = length(line);
+  vec3 line_dir;
+  if (line_len < 1e-4)
+    line_dir = vec3(1.0, 0.0, 0.0);
+  else
+    line_dir = line / line_len;
+  vec3 line_right_dir;
+  if (abs(dot(line_dir, UP)) > 0.9) {
+    line_right_dir = normalize(cross(vec3(1, 0, 0), line_dir));
+  }
+  else
+    line_right_dir = normalize(cross(line_dir, UP));
+  vec3 line_up_dir = normalize(cross(line_right_dir, line_dir));
+  const vec2 horizontal_vertical_view_signs_array[16] = vec2[](
+    vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, 0.0), vec2(0.0, -1.0),
+    vec2(0.0, -1.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, 0.0),
+    vec2(0.0, 1.0), vec2(-1.0, 0.0), vec2(0.0, 0.0), vec2(1.0, 0.0),
+    vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 0.0), vec2(0.0, 0.0)
+    );
+  int id = vertex_id < 4 ? id_a : id_b;
+  vec3 endpoint_pos = vertex_id < 4 ? pos_a : pos_b;
+  vec4 hwa_color = texelFetch(height_width_angle_tex, tex_coord(height_width_angle_tex, id), 0);   // .xyz=h/w/angle, .w=packed color
+  vec3 height_width_angle = hwa_color.xyz;
+#ifdef FIX_TWISTING
+  int closer_id = (dot(camera_position - pos_a, camera_position - pos_a) < dot(camera_position - pos_b, camera_position - pos_b)) ? id_a : id_b;
+  vec3 closer_pos = (closer_id == id_a) ? pos_a : pos_b;
+  vec3 camera_view_dir = normalize(closer_pos - camera_position);
+  vec3 closer_height_width_angle = texelFetch(height_width_angle_tex, tex_coord(height_width_angle_tex, closer_id), 0).xyz;
+  vec3 diagonal_dir_border = normalize(closer_height_width_angle.x * line_up_dir + closer_height_width_angle.y * line_right_dir);
+#else
+  vec3 camera_view_dir = normalize(endpoint_pos - camera_position);
+  vec3 diagonal_dir_border = normalize(height_width_angle.x * line_up_dir + height_width_angle.y * line_right_dir);
+#endif
+  bool is_vertical_view = abs(dot(camera_view_dir, line_up_dir)) / abs(dot(diagonal_dir_border, line_up_dir)) >
+    abs(dot(camera_view_dir, line_right_dir)) / abs(dot(diagonal_dir_border, line_right_dir));
+  vec2 signs = horizontal_vertical_view_signs_array[vertex_id + 8 * int(is_vertical_view)];
+#ifndef POINTY_CAPS
+  if (vertex_id == 2 || vertex_id == 7) signs = -horizontal_vertical_view_signs_array[(vertex_id - 2) + 8 * int(is_vertical_view)];
+#endif
+  float view_right_sign = sign(dot(-camera_view_dir, line_right_dir));
+  float view_top_sign = sign(dot(-camera_view_dir, line_up_dir));
+  float half_height = 0.5 * height_width_angle.x;
+  float half_width = 0.5 * height_width_angle.y;
+  vec3 horizontal_dir = half_width * line_right_dir;
+  vec3 vertical_dir = half_height * line_up_dir;
+  float horizontal_sign = signs.x * view_right_sign;
+  float vertical_sign = signs.y * view_top_sign;
+  vec3 pos = endpoint_pos + horizontal_sign * horizontal_dir + vertical_sign * vertical_dir;
+  if (vertex_id == 2 || vertex_id == 7) {
+    float line_dir_sign = (vertex_id == 2) ? -1.0 : 1.0;
+    if (height_width_angle.z == 0.0) {
+#ifdef POINTY_CAPS
+      pos += line_dir_sign * line_dir * half_width;
+#endif
+    }
+    else {
+      pos += line_dir_sign * line_dir * half_width * sin(abs(height_width_angle.z) * 0.5);
+      pos += sign(height_width_angle.z) * horizontal_dir * cos(abs(height_width_angle.z) * 0.5);
+    }
+  }
+  vec3 eye_position = (view_matrix * vec4(pos, 1.0)).xyz;
+  vec3 eye_normal = (view_matrix * vec4(normalize(pos - endpoint_pos), 0.0)).xyz;
+  vec3 color_base = decode_color(hwa_color.w);
+  color = color_base * lighting(eye_position, eye_normal);
+  gl_Position = projection_matrix * vec4(eye_position, 1.0);
+}
+`, pe = `
+precision highp float;
+in vec3 color;
+out vec4 fragment_color;
+void main() {
+  fragment_color = vec4(color, 1.0);
+}
+`;
+function me(t, i) {
+  const s = (c, d) => {
+    const u = Math.min(2048, Math.max(1, d)), f = Math.max(1, Math.ceil(d / u)), C = new Float32Array(u * f * 4);
+    C.set(c.subarray(0, Math.min(c.length, u * f * 4)));
+    const z = new t.DataTexture(C, u, f, t.RGBAFormat, t.FloatType);
+    return z.minFilter = z.magFilter = t.NearestFilter, z.generateMipmaps = !1, z.needsUpdate = !0, z;
+  }, n = s(i.position, i.nV), l = s(i.hwa, i.nV), a = new t.InstancedBufferGeometry();
+  a.setIndex(le), a.setAttribute("position", new t.BufferAttribute(new Float32Array(8 * 3), 3)), a.setAttribute("vertex_id_float", new t.BufferAttribute(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7]), 1));
+  const h = new t.InstancedInterleavedBuffer(i.segIndex, 4);
+  a.setAttribute("seg_id_a_u", new t.InterleavedBufferAttribute(h, 1, 0)), a.setAttribute("seg_layer_u", new t.InterleavedBufferAttribute(h, 1, 1)), a.instanceCount = 0;
+  const _ = new t.RawShaderMaterial({
+    glslVersion: t.GLSL3,
+    uniforms: {
+      view_matrix: { value: new t.Matrix4() },
+      projection_matrix: { value: new t.Matrix4() },
+      camera_position: { value: new t.Vector3() },
+      position_tex: { value: n },
+      height_width_angle_tex: { value: l },
+      layer_lo: { value: 0 },
+      layer_hi: { value: i.layerCount }
+    },
+    vertexShader: fe,
+    fragmentShader: pe,
+    side: t.DoubleSide
+  }), y = new t.Mesh(a, _);
+  let v = null;
+  if (i.bbox) {
+    const { min: c, max: d } = i.bbox, u = new t.Vector3((c[0] + d[0]) / 2, (c[1] + d[1]) / 2, (c[2] + d[2]) / 2), f = Math.hypot(d[0] - c[0], d[1] - c[1], d[2] - c[2]) / 2 + 2;
+    v = new t.Sphere(u, f);
+  }
+  y.frustumCulled = !!v, v && (a.boundingSphere = v);
+  const r = new t.Matrix4(), x = new t.Vector3();
+  y.onBeforeRender = (c, d, u) => {
+    _.uniforms.projection_matrix.value.copy(u.projectionMatrix), _.uniforms.view_matrix.value.multiplyMatrices(u.matrixWorldInverse, y.matrixWorld), r.copy(y.matrixWorld).invert(), u.getWorldPosition(x).applyMatrix4(r), _.uniforms.camera_position.value.copy(x);
+  };
+  const b = new t.BufferGeometry();
+  b.setAttribute("position", new t.BufferAttribute(i.travelPos, 3)), b.setDrawRange(0, 0);
+  const A = new t.LineSegments(b, new t.LineBasicMaterial({ color: 7041658 }));
+  A.frustumCulled = !!v, A.visible = !1, v && (b.boundingSphere = v);
+  let M = !1, L = 0, P = i.layerCount - 1;
+  const k = () => {
+    const c = i.travelPrefix[L], d = i.travelPrefix[P + 1];
+    b.setDrawRange(M ? c * 2 : 0, M ? (d - c) * 2 : 0);
+  }, W = (c, d) => {
+    const u = i.layerCount;
+    L = Math.max(0, Math.min(u - 1, c | 0)), P = Math.max(L, Math.min(u - 1, d | 0)), _.uniforms.layer_lo.value = L, _.uniforms.layer_hi.value = P, a.instanceCount = i.layerSegPrefix[P + 1], k();
+  };
+  return { mesh: y, travLines: A, setVisibleLayers: (c) => W(0, (c | 0) - 1), setLayerRange: W, setTravelVisible: (c) => {
+    M = !!c, A.visible = M, k();
+  }, setColors: (c) => {
+    const d = l.image.data, u = Math.min(c.length, d.length) / 4;
+    for (let f = 0; f < u; f++) d[f * 4 + 3] = c[f * 4];
+    l.needsUpdate = !0;
+  }, dispose: () => {
+    a.dispose(), _.dispose(), n.dispose(), l.dispose(), b.dispose(), A.material.dispose();
+  }, nSeg: i.nSeg, layerCount: i.layerCount };
+}
+export {
+  ce as DEFAULT_RANGES_COLORS,
+  U as TYPE_COLOR,
+  _e as TYPE_LABEL,
+  le as VERTEX_DATA,
+  ae as VIEW_TYPES,
+  ue as buildSegmentData,
+  ge as computeColors,
+  me as makeToolpath,
+  ve as roleRatios
+};

+ 3 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -2930,9 +2930,10 @@ export function ArchivesPage() {
     queryFn: api.getSettings,
   });
 
+  // Print Log user filter -- names only, so the slim listing is enough (#1894).
   const { data: users } = useQuery({
-    queryKey: ['users'],
-    queryFn: api.getUsers,
+    queryKey: ['users', 'slim'],
+    queryFn: api.getUsersSlim,
     enabled: viewMode === 'log',
   });
 

+ 2 - 2
frontend/src/pages/CameraTokensPage.tsx

@@ -472,8 +472,8 @@ export function CameraTokensSection() {
         // (e.g. permission missing for some reason), the table still renders
         // with the numeric user_id as fallback.
         try {
-          const users = await api.getUsers();
-          setUserIdToName(new Map(users.map((u: { id: number; username: string }) => [u.id, u.username])));
+          const users = await api.getUsersSlim();
+          setUserIdToName(new Map(users.map((u) => [u.id, u.username])));
         } catch {
           setUserIdToName(new Map());
         }

+ 10 - 9
frontend/src/pages/FileManagerPage.tsx

@@ -74,7 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
-import { isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
+import { isApiSliceableFilename, isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
@@ -895,7 +895,8 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('common.print')}
                 </button>
               )}
-              {isSliceableFilename(file.filename) && (useSlicerApi ? onSlice : onOpenInSlicer) && (
+              {(useSlicerApi ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename)) &&
+                (useSlicerApi ? onSlice : onOpenInSlicer) && (
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                     canSlice ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
@@ -913,7 +914,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('slice.action')}
                 </button>
               )}
-              {onRunPipeline && useSlicerApi && isSliceableFilename(file.filename) && (
+              {onRunPipeline && useSlicerApi && isApiSliceableFilename(file.filename) && (
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                     hasPermission('pipelines:run') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
@@ -1305,10 +1306,10 @@ export function FileManagerPage() {
     queryFn: () => api.getLibraryStats(),
   });
 
-  // Get users for the username filter autocomplete
+  // Get users for the username filter autocomplete -- names only (#1894)
   const { data: users } = useQuery({
-    queryKey: ['users'],
-    queryFn: () => api.getUsers(),
+    queryKey: ['users', 'slim'],
+    queryFn: () => api.getUsersSlim(),
   });
 
   // Get unique file types for filter dropdown
@@ -2639,7 +2640,7 @@ export function FileManagerPage() {
                           </button>
                         </>
                       )}
-                      {isSliceableFilename(file.filename) && (
+                      {(settings?.use_slicer_api ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename)) && (
                         <button
                           onClick={() => {
                             if (!canSlice()) return;
@@ -2656,7 +2657,7 @@ export function FileManagerPage() {
                           {settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
                         </button>
                       )}
-                      {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
+                      {(settings?.use_slicer_api ?? false) && isApiSliceableFilename(file.filename) && (
                         <button
                           onClick={() => hasPermission('pipelines:run') && setRunPipelineFile(file)}
                           className={`p-1.5 rounded transition-colors ${
@@ -2902,7 +2903,7 @@ export function FileManagerPage() {
           onSliceWithBambuddy={
             // Only offer in-app slicing on files the SliceModal can actually
             // handle (matches the file-row Cog visibility check at :2127).
-            isSliceableFilename(viewerFile.filename) && hasPermission('library:upload')
+            isApiSliceableFilename(viewerFile.filename) && hasPermission('library:upload')
               ? () => {
                   const f = viewerFile;
                   setViewerFile(null);

+ 6 - 3
frontend/src/pages/FinancePage.tsx

@@ -105,7 +105,10 @@ export function FinancePage() {
   const canUpdateBudgets = hasPermission('cost_centers:modify');
   const canAssignCostCenterUsers = hasPermission('cost_centers:modify');
   const canAdjustWallet = hasPermission('cost_centers:modify');
-  const canReadUsers = hasPermission('users:read');
+  // Finance only ever labels a user or picks one to assign, so the slim
+  // listing suffices -- and a billing operator should not need the admin-level
+  // users:read (emails, roles, permission sets) to staff a cost center (#1894).
+  const canReadUsers = hasPermission('users:read_slim') || hasPermission('users:read');
 
   const canAccessAllCostCenters =
     canReadAllFinance ||
@@ -202,8 +205,8 @@ export function FinancePage() {
   });
 
   const { data: users } = useQuery({
-    queryKey: ['users'],
-    queryFn: api.getUsers,
+    queryKey: ['users', 'slim'],
+    queryFn: api.getUsersSlim,
     enabled: canReadUsers && (canViewMyCostCenters || canAdjustWallet || canAssignCostCenterUsers),
   });
 

+ 86 - 118
frontend/src/pages/GCodeViewerPage.tsx

@@ -1,95 +1,95 @@
-import { useEffect, useState } from 'react';
+import { useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
 import { useNavigate, useSearchParams } from 'react-router-dom';
-import { ArrowLeft, ExternalLink, ShieldAlert } from 'lucide-react';
+import { ArrowLeft } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
-import { findFramingRefusal, type FrameProblem } from '../utils/framing';
 
+import { api } from '../api/client';
+import { GcodeToolpathViewer } from '../components/GcodeToolpathViewer';
+
+/**
+ * Full-page G-code preview.
+ *
+ * Previously an iframe onto a vendored copy of PrettyGCode served from
+ * `/gcode-viewer/`. That brought its own problems -- a second viewer to keep
+ * packaged and updated, no way to theme or translate it, and a whole
+ * frame-refusal probe to detect when a proxy blocked the embed -- and its
+ * output was the thing this page exists to show.
+ *
+ * It now renders Bambuddy's own toolpath viewer, which draws with OrcaSlicer's
+ * `libvgcode` and colours by feature. Same component as the file-manager
+ * preview, so the two surfaces cannot drift apart.
+ */
 export function GCodeViewerPage() {
   const navigate = useNavigate();
   const [searchParams] = useSearchParams();
   const { t } = useTranslation();
-  const [problem, setProblem] = useState<FrameProblem | null>(null);
 
-  // Forward the outer page's query string (e.g. ?archive=82) to the iframe so
-  // the adapter inside can pick up the archive to load. The iframe itself must
-  // keep the trailing slash on /gcode-viewer/ so it hits the raw-viewer route;
-  // the outer SPA URL uses no trailing slash so a reload falls through to the
-  // SPA catch-all and keeps the Bambuddy layout shell.
-  const iframeSrc = `/gcode-viewer/${window.location.search}`;
-  const embedded = window !== window.top;
+  const archiveId = searchParams.get('archive');
+  const libraryFileId = searchParams.get('library_file');
+  const plate = searchParams.get('plate');
 
-  // A frame refused by X-Frame-Options / frame-ancestors still fires `onLoad` —
-  // the browser commits its own "refused to connect" error page — so the iframe
-  // itself cannot tell us anything. Ask for the same URL directly instead: it is
-  // same-origin, so every response header is readable, and it travels through
-  // whatever proxy the browser reaches Bambuddy by. The iframe is rendered
-  // straight away regardless and only replaced if this comes back refusing,
-  // which keeps the working case exactly as fast as before.
-  useEffect(() => {
-    if (embedded) return;
-    const controller = new AbortController();
-    (async () => {
-      try {
-        const response = await fetch(iframeSrc, {
-          credentials: 'same-origin',
-          signal: controller.signal,
-        });
-        if (!response.ok) {
-          setProblem({ kind: 'unavailable', detail: `HTTP ${response.status}` });
-          return;
-        }
-        const refusal = findFramingRefusal(
-          response.headers.get('x-frame-options'),
-          response.headers.get('content-security-policy'),
-          window.location.origin,
-        );
-        if (refusal) setProblem({ kind: 'blocked', detail: refusal });
-      } catch {
-        // Aborted, offline, or the probe itself was blocked. The iframe stays;
-        // guessing at a cause we have no evidence for would be worse than the
-        // browser's own error page.
-      }
-    })();
-    return () => controller.abort();
-  }, [iframeSrc, embedded]);
+  // Filament colours, so a multi-material print opens on its own colours.
+  // The two sources differ: an archive reports them through its capabilities,
+  // while a library file carries them in its plate metadata, read straight out
+  // of the 3MF's slice info. Neither is worth blocking the preview over -- the
+  // viewer falls back to feature colouring -- hence no retry and no error path.
+  const archiveColorsQuery = useQuery({
+    queryKey: ['gcode-viewer-archive-colors', archiveId],
+    queryFn: () => api.getArchiveCapabilities(Number(archiveId)),
+    enabled: Boolean(archiveId),
+    staleTime: 5 * 60_000,
+    retry: false,
+  });
 
-  // Safety guard: if this React app is itself inside an iframe (e.g. the
-  // StaticFiles mount isn't registered and serve_spa returned us here),
-  // don't render another iframe — that would create an infinite loop.
-  if (embedded) {
-    return (
-      <div style={{ padding: 32, color: '#f88' }}>
-        GCode viewer static files not found. Check that the{' '}
-        <code>gcode_viewer/</code> directory exists and restart uvicorn.
-      </div>
-    );
-  }
+  const libraryPlatesQuery = useQuery({
+    queryKey: ['gcode-viewer-library-colors', libraryFileId],
+    queryFn: () => api.getLibraryFilePlates(Number(libraryFileId)),
+    enabled: Boolean(libraryFileId),
+    staleTime: 5 * 60_000,
+    retry: false,
+  });
 
-  const cameFromArchive = searchParams.has('archive');
-  const cameFromLibrary = searchParams.has('library_file');
-  const fallbackPath = cameFromArchive ? '/archives' : cameFromLibrary ? '/files' : '/';
-  const backLabel = cameFromArchive
-    ? t('gcodeViewer.backToArchives')
-    : cameFromLibrary
-    ? t('gcodeViewer.backToFiles')
-    : t('gcodeViewer.back');
+  const filamentColors = useMemo<string[] | undefined>(() => {
+    if (archiveId) return archiveColorsQuery.data?.filament_colors;
 
-  const handleBack = () => {
-    // Prefer browser history so we land where the user actually was (preserving
-    // scroll position, filters, etc.). Fall back to a sensible default route
-    // when the viewer was opened from a fresh tab / shared link.
-    if (window.history.length > 1) {
-      navigate(-1);
-    } else {
-      navigate(fallbackPath);
+    const plates = libraryPlatesQuery.data?.plates ?? [];
+    // Colours are per plate; use the one being previewed.
+    const wanted = plate ? Number(plate) : null;
+    const source = (wanted != null && plates.find((p) => p.index === wanted)) || plates[0];
+    if (!source?.filaments?.length) return undefined;
+
+    // slot_id is 1-based and the G-code's tool numbers are 0-based, so index
+    // by slot - 1 or every colour lands one filament out.
+    const colors: string[] = [];
+    for (const filament of source.filaments) {
+      const slot = Math.max(0, (filament.slot_id ?? 1) - 1);
+      if (filament.color) colors[slot] = filament.color;
     }
+    return colors.length > 0 ? colors : undefined;
+  }, [archiveId, archiveColorsQuery.data, libraryPlatesQuery.data, plate]);
+
+  const gcodeUrl = useMemo(() => {
+    // Multi-plate sources need the plate carried through, or the viewer shows
+    // whichever plate the backend defaults to rather than the one picked.
+    const withPlate = (base: string) => (plate ? `${base}?plate=${encodeURIComponent(plate)}` : base);
+    if (archiveId) return withPlate(api.getArchiveGcode(Number(archiveId)));
+    if (libraryFileId) return withPlate(api.getLibraryFileGcodeUrl(Number(libraryFileId)));
+    return null;
+  }, [archiveId, libraryFileId, plate]);
+
+  const handleBack = () => {
+    if (window.history.length > 1) navigate(-1);
+    else navigate(archiveId ? '/archives' : '/files');
   };
 
+  const backLabel = archiveId
+    ? t('gcodeViewer.backToArchives', 'Back to Archives')
+    : t('gcodeViewer.backToFiles', 'Back to File Manager');
+
   return (
-    // h-14 (3.5 rem) is the fixed header height defined in Layout.tsx.
-    // Subtracting it prevents a double scrollbar inside the layout shell.
-    <div style={{ height: 'calc(100vh - 3.5rem)', display: 'flex', flexDirection: 'column' }}>
-      <div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
+    <div className="flex flex-col h-full">
+      <div className="flex-shrink-0 px-4 py-2 border-b border-bambu-dark-tertiary">
         <button
           type="button"
           onClick={handleBack}
@@ -99,49 +99,17 @@ export function GCodeViewerPage() {
           {backLabel}
         </button>
       </div>
-      {problem ? (
-        <div className="flex-1 overflow-y-auto p-6">
-          <div role="alert" className="max-w-2xl mx-auto p-4 rounded-lg border border-amber-500/40 bg-amber-500/10">
-            <div className="flex items-start gap-3">
-              <ShieldAlert className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
-              <div className="min-w-0">
-                <p className="text-sm font-medium text-amber-300">
-                  {problem.kind === 'blocked'
-                    ? t('gcodeViewer.blockedTitle')
-                    : t('gcodeViewer.unavailableTitle')}
-                </p>
-                <p className="text-xs text-bambu-gray mt-1">
-                  {problem.kind === 'blocked'
-                    ? t('gcodeViewer.blockedBody')
-                    : t('gcodeViewer.unavailableBody')}
-                </p>
-                <p className="text-xs text-bambu-gray mt-2 font-mono break-all">
-                  {t('gcodeViewer.problemDetail', { detail: problem.detail })}
-                </p>
-                <a
-                  href={iframeSrc}
-                  target="_blank"
-                  rel="noreferrer"
-                  className="mt-3 inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
-                >
-                  <ExternalLink className="w-3 h-3" />
-                  {t('gcodeViewer.openInNewTab')}
-                </a>
-              </div>
-            </div>
-          </div>
-        </div>
-      ) : (
-        <iframe
-          src={iframeSrc}
-          title="GCode Viewer"
-          style={{
-            display: 'block',
-            width: '100%',
-            flex: 1,
-            border: 'none',
-          }}
+
+      {gcodeUrl ? (
+        <GcodeToolpathViewer
+          gcodeUrl={gcodeUrl}
+          filamentColors={filamentColors}
+          className="flex-1 min-h-0"
         />
+      ) : (
+        <div className="flex-1 flex items-center justify-center text-sm text-bambu-gray">
+          {t('gcodeViewer.noSource', 'No file was given to preview.')}
+        </div>
       )}
     </div>
   );

+ 35 - 0
frontend/src/pages/SettingsPage.tsx

@@ -54,6 +54,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
 import { Gauge, Palette } from 'lucide-react';
 import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
 import type { UsersSubTab } from '../lib/settingsSearch';
+import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines';
 
 const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
@@ -1067,6 +1068,7 @@ export function SettingsPage() {
       Number(baseline.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
       (baseline.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
       (baseline.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
+      resolveEngine(baseline.slice_engine) !== resolveEngine(localSettings.slice_engine) ||
       (baseline.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
       (baseline.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
       (baseline.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
@@ -1178,6 +1180,7 @@ export function SettingsPage() {
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
         camera_view_mode: localSettings.camera_view_mode,
         preferred_slicer: localSettings.preferred_slicer,
+        slice_engine: localSettings.slice_engine,
         open_in_slicer: localSettings.open_in_slicer,
         use_slicer_api: localSettings.use_slicer_api,
         orcaslicer_api_url: localSettings.orcaslicer_api_url,
@@ -5182,6 +5185,38 @@ export function SettingsPage() {
               </h3>
             </CardHeader>
             <CardContent className="space-y-3">
+              {/* Where slicing runs. Rendered only once more than one engine
+                  is actually usable — while the sidecar is the only one, a
+                  picker with a single entry is noise, and an entry the user
+                  can see but never select reads as a broken feature. Adding a
+                  browser engine to lib/sliceEngines.ts makes this appear. */}
+              {hasEngineChoice() && (
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    {t('settings.sliceEngine')}
+                  </label>
+                  <div className="relative">
+                    <select
+                      value={resolveEngine(localSettings.slice_engine)}
+                      onChange={(e) => updateSetting('slice_engine', e.target.value as SliceEngineId)}
+                      className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                    >
+                      {availableEngines().map((engine) => (
+                        <option key={engine.id} value={engine.id}>
+                          {t(engine.labelKey)}
+                        </option>
+                      ))}
+                    </select>
+                    <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                  </div>
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t(
+                      availableEngines().find((e) => e.id === resolveEngine(localSettings.slice_engine))?.descriptionKey
+                        ?? 'settings.sliceEngineSidecarHint',
+                    )}
+                  </p>
+                </div>
+              )}
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">
                   {t('settings.preferredSlicer')}

+ 5 - 2
frontend/src/pages/StatsPage.tsx

@@ -1053,9 +1053,12 @@ export function StatsPage() {
     queryFn: api.getSettings,
   });
 
+  // Slim listing (#1894): the filter only needs id + username, and gating it
+  // on the admin-level users:read left the dropdown empty for exactly the
+  // operators who were granted stats:filter_by_user.
   const { data: users } = useQuery({
-    queryKey: ['users'],
-    queryFn: api.getUsers,
+    queryKey: ['users', 'slim'],
+    queryFn: api.getUsersSlim,
     enabled: canFilterByUser,
   });
 

+ 64 - 0
frontend/src/types/slicerSettings.ts

@@ -0,0 +1,64 @@
+/**
+ * Types for the vendored OrcaSlicer process-settings metadata.
+ *
+ * The JSON under `src/data/slicer/` is generated by
+ * `scripts/generate-slicer-schema.mjs` from the `three-slicer` package, which
+ * extracts it from OrcaSlicer's own `PrintConfig.cpp` and `Tab.cpp`. These
+ * types describe that generated shape.
+ */
+
+/** A value the user has set for one process option, in its slicer-side form. */
+export type SettingValue = string | number | boolean | Array<string | number | boolean>;
+
+/**
+ * OrcaSlicer's `ConfigOptionType` names, as they appear in the extracted schema.
+ * The plural forms are per-extruder vectors.
+ */
+export type OptionType =
+  | 'coBool'
+  | 'coBools'
+  | 'coInt'
+  | 'coFloat'
+  | 'coFloats'
+  | 'coPercent'
+  | 'coFloatOrPercent'
+  | 'coFloatsOrPercents'
+  | 'coEnum'
+  | 'coString';
+
+/** OrcaSlicer's setting visibility tiers, mirrored by the panel's mode switch. */
+export type OptionMode = 'simple' | 'advanced' | 'expert' | 'develop';
+
+export interface ProcessOption {
+  type: OptionType;
+  mode: OptionMode;
+  label: string;
+  tooltip?: string;
+  /** Unit shown after the input ("mm", "mm/s²", "%"). */
+  sidetext?: string;
+  /**
+   * Bounds, normalised out of their C++ source form by the generator. Typed as
+   * string too because the JSON carries both shapes; coerce with
+   * ``numericBound`` rather than assuming a number.
+   */
+  min?: number | string;
+  max?: number | string;
+  enum_values?: string[];
+  enum_labels?: string[];
+  default?: SettingValue;
+}
+
+export type ProcessSchema = Record<string, ProcessOption>;
+
+export interface ProcessGroup {
+  group: string;
+  options: string[];
+}
+
+export interface ProcessPage {
+  page: string;
+  icon?: string;
+  groups: ProcessGroup[];
+}
+
+export type ProcessUiTree = ProcessPage[];

+ 0 - 65
frontend/src/utils/framing.ts

@@ -1,65 +0,0 @@
-/**
- * Reading a response's framing headers, for the embedded G-code viewer (#2787).
- *
- * The viewer is the only part of Bambuddy that embeds a Bambuddy page in a
- * frame, so it is the only part a proxy-added framing header can break — and it
- * breaks with the browser's own error page, which says nothing about what was
- * refused or by whom.
- */
-
-/** Why the viewer could not be shown inline, with the evidence that says so. */
-export type FrameProblem =
-  | { kind: 'blocked'; detail: string }
-  | { kind: 'unavailable'; detail: string };
-
-/**
- * Decide whether a response's framing headers allow `origin` to embed it.
- *
- * Returns the offending header verbatim when embedding is refused, or null when
- * it is allowed. Bambuddy's own headers always allow it (`frame-ancestors
- * 'self'` plus `X-Frame-Options: SAMEORIGIN`, set in `main.py`), so a refusal
- * means something between the browser and Bambuddy — a reverse proxy, a
- * security add-on, an auth gateway — added a stricter one.
- *
- * `frame-ancestors` wins outright when present: per CSP the browser must ignore
- * `X-Frame-Options` entirely in that case, so reading both would blame a
- * proxy-added `X-Frame-Options: DENY` the browser never consulted. Multiple CSP
- * headers are *intersected*, and `fetch` joins them into one comma-separated
- * string, so every `frame-ancestors` occurrence has to permit us — not just the
- * first one.
- */
-export function findFramingRefusal(
-  xFrameOptions: string | null,
-  contentSecurityPolicy: string | null,
-  origin: string,
-): string | null {
-  const csp = contentSecurityPolicy ?? '';
-  const directives = [...csp.matchAll(/(?:^|[;,])\s*frame-ancestors\s+([^;,]*)/gi)];
-  if (directives.length > 0) {
-    const self = origin.toLowerCase();
-    for (const [, raw] of directives) {
-      const value = raw.trim();
-      const sources = value.toLowerCase().split(/\s+/).filter(Boolean);
-      const permitsUs = sources.some(
-        (source) =>
-          source === '*' ||
-          source === "'self'" ||
-          source === self ||
-          source === self.replace(/^https?:\/\//, ''),
-      );
-      if (!permitsUs) return `Content-Security-Policy: frame-ancestors ${value}`;
-    }
-    return null;
-  }
-
-  // No frame-ancestors anywhere: the legacy header governs. Anything other than
-  // a single SAMEORIGIN refuses us — DENY, ALLOW-FROM, or the conflicting
-  // "SAMEORIGIN, DENY" that appears when a proxy appends a second copy.
-  const legacy = (xFrameOptions ?? '')
-    .split(',')
-    .map((value) => value.trim().toLowerCase())
-    .filter(Boolean);
-  if (legacy.length === 0) return null;
-  if (legacy.length === 1 && legacy[0] === 'sameorigin') return null;
-  return `X-Frame-Options: ${xFrameOptions}`;
-}

+ 34 - 0
frontend/src/utils/slicer.ts

@@ -54,6 +54,17 @@ export function resolveDesktopSlicer(
  */
 export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
 
+/**
+ * The subset the *sidecar* can slice.
+ *
+ * The desktop slicers open a STEP happily; their command-line interfaces do
+ * not. OrcaSlicer 2.4.2 and Bambu Studio 02.07.01.62 both answer one with
+ * "Unknown file format. Input file must have .stl, .obj, .amf(.xml) extension."
+ * So a STEP still gets an "Open in Slicer" handoff, and no longer gets a
+ * "Slice" button that could only ever fail.
+ */
+export const API_SLICEABLE_FILE_TYPES = ['3mf', 'stl'] as const;
+
 /**
  * Does a `LibraryFile.file_type` name a sliceable source file?
  *
@@ -78,6 +89,19 @@ export function isSliceableFilename(filename: string): boolean {
   return SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
 }
 
+/**
+ * Does a filename name something the slicer *sidecar* can slice?
+ *
+ * Narrower than `isSliceableFilename` by exactly STEP — see
+ * `API_SLICEABLE_FILE_TYPES`. Use this wherever the action posts to
+ * `/library/files/{id}/slice`; use the wider one for the desktop handoff.
+ */
+export function isApiSliceableFilename(filename: string): boolean {
+  const lower = filename.toLowerCase();
+  if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
+  return API_SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
+}
+
 /**
  * Detect the user's operating system
  */
@@ -148,3 +172,13 @@ export function openArchiveInSlicer(path: string, slicer: SlicerType = 'bambu_st
   const downloadUrl = buildDownloadUrl(path);
   openInSlicer(downloadUrl, slicer);
 }
+
+/**
+ * Does a `LibraryFile.file_type` name something the sidecar can slice?
+ *
+ * The `isSliceableFileType` counterpart, narrowed to the sidecar's formats.
+ */
+export function isApiSliceableFileType(fileType?: string | null): boolean {
+  const normalized = (fileType || '').toLowerCase();
+  return (API_SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
+}

+ 36 - 5
frontend/src/utils/slicerPrinterMatch.ts

@@ -13,9 +13,12 @@
 //      derived from the backend's canonical PRINTER_MODEL_MAP (fetched via
 //      /slicer/printer-models), not duplicated here.
 //
-// The result drives grouping, not hard hiding: a preset no rule covers
-// stays in the main list, and only a preset that resolves to a *different*
-// printer is pushed into an "Other printers" group.
+// Only a definite 'mismatch' is acted on: the dropdown holds those back
+// behind a "Show all" link. A preset no rule covers classifies as 'unknown'
+// and always stays in the list — absence of evidence is not evidence of
+// incompatibility, and hiding an untagged preset would make a user's own
+// imported profiles disappear. That asymmetry is why every parse failure
+// below returns 'unknown' rather than guessing a mismatch.
 
 export type PrinterCompatibility = 'match' | 'mismatch' | 'unknown';
 
@@ -100,6 +103,29 @@ function normalizeModelFragment(s: string): string {
   return s.replace(/\s+/g, '').toLowerCase();
 }
 
+/**
+ * Drop BambuStudio's ``"# "`` user-clone prefix.
+ *
+ * Editing a system preset saves a copy named ``"# Bambu Lab X1 Carbon 0.4
+ * nozzle"``, and `.bbscfg` bundle exports use the same convention. Two places
+ * need it off:
+ *
+ *   1. ``extractPrinterPresetModel`` — the prefix fails its "Bambu Lab …"
+ *      test, so a cloned *printer* made every preset classify as 'unknown'
+ *      and the dropdown filter silently did nothing.
+ *   2. The ``compatible_printers`` comparison, where a prefix on one side
+ *      alone reads as a mismatch against the very printer the preset was
+ *      cloned from — and a mismatch now hides the preset.
+ *
+ * The ``@`` tag extractors need no such handling: they scan for "@BBL " or
+ * the last "@", both of which skip a leading prefix already.
+ *
+ * The backend normalises the same prefix in ``_canonical_printer_model``.
+ */
+function stripUserClonePrefix(name: string): string {
+  return name.replace(/^#\s*/, '').trim();
+}
+
 // Bambu Studio's naming convention for bundled presets: the 0.4 nozzle is
 // the default and its variants drop the nozzle suffix; 0.2 / 0.6 / 0.8
 // carry an explicit "<size> nozzle" segment. So a process with no suffix
@@ -132,7 +158,7 @@ function extractBblToken(presetName: string): { token: string; nozzle: string |
 // nozzle]" printer preset name. Returns null for non-Bambu printer
 // presets — there is no reliable name-based match against those.
 function extractPrinterPresetModel(printerPresetName: string): { model: string; nozzle: string | null } | null {
-  const m = printerPresetName.match(/^Bambu Lab\s+(.+)$/i);
+  const m = stripUserClonePrefix(printerPresetName).match(/^Bambu Lab\s+(.+)$/i);
   if (!m) return null;
   const { stripped, nozzle } = takeNozzleSuffix(m[1]);
   return stripped ? { model: stripped, nozzle } : null;
@@ -282,7 +308,12 @@ export function presetCompatibility(
   // authoritative when set.
   const compat = preset.compatible_printers;
   if (compat && compat.length > 0) {
-    return compat.includes(selectedPrinterName) ? 'match' : 'mismatch';
+    // Compared with the clone prefix off both sides: a preset cloned from a
+    // system printer lists the *unprefixed* name, and comparing that raw
+    // against a selected "# Bambu Lab …" reads as a mismatch — which now
+    // hides the preset rather than merely demoting it.
+    const selected = stripUserClonePrefix(selectedPrinterName);
+    return compat.some((name) => stripUserClonePrefix(name) === selected) ? 'match' : 'mismatch';
   }
   // (2) BambuStudio's `@BBL <model>` name convention — covers cloud /
   // standard presets that don't carry compatible_printers.

+ 1 - 67
frontend/vite.config.ts

@@ -1,77 +1,11 @@
 import { defineConfig } from 'vite'
 import react from '@vitejs/plugin-react'
 import path from 'path'
-import fs from 'fs'
-import type { Connect } from 'vite'
 
 // Backend port for dev server proxy (default: 8000)
 const backendPort = process.env.BACKEND_PORT || '8000'
 const backendUrl = `http://localhost:${backendPort}`
 
-// Absolute path to the gcode_viewer directory at the repo root
-const gcodeViewerDir = path.resolve(__dirname, '../gcode_viewer')
-
-// MIME types for static files served from gcode_viewer/
-const MIME: Record<string, string> = {
-  '.html': 'text/html; charset=utf-8',
-  '.js':   'application/javascript',
-  '.css':  'text/css',
-  '.obj':  'model/obj',
-  '.mtl':  'model/mtl',
-  '.png':  'image/png',
-  '.jpg':  'image/jpeg',
-  '.svg':  'image/svg+xml',
-  '.json': 'application/json',
-  '.woff': 'font/woff',
-  '.woff2':'font/woff2',
-}
-
-/**
- * Vite dev-server plugin: serves ../gcode_viewer/ at /gcode-viewer/
- * without needing a proxy to uvicorn.  In production uvicorn handles it
- * via the StaticFiles mount in main.py.
- */
-function serveGcodeViewer() {
-  return {
-    name: 'serve-gcode-viewer',
-    configureServer(server: { middlewares: Connect.Server }) {
-      server.middlewares.use((req, res, next) => {
-        const url = req.url ?? ''
-        if (!url.startsWith('/gcode-viewer')) return next()
-
-        // Strip prefix, default to index.html
-        let rel = url.slice('/gcode-viewer'.length)
-        if (rel === '' || rel === '/') rel = '/index.html'
-        // Strip query string
-        rel = rel.split('?')[0]
-
-        const absPath = path.join(gcodeViewerDir, rel)
-
-        try {
-          const stat = fs.statSync(absPath)
-          if (stat.isFile()) {
-            const ext = path.extname(absPath).toLowerCase()
-            res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream')
-            res.end(fs.readFileSync(absPath))
-            return
-          }
-        } catch {
-          // file not found — fall through to index.html
-        }
-
-        // SPA fallback: serve index.html for any unmatched /gcode-viewer/* path
-        const index = path.join(gcodeViewerDir, 'index.html')
-        if (fs.existsSync(index)) {
-          res.setHeader('Content-Type', 'text/html; charset=utf-8')
-          res.end(fs.readFileSync(index))
-          return
-        }
-
-        next()
-      })
-    },
-  }
-}
 
 export default defineConfig({
   // Default base ('/') emits absolute asset URLs (/assets/...). Required so
@@ -83,7 +17,7 @@ export default defineConfig({
   // fix for subpath reverse proxies (#1195, wontfix) is reverted — that
   // audience uses NPM + Cloudflare Tunnel at a real domain per the
   // documented workaround, which doesn't depend on this setting.
-  plugins: [react(), serveGcodeViewer()],
+  plugins: [react()],
   build: {
     outDir: '../static',
     emptyOutDir: true,

+ 0 - 60
gcode_viewer/VENDORED.md

@@ -1,60 +0,0 @@
-# Third-Party Notices — gcode_viewer
-
-The `gcode_viewer/` directory bundles the following third-party libraries.
-All licenses are compatible with Bambuddy's AGPL-3.0.
-
----
-
-## PrettyGCode (OctoPrint plugin)
-
-- **File:** `js/prettygcode.js`
-- **Source:** https://github.com/Kragrathea/OctoPrint-PrettyGCode
-- **License:** AGPLv3
-
----
-
-## three.js
-
-- **Files:** `js/three.min.js`, `js/OBJLoader.js`, `js/Line2.js`,
-  `js/LineGeometry.js`, `js/LineMaterial.js`, `js/LineSegments2.js`,
-  `js/LineSegmentsGeometry.js`, `js/Lut.js`
-- **Version:** r108
-- **Source:** https://github.com/mrdoob/three.js
-- **License:** MIT — https://github.com/mrdoob/three.js/blob/dev/LICENSE
-- **Note:** `OBJLoader`, `Line2`, `LineGeometry`, `LineMaterial`,
-  `LineSegments2`, `LineSegmentsGeometry`, and `Lut` are examples/extras
-  from three.js r108, same MIT licence.
-
----
-
-## jQuery
-
-- **File:** `js/jquery.min.js`
-- **Version:** v3.7.1
-- **Source:** https://github.com/jquery/jquery
-- **License:** MIT — https://github.com/jquery/jquery/blob/main/LICENSE.txt
-
----
-
-## dat.GUI
-
-- **File:** `js/dat.gui.js`
-- **Source:** https://github.com/dataarts/dat.gui
-- **License:** Apache 2.0 — https://github.com/dataarts/dat.gui/blob/master/LICENSE
-
----
-
-## camera-controls
-
-- **File:** `js/camera-controls.js`
-- **Source:** https://github.com/yomotsu/camera-controls
-- **License:** MIT — https://github.com/yomotsu/camera-controls/blob/main/LICENSE
-
----
-
-## Helvetiker Bold (typeface.js font)
-
-- **File:** `js/helvetiker_bold.typeface.json`
-- **Source:** Bundled with three.js examples; derived from M+ FONTS
-- **License:** M+ Font License (free for any use including commercial)
-  https://mplus-fonts.osdn.jp/about-en.html

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä