lithop 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +14 -0
- data/Cargo.lock +284 -0
- data/Cargo.toml +6 -0
- data/README.md +31 -7
- data/RELEASING.md +24 -6
- data/docs/README.md +4 -1
- data/docs/artifacts.md +26 -2
- data/docs/bambu-studio.md +7 -1
- data/docs/color-model.md +2 -0
- data/docs/command.md +48 -7
- data/docs/config.md +31 -4
- data/docs/getting-started.md +10 -5
- data/docs/images/cmyw-lithophane-preview.png +0 -0
- data/docs/local-ui.md +114 -0
- data/docs/tiling.md +134 -0
- data/docs/troubleshooting.md +22 -0
- data/ext/lithop_native/Cargo.toml +17 -0
- data/ext/lithop_native/build.rs +4 -0
- data/ext/lithop_native/extconf.rb +6 -0
- data/ext/lithop_native/src/column_mesh.rs +388 -0
- data/ext/lithop_native/src/lib.rs +37 -0
- data/ext/lithop_native/src/mesh.rs +187 -0
- data/ext/lithop_native/src/palette.rs +67 -0
- data/ext/lithop_native/src/xml.rs +134 -0
- data/lib/lithop/calibration/export.rb +21 -8
- data/lib/lithop/cli/calibration.rb +3 -1
- data/lib/lithop/cli/image_options.rb +41 -2
- data/lib/lithop/cli/options.rb +13 -0
- data/lib/lithop/cli.rb +36 -1
- data/lib/lithop/export/color_metadata.rb +55 -0
- data/lib/lithop/export/generation.rb +131 -21
- data/lib/lithop/export/job.rb +30 -16
- data/lib/lithop/export/metadata.rb +54 -4
- data/lib/lithop/export/planning.rb +97 -0
- data/lib/lithop/export/previews.rb +25 -1
- data/lib/lithop/export/print_readme.rb +22 -4
- data/lib/lithop/export/result.rb +26 -4
- data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
- data/lib/lithop/formats/three_mf/mesh.rb +72 -3
- data/lib/lithop/formats/three_mf/model_writer.rb +249 -24
- data/lib/lithop/formats/three_mf/part_writer.rb +51 -6
- data/lib/lithop/formats/three_mf.rb +9 -6
- data/lib/lithop/image/pipeline.rb +66 -10
- data/lib/lithop/image/svg_loader.rb +5 -5
- data/lib/lithop/job_directory.rb +41 -0
- data/lib/lithop/job_execution.rb +378 -0
- data/lib/lithop/layer_structure.rb +11 -0
- data/lib/lithop/mesh/box_geometry.rb +41 -0
- data/lib/lithop/mesh/column_geometry.rb +10 -2
- data/lib/lithop/mesh/column_topology.rb +2 -0
- data/lib/lithop/mesh/geometry.rb +21 -13
- data/lib/lithop/model/layer_region_model.rb +24 -0
- data/lib/lithop/model/material_model.rb +17 -5
- data/lib/lithop/model/palette.rb +21 -3
- data/lib/lithop/native.rb +39 -0
- data/lib/lithop/reference_three_mf.rb +17 -1
- data/lib/lithop/settings.rb +116 -1
- data/lib/lithop/settings_file.rb +10 -4
- data/lib/lithop/tiling.rb +127 -0
- data/lib/lithop/version.rb +1 -1
- data/lib/lithop/web/app.rb +513 -0
- data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
- data/lib/lithop/web/job_launcher.rb +214 -0
- data/lib/lithop/web/job_store.rb +390 -0
- data/lib/lithop/web/preview_store.rb +182 -0
- data/lib/lithop/web/public/app.css +319 -0
- data/lib/lithop/web/public/app.js +1847 -0
- data/lib/lithop/web/server.rb +21 -0
- data/lib/lithop/web/settings_form.rb +64 -0
- data/lib/lithop/web/settings_library.rb +154 -0
- data/lib/lithop/web/views/index.erb +364 -0
- data/lib/lithop/web/worker.rb +27 -0
- data/lib/lithop.rb +5 -0
- data/sig/lithop.rbs +102 -2
- metadata +89 -2
|
@@ -17,6 +17,7 @@ module Lithop
|
|
|
17
17
|
pigment_order: settings.pigment_order
|
|
18
18
|
)
|
|
19
19
|
@settings = settings
|
|
20
|
+
@material_arrays = {}
|
|
20
21
|
validate_samples!(cmys, heights, cell_cmys)
|
|
21
22
|
@cells = build_cells(cmys, heights, cell_cmys).freeze
|
|
22
23
|
maximum_layers = cells.map { |cell| cell.materials.length }.max
|
|
@@ -31,7 +32,9 @@ module Lithop
|
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def materials
|
|
34
|
-
@materials ||= cells.
|
|
35
|
+
@materials ||= cells.each_with_object({}) do |cell, result|
|
|
36
|
+
cell.materials.each { |material| result[material] = true }
|
|
37
|
+
end.keys.freeze
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
def recipe_counts
|
|
@@ -63,12 +66,21 @@ module Lithop
|
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def build_cells(cmys, heights, cell_cmys)
|
|
66
|
-
Array.new(rows * columns)
|
|
69
|
+
cell_heights = Array.new(rows * columns)
|
|
70
|
+
cell_colors = Array.new(rows * columns) do |index|
|
|
67
71
|
row, column = index.divmod(columns)
|
|
68
72
|
cmy = cell_cmys ? cell_cmys.fetch(index) : average(corners(cmys, row, column))
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
cell_heights[index] = @settings.quantize_thickness(average(corners(heights, row, column)))
|
|
74
|
+
cmy
|
|
75
|
+
end
|
|
76
|
+
recipes = if @palette.respond_to?(:nearest_many)
|
|
77
|
+
@palette.nearest_many(cell_colors)
|
|
78
|
+
else
|
|
79
|
+
cell_colors.map { |cmy| @palette.nearest(cmy) }
|
|
80
|
+
end
|
|
81
|
+
cell_heights.zip(recipes).map do |height, recipe|
|
|
82
|
+
materials = @material_arrays[[recipe, height]] ||= materials_for(recipe, height).freeze
|
|
83
|
+
Cell.new(height:, recipe:, materials:)
|
|
72
84
|
end
|
|
73
85
|
end
|
|
74
86
|
|
data/lib/lithop/model/palette.rb
CHANGED
|
@@ -49,13 +49,20 @@ module Lithop
|
|
|
49
49
|
@layer_count = layer_count
|
|
50
50
|
@pigment_order = pigment_order.dup.freeze
|
|
51
51
|
@recipes = build_recipes.freeze
|
|
52
|
+
@native_coverages = recipes.flat_map(&:coverage_cmy).freeze
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def nearest(cmy)
|
|
55
56
|
validate_cmy!(cmy)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
nearest_validated(cmy)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def nearest_many(cmys)
|
|
61
|
+
cmys.each { |cmy| validate_cmy!(cmy) }
|
|
62
|
+
return cmys.map { |cmy| nearest_validated(cmy) } unless Native.enabled?
|
|
63
|
+
|
|
64
|
+
indices = Native.palette_indices(cmys.flatten, @native_coverages)
|
|
65
|
+
indices.map { |index| recipes.fetch(index) }
|
|
59
66
|
end
|
|
60
67
|
|
|
61
68
|
def display_color(recipe)
|
|
@@ -97,5 +104,16 @@ module Lithop
|
|
|
97
104
|
|
|
98
105
|
raise InputError, "CMY must contain three values between 0 and 1"
|
|
99
106
|
end
|
|
107
|
+
|
|
108
|
+
def squared_distance(coverage, target)
|
|
109
|
+
cyan = coverage[0] - target[0]
|
|
110
|
+
magenta = coverage[1] - target[1]
|
|
111
|
+
yellow = coverage[2] - target[2]
|
|
112
|
+
(cyan * cyan) + (magenta * magenta) + (yellow * yellow)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def nearest_validated(cmy)
|
|
116
|
+
recipes.min_by { |recipe| squared_distance(recipe.coverage_cmy, cmy) }
|
|
117
|
+
end
|
|
100
118
|
end
|
|
101
119
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Lithop
|
|
6
|
+
# Loads optional acceleration implemented by the bundled Rust extension.
|
|
7
|
+
module Native
|
|
8
|
+
class << self
|
|
9
|
+
def available? = @available == true
|
|
10
|
+
|
|
11
|
+
def enabled? = available? && ENV["LITHOP_DISABLE_NATIVE"] != "1"
|
|
12
|
+
|
|
13
|
+
def backend = enabled? ? :rust : :ruby
|
|
14
|
+
|
|
15
|
+
def loaded_extension_version
|
|
16
|
+
extension_version if available? && respond_to?(:extension_version)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def diagnostics
|
|
20
|
+
{
|
|
21
|
+
backend:,
|
|
22
|
+
available: available?,
|
|
23
|
+
enabled: enabled?,
|
|
24
|
+
extension_version: loaded_extension_version,
|
|
25
|
+
lithop_version: Lithop::VERSION,
|
|
26
|
+
ruby: RUBY_DESCRIPTION,
|
|
27
|
+
platform: RbConfig::CONFIG.fetch("host")
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
require "lithop_native/lithop_native"
|
|
36
|
+
Lithop::Native.instance_variable_set(:@available, true)
|
|
37
|
+
rescue LoadError
|
|
38
|
+
Lithop::Native.instance_variable_set(:@available, false)
|
|
39
|
+
end
|
|
@@ -7,6 +7,7 @@ require "zip"
|
|
|
7
7
|
|
|
8
8
|
module Lithop
|
|
9
9
|
# Streams structural measurements from an existing 3MF without retaining its meshes.
|
|
10
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
10
11
|
class ReferenceThreeMF
|
|
11
12
|
def initialize(path)
|
|
12
13
|
@path = File.expand_path(path)
|
|
@@ -63,6 +64,7 @@ module Lithop
|
|
|
63
64
|
def start_object(event)
|
|
64
65
|
{
|
|
65
66
|
id: event[1]["id"], type: event[1]["type"], vertices: 0, triangles: 0, components: 0,
|
|
67
|
+
component_refs: [],
|
|
66
68
|
minimum: [nil, nil, nil], maximum: [nil, nil, nil]
|
|
67
69
|
}
|
|
68
70
|
end
|
|
@@ -80,6 +82,10 @@ module Lithop
|
|
|
80
82
|
current[:triangles] += 1
|
|
81
83
|
when "component"
|
|
82
84
|
current[:components] += 1
|
|
85
|
+
current[:component_refs] << {
|
|
86
|
+
object_id: event[1]["objectid"],
|
|
87
|
+
transform: transform_values(event[1]["transform"])
|
|
88
|
+
}
|
|
83
89
|
end
|
|
84
90
|
end
|
|
85
91
|
|
|
@@ -105,7 +111,11 @@ module Lithop
|
|
|
105
111
|
|
|
106
112
|
document.get_elements("config/plate").map do |plate|
|
|
107
113
|
metadata = plate.get_elements("metadata").to_h { |entry| [entry.attributes["key"], entry.attributes["value"]] }
|
|
108
|
-
|
|
114
|
+
object_ids = plate.get_elements("model_instance").filter_map do |instance|
|
|
115
|
+
entry = instance.get_elements("metadata").find { |item| item.attributes["key"] == "object_id" }
|
|
116
|
+
entry && entry.attributes["value"]
|
|
117
|
+
end
|
|
118
|
+
{ id: integer_or_nil(metadata["plater_id"]), name: metadata["plater_name"], object_ids: }
|
|
109
119
|
end
|
|
110
120
|
end
|
|
111
121
|
|
|
@@ -115,5 +125,11 @@ module Lithop
|
|
|
115
125
|
end
|
|
116
126
|
|
|
117
127
|
def integer_or_nil(value) = value && Integer(value)
|
|
128
|
+
|
|
129
|
+
def transform_values(value)
|
|
130
|
+
return unless value
|
|
131
|
+
|
|
132
|
+
value.split.map { |number| Float(number) }
|
|
133
|
+
end
|
|
118
134
|
end
|
|
119
135
|
end
|
data/lib/lithop/settings.rb
CHANGED
|
@@ -13,10 +13,13 @@ module Lithop
|
|
|
13
13
|
validate_color_model!
|
|
14
14
|
validate_layout_profile!
|
|
15
15
|
validate_grid!
|
|
16
|
+
validate_tiles!
|
|
16
17
|
validate_layer_structure!
|
|
18
|
+
validate_z_parts!
|
|
17
19
|
validate_advanced_layer_structure!
|
|
18
20
|
validate_margins!
|
|
19
21
|
validate_image_placement!
|
|
22
|
+
validate_image_adjustments!
|
|
20
23
|
validate_nozzle_diameter!
|
|
21
24
|
validate_mesh_limit!
|
|
22
25
|
end
|
|
@@ -73,6 +76,55 @@ module Lithop
|
|
|
73
76
|
raise ConfigurationError, "grid_columns and grid_rows must be positive integers or null"
|
|
74
77
|
end
|
|
75
78
|
|
|
79
|
+
def validate_tiles!
|
|
80
|
+
validate_tile_count!
|
|
81
|
+
validate_tile_layout!
|
|
82
|
+
validate_tile_grid!
|
|
83
|
+
|
|
84
|
+
if tile_count > columns * rows
|
|
85
|
+
raise ConfigurationError, "tile layout must leave at least one XY cell in every tile"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
return if resolved_tile_columns <= columns && resolved_tile_rows <= rows
|
|
89
|
+
|
|
90
|
+
raise ConfigurationError, "tile layout must leave at least one XY cell in every tile"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_tile_count!
|
|
94
|
+
return if tile_count.is_a?(Integer) && tile_count.positive?
|
|
95
|
+
|
|
96
|
+
raise ConfigurationError, "tile_count must be a positive integer"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_tile_layout!
|
|
100
|
+
return if Settings::TILE_LAYOUTS.include?(tile_layout)
|
|
101
|
+
|
|
102
|
+
raise ConfigurationError, "tile_layout must be one of: #{Settings::TILE_LAYOUTS.join(", ")}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_tile_grid!
|
|
106
|
+
unless tile_layout == "grid"
|
|
107
|
+
return if tile_rows.nil? && tile_columns.nil?
|
|
108
|
+
|
|
109
|
+
raise ConfigurationError, "tile_rows and tile_columns may only be set for grid layout"
|
|
110
|
+
end
|
|
111
|
+
unless tile_rows.is_a?(Integer) && tile_rows.positive? &&
|
|
112
|
+
tile_columns.is_a?(Integer) && tile_columns.positive?
|
|
113
|
+
raise ConfigurationError, "tile_rows and tile_columns must be positive integers for grid layout"
|
|
114
|
+
end
|
|
115
|
+
return if tile_rows * tile_columns == tile_count
|
|
116
|
+
|
|
117
|
+
raise ConfigurationError, "tile_rows times tile_columns must equal tile_count"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def validate_z_parts!
|
|
121
|
+
raise ConfigurationError, "separate_z_parts must be a boolean" unless [true, false].include?(separate_z_parts)
|
|
122
|
+
unless color_region_parts.is_a?(Integer) && color_region_parts.positive? &&
|
|
123
|
+
color_region_parts <= color_layer_count
|
|
124
|
+
raise ConfigurationError, "color_region_parts must be an integer from 1 to color_layer_count"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
76
128
|
def validate_layer_structure!
|
|
77
129
|
return if Settings::COLOR_LAYER_COUNTS.include?(color_layer_count)
|
|
78
130
|
|
|
@@ -128,6 +180,15 @@ module Lithop
|
|
|
128
180
|
raise ConfigurationError, "crop_scale must be a finite number greater than 0 and at most 1"
|
|
129
181
|
end
|
|
130
182
|
|
|
183
|
+
def validate_image_adjustments!
|
|
184
|
+
%i[brightness contrast saturation].each do |key|
|
|
185
|
+
value = public_send(key)
|
|
186
|
+
next if value.is_a?(Numeric) && value.finite? && value.between?(-100.0, 100.0)
|
|
187
|
+
|
|
188
|
+
raise ConfigurationError, "#{key} must be a finite percentage from -100 to 100"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
131
192
|
def validate_nozzle_diameter!
|
|
132
193
|
return if Settings::NOZZLE_DIAMETERS.include?(nozzle_diameter)
|
|
133
194
|
|
|
@@ -174,6 +235,7 @@ module Lithop
|
|
|
174
235
|
COLOR_LAYER_COUNTS = [4, 5, 7].freeze
|
|
175
236
|
LAYOUT_PROFILES = ["standard"].freeze
|
|
176
237
|
IMAGE_FITS = %w[cover contain].freeze
|
|
238
|
+
TILE_LAYOUTS = %w[auto columns rows grid].freeze
|
|
177
239
|
PIGMENTS = %i[cyan magenta yellow].freeze
|
|
178
240
|
MINIMUM_TOP_WHITE_LAYERS = LayerStructure::MINIMUM_TOP_WHITE_LAYERS
|
|
179
241
|
DEFAULTS = {
|
|
@@ -188,12 +250,21 @@ module Lithop
|
|
|
188
250
|
crop_position_x: 0.5,
|
|
189
251
|
crop_position_y: 0.5,
|
|
190
252
|
crop_scale: 1.0,
|
|
253
|
+
brightness: 0.0,
|
|
254
|
+
contrast: 0.0,
|
|
255
|
+
saturation: 0.0,
|
|
191
256
|
minimum_thickness: 1.0,
|
|
192
257
|
maximum_thickness: 2.14689422,
|
|
193
258
|
pixel_size: 0.22,
|
|
194
259
|
color_pixel_size: nil,
|
|
195
260
|
grid_columns: 637,
|
|
196
261
|
grid_rows: 473,
|
|
262
|
+
tile_count: 1,
|
|
263
|
+
tile_layout: "auto",
|
|
264
|
+
tile_rows: nil,
|
|
265
|
+
tile_columns: nil,
|
|
266
|
+
separate_z_parts: false,
|
|
267
|
+
color_region_parts: 3,
|
|
197
268
|
first_layer_height: 0.1,
|
|
198
269
|
layer_height: 0.1,
|
|
199
270
|
base_white_layers: 2,
|
|
@@ -232,6 +303,7 @@ module Lithop
|
|
|
232
303
|
end
|
|
233
304
|
|
|
234
305
|
validate_physical_minimum!
|
|
306
|
+
validate_separated_top_white!
|
|
235
307
|
end
|
|
236
308
|
|
|
237
309
|
def without_default_grid(values)
|
|
@@ -250,6 +322,18 @@ module Lithop
|
|
|
250
322
|
def pixel_size_x = width.fdiv(columns)
|
|
251
323
|
def pixel_size_y = height.fdiv(rows)
|
|
252
324
|
|
|
325
|
+
def resolved_tile_shape
|
|
326
|
+
case tile_layout
|
|
327
|
+
when "columns" then [1, tile_count]
|
|
328
|
+
when "rows" then [tile_count, 1]
|
|
329
|
+
when "grid" then [tile_rows, tile_columns]
|
|
330
|
+
else automatic_tile_shape
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def resolved_tile_rows = resolved_tile_shape.fetch(0)
|
|
335
|
+
def resolved_tile_columns = resolved_tile_shape.fetch(1)
|
|
336
|
+
|
|
253
337
|
def estimated_triangles
|
|
254
338
|
(4 * columns * rows) + (4 * (columns + rows))
|
|
255
339
|
end
|
|
@@ -288,8 +372,39 @@ module Lithop
|
|
|
288
372
|
effective_color_pixel_size: effective_color_pixel_size,
|
|
289
373
|
effective_minimum_thickness: effective_minimum_thickness,
|
|
290
374
|
effective_maximum_thickness: effective_maximum_thickness,
|
|
291
|
-
columns: columns, rows: rows, pixel_size_x: pixel_size_x, pixel_size_y: pixel_size_y
|
|
375
|
+
columns: columns, rows: rows, pixel_size_x: pixel_size_x, pixel_size_y: pixel_size_y,
|
|
376
|
+
resolved_tile_rows: resolved_tile_rows, resolved_tile_columns: resolved_tile_columns
|
|
292
377
|
)
|
|
293
378
|
end
|
|
379
|
+
|
|
380
|
+
private
|
|
381
|
+
|
|
382
|
+
def automatic_tile_shape
|
|
383
|
+
factors = factor_shapes.map do |row_count, column_count|
|
|
384
|
+
tile_width = width.fdiv(column_count)
|
|
385
|
+
tile_height = height.fdiv(row_count)
|
|
386
|
+
[[Math.log(tile_width.fdiv(tile_height)).abs, -column_count], [row_count, column_count]]
|
|
387
|
+
end
|
|
388
|
+
feasible = factors.select do |_score, (row_count, column_count)|
|
|
389
|
+
row_count <= rows && column_count <= columns
|
|
390
|
+
end
|
|
391
|
+
(feasible.empty? ? factors : feasible).min_by(&:first).last
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def factor_shapes
|
|
395
|
+
(1..Math.sqrt(tile_count).floor).flat_map do |row_count|
|
|
396
|
+
next [] unless (tile_count % row_count).zero?
|
|
397
|
+
|
|
398
|
+
column_count = tile_count / row_count
|
|
399
|
+
row_count == column_count ? [[row_count, column_count]] : [[row_count, column_count], [column_count, row_count]]
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def validate_separated_top_white!
|
|
404
|
+
return unless separate_z_parts
|
|
405
|
+
return if effective_minimum_thickness > layer_structure.color_region_end
|
|
406
|
+
|
|
407
|
+
raise ConfigurationError, "separate_z_parts requires a positive top White thickness"
|
|
408
|
+
end
|
|
294
409
|
end
|
|
295
410
|
end
|
data/lib/lithop/settings_file.rb
CHANGED
|
@@ -26,10 +26,7 @@ module Lithop
|
|
|
26
26
|
|
|
27
27
|
validate_keys!(data, expanded)
|
|
28
28
|
validate_schema!(data, expanded)
|
|
29
|
-
values = data.except("schema_version").transform_keys(&:to_sym)
|
|
30
|
-
values[:color_model] = values[:color_model].downcase if values[:color_model].is_a?(String)
|
|
31
|
-
values[:image_fit] = values[:image_fit].downcase if values[:image_fit].is_a?(String)
|
|
32
|
-
values[:layout_profile] = values[:layout_profile].downcase if values[:layout_profile].is_a?(String)
|
|
29
|
+
values = normalize_values(data.except("schema_version").transform_keys(&:to_sym))
|
|
33
30
|
Result.new(path: expanded, values:)
|
|
34
31
|
rescue JSON::ParserError => e
|
|
35
32
|
raise ConfigurationError, "settings file is not valid JSON: #{expanded}: #{e.message}"
|
|
@@ -37,6 +34,15 @@ module Lithop
|
|
|
37
34
|
raise ConfigurationError, "could not read settings file: #{expanded}: #{e.message}"
|
|
38
35
|
end
|
|
39
36
|
|
|
37
|
+
def normalize_values(values)
|
|
38
|
+
values[:color_model] = values[:color_model].downcase if values[:color_model].is_a?(String)
|
|
39
|
+
values[:image_fit] = values[:image_fit].downcase if values[:image_fit].is_a?(String)
|
|
40
|
+
values[:layout_profile] = values[:layout_profile].downcase if values[:layout_profile].is_a?(String)
|
|
41
|
+
values[:tile_layout] = values[:tile_layout].downcase if values[:tile_layout].is_a?(String)
|
|
42
|
+
values
|
|
43
|
+
end
|
|
44
|
+
private_class_method :normalize_values
|
|
45
|
+
|
|
40
46
|
def validate_keys!(data, path)
|
|
41
47
|
unknown = data.keys - ALLOWED_KEYS
|
|
42
48
|
return if unknown.empty?
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Resolves a deterministic tile grid on existing XY cell boundaries.
|
|
5
|
+
class TileLayout
|
|
6
|
+
Tile = Data.define(
|
|
7
|
+
:id, :row, :column, :row_start, :row_end, :column_start, :column_end,
|
|
8
|
+
:x_min, :x_max, :y_min, :y_max
|
|
9
|
+
) do
|
|
10
|
+
def width = x_max - x_min
|
|
11
|
+
def height = y_max - y_min
|
|
12
|
+
def offset = { x: x_min, y: y_min }
|
|
13
|
+
def bounds = { x_min:, x_max:, y_min:, y_max: }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :rows, :columns, :tiles
|
|
17
|
+
|
|
18
|
+
def initialize(settings)
|
|
19
|
+
@settings = settings
|
|
20
|
+
@rows = settings.resolved_tile_rows
|
|
21
|
+
@columns = settings.resolved_tile_columns
|
|
22
|
+
@tiles = build_tiles.freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def build_tiles
|
|
28
|
+
row_spans = partition(@settings.rows, rows)
|
|
29
|
+
column_spans = partition(@settings.columns, columns)
|
|
30
|
+
row_spans.each_with_index.flat_map do |(row_start, row_end), row|
|
|
31
|
+
column_spans.each_with_index.map do |(column_start, column_end), column|
|
|
32
|
+
Tile.new(
|
|
33
|
+
id: format("tile_r%<row>02d_c%<column>02d", row: row + 1, column: column + 1),
|
|
34
|
+
row: row + 1,
|
|
35
|
+
column: column + 1,
|
|
36
|
+
row_start:, row_end:, column_start:, column_end:,
|
|
37
|
+
x_min: @settings.x_boundaries.fetch(column_start),
|
|
38
|
+
x_max: @settings.x_boundaries.fetch(column_end),
|
|
39
|
+
y_min: (@settings.height - @settings.y_boundaries.fetch(row_end)).round(9),
|
|
40
|
+
y_max: (@settings.height - @settings.y_boundaries.fetch(row_start)).round(9)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def partition(cell_count, part_count)
|
|
47
|
+
size, remainder = cell_count.divmod(part_count)
|
|
48
|
+
start = 0
|
|
49
|
+
Array.new(part_count) do |index|
|
|
50
|
+
finish = start + size + (index < remainder ? 1 : 0)
|
|
51
|
+
[start, finish].tap { start = finish }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Presents one rectangular subset of a material model in tile-local coordinates.
|
|
57
|
+
class TileMaterialModel
|
|
58
|
+
attr_reader :columns, :rows, :x_boundaries, :y_boundaries, :z_boundaries, :cells
|
|
59
|
+
|
|
60
|
+
def initialize(model:, tile:)
|
|
61
|
+
@columns = tile.column_end - tile.column_start
|
|
62
|
+
@rows = tile.row_end - tile.row_start
|
|
63
|
+
@x_boundaries = local_boundaries(model.x_boundaries, tile.column_start, tile.column_end)
|
|
64
|
+
@y_boundaries = local_boundaries(model.y_boundaries, tile.row_start, tile.row_end)
|
|
65
|
+
@z_boundaries = model.z_boundaries
|
|
66
|
+
@cells = slice_cells(model, tile).freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def materials = @materials ||= cells.flat_map(&:materials).uniq.freeze
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def local_boundaries(boundaries, first, last)
|
|
74
|
+
origin = boundaries.fetch(first)
|
|
75
|
+
boundaries[first..last].map { |value| (value - origin).round(9) }.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def slice_cells(model, tile)
|
|
79
|
+
(tile.row_start...tile.row_end).flat_map do |row|
|
|
80
|
+
offset = (row * model.columns) + tile.column_start
|
|
81
|
+
model.cells.slice(offset, columns)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Writes a physical-size overview of the tile IDs and assembled placement.
|
|
87
|
+
module TileLayoutSVG
|
|
88
|
+
module_function
|
|
89
|
+
|
|
90
|
+
def write(path, settings:, layout:)
|
|
91
|
+
File.write(path, document(settings:, layout:))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def document(settings:, layout:, background: true)
|
|
95
|
+
rectangles = layout.tiles.map { |tile| tile_element(settings, tile) }.join("\n")
|
|
96
|
+
fill = background ? "#fff" : "none"
|
|
97
|
+
<<~SVG
|
|
98
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
99
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="#{settings.width}mm" height="#{settings.height}mm"
|
|
100
|
+
viewBox="0 0 #{settings.width} #{settings.height}">
|
|
101
|
+
<rect width="#{settings.width}" height="#{settings.height}" fill="#{fill}" stroke="#000" stroke-width="0.4"/>
|
|
102
|
+
#{rectangles}
|
|
103
|
+
</svg>
|
|
104
|
+
SVG
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def tile_element(settings, tile)
|
|
108
|
+
y = settings.height - tile.y_max
|
|
109
|
+
font_size = [[tile.width, tile.height].min * 0.12, 5.0].min
|
|
110
|
+
center_x = tile.x_min + (tile.width / 2.0)
|
|
111
|
+
center_y = y + (tile.height / 2.0)
|
|
112
|
+
<<~SVG.chomp
|
|
113
|
+
<g>
|
|
114
|
+
<rect x="#{tile.x_min}" y="#{y}" width="#{tile.width}" height="#{tile.height}"
|
|
115
|
+
fill="none" stroke="#333" stroke-width="0.25"/>
|
|
116
|
+
<text x="#{center_x}" y="#{center_y - (font_size * 0.6)}"
|
|
117
|
+
font-family="sans-serif" font-size="#{font_size}" text-anchor="middle"
|
|
118
|
+
dominant-baseline="middle">#{tile.id}</text>
|
|
119
|
+
<text x="#{center_x}" y="#{center_y + (font_size * 0.6)}"
|
|
120
|
+
font-family="sans-serif" font-size="#{font_size * 0.72}" text-anchor="middle"
|
|
121
|
+
dominant-baseline="middle">#{tile.width.round(3)} × #{tile.height.round(3)} mm</text>
|
|
122
|
+
</g>
|
|
123
|
+
SVG
|
|
124
|
+
end
|
|
125
|
+
private_class_method :tile_element
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/lithop/version.rb
CHANGED