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
|
@@ -5,11 +5,13 @@ module Lithop
|
|
|
5
5
|
# rubocop:disable-next Metrics/ClassLength
|
|
6
6
|
class ImagePipeline
|
|
7
7
|
REFERENCE_WHITE_MAXIMUM_DENSITY = 0.04
|
|
8
|
-
REFERENCE_WHITE_MAXIMUM_CHROMA = 0.08
|
|
9
8
|
REFERENCE_DARK_MAXIMUM_VALUE = 0.75
|
|
10
9
|
REFERENCE_NEUTRAL_MAXIMUM_CHROMA = 0.25
|
|
11
10
|
REFERENCE_BROWN_LINE_CMY_COVERAGE = 1.0 / 7
|
|
12
|
-
Result = Data.define(
|
|
11
|
+
Result = Data.define(
|
|
12
|
+
:width, :height, :cmys, :cmy_densities, :white_densities, :color_cells, :corrected_preview_png
|
|
13
|
+
)
|
|
14
|
+
LoadedImage = Data.define(:image, :material_display)
|
|
13
15
|
|
|
14
16
|
def initialize(settings)
|
|
15
17
|
@settings = settings
|
|
@@ -21,18 +23,14 @@ module Lithop
|
|
|
21
23
|
require "vips"
|
|
22
24
|
|
|
23
25
|
access = @settings.color_pixel_size ? :random : :sequential
|
|
24
|
-
|
|
25
|
-
image = image
|
|
26
|
-
|
|
27
|
-
image = image.flatten(background: [255, 255, 255]) if image.has_alpha?
|
|
28
|
-
image = to_srgb(image)
|
|
29
|
-
image = adjust_image(image)
|
|
30
|
-
sampled = @placement.call(image, @settings.sampling_grid).cast(:uchar)
|
|
26
|
+
loaded = normalized_image(path, access:)
|
|
27
|
+
image = loaded.image
|
|
28
|
+
sampled = @placement.call(image, @settings.sampling_grid).cast(:uchar).copy_memory
|
|
31
29
|
|
|
32
30
|
rgbs = rgb_components(sampled)
|
|
33
|
-
color_samples = rgbs.map { |rgb| color_sample(rgb) }
|
|
31
|
+
color_samples = rgbs.map { |rgb| color_sample(rgb, material_display: loaded.material_display) }
|
|
34
32
|
result_color_cells = if @settings.color_pixel_size
|
|
35
|
-
color_cells(image)
|
|
33
|
+
color_cells(image, material_display: loaded.material_display)
|
|
36
34
|
else
|
|
37
35
|
standard_color_cells(color_samples, @settings.columns, @settings.rows)
|
|
38
36
|
end
|
|
@@ -41,21 +39,68 @@ module Lithop
|
|
|
41
39
|
height: sampled.height,
|
|
42
40
|
cmys: color_samples.map(&:first),
|
|
43
41
|
cmy_densities: color_samples.map(&:last),
|
|
44
|
-
white_densities: rgbs.map { |rgb| white_density(rgb) },
|
|
45
|
-
color_cells: result_color_cells
|
|
42
|
+
white_densities: rgbs.map { |rgb| white_density(rgb, material_display: loaded.material_display) },
|
|
43
|
+
color_cells: result_color_cells,
|
|
44
|
+
corrected_preview_png: sampled.write_to_buffer(".png")
|
|
46
45
|
)
|
|
47
46
|
rescue SystemCallError, Vips::Error => e
|
|
48
47
|
raise InputError, "could not read image: #{e.message}"
|
|
49
48
|
end
|
|
50
49
|
|
|
50
|
+
# Returns the same normalized and placed image used by conversion, bounded
|
|
51
|
+
# to a browser-friendly sampling grid and without running CMYW or mesh work.
|
|
52
|
+
def preview_png(path, maximum_width: 960, maximum_height: 720)
|
|
53
|
+
require "vips"
|
|
54
|
+
|
|
55
|
+
grid = preview_grid(maximum_width:, maximum_height:)
|
|
56
|
+
loaded = normalized_image(path, access: :sequential, sampling_grids: [grid])
|
|
57
|
+
@placement.call(loaded.image, grid).cast(:uchar).write_to_buffer(".png")
|
|
58
|
+
rescue SystemCallError, Vips::Error => e
|
|
59
|
+
raise InputError, "could not read image: #{e.message}"
|
|
60
|
+
end
|
|
61
|
+
|
|
51
62
|
private
|
|
52
63
|
|
|
53
|
-
def
|
|
64
|
+
def normalized_image(path, access:, sampling_grids: nil)
|
|
65
|
+
loaded = load_image(path, access:, sampling_grids:)
|
|
66
|
+
image = loaded.image
|
|
67
|
+
image = image.autorot
|
|
68
|
+
validate_input!(image)
|
|
69
|
+
image = image.flatten(background: [255, 255, 255]) if image.has_alpha?
|
|
70
|
+
LoadedImage.new(image: adjust_image(to_srgb(image)), material_display: loaded.material_display)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def load_image(path, access:, sampling_grids: nil)
|
|
54
74
|
signature = File.binread(path, 8)
|
|
55
75
|
raster = signature.start_with?("\xFF\xD8\xFF".b) || signature.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
56
|
-
|
|
76
|
+
if raster
|
|
77
|
+
image = Vips::Image.new_from_file(path.to_s, access:)
|
|
78
|
+
return LoadedImage.new(image:, material_display: false)
|
|
79
|
+
end
|
|
57
80
|
|
|
58
|
-
@svg_loader.call(path, access:)
|
|
81
|
+
result = @svg_loader.call(path, access:, sampling_grids:)
|
|
82
|
+
LoadedImage.new(image: result.image, material_display: result.material_display)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def preview_grid(maximum_width:, maximum_height:)
|
|
86
|
+
maximum_width = Integer(maximum_width)
|
|
87
|
+
maximum_height = Integer(maximum_height)
|
|
88
|
+
raise InputError, "preview dimensions must be at least 2 pixels" if maximum_width < 2 || maximum_height < 2
|
|
89
|
+
|
|
90
|
+
pixel_size = [
|
|
91
|
+
@settings.width.fdiv(maximum_width - 1),
|
|
92
|
+
@settings.height.fdiv(maximum_height - 1),
|
|
93
|
+
0.01
|
|
94
|
+
].max
|
|
95
|
+
SamplingGrid.new(
|
|
96
|
+
width: @settings.width, height: @settings.height, pixel_size:,
|
|
97
|
+
margins: {
|
|
98
|
+
top: @settings.margin_top, right: @settings.margin_right,
|
|
99
|
+
bottom: @settings.margin_bottom, left: @settings.margin_left
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
rescue ArgumentError, TypeError
|
|
103
|
+
raise InputError, "preview dimensions must be integers"
|
|
59
104
|
end
|
|
60
105
|
|
|
61
106
|
def validate_input!(image)
|
|
@@ -89,12 +134,12 @@ module Lithop
|
|
|
89
134
|
[@settings.brightness, @settings.contrast, @settings.saturation]
|
|
90
135
|
end
|
|
91
136
|
|
|
92
|
-
def color_cells(image)
|
|
137
|
+
def color_cells(image, material_display:)
|
|
93
138
|
return if @settings.color_pixel_size.nil?
|
|
94
139
|
|
|
95
140
|
grid = @settings.color_sampling_grid
|
|
96
141
|
sampled = @placement.call(image, grid).cast(:uchar)
|
|
97
|
-
samples = rgb_components(sampled).map { |rgb| color_sample(rgb) }
|
|
142
|
+
samples = rgb_components(sampled).map { |rgb| color_sample(rgb, material_display:) }
|
|
98
143
|
coarse_cells = standard_color_cells(samples, grid.columns, grid.rows)
|
|
99
144
|
expand_color_cells(coarse_cells, grid)
|
|
100
145
|
end
|
|
@@ -114,7 +159,7 @@ module Lithop
|
|
|
114
159
|
|
|
115
160
|
density = samples.sum(&:last).fdiv(samples.length)
|
|
116
161
|
cmy = samples.map(&:first).transpose.map { |components| components.sum.fdiv(components.length) }
|
|
117
|
-
next [0.0, 0.0, 0.0] if standard_white?(density
|
|
162
|
+
next [0.0, 0.0, 0.0] if standard_white?(density)
|
|
118
163
|
|
|
119
164
|
cmy
|
|
120
165
|
end
|
|
@@ -151,16 +196,28 @@ module Lithop
|
|
|
151
196
|
end
|
|
152
197
|
end
|
|
153
198
|
|
|
154
|
-
def color_sample(rgb)
|
|
155
|
-
display_sample = FilamentProfile.display_sample(rgb)
|
|
156
|
-
return [display_sample.cmy, display_sample.density] if display_sample
|
|
199
|
+
def color_sample(rgb, material_display:)
|
|
200
|
+
display_sample = FilamentProfile.display_sample(rgb, mixtures: material_display)
|
|
201
|
+
return [mix_yellow(display_sample.cmy), display_sample.density] if display_sample
|
|
157
202
|
|
|
158
203
|
maximum = rgb.max
|
|
159
|
-
cmy = standard_cmy(rgb, maximum)
|
|
204
|
+
cmy = mix_yellow(standard_cmy(rgb, maximum))
|
|
160
205
|
density = rgb.sum { |component| 1.0 - component }.fdiv(3)
|
|
161
206
|
[cmy, density]
|
|
162
207
|
end
|
|
163
208
|
|
|
209
|
+
def mix_yellow(cmy)
|
|
210
|
+
ratio = @settings.yellow_mix_percent.fdiv(100)
|
|
211
|
+
return cmy if ratio.zero? || cmy.fetch(2).zero?
|
|
212
|
+
|
|
213
|
+
mixed = cmy.dup
|
|
214
|
+
replacement = mixed.fetch(2) * ratio
|
|
215
|
+
mixed[2] -= replacement
|
|
216
|
+
index = @settings.yellow_mix_color == "cyan" ? 0 : 1
|
|
217
|
+
mixed[index] = [mixed.fetch(index) + replacement, 1.0].min
|
|
218
|
+
mixed
|
|
219
|
+
end
|
|
220
|
+
|
|
164
221
|
def standard_cmy(rgb, maximum)
|
|
165
222
|
chroma = rgb.map { |component| maximum - component }
|
|
166
223
|
return chroma unless maximum <= REFERENCE_DARK_MAXIMUM_VALUE &&
|
|
@@ -173,20 +230,16 @@ module Lithop
|
|
|
173
230
|
cmy.all? { |component| (component - REFERENCE_BROWN_LINE_CMY_COVERAGE).abs < 1e-12 }
|
|
174
231
|
end
|
|
175
232
|
|
|
176
|
-
def standard_white?(density
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
pale_yellow = cmy.fetch(2) == cmy.max && cmy.fetch(2) > REFERENCE_WHITE_MAXIMUM_CHROMA &&
|
|
180
|
-
cmy.fetch(0) <= 0.05
|
|
181
|
-
!pale_yellow
|
|
233
|
+
def standard_white?(density)
|
|
234
|
+
@settings.suppress_pale_colors && density <= REFERENCE_WHITE_MAXIMUM_DENSITY
|
|
182
235
|
end
|
|
183
236
|
|
|
184
237
|
def rec601_density(rgb)
|
|
185
238
|
1.0 - rgb.zip([0.299, 0.587, 0.114]).sum { |component, weight| component * weight }
|
|
186
239
|
end
|
|
187
240
|
|
|
188
|
-
def white_density(rgb)
|
|
189
|
-
FilamentProfile.display_sample(rgb)&.density || rec601_density(rgb)
|
|
241
|
+
def white_density(rgb, material_display:)
|
|
242
|
+
FilamentProfile.display_sample(rgb, mixtures: material_display)&.density || rec601_density(rgb)
|
|
190
243
|
end
|
|
191
244
|
end
|
|
192
245
|
end
|
|
@@ -6,6 +6,8 @@ require "rexml/xpath"
|
|
|
6
6
|
module Lithop
|
|
7
7
|
# Applies the self-contained, static subset accepted for SVG input.
|
|
8
8
|
class SVGDocumentValidator
|
|
9
|
+
MATERIAL_DISPLAY_ATTRIBUTE = "data-lithop-color-encoding"
|
|
10
|
+
MATERIAL_DISPLAY_VALUE = "material-display"
|
|
9
11
|
FORBIDDEN_ELEMENTS = %w[script foreignobject animate animatemotion animatetransform set].freeze
|
|
10
12
|
LENGTH = /\A\+?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?(?:px|mm|in)?\z/i
|
|
11
13
|
NUMBER = /[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/
|
|
@@ -15,6 +17,7 @@ module Lithop
|
|
|
15
17
|
validate_declarations!(data)
|
|
16
18
|
document = parse(data)
|
|
17
19
|
validate_document!(document)
|
|
20
|
+
document
|
|
18
21
|
rescue REXML::ParseException => e
|
|
19
22
|
raise InputError, "invalid SVG XML: #{e.message.lines.first.to_s.strip}"
|
|
20
23
|
end
|
|
@@ -133,6 +136,7 @@ module Lithop
|
|
|
133
136
|
|
|
134
137
|
# Rasterizes a validated SVG at the resolution required by the sampling grids.
|
|
135
138
|
class SVGLoader
|
|
139
|
+
Result = Data.define(:image, :material_display)
|
|
136
140
|
BASE_DPI = 96.0
|
|
137
141
|
MAXIMUM_FILE_BYTES = 15_000_000
|
|
138
142
|
|
|
@@ -141,23 +145,28 @@ module Lithop
|
|
|
141
145
|
@validator = SVGDocumentValidator.new
|
|
142
146
|
end
|
|
143
147
|
|
|
144
|
-
def call(path, access:)
|
|
148
|
+
def call(path, access:, sampling_grids: nil)
|
|
145
149
|
data = read(path)
|
|
146
|
-
@validator.call(data)
|
|
150
|
+
document = @validator.call(data)
|
|
147
151
|
ensure_loader!
|
|
148
152
|
|
|
149
153
|
probe = render(data, scale: 1.0, access: :sequential)
|
|
150
154
|
validate_rendered_image!(probe, pixels: false)
|
|
151
|
-
scale = render_scale(probe)
|
|
155
|
+
scale = render_scale(probe, sampling_grids || default_sampling_grids)
|
|
152
156
|
validate_planned_pixels!(probe, scale)
|
|
153
157
|
|
|
154
158
|
image = render(data, scale:, access:)
|
|
155
159
|
validate_rendered_image!(image)
|
|
156
|
-
image
|
|
160
|
+
Result.new(image:, material_display: material_display?(document))
|
|
157
161
|
end
|
|
158
162
|
|
|
159
163
|
private
|
|
160
164
|
|
|
165
|
+
def material_display?(document)
|
|
166
|
+
document.root.attributes[SVGDocumentValidator::MATERIAL_DISPLAY_ATTRIBUTE] ==
|
|
167
|
+
SVGDocumentValidator::MATERIAL_DISPLAY_VALUE
|
|
168
|
+
end
|
|
169
|
+
|
|
161
170
|
def read(path)
|
|
162
171
|
raise InputError, "SVG input exceeds #{MAXIMUM_FILE_BYTES} bytes" if File.size(path) > MAXIMUM_FILE_BYTES
|
|
163
172
|
|
|
@@ -180,8 +189,8 @@ module Lithop
|
|
|
180
189
|
raise InputError, "could not render SVG: #{e.message}"
|
|
181
190
|
end
|
|
182
191
|
|
|
183
|
-
def render_scale(image)
|
|
184
|
-
scales =
|
|
192
|
+
def render_scale(image, grids)
|
|
193
|
+
scales = grids.map do |grid|
|
|
185
194
|
width, height = grid.content_sample_size
|
|
186
195
|
ratios = [width.fdiv(image.width), height.fdiv(image.height)]
|
|
187
196
|
@settings.image_fit == "contain" ? ratios.min * @settings.crop_scale : ratios.max / @settings.crop_scale
|
|
@@ -189,7 +198,7 @@ module Lithop
|
|
|
189
198
|
[*scales, 1.0 / image.width, 1.0 / image.height].max
|
|
190
199
|
end
|
|
191
200
|
|
|
192
|
-
def
|
|
201
|
+
def default_sampling_grids
|
|
193
202
|
grids = [@settings.sampling_grid]
|
|
194
203
|
grids << @settings.color_sampling_grid if @settings.color_pixel_size
|
|
195
204
|
grids
|
data/lib/lithop/job_execution.rb
CHANGED
|
@@ -27,8 +27,10 @@ module Lithop
|
|
|
27
27
|
@jobs_dir = File.join(@build_dir, ".jobs")
|
|
28
28
|
@status_path = File.join(@jobs_dir, "#{job_id}.json")
|
|
29
29
|
@cancel_path = File.join(@jobs_dir, "#{job_id}.cancel")
|
|
30
|
+
@transition_lock_path = File.join(@jobs_dir, "#{job_id}.transition.lock")
|
|
30
31
|
@lock_path = File.join(@build_dir, ".lithop.lock")
|
|
31
32
|
@lock = nil
|
|
33
|
+
@transition_lock = nil
|
|
32
34
|
@record = base_record
|
|
33
35
|
end
|
|
34
36
|
|
|
@@ -40,7 +42,7 @@ module Lithop
|
|
|
40
42
|
raise_if_cancelled!
|
|
41
43
|
transition("running", phase: "starting", percent: 0, message: "Conversion started", started_at: timestamp)
|
|
42
44
|
result = yield self
|
|
43
|
-
raise_if_cancelled!
|
|
45
|
+
raise_if_cancelled! unless @publication_committed
|
|
44
46
|
complete(result)
|
|
45
47
|
result
|
|
46
48
|
rescue JobCancelled, Interrupt => e
|
|
@@ -53,6 +55,7 @@ module Lithop
|
|
|
53
55
|
raise
|
|
54
56
|
ensure
|
|
55
57
|
release_lock
|
|
58
|
+
release_transition_lock
|
|
56
59
|
remove_cancel_request if prepared?
|
|
57
60
|
end
|
|
58
61
|
|
|
@@ -62,21 +65,95 @@ module Lithop
|
|
|
62
65
|
raise_if_cancelled!
|
|
63
66
|
end
|
|
64
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
|
+
|
|
65
87
|
def self.request_cancel(project_dir:, job_id:, clock: Time)
|
|
66
88
|
validate_job_id!(job_id)
|
|
67
89
|
jobs_dir = File.join(File.expand_path(project_dir), "build", ".jobs")
|
|
68
90
|
FileUtils.mkdir_p(jobs_dir)
|
|
69
91
|
status_path = File.join(jobs_dir, "#{job_id}.json")
|
|
70
92
|
path = File.join(jobs_dir, "#{job_id}.cancel")
|
|
93
|
+
lock_path = File.join(jobs_dir, "#{job_id}.transition.lock")
|
|
71
94
|
requested_at = clock.now.utc.iso8601(6)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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)
|
|
75
131
|
end
|
|
76
132
|
|
|
77
133
|
class << self
|
|
78
134
|
private
|
|
79
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
|
+
|
|
80
157
|
def validate_job_id!(job_id)
|
|
81
158
|
return if job_id.is_a?(String) && job_id.match?(/\A[0-9A-Za-z][0-9A-Za-z._-]*\z/)
|
|
82
159
|
|
|
@@ -107,6 +184,39 @@ module Lithop
|
|
|
107
184
|
rescue JSON::ParserError, SystemCallError
|
|
108
185
|
nil
|
|
109
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
|
|
110
220
|
end
|
|
111
221
|
|
|
112
222
|
private
|
|
@@ -139,6 +249,24 @@ module Lithop
|
|
|
139
249
|
@lock = nil
|
|
140
250
|
end
|
|
141
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
|
+
|
|
142
270
|
def recover_temporary_directories
|
|
143
271
|
JobDirectory.recover(project_dir: @project_dir).each do |recovered|
|
|
144
272
|
mark_recovered_job(recovered)
|
|
@@ -64,15 +64,6 @@ module Lithop
|
|
|
64
64
|
def color_region_start = base_white_thickness
|
|
65
65
|
def color_region_end = color_end
|
|
66
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
|
|
75
|
-
|
|
76
67
|
def materials_for(recipe_layers, height)
|
|
77
68
|
top_white_layers = top_white_layer_count(height, pigment_layers: recipe_layers.length)
|
|
78
69
|
[*Array.new(base_white_layers, :white), *recipe_layers, *Array.new(top_white_layers, :white)]
|
|
@@ -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|
|
|
@@ -62,12 +62,12 @@ module Lithop
|
|
|
62
62
|
|
|
63
63
|
def default = BAMBU_LAB_PLA_CMYK
|
|
64
64
|
|
|
65
|
-
def display_sample(rgb)
|
|
66
|
-
cmy, density = MaterialDisplayMap.call(rgb)
|
|
65
|
+
def display_sample(rgb, mixtures: true)
|
|
66
|
+
cmy, density = MaterialDisplayMap.call(rgb, mixtures:)
|
|
67
67
|
DisplaySample.new(cmy:, density:) if cmy
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
def display_cmy(rgb) = display_sample(rgb)&.cmy
|
|
70
|
+
def display_cmy(rgb, mixtures: true) = display_sample(rgb, mixtures:)&.cmy
|
|
71
71
|
|
|
72
72
|
def validate!(profile)
|
|
73
73
|
channels = profile.materials.map(&:channel)
|
|
@@ -32,12 +32,13 @@ module Lithop
|
|
|
32
32
|
|
|
33
33
|
module_function
|
|
34
34
|
|
|
35
|
-
def call(rgb)
|
|
35
|
+
def call(rgb, mixtures: true)
|
|
36
36
|
channels = rgb.map { |component| (component * 255).round.clamp(0, 255) }
|
|
37
37
|
packed = channels.reduce(0) { |value, component| (value << 8) | component }
|
|
38
38
|
return [[0.0, 0.0, 0.0], 0.0] if packed == 0xFFFFFF
|
|
39
39
|
return [CMY_BLACK, CMY_BLACK_DENSITIES.fetch(packed)] if CMY_BLACK_DENSITIES.key?(packed)
|
|
40
40
|
return [COLOR_CMYS.fetch(packed), 1.0] if COLOR_CMYS.key?(packed)
|
|
41
|
+
return unless mixtures
|
|
41
42
|
|
|
42
43
|
match = closest_line(channels)
|
|
43
44
|
return unless match && match.first <= MAXIMUM_ERROR
|
|
@@ -17,6 +17,7 @@ module Lithop
|
|
|
17
17
|
pigment_order: settings.pigment_order
|
|
18
18
|
)
|
|
19
19
|
@settings = settings
|
|
20
|
+
@material_arrays = {}
|
|
20
21
|
validate_samples!(cmys, heights, cell_cmys)
|
|
21
22
|
@cells = build_cells(cmys, heights, cell_cmys).freeze
|
|
22
23
|
maximum_layers = cells.map { |cell| cell.materials.length }.max
|
|
@@ -31,7 +32,9 @@ module Lithop
|
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def materials
|
|
34
|
-
@materials ||= cells.
|
|
35
|
+
@materials ||= cells.each_with_object({}) do |cell, result|
|
|
36
|
+
cell.materials.each { |material| result[material] = true }
|
|
37
|
+
end.keys.freeze
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
def recipe_counts
|
|
@@ -63,12 +66,21 @@ module Lithop
|
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def build_cells(cmys, heights, cell_cmys)
|
|
66
|
-
Array.new(rows * columns)
|
|
69
|
+
cell_heights = Array.new(rows * columns)
|
|
70
|
+
cell_colors = Array.new(rows * columns) do |index|
|
|
67
71
|
row, column = index.divmod(columns)
|
|
68
72
|
cmy = cell_cmys ? cell_cmys.fetch(index) : average(corners(cmys, row, column))
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
cell_heights[index] = @settings.quantize_thickness(average(corners(heights, row, column)))
|
|
74
|
+
cmy
|
|
75
|
+
end
|
|
76
|
+
recipes = if @palette.respond_to?(:nearest_many)
|
|
77
|
+
@palette.nearest_many(cell_colors)
|
|
78
|
+
else
|
|
79
|
+
cell_colors.map { |cmy| @palette.nearest(cmy) }
|
|
80
|
+
end
|
|
81
|
+
cell_heights.zip(recipes).map do |height, recipe|
|
|
82
|
+
materials = @material_arrays[[recipe, height]] ||= materials_for(recipe, height).freeze
|
|
83
|
+
Cell.new(height:, recipe:, materials:)
|
|
72
84
|
end
|
|
73
85
|
end
|
|
74
86
|
|
data/lib/lithop/model/palette.rb
CHANGED
|
@@ -49,13 +49,20 @@ module Lithop
|
|
|
49
49
|
@layer_count = layer_count
|
|
50
50
|
@pigment_order = pigment_order.dup.freeze
|
|
51
51
|
@recipes = build_recipes.freeze
|
|
52
|
+
@native_coverages = recipes.flat_map(&:coverage_cmy).freeze
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def nearest(cmy)
|
|
55
56
|
validate_cmy!(cmy)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
nearest_validated(cmy)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def nearest_many(cmys)
|
|
61
|
+
cmys.each { |cmy| validate_cmy!(cmy) }
|
|
62
|
+
return cmys.map { |cmy| nearest_validated(cmy) } unless Native.enabled?
|
|
63
|
+
|
|
64
|
+
indices = Native.palette_indices(cmys.flatten, @native_coverages)
|
|
65
|
+
indices.map { |index| recipes.fetch(index) }
|
|
59
66
|
end
|
|
60
67
|
|
|
61
68
|
def display_color(recipe)
|
|
@@ -97,5 +104,16 @@ module Lithop
|
|
|
97
104
|
|
|
98
105
|
raise InputError, "CMY must contain three values between 0 and 1"
|
|
99
106
|
end
|
|
107
|
+
|
|
108
|
+
def squared_distance(coverage, target)
|
|
109
|
+
cyan = coverage[0] - target[0]
|
|
110
|
+
magenta = coverage[1] - target[1]
|
|
111
|
+
yellow = coverage[2] - target[2]
|
|
112
|
+
(cyan * cyan) + (magenta * magenta) + (yellow * yellow)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def nearest_validated(cmy)
|
|
116
|
+
recipes.min_by { |recipe| squared_distance(recipe.coverage_cmy, cmy) }
|
|
117
|
+
end
|
|
100
118
|
end
|
|
101
119
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Restricts a material model to an arbitrary contiguous Z range.
|
|
5
|
+
class ZRegionModel
|
|
6
|
+
Cell = Data.define(:materials)
|
|
7
|
+
|
|
8
|
+
attr_reader :columns, :rows, :x_boundaries, :y_boundaries, :z_boundaries, :cells, :z_range
|
|
9
|
+
|
|
10
|
+
def initialize(model:, z_range:)
|
|
11
|
+
@columns = model.columns
|
|
12
|
+
@rows = model.rows
|
|
13
|
+
@x_boundaries = model.x_boundaries
|
|
14
|
+
@y_boundaries = model.y_boundaries
|
|
15
|
+
@z_range = z_range
|
|
16
|
+
source_layers = overlapping_layers(model.z_boundaries)
|
|
17
|
+
@z_boundaries = clipped_boundaries(model.z_boundaries, source_layers).freeze
|
|
18
|
+
@cells = model.cells.map do |cell|
|
|
19
|
+
Cell.new(materials: source_layers.map { |layer| cell.materials.fetch(layer) }.freeze)
|
|
20
|
+
end.freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def materials = @materials ||= cells.flat_map(&:materials).uniq.freeze
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def overlapping_layers(boundaries)
|
|
28
|
+
layers = (0...(boundaries.length - 1)).select do |layer|
|
|
29
|
+
boundaries.fetch(layer) < z_range.end && boundaries.fetch(layer + 1) > z_range.begin
|
|
30
|
+
end
|
|
31
|
+
raise InputError, "Z region must overlap the material model" if layers.empty?
|
|
32
|
+
|
|
33
|
+
layers.freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clipped_boundaries(boundaries, layers)
|
|
37
|
+
inner = layers.filter_map do |layer|
|
|
38
|
+
boundary = boundaries.fetch(layer + 1)
|
|
39
|
+
boundary if boundary > z_range.begin && boundary < z_range.end
|
|
40
|
+
end
|
|
41
|
+
[z_range.begin, *inner, z_range.end]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|