lithop 0.1.0 → 0.2.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/README.md +9 -3
  4. data/RELEASING.md +3 -3
  5. data/docs/README.md +1 -0
  6. data/docs/artifacts.md +19 -2
  7. data/docs/bambu-studio.md +2 -0
  8. data/docs/color-model.md +2 -0
  9. data/docs/command.md +23 -5
  10. data/docs/config.md +30 -3
  11. data/docs/getting-started.md +5 -3
  12. data/docs/tiling.md +132 -0
  13. data/docs/troubleshooting.md +12 -0
  14. data/lib/lithop/calibration/export.rb +21 -8
  15. data/lib/lithop/cli/calibration.rb +3 -1
  16. data/lib/lithop/cli/image_options.rb +41 -2
  17. data/lib/lithop/cli.rb +8 -1
  18. data/lib/lithop/export/color_metadata.rb +55 -0
  19. data/lib/lithop/export/generation.rb +117 -20
  20. data/lib/lithop/export/job.rb +30 -16
  21. data/lib/lithop/export/metadata.rb +48 -3
  22. data/lib/lithop/export/planning.rb +97 -0
  23. data/lib/lithop/export/print_readme.rb +22 -4
  24. data/lib/lithop/export/result.rb +26 -4
  25. data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
  26. data/lib/lithop/formats/three_mf/model_writer.rb +245 -23
  27. data/lib/lithop/formats/three_mf/part_writer.rb +14 -6
  28. data/lib/lithop/formats/three_mf.rb +8 -5
  29. data/lib/lithop/image/pipeline.rb +17 -0
  30. data/lib/lithop/job_directory.rb +41 -0
  31. data/lib/lithop/job_execution.rb +250 -0
  32. data/lib/lithop/layer_structure.rb +11 -0
  33. data/lib/lithop/mesh/box_geometry.rb +41 -0
  34. data/lib/lithop/mesh/column_geometry.rb +2 -2
  35. data/lib/lithop/mesh/geometry.rb +21 -13
  36. data/lib/lithop/model/layer_region_model.rb +24 -0
  37. data/lib/lithop/reference_three_mf.rb +17 -1
  38. data/lib/lithop/settings.rb +116 -1
  39. data/lib/lithop/settings_file.rb +10 -4
  40. data/lib/lithop/tiling.rb +126 -0
  41. data/lib/lithop/version.rb +1 -1
  42. data/lib/lithop.rb +4 -0
  43. data/sig/lithop.rbs +77 -2
  44. metadata +6 -1
@@ -19,7 +19,11 @@ module Lithop
19
19
  MODEL_CONTENT_TYPE = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"
20
20
  ZIP_MUTEX = Mutex.new
21
21
  # Triangles are indexed and then serialized, so their enumerable must be replayable.
22
- Part = Data.define(:material, :triangles)
22
+ Part = Data.define(:material, :triangles, :name, :region, :build_z_offset) do
23
+ def initialize(material:, triangles:, name: nil, region: nil, build_z_offset: 0.0)
24
+ super
25
+ end
26
+ end
23
27
  Result = Data.define(:vertex_count, :triangle_count, :material_count, :object_count,
24
28
  :triangle_counts_by_material)
25
29
 
@@ -66,13 +70,13 @@ module Lithop
66
70
  unless valid_materials.include?(part.material)
67
71
  raise InputError, "3MF part uses an unknown material: #{part.material.inspect}"
68
72
  end
73
+ unless part.build_z_offset.is_a?(Numeric) && part.build_z_offset.finite?
74
+ raise InputError, "3MF part build_z_offset must be a finite number"
75
+ end
69
76
 
70
77
  part
71
78
  end
72
79
  raise InputError, "3MF requires at least one material part" if validated.empty?
73
- if validated.map(&:material).uniq.length != validated.length
74
- raise InputError, "3MF material parts must have unique materials"
75
- end
76
80
 
77
81
  validated.freeze
78
82
  end
@@ -87,7 +91,6 @@ module Lithop
87
91
  @instrumentation.measure(:three_mf_xml_zip) do
88
92
  write_entry(archive, "[Content_Types].xml", content_types_xml)
89
93
  write_entry(archive, "_rels/.rels", relationships_xml)
90
- archive.put_next_entry("3D/3dmodel.model")
91
94
  end
