Files
maziggy 4f10d15584 Record the colour a slice is actually printed in (issue #2977)
Every file the internal slicer produced came back with filament_colour
= #00AE42 whatever filament was picked: a green plate thumbnail, green
metadata, and a "Color mismatch" in the Print dialog against the AMS
slot the job had just been correctly mapped to.

A colour is not a property of a filament preset in either slicer. It
belongs to the project, and Bambu Studio and OrcaSlicer set it from the
plate in their GUIs -- so nothing was attached to the preset Bambuddy
sends by name, and the CLI fell back to its own compiled-in default,
which is Bambu green. None of the shipped BBL filament profiles define
filament_colour; the whole tree has zero occurrences.

default_filament_colour is not the answer on its own. Measured against
a 02.08.02.61 sidecar, a profile carrying only that still slices to
filament_colour ["#00AE42"] -- Bambu Studio consumes it in the GUI when
a project is created, not in --load-filaments. So it is read and
rewritten as filament_colour, which the same sidecar does honour: the
reporter's exact triplet returns ["#E8B00C"] when patched this way, and
the colour lands in both project_settings.config and slice_info.config,
which is what the thumbnail and the AMS mapping actually read.

Each filament row in the slice dialog gets a colour control, and the
resolved value flows through a chain: the user's pick, then the preset's
own default_filament_colour, then the colour that slot was designed with
in the source 3MF. A slot with none of the three is left untouched
rather than given a guess.

The designed colour is read from project_settings.config, not
slice_info.config. The latter records what the file was last sliced
with, which for a source that never carried a colour is #00AE42 itself
-- measured -- so using it would have been circular.

The control is offered on single-filament sources too, because an STL,
and equally a mesh-only 3MF exported from CAD, has no colour anywhere
else to inherit. That is the case this exists for, and it took three
shapes to make it visible: a swatch in the label row was identical to
the read-only dot multi-colour rows have carried for releases, and
adding the hex beside it only made it look like a caption. It now sits
beside the dropdown, styled like it and the same height, with the
swatch and hex wrapped in one label bound to the input so a click
anywhere on it opens the picker.

An untouched slot with no designed colour submits an empty string
rather than the control's displayed default. A sent colour outranks the
preset's own, so pinning the placeholder would silently discard the
real colour of an imported OrcaSlicer profile that carries one.

Slicer Pipelines pick up the same chain without carrying a colour of
their own.

Also adds a warning for a defect found while investigating this: a
filament preset whose name the sidecar's bundle cannot resolve is not
rejected. The CLI inherits nothing, falls back to its defaults for
every field, and returns a well-formed success -- measured, an
unresolvable name slices as filament_type ["PLA"] at nozzle_temperature
["200"] with filament_ids [""] and filament_vendor ["(Undefined)"], so
a PETG preset a sidecar image predates prints at PLA temperatures with
no diagnostic anywhere. Both signals are required together, which keeps
it off the two legitimate lookalikes: a hand-written profile that never
named a vendor still carries a real filament id, and a user's own cloud
preset carries a vendor while legitimately having no bundled id. The
file is kept rather than refused, unlike the missing start G-code of
whoever can see the temperatures.
2026-08-28 09:23:30 +02:00

289 lines
14 KiB
Python

"""Pydantic schemas for slice requests."""
import re
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
# `#RRGGBB` or `#RRGGBBAA`. Bambu Studio writes the 6-digit form into
# `filament_colour` but accepts and round-trips the 8-digit one, and the AMS
# reports colours with an alpha byte, so both have to pass.
_HEX_COLOUR = re.compile(r"#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})")
class PresetRef(BaseModel):
"""A source-aware reference to a printer / process / filament preset.
The SliceModal pulls dropdown options from four tiers (orca_cloud /
cloud / local / standard). At submit time the client sends one of these
per slot so the backend knows where to fetch the preset content from at
slice time. ``cloud`` is Bambu Cloud (kept as the bare name for backward
compatibility with existing requests); ``orca_cloud`` is Orca Cloud.
"""
source: Literal["orca_cloud", "cloud", "local", "standard"]
id: str = Field(
...,
description=(
"Orca Cloud profile id, Bambu Cloud setting_id, local DB row id (stringified), or standard preset name."
),
)
class SliceRequest(BaseModel):
"""Body for `POST /library/files/{file_id}/slice`.
Two preset shapes are accepted per slot for backwards-compatibility:
- **Legacy** — bare integer ``*_preset_id`` fields point into the
``local_presets`` table. Existing clients (and stale browser tabs after
a Bambuddy upgrade) keep working unchanged.
- **Source-aware** — ``*_preset`` carries an explicit
``{source, id}``. Required for cloud / standard tiers; also accepted
(and equivalent) for local presets when the client is on the new modal.
Exactly one of each pair must be set; the validator normalises legacy
integer ids into a ``PresetRef(source='local', id=str(id))`` so the
downstream resolver only deals with one shape.
"""
# Legacy fields — kept optional so older clients continue to work.
printer_preset_id: int | None = Field(
default=None,
description="DEPRECATED: prefer printer_preset. LocalPreset id with preset_type='printer'.",
)
process_preset_id: int | None = Field(
default=None,
description="DEPRECATED: prefer process_preset. LocalPreset id with preset_type='process'.",
)
filament_preset_id: int | None = Field(
default=None,
description="DEPRECATED: prefer filament_preset. LocalPreset id with preset_type='filament'.",
)
# Source-aware fields — set by the new SliceModal.
printer_preset: PresetRef | None = None
process_preset: PresetRef | None = None
filament_preset: PresetRef | None = None
# Multi-color: one PresetRef per AMS slot the source plate uses. Order is
# significant — the slicer matches index-by-index against the plate's
# filament slots. Always preferred over the legacy singular field; the
# validator promotes a singular field into ``[singular]`` when the list
# is empty so older clients keep working.
filament_presets: list[PresetRef] = Field(default_factory=list)
# Per-slot filament colour, plate-slot-ordered like ``filament_presets``.
# Neither Bambu Studio nor OrcaSlicer store a colour on a *filament preset*
# — it is a per-project property their GUIs set from the plate — so the CLI
# falls back to its compiled-in default (#00AE42, Bambu green) for every
# slice unless something supplies one. That default is what #2977 saw: a
# green plate thumbnail, `filament_colour = #00AE42` in the output, and a
# "Color mismatch" against the AMS slot the print was mapped to.
#
# `default_filament_colour` is NOT a substitute. Measured against a
# 02.08.02.61 sidecar: sending it alone leaves `filament_colour` at
# #00AE42, because the CLI never reads it — it is consumed by the GUI when
# initialising a project. The colour has to be written to `filament_colour`
# itself, which is what this field ends up doing.
filament_colours: list[str] = Field(
default_factory=list,
description=(
"Per-slot filament colour as ``#RRGGBB`` / ``#RRGGBBAA``, in the same "
"plate-slot order as ``filament_presets``. Written onto each resolved "
"filament profile as ``filament_colour`` so the sliced file records the "
"colour actually being printed instead of the slicer's built-in default "
"(#2977). A shorter list than ``filament_presets`` leaves the remaining "
"slots to the fallback chain; an empty string in any position does the "
"same for that one slot. An omitted list (older clients) falls back to "
"the preset's own ``default_filament_colour``, then to the colour the "
"source file's plate was designed with."
),
)
plate: int | None = Field(
default=None,
ge=0,
description=(
"Plate number to slice. ``None`` defaults to plate 1 on the sidecar "
"(matches the pre-multi-plate behaviour). ``0`` is the sidecar's "
"'all plates' sentinel — produces a single multi-plate 3MF whose "
"``Metadata/plate_N.gcode`` entries cover every plate in the "
"source. ``>= 1`` slices that one plate."
),
)
export_3mf: bool = Field(
default=False,
description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
)
design_overrides: list[str] | None = Field(
default=None,
description=(
"3MF only. Process setting keys from the source file's "
"``different_settings_to_system`` to carry onto the picked process "
"preset (#2622) — the designer's own wall count, infill, first-layer "
"height and so on, which ``--load-settings`` would otherwise discard. "
"Only keys the source actually lists as changed are applied; anything "
"else is ignored. An empty list is not the same answer as ``None``: "
"it says the caller was shown the file's settings and chose none of "
"them, which also holds back the support carry-over (#1881) for the "
"support keys the file offered, while ``None`` — a caller that "
"predates the per-key choice — leaves that carry-over unconditional."
),
)
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=(
"3MF only. Slice using the file's embedded "
"``Metadata/project_settings.config`` (the designer's own tweaks — wall "
"count, infill, etc.) instead of the picked printer/process/filament "
"triplet. This is the 'slice as designed' path: no ``--load-settings`` "
"override, so a MakerWorld author's settings survive. Ignored for STL / "
"plain-model 3MF (no embedded profile to honour). The preset refs are "
"still required by the validator but go unused on this path. Only makes "
"sense when the picked printer matches the design's target model — the "
"UI gates the toggle on that; there is no cross-printer re-targeting here "
"(that is exactly what the profile path is for)."
),
)
bed_type: str | None = Field(
default=None,
max_length=64,
description=(
"Override the process preset's curr_bed_type for this slice. Canonical "
"BambuStudio / OrcaSlicer values: 'Cool Plate', 'Engineering Plate', "
"'High Temp Plate', 'Textured PEI Plate', 'Smooth PEI Plate', "
"'Cool Plate (SuperTack)', 'Supertack Plate'. None ⇒ inherit from the "
"process preset unchanged (#1337)."
),
)
auto_orient: bool = Field(
default=False,
description=(
"Let the slicer pick each object's orientation before slicing "
"(BambuStudio / OrcaSlicer ``--orient 1``, the GUI's 'Auto orient'). "
"Off by default: it rotates geometry, so a model the designer laid "
"flat on purpose would silently change. Applies on the embedded-"
"settings path too — it is a CLI action, not a profile value (#2548)."
),
)
auto_arrange: bool = Field(
default=False,
description=(
"Let the slicer lay the objects out on the plate before slicing "
"(``--arrange 1``, the GUI's 'Auto arrange'). Off by default: it "
"repositions objects, discarding a deliberate layout. Forced on "
"regardless for cross-nozzle-class re-slices, where the source's "
"coordinates land in the target's dead zone (#1493). Applies on the "
"embedded-settings path too (#2548)."
),
)
@model_validator(mode="after")
def normalise_preset_refs(self) -> "SliceRequest":
"""Each slot must end up with a `PresetRef` set. Legacy integer ids
become `(source='local', id=str(int))` so the route handler only
deals with the canonical shape. For filament: a non-empty
``filament_presets`` list satisfies the requirement on its own; an
empty list falls back to the singular fields, which then promote
into a one-element list.
"""
for slot, ref_attr, legacy_attr in (
("printer", "printer_preset", "printer_preset_id"),
("process", "process_preset", "process_preset_id"),
):
ref = getattr(self, ref_attr)
legacy_id = getattr(self, legacy_attr)
if ref is None and legacy_id is None:
raise ValueError(
f"{slot} preset is required: provide '{ref_attr}' (preferred) or legacy '{legacy_attr}'"
)
if ref is None:
setattr(self, ref_attr, PresetRef(source="local", id=str(legacy_id)))
# Filament accepts THREE shapes, in priority order:
# 1. filament_presets — multi-color array (new clients)
# 2. filament_preset — source-aware singular (single-color new clients)
# 3. filament_preset_id — legacy bare integer (old clients)
# The first non-empty shape wins; missing all three raises.
if not self.filament_presets:
if self.filament_preset is not None:
self.filament_presets = [self.filament_preset]
elif self.filament_preset_id is not None:
fallback = PresetRef(source="local", id=str(self.filament_preset_id))
self.filament_preset = fallback
self.filament_presets = [fallback]
else:
raise ValueError(
"filament preset is required: provide 'filament_presets' (preferred), "
"'filament_preset', or legacy 'filament_preset_id'"
)
elif self.filament_preset is None:
# Multi-color caller: backfill the singular from the first slot
# so callers that still read the legacy field see a stable value.
self.filament_preset = self.filament_presets[0]
# Colours are pasted straight into a profile the slicer parses, so a
# malformed one is rejected here rather than passed through. Empty
# strings survive: they are how a caller says "no colour for this
# slot" without having to shorten the list and shift every slot after
# it. Normalised to upper-case so a slice never differs from another
# only by the case of a hex digit.
normalised: list[str] = []
for i, colour in enumerate(self.filament_colours):
value = (colour or "").strip()
if not value:
normalised.append("")
continue
if not _HEX_COLOUR.fullmatch(value):
raise ValueError(f"filament_colours[{i}] must be '#RRGGBB' or '#RRGGBBAA', got {colour!r}")
normalised.append("#" + value[1:].upper())
self.filament_colours = normalised
return self
class SliceResponse(BaseModel):
"""Response from `POST /library/files/{file_id}/slice`. The result lands
in the user's library as a new ``LibraryFile`` (in the same folder as
the source)."""
library_file_id: int
name: str
print_time_seconds: int
filament_used_g: float
filament_used_mm: float
used_embedded_settings: bool = False
# Set when the source lives in an external folder that could not receive
# the result (read-only, unreachable, not writable), so the file went to
# managed storage instead. Names which of those it was. ``None`` on every
# normal slice. Reported rather than silently absorbed: filing the output
# somewhere the user isn't looking, with no signal, is what made #2810
# impossible to reproduce from the UI.
external_write_fallback: str | None = None
class SliceArchiveResponse(BaseModel):
"""Response from `POST /archives/{archive_id}/slice`. The result lands
in the user's archives as a new ``PrintArchive`` row, inheriting
printer / project metadata from the source archive."""
archive_id: int
name: str
print_time_seconds: int
filament_used_g: float
filament_used_mm: float
used_embedded_settings: bool = False