slicer.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. /**
  2. * Utility for opening files in slicer applications
  3. *
  4. * Protocol handler URL formats (from BambuStudio/OrcaSlicer source code):
  5. *
  6. * Bambu Studio has TWO separate URL handlers:
  7. * 1. post_init() [Windows/Linux CLI args]: bambustudio://open?file=<URL>
  8. * - Checks: starts_with("bambustudio://open")
  9. * - Calls url_decode(), then split_str(url, "file=")
  10. * 2. MacOpenURL() [macOS Apple Events]: bambustudioopen://<encoded-URL>
  11. * - Checks: starts_with("bambustudioopen://")
  12. * - Strips prefix, then url_decode()
  13. *
  14. * OrcaSlicer Downloader accepts both formats via regex:
  15. * - (orcaslicer|bambustudio|...)://open?file=<URL>
  16. * - bambustudioopen://<URL>
  17. *
  18. * Key insight: every form needs encodeURIComponent on the file URL, because
  19. * the slicer calls url_decode() on the received query (post_init calls
  20. * url_decode then split_str; MacOpenURL strips the prefix then url_decode;
  21. * OrcaSlicer's Downloader regex-extracts then url_decode). Without encoding,
  22. * any already-percent-encoded character in the download URL (most commonly
  23. * %20 in filenames with spaces) decodes to a literal space and the slicer's
  24. * subsequent HTTP fetch fails with a 0-byte body or 404. See issue #1059.
  25. */
  26. export type SlicerType = 'bambu_studio' | 'orcaslicer';
  27. type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
  28. /**
  29. * Resolve the desktop "Open in Slicer" target. Prefers an explicit
  30. * `open_in_slicer` override (#1329), then falls back to the API slicer's
  31. * `preferred_slicer`, then Bambu Studio. This is ONLY the URI-handoff target;
  32. * the in-app SliceModal keeps using `preferred_slicer` for the sidecar.
  33. */
  34. export function resolveDesktopSlicer(
  35. openInSlicer?: SlicerType | null,
  36. preferredSlicer?: SlicerType,
  37. ): SlicerType {
  38. return openInSlicer ?? preferredSlicer ?? 'bambu_studio';
  39. }
  40. /**
  41. * What each desktop slicer's protocol handler will actually load.
  42. *
  43. * These are not the formats the two applications can open — both take an STL
  44. * from File > Import perfectly well. They are what survives the *URL handoff*,
  45. * which is a narrower thing, and the two slicers disagree about it (#3029).
  46. *
  47. * Bambu Studio routes every `bambustudio://open?file=` and `bambustudioopen://`
  48. * URL into `Plater::import_model_id`, which refuses any filename that is not
  49. * `.3mf` before it makes the HTTP request at all — "Download failed, unknown
  50. * file format." Handing it an STL could only ever fail, and the message names
  51. * the format rather than the handoff, so the failure reads as a broken file.
  52. *
  53. * OrcaSlicer sends only MakerWorld links and `bambustudioopen://` down that
  54. * same 3MF-only path (`Downloader::start_download`). Everything else — which is
  55. * what we emit for it, `orcaslicer://open?file=` against our own host — goes to
  56. * its generic downloader, which has no extension check at all. So STL and STEP
  57. * work there.
  58. *
  59. * Source geometry only either way: a sliced file is an output, and neither
  60. * slicer has anything to do with one.
  61. *
  62. * This lives here rather than beside any caller because the File Manager (which
  63. * has a filename) and the 3D preview (which has a `LibraryFile.file_type`)
  64. * decide the same thing about the same file. They used to hold separate lists,
  65. * and the two disagreed — a card menu offered a desktop handoff for an STL
  66. * whose own 3D preview showed "Open in Slicer" greyed out.
  67. */
  68. export const DESKTOP_SLICEABLE_FILE_TYPES: Record<SlicerType, readonly string[]> = {
  69. bambu_studio: ['3mf'],
  70. orcaslicer: ['3mf', 'stl', 'step', 'stp'],
  71. };
  72. /**
  73. * The formats `slicer` accepts over the handoff.
  74. *
  75. * Unknown values fall back to Bambu Studio's list rather than throwing, because
  76. * that is what the handoff itself does: `openInSlicer` treats anything that is
  77. * not exactly `orcaslicer` as Bambu Studio. `settings.open_in_slicer` comes off
  78. * the API unvalidated, so the two have to agree on that.
  79. */
  80. function desktopFormats(slicer: SlicerType): readonly string[] {
  81. return DESKTOP_SLICEABLE_FILE_TYPES[slicer] ?? DESKTOP_SLICEABLE_FILE_TYPES.bambu_studio;
  82. }
  83. /**
  84. * The subset the *sidecar* can slice.
  85. *
  86. * The desktop slicers open a STEP happily; their command-line interfaces do
  87. * not. OrcaSlicer 2.4.2 and Bambu Studio 02.07.01.62 both answer one with
  88. * "Unknown file format. Input file must have .stl, .obj, .amf(.xml) extension."
  89. * So a STEP still gets an "Open in Slicer" handoff, and no longer gets a
  90. * "Slice" button that could only ever fail.
  91. */
  92. export const API_SLICEABLE_FILE_TYPES = ['3mf', 'stl'] as const;
  93. /**
  94. * Can `slicer` be handed this `LibraryFile.file_type` over the protocol handler?
  95. *
  96. * The backend stores compound extensions whole — a sliced 3MF classifies as
  97. * `gcode.3mf`, not `3mf` (`classify_file_type` in `api/routes/library.py`) — so
  98. * membership alone is enough to exclude sliced output here.
  99. *
  100. * The slicer is a required argument on purpose: the answer genuinely differs
  101. * between the two, and a default would quietly reintroduce the STL handoff that
  102. * Bambu Studio cannot honour.
  103. */
  104. export function isSliceableFileType(fileType: string | null | undefined, slicer: SlicerType): boolean {
  105. const normalized = (fileType || '').toLowerCase();
  106. return desktopFormats(slicer).includes(normalized);
  107. }
  108. /**
  109. * Can `slicer` be handed this filename over the protocol handler?
  110. *
  111. * Checked against the name rather than a stored type, so the compound
  112. * extensions have to be ruled out explicitly: `.gcode.3mf` ends with `.3mf`.
  113. */
  114. export function isSliceableFilename(filename: string, slicer: SlicerType): boolean {
  115. const lower = filename.toLowerCase();
  116. if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
  117. return desktopFormats(slicer).some((ext) => lower.endsWith(`.${ext}`));
  118. }
  119. /**
  120. * Does a filename name something the slicer *sidecar* can slice?
  121. *
  122. * Narrower than OrcaSlicer's handoff list by exactly STEP — see
  123. * `API_SLICEABLE_FILE_TYPES`. Use this wherever the action posts to
  124. * `/library/files/{id}/slice`; use `isSliceableFilename` for the desktop
  125. * handoff, which needs to know which slicer it is handing to.
  126. */
  127. export function isApiSliceableFilename(filename: string): boolean {
  128. const lower = filename.toLowerCase();
  129. if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
  130. return API_SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
  131. }
  132. /**
  133. * Detect the user's operating system
  134. */
  135. export function detectPlatform(): Platform {
  136. const userAgent = navigator.userAgent.toLowerCase();
  137. const platform = navigator.platform?.toLowerCase() || '';
  138. if (userAgent.includes('win') || platform.includes('win')) {
  139. return 'windows';
  140. }
  141. if (userAgent.includes('mac') || platform.includes('mac')) {
  142. return 'macos';
  143. }
  144. if (userAgent.includes('linux') || platform.includes('linux')) {
  145. return 'linux';
  146. }
  147. return 'unknown';
  148. }
  149. /**
  150. * Open a URL in the specified slicer application.
  151. * @param downloadUrl - The URL to the file to open
  152. * @param slicer - Which slicer to use (defaults to bambu_studio)
  153. */
  154. export function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_studio'): void {
  155. let url: string;
  156. const encoded = encodeURIComponent(downloadUrl);
  157. if (slicer === 'orcaslicer') {
  158. url = `orcaslicer://open?file=${encoded}`;
  159. } else {
  160. const platform = detectPlatform();
  161. if (platform === 'macos') {
  162. // macOS only: bambustudioopen scheme via MacOpenURL() callback.
  163. url = `bambustudioopen://${encoded}`;
  164. } else {
  165. // Windows/Linux: bambustudio://open?file= via post_init() CLI args.
  166. // IMPORTANT: On Linux, BS only handles "bambustudio://open" prefix —
  167. // it does NOT process "bambustudioopen://" (that's macOS-only).
  168. url = `bambustudio://open?file=${encoded}`;
  169. }
  170. }
  171. // Use a temporary <a> element to trigger the protocol handler.
  172. // This avoids navigating away from the page (unlike window.location.href).
  173. const link = document.createElement('a');
  174. link.href = url;
  175. link.style.display = 'none';
  176. document.body.appendChild(link);
  177. link.click();
  178. document.body.removeChild(link);
  179. }
  180. /**
  181. * Build a full download URL for a file
  182. * @param path - The API path (e.g., from api.getArchiveForSlicer())
  183. */
  184. export function buildDownloadUrl(path: string): string {
  185. return `${window.location.origin}${path}`;
  186. }
  187. /**
  188. * Convenience function to open an archive in the slicer
  189. * @param path - The API path to the archive
  190. * @param slicer - Which slicer to use (defaults to bambu_studio)
  191. */
  192. export function openArchiveInSlicer(path: string, slicer: SlicerType = 'bambu_studio'): void {
  193. const downloadUrl = buildDownloadUrl(path);
  194. openInSlicer(downloadUrl, slicer);
  195. }
  196. /**
  197. * Does a `LibraryFile.file_type` name something the sidecar can slice?
  198. *
  199. * The `isSliceableFileType` counterpart, narrowed to the sidecar's formats.
  200. */
  201. export function isApiSliceableFileType(fileType?: string | null): boolean {
  202. const normalized = (fileType || '').toLowerCase();
  203. return (API_SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
  204. }