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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/Cargo.lock +284 -0
  4. data/Cargo.toml +6 -0
  5. data/README.md +31 -7
  6. data/RELEASING.md +24 -6
  7. data/docs/README.md +4 -1
  8. data/docs/artifacts.md +26 -2
  9. data/docs/bambu-studio.md +7 -1
  10. data/docs/color-model.md +2 -0
  11. data/docs/command.md +48 -7
  12. data/docs/config.md +31 -4
  13. data/docs/getting-started.md +10 -5
  14. data/docs/images/cmyw-lithophane-preview.png +0 -0
  15. data/docs/local-ui.md +114 -0
  16. data/docs/tiling.md +134 -0
  17. data/docs/troubleshooting.md +22 -0
  18. data/ext/lithop_native/Cargo.toml +17 -0
  19. data/ext/lithop_native/build.rs +4 -0
  20. data/ext/lithop_native/extconf.rb +6 -0
  21. data/ext/lithop_native/src/column_mesh.rs +388 -0
  22. data/ext/lithop_native/src/lib.rs +37 -0
  23. data/ext/lithop_native/src/mesh.rs +187 -0
  24. data/ext/lithop_native/src/palette.rs +67 -0
  25. data/ext/lithop_native/src/xml.rs +134 -0
  26. data/lib/lithop/calibration/export.rb +21 -8
  27. data/lib/lithop/cli/calibration.rb +3 -1
  28. data/lib/lithop/cli/image_options.rb +41 -2
  29. data/lib/lithop/cli/options.rb +13 -0
  30. data/lib/lithop/cli.rb +36 -1
  31. data/lib/lithop/export/color_metadata.rb +55 -0
  32. data/lib/lithop/export/generation.rb +131 -21
  33. data/lib/lithop/export/job.rb +30 -16
  34. data/lib/lithop/export/metadata.rb +54 -4
  35. data/lib/lithop/export/planning.rb +97 -0
  36. data/lib/lithop/export/previews.rb +25 -1
  37. data/lib/lithop/export/print_readme.rb +22 -4
  38. data/lib/lithop/export/result.rb +26 -4
  39. data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
  40. data/lib/lithop/formats/three_mf/mesh.rb +72 -3
  41. data/lib/lithop/formats/three_mf/model_writer.rb +249 -24
  42. data/lib/lithop/formats/three_mf/part_writer.rb +51 -6
  43. data/lib/lithop/formats/three_mf.rb +9 -6
  44. data/lib/lithop/image/pipeline.rb +66 -10
  45. data/lib/lithop/image/svg_loader.rb +5 -5
  46. data/lib/lithop/job_directory.rb +41 -0
  47. data/lib/lithop/job_execution.rb +378 -0
  48. data/lib/lithop/layer_structure.rb +11 -0
  49. data/lib/lithop/mesh/box_geometry.rb +41 -0
  50. data/lib/lithop/mesh/column_geometry.rb +10 -2
  51. data/lib/lithop/mesh/column_topology.rb +2 -0
  52. data/lib/lithop/mesh/geometry.rb +21 -13
  53. data/lib/lithop/model/layer_region_model.rb +24 -0
  54. data/lib/lithop/model/material_model.rb +17 -5
  55. data/lib/lithop/model/palette.rb +21 -3
  56. data/lib/lithop/native.rb +39 -0
  57. data/lib/lithop/reference_three_mf.rb +17 -1
  58. data/lib/lithop/settings.rb +116 -1
  59. data/lib/lithop/settings_file.rb +10 -4
  60. data/lib/lithop/tiling.rb +127 -0
  61. data/lib/lithop/version.rb +1 -1
  62. data/lib/lithop/web/app.rb +513 -0
  63. data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
  64. data/lib/lithop/web/job_launcher.rb +214 -0
  65. data/lib/lithop/web/job_store.rb +390 -0
  66. data/lib/lithop/web/preview_store.rb +182 -0
  67. data/lib/lithop/web/public/app.css +319 -0
  68. data/lib/lithop/web/public/app.js +1847 -0
  69. data/lib/lithop/web/server.rb +21 -0
  70. data/lib/lithop/web/settings_form.rb +64 -0
  71. data/lib/lithop/web/settings_library.rb +154 -0
  72. data/lib/lithop/web/views/index.erb +364 -0
  73. data/lib/lithop/web/worker.rb +27 -0
  74. data/lib/lithop.rb +5 -0
  75. data/sig/lithop.rbs +102 -2
  76. 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.flat_map(&:materials).uniq.freeze
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) do |index|
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
- height = @settings.quantize_thickness(average(corners(heights, row, column)))
70
- recipe = @palette.nearest(cmy)
71
- Cell.new(height:, recipe:, materials: materials_for(recipe, height).freeze)
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
 
@@ -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
- recipes.min_by do |recipe|
57
- recipe.coverage_cmy.zip(cmy).sum { |coverage, target| (coverage - target)**2 }
58
- end
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
- { id: integer_or_nil(metadata["plater_id"]), name: metadata["plater_name"] }
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
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lithop
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end