slicer.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  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. * File types a slicer can be handed — both by the desktop URI handler and by
  42. * the in-app sidecar. Source geometry only: a sliced file is an output, and
  43. * neither slicer has anything to do with one.
  44. *
  45. * Lives here rather than beside either caller because both the File Manager
  46. * (which has a filename) and the 3D preview (which has a `LibraryFile.file_type`)
  47. * decide the same thing about the same file. They used to hold separate lists,
  48. * and the two disagreed — a card menu offered a desktop handoff for an STL
  49. * whose own 3D preview showed "Open in Slicer" greyed out.
  50. */
  51. export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
  52. /**
  53. * The subset the *sidecar* can slice.
  54. *
  55. * The desktop slicers open a STEP happily; their command-line interfaces do
  56. * not. OrcaSlicer 2.4.2 and Bambu Studio 02.07.01.62 both answer one with
  57. * "Unknown file format. Input file must have .stl, .obj, .amf(.xml) extension."
  58. * So a STEP still gets an "Open in Slicer" handoff, and no longer gets a
  59. * "Slice" button that could only ever fail.
  60. */
  61. export const API_SLICEABLE_FILE_TYPES = ['3mf', 'stl'] as const;
  62. /**
  63. * Does a `LibraryFile.file_type` name a sliceable source file?
  64. *
  65. * The backend stores compound extensions whole — a sliced 3MF classifies as
  66. * `gcode.3mf`, not `3mf` (`classify_file_type` in `api/routes/library.py`) — so
  67. * membership alone is enough to exclude sliced output here.
  68. */
  69. export function isSliceableFileType(fileType?: string | null): boolean {
  70. const normalized = (fileType || '').toLowerCase();
  71. return (SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
  72. }
  73. /**
  74. * Does a filename name a sliceable source file?
  75. *
  76. * Checked against the name rather than a stored type, so the compound
  77. * extensions have to be ruled out explicitly: `.gcode.3mf` ends with `.3mf`.
  78. */
  79. export function isSliceableFilename(filename: string): boolean {
  80. const lower = filename.toLowerCase();
  81. if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
  82. return SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
  83. }
  84. /**
  85. * Does a filename name something the slicer *sidecar* can slice?
  86. *
  87. * Narrower than `isSliceableFilename` by exactly STEP — see
  88. * `API_SLICEABLE_FILE_TYPES`. Use this wherever the action posts to
  89. * `/library/files/{id}/slice`; use the wider one for the desktop handoff.
  90. */
  91. export function isApiSliceableFilename(filename: string): boolean {
  92. const lower = filename.toLowerCase();
  93. if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
  94. return API_SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
  95. }
  96. /**
  97. * Detect the user's operating system
  98. */
  99. export function detectPlatform(): Platform {
  100. const userAgent = navigator.userAgent.toLowerCase();
  101. const platform = navigator.platform?.toLowerCase() || '';
  102. if (userAgent.includes('win') || platform.includes('win')) {
  103. return 'windows';
  104. }
  105. if (userAgent.includes('mac') || platform.includes('mac')) {
  106. return 'macos';
  107. }
  108. if (userAgent.includes('linux') || platform.includes('linux')) {
  109. return 'linux';
  110. }
  111. return 'unknown';
  112. }
  113. /**
  114. * Open a URL in the specified slicer application.
  115. * @param downloadUrl - The URL to the file to open
  116. * @param slicer - Which slicer to use (defaults to bambu_studio)
  117. */
  118. export function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_studio'): void {
  119. let url: string;
  120. const encoded = encodeURIComponent(downloadUrl);
  121. if (slicer === 'orcaslicer') {
  122. url = `orcaslicer://open?file=${encoded}`;
  123. } else {
  124. const platform = detectPlatform();
  125. if (platform === 'macos') {
  126. // macOS only: bambustudioopen scheme via MacOpenURL() callback.
  127. url = `bambustudioopen://${encoded}`;
  128. } else {
  129. // Windows/Linux: bambustudio://open?file= via post_init() CLI args.
  130. // IMPORTANT: On Linux, BS only handles "bambustudio://open" prefix —
  131. // it does NOT process "bambustudioopen://" (that's macOS-only).
  132. url = `bambustudio://open?file=${encoded}`;
  133. }
  134. }
  135. // Use a temporary <a> element to trigger the protocol handler.
  136. // This avoids navigating away from the page (unlike window.location.href).
  137. const link = document.createElement('a');
  138. link.href = url;
  139. link.style.display = 'none';
  140. document.body.appendChild(link);
  141. link.click();
  142. document.body.removeChild(link);
  143. }
  144. /**
  145. * Build a full download URL for a file
  146. * @param path - The API path (e.g., from api.getArchiveForSlicer())
  147. */
  148. export function buildDownloadUrl(path: string): string {
  149. return `${window.location.origin}${path}`;
  150. }
  151. /**
  152. * Convenience function to open an archive in the slicer
  153. * @param path - The API path to the archive
  154. * @param slicer - Which slicer to use (defaults to bambu_studio)
  155. */
  156. export function openArchiveInSlicer(path: string, slicer: SlicerType = 'bambu_studio'): void {
  157. const downloadUrl = buildDownloadUrl(path);
  158. openInSlicer(downloadUrl, slicer);
  159. }
  160. /**
  161. * Does a `LibraryFile.file_type` name something the sidecar can slice?
  162. *
  163. * The `isSliceableFileType` counterpart, narrowed to the sidecar's formats.
  164. */
  165. export function isApiSliceableFileType(fileType?: string | null): boolean {
  166. const normalized = (fileType || '').toLowerCase();
  167. return (API_SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
  168. }