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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/RELEASING.md +63 -0
- data/bin/lithop +6 -0
- data/docs/README.md +44 -0
- data/docs/artifacts.md +122 -0
- data/docs/bambu-studio.md +93 -0
- data/docs/calibration.md +126 -0
- data/docs/color-model.md +114 -0
- data/docs/command.md +182 -0
- data/docs/config.md +201 -0
- data/docs/getting-started.md +98 -0
- data/docs/troubleshooting.md +112 -0
- data/lib/lithop/calibration/export.rb +102 -0
- data/lib/lithop/calibration/profile.rb +127 -0
- data/lib/lithop/calibration/profile_measurements.rb +82 -0
- data/lib/lithop/calibration/recipe_selector.rb +54 -0
- data/lib/lithop/calibration/target.rb +135 -0
- data/lib/lithop/calibration/target_svg.rb +89 -0
- data/lib/lithop/calibration/thickness_selector.rb +35 -0
- data/lib/lithop/cli/calibration.rb +40 -0
- data/lib/lithop/cli/image_options.rb +69 -0
- data/lib/lithop/cli/options.rb +95 -0
- data/lib/lithop/cli/settings.rb +34 -0
- data/lib/lithop/cli.rb +102 -0
- data/lib/lithop/export/calibration.rb +46 -0
- data/lib/lithop/export/color_metadata.rb +80 -0
- data/lib/lithop/export/generation.rb +122 -0
- data/lib/lithop/export/job.rb +49 -0
- data/lib/lithop/export/metadata.rb +85 -0
- data/lib/lithop/export/planning.rb +78 -0
- data/lib/lithop/export/previews.rb +35 -0
- data/lib/lithop/export/print_readme.rb +69 -0
- data/lib/lithop/export/result.rb +21 -0
- data/lib/lithop/formats/stl.rb +60 -0
- data/lib/lithop/formats/three_mf/bambu_metadata.rb +105 -0
- data/lib/lithop/formats/three_mf/mesh.rb +55 -0
- data/lib/lithop/formats/three_mf/model_writer.rb +114 -0
- data/lib/lithop/formats/three_mf/part_writer.rb +49 -0
- data/lib/lithop/formats/three_mf.rb +128 -0
- data/lib/lithop/image/pipeline.rb +175 -0
- data/lib/lithop/image/placement.rb +54 -0
- data/lib/lithop/image/preview.rb +63 -0
- data/lib/lithop/image/svg_loader.rb +218 -0
- data/lib/lithop/instrumentation.rb +22 -0
- data/lib/lithop/job_directory.rb +55 -0
- data/lib/lithop/layer_structure.rb +134 -0
- data/lib/lithop/mesh/column_geometry.rb +161 -0
- data/lib/lithop/mesh/column_topology.rb +262 -0
- data/lib/lithop/mesh/geometry.rb +116 -0
- data/lib/lithop/mesh/validator.rb +123 -0
- data/lib/lithop/mesh/voxel_geometry.rb +113 -0
- data/lib/lithop/mesh/voxel_topology.rb +79 -0
- data/lib/lithop/model/filament_profile.rb +79 -0
- data/lib/lithop/model/material_display_map.rb +61 -0
- data/lib/lithop/model/material_model.rb +106 -0
- data/lib/lithop/model/palette.rb +101 -0
- data/lib/lithop/model/thickness.rb +45 -0
- data/lib/lithop/reference_three_mf.rb +119 -0
- data/lib/lithop/sampling_grid.rb +41 -0
- data/lib/lithop/settings.rb +295 -0
- data/lib/lithop/settings_file.rb +56 -0
- data/lib/lithop/version.rb +5 -0
- data/lib/lithop.rb +55 -0
- data/sig/lithop.rbs +291 -0
- 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
|