lithop 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/Cargo.lock +284 -0
  4. data/Cargo.toml +6 -0
  5. data/README.md +25 -7
  6. data/RELEASING.md +24 -6
  7. data/docs/README.md +3 -1
  8. data/docs/artifacts.md +9 -2
  9. data/docs/bambu-studio.md +5 -1
  10. data/docs/command.md +25 -2
  11. data/docs/config.md +1 -1
  12. data/docs/getting-started.md +8 -5
  13. data/docs/images/cmyw-lithophane-preview.png +0 -0
  14. data/docs/local-ui.md +114 -0
  15. data/docs/tiling.md +4 -2
  16. data/docs/troubleshooting.md +10 -0
  17. data/ext/lithop_native/Cargo.toml +17 -0
  18. data/ext/lithop_native/build.rs +4 -0
  19. data/ext/lithop_native/extconf.rb +6 -0
  20. data/ext/lithop_native/src/column_mesh.rs +388 -0
  21. data/ext/lithop_native/src/lib.rs +37 -0
  22. data/ext/lithop_native/src/mesh.rs +187 -0
  23. data/ext/lithop_native/src/palette.rs +67 -0
  24. data/ext/lithop_native/src/xml.rs +134 -0
  25. data/lib/lithop/calibration/export.rb +2 -2
  26. data/lib/lithop/cli/options.rb +13 -0
  27. data/lib/lithop/cli.rb +28 -0
  28. data/lib/lithop/export/generation.rb +30 -17
  29. data/lib/lithop/export/job.rb +2 -2
  30. data/lib/lithop/export/metadata.rb +8 -3
  31. data/lib/lithop/export/previews.rb +25 -1
  32. data/lib/lithop/formats/three_mf/mesh.rb +72 -3
  33. data/lib/lithop/formats/three_mf/model_writer.rb +19 -16
  34. data/lib/lithop/formats/three_mf/part_writer.rb +37 -0
  35. data/lib/lithop/formats/three_mf.rb +1 -1
  36. data/lib/lithop/image/pipeline.rb +50 -11
  37. data/lib/lithop/image/svg_loader.rb +5 -5
  38. data/lib/lithop/job_execution.rb +132 -4
  39. data/lib/lithop/mesh/column_geometry.rb +8 -0
  40. data/lib/lithop/mesh/column_topology.rb +2 -0
  41. data/lib/lithop/model/material_model.rb +17 -5
  42. data/lib/lithop/model/palette.rb +21 -3
  43. data/lib/lithop/native.rb +39 -0
  44. data/lib/lithop/tiling.rb +3 -2
  45. data/lib/lithop/version.rb +1 -1
  46. data/lib/lithop/web/app.rb +513 -0
  47. data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
  48. data/lib/lithop/web/job_launcher.rb +214 -0
  49. data/lib/lithop/web/job_store.rb +390 -0
  50. data/lib/lithop/web/preview_store.rb +182 -0
  51. data/lib/lithop/web/public/app.css +319 -0
  52. data/lib/lithop/web/public/app.js +1847 -0
  53. data/lib/lithop/web/server.rb +21 -0
  54. data/lib/lithop/web/settings_form.rb +64 -0
  55. data/lib/lithop/web/settings_library.rb +154 -0
  56. data/lib/lithop/web/views/index.erb +364 -0
  57. data/lib/lithop/web/worker.rb +27 -0
  58. data/lib/lithop.rb +1 -0
  59. data/sig/lithop.rbs +25 -0
  60. metadata +84 -2
@@ -3,11 +3,15 @@
3
3
  module Lithop
4
4
  # Indexes vertices and triangles for a 3MF mesh resource.
5
5
  class ThreeMFMesh
6
- attr_reader :vertices, :vertex_indices, :triangle_count
6
+ NATIVE_BATCH_SIZE = 8_192
7
+ MAXIMUM_NATIVE_TOPOLOGY_KEY = (2**63) - 1
8
+ class UnsupportedNativeTopology < StandardError; end
9
+
10
+ attr_reader :vertices, :vertex_indices, :triangle_count, :native_mesh
7
11
 
8
12
  def initialize(triangles:)
9
13
  @triangles = triangles
10
- index_resources
14
+ Native.enabled? ? index_native_resources : index_ruby_resources
11
15
  end
12
16
 
13
17
  def each_indexed_triangle
@@ -18,9 +22,74 @@ module Lithop
18
22
  end
19
23
  end
20
24
 
