slicer.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. """Pydantic schemas for slice requests."""
  2. import re
  3. from typing import Any, Literal
  4. from pydantic import BaseModel, Field, field_validator, model_validator
  5. # `#RRGGBB` or `#RRGGBBAA`. Bambu Studio writes the 6-digit form into
  6. # `filament_colour` but accepts and round-trips the 8-digit one, and the AMS
  7. # reports colours with an alpha byte, so both have to pass.
  8. _HEX_COLOUR = re.compile(r"#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})")
  9. # The ``curr_bed_type`` values the slicers know: ``s_keys_map_BedType`` in
  10. # BambuStudio's and OrcaSlicer's PrintConfig.cpp (Textured Cool Plate is Orca
  11. # only). The slicer CLI does not refuse any other name: it slices as Cool Plate
  12. # and then fails on filaments the Cool Plate does not take (#3359). "Default
  13. # Plate" is left out: it only means "the global plate" on a plate, and as the
  14. # global plate the CLI has no bed temperature to read for it.
  15. BED_TYPES = frozenset(
  16. {
  17. "Cool Plate",
  18. "Engineering Plate",
  19. "High Temp Plate",
  20. "Textured PEI Plate",
  21. "Supertack Plate",
  22. "Textured Cool Plate",
  23. }
  24. )
  25. # Plates only OrcaSlicer has; Bambu Studio slices them as Cool Plate.
  26. ORCA_ONLY_BED_TYPES = frozenset({"Textured Cool Plate"})
  27. # Names the Slice modal sent before #3359. Saved pipelines may still hold them.
  28. # Smooth PEI is not a plate of its own: the slicers call it High Temp Plate.
  29. _OLD_BED_TYPE_NAMES = {
  30. "Cool Plate (SuperTack)": "Supertack Plate",
  31. "Smooth PEI Plate": "High Temp Plate",
  32. }
  33. def canonical_bed_type(bed_type: str | None) -> str | None:
  34. """The slicer's name for a bed type; empty means no override."""
  35. if not bed_type:
  36. return bed_type
  37. return _OLD_BED_TYPE_NAMES.get(bed_type, bed_type)
  38. def checked_bed_type(bed_type: str | None) -> str | None:
  39. """``canonical_bed_type`` for request bodies: a name no slicer knows is
  40. refused instead of being sliced as Cool Plate."""
  41. bed_type = canonical_bed_type(bed_type)
  42. if bed_type and bed_type not in BED_TYPES:
  43. raise ValueError(f"unknown bed_type {bed_type!r}, expected one of: {', '.join(sorted(BED_TYPES))}")
  44. return bed_type
  45. class PresetRef(BaseModel):
  46. """A source-aware reference to a printer / process / filament preset.
  47. The SliceModal pulls dropdown options from four tiers (orca_cloud /
  48. cloud / local / standard). At submit time the client sends one of these
  49. per slot so the backend knows where to fetch the preset content from at
  50. slice time. ``cloud`` is Bambu Cloud (kept as the bare name for backward
  51. compatibility with existing requests); ``orca_cloud`` is Orca Cloud.
  52. """
  53. source: Literal["orca_cloud", "cloud", "local", "standard"]
  54. id: str = Field(
  55. ...,
  56. description=(
  57. "Orca Cloud profile id, Bambu Cloud setting_id, local DB row id (stringified), or standard preset name."
  58. ),
  59. )
  60. class SliceRequest(BaseModel):
  61. """Body for `POST /library/files/{file_id}/slice`.
  62. Two preset shapes are accepted per slot for backwards-compatibility:
  63. - **Legacy** — bare integer ``*_preset_id`` fields point into the
  64. ``local_presets`` table. Existing clients (and stale browser tabs after
  65. a Bambuddy upgrade) keep working unchanged.
  66. - **Source-aware** — ``*_preset`` carries an explicit
  67. ``{source, id}``. Required for cloud / standard tiers; also accepted
  68. (and equivalent) for local presets when the client is on the new modal.
  69. Exactly one of each pair must be set; the validator normalises legacy
  70. integer ids into a ``PresetRef(source='local', id=str(id))`` so the
  71. downstream resolver only deals with one shape.
  72. """
  73. # Legacy fields — kept optional so older clients continue to work.
  74. printer_preset_id: int | None = Field(
  75. default=None,
  76. description="DEPRECATED: prefer printer_preset. LocalPreset id with preset_type='printer'.",
  77. )
  78. process_preset_id: int | None = Field(
  79. default=None,
  80. description="DEPRECATED: prefer process_preset. LocalPreset id with preset_type='process'.",
  81. )
  82. filament_preset_id: int | None = Field(
  83. default=None,
  84. description="DEPRECATED: prefer filament_preset. LocalPreset id with preset_type='filament'.",
  85. )
  86. # Source-aware fields — set by the new SliceModal.
  87. printer_preset: PresetRef | None = None
  88. process_preset: PresetRef | None = None
  89. filament_preset: PresetRef | None = None
  90. # Multi-color: one PresetRef per AMS slot the source plate uses. Order is
  91. # significant — the slicer matches index-by-index against the plate's
  92. # filament slots. Always preferred over the legacy singular field; the
  93. # validator promotes a singular field into ``[singular]`` when the list
  94. # is empty so older clients keep working.
  95. filament_presets: list[PresetRef] = Field(default_factory=list)
  96. # Per-slot filament colour, plate-slot-ordered like ``filament_presets``.
  97. # Neither Bambu Studio nor OrcaSlicer store a colour on a *filament preset*
  98. # — it is a per-project property their GUIs set from the plate — so the CLI
  99. # falls back to its compiled-in default (#00AE42, Bambu green) for every
  100. # slice unless something supplies one. That default is what #2977 saw: a
  101. # green plate thumbnail, `filament_colour = #00AE42` in the output, and a
  102. # "Color mismatch" against the AMS slot the print was mapped to.
  103. #
  104. # `default_filament_colour` is NOT a substitute. Measured against a
  105. # 02.08.02.61 sidecar: sending it alone leaves `filament_colour` at
  106. # #00AE42, because the CLI never reads it — it is consumed by the GUI when
  107. # initialising a project. The colour has to be written to `filament_colour`
  108. # itself, which is what this field ends up doing.
  109. filament_colours: list[str] = Field(
  110. default_factory=list,
  111. description=(
  112. "Per-slot filament colour as ``#RRGGBB`` / ``#RRGGBBAA``, in the same "
  113. "plate-slot order as ``filament_presets``. Written onto each resolved "
  114. "filament profile as ``filament_colour`` so the sliced file records the "
  115. "colour actually being printed instead of the slicer's built-in default "
  116. "(#2977). A shorter list than ``filament_presets`` leaves the remaining "
  117. "slots to the fallback chain; an empty string in any position does the "
  118. "same for that one slot. An omitted list (older clients) falls back to "
  119. "the preset's own ``default_filament_colour``, then to the colour the "
  120. "source file's plate was designed with."
  121. ),
  122. )
  123. plate: int | None = Field(
  124. default=None,
  125. ge=0,
  126. description=(
  127. "Plate number to slice. ``None`` defaults to plate 1 on the sidecar "
  128. "(matches the pre-multi-plate behaviour). ``0`` is the sidecar's "
  129. "'all plates' sentinel — produces a single multi-plate 3MF whose "
  130. "``Metadata/plate_N.gcode`` entries cover every plate in the "
  131. "source. ``>= 1`` slices that one plate."
  132. ),
  133. )
  134. export_3mf: bool = Field(
  135. default=False,
  136. description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
  137. )
  138. design_overrides: list[str] | None = Field(
  139. default=None,
  140. description=(
  141. "3MF only. Process setting keys from the source file's "
  142. "``different_settings_to_system`` to carry onto the picked process "
  143. "preset (#2622) — the designer's own wall count, infill, first-layer "
  144. "height and so on, which ``--load-settings`` would otherwise discard. "
  145. "Only keys the source actually lists as changed are applied; anything "
  146. "else is ignored. An empty list is not the same answer as ``None``: "
  147. "it says the caller was shown the file's settings and chose none of "
  148. "them, which also holds back the support carry-over (#1881) for the "
  149. "support keys the file offered, while ``None`` — a caller that "
  150. "predates the per-key choice — leaves that carry-over unconditional."
  151. ),
  152. )
  153. process_overrides: dict[str, Any] | None = Field(
  154. default=None,
  155. description=(
  156. "The user's own process-setting edits from the slice modal's settings "
  157. "panel, as a sparse ``{option_key: value}`` map (layer height, wall "
  158. "count, supports, speeds — OrcaSlicer's process parameter set). Written "
  159. "into the process JSON *after* the source's support settings and the "
  160. "designer's carried tweaks, so an explicit choice here wins over both. "
  161. "Values are normalised to the string forms a process preset stores; "
  162. "keys that aren't valid config keys are dropped rather than failing "
  163. "the slice. ``None``/empty leaves the picked preset untouched."
  164. ),
  165. )
  166. use_embedded_settings: bool = Field(
  167. default=False,
  168. description=(
  169. "3MF only. Slice using the file's embedded "
  170. "``Metadata/project_settings.config`` (the designer's own tweaks — wall "
  171. "count, infill, etc.) instead of the picked printer/process/filament "
  172. "triplet. This is the 'slice as designed' path: no ``--load-settings`` "
  173. "override, so a MakerWorld author's settings survive. Ignored for STL / "
  174. "plain-model 3MF (no embedded profile to honour). The preset refs are "
  175. "still required by the validator but go unused on this path. Only makes "
  176. "sense when the picked printer matches the design's target model — the "
  177. "UI gates the toggle on that; there is no cross-printer re-targeting here "
  178. "(that is exactly what the profile path is for)."
  179. ),
  180. )
  181. bed_type: str | None = Field(
  182. default=None,
  183. max_length=64,
  184. description=(
  185. "Override the process preset's curr_bed_type for this slice. One of "
  186. "the BambuStudio / OrcaSlicer values: 'Cool Plate', 'Engineering Plate', "
  187. "'High Temp Plate' (Smooth PEI), 'Textured PEI Plate', 'Supertack "
  188. "Plate', 'Textured Cool Plate' (OrcaSlicer only). "
  189. "Any other name is refused (#3359). None ⇒ inherit from the process "
  190. "preset unchanged (#1337)."
  191. ),
  192. )
  193. auto_orient: bool = Field(
  194. default=False,
  195. description=(
  196. "Let the slicer pick each object's orientation before slicing "
  197. "(BambuStudio / OrcaSlicer ``--orient 1``, the GUI's 'Auto orient'). "
  198. "Off by default: it rotates geometry, so a model the designer laid "
  199. "flat on purpose would silently change. Applies on the embedded-"
  200. "settings path too — it is a CLI action, not a profile value (#2548)."
  201. ),
  202. )
  203. auto_arrange: bool = Field(
  204. default=False,
  205. description=(
  206. "Let the slicer lay the objects out on the plate before slicing "
  207. "(``--arrange 1``, the GUI's 'Auto arrange'). Off by default: it "
  208. "repositions objects, discarding a deliberate layout. Forced on "
  209. "regardless for cross-nozzle-class re-slices, where the source's "
  210. "coordinates land in the target's dead zone (#1493). Applies on the "
  211. "embedded-settings path too (#2548)."
  212. ),
  213. )
  214. @field_validator("bed_type")
  215. @classmethod
  216. def check_bed_type(cls, value: str | None) -> str | None:
  217. return checked_bed_type(value)
  218. @model_validator(mode="after")
  219. def normalise_preset_refs(self) -> "SliceRequest":
  220. """Each slot must end up with a `PresetRef` set. Legacy integer ids
  221. become `(source='local', id=str(int))` so the route handler only
  222. deals with the canonical shape. For filament: a non-empty
  223. ``filament_presets`` list satisfies the requirement on its own; an
  224. empty list falls back to the singular fields, which then promote
  225. into a one-element list.
  226. """
  227. for slot, ref_attr, legacy_attr in (
  228. ("printer", "printer_preset", "printer_preset_id"),
  229. ("process", "process_preset", "process_preset_id"),
  230. ):
  231. ref = getattr(self, ref_attr)
  232. legacy_id = getattr(self, legacy_attr)
  233. if ref is None and legacy_id is None:
  234. raise ValueError(
  235. f"{slot} preset is required: provide '{ref_attr}' (preferred) or legacy '{legacy_attr}'"
  236. )
  237. if ref is None:
  238. setattr(self, ref_attr, PresetRef(source="local", id=str(legacy_id)))
  239. # Filament accepts THREE shapes, in priority order:
  240. # 1. filament_presets — multi-color array (new clients)
  241. # 2. filament_preset — source-aware singular (single-color new clients)
  242. # 3. filament_preset_id — legacy bare integer (old clients)
  243. # The first non-empty shape wins; missing all three raises.
  244. if not self.filament_presets:
  245. if self.filament_preset is not None:
  246. self.filament_presets = [self.filament_preset]
  247. elif self.filament_preset_id is not None:
  248. fallback = PresetRef(source="local", id=str(self.filament_preset_id))
  249. self.filament_preset = fallback
  250. self.filament_presets = [fallback]
  251. else:
  252. raise ValueError(
  253. "filament preset is required: provide 'filament_presets' (preferred), "
  254. "'filament_preset', or legacy 'filament_preset_id'"
  255. )
  256. elif self.filament_preset is None:
  257. # Multi-color caller: backfill the singular from the first slot
  258. # so callers that still read the legacy field see a stable value.
  259. self.filament_preset = self.filament_presets[0]
  260. # Colours are pasted straight into a profile the slicer parses, so a
  261. # malformed one is rejected here rather than passed through. Empty
  262. # strings survive: they are how a caller says "no colour for this
  263. # slot" without having to shorten the list and shift every slot after
  264. # it. Normalised to upper-case so a slice never differs from another
  265. # only by the case of a hex digit.
  266. normalised: list[str] = []
  267. for i, colour in enumerate(self.filament_colours):
  268. value = (colour or "").strip()
  269. if not value:
  270. normalised.append("")
  271. continue
  272. if not _HEX_COLOUR.fullmatch(value):
  273. raise ValueError(f"filament_colours[{i}] must be '#RRGGBB' or '#RRGGBBAA', got {colour!r}")
  274. normalised.append("#" + value[1:].upper())
  275. self.filament_colours = normalised
  276. return self
  277. class SliceResponse(BaseModel):
  278. """Response from `POST /library/files/{file_id}/slice`. The result lands
  279. in the user's library as a new ``LibraryFile`` (in the same folder as
  280. the source)."""
  281. library_file_id: int
  282. name: str
  283. print_time_seconds: int
  284. filament_used_g: float
  285. filament_used_mm: float
  286. used_embedded_settings: bool = False
  287. # Set when the source lives in an external folder that could not receive
  288. # the result (read-only, unreachable, not writable), so the file went to
  289. # managed storage instead. Names which of those it was. ``None`` on every
  290. # normal slice. Reported rather than silently absorbed: filing the output
  291. # somewhere the user isn't looking, with no signal, is what made #2810
  292. # impossible to reproduce from the UI.
  293. external_write_fallback: str | None = None
  294. class SliceArchiveResponse(BaseModel):
  295. """Response from `POST /archives/{archive_id}/slice`. The result lands
  296. in the user's archives as a new ``PrintArchive`` row, inheriting
  297. printer / project metadata from the source archive."""
  298. archive_id: int
  299. name: str
  300. print_time_seconds: int
  301. filament_used_g: float
  302. filament_used_mm: float
  303. used_embedded_settings: bool = False