92
95
  package = ThreeMFModelWriter.new(
93
96
  archive, @parts, @profile, instrumentation: @instrumentation, settings: @settings
@@ -26,6 +26,7 @@ module Lithop
26
26
  validate_input!(image)
27
27
  image = image.flatten(background: [255, 255, 255]) if image.has_alpha?
28
28
  image = to_srgb(image)
29
+ image = adjust_image(image)
29
30
  sampled = @placement.call(image, @settings.sampling_grid).cast(:uchar)
30
31
 
31
32
  rgbs = rgb_components(sampled)
@@ -72,6 +73,22 @@ module Lithop
72
73
  image.icc_import(embedded: true, pcs: :xyz).colourspace(:srgb)
73
74
  end
74
75
 
76
+ def adjust_image(image)
77
+ return image if image_adjustments.none?(&:nonzero?)
78
+
79
+ adjusted = image.cast(:float)
80
+ adjusted += @settings.brightness.fdiv(100) * 255
81
+ contrast_factor = 1.0 + @settings.contrast.fdiv(100)
82
+ adjusted = ((adjusted - 127.5) * contrast_factor) + 127.5
83
+ saturation_factor = 1.0 + @settings.saturation.fdiv(100)
84
+ luminance = (adjusted[0] * 0.299) + (adjusted[1] * 0.587) + (adjusted[2] * 0.114)
85
+ luminance + ((adjusted - luminance) * saturation_factor)
86
+ end
87
+
88
+ def image_adjustments
89
+ [@settings.brightness, @settings.contrast, @settings.saturation]
90
+ end
91
+
75
92
  def color_cells(image)
76
93
  return if @settings.color_pixel_size.nil?
77
94
 
@@ -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,250 @@
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
+ @lock_path = File.join(@build_dir, ".lithop.lock")
31
+ @lock = nil
32
+ @record = base_record
33
+ end
34
+
35
+ def call
36
+ prepare
37
+ transition("queued", phase: "queued", percent: 0, message: "Waiting to start")
38
+ acquire_lock
39
+ recover_temporary_directories
40
+ raise_if_cancelled!
41
+ transition("running", phase: "starting", percent: 0, message: "Conversion started", started_at: timestamp)
42
+ result = yield self
43
+ raise_if_cancelled!
44
+ complete(result)
45
+ result
46
+ rescue JobCancelled, Interrupt => e
47
+ record_terminal_state(
48
+ "cancelled", phase: "cancelled", message: "Conversion cancelled", error: e
49
+ )
50
+ raise
51
+ rescue StandardError => e
52
+ record_terminal_state("failed", phase: "failed", message: "Conversion failed", error: e)
53
+ raise
54
+ ensure
55
+ release_lock
56
+ remove_cancel_request if prepared?
57
+ end
58
+
59
+ def progress(phase:, percent:, message:)
60
+ raise_if_cancelled!
61
+ transition("running", phase: phase.to_s, percent: normalize_percent(percent), message: message.to_s)
62
+ raise_if_cancelled!
63
+ end
64
+
65
+ def self.request_cancel(project_dir:, job_id:, clock: Time)
66
+ validate_job_id!(job_id)
67
+ jobs_dir = File.join(File.expand_path(project_dir), "build", ".jobs")
68
+ FileUtils.mkdir_p(jobs_dir)
69
+ status_path = File.join(jobs_dir, "#{job_id}.json")
70
+ path = File.join(jobs_dir, "#{job_id}.cancel")
71
+ 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
75
+ end
76
+
77
+ class << self
78
+ private
79
+
80
+ def validate_job_id!(job_id)
81
+ return if job_id.is_a?(String) && job_id.match?(/\A[0-9A-Za-z][0-9A-Za-z._-]*\z/)
82
+
83
+ raise Error, "invalid job ID: #{job_id.inspect}"
84
+ end
85
+
86
+ def atomic_write(path, contents)
87
+ temporary = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
88
+ File.write(temporary, contents)
89
+ File.rename(temporary, path)
90
+ ensure
91
+ FileUtils.rm_f(temporary) if defined?(temporary)
92
+ end
93
+
94
+ def mark_cancelling(path, requested_at)
95
+ return unless File.file?(path)
96
+
97
+ record = JSON.parse(File.read(path))
98
+ return unless ACTIVE_STATES.include?(record["state"])
99
+
100
+ record.merge!(
101
+ "state" => "cancelling",
102
+ "phase" => "cancelling",
103
+ "message" => "Cancellation requested",
104
+ "updated_at" => requested_at
105
+ )
106
+ atomic_write(path, JSON.pretty_generate(record) << "\n")
107
+ rescue JSON::ParserError, SystemCallError
108
+ nil
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def validate_job_id!(value) = self.class.send(:validate_job_id!, value)
115
+
116
+ def prepare
117
+ FileUtils.mkdir_p(@jobs_dir)
118
+ raise Error, "job state already exists: #{@status_path}" if File.exist?(@status_path)
119
+
120
+ @prepared = true
121
+ end
122
+
123
+ def prepared? = @prepared
124
+
125
+ def acquire_lock
126
+ @lock = File.open(@lock_path, File::RDWR | File::CREAT, 0o600)
127
+ return if @lock.flock(File::LOCK_EX | File::LOCK_NB)
128
+
129
+ raise Error, "another conversion is already running in project: #{@project_dir}"
130
+ end
131
+
132
+ def release_lock
133
+ return unless @lock
134
+
135
+ @lock.flock(File::LOCK_UN)
136
+ @lock.close
137
+ @lock = nil
138
+ rescue IOError, SystemCallError
139
+ @lock = nil
140
+ end
141
+
142
+ def recover_temporary_directories
143
+ JobDirectory.recover(project_dir: @project_dir).each do |recovered|
144
+ mark_recovered_job(recovered)
145
+ end
146
+ end
147
+
148
+ def mark_recovered_job(recovered)
149
+ path = File.join(@jobs_dir, "#{recovered.job_id}.json")
150
+ return unless File.file?(path)
151
+
152
+ record = JSON.parse(File.read(path))
153
+ return unless ACTIVE_STATES.include?(record["state"])
154
+
155
+ now = timestamp
156
+ record.merge!(
157
+ "state" => "failed",
158
+ "phase" => "recovered",
159
+ "message" => "Abandoned temporary data recovered",
160
+ "updated_at" => now,
161
+ "finished_at" => now,
162
+ "error" => { "class" => "Lithop::Error", "message" => "conversion process ended before completion" }
163
+ )
164
+ self.class.send(:atomic_write, path, JSON.pretty_generate(record) << "\n")
165
+ rescue JSON::ParserError, SystemCallError
166
+ nil
167
+ end
168
+
169
+ def complete(result)
170
+ additions = { finished_at: timestamp }
171
+ additions[:output_path] = result.path if result.respond_to?(:path)
172
+ additions[:files] = result.files if result.respond_to?(:files)
173
+ transition("completed", phase: "completed", percent: 100, message: "Conversion completed", **additions)
174
+ end
175
+
176
+ def transition(state, phase:, percent:, message:, **additions)
177
+ now = timestamp
178
+ @record.merge!(
179
+ state:,
180
+ phase:,
181
+ percent: normalize_percent(percent),
182
+ message:,
183
+ updated_at: now,
184
+ **additions
185
+ )
186
+ self.class.send(:atomic_write, @status_path, JSON.pretty_generate(@record) << "\n")
187
+ notify
188
+ end
189
+
190
+ def notify
191
+ return unless @progress_callback
192
+
193
+ @progress_callback.call(
194
+ Progress.new(
195
+ job_id:,
196
+ state: @record.fetch(:state),
197
+ phase: @record.fetch(:phase),
198
+ percent: @record.fetch(:percent),
199
+ message: @record.fetch(:message),
200
+ updated_at: @record.fetch(:updated_at)
201
+ )
202
+ )
203
+ rescue StandardError
204
+ nil
205
+ end
206
+
207
+ def record_terminal_state(state, phase:, message:, error:)
208
+ return unless prepared?
209
+
210
+ transition(state, phase:, percent: current_percent, message:, finished_at: timestamp,
211
+ error: error_record(error))
212
+ rescue StandardError
213
+ nil
214
+ end
215
+
216
+ def raise_if_cancelled!
217
+ raise JobCancelled, "job #{job_id} was cancelled" if File.file?(@cancel_path)
218
+ end
219
+
220
+ def remove_cancel_request
221
+ FileUtils.rm_f(@cancel_path) if @cancel_path
222
+ rescue SystemCallError
223
+ nil
224
+ end
225
+
226
+ def base_record
227
+ now = timestamp
228
+ {
229
+ schema_version: SCHEMA_VERSION,
230
+ job_id:,
231
+ state: "queued",
232
+ phase: "queued",
233
+ percent: 0,
234
+ message: "Waiting to start",
235
+ created_at: now,
236
+ updated_at: now,
237
+ started_at: nil,
238
+ finished_at: nil,
239
+ output_path: nil,
240
+ files: nil,
241
+ error: nil
242
+ }
243
+ end
244
+
245
+ def error_record(error) = { class: error.class.name, message: error.message }
246
+ def timestamp = @clock.now.utc.iso8601(6)
247
+ def current_percent = @record.fetch(:percent, 0)
248
+ def normalize_percent(value) = Float(value).clamp(0.0, 100.0)
249
+ end
250
+ 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::PIGMENTS.include?(material)
11
- raise InputError, "column geometry requires a CMY pigment: #{material.inspect}"
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
@@ -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
- def initialize(heights:, columns:, rows:, x_boundaries:, y_boundaries:, optimize_bottom: false)
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, 0.0], [x1, y1, 0.0], [x1, y0, 0.0], [x0, y0, 0.0], &)
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, 0.0], [0.0, y0, height(row + 1, 0)],
62
- [0.0, y1, height(row, 0)], [0.0, y1, 0.0], &
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, 0.0], [x, y1, 0.0],
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, 0.0], [x1, 0.0, 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, 0.0], [x0, y, height(0, column)],
91
- [x1, y, height(0, column + 1)], [x1, y, 0.0], &
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), 0.0]
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, 0.0] }
99
- @x.drop(1).each { |x| perimeter << [x, @y.last, 0.0] }
100
- rendered_y.reverse.drop(1).each { |y| perimeter << [@x.last, y, 0.0] }
101
- @x.reverse.drop(1).first(@x.length - 2).each { |x| perimeter << [x, 0.0, 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
@@ -7,6 +7,7 @@ require "zip"
7
7
 
8
8
  module Lithop
9
9
  # Streams structural measurements from an existing 3MF without retaining its meshes.
10
+ # rubocop:disable-next Metrics/ClassLength
10
11
  class ReferenceThreeMF
11
12
  def initialize(path)
12
13
  @path = File.expand_path(path)
@@ -63,6 +64,7 @@ module Lithop
63
64
  def start_object(event)
64
65
  {
65
66
  id: event[1]["id"], type: event[1]["type"], vertices: 0, triangles: 0, components: 0,
67
+ component_refs: [],
66
68
  minimum: [nil, nil, nil], maximum: [nil, nil, nil]
67
69
  }
68
70
  end
@@ -80,6 +82,10 @@ module Lithop
80
82
  current[:triangles] += 1
81
83
  when "component"
82
84
  current[:components] += 1
85
+ current[:component_refs] << {
86
+ object_id: event[1]["objectid"],
87
+ transform: transform_values(event[1]["transform"])
88
+ }
83
89
  end
84
90
  end
85
91
 
@@ -105,7 +111,11 @@ module Lithop
105
111
 
106
112
  document.get_elements("config/plate").map do |plate|
107
113
  metadata = plate.get_elements("metadata").to_h { |entry| [entry.attributes["key"], entry.attributes["value"]] }
108
- { id: integer_or_nil(metadata["plater_id"]), name: metadata["plater_name"] }
114
+ object_ids = plate.get_elements("model_instance").filter_map do |instance|
115
+ entry = instance.get_elements("metadata").find { |item| item.attributes["key"] == "object_id" }
116
+ entry && entry.attributes["value"]
117
+ end
118
+ { id: integer_or_nil(metadata["plater_id"]), name: metadata["plater_name"], object_ids: }
109
119
  end
110
120
  end
111
121
 
@@ -115,5 +125,11 @@ module Lithop
115
125
  end
116
126
 
117
127
  def integer_or_nil(value) = value && Integer(value)
128
+
129
+ def transform_values(value)
130
+ return unless value
131
+
132
+ value.split.map { |number| Float(number) }
133
+ end
118
134
  end
119
135
  end