lithop 0.2.0 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/Cargo.lock +284 -0
- data/Cargo.toml +6 -0
- data/README.md +34 -13
- data/RELEASING.md +24 -6
- data/docs/README.md +6 -3
- data/docs/artifacts.md +35 -16
- data/docs/bambu-studio.md +17 -11
- data/docs/calibration.md +4 -4
- data/docs/color-model.md +20 -18
- data/docs/command.md +53 -14
- data/docs/config.md +43 -31
- data/docs/getting-started.md +17 -8
- data/docs/images/cmyw-lithophane-preview.png +0 -0
- data/docs/local-ui.md +117 -0
- data/docs/tiling.md +20 -19
- data/docs/troubleshooting.md +12 -2
- data/ext/lithop_native/Cargo.toml +17 -0
- data/ext/lithop_native/build.rs +4 -0
- data/ext/lithop_native/extconf.rb +6 -0
- data/ext/lithop_native/src/column_mesh.rs +388 -0
- data/ext/lithop_native/src/lib.rs +37 -0
- data/ext/lithop_native/src/mesh.rs +187 -0
- data/ext/lithop_native/src/palette.rs +67 -0
- data/ext/lithop_native/src/xml.rs +134 -0
- data/lib/lithop/calibration/export.rb +2 -2
- data/lib/lithop/calibration/target.rb +5 -5
- data/lib/lithop/cli/image_adjustment_options.rb +33 -0
- data/lib/lithop/cli/image_options.rb +5 -18
- data/lib/lithop/cli/options.rb +17 -4
- data/lib/lithop/cli.rb +28 -0
- data/lib/lithop/export/color_metadata.rb +46 -23
- data/lib/lithop/export/generation.rb +38 -27
- data/lib/lithop/export/job.rb +2 -2
- data/lib/lithop/export/metadata.rb +8 -3
- data/lib/lithop/export/planning.rb +82 -45
- data/lib/lithop/export/previews.rb +25 -1
- data/lib/lithop/export/print_readme.rb +20 -4
- data/lib/lithop/formats/three_mf/bambu_metadata.rb +11 -8
- data/lib/lithop/formats/three_mf/mesh.rb +72 -3
- data/lib/lithop/formats/three_mf/model_writer.rb +21 -21
- data/lib/lithop/formats/three_mf/part_writer.rb +37 -0
- data/lib/lithop/formats/three_mf.rb +1 -1
- data/lib/lithop/image/pipeline.rb +84 -31
- data/lib/lithop/image/svg_loader.rb +16 -7
- data/lib/lithop/job_execution.rb +132 -4
- data/lib/lithop/layer_structure.rb +0 -9
- data/lib/lithop/mesh/column_geometry.rb +8 -0
- data/lib/lithop/mesh/column_topology.rb +2 -0
- data/lib/lithop/model/filament_profile.rb +3 -3
- data/lib/lithop/model/material_display_map.rb +2 -1
- data/lib/lithop/model/material_model.rb +17 -5
- data/lib/lithop/model/palette.rb +21 -3
- data/lib/lithop/model/z_region_model.rb +44 -0
- data/lib/lithop/native.rb +39 -0
- data/lib/lithop/settings.rb +147 -15
- data/lib/lithop/settings_file.rb +1 -0
- data/lib/lithop/tiling.rb +3 -2
- data/lib/lithop/version.rb +1 -1
- data/lib/lithop/web/app.rb +517 -0
- data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
- data/lib/lithop/web/job_launcher.rb +214 -0
- data/lib/lithop/web/job_store.rb +390 -0
- data/lib/lithop/web/preview_store.rb +182 -0
- data/lib/lithop/web/public/app.css +330 -0
- data/lib/lithop/web/public/app.js +1949 -0
- data/lib/lithop/web/server.rb +21 -0
- data/lib/lithop/web/settings_form.rb +67 -0
- data/lib/lithop/web/settings_library.rb +154 -0
- data/lib/lithop/web/views/index.erb +418 -0
- data/lib/lithop/web/worker.rb +27 -0
- data/lib/lithop.rb +3 -1
- data/sig/lithop.rbs +41 -0
- metadata +86 -3
- data/lib/lithop/model/layer_region_model.rb +0 -24
|
@@ -0,0 +1,517 @@
|
|
|
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.resolved_z_part_count : 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
|
+
apply_submitted_values(submitted) if submitted
|
|
364
|
+
@jobs = settings.job_store.recent.map do |job|
|
|
365
|
+
job.merge(settings.job_store.summary(job.fetch("job_id")))
|
|
366
|
+
end
|
|
367
|
+
prepare_navigation
|
|
368
|
+
@csrf_token = settings.csrf_token
|
|
369
|
+
rescue Error => e
|
|
370
|
+
@error ||= e.message
|
|
371
|
+
@values ||= Settings::DEFAULTS.transform_keys(&:to_s)
|
|
372
|
+
@jobs ||= []
|
|
373
|
+
@initial_view ||= "create"
|
|
374
|
+
@project_name ||= File.basename(settings.project_dir)
|
|
375
|
+
@csrf_token = settings.csrf_token
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def apply_submitted_values(submitted)
|
|
379
|
+
SettingsForm::FIELD_NAMES.each { |key| @values[key] = submitted[key] if submitted.key?(key) }
|
|
380
|
+
SettingsForm::BOOLEAN_FIELDS.each do |key|
|
|
381
|
+
@values[key.to_s] = submitted[key.to_s] == "1"
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def prepare_navigation
|
|
386
|
+
@selected_job_id = params["job"]
|
|
387
|
+
requested_view = params["view"].to_s
|
|
388
|
+
@initial_view = if %w[create jobs presets].include?(requested_view)
|
|
389
|
+
requested_view
|
|
390
|
+
elsif @selected_job_id
|
|
391
|
+
"jobs"
|
|
392
|
+
else
|
|
393
|
+
"create"
|
|
394
|
+
end
|
|
395
|
+
@project_name = File.basename(settings.project_dir)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def valid_upload?(upload)
|
|
399
|
+
upload.is_a?(Hash) && upload[:tempfile].respond_to?(:read) && !upload[:filename].to_s.empty?
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def launcher_active?(job_id)
|
|
403
|
+
settings.launcher.respond_to?(:active?) && settings.launcher.active?(job_id)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def bambu_studio_active?(job_id)
|
|
407
|
+
settings.bambu_studio_launcher.active?(job_id)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def valid_3mf_artifact_name?(name)
|
|
411
|
+
parts = name.split("/", -1)
|
|
412
|
+
return false if name.start_with?("/") || name.include?("\\")
|
|
413
|
+
return false if parts.empty? || parts.any? { |part| part.empty? || %w[. ..].include?(part) }
|
|
414
|
+
|
|
415
|
+
File.extname(name).casecmp?(".3mf")
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def start_job(resolved)
|
|
419
|
+
preview_id = params["preview_id"].to_s
|
|
420
|
+
return start_staged_job(preview_id, resolved) unless preview_id.empty?
|
|
421
|
+
|
|
422
|
+
upload = params["image"]
|
|
423
|
+
raise InputError, "select a JPEG, PNG, or SVG image" unless valid_upload?(upload)
|
|
424
|
+
|
|
425
|
+
settings.launcher.start(
|
|
426
|
+
upload: upload.fetch(:tempfile), filename: upload.fetch(:filename), settings: resolved
|
|
427
|
+
)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def start_staged_job(preview_id, resolved)
|
|
431
|
+
settings.preview_store.with_input(preview_id) do |path, filename|
|
|
432
|
+
File.open(path, "rb") { |upload| settings.launcher.start(upload:, filename:, settings: resolved) }
|
|
433
|
+
end
|
|
434
|
+
ensure
|
|
435
|
+
settings.preview_store.discard(preview_id)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def discard_submitted_preview
|
|
439
|
+
preview_id = params["preview_id"].to_s
|
|
440
|
+
settings.preview_store.discard(preview_id) unless preview_id.empty?
|
|
441
|
+
rescue Error
|
|
442
|
+
nil
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def verify_request_size!
|
|
446
|
+
length = request.content_length.to_i
|
|
447
|
+
halt 413, "Upload is too large" if length > MAX_REQUEST_BYTES
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def verify_csrf!
|
|
451
|
+
supplied = params["csrf_token"].to_s
|
|
452
|
+
supplied = request.env["HTTP_X_CSRF_TOKEN"].to_s if supplied.empty?
|
|
453
|
+
expected = settings.csrf_token.to_s
|
|
454
|
+
halt 403, "Invalid request token" unless secure_compare(supplied, expected)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def secure_compare(left, right)
|
|
458
|
+
left.bytesize == right.bytesize && Rack::Utils.secure_compare(left, right)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def json_request?
|
|
462
|
+
request.env.fetch("HTTP_ACCEPT", "").split(",").any? { |type| type.include?("application/json") }
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def present_job(record)
|
|
466
|
+
presented = record.dup
|
|
467
|
+
presented["can_open_in_bambu_studio"] = settings.bambu_studio_launcher.supported?
|
|
468
|
+
files = record.fetch("files", []) || []
|
|
469
|
+
presented["artifacts"] = files.map do |name|
|
|
470
|
+
{ "name" => name, "url" => artifact_url(record.fetch("job_id"), name) }
|
|
471
|
+
end
|
|
472
|
+
presented["preview_url"] = artifact_url(record.fetch("job_id"), "preview.png") if files.include?("preview.png")
|
|
473
|
+
if files.include?("corrected-input.png")
|
|
474
|
+
presented["corrected_input_url"] = artifact_url(record.fetch("job_id"), "corrected-input.png")
|
|
475
|
+
end
|
|
476
|
+
presented.merge!(manifest_presentation(record, files))
|
|
477
|
+
presented["log_url"] = "/jobs/#{Rack::Utils.escape_path(record.fetch("job_id"))}/log" if job_log?(record)
|
|
478
|
+
presented.merge!(settings.job_store.summary(record.fetch("job_id")))
|
|
479
|
+
presented
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def manifest_presentation(record, files)
|
|
483
|
+
return {} unless record["state"] == "completed" && files.include?("manifest.json")
|
|
484
|
+
|
|
485
|
+
manifest = JSON.parse(File.read(settings.job_store.artifact(record.fetch("job_id"), "manifest.json")))
|
|
486
|
+
{
|
|
487
|
+
"tile_layout_metadata" => manifest["tile_layout"],
|
|
488
|
+
"tiles" => manifest["tiles"],
|
|
489
|
+
"z_part_separation" => manifest.dig("color_model", "z_part_separation")
|
|
490
|
+
}
|
|
491
|
+
rescue Error, JSON::ParserError, SystemCallError
|
|
492
|
+
{}
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def job_log?(record)
|
|
496
|
+
settings.job_store.log(record.fetch("job_id"))
|
|
497
|
+
true
|
|
498
|
+
rescue Error
|
|
499
|
+
false
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def artifact_url(job_id, name)
|
|
503
|
+
encoded = name.split("/").map { |part| Rack::Utils.escape_path(part) }.join("/")
|
|
504
|
+
"/jobs/#{Rack::Utils.escape_path(job_id)}/files/#{encoded}"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def inline_artifact?(path)
|
|
508
|
+
%w[.png .svg .json .md .txt].include?(File.extname(path).downcase)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def json_error(message)
|
|
512
|
+
content_type :json
|
|
513
|
+
JSON.generate(error: message)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
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
|