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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/Cargo.lock +284 -0
- data/Cargo.toml +6 -0
- data/README.md +31 -7
- data/RELEASING.md +24 -6
- data/docs/README.md +4 -1
- data/docs/artifacts.md +26 -2
- data/docs/bambu-studio.md +7 -1
- data/docs/color-model.md +2 -0
- data/docs/command.md +48 -7
- data/docs/config.md +31 -4
- data/docs/getting-started.md +10 -5
- data/docs/images/cmyw-lithophane-preview.png +0 -0
- data/docs/local-ui.md +114 -0
- data/docs/tiling.md +134 -0
- data/docs/troubleshooting.md +22 -0
- 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 +21 -8
- data/lib/lithop/cli/calibration.rb +3 -1
- data/lib/lithop/cli/image_options.rb +41 -2
- data/lib/lithop/cli/options.rb +13 -0
- data/lib/lithop/cli.rb +36 -1
- data/lib/lithop/export/color_metadata.rb +55 -0
- data/lib/lithop/export/generation.rb +131 -21
- data/lib/lithop/export/job.rb +30 -16
- data/lib/lithop/export/metadata.rb +54 -4
- data/lib/lithop/export/planning.rb +97 -0
- data/lib/lithop/export/previews.rb +25 -1
- data/lib/lithop/export/print_readme.rb +22 -4
- data/lib/lithop/export/result.rb +26 -4
- data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
- data/lib/lithop/formats/three_mf/mesh.rb +72 -3
- data/lib/lithop/formats/three_mf/model_writer.rb +249 -24
- data/lib/lithop/formats/three_mf/part_writer.rb +51 -6
- data/lib/lithop/formats/three_mf.rb +9 -6
- data/lib/lithop/image/pipeline.rb +66 -10
- data/lib/lithop/image/svg_loader.rb +5 -5
- data/lib/lithop/job_directory.rb +41 -0
- data/lib/lithop/job_execution.rb +378 -0
- data/lib/lithop/layer_structure.rb +11 -0
- data/lib/lithop/mesh/box_geometry.rb +41 -0
- data/lib/lithop/mesh/column_geometry.rb +10 -2
- data/lib/lithop/mesh/column_topology.rb +2 -0
- data/lib/lithop/mesh/geometry.rb +21 -13
- data/lib/lithop/model/layer_region_model.rb +24 -0
- data/lib/lithop/model/material_model.rb +17 -5
- data/lib/lithop/model/palette.rb +21 -3
- data/lib/lithop/native.rb +39 -0
- data/lib/lithop/reference_three_mf.rb +17 -1
- data/lib/lithop/settings.rb +116 -1
- data/lib/lithop/settings_file.rb +10 -4
- data/lib/lithop/tiling.rb +127 -0
- data/lib/lithop/version.rb +1 -1
- data/lib/lithop/web/app.rb +513 -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 +319 -0
- data/lib/lithop/web/public/app.js +1847 -0
- data/lib/lithop/web/server.rb +21 -0
- data/lib/lithop/web/settings_form.rb +64 -0
- data/lib/lithop/web/settings_library.rb +154 -0
- data/lib/lithop/web/views/index.erb +364 -0
- data/lib/lithop/web/worker.rb +27 -0
- data/lib/lithop.rb +5 -0
- data/sig/lithop.rbs +102 -2
- metadata +89 -2
|
@@ -141,14 +141,14 @@ module Lithop
|
|
|
141
141
|
@validator = SVGDocumentValidator.new
|
|
142
142
|
end
|
|
143
143
|
|
|
144
|
-
def call(path, access:)
|
|
144
|
+
def call(path, access:, sampling_grids: nil)
|
|
145
145
|
data = read(path)
|
|
146
146
|
@validator.call(data)
|
|
147
147
|
ensure_loader!
|
|
148
148
|
|
|
149
149
|
probe = render(data, scale: 1.0, access: :sequential)
|
|
150
150
|
validate_rendered_image!(probe, pixels: false)
|
|
151
|
-
scale = render_scale(probe)
|
|
151
|
+
scale = render_scale(probe, sampling_grids || default_sampling_grids)
|
|
152
152
|
validate_planned_pixels!(probe, scale)
|
|
153
153
|
|
|
154
154
|
image = render(data, scale:, access:)
|
|
@@ -180,8 +180,8 @@ module Lithop
|
|
|
180
180
|
raise InputError, "could not render SVG: #{e.message}"
|
|
181
181
|
end
|
|
182
182
|
|
|
183
|
-
def render_scale(image)
|
|
184
|
-
scales =
|
|
183
|
+
def render_scale(image, grids)
|
|
184
|
+
scales = grids.map do |grid|
|
|
185
185
|
width, height = grid.content_sample_size
|
|
186
186
|
ratios = [width.fdiv(image.width), height.fdiv(image.height)]
|
|
187
187
|
@settings.image_fit == "contain" ? ratios.min * @settings.crop_scale : ratios.max / @settings.crop_scale
|
|
@@ -189,7 +189,7 @@ module Lithop
|
|
|
189
189
|
[*scales, 1.0 / image.width, 1.0 / image.height].max
|
|
190
190
|
end
|
|
191
191
|
|
|
192
|
-
def
|
|
192
|
+
def default_sampling_grids
|
|
193
193
|
grids = [@settings.sampling_grid]
|
|
194
194
|
grids << @settings.color_sampling_grid if @settings.color_pixel_size
|
|
195
195
|
grids
|
data/lib/lithop/job_directory.rb
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
4
6
|
|
|
5
7
|
module Lithop
|
|
6
8
|
# Owns one temporary job directory and publishes it with an atomic rename.
|
|
7
9
|
class JobDirectory
|
|
10
|
+
OWNER_FILE = ".lithop-owner.json"
|
|
11
|
+
Recovered = Data.define(:job_id, :path)
|
|
12
|
+
|
|
8
13
|
attr_reader :destination, :temporary
|
|
9
14
|
|
|
10
15
|
def initialize(project_dir:, job_id:)
|
|
@@ -24,6 +29,37 @@ module Lithop
|
|
|
24
29
|
raise
|
|
25
30
|
end
|
|
26
31
|
|
|
32
|
+
def self.recover(project_dir:)
|
|
33
|
+
build_dir = File.join(File.expand_path(project_dir), "build")
|
|
34
|
+
Dir.glob(File.join(build_dir, ".tmp-*")).filter_map do |path|
|
|
35
|
+
recover_directory(path)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.recover_directory(path)
|
|
40
|
+
return if File.symlink?(path) || !File.directory?(path)
|
|
41
|
+
|
|
42
|
+
owner = JSON.parse(File.read(File.join(path, OWNER_FILE)))
|
|
43
|
+
job_id = owner.fetch("job_id")
|
|
44
|
+
return unless File.basename(path) == ".tmp-#{job_id}"
|
|
45
|
+
return if process_alive?(Integer(owner.fetch("pid")))
|
|
46
|
+
|
|
47
|
+
FileUtils.rm_rf(path)
|
|
48
|
+
Recovered.new(job_id:, path:)
|
|
49
|
+
rescue JSON::ParserError, KeyError, ArgumentError, SystemCallError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.process_alive?(pid)
|
|
54
|
+
Process.kill(0, pid)
|
|
55
|
+
true
|
|
56
|
+
rescue Errno::ESRCH
|
|
57
|
+
false
|
|
58
|
+
rescue Errno::EPERM
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
private_class_method :recover_directory, :process_alive?
|
|
62
|
+
|
|
27
63
|
private
|
|
28
64
|
|
|
29
65
|
def acquire
|
|
@@ -32,6 +68,10 @@ module Lithop
|
|
|
32
68
|
|
|
33
69
|
Dir.mkdir(temporary)
|
|
34
70
|
@owned = true
|
|
71
|
+
File.write(
|
|
72
|
+
File.join(temporary, OWNER_FILE),
|
|
73
|
+
JSON.generate(job_id: File.basename(destination), pid: Process.pid, created_at: Time.now.utc.iso8601(6))
|
|
74
|
+
)
|
|
35
75
|
rescue Errno::EEXIST
|
|
36
76
|
raise Error, "output already exists: #{File.exist?(destination) ? destination : temporary}"
|
|
37
77
|
end
|
|
@@ -39,6 +79,7 @@ module Lithop
|
|
|
39
79
|
def publish
|
|
40
80
|
raise Error, "output already exists: #{destination}" if File.exist?(destination)
|
|
41
81
|
|
|
82
|
+
FileUtils.rm_f(File.join(temporary, OWNER_FILE))
|
|
42
83
|
File.rename(temporary, destination)
|
|
43
84
|
@owned = false
|
|
44
85
|
end
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Lithop
|
|
8
|
+
class JobCancelled < Error; end
|
|
9
|
+
|
|
10
|
+
# Persists one conversion's state and limits a project to one active conversion.
|
|
11
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
12
|
+
class JobExecution
|
|
13
|
+
SCHEMA_VERSION = 1
|
|
14
|
+
ACTIVE_STATES = %w[queued running cancelling].freeze
|
|
15
|
+
FINAL_STATES = %w[completed failed cancelled].freeze
|
|
16
|
+
Progress = Data.define(:job_id, :state, :phase, :percent, :message, :updated_at)
|
|
17
|
+
|
|
18
|
+
attr_reader :job_id, :status_path
|
|
19
|
+
|
|
20
|
+
def initialize(project_dir:, job_id:, progress: nil, clock: Time)
|
|
21
|
+
validate_job_id!(job_id)
|
|
22
|
+
@project_dir = File.expand_path(project_dir)
|
|
23
|
+
@job_id = job_id
|
|
24
|
+
@progress_callback = progress
|
|
25
|
+
@clock = clock
|
|
26
|
+
@build_dir = File.join(@project_dir, "build")
|
|
27
|
+
@jobs_dir = File.join(@build_dir, ".jobs")
|
|
28
|
+
@status_path = File.join(@jobs_dir, "#{job_id}.json")
|
|
29
|
+
@cancel_path = File.join(@jobs_dir, "#{job_id}.cancel")
|
|
30
|
+
@transition_lock_path = File.join(@jobs_dir, "#{job_id}.transition.lock")
|
|
31
|
+
@lock_path = File.join(@build_dir, ".lithop.lock")
|
|
32
|
+
@lock = nil
|
|
33
|
+
@transition_lock = nil
|
|
34
|
+
@record = base_record
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call
|
|
38
|
+
prepare
|
|
39
|
+
transition("queued", phase: "queued", percent: 0, message: "Waiting to start")
|
|
40
|
+
acquire_lock
|
|
41
|
+
recover_temporary_directories
|
|
42
|
+
raise_if_cancelled!
|
|
43
|
+
transition("running", phase: "starting", percent: 0, message: "Conversion started", started_at: timestamp)
|
|
44
|
+
result = yield self
|
|
45
|
+
raise_if_cancelled! unless @publication_committed
|
|
46
|
+
complete(result)
|
|
47
|
+
result
|
|
48
|
+
rescue JobCancelled, Interrupt => e
|
|
49
|
+
record_terminal_state(
|
|
50
|
+
"cancelled", phase: "cancelled", message: "Conversion cancelled", error: e
|
|
51
|
+
)
|
|
52
|
+
raise
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
record_terminal_state("failed", phase: "failed", message: "Conversion failed", error: e)
|
|
55
|
+
raise
|
|
56
|
+
ensure
|
|
57
|
+
release_lock
|
|
58
|
+
release_transition_lock
|
|
59
|
+
remove_cancel_request if prepared?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def progress(phase:, percent:, message:)
|
|
63
|
+
raise_if_cancelled!
|
|
64
|
+
transition("running", phase: phase.to_s, percent: normalize_percent(percent), message: message.to_s)
|
|
65
|
+
raise_if_cancelled!
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def checkpoint!
|
|
69
|
+
@transition_mutex = self.class.send(:transition_mutex, @transition_lock_path)
|
|
70
|
+
@transition_mutex.lock
|
|
71
|
+
@transition_mutex_locked = true
|
|
72
|
+
@transition_lock = File.open(@transition_lock_path, File::RDWR | File::CREAT, 0o600)
|
|
73
|
+
@transition_lock.flock(File::LOCK_EX)
|
|
74
|
+
raise_if_cancelled!
|
|
75
|
+
rescue StandardError
|
|
76
|
+
release_transition_lock
|
|
77
|
+
raise
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Once JobDirectory has atomically published the output, late cancellation
|
|
81
|
+
# must not turn a complete artifact set into a cancelled job.
|
|
82
|
+
def publication_committed!(message: "Artifacts published")
|
|
83
|
+
@publication_committed = true
|
|
84
|
+
transition("running", phase: "publishing", percent: 99, message:)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.request_cancel(project_dir:, job_id:, clock: Time)
|
|
88
|
+
validate_job_id!(job_id)
|
|
89
|
+
jobs_dir = File.join(File.expand_path(project_dir), "build", ".jobs")
|
|
90
|
+
FileUtils.mkdir_p(jobs_dir)
|
|
91
|
+
status_path = File.join(jobs_dir, "#{job_id}.json")
|
|
92
|
+
path = File.join(jobs_dir, "#{job_id}.cancel")
|
|
93
|
+
lock_path = File.join(jobs_dir, "#{job_id}.transition.lock")
|
|
94
|
+
requested_at = clock.now.utc.iso8601(6)
|
|
95
|
+
requested = false
|
|
96
|
+
transition_mutex(lock_path).synchronize do
|
|
97
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
98
|
+
lock.flock(File::LOCK_EX)
|
|
99
|
+
unless final_status?(status_path)
|
|
100
|
+
atomic_write(path, JSON.generate(job_id:, requested_at:))
|
|
101
|
+
mark_cancelling(status_path, requested_at)
|
|
102
|
+
requested = true
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
path if requested
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.finalize_process_exit(project_dir:, job_id:, clock: Time)
|
|
110
|
+
validate_job_id!(job_id)
|
|
111
|
+
build_dir = File.join(File.expand_path(project_dir), "build")
|
|
112
|
+
jobs_dir = File.join(build_dir, ".jobs")
|
|
113
|
+
status_path = File.join(jobs_dir, "#{job_id}.json")
|
|
114
|
+
cancel_path = File.join(jobs_dir, "#{job_id}.cancel")
|
|
115
|
+
record = if File.file?(status_path)
|
|
116
|
+
JSON.parse(File.read(status_path))
|
|
117
|
+
else
|
|
118
|
+
process_exit_record(job_id:, cancelled: File.file?(cancel_path), clock:)
|
|
119
|
+
end
|
|
120
|
+
return record unless ACTIVE_STATES.include?(record["state"])
|
|
121
|
+
|
|
122
|
+
now = clock.now.utc.iso8601(6)
|
|
123
|
+
output_path = File.join(build_dir, job_id)
|
|
124
|
+
finalize_exit_record(record, output_path:, cancelled: File.file?(cancel_path), now:)
|
|
125
|
+
atomic_write(status_path, JSON.pretty_generate(record) << "\n")
|
|
126
|
+
record
|
|
127
|
+
rescue JSON::ParserError, SystemCallError
|
|
128
|
+
nil
|
|
129
|
+
ensure
|
|
130
|
+
FileUtils.rm_f(cancel_path) if defined?(cancel_path)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class << self
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def finalize_exit_record(record, output_path:, cancelled:, now:)
|
|
137
|
+
if File.directory?(output_path) && !File.symlink?(output_path)
|
|
138
|
+
record.merge!(
|
|
139
|
+
"state" => "completed", "phase" => "completed", "percent" => 100.0,
|
|
140
|
+
"message" => "Conversion completed", "updated_at" => now, "finished_at" => now,
|
|
141
|
+
"output_path" => output_path, "files" => artifact_files(output_path), "error" => nil
|
|
142
|
+
)
|
|
143
|
+
elsif record["state"] == "cancelling" || cancelled
|
|
144
|
+
record.merge!(
|
|
145
|
+
"state" => "cancelled", "phase" => "cancelled", "message" => "Conversion cancelled",
|
|
146
|
+
"updated_at" => now, "finished_at" => now
|
|
147
|
+
)
|
|
148
|
+
else
|
|
149
|
+
record.merge!(
|
|
150
|
+
"state" => "failed", "phase" => "failed", "message" => "Conversion process exited",
|
|
151
|
+
"updated_at" => now, "finished_at" => now,
|
|
152
|
+
"error" => { "class" => "Lithop::Error", "message" => "conversion process exited before completion" }
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_job_id!(job_id)
|
|
158
|
+
return if job_id.is_a?(String) && job_id.match?(/\A[0-9A-Za-z][0-9A-Za-z._-]*\z/)
|
|
159
|
+
|
|
160
|
+
raise Error, "invalid job ID: #{job_id.inspect}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def atomic_write(path, contents)
|
|
164
|
+
temporary = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
165
|
+
File.write(temporary, contents)
|
|
166
|
+
File.rename(temporary, path)
|
|
167
|
+
ensure
|
|
168
|
+
FileUtils.rm_f(temporary) if defined?(temporary)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def mark_cancelling(path, requested_at)
|
|
172
|
+
return unless File.file?(path)
|
|
173
|
+
|
|
174
|
+
record = JSON.parse(File.read(path))
|
|
175
|
+
return unless ACTIVE_STATES.include?(record["state"])
|
|
176
|
+
|
|
177
|
+
record.merge!(
|
|
178
|
+
"state" => "cancelling",
|
|
179
|
+
"phase" => "cancelling",
|
|
180
|
+
"message" => "Cancellation requested",
|
|
181
|
+
"updated_at" => requested_at
|
|
182
|
+
)
|
|
183
|
+
atomic_write(path, JSON.pretty_generate(record) << "\n")
|
|
184
|
+
rescue JSON::ParserError, SystemCallError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def final_status?(path)
|
|
189
|
+
File.file?(path) && FINAL_STATES.include?(JSON.parse(File.read(path))["state"])
|
|
190
|
+
rescue JSON::ParserError, SystemCallError
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def transition_mutex(path)
|
|
195
|
+
@transition_mutex_guard ||= Mutex.new
|
|
196
|
+
@transition_mutex_guard.synchronize do
|
|
197
|
+
@transition_mutexes ||= {}
|
|
198
|
+
@transition_mutexes[path] ||= Mutex.new
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def artifact_files(output_path)
|
|
203
|
+
Dir.glob("**/*", base: output_path).select do |name|
|
|
204
|
+
path = File.join(output_path, name)
|
|
205
|
+
File.file?(path) && !File.symlink?(path)
|
|
206
|
+
end.sort
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def process_exit_record(job_id:, cancelled:, clock:)
|
|
210
|
+
now = clock.now.utc.iso8601(6)
|
|
211
|
+
{
|
|
212
|
+
"schema_version" => SCHEMA_VERSION, "job_id" => job_id,
|
|
213
|
+
"state" => cancelled ? "cancelling" : "running",
|
|
214
|
+
"phase" => cancelled ? "cancelling" : "starting", "percent" => 0,
|
|
215
|
+
"message" => cancelled ? "Cancellation requested" : "Conversion process started",
|
|
216
|
+
"created_at" => now, "updated_at" => now, "started_at" => nil,
|
|
217
|
+
"finished_at" => nil, "output_path" => nil, "files" => nil, "error" => nil
|
|
218
|
+
}
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def validate_job_id!(value) = self.class.send(:validate_job_id!, value)
|
|
225
|
+
|
|
226
|
+
def prepare
|
|
227
|
+
FileUtils.mkdir_p(@jobs_dir)
|
|
228
|
+
raise Error, "job state already exists: #{@status_path}" if File.exist?(@status_path)
|
|
229
|
+
|
|
230
|
+
@prepared = true
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def prepared? = @prepared
|
|
234
|
+
|
|
235
|
+
def acquire_lock
|
|
236
|
+
@lock = File.open(@lock_path, File::RDWR | File::CREAT, 0o600)
|
|
237
|
+
return if @lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
238
|
+
|
|
239
|
+
raise Error, "another conversion is already running in project: #{@project_dir}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def release_lock
|
|
243
|
+
return unless @lock
|
|
244
|
+
|
|
245
|
+
@lock.flock(File::LOCK_UN)
|
|
246
|
+
@lock.close
|
|
247
|
+
@lock = nil
|
|
248
|
+
rescue IOError, SystemCallError
|
|
249
|
+
@lock = nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def release_transition_lock
|
|
253
|
+
if @transition_lock
|
|
254
|
+
@transition_lock.flock(File::LOCK_UN)
|
|
255
|
+
@transition_lock.close
|
|
256
|
+
@transition_lock = nil
|
|
257
|
+
end
|
|
258
|
+
if @transition_mutex_locked
|
|
259
|
+
@transition_mutex.unlock
|
|
260
|
+
@transition_mutex_locked = false
|
|
261
|
+
end
|
|
262
|
+
rescue IOError, SystemCallError
|
|
263
|
+
@transition_lock = nil
|
|
264
|
+
if @transition_mutex_locked
|
|
265
|
+
@transition_mutex.unlock
|
|
266
|
+
@transition_mutex_locked = false
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def recover_temporary_directories
|
|
271
|
+
JobDirectory.recover(project_dir: @project_dir).each do |recovered|
|
|
272
|
+
mark_recovered_job(recovered)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def mark_recovered_job(recovered)
|
|
277
|
+
path = File.join(@jobs_dir, "#{recovered.job_id}.json")
|
|
278
|
+
return unless File.file?(path)
|
|
279
|
+
|
|
280
|
+
record = JSON.parse(File.read(path))
|
|
281
|
+
return unless ACTIVE_STATES.include?(record["state"])
|
|
282
|
+
|
|
283
|
+
now = timestamp
|
|
284
|
+
record.merge!(
|
|
285
|
+
"state" => "failed",
|
|
286
|
+
"phase" => "recovered",
|
|
287
|
+
"message" => "Abandoned temporary data recovered",
|
|
288
|
+
"updated_at" => now,
|
|
289
|
+
"finished_at" => now,
|
|
290
|
+
"error" => { "class" => "Lithop::Error", "message" => "conversion process ended before completion" }
|
|
291
|
+
)
|
|
292
|
+
self.class.send(:atomic_write, path, JSON.pretty_generate(record) << "\n")
|
|
293
|
+
rescue JSON::ParserError, SystemCallError
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def complete(result)
|
|
298
|
+
additions = { finished_at: timestamp }
|
|
299
|
+
additions[:output_path] = result.path if result.respond_to?(:path)
|
|
300
|
+
additions[:files] = result.files if result.respond_to?(:files)
|
|
301
|
+
transition("completed", phase: "completed", percent: 100, message: "Conversion completed", **additions)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def transition(state, phase:, percent:, message:, **additions)
|
|
305
|
+
now = timestamp
|
|
306
|
+
@record.merge!(
|
|
307
|
+
state:,
|
|
308
|
+
phase:,
|
|
309
|
+
percent: normalize_percent(percent),
|
|
310
|
+
message:,
|
|
311
|
+
updated_at: now,
|
|
312
|
+
**additions
|
|
313
|
+
)
|
|
314
|
+
self.class.send(:atomic_write, @status_path, JSON.pretty_generate(@record) << "\n")
|
|
315
|
+
notify
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def notify
|
|
319
|
+
return unless @progress_callback
|
|
320
|
+
|
|
321
|
+
@progress_callback.call(
|
|
322
|
+
Progress.new(
|
|
323
|
+
job_id:,
|
|
324
|
+
state: @record.fetch(:state),
|
|
325
|
+
phase: @record.fetch(:phase),
|
|
326
|
+
percent: @record.fetch(:percent),
|
|
327
|
+
message: @record.fetch(:message),
|
|
328
|
+
updated_at: @record.fetch(:updated_at)
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
rescue StandardError
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def record_terminal_state(state, phase:, message:, error:)
|
|
336
|
+
return unless prepared?
|
|
337
|
+
|
|
338
|
+
transition(state, phase:, percent: current_percent, message:, finished_at: timestamp,
|
|
339
|
+
error: error_record(error))
|
|
340
|
+
rescue StandardError
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def raise_if_cancelled!
|
|
345
|
+
raise JobCancelled, "job #{job_id} was cancelled" if File.file?(@cancel_path)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def remove_cancel_request
|
|
349
|
+
FileUtils.rm_f(@cancel_path) if @cancel_path
|
|
350
|
+
rescue SystemCallError
|
|
351
|
+
nil
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def base_record
|
|
355
|
+
now = timestamp
|
|
356
|
+
{
|
|
357
|
+
schema_version: SCHEMA_VERSION,
|
|
358
|
+
job_id:,
|
|
359
|
+
state: "queued",
|
|
360
|
+
phase: "queued",
|
|
361
|
+
percent: 0,
|
|
362
|
+
message: "Waiting to start",
|
|
363
|
+
created_at: now,
|
|
364
|
+
updated_at: now,
|
|
365
|
+
started_at: nil,
|
|
366
|
+
finished_at: nil,
|
|
367
|
+
output_path: nil,
|
|
368
|
+
files: nil,
|
|
369
|
+
error: nil
|
|
370
|
+
}
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def error_record(error) = { class: error.class.name, message: error.message }
|
|
374
|
+
def timestamp = @clock.now.utc.iso8601(6)
|
|
375
|
+
def current_percent = @record.fetch(:percent, 0)
|
|
376
|
+
def normalize_percent(value) = Float(value).clamp(0.0, 100.0)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
@@ -61,6 +61,17 @@ module Lithop
|
|
|
61
61
|
def color_region_thickness = (color_layer_count * layer_height).round(9)
|
|
62
62
|
def base_white_thickness = (base_white_layers * first_layer_height).round(9)
|
|
63
63
|
def minimum_top_white_thickness = (minimum_physical_thickness - color_end).round(9)
|
|
64
|
+
def color_region_start = base_white_thickness
|
|
65
|
+
def color_region_end = color_end
|
|
66
|
+
|
|
67
|
+
def color_region_layer_ranges(part_count)
|
|
68
|
+
size, remainder = color_layer_count.divmod(part_count)
|
|
69
|
+
start = base_white_layers
|
|
70
|
+
Array.new(part_count) do |index|
|
|
71
|
+
finish = start + size + (index < remainder ? 1 : 0)
|
|
72
|
+
(start...finish).tap { start = finish }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
64
75
|
|
|
65
76
|
def materials_for(recipe_layers, height)
|
|
66
77
|
top_white_layers = top_white_layer_count(height, pigment_layers: recipe_layers.length)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Generates a closed axis-aligned box with outward-facing triangles.
|
|
5
|
+
class BoxGeometry
|
|
6
|
+
attr_reader :triangle_count
|
|
7
|
+
|
|
8
|
+
def initialize(width:, height:, z_min:, z_max:)
|
|
9
|
+
@width = width
|
|
10
|
+
@height = height
|
|
11
|
+
@z_min = z_min
|
|
12
|
+
@z_max = z_max
|
|
13
|
+
@triangle_count = 12
|
|
14
|
+
raise InputError, "box dimensions must be positive" unless width.positive? && height.positive? && z_max > z_min
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def each_triangle(&block)
|
|
18
|
+
return enum_for(__method__) unless block
|
|
19
|
+
|
|
20
|
+
x0 = 0.0
|
|
21
|
+
y0 = 0.0
|
|
22
|
+
x1 = @width
|
|
23
|
+
y1 = @height
|
|
24
|
+
z0 = @z_min
|
|
25
|
+
z1 = @z_max
|
|
26
|
+
quad([x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0], &block)
|
|
27
|
+
quad([x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], &block)
|
|
28
|
+
quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], &block)
|
|
29
|
+
quad([x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1], &block)
|
|
30
|
+
quad([x1, y1, z0], [x0, y1, z0], [x0, y1, z1], [x1, y1, z1], &block)
|
|
31
|
+
quad([x0, y1, z0], [x0, y0, z0], [x0, y0, z1], [x0, y1, z1], &block)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def quad(a, b, c, d)
|
|
37
|
+
yield [a, b, c]
|
|
38
|
+
yield [a, c, d]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -7,8 +7,8 @@ module Lithop
|
|
|
7
7
|
include Enumerable
|
|
8
8
|
|
|
9
9
|
def initialize(model:, material:)
|
|
10
|
-
unless Palette::
|
|
11
|
-
raise InputError, "column geometry requires a
|
|
10
|
+
unless Palette::MATERIALS.include?(material)
|
|
11
|
+
raise InputError, "column geometry requires a CMYW material: #{material.inspect}"
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
@model = model
|
|
@@ -35,6 +35,14 @@ module Lithop
|
|
|
35
35
|
|
|
36
36
|
def release_cache! = @topology.release_cache!
|
|
37
37
|
|
|
38
|
+
def native_indexed_mesh
|
|
39
|
+
Native.column_mesh(
|
|
40
|
+
@model.columns, @model.rows, @model.x_boundaries, @model.y_boundaries, @model.z_boundaries,
|
|
41
|
+
@intervals.flat_map { |value| value || [-1, -1] },
|
|
42
|
+
@topology.group_ids, @topology.group_count
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
38
46
|
def triangle_count
|
|
39
47
|
@model.rows.times.sum do |row|
|
|
40
48
|
@model.columns.times.sum do |column|
|
data/lib/lithop/mesh/geometry.rb
CHANGED
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
module Lithop
|
|
4
4
|
# Generates the closed triangle mesh for a monochrome height field.
|
|
5
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
5
6
|
class Geometry
|
|
6
|
-
|
|
7
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
8
|
+
def initialize(
|
|
9
|
+
heights:, columns:, rows:, x_boundaries:, y_boundaries:, optimize_bottom: false, base_height: 0.0
|
|
10
|
+
)
|
|
7
11
|
@heights = heights
|
|
8
12
|
@columns = columns
|
|
9
13
|
@rows = rows
|
|
10
14
|
@x = x_boundaries
|
|
11
15
|
@y = y_boundaries
|
|
12
16
|
@optimize_bottom = optimize_bottom
|
|
17
|
+
@base_height = base_height
|
|
13
18
|
expected = (columns + 1) * (rows + 1)
|
|
14
19
|
raise InputError, "expected #{expected} vertex heights, got #{heights.length}" unless heights.length == expected
|
|
20
|
+
raise InputError, "base height must be finite" unless base_height.is_a?(Numeric) && base_height.finite?
|
|
21
|
+
raise InputError, "vertex heights must not be below the base" if heights.any? { |height| height < base_height }
|
|
15
22
|
end
|
|
16
23
|
|
|
17
24
|
def each_triangle(&block)
|
|
@@ -40,7 +47,8 @@ module Lithop
|
|
|
40
47
|
yield [top_left, bottom_right, top_right]
|
|
41
48
|
return if @optimize_bottom
|
|
42
49
|
|
|
43
|
-
quad([x0, y1,
|
|
50
|
+
quad([x0, y1, @base_height], [x1, y1, @base_height],
|
|
51
|
+
[x1, y0, @base_height], [x0, y0, @base_height], &)
|
|
44
52
|
end
|
|
45
53
|
|
|
46
54
|
def emit_perimeter(&)
|
|
@@ -58,8 +66,8 @@ module Lithop
|
|
|
58
66
|
y0 = @y.last - @y.fetch(row + 1)
|
|
59
67
|
y1 = @y.last - @y.fetch(row)
|
|
60
68
|
quad(
|
|
61
|
-
[0.0, y0,
|
|
62
|
-
[0.0, y1, height(row, 0)], [0.0, y1,
|
|
69
|
+
[0.0, y0, @base_height], [0.0, y0, height(row + 1, 0)],
|
|
70
|
+
[0.0, y1, height(row, 0)], [0.0, y1, @base_height], &
|
|
63
71
|
)
|
|
64
72
|
end
|
|
65
73
|
|
|
@@ -68,7 +76,7 @@ module Lithop
|
|
|
68
76
|
y0 = @y.last - @y.fetch(row + 1)
|
|
69
77
|
y1 = @y.last - @y.fetch(row)
|
|
70
78
|
quad(
|
|
71
|
-
[x, y0,
|
|
79
|
+
[x, y0, @base_height], [x, y1, @base_height],
|
|
72
80
|
[x, y1, height(row, @columns)], [x, y0, height(row + 1, @columns)], &
|
|
73
81
|
)
|
|
74
82
|
end
|
|
@@ -77,7 +85,7 @@ module Lithop
|
|
|
77
85
|
x0 = @x.fetch(column)
|
|
78
86
|
x1 = @x.fetch(column + 1)
|
|
79
87
|
quad(
|
|
80
|
-
[x0, 0.0,
|
|
88
|
+
[x0, 0.0, @base_height], [x1, 0.0, @base_height],
|
|
81
89
|
[x1, 0.0, height(@rows, column + 1)], [x0, 0.0, height(@rows, column)], &
|
|
82
90
|
)
|
|
83
91
|
end
|
|
@@ -87,18 +95,18 @@ module Lithop
|
|
|
87
95
|
x1 = @x.fetch(column + 1)
|
|
88
96
|
y = @y.last
|
|
89
97
|
quad(
|
|
90
|
-
[x0, y,
|
|
91
|
-
[x1, y, height(0, column + 1)], [x1, y,
|
|
98
|
+
[x0, y, @base_height], [x0, y, height(0, column)],
|
|
99
|
+
[x1, y, height(0, column + 1)], [x1, y, @base_height], &
|
|
92
100
|
)
|
|
93
101
|
end
|
|
94
102
|
|
|
95
103
|
def emit_bottom_fan
|
|
96
|
-
center = [@x.last.fdiv(2), @y.last.fdiv(2),
|
|
104
|
+
center = [@x.last.fdiv(2), @y.last.fdiv(2), @base_height]
|
|
97
105
|
rendered_y = @y.reverse.map { |y| @y.last - y }
|
|
98
|
-
perimeter = rendered_y.map { |y| [0.0, y,
|
|
99
|
-
@x.drop(1).each { |x| perimeter << [x, @y.last,
|
|
100
|
-
rendered_y.reverse.drop(1).each { |y| perimeter << [@x.last, y,
|
|
101
|
-
@x.reverse.drop(1).first(@x.length - 2).each { |x| perimeter << [x, 0.0,
|
|
106
|
+
perimeter = rendered_y.map { |y| [0.0, y, @base_height] }
|
|
107
|
+
@x.drop(1).each { |x| perimeter << [x, @y.last, @base_height] }
|
|
108
|
+
rendered_y.reverse.drop(1).each { |y| perimeter << [@x.last, y, @base_height] }
|
|
109
|
+
@x.reverse.drop(1).first(@x.length - 2).each { |x| perimeter << [x, 0.0, @base_height] }
|
|
102
110
|
perimeter.each_with_index do |from, index|
|
|
103
111
|
yield [center, from, perimeter.fetch((index + 1) % perimeter.length)]
|
|
104
112
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Restricts a material model to one contiguous Z-layer range while preserving global Z coordinates.
|
|
5
|
+
class LayerRegionModel
|
|
6
|
+
Cell = Data.define(:materials)
|
|
7
|
+
|
|
8
|
+
attr_reader :columns, :rows, :x_boundaries, :y_boundaries, :z_boundaries, :cells, :layer_range
|
|
9
|
+
|
|
10
|
+
def initialize(model:, layer_range:)
|
|
11
|
+
@columns = model.columns
|
|
12
|
+
@rows = model.rows
|
|
13
|
+
@x_boundaries = model.x_boundaries
|
|
14
|
+
@y_boundaries = model.y_boundaries
|
|
15
|
+
@layer_range = layer_range
|
|
16
|
+
@z_boundaries = model.z_boundaries[layer_range.begin..layer_range.end].freeze
|
|
17
|
+
@cells = model.cells.map do |cell|
|
|
18
|
+
Cell.new(materials: cell.materials.slice(layer_range).to_a.freeze)
|
|
19
|
+
end.freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def materials = @materials ||= cells.flat_map(&:materials).uniq.freeze
|
|
23
|
+
end
|
|
24
|
+
end
|