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,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Finds columns that must become separate shells to remove four-face edge contacts.
5
+ # rubocop:disable-next Metrics/ClassLength
6
+ class ColumnIsolation
7
+ def initialize(model:, intervals:)
8
+ @model = model
9
+ @intervals = intervals
10
+ end
11
+
12
+ def groups
13
+ isolated = {}
14
+ each_edge_contact do |endpoints, owners|
15
+ next unless endpoints.all? { |endpoint| merged_at?(endpoint, owners) }
16
+
17
+ _layer, row, column = owners.max
18
+ isolated[[row, column]] = true
19
+ end
20
+ isolated.each_key.with_index(1).to_h.freeze
21
+ end
22
+
23
+ private
24
+
25
+ def each_edge_contact(&)
26
+ each_x_edge_contact(&)
27
+ each_row_edge_contact(&)
28
+ each_z_edge_contact(&)
29
+ end
30
+
31
+ def each_x_edge_contact
32
+ @model.columns.times do |column|
33
+ (1...@model.rows).each do |row_boundary|
34
+ upper = interval_at(row_boundary - 1, column)
35
+ lower = interval_at(row_boundary, column)
36
+ contact = touching_layers(upper, lower)
37
+ next unless contact
38
+
39
+ z_index, upper_layer, lower_layer = contact
40
+ owners = [[upper_layer, row_boundary - 1, column], [lower_layer, row_boundary, column]]
41
+ yield [[column, row_boundary, z_index], [column + 1, row_boundary, z_index]], owners
42
+ end
43
+ end
44
+ end
45
+
46
+ def each_row_edge_contact
47
+ @model.rows.times do |row|
48
+ (1...@model.columns).each do |x_index|
49
+ left = interval_at(row, x_index - 1)
50
+ right = interval_at(row, x_index)
51
+ contact = touching_layers(left, right)
52
+ next unless contact
53
+
54
+ z_index, left_layer, right_layer = contact
55
+ owners = [[left_layer, row, x_index - 1], [right_layer, row, x_index]]
56
+ yield [[x_index, row, z_index], [x_index, row + 1, z_index]], owners
57
+ end
58
+ end
59
+ end
60
+
61
+ def each_z_edge_contact
62
+ (1...@model.rows).each do |row_boundary|
63
+ (1...@model.columns).each do |x_index|
64
+ columns = [
65
+ [row_boundary - 1, x_index - 1], [row_boundary - 1, x_index],
66
+ [row_boundary, x_index - 1], [row_boundary, x_index]
67
+ ]
68
+ intervals = columns.map { |row, column| interval_at(row, column) }
69
+ [[0, 3, 1, 2], [1, 2, 0, 3]].each do |first, second, lower_bridge, upper_bridge|
70
+ layer = vertical_contact(
71
+ intervals[first], intervals[second], intervals[lower_bridge], intervals[upper_bridge]
72
+ )
73
+ next unless layer
74
+
75
+ owners = [first, second].map do |index|
76
+ row, column = columns[index]
77
+ [layer, row, column]
78
+ end
79
+ yield [[x_index, row_boundary, layer], [x_index, row_boundary, layer + 1]], owners
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def vertical_contact(first, second, lower_bridge, upper_bridge)
86
+ return unless first && second && lower_bridge && upper_bridge
87
+
88
+ layer = if lower_bridge.last + 1 == upper_bridge.first
89
+ lower_bridge.last
90
+ elsif upper_bridge.last + 1 == lower_bridge.first
91
+ upper_bridge.last
92
+ end
93
+ return unless layer
94
+ return unless [first, second].all? { |interval| interval.first <= layer - 1 && interval.last >= layer + 2 }
95
+
96
+ layer
97
+ end
98
+
99
+ def merged_at?(endpoint, owners)
100
+ x_index, row_boundary, z_index = endpoint
101
+ incident = [z_index - 1, z_index].product(
102
+ [row_boundary - 1, row_boundary],
103
+ [x_index - 1, x_index]
104
+ ).select { |owner| occupied?(*owner) }
105
+ connected?(owners.first, owners.last, incident)
106
+ end
107
+
108
+ def connected?(start, target, incident)
109
+ visited = {}
110
+ pending = [start]
111
+ until pending.empty?
112
+ current = pending.pop
113
+ next if visited[current]
114
+ return true if current == target
115
+
116
+ visited[current] = true
117
+ pending.concat(incident.reject do |candidate|
118
+ visited[candidate] || !face_neighbours?(current, candidate)
119
+ end)
120
+ end
121
+ false
122
+ end
123
+
124
+ def face_neighbours?(left, right)
125
+ left.zip(right).sum { |a, b| (a - b).abs } == 1
126
+ end
127
+
128
+ def touching_layers(first, second)
129
+ return unless first && second
130
+ return [first.last, first.last - 1, second.first] if first.last == second.first
131
+
132
+ [second.last, first.first, second.last - 1] if second.last == first.first
133
+ end
134
+
135
+ def interval_at(row, column)
136
+ return if row.negative? || column.negative? || row >= @model.rows || column >= @model.columns
137
+
138
+ @intervals.fetch((row * @model.columns) + column)
139
+ end
140
+
141
+ def occupied?(layer, row, column)
142
+ return false if layer.negative? || row.negative? || column.negative?
143
+ return false if row >= @model.rows || column >= @model.columns
144
+
145
+ interval = @intervals.fetch((row * @model.columns) + column)
146
+ interval && layer >= interval.first && layer < interval.last
147
+ end
148
+ end
149
+
150
+ # Assigns stable vertex identities after exceptional columns are isolated.
151
+ class ColumnTopology
152
+ Vertex = VoxelTopology::Vertex
153
+ COMPONENT_ROOTS = Array.new(256) do |mask|
154
+ roots = Array.new(8)
155
+ 8.times do |start|
156
+ next if mask.nobits?(1 << start) || roots[start]
157
+
158
+ component = []
159
+ pending = [start]
160
+ until pending.empty?
161
+ current = pending.pop
162
+ next if component.include?(current)
163
+
164
+ component << current
165
+ 8.times do |candidate|
166
+ next if mask.nobits?(1 << candidate) || component.include?(candidate)
167
+
168
+ pending << candidate if (current ^ candidate).then { |difference| [1, 2, 4].include?(difference) }
169
+ end
170
+ end
171
+ root = component.min
172
+ component.each { |member| roots[member] = root }
173
+ end
174
+ roots.freeze
175
+ end.freeze
176
+
177
+ def initialize(model:, intervals:)
178
+ @model = model
179
+ @intervals = intervals
180
+ isolated = ColumnIsolation.new(model:, intervals:).groups
181
+ @group_ids = Array.new(model.rows * model.columns, 0)
182
+ isolated.each { |(row, column), group| @group_ids[(row * model.columns) + column] = group }
183
+ @group_count = isolated.length + 1
184
+ @z_count = model.z_boundaries.length
185
+ @mask_cache = {}
186
+ @vertex_cache = {}
187
+ end
188
+
189
+ def same_group?(left_row, left_column, right_row, right_column)
190
+ group_at(left_row, left_column) == group_at(right_row, right_column)
191
+ end
192
+
193
+ def vertex(x_index, row_boundary, z_index, owner)
194
+ _layer, row, column = owner
195
+ group = group_at(row, column)
196
+ topology = topology_key(x_index, row_boundary, z_index, owner, group)
197
+ @vertex_cache[topology] ||= Vertex.new(
198
+ x: @model.x_boundaries.fetch(x_index),
199
+ y: @model.y_boundaries.last - @model.y_boundaries.fetch(row_boundary),
200
+ z: @model.z_boundaries.fetch(z_index),
201
+ topology:
202
+ )
203
+ end
204
+
205
+ def release_cache!
206
+ @mask_cache.clear
207
+ @vertex_cache.clear
208
+ end
209
+
210
+ private
211
+
212
+ def topology_key(x_index, row_boundary, z_index, owner, group)
213
+ coordinate_key = (((x_index * (@model.rows + 1)) + row_boundary) * @z_count) + z_index
214
+ cache_key = (coordinate_key * @group_count) + group
215
+ mask = @mask_cache[cache_key] ||= occupancy_mask(x_index, row_boundary, z_index, group)
216
+ owner_bit = owner_bit(owner, x_index, row_boundary, z_index)
217
+ component = COMPONENT_ROOTS.fetch(mask).fetch(owner_bit)
218
+ (cache_key * 8) + component
219
+ end
220
+
221
+ def occupancy_mask(x_index, row_boundary, z_index, group)
222
+ mask = 0
223
+ 2.times do |layer_offset|
224
+ 2.times do |row_offset|
225
+ 2.times do |column_offset|
226
+ bit = (layer_offset * 4) + (row_offset * 2) + column_offset
227
+ occupied = occupied?(
228
+ z_index - 1 + layer_offset,
229
+ row_boundary - 1 + row_offset,
230
+ x_index - 1 + column_offset,
231
+ group
232
+ )
233
+ mask |= (1 << bit) if occupied
234
+ end
235
+ end
236
+ end
237
+ mask
238
+ end
239
+
240
+ def owner_bit(owner, x_index, row_boundary, z_index)
241
+ layer, row, column = owner
242
+ ((layer - z_index + 1) * 4) + ((row - row_boundary + 1) * 2) + (column - x_index + 1)
243
+ end
244
+
245
+ def occupied?(layer, row, column, group)
246
+ interval = interval_at(row, column)
247
+ interval && layer >= interval.first && layer < interval.last && group_at(row, column) == group
248
+ end
249
+
250
+ def group_at(row, column)
251
+ return 0 if row.negative? || column.negative? || row >= @model.rows || column >= @model.columns
252
+
253
+ @group_ids.fetch((row * @model.columns) + column)
254
+ end
255
+
256
+ def interval_at(row, column)
257
+ return if row.negative? || column.negative? || row >= @model.rows || column >= @model.columns
258
+
259
+ @intervals.fetch((row * @model.columns) + column)
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Generates the closed triangle mesh for a monochrome height field.
5
+ class Geometry
6
+ def initialize(heights:, columns:, rows:, x_boundaries:, y_boundaries:, optimize_bottom: false)
7
+ @heights = heights
8
+ @columns = columns
9
+ @rows = rows
10
+ @x = x_boundaries
11
+ @y = y_boundaries
12
+ @optimize_bottom = optimize_bottom
13
+ expected = (columns + 1) * (rows + 1)
14
+ raise InputError, "expected #{expected} vertex heights, got #{heights.length}" unless heights.length == expected
15
+ end
16
+
17
+ def each_triangle(&block)
18
+ return enum_for(__method__) unless block
19
+
20
+ @rows.times do |row|
21
+ @columns.times { |column| emit_cell(row, column, &block) }
22
+ end
23
+ emit_perimeter(&block)
24
+ emit_bottom_fan(&block) if @optimize_bottom
25
+ end
26
+
27
+ private
28
+
29
+ def emit_cell(row, column, &)
30
+ x0 = @x.fetch(column)
31
+ x1 = @x.fetch(column + 1)
32
+ y0 = @y.last - @y.fetch(row + 1)
33
+ y1 = @y.last - @y.fetch(row)
34
+ top_left = [x0, y1, height(row, column)]
35
+ top_right = [x1, y1, height(row, column + 1)]
36
+ bottom_right = [x1, y0, height(row + 1, column + 1)]
37
+ bottom_left = [x0, y0, height(row + 1, column)]
38
+
39
+ yield [top_left, bottom_left, bottom_right]
40
+ yield [top_left, bottom_right, top_right]
41
+ return if @optimize_bottom
42
+
43
+ quad([x0, y1, 0.0], [x1, y1, 0.0], [x1, y0, 0.0], [x0, y0, 0.0], &)
44
+ end
45
+
46
+ def emit_perimeter(&)
47
+ @rows.times do |row|
48
+ emit_left_side(row, &)
49
+ emit_right_side(row, &)
50
+ end
51
+ @columns.times do |column|
52
+ emit_bottom_side(column, &)
53
+ emit_top_side(column, &)
54
+ end
55
+ end
56
+
57
+ def emit_left_side(row, &)
58
+ y0 = @y.last - @y.fetch(row + 1)
59
+ y1 = @y.last - @y.fetch(row)
60
+ 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], &
63
+ )
64
+ end
65
+
66
+ def emit_right_side(row, &)
67
+ x = @x.last
68
+ y0 = @y.last - @y.fetch(row + 1)
69
+ y1 = @y.last - @y.fetch(row)
70
+ quad(
71
+ [x, y0, 0.0], [x, y1, 0.0],
72
+ [x, y1, height(row, @columns)], [x, y0, height(row + 1, @columns)], &
73
+ )
74
+ end
75
+
76
+ def emit_bottom_side(column, &)
77
+ x0 = @x.fetch(column)
78
+ x1 = @x.fetch(column + 1)
79
+ quad(
80
+ [x0, 0.0, 0.0], [x1, 0.0, 0.0],
81
+ [x1, 0.0, height(@rows, column + 1)], [x0, 0.0, height(@rows, column)], &
82
+ )
83
+ end
84
+
85
+ def emit_top_side(column, &)
86
+ x0 = @x.fetch(column)
87
+ x1 = @x.fetch(column + 1)
88
+ y = @y.last
89
+ quad(
90
+ [x0, y, 0.0], [x0, y, height(0, column)],
91
+ [x1, y, height(0, column + 1)], [x1, y, 0.0], &
92
+ )
93
+ end
94
+
95
+ def emit_bottom_fan
96
+ center = [@x.last.fdiv(2), @y.last.fdiv(2), 0.0]
97
+ 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] }
102
+ perimeter.each_with_index do |from, index|
103
+ yield [center, from, perimeter.fetch((index + 1) % perimeter.length)]
104
+ end
105
+ end
106
+
107
+ def height(row, column)
108
+ @heights.fetch((row * (@columns + 1)) + column)
109
+ end
110
+
111
+ def quad(a, b, c, d)
112
+ yield [a, b, c]
113
+ yield [a, c, d]
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Validates that triangle meshes are closed and consistently oriented.
5
+ module MeshValidator
6
+ module_function
7
+
8
+ def validate!(triangles)
9
+ report!(triangles)
10
+ true
11
+ end
12
+
13
+ Report = Data.define(:triangle_count, :edge_count, :component_volumes, :volume)
14
+ Topology = Data.define(:edges, :triangle_volumes, :triangle_count)
15
+
16
+ # Vertex-only contacts remain separate components with positive volumes.
17
+ def report!(triangles)
18
+ topology = collect_topology(triangles)
19
+ validate_edges!(topology.edges)
20
+ component_volumes = component_volumes(
21
+ topology.triangle_count,
22
+ neighbours_for(topology.edges),
23
+ topology.triangle_volumes
24
+ )
25
+ raise InputError, "mesh is not outward-facing" if component_volumes.any? { |volume| volume <= 0.0 }
26
+
27
+ Report.new(
28
+ triangle_count: topology.triangle_count,
29
+ edge_count: topology.edges.length,
30
+ component_volumes: component_volumes.freeze,
31
+ volume: component_volumes.sum
32
+ )
33
+ end
34
+
35
+ def collect_topology(triangles)
36
+ edges = Hash.new { |hash, key| hash[key] = [] }
37
+ triangle_volumes = []
38
+ triangle_count = 0
39
+
40
+ triangles.each do |triangle|
41
+ validate_triangle!(triangle)
42
+ triangle_index = triangle_count
43
+ triangle_count += 1
44
+ triangle_volumes << signed_volume(triangle)
45
+ triangle.each_with_index do |from, index|
46
+ to = triangle[(index + 1) % 3]
47
+ key = { from => true, to => true }.freeze
48
+ edges[key] << [triangle_index, from, to]
49
+ end
50
+ end
51
+
52
+ raise InputError, "mesh contains no triangles" if triangle_count.zero?
53
+
54
+ Topology.new(edges:, triangle_volumes:, triangle_count:)
55
+ end
56
+
57
+ def validate_triangle!(triangle)
58
+ raise InputError, "triangle must contain three vertices" unless triangle.length == 3
59
+ raise InputError, "mesh contains a zero-area triangle" if zero_area?(triangle)
60
+ end
61
+
62
+ def validate_edges!(edges)
63
+ invalid = edges.find do |_key, faces|
64
+ faces.length != 2 || faces[0].drop(1) != faces[1].drop(1).reverse
65
+ end
66
+ raise InputError, "mesh is not a closed, consistently oriented surface" if invalid
67
+ end
68
+
69
+ def neighbours_for(edges)
70
+ edges.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(_key, faces), neighbours|
71
+ left, right = faces.map(&:first)
72
+ neighbours[left] << right
73
+ neighbours[right] << left
74
+ end
75
+ end
76
+
77
+ def zero_area?(triangle)
78
+ a, b, c = triangle.map { |vertex| coordinates(vertex) }
79
+ ab = b.zip(a).map { |left, right| left - right }
80
+ ac = c.zip(a).map { |left, right| left - right }
81
+ cross = [
82
+ (ab[1] * ac[2]) - (ab[2] * ac[1]),
83
+ (ab[2] * ac[0]) - (ab[0] * ac[2]),
84
+ (ab[0] * ac[1]) - (ab[1] * ac[0])
85
+ ]
86
+ cross.all?(&:zero?)
87
+ end
88
+
89
+ def coordinates(vertex)
90
+ vertex.respond_to?(:coordinates) ? vertex.coordinates : vertex
91
+ end
92
+
93
+ def signed_volume(triangle)
94
+ a, b, c = triangle.map { |vertex| coordinates(vertex) }
95
+ (
96
+ (a[0] * ((b[1] * c[2]) - (b[2] * c[1]))) -
97
+ (a[1] * ((b[0] * c[2]) - (b[2] * c[0]))) +
98
+ (a[2] * ((b[0] * c[1]) - (b[1] * c[0])))
99
+ ) / 6.0
100
+ end
101
+
102
+ def component_volumes(triangle_count, neighbours, triangle_volumes)
103
+ visited = Array.new(triangle_count, false)
104
+ triangle_count.times.filter_map do |start|
105
+ next if visited[start]
106
+
107
+ pending = [start]
108
+ volume = 0.0
109
+ until pending.empty?
110
+ index = pending.pop
111
+ next if visited[index]
112
+
113
+ visited[index] = true
114
+ volume += triangle_volumes.fetch(index)
115
+ pending.concat(neighbours.fetch(index, []).reject { |candidate| visited[candidate] })
116
+ end
117
+ volume
118
+ end
119
+ end
120
+ private_class_method :coordinates, :zero_area?, :signed_volume, :component_volumes, :collect_topology,
121
+ :validate_triangle!, :validate_edges!, :neighbours_for
122
+ end
123
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Generates the exposed triangles for one material in a voxel model.
5
+ class VoxelGeometry
6
+ Vertex = VoxelTopology::Vertex
7
+
8
+ def initialize(model:, material:)
9
+ raise InputError, "unknown material: #{material.inspect}" unless Palette::MATERIALS.include?(material)
10
+
11
+ @model = model
12
+ @material = material
13
+ @topology = VoxelTopology.new(model:, material:)
14
+ end
15
+
16
+ def each_triangle(&)
17
+ return enum_for(__method__) unless block_given?
18
+
19
+ (@model.z_boundaries.length - 1).times do |layer|
20
+ @model.rows.times do |row|
21
+ @model.columns.times do |column|
22
+ emit_voxel(layer, row, column, &) if occupied?(layer, row, column)
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ def triangle_count
29
+ count = 0
30
+ each_occupied_voxel do |layer, row, column|
31
+ count += 2 unless occupied?(layer - 1, row, column)
32
+ count += 2 unless occupied?(layer + 1, row, column)
33
+ count += 2 unless occupied?(layer, row, column - 1)
34
+ count += 2 unless occupied?(layer, row, column + 1)
35
+ count += 2 unless occupied?(layer, row + 1, column)
36
+ count += 2 unless occupied?(layer, row - 1, column)
37
+ end
38
+ count
39
+ end
40
+
41
+ private
42
+
43
+ def each_occupied_voxel
44
+ (@model.z_boundaries.length - 1).times do |layer|
45
+ @model.rows.times do |row|
46
+ @model.columns.times do |column|
47
+ yield layer, row, column if occupied?(layer, row, column)
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ def emit_voxel(layer, row, column, &)
54
+ owner = [layer, row, column]
55
+ x0 = column
56
+ x1 = column + 1
57
+ y0 = row + 1
58
+ y1 = row
59
+ z0 = layer
60
+ z1 = layer + 1
61
+
62
+ emit_bottom(x0, x1, y0, y1, z0, owner, &) unless occupied?(layer - 1, row, column)
63
+ emit_top(x0, x1, y0, y1, z1, owner, &) unless occupied?(layer + 1, row, column)
64
+ emit_left(x0, y0, y1, z0, z1, owner, &) unless occupied?(layer, row, column - 1)
65
+ emit_right(x1, y0, y1, z0, z1, owner, &) unless occupied?(layer, row, column + 1)
66
+ emit_front(x0, x1, y0, z0, z1, owner, &) unless occupied?(layer, row + 1, column)
67
+ emit_back(x0, x1, y1, z0, z1, owner, &) unless occupied?(layer, row - 1, column)
68
+ end
69
+
70
+ def occupied?(layer, row, column)
71
+ @model.material_at(layer, row, column) == @material
72
+ end
73
+
74
+ def emit_bottom(x0, x1, y0, y1, z, owner, &)
75
+ quad(vertex(x0, y0, z, owner), vertex(x0, y1, z, owner),
76
+ vertex(x1, y1, z, owner), vertex(x1, y0, z, owner), &)
77
+ end
78
+
79
+ def emit_top(x0, x1, y0, y1, z, owner, &)
80
+ quad(vertex(x0, y0, z, owner), vertex(x1, y0, z, owner),
81
+ vertex(x1, y1, z, owner), vertex(x0, y1, z, owner), &)
82
+ end
83
+
84
+ def emit_left(x, y0, y1, z0, z1, owner, &)
85
+ quad(vertex(x, y0, z0, owner), vertex(x, y0, z1, owner),
86
+ vertex(x, y1, z1, owner), vertex(x, y1, z0, owner), &)
87
+ end
88
+
89
+ def emit_right(x, y0, y1, z0, z1, owner, &)
90
+ quad(vertex(x, y0, z0, owner), vertex(x, y1, z0, owner),
91
+ vertex(x, y1, z1, owner), vertex(x, y0, z1, owner), &)
92
+ end
93
+
94
+ def emit_front(x0, x1, y, z0, z1, owner, &)
95
+ quad(vertex(x0, y, z0, owner), vertex(x1, y, z0, owner),
96
+ vertex(x1, y, z1, owner), vertex(x0, y, z1, owner), &)
97
+ end
98
+
99
+ def emit_back(x0, x1, y, z0, z1, owner, &)
100
+ quad(vertex(x0, y, z0, owner), vertex(x0, y, z1, owner),
101
+ vertex(x1, y, z1, owner), vertex(x1, y, z0, owner), &)
102
+ end
103
+
104
+ def vertex(x_index, row_boundary, z_index, owner)
105
+ @topology.vertex(x_index, row_boundary, z_index, owner)
106
+ end
107
+
108
+ def quad(a, b, c, d)
109
+ yield [a, b, c]
110
+ yield [a, c, d]
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Assigns stable topological identities to multipart voxel vertices.
5
+ class VoxelTopology
6
+ Vertex = Data.define(:x, :y, :z, :topology) do
7
+ def coordinates
8
+ [x, y, z]
9
+ end
10
+
11
+ def hash = topology.hash
12
+
13
+ def eql?(other) = other.is_a?(self.class) && topology.eql?(other.topology)
14
+
15
+ alias_method :==, :eql?
16
+ end
17
+
18
+ def initialize(model:, material:)
19
+ @model = model
20
+ @material = material
21
+ @component_cache = {}
22
+ end
23
+
24
+ def vertex(x_index, row_boundary, z_index, owner)
25
+ Vertex.new(
26
+ x: @model.x_boundaries.fetch(x_index),
27
+ y: @model.y_boundaries.last - @model.y_boundaries.fetch(row_boundary),
28
+ z: @model.z_boundaries.fetch(z_index),
29
+ topology: topology_key(x_index, row_boundary, z_index, owner)
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def topology_key(x_index, row_boundary, z_index, owner)
36
+ cache_key = [x_index, row_boundary, z_index]
37
+ components = @component_cache[cache_key] ||= components_at(x_index, row_boundary, z_index)
38
+ [x_index, row_boundary, z_index, components.fetch(owner)]
39
+ end
40
+
41
+ def components_at(x_index, row_boundary, z_index)
42
+ incident = incident_voxels(x_index, row_boundary, z_index)
43
+ components = {}
44
+ incident.each do |owner|
45
+ next if components.key?(owner)
46
+
47
+ component = []
48
+ pending = [owner]
49
+ until pending.empty?
50
+ current = pending.pop
51
+ next if component.include?(current)
52
+
53
+ component << current
54
+ pending.concat(incident.reject do |candidate|
55
+ component.include?(candidate) || !face_neighbours?(current, candidate)
56
+ end)
57
+ end
58
+ root = component.min
59
+ component.each { |member| components[member] = root }
60
+ end
61
+ components.freeze
62
+ end
63
+
64
+ def incident_voxels(x_index, row_boundary, z_index)
65
+ [z_index - 1, z_index].product(
66
+ [row_boundary - 1, row_boundary],
67
+ [x_index - 1, x_index]
68
+ ).select { |layer, row, column| occupied?(layer, row, column) }
69
+ end
70
+
71
+ def face_neighbours?(left, right)
72
+ left.zip(right).sum { |a, b| (a - b).abs } == 1
73
+ end
74
+
75
+ def occupied?(layer, row, column)
76
+ @model.material_at(layer, row, column) == @material
77
+ end
78
+ end
79
+ end