lithop 0.1.0 → 0.3.0

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