25
+ def native_indexed? = !native_mesh.nil?
26
+
27
+ def vertex_count = native_indexed? ? native_mesh.vertex_count : vertices.length
28
+
29
+ def center
30
+ return native_mesh.center if native_indexed?
31
+
32
+ axes = vertices.transpose
33
+ axes.map { |values| (values.min + values.max) / 2.0 }
34
+ end
35
+
21
36
  private
22
37
 
23
- def index_resources
38
+ def index_native_resources
39
+ if @triangles.respond_to?(:native_indexed_mesh)
40
+ @native_mesh = @triangles.native_indexed_mesh
41
+ raise InputError, "3MF mesh contains no triangles" if native_mesh.vertex_count.zero?
42
+
43
+ @triangle_count = native_mesh.triangle_count
44
+ @vertices = nil
45
+ @vertex_indices = nil
46
+ return
47
+ end
48
+
49
+ @native_mesh = Native::MeshBuilder.new
50
+ coordinates = []
51
+ topology_keys = []
52
+ count = 0
53
+ @triangles.each do |triangle|
54
+ validate_triangle!(triangle)
55
+ triangle.each do |vertex|
56
+ coordinates.concat(coordinates(vertex))
57
+ topology_keys << native_topology_key(vertex)
58
+ end
59
+ count += 1
60
+ flush_native_batch(coordinates, topology_keys) if (count % NATIVE_BATCH_SIZE).zero?
61
+ end
62
+ flush_native_batch(coordinates, topology_keys)
63
+ raise InputError, "3MF mesh contains no triangles" if native_mesh.vertex_count.zero?
64
+
65
+ @triangle_count = native_mesh.triangle_count
66
+ @vertices = nil
67
+ @vertex_indices = nil
68
+ rescue UnsupportedNativeTopology
69
+ @native_mesh = nil
70
+ index_ruby_resources
71
+ end
72
+
73
+ def flush_native_batch(coordinates, topology_keys)
74
+ return if coordinates.empty?
75
+
76
+ native_mesh.add_batch(coordinates, topology_keys)
77
+ coordinates.clear
78
+ topology_keys.clear
79
+ end
80
+
81
+ def native_topology_key(vertex)
82
+ return -1 unless vertex.respond_to?(:topology)
83
+
84
+ topology = vertex.topology
85
+ unless topology.is_a?(Integer) && topology.between?(0, MAXIMUM_NATIVE_TOPOLOGY_KEY)
86
+ raise UnsupportedNativeTopology
87
+ end
88
+
89
+ topology
90
+ end
91
+
92
+ def index_ruby_resources
24
93
  @vertices = []
25
94
  @vertex_indices = {}
26
95
  @triangle_count = 0
@@ -78,7 +78,7 @@ module Lithop
78
78
  ThreeMFPartWriter.new(archive: @archive, profile: @profile).write(object_id, part, mesh)
79
79
  end
80
80
  part.triangles.release_cache! if part.triangles.respond_to?(:release_cache!)
81
- vertex_count += mesh.vertices.length
81
+ vertex_count += mesh.vertex_count
82
82
  triangle_counts[part.material] = triangle_counts.fetch(part.material, 0) + mesh.triangle_count
83
83
  end
