lithop 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/Cargo.lock +284 -0
  4. data/Cargo.toml +6 -0
  5. data/README.md +31 -7
  6. data/RELEASING.md +24 -6
  7. data/docs/README.md +4 -1
  8. data/docs/artifacts.md +26 -2
  9. data/docs/bambu-studio.md +7 -1
  10. data/docs/color-model.md +2 -0
  11. data/docs/command.md +48 -7
  12. data/docs/config.md +31 -4
  13. data/docs/getting-started.md +10 -5
  14. data/docs/images/cmyw-lithophane-preview.png +0 -0
  15. data/docs/local-ui.md +114 -0
  16. data/docs/tiling.md +134 -0
  17. data/docs/troubleshooting.md +22 -0
  18. data/ext/lithop_native/Cargo.toml +17 -0
  19. data/ext/lithop_native/build.rs +4 -0
  20. data/ext/lithop_native/extconf.rb +6 -0
  21. data/ext/lithop_native/src/column_mesh.rs +388 -0
  22. data/ext/lithop_native/src/lib.rs +37 -0
  23. data/ext/lithop_native/src/mesh.rs +187 -0
  24. data/ext/lithop_native/src/palette.rs +67 -0
  25. data/ext/lithop_native/src/xml.rs +134 -0
  26. data/lib/lithop/calibration/export.rb +21 -8
  27. data/lib/lithop/cli/calibration.rb +3 -1
  28. data/lib/lithop/cli/image_options.rb +41 -2
  29. data/lib/lithop/cli/options.rb +13 -0
  30. data/lib/lithop/cli.rb +36 -1
  31. data/lib/lithop/export/color_metadata.rb +55 -0
  32. data/lib/lithop/export/generation.rb +131 -21
  33. data/lib/lithop/export/job.rb +30 -16
  34. data/lib/lithop/export/metadata.rb +54 -4
  35. data/lib/lithop/export/planning.rb +97 -0
  36. data/lib/lithop/export/previews.rb +25 -1
  37. data/lib/lithop/export/print_readme.rb +22 -4
  38. data/lib/lithop/export/result.rb +26 -4
  39. data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
  40. data/lib/lithop/formats/three_mf/mesh.rb +72 -3
  41. data/lib/lithop/formats/three_mf/model_writer.rb +249 -24
  42. data/lib/lithop/formats/three_mf/part_writer.rb +51 -6
  43. data/lib/lithop/formats/three_mf.rb +9 -6
  44. data/lib/lithop/image/pipeline.rb +66 -10
  45. data/lib/lithop/image/svg_loader.rb +5 -5
  46. data/lib/lithop/job_directory.rb +41 -0
  47. data/lib/lithop/job_execution.rb +378 -0
  48. data/lib/lithop/layer_structure.rb +11 -0
  49. data/lib/lithop/mesh/box_geometry.rb +41 -0
  50. data/lib/lithop/mesh/column_geometry.rb +10 -2
  51. data/lib/lithop/mesh/column_topology.rb +2 -0
  52. data/lib/lithop/mesh/geometry.rb +21 -13
  53. data/lib/lithop/model/layer_region_model.rb +24 -0
  54. data/lib/lithop/model/material_model.rb +17 -5
  55. data/lib/lithop/model/palette.rb +21 -3
  56. data/lib/lithop/native.rb +39 -0
  57. data/lib/lithop/reference_three_mf.rb +17 -1
  58. data/lib/lithop/settings.rb +116 -1
  59. data/lib/lithop/settings_file.rb +10 -4
  60. data/lib/lithop/tiling.rb +127 -0
  61. data/lib/lithop/version.rb +1 -1
  62. data/lib/lithop/web/app.rb +513 -0
  63. data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
  64. data/lib/lithop/web/job_launcher.rb +214 -0
  65. data/lib/lithop/web/job_store.rb +390 -0
  66. data/lib/lithop/web/preview_store.rb +182 -0
  67. data/lib/lithop/web/public/app.css +319 -0
  68. data/lib/lithop/web/public/app.js +1847 -0
  69. data/lib/lithop/web/server.rb +21 -0
  70. data/lib/lithop/web/settings_form.rb +64 -0
  71. data/lib/lithop/web/settings_library.rb +154 -0
  72. data/lib/lithop/web/views/index.erb +364 -0
  73. data/lib/lithop/web/worker.rb +27 -0
  74. data/lib/lithop.rb +5 -0
  75. data/sig/lithop.rbs +102 -2
  76. metadata +89 -2
