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.
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 +34 -13
  6. data/RELEASING.md +24 -6
  7. data/docs/README.md +6 -3
  8. data/docs/artifacts.md +35 -16
  9. data/docs/bambu-studio.md +17 -11
  10. data/docs/calibration.md +4 -4
  11. data/docs/color-model.md +20 -18
  12. data/docs/command.md +53 -14
  13. data/docs/config.md +43 -31
  14. data/docs/getting-started.md +17 -8
  15. data/docs/images/cmyw-lithophane-preview.png +0 -0
  16. data/docs/local-ui.md +117 -0
  17. data/docs/tiling.md +20 -19
  18. data/docs/troubleshooting.md +12 -2
  19. data/ext/lithop_native/Cargo.toml +17 -0
  20. data/ext/lithop_native/build.rs +4 -0
  21. data/ext/lithop_native/extconf.rb +6 -0
  22. data/ext/lithop_native/src/column_mesh.rs +388 -0
  23. data/ext/lithop_native/src/lib.rs +37 -0
  24. data/ext/lithop_native/src/mesh.rs +187 -0
  25. data/ext/lithop_native/src/palette.rs +67 -0
  26. data/ext/lithop_native/src/xml.rs +134 -0
  27. data/lib/lithop/calibration/export.rb +2 -2
  28. data/lib/lithop/calibration/target.rb +5 -5
  29. data/lib/lithop/cli/image_adjustment_options.rb +33 -0
  30. data/lib/lithop/cli/image_options.rb +5 -18
  31. data/lib/lithop/cli/options.rb +17 -4
  32. data/lib/lithop/cli.rb +28 -0
  33. data/lib/lithop/export/color_metadata.rb +46 -23
  34. data/lib/lithop/export/generation.rb +38 -27
  35. data/lib/lithop/export/job.rb +2 -2
  36. data/lib/lithop/export/metadata.rb +8 -3
  37. data/lib/lithop/export/planning.rb +82 -45
  38. data/lib/lithop/export/previews.rb +25 -1
  39. data/lib/lithop/export/print_readme.rb +20 -4
  40. data/lib/lithop/formats/three_mf/bambu_metadata.rb +11 -8
  41. data/lib/lithop/formats/three_mf/mesh.rb +72 -3
  42. data/lib/lithop/formats/three_mf/model_writer.rb +21 -21
  43. data/lib/lithop/formats/three_mf/part_writer.rb +37 -0
  44. data/lib/lithop/formats/three_mf.rb +1 -1
  45. data/lib/lithop/image/pipeline.rb +84 -31
  46. data/lib/lithop/image/svg_loader.rb +16 -7
  47. data/lib/lithop/job_execution.rb +132 -4
  48. data/lib/lithop/layer_structure.rb +0 -9
  49. data/lib/lithop/mesh/column_geometry.rb +8 -0
  50. data/lib/lithop/mesh/column_topology.rb +2 -0
  51. data/lib/lithop/model/filament_profile.rb +3 -3
  52. data/lib/lithop/model/material_display_map.rb +2 -1
  53. data/lib/lithop/model/material_model.rb +17 -5
  54. data/lib/lithop/model/palette.rb +21 -3
  55. data/lib/lithop/model/z_region_model.rb +44 -0
  56. data/lib/lithop/native.rb +39 -0
  57. data/lib/lithop/settings.rb +147 -15
  58. data/lib/lithop/settings_file.rb +1 -0
  59. data/lib/lithop/tiling.rb +3 -2
  60. data/lib/lithop/version.rb +1 -1
  61. data/lib/lithop/web/app.rb +517 -0
  62. data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
  63. data/lib/lithop/web/job_launcher.rb +214 -0
  64. data/lib/lithop/web/job_store.rb +390 -0
  65. data/lib/lithop/web/preview_store.rb +182 -0
  66. data/lib/lithop/web/public/app.css +330 -0
  67. data/lib/lithop/web/public/app.js +1949 -0
  68. data/lib/lithop/web/server.rb +21 -0
  69. data/lib/lithop/web/settings_form.rb +67 -0
  70. data/lib/lithop/web/settings_library.rb +154 -0
  71. data/lib/lithop/web/views/index.erb +418 -0
  72. data/lib/lithop/web/worker.rb +27 -0
  73. data/lib/lithop.rb +3 -1
  74. data/sig/lithop.rbs +41 -0
  75. metadata +86 -3
  76. 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(:width, :height, :cmys, :cmy_densities, :white_densities, :color_cells)
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
- image = load_image(path, access:)
25
- image = image.autorot
26
- validate_input!(image)
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 load_image(path, access:)
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
- return Vips::Image.new_from_file(path.to_s, access:) if raster
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, cmy)
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, cmy)
177
- return false if density > REFERENCE_WHITE_MAXIMUM_DENSITY
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 = sampling_grids.map do |grid|
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 sampling_grids
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
@@ -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
- atomic_write(path, JSON.generate(job_id:, requested_at:))
73
- mark_cancelling(status_path, requested_at)
74
- path
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|
@@ -174,6 +174,8 @@ module Lithop
174
174
  roots.freeze
175
175
  end.freeze
176
176
 
177
+ attr_reader :group_ids, :group_count
178
+
177
179
  def initialize(model:, intervals:)
178
180
  @model = model
179
181
  @intervals = intervals
@@ -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.flat_map(&:materials).uniq.freeze
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) do |index|
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
- height = @settings.quantize_thickness(average(corners(heights, row, column)))
70
- recipe = @palette.nearest(cmy)
71
- Cell.new(height:, recipe:, materials: materials_for(recipe, height).freeze)
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
 
@@ -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
- recipes.min_by do |recipe|
57
- recipe.coverage_cmy.zip(cmy).sum { |coverage, target| (coverage - target)**2 }
58
- end
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