84
84
  Result.new(
@@ -96,7 +96,7 @@ module Lithop
96
96
  result
97
97
  end
98
98
 
99
- # rubocop:disable-next Metrics/AbcSize
99
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
100
100
  def write_external_parts(object_ids, assemblies)
101
101
  parts_by_object_id = object_ids.zip(@parts).to_h
102
102
  vertex_count = 0
@@ -106,22 +106,26 @@ module Lithop
106
106
  measure_xml do
107
107
  @archive.write(submodel_header)
108
108
  write_materials
109
- assembly.object_ids.each do |object_id|
110
- part = parts_by_object_id.fetch(object_id)
111
- mesh = @instrumentation.measure(:three_mf_indexing) do
112
- ThreeMFMesh.new(triangles: part.triangles)
113
- end
114
- center = mesh_center(mesh)
115
- assembly.component_origins[object_id] = component_origin(
116
- center, assembly.component_origins.fetch(object_id)
117
- )
109
+ end
110
+ assembly.object_ids.each do |object_id|
111
+ part = parts_by_object_id.fetch(object_id)
112
+ mesh = @instrumentation.measure(:three_mf_indexing) do
113
+ ThreeMFMesh.new(triangles: part.triangles)
114
+ end
115
+ center = mesh_center(mesh)
116
+ assembly.component_origins[object_id] = component_origin(
117
+ center, assembly.component_origins.fetch(object_id)
118
+ )
119
+ measure_xml do
118
120
  ThreeMFPartWriter.new(
119
121
  archive: @archive, profile: @profile, production: true, origin: center
120
122
  ).write(object_id, part, mesh)
121
- part.triangles.release_cache! if part.triangles.respond_to?(:release_cache!)
122
- vertex_count += mesh.vertices.length
123
- triangle_counts[part.material] = triangle_counts.fetch(part.material, 0) + mesh.triangle_count
124
123
  end
124
+ part.triangles.release_cache! if part.triangles.respond_to?(:release_cache!)
125
+ vertex_count += mesh.vertex_count
126
+ triangle_counts[part.material] = triangle_counts.fetch(part.material, 0) + mesh.triangle_count
127
+ end
128
+ measure_xml do
125
129
  @archive.write(" </resources>\n <build/>\n</model>\n")
126
130
  end
127
131
  end
@@ -252,8 +256,7 @@ module Lithop
252
256
  end
253
257
 
254
258
  def mesh_center(mesh)
255
- axes = mesh.vertices.transpose
256
- axes.map { |values| (values.min + values.max) / 2.0 }
259
+ mesh.center
257
260
  end
258
261
 
259
262
  def component_origin(center, base_origin)
@@ -3,6 +3,8 @@
3
3
  module Lithop
4
4
  # Serializes one indexed material mesh into a 3MF model document.
5
5
  class ThreeMFPartWriter
6
+ XML_BATCH_SIZE = 8_192
7
+
6
8
  def initialize(archive:, profile:, production: false, origin: nil)
7
9
  @archive = archive
8
10
  @profile = profile
@@ -31,6 +33,9 @@ module Lithop
31
33
  private
32
34
 
33
35
  def write_vertices(mesh)
36
+ return write_indexed_native_vertices(mesh) if mesh.native_indexed?
37
+ return write_native_vertices(mesh) if Native.enabled?
38
+
34
39
  mesh.vertices.each do |x, y, z|
35
40
  @archive.write(
36
41
  " <vertex x=\"#{number(x - @origin[0])}\" y=\"#{number(y - @origin[1])}\" " \
@@ -40,6 +45,9 @@ module Lithop
40
45
  end
41
46
 
42
47
  def write_triangles(mesh)
48
+ return write_indexed_native_triangles(mesh) if mesh.native_indexed?
49
+ return write_native_triangles(mesh) if Native.enabled?
50
+
43
51
  count = 0
44
52
  mesh.each_indexed_triangle do |vertices|
45
53
  @archive.write(
@@ -52,6 +60,35 @@ module Lithop
52
60
  raise InputError, "3MF triangle enumerables must be repeatable"
53
61
  end
54
62
 
63
+ def write_native_vertices(mesh)
64
+ mesh.vertices.each_slice(XML_BATCH_SIZE) do |vertices|
65
+ @archive.write(Native.vertex_xml(vertices.flatten, @origin))
66
+ end
67
+ end
68
+
69
+ def write_indexed_native_vertices(mesh)
70
+ 0.step(mesh.vertex_count - 1, XML_BATCH_SIZE) do |offset|
71
+ @archive.write(mesh.native_mesh.vertex_xml(offset, XML_BATCH_SIZE, @origin))
72
+ end
73
+ end
74
+
75
+ def write_native_triangles(mesh)
76
+ count = 0
77
+ mesh.each_indexed_triangle.each_slice(XML_BATCH_SIZE) do |triangles|
78
+ @archive.write(Native.triangle_xml(triangles.flatten))
79
+ count += triangles.length
80
+ end
81
+ return if count == mesh.triangle_count
82
+
83
+ raise InputError, "3MF triangle enumerables must be repeatable"
84
+ end
85
+
86
+ def write_indexed_native_triangles(mesh)
87
+ 0.step(mesh.triangle_count - 1, XML_BATCH_SIZE) do |offset|
88
+ @archive.write(mesh.native_mesh.triangle_xml(offset, XML_BATCH_SIZE))
89
+ end
90
+ end
91
+
55
92
  def number(value) = format("%.9g", value)
56
93
  end
57
94
  end
@@ -18,7 +18,7 @@ module Lithop
18
18
  MODEL_RELATIONSHIP = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"
19
19
  MODEL_CONTENT_TYPE = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"
20
20
  ZIP_MUTEX = Mutex.new
21
- # Triangles are indexed and then serialized, so their enumerable must be replayable.
21
+ # The Rust indexer consumes triangles once; the Ruby comparison path requires replayable input.
22
22
  Part = Data.define(:material, :triangles, :name, :region, :build_z_offset) do
23
23
  def initialize(material:, triangles:, name: nil, region: nil, build_z_offset: 0.0)
24
24
  super
@@ -9,7 +9,9 @@ module Lithop
9
9
  REFERENCE_DARK_MAXIMUM_VALUE = 0.75
10
10
  REFERENCE_NEUTRAL_MAXIMUM_CHROMA = 0.25
11
11
  REFERENCE_BROWN_LINE_CMY_COVERAGE = 1.0 / 7
12
- Result = Data.define(:width, :height, :cmys, :cmy_densities, :white_densities, :color_cells)
12
+ Result = Data.define(
13
+ :width, :height, :cmys, :cmy_densities, :white_densities, :color_cells, :corrected_preview_png
14
+ )
13
15
 
14
16
  def initialize(settings)
15
17
  @settings = settings
@@ -21,13 +23,8 @@ 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
+ image = normalized_image(path, access:)
27
+ sampled = @placement.call(image, @settings.sampling_grid).cast(:uchar).copy_memory
31
28
 
32
29
  rgbs = rgb_components(sampled)
33
30
  color_samples = rgbs.map { |rgb| color_sample(rgb) }
@@ -42,20 +39,62 @@ module Lithop
42
39
  cmys: color_samples.map(&:first),
43
40
  cmy_densities: color_samples.map(&:last),
44
41
  white_densities: rgbs.map { |rgb| white_density(rgb) },
45
- color_cells: result_color_cells
42
+ color_cells: result_color_cells,
43
+ corrected_preview_png: sampled.write_to_buffer(".png")
46
44
  )
47
45
  rescue SystemCallError, Vips::Error => e
48
46
  raise InputError, "could not read image: #{e.message}"
49
47
  end
50
48
 
49
+ # Returns the same normalized and placed image used by conversion, bounded
50
+ # to a browser-friendly sampling grid and without running CMYW or mesh work.
51
+ def preview_png(path, maximum_width: 960, maximum_height: 720)
52
+ require "vips"
53
+
54
+ grid = preview_grid(maximum_width:, maximum_height:)
55
+ image = normalized_image(path, access: :sequential, sampling_grids: [grid])
56
+ @placement.call(image, grid).cast(:uchar).write_to_buffer(".png")
57
+ rescue SystemCallError, Vips::Error => e
58
+ raise InputError, "could not read image: #{e.message}"
59
+ end
60
+
51
61
  private
52
62
 
53
- def load_image(path, access:)
63
+ def normalized_image(path, access:, sampling_grids: nil)
64
+ image = load_image(path, access:, sampling_grids:)
65
+ image = image.autorot
66
+ validate_input!(image)
67
+ image = image.flatten(background: [255, 255, 255]) if image.has_alpha?
68
+ adjust_image(to_srgb(image))
69
+ end
70
+
71
+ def load_image(path, access:, sampling_grids: nil)
54
72
  signature = File.binread(path, 8)
55
73
  raster = signature.start_with?("\xFF\xD8\xFF".b) || signature.start_with?("\x89PNG\r\n\x1A\n".b)
56
74
  return Vips::Image.new_from_file(path.to_s, access:) if raster
57
75
 
58
- @svg_loader.call(path, access:)
76
+ @svg_loader.call(path, access:, sampling_grids:)
77
+ end
78
+
79
+ def preview_grid(maximum_width:, maximum_height:)
80
+ maximum_width = Integer(maximum_width)
81
+ maximum_height = Integer(maximum_height)
82
+ raise InputError, "preview dimensions must be at least 2 pixels" if maximum_width < 2 || maximum_height < 2
83
+
84
+ pixel_size = [
85
+ @settings.width.fdiv(maximum_width - 1),
86
+ @settings.height.fdiv(maximum_height - 1),
87
+ 0.01
88
+ ].max
89
+ SamplingGrid.new(
90
+ width: @settings.width, height: @settings.height, pixel_size:,
91
+ margins: {
92
+ top: @settings.margin_top, right: @settings.margin_right,
93
+ bottom: @settings.margin_bottom, left: @settings.margin_left
94
+ }
95
+ )
96
+ rescue ArgumentError, TypeError
97
+ raise InputError, "preview dimensions must be integers"
59
98
  end
60
99
 
61
100
  def validate_input!(image)
@@ -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 = sampling_grids.map do |grid|
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 sampling_grids
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
@@ -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)
@@ -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
@@ -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