@@ -0,0 +1,513 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "rack/utils"
7
+ require "securerandom"
8
+ require "sinatra/base"
9
+ require_relative "settings_form"
10
+ require_relative "job_launcher"
11
+ require_relative "job_store"
12
+ require_relative "preview_store"
13
+ require_relative "settings_library"
14
+ require_relative "bambu_studio_launcher"
15
+
16
+ module Lithop
17
+ module Web
18
+ # Local-only browser interface for conversion settings, progress, and artifacts.
19
+ # rubocop:disable-next Metrics/ClassLength
20
+ class App < Sinatra::Base
21
+ ROOT = File.expand_path(__dir__)
22
+ MAX_REQUEST_BYTES = 100 * 1024 * 1024
23
+
24
+ # Streams an archive and removes the temporary file when Rack closes it.
25
+ class TemporaryFileBody
26
+ def initialize(path)
27
+ @path = path
28
+ end
29
+
30
+ def each
31
+ File.open(@path, "rb") do |file|
32
+ yield file.read(64 * 1024) until file.eof?
33
+ end
34
+ ensure
35
+ close
36
+ end
37
+
38
+ def close
39
+ FileUtils.rm_f(@path) if @path
40
+ @path = nil
41
+ end
42
+ end
43
+
44
+ set :environment, :production
45
+ set :logging, false
46
+ set :show_exceptions, false
47
+ set :raise_errors, false
48
+ set :static, true
49
+ set :public_folder, File.join(ROOT, "public")
50
+ set :views, File.join(ROOT, "views")
51
+
52
+ def self.configured(
53
+ project_dir:, launcher: nil, preview_store: nil, bambu_studio_launcher: nil,
54
+ csrf_token: SecureRandom.hex(32)
55
+ )
56
+ Class.new(self).tap do |application|
57
+ expanded = File.expand_path(project_dir)
58
+ application.set :project_dir, expanded
59
+ application.set :launcher, launcher || JobLauncher.new(project_dir: expanded)
60
+ application.set :job_store, JobStore.new(project_dir: expanded)
61
+ application.set :preview_store, preview_store || PreviewStore.new(project_dir: expanded)
62
+ application.set :settings_library, SettingsLibrary.new(project_dir: expanded)
63
+ application.set :bambu_studio_launcher, bambu_studio_launcher || BambuStudioLauncher.new
64
+ application.set :csrf_token, csrf_token
65
+ end
66
+ end
67
+
68
+ before do
69
+ headers(
70
+ "Cache-Control" => "no-store",
71
+ "Content-Security-Policy" => "default-src 'self'; img-src 'self' blob: data:; " \
72
+ "style-src 'self'; style-src-attr 'unsafe-inline'; script-src 'self'; " \
73
+ "object-src 'none'; base-uri 'none'; " \
74
+ "form-action 'self'; frame-ancestors 'none'",
75
+ "Referrer-Policy" => "no-referrer",
76
+ "X-Content-Type-Options" => "nosniff",
77
+ "X-Frame-Options" => "DENY"
78
+ )
79
+ verify_request_size!
80
+ verify_csrf! if request.post?
81
+ end
82
+
83
+ get "/" do
84
+ prepare_page
85
+ erb :index
86
+ end
87
+
88
+ post "/jobs" do
89
+ form = SettingsForm.new(project_dir: settings.project_dir)
90
+ resolved = form.resolve(params)
91
+ job_id = start_job(resolved)
92
+ if json_request?
93
+ status 202
94
+ content_type :json
95
+ JSON.generate(job_id:, url: "/?job=#{Rack::Utils.escape_path(job_id)}")
96
+ else
97
+ redirect "/?job=#{job_id}", 303
98
+ end
99
+ rescue Error, SystemCallError => e
100
+ discard_submitted_preview
101
+ status 422
102
+ @error = e.message
103
+ prepare_page(submitted: params)
104
+ erb :index
105
+ end
106
+
107
+ post "/api/previews" do
108
+ upload = params["image"]
109
+ raise InputError, "select a JPEG, PNG, or SVG image" unless valid_upload?(upload)
110
+
111
+ preview_id = settings.preview_store.stage(
112
+ upload: upload.fetch(:tempfile), filename: upload.fetch(:filename)
113
+ )
114
+ content_type :json
115
+ JSON.generate(id: preview_id)
116
+ rescue Error, SystemCallError => e
117
+ halt 422, json_error(e.message)
118
+ end
119
+
120
+ post "/api/previews/:id/render" do
121
+ resolved = SettingsForm.new(project_dir: settings.project_dir).resolve(params)
122
+ cache_key = Digest::SHA256.hexdigest(JSON.generate(resolved.to_h))
123
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
+ png, cache_hit = settings.preview_store.render(params.fetch("id"), cache_key) do |path, _filename|
125
+ ImagePipeline.new(resolved).preview_png(path)
126
+ end
127
+ elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
128
+ headers "Server-Timing" => %(preview;dur=#{elapsed.round(3)}),
129
+ "X-Lithop-Preview-Cache" => (cache_hit ? "hit" : "miss")
130
+ content_type "image/png"
131
+ body png
132
+ rescue Error, SystemCallError => e
133
+ halt 422, json_error(e.message)
134
+ end
135
+
136
+ post "/api/previews/:id/discard" do
137
+ discarded = settings.preview_store.discard(params.fetch("id"))
138
+ content_type :json
139
+ JSON.generate(ok: true, discarded:)
140
+ rescue Error => e
141
+ halt 422, json_error(e.message)
142
+ end
143
+
144
+ post "/api/tile-layout" do
145
+ resolved = SettingsForm.new(project_dir: settings.project_dir).resolve(params)
146
+ content_type "image/svg+xml"
147
+ body TileLayoutSVG.document(settings: resolved, layout: TileLayout.new(resolved), background: false)
148
+ rescue Error, SystemCallError => e
149
+ halt 422, json_error(e.message)
150
+ end
151
+
152
+ post "/api/settings-summary" do
153
+ resolved = SettingsForm.new(project_dir: settings.project_dir).resolve(params)
154
+ layout = TileLayout.new(resolved)
155
+ content_type :json
156
+ JSON.generate(
157
+ tile_count: resolved.tile_count, tile_rows: layout.rows, tile_columns: layout.columns,
158
+ z_plate_count: resolved.separate_z_parts ? resolved.color_region_parts + 1 : 1
159
+ )
160
+ rescue Error, SystemCallError => e
161
+ halt 422, json_error(e.message)
162
+ end
163
+
164
+ get "/api/settings-library" do
165
+ content_type :json
166
+ JSON.generate(settings.settings_library.read)
167
+ rescue Error, SystemCallError => e
168
+ halt 422, json_error(e.message)
169
+ end
170
+
171
+ post "/api/settings-library/presets" do
172
+ content_type :json
173
+ JSON.generate(settings.settings_library.save_preset(params["name"], params))
174
+ rescue Error, SystemCallError => e
175
+ halt 422, json_error(e.message)
176
+ end
177
+
178
+ post "/api/settings-library/presets/rename" do
179
+ content_type :json
180
+ JSON.generate(settings.settings_library.rename_preset(params["name"], params["new_name"]))
181
+ rescue Error, SystemCallError => e
182
+ halt 422, json_error(e.message)
183
+ end
184
+
185
+ post "/api/settings-library/presets/delete" do
186
+ content_type :json
187
+ JSON.generate(settings.settings_library.delete_preset(params["name"]))
188
+ rescue Error, SystemCallError => e
189
+ halt 422, json_error(e.message)
190
+ end
191
+
192
+ post "/api/settings-library/draft" do
193
+ content_type :json
194
+ JSON.generate(settings.settings_library.save_draft(params))
195
+ rescue Error, SystemCallError => e
196
+ halt 422, json_error(e.message)
197
+ end
198
+
199
+ post "/api/settings-library/draft/delete" do
200
+ content_type :json
201
+ JSON.generate(settings.settings_library.delete_draft)
202
+ rescue Error, SystemCallError => e
203
+ halt 422, json_error(e.message)
204
+ end
205
+
206
+ get "/api/jobs/:id" do
207
+ record = settings.job_store.fetch(params.fetch("id"))
208
+ halt 404, json_error("job not found") unless record
209
+
210
+ content_type :json
211
+ JSON.generate(present_job(record))
212
+ end
213
+
214
+ get "/api/jobs" do
215
+ limit = Integer(params.fetch("limit", 12).to_s, 10).clamp(1, 50)
216
+ offset = Integer(params.fetch("offset", 0).to_s, 10)
217
+ halt 422, json_error("offset must be zero or greater") if offset.negative?
218
+ content_type :json
219
+ filters = {
220
+ query: params["q"], state: params.fetch("state", "all"), date_from: params["date_from"],
221
+ date_to: params["date_to"], sort: params.fetch("sort", "created_desc")
222
+ }
223
+ result = settings.job_store.page(limit:, offset:, filters:)
224
+ result["jobs"] = result.fetch("jobs").map { |record| present_job(record) }
225
+ JSON.generate(result)
226
+ rescue ArgumentError
227
+ halt 422, json_error("limit and offset must be integers")
228
+ end
229
+
230
+ get "/api/jobs/:id/archive-summary" do
231
+ summary = settings.job_store.archive_summary(params.fetch("id"), scope: params.fetch("scope", "all"))
232
+ job_id = Rack::Utils.escape_path(params.fetch("id"))
233
+ scope = Rack::Utils.escape(params.fetch("scope", "all"))
234
+ summary["download_url"] = "/jobs/#{job_id}/archive?scope=#{scope}"
235
+ content_type :json
236
+ JSON.generate(summary)
237
+ rescue Error, SystemCallError => e
238
+ halt 422, json_error(e.message)
239
+ end
240
+
241
+ get "/jobs/:id/archive" do
242
+ path = nil
243
+ job_id = params.fetch("id")
244
+ scope = params.fetch("scope", "all")
245
+ path = settings.job_store.create_archive(job_id, scope:)
246
+ content_type "application/zip"
247
+ attachment "#{job_id}-#{scope}.zip"
248
+ headers "Content-Length" => File.size(path).to_s
249
+ body TemporaryFileBody.new(path)
250
+ rescue Error, SystemCallError, Zip::Error => e
251
+ FileUtils.rm_f(path) if path
252
+ halt 422, e.message
253
+ end
254
+
255
+ post "/api/jobs/delete" do
256
+ job_ids = Array(params["job_ids"]).uniq
257
+ halt 422, json_error("select at least one job") if job_ids.empty?
258
+ deleted = []
259
+ errors = []
260
+ job_ids.each do |job_id|
261
+ if launcher_active?(job_id)
262
+ errors << { job_id:, error: "job process is still exiting" }
263
+ next
264
+ end
265
+ if bambu_studio_active?(job_id)
266
+ errors << { job_id:, error: "Bambu Studio launch is still in progress" }
267
+ next
268
+ end
269
+ removed = settings.job_store.delete(job_id)
270
+ deleted << { job_id: }.merge(removed)
271
+ rescue Error => e
272
+ errors << { job_id:, error: e.message }
273
+ end
274
+ content_type :json
275
+ JSON.generate(ok: errors.empty?, deleted:, errors:)
276
+ end
277
+
278
+ post "/jobs/:id/cancel" do
279
+ job_id = params.fetch("id")
280
+ record = settings.job_store.fetch(job_id)
281
+ halt 404, json_error("job not found") unless record
282
+ halt 422, json_error("job is not active") unless JobExecution::ACTIVE_STATES.include?(record["state"])
283
+
284
+ settings.launcher.cancel(job_id)
285
+ content_type :json
286
+ JSON.generate(ok: true, job_id:)
287
+ rescue Error => e
288
+ halt 422, json_error(e.message)
289
+ end
290
+
291
+ post "/jobs/:id/delete" do
292
+ job_id = params.fetch("id")
293
+ halt 422, json_error("job process is still exiting") if launcher_active?(job_id)
294
+ halt 409, json_error("Bambu Studio launch is still in progress") if bambu_studio_active?(job_id)
295
+ removed = settings.job_store.delete(job_id)
296
+ content_type :json
297
+ JSON.generate({ ok: true, job_id: }.merge(removed))
298
+ rescue Error => e
299
+ halt 422, json_error(e.message)
300
+ end
301
+
302
+ post "/api/jobs/:id/open-in-bambu-studio" do
303
+ job_id = params.fetch("id")
304
+ name = params["name"].to_s
305
+ halt 422, json_error("select a valid 3MF artifact") unless valid_3mf_artifact_name?(name)
306
+
307
+ record = settings.job_store.fetch(job_id)
308
+ halt 404, json_error("job not found") unless record
309
+ halt 409, json_error("job is not complete") unless record["state"] == "completed"
310
+
311
+ path = settings.job_store.artifact(job_id, name)
312
+ settings.bambu_studio_launcher.open(job_id:, path:)
313
+ content_type :json
314
+ JSON.generate(status: "launch_requested", job_id:, name:)
315
+ rescue BambuStudioLauncher::BusyError => e
316
+ halt 409, json_error(e.message)
317
+ rescue BambuStudioLauncher::UnsupportedPlatformError, BambuStudioLauncher::InvalidArtifactError => e
318
+ halt 422, json_error(e.message)
319
+ rescue BambuStudioLauncher::UnavailableError => e
320
+ halt 503, json_error(e.message)
321
+ rescue BambuStudioLauncher::LaunchTimeoutError => e
322
+ halt 504, json_error(e.message)
323
+ rescue Error => e
324
+ halt 404, json_error(e.message)
325
+ end
326
+
327
+ get "/jobs/:id/files/*" do
328
+ path = settings.job_store.artifact(params.fetch("id"), params.fetch("splat").first)
329
+ send_file(path, disposition: inline_artifact?(path) ? "inline" : "attachment")
330
+ rescue Error => e
331
+ halt 404, e.message
332
+ end
333
+
334
+ get "/jobs/:id/log" do
335
+ send_file(settings.job_store.log(params.fetch("id")), type: "text/plain", disposition: "inline")
336
+ rescue Error => e
337
+ halt 404, e.message
338
+ end
339
+
340
+ not_found { "Not found" }
341
+
342
+ error do
343
+ content_type :json
344
+ status 500
345
+ json_error("local UI request failed")
346
+ end
347
+
348
+ helpers do
349
+ def h(value) = Rack::Utils.escape_html(value.to_s)
350
+
351
+ def checked?(key)
352
+ @values.fetch(key.to_s, false) == true || @values.fetch(key.to_s, nil) == "1"
353
+ end
354
+
355
+ def selected?(key, value) = @values[key.to_s].to_s == value.to_s
356
+ end
357
+
358
+ private
359
+
360
+ def prepare_page(submitted: nil)
361
+ form = SettingsForm.new(project_dir: settings.project_dir)
362
+ @values = form.values
363
+ if submitted
364
+ SettingsForm::FIELD_NAMES.each { |key| @values[key] = submitted[key] if submitted.key?(key) }
365
+ @values["separate_z_parts"] = submitted["separate_z_parts"] == "1"
366
+ end
367
+ @jobs = settings.job_store.recent.map do |job|
368
+ job.merge(settings.job_store.summary(job.fetch("job_id")))
369
+ end
370
+ prepare_navigation
371
+ @csrf_token = settings.csrf_token
372
+ rescue Error => e
373
+ @error ||= e.message
374
+ @values ||= Settings::DEFAULTS.transform_keys(&:to_s)
375
+ @jobs ||= []
376
+ @initial_view ||= "create"
377
+ @project_name ||= File.basename(settings.project_dir)
378
+ @csrf_token = settings.csrf_token
379
+ end
380
+
381
+ def prepare_navigation
382
+ @selected_job_id = params["job"]
383
+ requested_view = params["view"].to_s
384
+ @initial_view = if %w[create jobs presets].include?(requested_view)
385
+ requested_view
386
+ elsif @selected_job_id
387
+ "jobs"
388
+ else
389
+ "create"
390
+ end
391
+ @project_name = File.basename(settings.project_dir)
392
+ end
393
+
394
+ def valid_upload?(upload)
395
+ upload.is_a?(Hash) && upload[:tempfile].respond_to?(:read) && !upload[:filename].to_s.empty?
396
+ end
397
+
398
+ def launcher_active?(job_id)
399
+ settings.launcher.respond_to?(:active?) && settings.launcher.active?(job_id)
400
+ end
401
+
402
+ def bambu_studio_active?(job_id)
403
+ settings.bambu_studio_launcher.active?(job_id)
404
+ end
405
+
406
+ def valid_3mf_artifact_name?(name)
407
+ parts = name.split("/", -1)
408
+ return false if name.start_with?("/") || name.include?("\\")
409
+ return false if parts.empty? || parts.any? { |part| part.empty? || %w[. ..].include?(part) }
410
+
411
+ File.extname(name).casecmp?(".3mf")
412
+ end
413
+
414
+ def start_job(resolved)
415
+ preview_id = params["preview_id"].to_s
416
+ return start_staged_job(preview_id, resolved) unless preview_id.empty?
417
+
418
+ upload = params["image"]
419
+ raise InputError, "select a JPEG, PNG, or SVG image" unless valid_upload?(upload)
420
+
421
+ settings.launcher.start(
422
+ upload: upload.fetch(:tempfile), filename: upload.fetch(:filename), settings: resolved
423
+ )
424
+ end
425
+
426
+ def start_staged_job(preview_id, resolved)
427
+ settings.preview_store.with_input(preview_id) do |path, filename|
428
+ File.open(path, "rb") { |upload| settings.launcher.start(upload:, filename:, settings: resolved) }
429
+ end
430
+ ensure
431
+ settings.preview_store.discard(preview_id)
432
+ end
433
+
434
+ def discard_submitted_preview
435
+ preview_id = params["preview_id"].to_s
436
+ settings.preview_store.discard(preview_id) unless preview_id.empty?
437
+ rescue Error
438
+ nil
439
+ end
440
+
441
+ def verify_request_size!
442
+ length = request.content_length.to_i
443
+ halt 413, "Upload is too large" if length > MAX_REQUEST_BYTES
444
+ end
445
+
446
+ def verify_csrf!
447
+ supplied = params["csrf_token"].to_s
448
+ supplied = request.env["HTTP_X_CSRF_TOKEN"].to_s if supplied.empty?
449
+ expected = settings.csrf_token.to_s
450
+ halt 403, "Invalid request token" unless secure_compare(supplied, expected)
451
+ end
452
+
453
+ def secure_compare(left, right)
454
+ left.bytesize == right.bytesize && Rack::Utils.secure_compare(left, right)
455
+ end
456
+
457
+ def json_request?
458
+ request.env.fetch("HTTP_ACCEPT", "").split(",").any? { |type| type.include?("application/json") }
459
+ end
460
+
461
+ def present_job(record)
462
+ presented = record.dup
463
+ presented["can_open_in_bambu_studio"] = settings.bambu_studio_launcher.supported?
464
+ files = record.fetch("files", []) || []
465
+ presented["artifacts"] = files.map do |name|
466
+ { "name" => name, "url" => artifact_url(record.fetch("job_id"), name) }
467
+ end
468
+ presented["preview_url"] = artifact_url(record.fetch("job_id"), "preview.png") if files.include?("preview.png")
469
+ if files.include?("corrected-input.png")
470
+ presented["corrected_input_url"] = artifact_url(record.fetch("job_id"), "corrected-input.png")
471
+ end
472
+ presented.merge!(manifest_presentation(record, files))
473
+ presented["log_url"] = "/jobs/#{Rack::Utils.escape_path(record.fetch("job_id"))}/log" if job_log?(record)
474
+ presented.merge!(settings.job_store.summary(record.fetch("job_id")))
475
+ presented
476
+ end
477
+
478
+ def manifest_presentation(record, files)
479
+ return {} unless record["state"] == "completed" && files.include?("manifest.json")
480
+
481
+ manifest = JSON.parse(File.read(settings.job_store.artifact(record.fetch("job_id"), "manifest.json")))
482
+ {
483
+ "tile_layout_metadata" => manifest["tile_layout"],
484
+ "tiles" => manifest["tiles"],
485
+ "z_part_separation" => manifest.dig("color_model", "z_part_separation")
486
+ }
487
+ rescue Error, JSON::ParserError, SystemCallError
488
+ {}
489
+ end
490
+
491
+ def job_log?(record)
492
+ settings.job_store.log(record.fetch("job_id"))
493
+ true
494
+ rescue Error
495
+ false
496
+ end
497
+
498
+ def artifact_url(job_id, name)
499
+ encoded = name.split("/").map { |part| Rack::Utils.escape_path(part) }.join("/")
500
+ "/jobs/#{Rack::Utils.escape_path(job_id)}/files/#{encoded}"
501
+ end
502
+
503
+ def inline_artifact?(path)
504
+ %w[.png .svg .json .md .txt].include?(File.extname(path).downcase)
505
+ end
506
+
507
+ def json_error(message)
508
+ content_type :json
509
+ JSON.generate(error: message)
510
+ end
511
+ end
512
+ end
513
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Lithop
6
+ module Web
7
+ # Hands a validated 3MF artifact to Bambu Studio through macOS LaunchServices.
8
+ class BambuStudioLauncher
9
+ BUNDLE_ID = "com.bambulab.bambu-studio"
10
+ OPEN_COMMAND = "/usr/bin/open"
11
+ DEFAULT_TIMEOUT_SECONDS = 5
12
+
13
+ class LauncherError < StandardError; end
14
+ class BusyError < LauncherError; end
15
+ class UnsupportedPlatformError < LauncherError; end
16
+ class UnavailableError < LauncherError; end
17
+ class LaunchTimeoutError < LauncherError; end
18
+ class InvalidArtifactError < LauncherError; end
19
+
20
+ def initialize(platform: RUBY_PLATFORM, runner: nil, timeout_seconds: DEFAULT_TIMEOUT_SECONDS)
21
+ @platform = platform
22
+ @runner = runner || method(:run_command)
23
+ @timeout_seconds = timeout_seconds
24
+ @mutex = Mutex.new
25
+ @active_jobs = Set.new
26
+ end
27
+
28
+ def open(job_id:, path:)
29
+ begin_open(job_id)
30
+ acquired = true
31
+ validate!(path)
32
+ command = [OPEN_COMMAND, "-b", BUNDLE_ID, path]
33
+ raise UnavailableError, "Bambu Studio could not be opened" unless @runner.call(command, @timeout_seconds)
34
+
35
+ true
36
+ ensure
37
+ finish_open(job_id) if acquired
38
+ end
39
+
40
+ def active?(job_id) = @mutex.synchronize { @active_jobs.include?(job_id) }
41
+ def supported? = @platform.include?("darwin")
42
+
43
+ private
44
+
45
+ def begin_open(job_id)
46
+ @mutex.synchronize do
47
+ raise BusyError, "Bambu Studio launch is already in progress" unless @active_jobs.empty?
48
+
49
+ @active_jobs.add(job_id)
50
+ end
51
+ end
52
+
53
+ def finish_open(job_id) = @mutex.synchronize { @active_jobs.delete(job_id) }
54
+
55
+ def validate!(path)
56
+ raise UnsupportedPlatformError, "opening Bambu Studio is supported on macOS" unless supported?
57
+ raise InvalidArtifactError, "artifact must be a 3MF file" unless File.extname(path).casecmp?(".3mf")
58
+ raise InvalidArtifactError, "3MF artifact not found" unless File.file?(path)
59
+ end
60
+
61
+ def run_command(command, timeout_seconds)
62
+ pid = Process.spawn(*command, out: File::NULL, err: File::NULL)
63
+ Timeout.timeout(timeout_seconds) { Process.wait2(pid).last.success? }
64
+ rescue Timeout::Error
65
+ terminate(pid)
66
+ raise LaunchTimeoutError, "Bambu Studio launch request timed out"
67
+ rescue SystemCallError
68
+ raise UnavailableError, "Bambu Studio could not be opened"
69
+ end
70
+
71
+ def terminate(pid)
72
+ Process.kill("TERM", pid)
73
+ Timeout.timeout(0.25) { Process.wait(pid) }
74
+ rescue Timeout::Error
75
+ Process.kill("KILL", pid)
76
+ Process.wait(pid)
77
+ rescue Errno::ESRCH, Errno::ECHILD
78
+ nil
79
+ end
80
+ end
81
+ end
82
+ end