lithop 0.1.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/RELEASING.md +63 -0
  6. data/bin/lithop +6 -0
  7. data/docs/README.md +44 -0
  8. data/docs/artifacts.md +122 -0
  9. data/docs/bambu-studio.md +93 -0
  10. data/docs/calibration.md +126 -0
  11. data/docs/color-model.md +114 -0
  12. data/docs/command.md +182 -0
  13. data/docs/config.md +201 -0
  14. data/docs/getting-started.md +98 -0
  15. data/docs/troubleshooting.md +112 -0
  16. data/lib/lithop/calibration/export.rb +102 -0
  17. data/lib/lithop/calibration/profile.rb +127 -0
  18. data/lib/lithop/calibration/profile_measurements.rb +82 -0
  19. data/lib/lithop/calibration/recipe_selector.rb +54 -0
  20. data/lib/lithop/calibration/target.rb +135 -0
  21. data/lib/lithop/calibration/target_svg.rb +89 -0
  22. data/lib/lithop/calibration/thickness_selector.rb +35 -0
  23. data/lib/lithop/cli/calibration.rb +40 -0
  24. data/lib/lithop/cli/image_options.rb +69 -0
  25. data/lib/lithop/cli/options.rb +95 -0
  26. data/lib/lithop/cli/settings.rb +34 -0
  27. data/lib/lithop/cli.rb +102 -0
  28. data/lib/lithop/export/calibration.rb +46 -0
  29. data/lib/lithop/export/color_metadata.rb +80 -0
  30. data/lib/lithop/export/generation.rb +122 -0
  31. data/lib/lithop/export/job.rb +49 -0
  32. data/lib/lithop/export/metadata.rb +85 -0
  33. data/lib/lithop/export/planning.rb +78 -0
  34. data/lib/lithop/export/previews.rb +35 -0
  35. data/lib/lithop/export/print_readme.rb +69 -0
  36. data/lib/lithop/export/result.rb +21 -0
  37. data/lib/lithop/formats/stl.rb +60 -0
  38. data/lib/lithop/formats/three_mf/bambu_metadata.rb +105 -0
  39. data/lib/lithop/formats/three_mf/mesh.rb +55 -0
  40. data/lib/lithop/formats/three_mf/model_writer.rb +114 -0
  41. data/lib/lithop/formats/three_mf/part_writer.rb +49 -0
  42. data/lib/lithop/formats/three_mf.rb +128 -0
  43. data/lib/lithop/image/pipeline.rb +175 -0
  44. data/lib/lithop/image/placement.rb +54 -0
  45. data/lib/lithop/image/preview.rb +63 -0
  46. data/lib/lithop/image/svg_loader.rb +218 -0
  47. data/lib/lithop/instrumentation.rb +22 -0
  48. data/lib/lithop/job_directory.rb +55 -0
  49. data/lib/lithop/layer_structure.rb +134 -0
  50. data/lib/lithop/mesh/column_geometry.rb +161 -0
  51. data/lib/lithop/mesh/column_topology.rb +262 -0
  52. data/lib/lithop/mesh/geometry.rb +116 -0
  53. data/lib/lithop/mesh/validator.rb +123 -0
  54. data/lib/lithop/mesh/voxel_geometry.rb +113 -0
  55. data/lib/lithop/mesh/voxel_topology.rb +79 -0
  56. data/lib/lithop/model/filament_profile.rb +79 -0
  57. data/lib/lithop/model/material_display_map.rb +61 -0
  58. data/lib/lithop/model/material_model.rb +106 -0
  59. data/lib/lithop/model/palette.rb +101 -0
  60. data/lib/lithop/model/thickness.rb +45 -0
  61. data/lib/lithop/reference_three_mf.rb +119 -0
  62. data/lib/lithop/sampling_grid.rb +41 -0
  63. data/lib/lithop/settings.rb +295 -0
  64. data/lib/lithop/settings_file.rb +56 -0
  65. data/lib/lithop/version.rb +5 -0
  66. data/lib/lithop.rb +55 -0
  67. data/sig/lithop.rbs +291 -0
  68. metadata +155 -0
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Validates the user-supplied generation settings.
5
+ # rubocop:disable-next Metrics/ModuleLength
6
+ module SettingsValidation
7
+ private
8
+
9
+ def validate!
10
+ validate_positive_values!
11
+ validate_thickness_range!
12
+ validate_output_limits!
13
+ validate_color_model!
14
+ validate_layout_profile!
15
+ validate_grid!
16
+ validate_layer_structure!
17
+ validate_advanced_layer_structure!
18
+ validate_margins!
19
+ validate_image_placement!
20
+ validate_nozzle_diameter!
21
+ validate_mesh_limit!
22
+ end
23
+
24
+ def validate_positive_values!
25
+ %i[width height minimum_thickness maximum_thickness pixel_size first_layer_height layer_height].each do |key|
26
+ value = public_send(key)
27
+ next if value.is_a?(Numeric) && value.finite? && value.positive?
28
+
29
+ raise ConfigurationError, "#{key} must be a positive finite number"
30
+ end
31
+ return if color_pixel_size.nil?
32
+ unless color_pixel_size.is_a?(Numeric) && color_pixel_size.finite? && color_pixel_size.positive?
33
+ raise ConfigurationError, "color_pixel_size must be a positive finite number or null"
34
+ end
35
+ return if color_pixel_size >= pixel_size
36
+
37
+ raise ConfigurationError, "color_pixel_size must be greater than or equal to pixel_size"
38
+ end
39
+
40
+ def validate_thickness_range!
41
+ return unless minimum_thickness > maximum_thickness
42
+
43
+ raise ConfigurationError, "minimum_thickness must not exceed maximum_thickness"
44
+ end
45
+
46
+ def validate_output_limits!
47
+ %i[maximum_input_pixels maximum_triangles].each do |key|
48
+ value = public_send(key)
49
+ next if value.is_a?(Integer) && value.positive?
50
+
51
+ raise ConfigurationError, "#{key} must be a positive integer"
52
+ end
53
+ end
54
+
55
+ def validate_color_model!
56
+ return if Settings::COLOR_MODELS.include?(color_model)
57
+
58
+ raise ConfigurationError, "color_model must be one of: cmy"
59
+ end
60
+
61
+ def validate_layout_profile!
62
+ return if Settings::LAYOUT_PROFILES.include?(layout_profile)
63
+
64
+ raise ConfigurationError, "layout_profile must be one of: #{Settings::LAYOUT_PROFILES.join(", ")}"
65
+ end
66
+
67
+ def validate_grid!
68
+ raise ConfigurationError, "grid_columns and grid_rows must be set together" if grid_columns.nil? ^ grid_rows.nil?
69
+ return if grid_columns.nil?
70
+ return if grid_columns.is_a?(Integer) && grid_columns.positive? &&
71
+ grid_rows.is_a?(Integer) && grid_rows.positive?
72
+
73
+ raise ConfigurationError, "grid_columns and grid_rows must be positive integers or null"
74
+ end
75
+
76
+ def validate_layer_structure!
77
+ return if Settings::COLOR_LAYER_COUNTS.include?(color_layer_count)
78
+
79
+ raise ConfigurationError, "color_layer_count must be one of: #{Settings::COLOR_LAYER_COUNTS.join(", ")}"
80
+ end
81
+
82
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
83
+ def validate_advanced_layer_structure!
84
+ unless base_white_layers.is_a?(Integer) && base_white_layers.positive?
85
+ raise ConfigurationError, "base_white_layers must be a positive integer"
86
+ end
87
+ unless minimum_top_white_layers.is_a?(Integer) && minimum_top_white_layers >= 0
88
+ raise ConfigurationError, "minimum_top_white_layers must be a non-negative integer"
89
+ end
90
+ unless top_layer_height.nil? ||
91
+ (top_layer_height.is_a?(Numeric) && top_layer_height.finite? && top_layer_height.positive?)
92
+ raise ConfigurationError, "top_layer_height must be a positive finite number or null"
93
+ end
94
+ if !top_white_start.nil? &&
95
+ (!top_white_start.is_a?(Numeric) || !top_white_start.finite? || !top_white_start.positive?)
96
+ raise ConfigurationError, "top_white_start must be a positive finite number or null"
97
+ end
98
+ return if [true, false].include?(exact_maximum_thickness) && [true, false].include?(bambu_project)
99
+
100
+ raise ConfigurationError, "exact_maximum_thickness and bambu_project must be booleans"
101
+ end
102
+
103
+ def validate_margins!
104
+ %i[margin_top margin_right margin_bottom margin_left].each do |key|
105
+ value = public_send(key)
106
+ next if value.is_a?(Numeric) && value.finite? && value >= 0.0
107
+
108
+ raise ConfigurationError, "#{key} must be a non-negative finite number"
109
+ end
110
+ return if margin_left + margin_right < width && margin_top + margin_bottom < height
111
+
112
+ raise ConfigurationError, "horizontal and vertical margins must each leave image area"
113
+ end
114
+
115
+ def validate_image_placement!
116
+ unless Settings::IMAGE_FITS.include?(image_fit)
117
+ raise ConfigurationError, "image_fit must be one of: #{Settings::IMAGE_FITS.join(", ")}"
118
+ end
119
+
120
+ %i[crop_position_x crop_position_y].each do |key|
121
+ value = public_send(key)
122
+ next if value.is_a?(Numeric) && value.finite? && value.between?(0.0, 1.0)
123
+
124
+ raise ConfigurationError, "#{key} must be a finite number from 0 to 1"
125
+ end
126
+ return if crop_scale.is_a?(Numeric) && crop_scale.finite? && crop_scale.positive? && crop_scale <= 1.0
127
+
128
+ raise ConfigurationError, "crop_scale must be a finite number greater than 0 and at most 1"
129
+ end
130
+
131
+ def validate_nozzle_diameter!
132
+ return if Settings::NOZZLE_DIAMETERS.include?(nozzle_diameter)
133
+
134
+ raise ConfigurationError, "nozzle_diameter must be one of: 0.2, 0.4"
135
+ end
136
+
137
+ def validate_mesh_limit!
138
+ return unless estimated_triangles > maximum_triangles
139
+
140
+ raise ConfigurationError, triangle_limit_message("estimated STL mesh", estimated_triangles)
141
+ end
142
+
143
+ def normalize_pigment_order(value)
144
+ entries = value.is_a?(String) ? value.split(",") : value
145
+ raise ConfigurationError, "pigment_order must be a comma-separated string or an array" unless entries.is_a?(Array)
146
+
147
+ order = entries.map { |entry| entry.to_s.strip.downcase.to_sym }
148
+ return order.freeze if order.length == Settings::PIGMENTS.length && order.sort == Settings::PIGMENTS.sort
149
+
150
+ raise ConfigurationError, "pigment_order must contain cyan, magenta, and yellow exactly once"
151
+ end
152
+
153
+ def validate_physical_minimum!
154
+ return if effective_minimum_thickness >= minimum_physical_thickness
155
+
156
+ raise ConfigurationError,
157
+ "physical CMYW output requires at least #{minimum_physical_thickness} mm thickness; " \
158
+ "set --min-thickness accordingly"
159
+ end
160
+
161
+ def minimum_physical_thickness
162
+ layer_structure.minimum_physical_thickness
163
+ end
164
+ end
165
+
166
+ # Validated dimensions, print parameters, and output limits for a generation job.
167
+ # rubocop:disable-next Metrics/ClassLength
168
+ class Settings
169
+ include SettingsValidation
170
+
171
+ MICRONS_PER_MM = LayerStructure::MICRONS_PER_MM
172
+ COLOR_MODELS = %w[cmy].freeze
173
+ NOZZLE_DIAMETERS = [0.2, 0.4].freeze
174
+ COLOR_LAYER_COUNTS = [4, 5, 7].freeze
175
+ LAYOUT_PROFILES = ["standard"].freeze
176
+ IMAGE_FITS = %w[cover contain].freeze
177
+ PIGMENTS = %i[cyan magenta yellow].freeze
178
+ MINIMUM_TOP_WHITE_LAYERS = LayerStructure::MINIMUM_TOP_WHITE_LAYERS
179
+ DEFAULTS = {
180
+ layout_profile: "standard",
181
+ width: 140.0,
182
+ height: 104.0,
183
+ margin_top: 0.0,
184
+ margin_right: 0.0,
185
+ margin_bottom: 0.0,
186
+ margin_left: 0.0,
187
+ image_fit: "cover",
188
+ crop_position_x: 0.5,
189
+ crop_position_y: 0.5,
190
+ crop_scale: 1.0,
191
+ minimum_thickness: 1.0,
192
+ maximum_thickness: 2.14689422,
193
+ pixel_size: 0.22,
194
+ color_pixel_size: nil,
195
+ grid_columns: 637,
196
+ grid_rows: 473,
197
+ first_layer_height: 0.1,
198
+ layer_height: 0.1,
199
+ base_white_layers: 2,
200
+ top_layer_height: 0.04,
201
+ top_white_start: 1.0,
202
+ minimum_top_white_layers: 0,
203
+ exact_maximum_thickness: true,
204
+ nozzle_diameter: 0.2,
205
+ color_model: "cmy",
206
+ color_layer_count: 7,
207
+ pigment_order: PIGMENTS,
208
+ bambu_project: true,
209
+ maximum_input_pixels: 20_000_000,
210
+ maximum_triangles: 5_000_000
211
+ }.freeze
212
+ attr_reader(
213
+ *DEFAULTS.keys, :effective_minimum_thickness, :effective_maximum_thickness, :layer_structure
214
+ )
215
+
216
+ def initialize(**values)
217
+ profile = values.fetch(:layout_profile, DEFAULTS.fetch(:layout_profile)).to_s
218
+ values = without_default_grid(values)
219
+ settings = DEFAULTS.merge(values).merge(layout_profile: profile)
220
+ DEFAULTS.each_key { |key| instance_variable_set("@#{key}", settings.fetch(key)) }
221
+ @pigment_order = normalize_pigment_order(@pigment_order)
222
+ validate!
223
+ @layer_structure = LayerStructure.new(
224
+ first_layer_height:, layer_height:, color_layer_count:, minimum_thickness:, maximum_thickness:,
225
+ base_white_layers:, top_layer_height:, top_white_start:, minimum_top_white_layers:,
226
+ exact_maximum_thickness:
227
+ )
228
+ @effective_minimum_thickness = layer_structure.effective_minimum_thickness
229
+ @effective_maximum_thickness = layer_structure.effective_maximum_thickness
230
+ if effective_minimum_thickness > effective_maximum_thickness
231
+ raise ConfigurationError, "the requested thickness range contains no printable layer boundary"
232
+ end
233
+
234
+ validate_physical_minimum!
235
+ end
236
+
237
+ def without_default_grid(values)
238
+ dimensions = %i[width height pixel_size]
239
+ explicit_grid = values.key?(:grid_columns) || values.key?(:grid_rows)
240
+ return values if explicit_grid || dimensions.none? { |key| values.key?(key) }
241
+
242
+ values.merge(grid_columns: nil, grid_rows: nil)
243
+ end
244
+ private :without_default_grid
245
+
246
+ def columns = sampling_grid.columns
247
+ def rows = sampling_grid.rows
248
+ def effective_color_pixel_size = color_pixel_size || pixel_size
249
+ def fitted_grid? = !grid_columns.nil?
250
+ def pixel_size_x = width.fdiv(columns)
251
+ def pixel_size_y = height.fdiv(rows)
252
+
253
+ def estimated_triangles
254
+ (4 * columns * rows) + (4 * (columns + rows))
255
+ end
256
+
257
+ def triangle_limit_message(label, triangle_count)
258
+ scale = Math.sqrt(triangle_count.fdiv(maximum_triangles))
259
+ suggested_pixel_size = ((pixel_size * scale * 100).ceil / 100.0).round(2)
260
+ "#{label} has #{triangle_count} triangles; maximum is #{maximum_triangles}. " \
261
+ "Increase pixel_size to about #{suggested_pixel_size} mm or reduce width/height"
262
+ end
263
+
264
+ def x_boundaries = sampling_grid.x_boundaries
265
+ def y_boundaries = sampling_grid.y_boundaries
266
+ def quantize_thickness(value) = layer_structure.quantize_thickness(value)
267
+ def margin_samples = sampling_grid.margin_samples
268
+
269
+ def sampling_grid
270
+ @sampling_grid ||= SamplingGrid.new(
271
+ width:, height:, pixel_size:,
272
+ margins: { top: margin_top, right: margin_right, bottom: margin_bottom, left: margin_left },
273
+ columns: grid_columns, rows: grid_rows
274
+ )
275
+ end
276
+
277
+ def color_sampling_grid
278
+ return sampling_grid if color_pixel_size.nil?
279
+
280
+ @color_sampling_grid ||= SamplingGrid.new(
281
+ width:, height:, pixel_size: effective_color_pixel_size,
282
+ margins: { top: margin_top, right: margin_right, bottom: margin_bottom, left: margin_left }
283
+ )
284
+ end
285
+
286
+ def to_h
287
+ DEFAULTS.keys.to_h { |key| [key, public_send(key)] }.merge(
288
+ effective_color_pixel_size: effective_color_pixel_size,
289
+ effective_minimum_thickness: effective_minimum_thickness,
290
+ effective_maximum_thickness: effective_maximum_thickness,
291
+ columns: columns, rows: rows, pixel_size_x: pixel_size_x, pixel_size_y: pixel_size_y
292
+ )
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lithop
6
+ # Loads validated generation overrides from a JSON settings file.
7
+ module SettingsFile
8
+ DEFAULT_PATH = File.join("config", "settings.json")
9
+ SCHEMA_VERSION = 1
10
+ ALLOWED_KEYS = (Settings::DEFAULTS.keys.map(&:to_s) + ["schema_version"]).freeze
11
+ Result = Data.define(:path, :values)
12
+
13
+ module_function
14
+
15
+ def load(path, required: false)
16
+ expanded = File.expand_path(path)
17
+ unless File.exist?(expanded)
18
+ raise ConfigurationError, "settings file does not exist: #{expanded}" if required
19
+
20
+ return Result.new(path: nil, values: {})
21
+ end
22
+ raise ConfigurationError, "settings path is not a file: #{expanded}" unless File.file?(expanded)
23
+
24
+ data = JSON.parse(File.read(expanded))
25
+ raise ConfigurationError, "settings file must contain a JSON object: #{expanded}" unless data.is_a?(Hash)
26
+
27
+ validate_keys!(data, expanded)
28
+ validate_schema!(data, expanded)
29
+ values = data.except("schema_version").transform_keys(&:to_sym)
30
+ values[:color_model] = values[:color_model].downcase if values[:color_model].is_a?(String)
31
+ values[:image_fit] = values[:image_fit].downcase if values[:image_fit].is_a?(String)
32
+ values[:layout_profile] = values[:layout_profile].downcase if values[:layout_profile].is_a?(String)
33
+ Result.new(path: expanded, values:)
34
+ rescue JSON::ParserError => e
35
+ raise ConfigurationError, "settings file is not valid JSON: #{expanded}: #{e.message}"
36
+ rescue SystemCallError => e
37
+ raise ConfigurationError, "could not read settings file: #{expanded}: #{e.message}"
38
+ end
39
+
40
+ def validate_keys!(data, path)
41
+ unknown = data.keys - ALLOWED_KEYS
42
+ return if unknown.empty?
43
+
44
+ raise ConfigurationError, "settings file contains unknown keys: #{unknown.sort.join(", ")}: #{path}"
45
+ end
46
+ private_class_method :validate_keys!
47
+
48
+ def validate_schema!(data, path)
49
+ version = data.fetch("schema_version", SCHEMA_VERSION)
50
+ return if version.eql?(SCHEMA_VERSION)
51
+
52
+ raise ConfigurationError, "unsupported settings schema_version #{version.inspect}: #{path}"
53
+ end
54
+ private_class_method :validate_schema!
55
+ end
56
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ VERSION = "0.1.0"
5
+ end
data/lib/lithop.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lithop/version"
4
+
5
+ module Lithop
6
+ class Error < StandardError; end
7
+ class ConfigurationError < Error; end
8
+ class InputError < Error; end
9
+ end
10
+
11
+ require_relative "lithop/sampling_grid"
12
+ require_relative "lithop/layer_structure"
13
+ require_relative "lithop/settings"
14
+ require_relative "lithop/settings_file"
15
+ require_relative "lithop/job_directory"
16
+ require_relative "lithop/instrumentation"
17
+ require_relative "lithop/model/thickness"
18
+ require_relative "lithop/model/palette"
19
+ require_relative "lithop/model/material_display_map"
20
+ require_relative "lithop/model/filament_profile"
21
+ require_relative "lithop/model/material_model"
22
+ require_relative "lithop/image/placement"
23
+ require_relative "lithop/image/svg_loader"
24
+ require_relative "lithop/image/pipeline"
25
+ require_relative "lithop/image/preview"
26
+ require_relative "lithop/mesh/geometry"
27
+ require_relative "lithop/mesh/voxel_topology"
28
+ require_relative "lithop/mesh/voxel_geometry"
29
+ require_relative "lithop/mesh/column_topology"
30
+ require_relative "lithop/mesh/column_geometry"
31
+ require_relative "lithop/mesh/validator"
32
+ require_relative "lithop/formats/stl"
33
+ require_relative "lithop/formats/three_mf"
34
+ require_relative "lithop/reference_three_mf"
35
+ require_relative "lithop/calibration/target"
36
+ require_relative "lithop/calibration/target_svg"
37
+ require_relative "lithop/calibration/profile_measurements"
38
+ require_relative "lithop/calibration/profile"
39
+ require_relative "lithop/calibration/recipe_selector"
40
+ require_relative "lithop/calibration/thickness_selector"
41
+ require_relative "lithop/calibration/export"
42
+ require_relative "lithop/export/calibration"
43
+ require_relative "lithop/export/result"
44
+ require_relative "lithop/export/color_metadata"
45
+ require_relative "lithop/export/metadata"
46
+ require_relative "lithop/export/planning"
47
+ require_relative "lithop/export/print_readme"
48
+ require_relative "lithop/export/previews"
49
+ require_relative "lithop/export/generation"
50
+ require_relative "lithop/export/job"
51
+ require_relative "lithop/cli/image_options"
52
+ require_relative "lithop/cli/options"
53
+ require_relative "lithop/cli/settings"
54
+ require_relative "lithop/cli/calibration"
55
+ require_relative "lithop/cli"