slicer_api.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. """HTTP client for an OrcaSlicer / BambuStudio API sidecar.
  2. Bambuddy stores user printer/process/filament profiles itself (cloud-synced
  3. or locally imported), so the slice flow always sends the model file plus an
  4. explicit JSON profile triplet to the sidecar's `/slice` endpoint. The sidecar
  5. shape mirrors `AFKFelix/orca-slicer-api` (multipart upload, `--load-settings`
  6. under the hood, response body is raw G-code or 3MF with metadata in the
  7. `X-Print-Time-Seconds` / `X-Filament-Used-G` / `X-Filament-Used-Mm` headers).
  8. """
  9. import asyncio
  10. import logging
  11. from collections.abc import Callable
  12. from typing import NamedTuple
  13. import httpx
  14. logger = logging.getLogger(__name__)
  15. class SlicerApiError(Exception):
  16. """Base error from the slicer API sidecar."""
  17. class SlicerApiUnavailableError(SlicerApiError):
  18. """Sidecar is unreachable (connection error, no response)."""
  19. class SlicerApiServerError(SlicerApiError):
  20. """Sidecar responded with a 5xx — usually the wrapped slicer CLI exited
  21. non-zero (range-validation reject, segfault on complex models, etc.).
  22. Distinguished from `SlicerApiUnavailableError` so the caller can decide
  23. whether to retry with a different request shape (e.g. a 3MF embedded-
  24. settings fallback)."""
  25. class SlicerInputError(SlicerApiError):
  26. """Sidecar rejected the input as invalid (4xx)."""
  27. class SliceResult(NamedTuple):
  28. """Result of a slice operation."""
  29. content: bytes
  30. print_time_seconds: int
  31. filament_used_g: float
  32. filament_used_mm: float
  33. class BundleSummary(NamedTuple):
  34. """Sidecar's view of a stored Printer Preset Bundle (.bbscfg).
  35. Mirrors the JSON shape returned by `/profiles/bundle(s)` on the
  36. sidecar — `printer`, `process`, `filament` are each a list of preset
  37. names available within the bundle (without the `.json` extension and
  38. without the BambuStudio "# " user-clone prefix; the sidecar accepts
  39. both forms when looking them up at slice time).
  40. """
  41. id: str
  42. printer_preset_name: str
  43. printer: list[str]
  44. process: list[str]
  45. filament: list[str]
  46. version: str | None
  47. class BundleNotFoundError(SlicerApiError):
  48. """Sidecar returned 404 for the bundle id (deleted, never imported)."""
  49. def _parse_bundle_summary(payload: dict) -> BundleSummary:
  50. """Build a BundleSummary from the sidecar's JSON. Tolerant of missing
  51. optional fields so a sidecar that adds keys later doesn't break parsing.
  52. """
  53. return BundleSummary(
  54. id=str(payload.get("id") or ""),
  55. printer_preset_name=str(payload.get("printer_preset_name") or ""),
  56. printer=list(payload.get("printer") or []),
  57. process=list(payload.get("process") or []),
  58. filament=list(payload.get("filament") or []),
  59. version=payload.get("version"),
  60. )
  61. _shared_http_client: httpx.AsyncClient | None = None
  62. def _format_sidecar_error(response: httpx.Response) -> str:
  63. """Build a human-readable error string from a sidecar 4xx/5xx response.
  64. The sidecar's `AppError` middleware emits a JSON body of the shape
  65. ``{"message": "...", "details": "..."}``. Earlier versions of this
  66. client only read ``message``, which left every CLI failure surfaced
  67. as the generic ``Failed to slice the model`` because the *actual*
  68. CLI stderr / `error_string` lives in ``details``. Including both
  69. means ``bambuddy.log`` carries the real reason a slice rejected
  70. the supplied profiles instead of an unhelpful generic line.
  71. """
  72. try:
  73. payload = response.json()
  74. except Exception:
  75. return response.text[:500]
  76. if not isinstance(payload, dict):
  77. return str(payload)[:500]
  78. message = payload.get("message") or ""
  79. details = payload.get("details") or ""
  80. if message and details:
  81. return f"{message}: {details}"[:500]
  82. return (message or details or response.text)[:500]
  83. def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  84. """Register an app-scoped client so per-request services can pool transport."""
  85. global _shared_http_client
  86. _shared_http_client = client
  87. def _guess_model_content_type(filename: str) -> str:
  88. lower = filename.lower()
  89. if lower.endswith(".stl"):
  90. return "model/stl"
  91. if lower.endswith(".3mf") or lower.endswith(".gcode.3mf"):
  92. return "model/3mf"
  93. if lower.endswith(".step") or lower.endswith(".stp"):
  94. return "model/step"
  95. return "application/octet-stream"
  96. class SlicerApiService:
  97. """Talks to an OrcaSlicer / BambuStudio API sidecar."""
  98. def __init__(
  99. self,
  100. base_url: str,
  101. *,
  102. client: httpx.AsyncClient | None = None,
  103. timeout_seconds: float = 300.0,
  104. ) -> None:
  105. self.base_url = base_url.rstrip("/")
  106. self.timeout_seconds = timeout_seconds
  107. if client is not None:
  108. self._client = client
  109. self._owns_client = False
  110. elif _shared_http_client is not None:
  111. self._client = _shared_http_client
  112. self._owns_client = False
  113. else:
  114. self._client = httpx.AsyncClient(timeout=timeout_seconds)
  115. self._owns_client = True
  116. async def close(self) -> None:
  117. if self._owns_client:
  118. await self._client.aclose()
  119. async def __aenter__(self) -> "SlicerApiService":
  120. return self
  121. async def __aexit__(self, *_: object) -> None:
  122. await self.close()
  123. async def health(self) -> dict:
  124. """GET /health — used to surface a clear "sidecar offline" error before
  125. accepting a slice request from the user."""
  126. try:
  127. response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
  128. except httpx.RequestError as exc:
  129. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  130. if response.status_code >= 400:
  131. raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
  132. return response.json()
  133. async def list_bundled_profiles(self) -> dict:
  134. """GET /profiles/bundled — return the slicer's stock profiles by slot.
  135. Powers the "Standard" tier of Bambuddy's SliceModal preset dropdowns.
  136. The sidecar walks the slicer's read-only `resources/profiles/BBL/`
  137. tree and returns ``{printer, process, filament}`` arrays of
  138. ``{name, base_id}`` (alphabetised, instantiable presets only — abstract
  139. bases like `fdm_filament_pla` are filtered out by the sidecar).
  140. Returns an empty-shaped dict when the sidecar is unreachable so the
  141. unified-presets endpoint can degrade to "no standard tier" without
  142. crashing the modal — cloud + local-imported profiles still render.
  143. """
  144. try:
  145. response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
  146. except httpx.RequestError as exc:
  147. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  148. if response.status_code >= 400:
  149. raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
  150. return response.json()
  151. async def import_bundle(
  152. self,
  153. zip_bytes: bytes,
  154. *,
  155. filename: str = "bundle.bbscfg",
  156. ) -> BundleSummary:
  157. """POST /profiles/bundle — upload a BambuStudio Printer Preset Bundle.
  158. Idempotent on the sidecar side: re-uploading the same file yields the
  159. same id (deterministic SHA-256 prefix of the zip content) and the
  160. sidecar reuses its existing extracted directory, so re-importing is
  161. always safe.
  162. Raises:
  163. SlicerInputError: 4xx — bundle isn't a valid .bbscfg, or fails the
  164. sidecar's path-traversal / manifest validation.
  165. SlicerApiUnavailableError: connection error or 5xx.
  166. """
  167. files = {"file": (filename, zip_bytes, "application/zip")}
  168. try:
  169. response = await self._client.post(
  170. f"{self.base_url}/profiles/bundle",
  171. files=files,
  172. timeout=60.0,
  173. )
  174. except httpx.RequestError as exc:
  175. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  176. if response.status_code >= 500:
  177. raise SlicerApiServerError(
  178. f"Slicer sidecar /profiles/bundle failed ({response.status_code}): {_format_sidecar_error(response)}",
  179. )
  180. if response.status_code >= 400:
  181. raise SlicerInputError(
  182. f"Slicer sidecar rejected bundle ({response.status_code}): {_format_sidecar_error(response)}",
  183. )
  184. return _parse_bundle_summary(response.json())
  185. async def list_bundles(self) -> list[BundleSummary]:
  186. """GET /profiles/bundles — list every imported bundle and its presets.
  187. Returns an empty list when the sidecar's bundle store is empty (the
  188. sidecar returns ``[]`` rather than 404 in that case). Network errors
  189. and 5xx surface as ``SlicerApiUnavailableError`` so callers can
  190. decide whether to render an empty UI or a "sidecar offline" banner.
  191. """
  192. try:
  193. response = await self._client.get(f"{self.base_url}/profiles/bundles", timeout=10.0)
  194. except httpx.RequestError as exc:
  195. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  196. if response.status_code >= 400:
  197. raise SlicerApiUnavailableError(
  198. f"Slicer sidecar /profiles/bundles returned {response.status_code}",
  199. )
  200. payload = response.json()
  201. if not isinstance(payload, list):
  202. raise SlicerApiServerError("Slicer sidecar returned non-array bundle list")
  203. return [_parse_bundle_summary(b) for b in payload if isinstance(b, dict)]
  204. async def get_bundle(self, bundle_id: str) -> BundleSummary:
  205. """GET /profiles/bundles/<id> — single bundle summary.
  206. Raises:
  207. BundleNotFoundError: 404 — id does not exist on the sidecar.
  208. SlicerApiUnavailableError: connection error or 5xx.
  209. """
  210. try:
  211. response = await self._client.get(
  212. f"{self.base_url}/profiles/bundles/{bundle_id}",
  213. timeout=10.0,
  214. )
  215. except httpx.RequestError as exc:
  216. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  217. if response.status_code == 404:
  218. raise BundleNotFoundError(f"Bundle {bundle_id!r} not found on sidecar")
  219. if response.status_code >= 400:
  220. raise SlicerApiUnavailableError(
  221. f"Slicer sidecar /profiles/bundles/{bundle_id} returned {response.status_code}",
  222. )
  223. return _parse_bundle_summary(response.json())
  224. async def delete_bundle(self, bundle_id: str) -> None:
  225. """DELETE /profiles/bundles/<id> — remove a stored bundle."""
  226. try:
  227. response = await self._client.delete(
  228. f"{self.base_url}/profiles/bundles/{bundle_id}",
  229. timeout=10.0,
  230. )
  231. except httpx.RequestError as exc:
  232. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  233. if response.status_code == 404:
  234. raise BundleNotFoundError(f"Bundle {bundle_id!r} not found on sidecar")
  235. if response.status_code >= 400:
  236. raise SlicerApiUnavailableError(
  237. f"Slicer sidecar DELETE /profiles/bundles/{bundle_id} returned {response.status_code}",
  238. )
  239. async def _poll_progress(
  240. self,
  241. request_id: str,
  242. on_progress: Callable[[dict], None],
  243. ) -> None:
  244. """Poll the sidecar's progress endpoint at ~1Hz and forward each
  245. snapshot to ``on_progress``. Runs until cancelled.
  246. 4xx is NOT treated as terminal: the FIRST poll fires the moment
  247. the slice POST is sent, which can be milliseconds before the
  248. request actually lands on the sidecar and `progressStore.start()`
  249. runs — so a fresh request legitimately returns 404 for the first
  250. tick or two. Bailing on the first 404 (the original implementation)
  251. meant we'd quit before progress could ever arrive. The polling
  252. task is cancelled by the outer slice request anyway, so a
  253. sustained 404 (older sidecar without progress support, or post-
  254. slice grace expiry) just costs a few wasted GETs that the cancel
  255. will stop. Network errors and non-JSON 5xx are swallowed; the
  256. next tick retries.
  257. """
  258. url = f"{self.base_url}/slice/progress/{request_id}"
  259. while True:
  260. try:
  261. response = await self._client.get(url, timeout=5.0)
  262. if response.status_code == 200:
  263. payload = response.json()
  264. if isinstance(payload, dict):
  265. on_progress(payload)
  266. # 404 / other 4xx = no progress available (yet, or ever
  267. # for older sidecars). Keep polling — the outer slice
  268. # request will cancel this task on completion.
  269. except (httpx.RequestError, ValueError):
  270. # ValueError covers JSONDecodeError when the sidecar
  271. # returns a non-JSON 5xx. Don't crash the poller.
  272. pass
  273. try:
  274. await asyncio.sleep(1.0)
  275. except asyncio.CancelledError:
  276. return
  277. async def slice_with_profiles(
  278. self,
  279. *,
  280. model_bytes: bytes,
  281. model_filename: str,
  282. printer_profile_json: str,
  283. process_profile_json: str,
  284. filament_profile_jsons: list[str],
  285. plate: int | None = None,
  286. export_3mf: bool = False,
  287. arrange: bool = False,
  288. request_id: str | None = None,
  289. on_progress: Callable[[dict], None] | None = None,
  290. ) -> SliceResult:
  291. """POST /slice with model + printer/process/filament profiles.
  292. ``filament_profile_jsons`` is plate-slot-ordered: index 0 is the
  293. profile for slot 1, etc. Single-color callers pass a one-element
  294. list. Multiple ``filamentProfile`` parts are sent as a repeated form
  295. field — the sidecar's route declares ``maxCount: 16`` and the
  296. slicing service joins them as semicolon-separated
  297. ``--load-filaments`` for the OrcaSlicer / BambuStudio CLI.
  298. ``arrange`` forwards the sidecar's ``--arrange`` flag to BambuStudio.
  299. When True the slicer auto-repositions objects on the target bed,
  300. which Bambuddy uses for cross-nozzle-class re-slices (#1493) where
  301. the source's X1C-coordinate layout would otherwise drop into an H2D
  302. dead zone or trigger the multi-extruder geometry pipeline's polygon
  303. clipping crash. Default off so single-printer slices preserve the
  304. user's deliberate layout.
  305. ``request_id``: when supplied, the sidecar wires --pipe to a
  306. per-request FIFO and publishes structured JSON progress events to
  307. its in-memory ProgressStore under this id. Bambuddy's slice
  308. dispatch polls ``GET /slice/progress/{request_id}`` in parallel
  309. to drive the live-progress toast.
  310. Raises:
  311. SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
  312. SlicerApiUnavailableError: connection error or 5xx from sidecar.
  313. """
  314. # httpx supports repeated multipart fields when files is a list of
  315. # tuples — using the dict form would silently overwrite duplicate
  316. # keys and ship only the last filament profile.
  317. files: list[tuple[str, tuple[str, bytes, str]]] = [
  318. ("file", (model_filename, model_bytes, _guess_model_content_type(model_filename))),
  319. ("printerProfile", ("printer.json", printer_profile_json.encode("utf-8"), "application/json")),
  320. ("presetProfile", ("preset.json", process_profile_json.encode("utf-8"), "application/json")),
  321. ]
  322. for idx, fjson in enumerate(filament_profile_jsons):
  323. files.append(
  324. (
  325. "filamentProfile",
  326. (f"filament_{idx + 1}.json", fjson.encode("utf-8"), "application/json"),
  327. )
  328. )
  329. data: dict[str, str] = {}
  330. if plate is not None:
  331. data["plate"] = str(plate)
  332. if export_3mf:
  333. data["exportType"] = "3mf"
  334. if arrange:
  335. # Sidecar reads non-empty truthy strings as True; only send the
  336. # field when we want the flag on, so default-off callers exactly
  337. # match the previous wire payload.
  338. data["arrange"] = "true"
  339. if request_id is not None:
  340. data["requestId"] = request_id
  341. # When the caller supplied a request_id, kick off a parallel
  342. # poller that reads the sidecar's --pipe-fed progress endpoint
  343. # and surfaces structured updates via on_progress. Uses a
  344. # short-tick poll (1s) since the slicer emits stage changes
  345. # several times per minute on complex models.
  346. progress_task: asyncio.Task | None = None
  347. if request_id is not None and on_progress is not None:
  348. progress_task = asyncio.create_task(
  349. self._poll_progress(request_id, on_progress),
  350. name=f"slicer-progress-{request_id}",
  351. )
  352. try:
  353. response = await self._client.post(
  354. f"{self.base_url}/slice",
  355. files=files,
  356. data=data,
  357. timeout=self.timeout_seconds,
  358. )
  359. except httpx.RequestError as exc:
  360. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  361. finally:
  362. if progress_task is not None:
  363. progress_task.cancel()
  364. try:
  365. await progress_task
  366. except (asyncio.CancelledError, Exception):
  367. pass # Polling errors must not fail the slice.
  368. if response.status_code >= 500:
  369. raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
  370. if response.status_code >= 400:
  371. raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
  372. return SliceResult(
  373. content=response.content,
  374. print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
  375. filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
  376. filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
  377. )
  378. async def slice_with_bundle(
  379. self,
  380. *,
  381. model_bytes: bytes,
  382. model_filename: str,
  383. bundle_id: str,
  384. printer_name: str,
  385. process_name: str,
  386. filament_names: list[str],
  387. plate: int | None = None,
  388. export_3mf: bool = False,
  389. arrange: bool = False,
  390. bed_type: str | None = None,
  391. request_id: str | None = None,
  392. on_progress: Callable[[dict], None] | None = None,
  393. ) -> SliceResult:
  394. """POST /slice with bundle id + per-category preset names.
  395. Asks the sidecar to materialize the printer / process / filament
  396. JSONs from a previously-imported `.bbscfg`, instead of accepting
  397. them as multipart attachments. Equivalent to
  398. ``slice_with_profiles`` from the user's perspective — same return
  399. shape, same 4xx/5xx semantics, same progress-poll wiring — but
  400. the sidecar saves the round-trip of re-uploading the JSONs every
  401. time a user kicks off a slice with the same bundle.
  402. ``filament_names`` is plate-slot-ordered: index 0 is slot 1, etc.
  403. Single-color callers pass a one-element list. The sidecar joins
  404. them as semicolon-separated `--load-filaments` for the CLI.
  405. Raises:
  406. SlicerInputError: 4xx — bundle / preset name not found, etc.
  407. SlicerApiServerError: sidecar 5xx (CLI failure on resolved
  408. triplet — same conditions that fail slice_with_profiles).
  409. SlicerApiUnavailableError: connection error.
  410. """
  411. files = {
  412. "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
  413. }
  414. data: dict[str, str | list[str]] = {
  415. "bundle": bundle_id,
  416. "printerName": printer_name,
  417. "processName": process_name,
  418. }
  419. # The sidecar's SlicingSettings supports both `filamentName` (single
  420. # legacy field, kept for clients that pre-date multi-color) and
  421. # `filamentNames` (semicolon/comma-separated, matches multi-color
  422. # uploads). Always send the array form so a single-slot case still
  423. # ends up in the same code path on the sidecar.
  424. data["filamentNames"] = ";".join(filament_names)
  425. if plate is not None:
  426. data["plate"] = str(plate)
  427. if export_3mf:
  428. data["exportType"] = "3mf"
  429. if arrange:
  430. # See slice_with_profiles for the rationale: cross-class re-slices
  431. # (#1493) need --arrange so BS repositions objects for the target
  432. # bed instead of inheriting the source printer's coordinate layout.
  433. data["arrange"] = "true"
  434. if bed_type is not None:
  435. # #1337: bed-plate override flows through to the sidecar as a
  436. # standalone field. The sidecar wraps this as --curr_bed_type on
  437. # the CLI invocation, overriding whatever the bundle's process
  438. # JSON specifies. Bambuddy can't patch the bundle's JSON locally
  439. # (the sidecar materialises it from disk), so this round-trip is
  440. # the only path. Silently no-ops on sidecar versions that don't
  441. # yet recognise the field — the user's slice still runs with the
  442. # bundle's default plate, no crash.
  443. data["bedType"] = bed_type
  444. if request_id is not None:
  445. data["requestId"] = request_id
  446. progress_task: asyncio.Task | None = None
  447. if request_id is not None and on_progress is not None:
  448. progress_task = asyncio.create_task(
  449. self._poll_progress(request_id, on_progress),
  450. name=f"slicer-progress-{request_id}",
  451. )
  452. try:
  453. response = await self._client.post(
  454. f"{self.base_url}/slice",
  455. files=files,
  456. data=data,
  457. timeout=self.timeout_seconds,
  458. )
  459. except httpx.RequestError as exc:
  460. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  461. finally:
  462. if progress_task is not None:
  463. progress_task.cancel()
  464. try:
  465. await progress_task
  466. except (asyncio.CancelledError, Exception):
  467. pass
  468. if response.status_code >= 500:
  469. raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
  470. if response.status_code >= 400:
  471. raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
  472. return SliceResult(
  473. content=response.content,
  474. print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
  475. filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
  476. filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
  477. )
  478. async def slice_without_profiles(
  479. self,
  480. *,
  481. model_bytes: bytes,
  482. model_filename: str,
  483. plate: int | None = None,
  484. export_3mf: bool = False,
  485. request_id: str | None = None,
  486. on_progress: Callable[[dict], None] | None = None,
  487. ) -> SliceResult:
  488. """POST /slice with only the model file and no profile triplet.
  489. For 3MF inputs this lets the slicer fall back on the file's embedded
  490. `Metadata/project_settings.config`. Used as a fallback when
  491. `slice_with_profiles` triggers a CLI segfault or other 5xx —
  492. complex H2D / multi-extruder models hit upstream bugs in both the
  493. OrcaSlicer and BambuStudio CLIs when invoked via `--load-settings`.
  494. Also used by the SliceModal's per-plate filament discovery path:
  495. for an unsliced project file we run a real preview slice via the
  496. sidecar to find which AMS slots the picked plate consumes. The
  497. ``request_id`` parameter routes the sidecar's --pipe progress
  498. events to the ProgressStore so the modal's inline spinner +
  499. toast can show "Generating G-code (75%)" for that preview as
  500. well.
  501. """
  502. files = {
  503. "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
  504. }
  505. data: dict[str, str] = {}
  506. if plate is not None:
  507. data["plate"] = str(plate)
  508. if export_3mf:
  509. data["exportType"] = "3mf"
  510. if request_id is not None:
  511. data["requestId"] = request_id
  512. # Same progress-poller wiring as slice_with_profiles. Used by the
  513. # SliceModal's preview slice (for filament discovery) AND the
  514. # embedded-settings fallback path triggered by an Orca/Bambu CLI
  515. # segfault on complex H2D models — both want to keep updating
  516. # the user's toast through the slow operation.
  517. progress_task: asyncio.Task | None = None
  518. if request_id is not None and on_progress is not None:
  519. progress_task = asyncio.create_task(
  520. self._poll_progress(request_id, on_progress),
  521. name=f"slicer-progress-{request_id}",
  522. )
  523. try:
  524. response = await self._client.post(
  525. f"{self.base_url}/slice",
  526. files=files,
  527. data=data,
  528. timeout=self.timeout_seconds,
  529. )
  530. except httpx.RequestError as exc:
  531. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  532. finally:
  533. if progress_task is not None:
  534. progress_task.cancel()
  535. try:
  536. await progress_task
  537. except (asyncio.CancelledError, Exception):
  538. pass
  539. if response.status_code >= 500:
  540. raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
  541. if response.status_code >= 400:
  542. raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
  543. return SliceResult(
  544. content=response.content,
  545. print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
  546. filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
  547. filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
  548. )
  549. def _safe_int(value: str | None) -> int:
  550. if not value:
  551. return 0
  552. try:
  553. return int(float(value))
  554. except (TypeError, ValueError):
  555. return 0
  556. def _safe_float(value: str | None) -> float:
  557. if not value:
  558. return 0.0
  559. try:
  560. return float(value)
  561. except (TypeError, ValueError):
  562. return 0.0