slicer.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. """Pydantic schemas for slice requests."""
  2. from typing import Literal
  3. from pydantic import BaseModel, Field, model_validator
  4. class PresetRef(BaseModel):
  5. """A source-aware reference to a printer / process / filament preset.
  6. The SliceModal pulls dropdown options from four tiers (orca_cloud /
  7. cloud / local / standard). At submit time the client sends one of these
  8. per slot so the backend knows where to fetch the preset content from at
  9. slice time. ``cloud`` is Bambu Cloud (kept as the bare name for backward
  10. compatibility with existing requests); ``orca_cloud`` is Orca Cloud.
  11. """
  12. source: Literal["orca_cloud", "cloud", "local", "standard"]
  13. id: str = Field(
  14. ...,
  15. description=(
  16. "Orca Cloud profile id, Bambu Cloud setting_id, local DB row id (stringified), or standard preset name."
  17. ),
  18. )
  19. class SliceBundleSpec(BaseModel):
  20. """Per-request reference to a Printer Preset Bundle stored on the slicer
  21. sidecar. When SliceRequest.bundle is set, the dispatch skips PresetRef
  22. resolution entirely and asks the sidecar to pick its inner JSON triplet
  23. by name from the bundle's extracted directory — much faster than
  24. re-uploading three profile JSONs every slice and matches the preset
  25. triplet the user actually slices with in BambuStudio.
  26. """
  27. bundle_id: str = Field(
  28. ...,
  29. min_length=1,
  30. description="Sidecar-side bundle id from POST /api/v1/slicer/bundles.",
  31. )
  32. printer_name: str = Field(
  33. ...,
  34. min_length=1,
  35. description="Preset name within the bundle's printer/ directory (with or without the BambuStudio '# ' prefix).",
  36. )
  37. process_name: str = Field(
  38. ...,
  39. min_length=1,
  40. description="Preset name within the bundle's process/ directory.",
  41. )
  42. filament_names: list[str] = Field(
  43. ...,
  44. min_length=1,
  45. description="Per-slot filament preset names within the bundle's filament/ directory. Index 0 = slot 1.",
  46. )
  47. class SliceRequest(BaseModel):
  48. """Body for `POST /library/files/{file_id}/slice`.
  49. Two preset shapes are accepted per slot for backwards-compatibility:
  50. - **Legacy** — bare integer ``*_preset_id`` fields point into the
  51. ``local_presets`` table. Existing clients (and stale browser tabs after
  52. a Bambuddy upgrade) keep working unchanged.
  53. - **Source-aware** — ``*_preset`` carries an explicit
  54. ``{source, id}``. Required for cloud / standard tiers; also accepted
  55. (and equivalent) for local presets when the client is on the new modal.
  56. Exactly one of each pair must be set; the validator normalises legacy
  57. integer ids into a ``PresetRef(source='local', id=str(id))`` so the
  58. downstream resolver only deals with one shape.
  59. """
  60. # Legacy fields — kept optional so older clients continue to work.
  61. printer_preset_id: int | None = Field(
  62. default=None,
  63. description="DEPRECATED: prefer printer_preset. LocalPreset id with preset_type='printer'.",
  64. )
  65. process_preset_id: int | None = Field(
  66. default=None,
  67. description="DEPRECATED: prefer process_preset. LocalPreset id with preset_type='process'.",
  68. )
  69. filament_preset_id: int | None = Field(
  70. default=None,
  71. description="DEPRECATED: prefer filament_preset. LocalPreset id with preset_type='filament'.",
  72. )
  73. # Source-aware fields — set by the new SliceModal.
  74. printer_preset: PresetRef | None = None
  75. process_preset: PresetRef | None = None
  76. filament_preset: PresetRef | None = None
  77. # Multi-color: one PresetRef per AMS slot the source plate uses. Order is
  78. # significant — the slicer matches index-by-index against the plate's
  79. # filament slots. Always preferred over the legacy singular field; the
  80. # validator promotes a singular field into ``[singular]`` when the list
  81. # is empty so older clients keep working.
  82. filament_presets: list[PresetRef] = Field(default_factory=list)
  83. # Bundle dispatch alternative — when set, presets above are ignored and
  84. # the slicer dispatch picks per-category JSONs from a previously-imported
  85. # .bbscfg on the sidecar. Validator below short-circuits the
  86. # presets-required check when this is non-None.
  87. bundle: SliceBundleSpec | None = Field(
  88. default=None,
  89. description="When set, slice via a sidecar-side bundle instead of resolved preset refs.",
  90. )
  91. plate: int | None = Field(
  92. default=None,
  93. ge=0,
  94. description=(
  95. "Plate number to slice. ``None`` defaults to plate 1 on the sidecar "
  96. "(matches the pre-multi-plate behaviour). ``0`` is the sidecar's "
  97. "'all plates' sentinel — produces a single multi-plate 3MF whose "
  98. "``Metadata/plate_N.gcode`` entries cover every plate in the "
  99. "source. ``>= 1`` slices that one plate."
  100. ),
  101. )
  102. export_3mf: bool = Field(
  103. default=False,
  104. description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
  105. )
  106. bed_type: str | None = Field(
  107. default=None,
  108. max_length=64,
  109. description=(
  110. "Override the process preset's curr_bed_type for this slice. Canonical "
  111. "BambuStudio / OrcaSlicer values: 'Cool Plate', 'Engineering Plate', "
  112. "'High Temp Plate', 'Textured PEI Plate', 'Smooth PEI Plate', "
  113. "'Cool Plate (SuperTack)', 'Supertack Plate'. None ⇒ inherit from the "
  114. "process preset unchanged (#1337)."
  115. ),
  116. )
  117. @model_validator(mode="after")
  118. def normalise_preset_refs(self) -> "SliceRequest":
  119. """Each slot must end up with a `PresetRef` set. Legacy integer ids
  120. become `(source='local', id=str(int))` so the route handler only
  121. deals with the canonical shape. For filament: a non-empty
  122. ``filament_presets`` list satisfies the requirement on its own; an
  123. empty list falls back to the singular fields, which then promote
  124. into a one-element list.
  125. When ``bundle`` is set, the dispatch picks the JSON triplet from
  126. the sidecar bundle directly so PresetRef resolution is skipped —
  127. return early before the presets-required checks below.
  128. """
  129. if self.bundle is not None:
  130. return self
  131. for slot, ref_attr, legacy_attr in (
  132. ("printer", "printer_preset", "printer_preset_id"),
  133. ("process", "process_preset", "process_preset_id"),
  134. ):
  135. ref = getattr(self, ref_attr)
  136. legacy_id = getattr(self, legacy_attr)
  137. if ref is None and legacy_id is None:
  138. raise ValueError(
  139. f"{slot} preset is required: provide '{ref_attr}' (preferred) or legacy '{legacy_attr}'"
  140. )
  141. if ref is None:
  142. setattr(self, ref_attr, PresetRef(source="local", id=str(legacy_id)))
  143. # Filament accepts THREE shapes, in priority order:
  144. # 1. filament_presets — multi-color array (new clients)
  145. # 2. filament_preset — source-aware singular (single-color new clients)
  146. # 3. filament_preset_id — legacy bare integer (old clients)
  147. # The first non-empty shape wins; missing all three raises.
  148. if not self.filament_presets:
  149. if self.filament_preset is not None:
  150. self.filament_presets = [self.filament_preset]
  151. elif self.filament_preset_id is not None:
  152. fallback = PresetRef(source="local", id=str(self.filament_preset_id))
  153. self.filament_preset = fallback
  154. self.filament_presets = [fallback]
  155. else:
  156. raise ValueError(
  157. "filament preset is required: provide 'filament_presets' (preferred), "
  158. "'filament_preset', or legacy 'filament_preset_id'"
  159. )
  160. elif self.filament_preset is None:
  161. # Multi-color caller: backfill the singular from the first slot
  162. # so callers that still read the legacy field see a stable value.
  163. self.filament_preset = self.filament_presets[0]
  164. return self
  165. class SliceResponse(BaseModel):
  166. """Response from `POST /library/files/{file_id}/slice`. The result lands
  167. in the user's library as a new ``LibraryFile`` (in the same folder as
  168. the source)."""
  169. library_file_id: int
  170. name: str
  171. print_time_seconds: int
  172. filament_used_g: float
  173. filament_used_mm: float
  174. used_embedded_settings: bool = False
  175. class SliceArchiveResponse(BaseModel):
  176. """Response from `POST /archives/{archive_id}/slice`. The result lands
  177. in the user's archives as a new ``PrintArchive`` row, inheriting
  178. printer / project metadata from the source archive."""
  179. archive_id: int
  180. name: str
  181. print_time_seconds: int
  182. filament_used_g: float
  183. filament_used_mm: float
  184. used_embedded_settings: bool = False