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,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+ require "rexml/xpath"
5
+
6
+ module Lithop
7
+ # Applies the self-contained, static subset accepted for SVG input.
8
+ class SVGDocumentValidator
9
+ FORBIDDEN_ELEMENTS = %w[script foreignobject animate animatemotion animatetransform set].freeze
10
+ LENGTH = /\A\+?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?(?:px|mm|in)?\z/i
11
+ NUMBER = /[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/
12
+ URL_REFERENCE = /url\s*\(\s*(?:(['"])(.*?)\1|([^)]*))\s*\)/im
13
+
14
+ def call(data)
15
+ validate_declarations!(data)
16
+ document = parse(data)
17
+ validate_document!(document)
18
+ rescue REXML::ParseException => e
19
+ raise InputError, "invalid SVG XML: #{e.message.lines.first.to_s.strip}"
20
+ end
21
+
22
+ private
23
+
24
+ def parse(data)
25
+ utf8 = data.dup.force_encoding(Encoding::UTF_8)
26
+ raise InputError, "SVG input must use UTF-8 XML" unless utf8.valid_encoding?
27
+
28
+ document = REXML::Document.new(utf8)
29
+ root = document.root
30
+ raise InputError, "input must be a JPEG, PNG, or SVG image" unless root&.name&.casecmp?("svg")
31
+
32
+ encoding = document.xml_decl&.encoding
33
+ raise InputError, "SVG input must use UTF-8 XML" if encoding && !encoding.casecmp?("UTF-8")
34
+
35
+ document
36
+ end
37
+
38
+ def validate_declarations!(data)
39
+ return unless data.match?(/<!DOCTYPE\b|<!ENTITY\b|<\?xml-stylesheet\b/i)
40
+
41
+ raise InputError, "SVG DTDs, entities, and external stylesheets are not supported"
42
+ end
43
+
44
+ def validate_document!(document)
45
+ validate_dimensions!(document.root)
46
+ REXML::XPath.each(document, "//*") { |element| validate_element!(element) }
47
+ end
48
+
49
+ def validate_dimensions!(root)
50
+ width = root.attributes["width"]
51
+ height = root.attributes["height"]
52
+ view_box = root.attributes["viewBox"]
53
+
54
+ validate_explicit_dimensions!(width, height) if width || height
55
+ raise InputError, "SVG must define positive width and height or a viewBox" unless width || view_box
56
+
57
+ validate_view_box!(view_box) if view_box
58
+ end
59
+
60
+ def validate_explicit_dimensions!(width, height)
61
+ return if width && height && valid_length?(width) && valid_length?(height)
62
+
63
+ raise InputError, "SVG width and height must be positive values in px, mm, or in"
64
+ end
65
+
66
+ def validate_view_box!(view_box)
67
+ values = view_box.scan(NUMBER)
68
+ remainder = view_box.gsub(NUMBER, "").gsub(/[\s,]/, "")
69
+ valid = remainder.empty? && values.length == 4 && values.all? { |value| finite_number?(value) }
70
+ valid &&= values[2].to_f.positive? && values[3].to_f.positive?
71
+ raise InputError, "SVG viewBox must contain four finite numbers with positive width and height" unless valid
72
+ end
73
+
74
+ def valid_length?(value)
75
+ value = value.strip
76
+ value.match?(LENGTH) && finite_number?(value.sub(/(?:px|mm|in)\z/i, "")) && value.to_f.positive?
77
+ end
78
+
79
+ def finite_number?(value)
80
+ Float(value).finite?
81
+ rescue ArgumentError
82
+ false
83
+ end
84
+
85
+ def validate_element!(element)
86
+ if FORBIDDEN_ELEMENTS.include?(element.name.downcase)
87
+ raise InputError, "SVG element <#{element.name}> is not supported"
88
+ end
89
+
90
+ element.attributes.each_attribute do |attribute|
91
+ name = attribute.expanded_name.downcase
92
+ value = attribute.value.to_s
93
+ validate_href!(value) if name == "href" || name.end_with?(":href")
94
+ raise InputError, "SVG xml:base is not supported" if name == "xml:base"
95
+
96
+ validate_css_references!(value)
97
+ end
98
+
99
+ return unless element.name.casecmp?("style")
100
+
101
+ validate_css_references!(element.texts.map(&:value).join)
102
+ end
103
+
104
+ def validate_href!(value)
105
+ value = value.strip
106
+ return if value.match?(/\A#[^\s#]+\z/)
107
+
108
+ raise InputError, "SVG external and data references are not supported"
109
+ end
110
+
111
+ def validate_css_references!(value)
112
+ css = normalize_css(value)
113
+ raise InputError, "SVG external CSS imports are not supported" if css.match?(/@import\b/i)
114
+
115
+ remainder = css.gsub(URL_REFERENCE) do
116
+ reference = Regexp.last_match(2) || Regexp.last_match(3)
117
+ validate_href!(reference.strip)
118
+ ""
119
+ end
120
+ return unless remainder.match?(/url\s*\(/i)
121
+
122
+ raise InputError, "SVG contains an invalid CSS URL reference"
123
+ end
124
+
125
+ def normalize_css(value)
126
+ css = value.gsub(%r{/\*.*?\*/}m, "").gsub(/\\(?:\r\n|[\n\r\f])/, "")
127
+ css = css.gsub(/\\([0-9a-f]{1,6})[ \t\r\n\f]?/i) { [Regexp.last_match(1).to_i(16)].pack("U") }
128
+ css.gsub(/\\(.)/m, "\\1")
129
+ rescue RangeError
130
+ raise InputError, "SVG contains an invalid CSS escape"
131
+ end
132
+ end
133
+
134
+ # Rasterizes a validated SVG at the resolution required by the sampling grids.
135
+ class SVGLoader
136
+ BASE_DPI = 96.0
137
+ MAXIMUM_FILE_BYTES = 15_000_000
138
+
139
+ def initialize(settings)
140
+ @settings = settings
141
+ @validator = SVGDocumentValidator.new
142
+ end
143
+
144
+ def call(path, access:)
145
+ data = read(path)
146
+ @validator.call(data)
147
+ ensure_loader!
148
+
149
+ probe = render(data, scale: 1.0, access: :sequential)
150
+ validate_rendered_image!(probe, pixels: false)
151
+ scale = render_scale(probe)
152
+ validate_planned_pixels!(probe, scale)
153
+
154
+ image = render(data, scale:, access:)
155
+ validate_rendered_image!(image)
156
+ image
157
+ end
158
+
159
+ private
160
+
161
+ def read(path)
162
+ raise InputError, "SVG input exceeds #{MAXIMUM_FILE_BYTES} bytes" if File.size(path) > MAXIMUM_FILE_BYTES
163
+
164
+ data = File.binread(path, MAXIMUM_FILE_BYTES + 1)
165
+ raise InputError, "SVG input exceeds #{MAXIMUM_FILE_BYTES} bytes" if data.bytesize > MAXIMUM_FILE_BYTES
166
+ raise InputError, "compressed SVG (SVGZ) is not supported" if data.start_with?("\x1F\x8B".b)
167
+
168
+ data
169
+ end
170
+
171
+ def ensure_loader!
172
+ Vips::Operation.new("svgload_buffer")
173
+ rescue Vips::Error
174
+ raise InputError, "SVG input requires libvips with librsvg support; install libvips with SVG support"
175
+ end
176
+
177
+ def render(data, scale:, access:)
178
+ Vips::Image.new_from_buffer(data, "", dpi: BASE_DPI, scale:, unlimited: false, access:)
179
+ rescue Vips::Error => e
180
+ raise InputError, "could not render SVG: #{e.message}"
181
+ end
182
+
183
+ def render_scale(image)
184
+ scales = sampling_grids.map do |grid|
185
+ width, height = grid.content_sample_size
186
+ ratios = [width.fdiv(image.width), height.fdiv(image.height)]
187
+ @settings.image_fit == "contain" ? ratios.min * @settings.crop_scale : ratios.max / @settings.crop_scale
188
+ end
189
+ [*scales, 1.0 / image.width, 1.0 / image.height].max
190
+ end
191
+
192
+ def sampling_grids
193
+ grids = [@settings.sampling_grid]
194
+ grids << @settings.color_sampling_grid if @settings.color_pixel_size
195
+ grids
196
+ end
197
+
198
+ def validate_planned_pixels!(image, scale)
199
+ width = [(image.width * scale).ceil, 1].max
200
+ height = [(image.height * scale).ceil, 1].max
201
+ validate_pixel_count!(width, height, "SVG rasterization would exceed")
202
+ end
203
+
204
+ def validate_rendered_image!(image, pixels: true)
205
+ unless image.get("vips-loader") == "svgload_buffer" && image.width.positive? && image.height.positive?
206
+ raise InputError, "could not render SVG with positive dimensions"
207
+ end
208
+
209
+ validate_pixel_count!(image.width, image.height, "SVG rasterization exceeds") if pixels
210
+ end
211
+
212
+ def validate_pixel_count!(width, height, prefix)
213
+ return unless width * height > @settings.maximum_input_pixels
214
+
215
+ raise InputError, "#{prefix} #{@settings.maximum_input_pixels} pixels"
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Measures named generation stages for an optional benchmark listener.
5
+ class Instrumentation
6
+ def initialize(listener = nil)
7
+ @listener = listener
8
+ end
9
+
10
+ def measure(stage)
11
+ return yield unless @listener
12
+
13
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+ yield
15
+ ensure
16
+ if @listener && started
17
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
18
+ @listener.call(stage, elapsed)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Lithop
6
+ # Owns one temporary job directory and publishes it with an atomic rename.
7
+ class JobDirectory
8
+ attr_reader :destination, :temporary
9
+
10
+ def initialize(project_dir:, job_id:)
11
+ build_dir = File.join(File.expand_path(project_dir), "build")
12
+ @destination = File.join(build_dir, job_id)
13
+ @temporary = File.join(build_dir, ".tmp-#{job_id}")
14
+ @owned = false
15
+ end
16
+
17
+ def call
18
+ acquire
19
+ result = yield temporary
20
+ publish
21
+ [destination, result]
22
+ rescue StandardError, Interrupt
23
+ cleanup
24
+ raise
25
+ end
26
+
27
+ private
28
+
29
+ def acquire
30
+ FileUtils.mkdir_p(File.dirname(temporary))
31
+ raise Error, "output already exists: #{destination}" if File.exist?(destination)
32
+
33
+ Dir.mkdir(temporary)
34
+ @owned = true
35
+ rescue Errno::EEXIST
36
+ raise Error, "output already exists: #{File.exist?(destination) ? destination : temporary}"
37
+ end
38
+
39
+ def publish
40
+ raise Error, "output already exists: #{destination}" if File.exist?(destination)
41
+
42
+ File.rename(temporary, destination)
43
+ @owned = false
44
+ end
45
+
46
+ def cleanup
47
+ return unless @owned
48
+
49
+ FileUtils.rm_rf(temporary)
50
+ @owned = false
51
+ rescue StandardError
52
+ @owned = false
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Calculates printable Z boundaries and the physical CMYW layer structure.
5
+ # rubocop:disable-next Metrics/ClassLength
6
+ class LayerStructure
7
+ MICRONS_PER_MM = 1_000
8
+ MINIMUM_TOP_WHITE_LAYERS = 3
9
+
10
+ attr_reader :first_layer_height, :layer_height, :color_layer_count, :base_white_layers,
11
+ :top_layer_height, :top_white_start, :minimum_top_white_layers,
12
+ :effective_minimum_thickness, :effective_maximum_thickness
13
+
14
+ # rubocop:disable-next Metrics/ParameterLists
15
+ def initialize(
16
+ first_layer_height:, layer_height:, color_layer_count:, minimum_thickness:, maximum_thickness:,
17
+ base_white_layers: 1, top_layer_height: nil, top_white_start: nil,
18
+ minimum_top_white_layers: MINIMUM_TOP_WHITE_LAYERS, exact_maximum_thickness: false
19
+ )
20
+ @first_layer_height = first_layer_height
21
+ @layer_height = layer_height
22
+ @color_layer_count = color_layer_count
23
+ @base_white_layers = base_white_layers
24
+ @top_layer_height = top_layer_height || layer_height
25
+ @top_white_start = top_white_start || color_end
26
+ @minimum_top_white_layers = minimum_top_white_layers
27
+ @effective_minimum_thickness = aligned_at_or_above(minimum_thickness)
28
+ @effective_maximum_thickness = if exact_maximum_thickness
29
+ maximum_thickness.round(9)
30
+ else
31
+ aligned_at_or_below(maximum_thickness)
32
+ end
33
+ end
34
+
35
+ def minimum_physical_thickness
36
+ (top_white_start + (minimum_top_white_layers * top_layer_height)).round(9)
37
+ end
38
+
39
+ def quantize_thickness(value)
40
+ printable_thicknesses.min_by { |boundary| [(boundary - value).abs.round(12), -boundary] }
41
+ end
42
+
43
+ def normal_layer_count(height)
44
+ boundary_index(height) - base_white_layers
45
+ end
46
+
47
+ def top_white_layer_count(height, pigment_layers: color_layer_count)
48
+ boundary_index(height) - base_white_layers - pigment_layers
49
+ end
50
+
51
+ def top_white_thickness(height, pigment_layers: color_layer_count)
52
+ return 0.0 if pigment_layers != color_layer_count
53
+
54
+ (height - color_end).round(9)
55
+ end
56
+
57
+ def z_boundaries(material_layer_count)
58
+ all_boundaries.first(material_layer_count + 1)
59
+ end
60
+
61
+ def color_region_thickness = (color_layer_count * layer_height).round(9)
62
+ def base_white_thickness = (base_white_layers * first_layer_height).round(9)
63
+ def minimum_top_white_thickness = (minimum_physical_thickness - color_end).round(9)
64
+
65
+ def materials_for(recipe_layers, height)
66
+ top_white_layers = top_white_layer_count(height, pigment_layers: recipe_layers.length)
67
+ [*Array.new(base_white_layers, :white), *recipe_layers, *Array.new(top_white_layers, :white)]
68
+ end
69
+
70
+ private
71
+
72
+ def color_end
73
+ ((base_white_layers * first_layer_height) + color_region_thickness).round(9)
74
+ end
75
+
76
+ def aligned_at_or_above(value)
77
+ return top_white_start if value <= top_white_start
78
+
79
+ steps = (((value - top_white_start) / top_layer_height) - 1e-9).ceil
80
+ (top_white_start + (steps * top_layer_height)).round(9)
81
+ end
82
+
83
+ def aligned_at_or_below(value)
84
+ return 0.0 if value < top_white_start
85
+
86
+ steps = (((value - top_white_start) / top_layer_height) + 1e-9).floor
87
+ (top_white_start + (steps * top_layer_height)).round(9)
88
+ end
89
+
90
+ def printable_thicknesses
91
+ @printable_thicknesses ||= begin
92
+ values = []
93
+ value = effective_minimum_thickness
94
+ while value <= effective_maximum_thickness + 1e-9
95
+ values << value.round(9)
96
+ value += top_layer_height
97
+ end
98
+ values << effective_maximum_thickness unless values.any? do |candidate|
99
+ same_boundary?(candidate, effective_maximum_thickness)
100
+ end
101
+ values.freeze
102
+ end
103
+ end
104
+
105
+ # rubocop:disable-next Metrics/AbcSize
106
+ def all_boundaries
107
+ @all_boundaries ||= begin
108
+ values = [0.0]
109
+ base_white_layers.times { |index| values << ((index + 1) * first_layer_height).round(9) }
110
+ color_layer_count.times do |index|
111
+ values << (color_end - ((color_layer_count - index - 1) * layer_height)).round(9)
112
+ end
113
+ values << top_white_start unless same_boundary?(values.last, top_white_start)
114
+ value = (top_white_start + top_layer_height).round(9)
115
+ while value <= effective_maximum_thickness + 1e-9
116
+ values << value unless values.any? { |candidate| same_boundary?(candidate, value) }
117
+ value = (value + top_layer_height).round(9)
118
+ end
119
+ values << effective_maximum_thickness unless
120
+ values.any? { |candidate| same_boundary?(candidate, effective_maximum_thickness) }
121
+ values.freeze
122
+ end
123
+ end
124
+
125
+ def boundary_index(height)
126
+ index = all_boundaries.index { |boundary| same_boundary?(boundary, height) }
127
+ return index if index
128
+
129
+ raise ConfigurationError, "thickness #{height} is not a printable layer boundary"
130
+ end
131
+
132
+ def same_boundary?(left, right) = (left - right).abs < 1e-8
133
+ end
134
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Builds one closed pigment solid from a single contiguous Z interval per XY cell.
5
+ # rubocop:disable-next Metrics/ClassLength
6
+ class ColumnGeometry
7
+ include Enumerable
8
+
9
+ def initialize(model:, material:)
10
+ unless Palette::PIGMENTS.include?(material)
11
+ raise InputError, "column geometry requires a CMY pigment: #{material.inspect}"
12
+ end
13
+
14
+ @model = model
15
+ @material = material
16
+ @intervals = model.cells.map { |cell| interval(cell.materials) }.freeze
17
+ @topology = ColumnTopology.new(model:, intervals: @intervals)
18
+ end
19
+
20
+ def each_triangle(&)
21
+ return enum_for(__method__) unless block_given?
22
+
23
+ @model.rows.times do |row|
24
+ @model.columns.times do |column|
25
+ current = interval_at(row, column)
26
+ next unless current
27
+
28
+ emit_caps(row, column, current, &)
29
+ emit_sides(row, column, current, &)
30
+ end
31
+ end
32
+ end
33
+
34
+ def each(&) = each_triangle(&)
35
+
36
+ def release_cache! = @topology.release_cache!
37
+
38
+ def triangle_count
39
+ @model.rows.times.sum do |row|
40
+ @model.columns.times.sum do |column|
41
+ current = interval_at(row, column)
42
+ next 0 unless current
43
+
44
+ side_layers = exposed_layer_count(current, grouped_interval(row, column, row, column - 1)) +
45
+ exposed_layer_count(current, grouped_interval(row, column, row, column + 1)) +
46
+ exposed_layer_count(current, grouped_interval(row, column, row + 1, column)) +
47
+ exposed_layer_count(current, grouped_interval(row, column, row - 1, column))
48
+ 4 + (side_layers * 2)
49
+ end
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def interval(materials)
56
+ first = materials.index(@material)
57
+ return unless first
58
+
59
+ last = materials.rindex(@material)
60
+ [first, last + 1]
61
+ end
62
+
63
+ def interval_at(row, column)
64
+ return if row.negative? || column.negative? || row >= @model.rows || column >= @model.columns
65
+
66
+ @intervals.fetch((row * @model.columns) + column)
67
+ end
68
+
69
+ def emit_caps(row, column, current, &)
70
+ bottom, top = current
71
+ bottom_owner = [bottom, row, column]
72
+ top_owner = [top - 1, row, column]
73
+ quad(
74
+ vertex(column, row + 1, bottom, bottom_owner), vertex(column, row, bottom, bottom_owner),
75
+ vertex(column + 1, row, bottom, bottom_owner), vertex(column + 1, row + 1, bottom, bottom_owner), &
76
+ )
77
+ quad(
78
+ vertex(column, row + 1, top, top_owner), vertex(column + 1, row + 1, top, top_owner),
79
+ vertex(column + 1, row, top, top_owner), vertex(column, row, top, top_owner), &
80
+ )
81
+ end
82
+
83
+ # Four directions intentionally stay together so their winding order is easy to compare.
84
+ # rubocop:disable-next Metrics/AbcSize
85
+ def emit_sides(row, column, current, &)
86
+ emit_side(current, grouped_interval(row, column, row, column - 1)) do |layer|
87
+ owner = [layer, row, column]
88
+ quad(
89
+ vertex(column, row + 1, layer, owner), vertex(column, row + 1, layer + 1, owner),
90
+ vertex(column, row, layer + 1, owner), vertex(column, row, layer, owner), &
91
+ )
92
+ end
93
+ emit_side(current, grouped_interval(row, column, row, column + 1)) do |layer|
94
+ owner = [layer, row, column]
95
+ quad(
96
+ vertex(column + 1, row + 1, layer, owner), vertex(column + 1, row, layer, owner),
97
+ vertex(column + 1, row, layer + 1, owner), vertex(column + 1, row + 1, layer + 1, owner), &
98
+ )
99
+ end
100
+ emit_side(current, grouped_interval(row, column, row + 1, column)) do |layer|
101
+ owner = [layer, row, column]
102
+ quad(
103
+ vertex(column, row + 1, layer, owner), vertex(column + 1, row + 1, layer, owner),
104
+ vertex(column + 1, row + 1, layer + 1, owner), vertex(column, row + 1, layer + 1, owner), &
105
+ )
106
+ end
107
+ emit_side(current, grouped_interval(row, column, row - 1, column)) do |layer|
108
+ owner = [layer, row, column]
109
+ quad(
110
+ vertex(column, row, layer, owner), vertex(column, row, layer + 1, owner),
111
+ vertex(column + 1, row, layer + 1, owner), vertex(column + 1, row, layer, owner), &
112
+ )
113
+ end
114
+ end
115
+
116
+ def emit_side(current, neighbour, &)
117
+ bottom, top = current
118
+ unless neighbour
119
+ emit_layers(bottom, top, &)
120
+ return
121
+ end
122
+ neighbour_bottom, neighbour_top = neighbour
123
+ if neighbour_top <= bottom || neighbour_bottom >= top
124
+ emit_layers(bottom, top, &)
125
+ return
126
+ end
127
+ emit_layers(bottom, [top, neighbour_bottom].min, &) if neighbour_bottom > bottom
128
+ return unless neighbour_top < top
129
+
130
+ emit_layers([bottom, neighbour_top].max, top, &)
131
+ end
132
+
133
+ def exposed_layer_count(current, neighbour)
134
+ bottom, top = current
135
+ return top - bottom unless neighbour
136
+
137
+ neighbour_bottom, neighbour_top = neighbour
138
+ overlap = [[top, neighbour_top].min - [bottom, neighbour_bottom].max, 0].max
139
+ (top - bottom) - overlap
140
+ end
141
+
142
+ def grouped_interval(row, column, neighbour_row, neighbour_column)
143
+ return unless @topology.same_group?(row, column, neighbour_row, neighbour_column)
144
+
145
+ interval_at(neighbour_row, neighbour_column)
146
+ end
147
+
148
+ def emit_layers(bottom, top, &)
149
+ (bottom...top).each(&)
150
+ end
151
+
152
+ def vertex(x_index, row_boundary, z_index, owner)
153
+ @topology.vertex(x_index, row_boundary, z_index, owner)
154
+ end
155
+
156
+ def quad(a, b, c, d)
157
+ yield [a, b, c]
158
+ yield [a, c, d]
159
+ end
160
+ end
161
+ end