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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Describes and validates the filament materials used by multipart models.
5
+ module FilamentProfile
6
+ REQUIRED_CHANNELS = %i[cyan magenta yellow white].freeze
7
+
8
+ Material = Data.define(:channel, :name, :color_code, :display_color, :diameter_mm) do
9
+ def to_h
10
+ {
11
+ channel:,
12
+ name:,
13
+ color_code:,
14
+ display_color:,
15
+ diameter_mm:
16
+ }
17
+ end
18
+ end
19
+
20
+ DisplaySample = Data.define(:cmy, :density)
21
+
22
+ Profile = Data.define(:id, :name, :manufacturer, :product_url, :reference_url, :materials) do
23
+ def to_h
24
+ {
25
+ id:,
26
+ name:,
27
+ manufacturer:,
28
+ product_url:,
29
+ reference_url:,
30
+ materials: materials.map(&:to_h)
31
+ }
32
+ end
33
+ end
34
+
35
+ BAMBU_LAB_PLA_CMYK = Profile.new(
36
+ id: "bambu_lab_pla_basic_cmyw",
37
+ name: "Bambu Lab PLA Basic CMYW",
38
+ manufacturer: "Bambu Lab",
39
+ product_url: "https://jp.store.bambulab.com/products/pla-cmyk-lithophane",
40
+ reference_url: "https://wiki.bambulab.com/en/knowledge-sharing/CMYK-color-lithophane-printing-instructions",
41
+ materials: [
42
+ Material.new(
43
+ channel: :cyan, name: "PLA Basic Cyan", color_code: "10603",
44
+ display_color: "#0086D6FF", diameter_mm: 1.75
45
+ ),
46
+ Material.new(
47
+ channel: :magenta, name: "PLA Basic Magenta", color_code: "10202",
48
+ display_color: "#EC008CFF", diameter_mm: 1.75
49
+ ),
50
+ Material.new(
51
+ channel: :yellow, name: "PLA Basic Yellow", color_code: "10400",
52
+ display_color: "#FCE300FF", diameter_mm: 1.75
53
+ ),
54
+ Material.new(
55
+ channel: :white, name: "PLA Basic White", color_code: "10100",
56
+ display_color: "#FFFFFFFF", diameter_mm: 1.75
57
+ )
58
+ ].freeze
59
+ ).freeze
60
+
61
+ module_function
62
+
63
+ def default = BAMBU_LAB_PLA_CMYK
64
+
65
+ def display_sample(rgb)
66
+ cmy, density = MaterialDisplayMap.call(rgb)
67
+ DisplaySample.new(cmy:, density:) if cmy
68
+ end
69
+
70
+ def display_cmy(rgb) = display_sample(rgb)&.cmy
71
+
72
+ def validate!(profile)
73
+ channels = profile.materials.map(&:channel)
74
+ return true if channels == REQUIRED_CHANNELS
75
+
76
+ raise ConfigurationError, "filament profile must contain only Cyan, Magenta, Yellow, and White"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Recognizes the material-display mixtures used by the bundled CMY chart.
5
+ module MaterialDisplayMap
6
+ CMY_BLACK = [0.2, 0.4, 0.4].freeze
7
+ CMY_BLACK_DENSITIES = {
8
+ 0xF4F4F5 => 0.05, 0xE8E8E9 => 0.1, 0xCFD1D2 => 0.2, 0xB8BABD => 0.3,
9
+ 0x9FA3A6 => 0.4, 0x878B8F => 0.5, 0x707479 => 0.6, 0x575D62 => 0.7,
10
+ 0x40464D => 0.8, 0x272F36 => 0.9, 0x101820 => 1.0
11
+ }.freeze
12
+ COLOR_CMYS = {
13
+ 0x760081 => [0.4, 0.6, 0.0].freeze,
14
+ 0x000075 => [0.4, 0.6, 0.0].freeze,
15
+ 0x0043A6 => [0.6, 0.4, 0.0].freeze,
16
+ 0x0086D6 => [1.0, 0.0, 0.0].freeze,
17
+ 0x007F6B => [0.6, 0.0, 0.4].freeze,
18
+ 0x007700 => [0.4, 0.0, 0.6].freeze,
19
+ 0x7EAD00 => [0.4, 0.0, 0.6].freeze,
20
+ 0xFCE300 => [0.0, 0.0, 1.0].freeze,
21
+ 0xF37200 => [0.0, 0.4, 0.6].freeze,
22
+ 0xE90000 => [0.0, 0.4, 0.6].freeze,
23
+ 0xEB0046 => [0.0, 0.6, 0.4].freeze,
24
+ 0xEC008C => [0.0, 1.0, 0.0].freeze
25
+ }.freeze
26
+ COLOR_LINES = COLOR_CMYS.map do |packed, cmy|
27
+ rgb = [packed >> 16, (packed >> 8) & 0xFF, packed & 0xFF]
28
+ direction = rgb.map { |component| component - 255 }
29
+ [direction.freeze, direction.sum { |component| component * component }, cmy]
30
+ end.freeze
31
+ MAXIMUM_ERROR = 3.0
32
+
33
+ module_function
34
+
35
+ def call(rgb)
36
+ channels = rgb.map { |component| (component * 255).round.clamp(0, 255) }
37
+ packed = channels.reduce(0) { |value, component| (value << 8) | component }
38
+ return [[0.0, 0.0, 0.0], 0.0] if packed == 0xFFFFFF
39
+ return [CMY_BLACK, CMY_BLACK_DENSITIES.fetch(packed)] if CMY_BLACK_DENSITIES.key?(packed)
40
+ return [COLOR_CMYS.fetch(packed), 1.0] if COLOR_CMYS.key?(packed)
41
+
42
+ match = closest_line(channels)
43
+ return unless match && match.first <= MAXIMUM_ERROR
44
+
45
+ _error, density, cmy = match
46
+ [cmy, density]
47
+ end
48
+
49
+ def closest_line(channels)
50
+ offset = channels.map { |component| component - 255 }
51
+ COLOR_LINES.filter_map do |direction, magnitude, cmy|
52
+ density = offset.zip(direction).sum { |value, axis| value * axis }.fdiv(magnitude)
53
+ next unless density.between?(0.0, 1.0)
54
+
55
+ error = offset.zip(direction).sum { |value, axis| (value - (density * axis))**2 }
56
+ [error, density, cmy]
57
+ end.min_by(&:first)
58
+ end
59
+ private_class_method :closest_line
60
+ end
61
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Builds the printable material grid from sampled colors and thicknesses.
5
+ class MaterialModel
6
+ Cell = Data.define(:height, :recipe, :materials)
7
+
8
+ attr_reader :columns, :rows, :x_boundaries, :y_boundaries, :z_boundaries, :cells
9
+
10
+ def initialize(cmys:, heights:, settings:, palette: nil, cell_cmys: nil)
11
+ @columns = settings.columns
12
+ @rows = settings.rows
13
+ @x_boundaries = settings.x_boundaries
14
+ @y_boundaries = settings.y_boundaries
15
+ @palette = palette || Palette.new(
16
+ layer_count: settings.color_layer_count,
17
+ pigment_order: settings.pigment_order
18
+ )
19
+ @settings = settings
20
+ validate_samples!(cmys, heights, cell_cmys)
21
+ @cells = build_cells(cmys, heights, cell_cmys).freeze
22
+ maximum_layers = cells.map { |cell| cell.materials.length }.max
23
+ @z_boundaries = @settings.layer_structure.z_boundaries(maximum_layers).freeze
24
+ end
25
+
26
+ def material_at(layer, row, column)
27
+ return if layer.negative? || row.negative? || column.negative?
28
+ return if row >= rows || column >= columns
29
+
30
+ cells.fetch((row * columns) + column).materials[layer]
31
+ end
32
+
33
+ def materials
34
+ @materials ||= cells.flat_map(&:materials).uniq.freeze
35
+ end
36
+
37
+ def recipe_counts
38
+ cells.each_with_object(Hash.new(0)) do |cell, counts|
39
+ key = Palette::MATERIALS.map { |material| cell.recipe.public_send(material) }.join(":")
40
+ counts[key] += 1
41
+ end.freeze
42
+ end
43
+
44
+ def recipe_summaries
45
+ counts = cells.each_with_object(Hash.new(0)) { |cell, result| result[cell.recipe] += 1 }
46
+ @palette.recipes.filter_map do |recipe|
47
+ cell_count = counts[recipe]
48
+ next if cell_count.zero?
49
+
50
+ { counts: recipe.to_h.fetch(:counts), cells: cell_count }
51
+ end.freeze
52
+ end
53
+
54
+ private
55
+
56
+ def validate_samples!(cmys, heights, cell_cmys)
57
+ expected = (columns + 1) * (rows + 1)
58
+ raise InputError, "expected #{expected} CMY samples, got #{cmys.length}" unless cmys.length == expected
59
+ raise InputError, "expected #{expected} height samples, got #{heights.length}" unless heights.length == expected
60
+ return if cell_cmys.nil? || cell_cmys.length == columns * rows
61
+
62
+ raise InputError, "expected #{columns * rows} color cells, got #{cell_cmys.length}"
63
+ end
64
+
65
+ def build_cells(cmys, heights, cell_cmys)
66
+ Array.new(rows * columns) do |index|
67
+ row, column = index.divmod(columns)
68
+ 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)
72
+ end
73
+ end
74
+
75
+ def corners(values, row, column)
76
+ stride = columns + 1
77
+ top_left = (row * stride) + column
78
+ [
79
+ values.fetch(top_left),
80
+ values.fetch(top_left + 1),
81
+ values.fetch(top_left + stride),
82
+ values.fetch(top_left + stride + 1)
83
+ ]
84
+ end
85
+
86
+ def average(values)
87
+ return values.sum.fdiv(values.length) unless values.first.is_a?(Array)
88
+
89
+ values.transpose.map { |components| components.sum.fdiv(components.length) }
90
+ end
91
+
92
+ def materials_for(recipe, height)
93
+ top_white_layers = @settings.layer_structure.top_white_layer_count(height, pigment_layers: @palette.layer_count)
94
+ if top_white_layers < @settings.minimum_top_white_layers
95
+ raise ConfigurationError,
96
+ "physical CMYW output requires at least #{minimum_thickness} mm thickness"
97
+ end
98
+
99
+ @settings.layer_structure.materials_for(recipe.layers, height)
100
+ end
101
+
102
+ def minimum_thickness
103
+ @settings.layer_structure.minimum_physical_thickness
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Maps CMY samples to printable CMYW layer recipes.
5
+ class Palette
6
+ PIGMENTS = %i[cyan magenta yellow].freeze
7
+ MATERIALS = [*PIGMENTS, :white].freeze
8
+ SUBTRACTIVE_MIXES = {
9
+ green: %i[cyan yellow].freeze,
10
+ purple: %i[cyan magenta].freeze,
11
+ red: %i[magenta yellow].freeze
12
+ }.freeze
13
+ DEFAULT_LAYER_COUNT = 7
14
+
15
+ Recipe = Data.define(:cyan, :magenta, :yellow, :white, :coverage_cmy, :ordered_layers) do
16
+ def layer_count
17
+ cyan + magenta + yellow + white
18
+ end
19
+
20
+ def layers = ordered_layers
21
+
22
+ def pigments
23
+ PIGMENTS.select { |pigment| public_send(pigment).positive? }
24
+ end
25
+
26
+ def to_h
27
+ {
28
+ counts: MATERIALS.to_h { |material| [material, public_send(material)] },
29
+ layers:,
30
+ pigments:,
31
+ coverage_cmy:
32
+ }
33
+ end
34
+ end
35
+
36
+ attr_reader :layer_count, :pigment_order, :recipes
37
+
38
+ def initialize(layer_count: DEFAULT_LAYER_COUNT, pigment_order: PIGMENTS)
39
+ unless layer_count.is_a?(Integer) && layer_count.positive?
40
+ raise ConfigurationError, "palette layer_count must be a positive integer"
41
+ end
42
+
43
+ valid_pigment_order = pigment_order.is_a?(Array) && pigment_order.length == PIGMENTS.length &&
44
+ pigment_order.sort == PIGMENTS.sort
45
+ unless valid_pigment_order
46
+ raise ConfigurationError, "palette pigment_order must contain cyan, magenta, and yellow exactly once"
47
+ end
48
+
49
+ @layer_count = layer_count
50
+ @pigment_order = pigment_order.dup.freeze
51
+ @recipes = build_recipes.freeze
52
+ end
53
+
54
+ def nearest(cmy)
55
+ validate_cmy!(cmy)
56
+ recipes.min_by do |recipe|
57
+ recipe.coverage_cmy.zip(cmy).sum { |coverage, target| (coverage - target)**2 }
58
+ end
59
+ end
60
+
61
+ def display_color(recipe)
62
+ channels = recipe.coverage_cmy.map { |component| ((1.0 - component) * 255).round.clamp(0, 255) }
63
+ format("#%<red>02X%<green>02X%<blue>02XFF", red: channels[0], green: channels[1], blue: channels[2])
64
+ end
65
+
66
+ private
67
+
68
+ def build_recipes
69
+ recipes = []
70
+ (0..layer_count).each do |cyan|
71
+ (0..(layer_count - cyan)).each do |magenta|
72
+ (0..(layer_count - cyan - magenta)).each do |yellow|
73
+ white = layer_count - cyan - magenta - yellow
74
+ counts = { cyan:, magenta:, yellow: }
75
+ recipes << Recipe.new(
76
+ cyan:,
77
+ magenta:,
78
+ yellow:,
79
+ white:,
80
+ coverage_cmy: coverage_cmy(cyan:, magenta:, yellow:),
81
+ ordered_layers: pigment_order.flat_map { |pigment| [pigment] * counts.fetch(pigment) } +
82
+ Array.new(white, :white)
83
+ )
84
+ end
85
+ end
86
+ end
87
+ recipes
88
+ end
89
+
90
+ def coverage_cmy(cyan:, magenta:, yellow:)
91
+ [cyan, magenta, yellow].map { |layers| layers.fdiv(layer_count) }.freeze
92
+ end
93
+
94
+ def validate_cmy!(cmy)
95
+ return if cmy.is_a?(Array) && cmy.length == 3 &&
96
+ cmy.all? { |value| value.is_a?(Numeric) && value.finite? && value.between?(0.0, 1.0) }
97
+
98
+ raise InputError, "CMY must contain three values between 0 and 1"
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Converts image density values into printable, layer-aligned thicknesses.
5
+ module Thickness
6
+ module_function
7
+
8
+ def from_cmy_density(densities, settings)
9
+ validate_densities!(densities)
10
+ densities = normalize_reference_densities(densities)
11
+ minimum = settings.effective_minimum_thickness
12
+ range = settings.effective_maximum_thickness - minimum
13
+
14
+ densities.map do |density|
15
+ unless density.is_a?(Numeric) && density.finite? && density.between?(0.0, 1.0)
16
+ raise InputError, "CMY density must be between 0 and 1"
17
+ end
18
+
19
+ settings.quantize_thickness(minimum + (density * range))
20
+ end
21
+ end
22
+
23
+ def continuous_from_density(densities, settings)
24
+ validate_densities!(densities)
25
+ normalized = normalize_reference_densities(densities)
26
+ minimum = settings.effective_minimum_thickness
27
+ range = settings.effective_maximum_thickness - minimum
28
+ normalized.map { |density| minimum + (density * range) }
29
+ end
30
+
31
+ def validate_densities!(densities)
32
+ return if densities.all? { |density| density.is_a?(Numeric) && density.finite? && density.between?(0.0, 1.0) }
33
+
34
+ raise InputError, "CMY density must be between 0 and 1"
35
+ end
36
+
37
+ def normalize_reference_densities(densities)
38
+ minimum, maximum = densities.minmax
39
+ return Array.new(densities.length, 0.0) if maximum == minimum
40
+
41
+ densities.map { |density| (density - minimum).fdiv(maximum - minimum) }
42
+ end
43
+ private_class_method :normalize_reference_densities, :validate_densities!
44
+ end
45
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "rexml/document"
5
+ require "rexml/parsers/pullparser"
6
+ require "zip"
7
+
8
+ module Lithop
9
+ # Streams structural measurements from an existing 3MF without retaining its meshes.
10
+ class ReferenceThreeMF
11
+ def initialize(path)
12
+ @path = File.expand_path(path)
13
+ end
14
+
15
+ def call
16
+ raise InputError, "reference 3MF does not exist: #{@path}" unless File.file?(@path)
17
+
18
+ Zip::File.open(@path) do |archive|
19
+ entries = archive.entries
20
+ {
21
+ path: @path,
22
+ sha256: Digest::SHA256.file(@path).hexdigest,
23
+ bytes: File.size(@path),
24
+ entry_count: entries.length,
25
+ entries: entries.map(&:name).sort,
26
+ uncompressed_bytes: entries.sum(&:size),
27
+ models: model_entries(archive),
28
+ parts: parts(archive),
29
+ plates: plates(archive)
30
+ }
31
+ end
32
+ rescue Zip::Error, REXML::ParseException => e
33
+ raise InputError, "could not inspect reference 3MF: #{e.message}"
34
+ end
35
+
36
+ private
37
+
38
+ def model_entries(archive)
39
+ archive.entries.filter_map do |entry|
40
+ next unless entry.name.start_with?("3D/") && entry.name.end_with?(".model")
41
+
42
+ entry.get_input_stream { |stream| inspect_model(entry.name, stream) }
43
+ end
44
+ end
45
+
46
+ def inspect_model(name, stream)
47
+ parser = REXML::Parsers::PullParser.new(stream)
48
+ objects = []
49
+ current = nil
50
+ while parser.has_next?
51
+ event = parser.pull
52
+ if event.start_element?
53
+ current = start_object(event) if event[0] == "object"
54
+ inspect_mesh_element(current, event) if current
55
+ elsif event.end_element? && event[0] == "object" && current
56
+ objects << finish_object(current)
57
+ current = nil
58
+ end
59
+ end
60
+ { file: name, objects: }
61
+ end
62
+
63
+ def start_object(event)
64
+ {
65
+ id: event[1]["id"], type: event[1]["type"], vertices: 0, triangles: 0, components: 0,
66
+ minimum: [nil, nil, nil], maximum: [nil, nil, nil]
67
+ }
68
+ end
69
+
70
+ def inspect_mesh_element(current, event)
71
+ case event[0]
72
+ when "vertex"
73
+ coordinates = %w[x y z].map { |axis| Float(event[1].fetch(axis)) }
74
+ current[:vertices] += 1
75
+ coordinates.each_index do |index|
76
+ current[:minimum][index] = [current[:minimum][index], coordinates[index]].compact.min
77
+ current[:maximum][index] = [current[:maximum][index], coordinates[index]].compact.max
78
+ end
79
+ when "triangle"
80
+ current[:triangles] += 1
81
+ when "component"
82
+ current[:components] += 1
83
+ end
84
+ end
85
+
86
+ def finish_object(object)
87
+ object.merge(size: object[:maximum].zip(object[:minimum]).map do |maximum, minimum|
88
+ maximum && minimum && (maximum - minimum)
89
+ end)
90
+ end
91
+
92
+ def parts(archive)
93
+ document = metadata_document(archive)
94
+ return [] unless document
95
+
96
+ document.get_elements("config/object/part").map do |part|
97
+ metadata = part.get_elements("metadata").to_h { |entry| [entry.attributes["key"], entry.attributes["value"]] }
98
+ { id: part.attributes["id"], name: metadata["name"], extruder: integer_or_nil(metadata["extruder"]) }
99
+ end
100
+ end
101
+
102
+ def plates(archive)
103
+ document = metadata_document(archive)
104
+ return [] unless document
105
+
106
+ document.get_elements("config/plate").map do |plate|
107
+ 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"] }
109
+ end
110
+ end
111
+
112
+ def metadata_document(archive)
113
+ entry = archive.find_entry("Metadata/model_settings.config")
114
+ entry && REXML::Document.new(entry.get_input_stream.read)
115
+ end
116
+
117
+ def integer_or_nil(value) = value && Integer(value)
118
+ end
119
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Calculates the XY sampling grid, partial edge cells, and sampled margins.
5
+ class SamplingGrid
6
+ attr_reader :columns, :rows, :x_boundaries, :y_boundaries, :margin_samples
7
+
8
+ def initialize(width:, height:, pixel_size:, margins:, columns: nil, rows: nil)
9
+ @columns = columns || (width / pixel_size).ceil
10
+ @rows = rows || (height / pixel_size).ceil
11
+ @x_boundaries = boundaries(width, pixel_size, @columns, fitted: !columns.nil?).freeze
12
+ @y_boundaries = boundaries(height, pixel_size, @rows, fitted: !rows.nil?).freeze
13
+ x_size = width.fdiv(@columns)
14
+ y_size = height.fdiv(@rows)
15
+ @margin_samples = {
16
+ top: (margins.fetch(:top) / y_size).round,
17
+ right: (margins.fetch(:right) / x_size).round,
18
+ bottom: (margins.fetch(:bottom) / y_size).round,
19
+ left: (margins.fetch(:left) / x_size).round
20
+ }.freeze
21
+ end
22
+
23
+ def sample_width = columns + 1
24
+ def sample_height = rows + 1
25
+
26
+ def content_sample_size
27
+ [
28
+ sample_width - margin_samples.fetch(:left) - margin_samples.fetch(:right),
29
+ sample_height - margin_samples.fetch(:top) - margin_samples.fetch(:bottom)
30
+ ]
31
+ end
32
+
33
+ private
34
+
35
+ def boundaries(length, pixel_size, count, fitted:)
36
+ return Array.new(count + 1) { |index| (length * index.fdiv(count)).round(9) } if fitted
37
+
38
+ Array.new(count + 1) { |index| index == count ? length : [index * pixel_size, length].min }
39
+ end
40
+ end
41
+ end