lithop 0.3.0 → 0.4.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/Cargo.lock +1 -1
  4. data/README.md +14 -11
  5. data/RELEASING.md +3 -3
  6. data/docs/README.md +4 -3
  7. data/docs/artifacts.md +27 -15
  8. data/docs/bambu-studio.md +12 -10
  9. data/docs/calibration.md +4 -4
  10. data/docs/color-model.md +20 -18
  11. data/docs/command.md +28 -12
  12. data/docs/config.md +42 -30
  13. data/docs/getting-started.md +12 -6
  14. data/docs/local-ui.md +12 -9
  15. data/docs/tiling.md +18 -19
  16. data/docs/troubleshooting.md +3 -3
  17. data/ext/lithop_native/Cargo.toml +1 -1
  18. data/lib/lithop/calibration/target.rb +5 -5
  19. data/lib/lithop/cli/image_adjustment_options.rb +33 -0
  20. data/lib/lithop/cli/image_options.rb +5 -18
  21. data/lib/lithop/cli/options.rb +4 -4
  22. data/lib/lithop/export/color_metadata.rb +46 -23
  23. data/lib/lithop/export/generation.rb +8 -10
  24. data/lib/lithop/export/planning.rb +82 -45
  25. data/lib/lithop/export/print_readme.rb +20 -4
  26. data/lib/lithop/formats/three_mf/bambu_metadata.rb +11 -8
  27. data/lib/lithop/formats/three_mf/model_writer.rb +2 -5
  28. data/lib/lithop/image/pipeline.rb +40 -26
  29. data/lib/lithop/image/svg_loader.rb +11 -2
  30. data/lib/lithop/layer_structure.rb +0 -9
  31. data/lib/lithop/model/filament_profile.rb +3 -3
  32. data/lib/lithop/model/material_display_map.rb +2 -1
  33. data/lib/lithop/model/z_region_model.rb +44 -0
  34. data/lib/lithop/settings.rb +147 -15
  35. data/lib/lithop/settings_file.rb +1 -0
  36. data/lib/lithop/version.rb +1 -1
  37. data/lib/lithop/web/app.rb +9 -5
  38. data/lib/lithop/web/public/app.css +48 -37
  39. data/lib/lithop/web/public/app.js +149 -47
  40. data/lib/lithop/web/settings_form.rb +8 -5
  41. data/lib/lithop/web/views/index.erb +129 -75
  42. data/lib/lithop.rb +2 -1
  43. data/sig/lithop.rbs +16 -0
  44. metadata +3 -2
  45. data/lib/lithop/model/layer_region_model.rb +0 -24
@@ -68,20 +68,31 @@ module Lithop
68
68
 
69
69
  def z_part_separation
70
70
  structure = @settings.layer_structure
71
- boundaries = structure.z_boundaries(@settings.base_white_layers + @settings.color_layer_count)
72
- color_regions = structure.color_region_layer_ranges(@settings.color_region_parts)
73
- .each_with_index.map do |range, index|
71
+ color_regions = @settings.resolved_z_color_ranges.each_with_index.filter_map do |range, index|
72
+ next unless range.begin < range.end
73
+
74
74
  {
75
- id: "color_region_#{index + 1}",
76
- layer_start: range.begin - @settings.base_white_layers + 1,
77
- layer_end: range.end - @settings.base_white_layers,
78
- z_min: boundaries.fetch(range.begin),
79
- z_max: boundaries.fetch(range.end)
75
+ id: "z_part_#{index + 1}",
76
+ z_min: range.begin,
77
+ z_max: range.end
80
78
  }
81
79
  end
82
80
  metadata = {
83
81
  enabled: @settings.separate_z_parts,
84
- base_white: { id: "base_white", z_min: 0.0, z_max: structure.base_white_thickness },
82
+ requested_parts: @settings.color_region_parts,
83
+ resolved_parts: @settings.resolved_z_part_count,
84
+ minimum_source_plate_thickness: Settings::MINIMUM_Z_PART_THICKNESS,
85
+ minimum_plate_thickness: @settings.z_part_minimum_build_thickness,
86
+ adjusted_minimum_total_thickness: @settings.z_separated_output_minimum_thickness,
87
+ base_white: {
88
+ id: "base_white", included: true,
89
+ layers_per_plate: base_white_layers_per_plate,
90
+ thickness_per_plate: base_white_thickness_per_plate,
91
+ repeated_per_plate: true
92
+ },
93
+ source_base_white: {
94
+ id: "source_base_white", included: false, z_min: 0.0, z_max: structure.base_white_thickness
95
+ },
85
96
  color_regions:,
86
97
  top_white: {
87
98
  id: "top_white", z_min: structure.color_region_end,
@@ -92,30 +103,42 @@ module Lithop
92
103
  metadata
93
104
  end
94
105
 
106
+ def base_white_layers_per_plate
107
+ return Settings::Z_PART_BASE_WHITE_LAYERS unless @settings.separate_z_parts
108
+
109
+ @settings.z_part_base_white_layers_by_plate
110
+ end
111
+
112
+ def base_white_thickness_per_plate
113
+ return @settings.z_part_base_white_thickness unless @settings.separate_z_parts
114
+
115
+ @settings.z_part_base_white_thicknesses
116
+ end
117
+
95
118
  def z_plates(metadata)
96
- base_white = metadata.fetch(:base_white)
97
- regions = metadata.fetch(:color_regions).map do |region|
98
- region.merge(z_min: base_white.fetch(:z_min), build_z_max: region.fetch(:z_max))
99
- end
100
- regions << metadata.fetch(:top_white)
101
- regions.each_with_index.map do |region, index|
102
- z_min = region.fetch(:z_min)
103
- z_max = region.fetch(:z_max)
119
+ top_white = metadata.fetch(:top_white)
120
+ ranges = @settings.resolved_z_part_ranges
121
+ ranges.each_with_index.map do |range, index|
122
+ z_min = range.begin
123
+ z_max = index == ranges.length - 1 ? top_white.fetch(:z_max) : range.end
104
124
  {
105
125
  plate_id: index + 1,
106
- name: plate_name(region.fetch(:id)),
126
+ name: "Z plate #{index + 1}",
107
127
  source_z_min: z_min,
108
128
  source_z_max: z_max,
109
129
  build_z_min: 0.0,
110
- build_z_max: (region[:build_z_max] || (z_max - z_min)).round(9)
130
+ build_z_max: (z_max - z_min + @settings.z_part_base_white_thickness(index)).round(9),
131
+ minimum_build_thickness: minimum_plate_build_thickness(range, index),
132
+ base_white_layers: @settings.z_part_base_white_layers(index),
133
+ base_white_thickness: @settings.z_part_base_white_thickness(index)
111
134
  }
112
135
  end
113
136
  end
114
137
 
115
- def plate_name(id)
116
- return "Top White" if id == "top_white"
117
-
118
- id.sub("color_region_", "Color region ")
138
+ def minimum_plate_build_thickness(range, index)
139
+ source_end = [range.end, @settings.effective_minimum_thickness].min
140
+ source_thickness = [source_end - range.begin, 0.0].max
141
+ (source_thickness + @settings.z_part_base_white_thickness(index)).round(9)
119
142
  end
120
143
 
121
144
  def used_materials(artifacts)
@@ -92,7 +92,11 @@ module Lithop
92
92
  plan = if @settings.separate_z_parts
93
93
  top_geometry = tile_white_geometry(
94
94
  tile, tile_model, white_heights,
95
- base_height: @settings.layer_structure.color_region_end
95
+ base_height: [
96
+ @settings.layer_structure.color_region_end,
97
+ @settings.resolved_z_part_ranges.last.begin
98
+ ].max,
99
+ minimum_height: @settings.effective_minimum_thickness
96
100
  )
97
101
  z_separated_material_mesh_plan(tile_model, top_geometry)
98
102
  else
@@ -102,11 +106,12 @@ module Lithop
102
106
  [tile_model, plan]
103
107
  end
104
108
 
105
- def tile_white_geometry(tile, model, heights, base_height: 0.0)
109
+ def tile_white_geometry(tile, model, heights, base_height: 0.0, minimum_height: nil)
106
110
  stride = @settings.columns + 1
107
111
  sliced_heights = (tile.row_start..tile.row_end).flat_map do |row|
108
112
  heights.slice((row * stride) + tile.column_start, model.columns + 1)
109
113
  end
114
+ sliced_heights.map! { |height| [height, minimum_height].max } if minimum_height
110
115
  Geometry.new(
111
116
  heights: sliced_heights,
112
117
  columns: model.columns,
@@ -119,15 +124,8 @@ module Lithop
119
124
  end
120
125
 
121
126
  def z_separated_material_mesh_plan(model, top_geometry)
122
- base_height = @settings.layer_structure.base_white_thickness
123
- base_geometry = BoxGeometry.new(
124
- width: model.x_boundaries.last,
125
- height: model.y_boundaries.last,
126
- z_min: 0.0,
127
- z_max: base_height
128
- )
129
127
  ZSeparatedMaterialMeshPlan.new(
130
- model:, base_geometry:, top_geometry:, settings: @settings,
128
+ model:, top_geometry:, settings: @settings,
131
129
  maximum_triangles: @settings.maximum_triangles,
132
130
  limit_message: ->(count) { @settings.triangle_limit_message("physical Z-separated CMYW mesh", count) }
133
131
  ).call
@@ -1,13 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lithop
4
- # Splits the reference White carrier and CMY pigments into closed Z-region parts.
4
+ # Shared mesh counting and conversion helpers for export plans.
5
+ module MaterialMeshPlanningHelpers
6
+ private
7
+
8
+ def triangle_count(geometry)
9
+ geometry.respond_to?(:triangle_count) ? geometry.triangle_count : geometry.each_triangle.count
10
+ end
11
+
12
+ def triangle_source(geometry)
13
+ geometry.is_a?(ColumnGeometry) ? geometry : geometry.each_triangle
14
+ end
15
+
16
+ def counts_by_material(entries, entry_counts)
17
+ entries.zip(entry_counts).each_with_object(Hash.new(0)) do |(entry, count), totals|
18
+ totals[entry.material] += count
19
+ end.freeze
20
+ end
21
+ end
22
+
23
+ # Slices the CMYW model into contiguous plates over strength-adjusted White supports.
5
24
  class ZSeparatedMaterialMeshPlan
25
+ include MaterialMeshPlanningHelpers
26
+
6
27
  Entry = Data.define(:material, :geometry, :name, :region, :build_z_offset)
7
28
 
8
- def initialize(model:, base_geometry:, top_geometry:, settings:, maximum_triangles:, limit_message:)
29
+ def initialize(model:, top_geometry:, settings:, maximum_triangles:, limit_message:)
9
30
  @model = model
10
- @base_geometry = base_geometry
11
31
  @top_geometry = top_geometry
12
32
  @settings = settings
13
33
  @maximum_triangles = maximum_triangles
@@ -36,42 +56,73 @@ module Lithop
36
56
 
37
57
  def build_entries
38
58
  entries = []
39
- color_ranges.each_with_index do |range, index|
40
- region_model = LayerRegionModel.new(model: @model, layer_range: range)
41
- region = "color_region_#{index + 1}"
42
- build_z_offset = @settings.layer_structure.base_white_thickness - region_model.z_boundaries.first
43
- entries << Entry.new(
44
- material: :white, geometry: @base_geometry, name: "Color region #{index + 1} Base White",
45
- region:, build_z_offset: 0.0
46
- )
59
+ plate_ranges.each_with_index do |plate_range, index|
60
+ region = "z_part_#{index + 1}"
61
+ source_z_min = plate_range.begin
62
+ build_z_offset = @settings.z_part_base_white_thickness(index) - source_z_min
63
+ append_base_white_entry(entries, index:, region:)
64
+ append_color_entries(entries, index:, region:, build_z_offset:)
65
+ append_top_white_entry(entries, index:, plate_range:, region:, build_z_offset:)
66
+ end
67
+ entries
68
+ end
69
+
70
+ def append_base_white_entry(entries, index:, region:)
71
+ range = 0.0...@settings.z_part_base_white_thickness(index)
72
+ entries << Entry.new(
73
+ material: :white, geometry: box_geometry(range),
74
+ name: "Z plate #{index + 1} Base White", region:, build_z_offset: 0.0
75
+ )
76
+ end
77
+
78
+ def append_color_entries(entries, index:, region:, build_z_offset:)
79
+ range = color_ranges.fetch(index)
80
+ return unless range.begin < range.end
81
+
82
+ region_model = ZRegionModel.new(model: @model, z_range: range)
83
+ entries << Entry.new(
84
+ material: :white, geometry: color_region_white_geometry(region_model),
85
+ name: "Z plate #{index + 1} White", region:, build_z_offset:
86
+ )
87
+ Palette::PIGMENTS.each do |material|
88
+ next unless region_model.materials.include?(material)
89
+
47
90
  entries << Entry.new(
48
- material: :white,
49
- geometry: color_region_white_geometry(region_model),
50
- name: "Color region #{index + 1} White",
51
- region:,
52
- build_z_offset:
91
+ material:, geometry: ColumnGeometry.new(model: region_model, material:),
92
+ name: "Z plate #{index + 1} #{material.to_s.capitalize}", region:, build_z_offset:
53
93
  )
54
- Palette::PIGMENTS.each do |material|
55
- next unless region_model.materials.include?(material)
56
-
57
- entries << Entry.new(
58
- material:,
59
- geometry: ColumnGeometry.new(model: region_model, material:),
60
- name: "Color region #{index + 1} #{material.to_s.capitalize}",
61
- region:,
62
- build_z_offset:
63
- )
64
- end
65
94
  end
95
+ end
96
+
97
+ def append_top_white_entry(entries, index:, plate_range:, region:, build_z_offset:)
98
+ top_start = @settings.layer_structure.color_region_end
99
+ range = intersection(plate_range, top_start...@settings.z_separated_source_end)
100
+ return unless range
101
+
102
+ geometry = index == plate_ranges.length - 1 ? @top_geometry : box_geometry(range)
66
103
  entries << Entry.new(
67
- material: :white, geometry: @top_geometry, name: "Top White",
68
- region: "top_white", build_z_offset: -@settings.layer_structure.color_region_end
104
+ material: :white, geometry:, name: "Z plate #{index + 1} Top White",
105
+ region:, build_z_offset:
69
106
  )
70
- entries
71
107
  end
72
108
 
73
109
  def color_ranges
74
- @settings.layer_structure.color_region_layer_ranges(@settings.color_region_parts)
110
+ @settings.resolved_z_color_ranges
111
+ end
112
+
113
+ def plate_ranges = @settings.resolved_z_part_ranges
114
+
115
+ def intersection(left, right)
116
+ start = [left.begin, right.begin].max
117
+ finish = [left.end, right.end].min
118
+ start < finish ? start...finish : nil
119
+ end
120
+
121
+ def box_geometry(range)
122
+ BoxGeometry.new(
123
+ width: @model.x_boundaries.last, height: @model.y_boundaries.last,
124
+ z_min: range.begin, z_max: range.end
125
+ )
75
126
  end
76
127
 
77
128
  def color_region_white_geometry(model)
@@ -82,20 +133,6 @@ module Lithop
82
133
  z_max: model.z_boundaries.last
83
134
  )
84
135
  end
85
-
86
- def triangle_count(geometry)
87
- geometry.respond_to?(:triangle_count) ? geometry.triangle_count : geometry.each_triangle.count
88
- end
89
-
90
- def triangle_source(geometry)
91
- geometry.is_a?(ColumnGeometry) ? geometry : geometry.each_triangle
92
- end
93
-
94
- def counts_by_material(entries, entry_counts)
95
- entries.zip(entry_counts).each_with_object(Hash.new(0)) do |(entry, count), totals|
96
- totals[entry.material] += count
97
- end.freeze
98
- end
99
136
  end
100
137
 
101
138
  # Builds the reference-style overlapping White height field and contiguous CMY columns.
@@ -38,7 +38,7 @@ module Lithop
38
38
  - White thickness uses #{settings.pixel_size} mm XY cells; CMYW recipes use
39
39
  #{settings.effective_color_pixel_size} mm color cells.
40
40
  - Use 100% rectilinear infill and verify every layer in the slicer before printing.
41
- - The effective thickness range is #{settings.effective_minimum_thickness}–#{settings.effective_maximum_thickness} mm.
41
+ - The effective thickness range is #{output_minimum_thickness(settings)}–#{output_maximum_thickness(settings)} mm.
42
42
  MARKDOWN
43
43
  end
44
44
 
@@ -52,8 +52,23 @@ module Lithop
52
52
  def z_part_line(settings)
53
53
  return "- Z regions remain combined into the material parts." unless settings.separate_z_parts
54
54
 
55
- "- Z regions use #{settings.color_region_parts + 1} plates: each CMYW color region includes " \
56
- "a Base White carrier, followed by Top White; every plate starts at Z=0."
55
+ "- The separated model uses #{settings.resolved_z_part_count} contiguous Z plates with Base White " \
56
+ "supports of #{settings.z_part_base_white_layers_by_plate.join("/")} layers " \
57
+ "(#{settings.z_part_base_white_thicknesses.join("/")} mm); every plate is at least " \
58
+ "#{settings.z_part_minimum_build_thickness} mm thick. The assembled thickness range is " \
59
+ "#{settings.z_separated_output_minimum_thickness}–#{settings.z_separated_output_maximum_thickness} mm."
60
+ end
61
+
62
+ def output_minimum_thickness(settings)
63
+ return settings.effective_minimum_thickness unless settings.separate_z_parts
64
+
65
+ settings.z_separated_output_minimum_thickness
66
+ end
67
+
68
+ def output_maximum_thickness(settings)
69
+ return settings.effective_maximum_thickness unless settings.separate_z_parts
70
+
71
+ settings.z_separated_output_maximum_thickness
57
72
  end
58
73
 
59
74
  def profile_material(profile, material)
@@ -81,7 +96,8 @@ module Lithop
81
96
  "- Use a #{settings.nozzle_diameter} mm nozzle, #{settings.first_layer_height} mm base layers,\n " \
82
97
  "#{settings.layer_height} mm color layers, and #{upper_height} mm upper White layers."
83
98
  end
84
- private_class_method :model_instruction, :z_part_line, :profile_material, :calibration_line, :thickness_method,
99
+ private_class_method :model_instruction, :z_part_line, :output_minimum_thickness, :profile_material,
100
+ :calibration_line, :thickness_method,
85
101
  :base_layer_description, :layer_height_description
86
102
  end
87
103
  end
@@ -136,9 +136,7 @@ module Lithop
136
136
  end
137
137
 
138
138
  def source_object_id(assembly)
139
- return 3 if assembly.region == "top_white"
140
-
141
- assembly.region.delete_prefix("color_region_").to_i - 1
139
+ assembly.region.delete_prefix("z_part_").to_i - 1
142
140
  end
143
141
 
144
142
  def number(value) = format("%.9g", value)
@@ -150,12 +148,13 @@ module Lithop
150
148
 
151
149
  def layer_ranges_xml
152
150
  object_id = @assemblies.length
153
- min_z = @settings.top_white_start
154
- max_z = @settings.maximum_thickness
151
+ min_z = @settings.layer_structure.top_white_start
152
+ max_z = @settings.effective_maximum_thickness
155
153
  if @settings.separate_z_parts
156
- color_end = @settings.layer_structure.color_region_end
157
- min_z = (min_z - color_end).round(9)
158
- max_z = (max_z - color_end).round(9)
154
+ source_z_min = last_plate_source_z_min
155
+ base_white = @settings.z_part_base_white_thickness(@settings.resolved_z_part_count - 1)
156
+ min_z = (min_z - source_z_min + base_white).round(9)
157
+ max_z = (max_z - source_z_min + base_white).round(9)
159
158
  end
160
159
  <<~XML
161
160
  <?xml version="1.0" encoding="utf-8"?>
@@ -170,6 +169,10 @@ module Lithop
170
169
  XML
171
170
  end
172
171
 
172
+ def last_plate_source_z_min
173
+ @settings.resolved_z_part_ranges.last.begin
174
+ end
175
+
173
176
  def project_settings
174
177
  {
175
178
  "filament_colour" => %w[#0086D6 #EC008C #FCE300 #FFFFFF],
@@ -198,9 +198,8 @@ module Lithop
198
198
 
199
199
  def assembly_name(region)
200
200
  return "Lithop CMYW lithophane" unless region
201
- return "Top White" if region == "top_white"
202
201
 
203
- region.sub("color_region_", "Color region ")
202
+ region.sub("z_part_", "Z plate ")
204
203
  end
205
204
 
206
205
  def write_assembly(assembly, submodel_index = nil)
@@ -264,9 +263,7 @@ module Lithop
264
263
  end
265
264
 
266
265
  def plate_index(assembly)
267
- return 3 if assembly.region == "top_white"
268
-
269
- assembly.region.delete_prefix("color_region_").to_i - 1
266
+ assembly.region.delete_prefix("z_part_").to_i - 1
270
267
  end
271
268
 
272
269
  def model_header
@@ -5,13 +5,13 @@ module Lithop
5
5
  # rubocop:disable-next Metrics/ClassLength
6
6
  class ImagePipeline
7
7
  REFERENCE_WHITE_MAXIMUM_DENSITY = 0.04
8
- REFERENCE_WHITE_MAXIMUM_CHROMA = 0.08
9
8
  REFERENCE_DARK_MAXIMUM_VALUE = 0.75
10
9
  REFERENCE_NEUTRAL_MAXIMUM_CHROMA = 0.25
11
10
  REFERENCE_BROWN_LINE_CMY_COVERAGE = 1.0 / 7
12
11
  Result = Data.define(
13
12
  :width, :height, :cmys, :cmy_densities, :white_densities, :color_cells, :corrected_preview_png
14
13
  )
14
+ LoadedImage = Data.define(:image, :material_display)
15
15
 
16
16
  def initialize(settings)
17
17
  @settings = settings
@@ -23,13 +23,14 @@ module Lithop
23
23
  require "vips"
24
24
 
25
25
  access = @settings.color_pixel_size ? :random : :sequential
26
- image = normalized_image(path, access:)
26
+ loaded = normalized_image(path, access:)
27
+ image = loaded.image
27
28
  sampled = @placement.call(image, @settings.sampling_grid).cast(:uchar).copy_memory
28
29
 
29
30
  rgbs = rgb_components(sampled)
30
- color_samples = rgbs.map { |rgb| color_sample(rgb) }
31
+ color_samples = rgbs.map { |rgb| color_sample(rgb, material_display: loaded.material_display) }
31
32
  result_color_cells = if @settings.color_pixel_size
32
- color_cells(image)
33
+ color_cells(image, material_display: loaded.material_display)
33
34
  else
34
35
  standard_color_cells(color_samples, @settings.columns, @settings.rows)
35
36
  end
@@ -38,7 +39,7 @@ module Lithop
38
39
  height: sampled.height,
39
40
  cmys: color_samples.map(&:first),
40
41
  cmy_densities: color_samples.map(&:last),
41
- white_densities: rgbs.map { |rgb| white_density(rgb) },
42
+ white_densities: rgbs.map { |rgb| white_density(rgb, material_display: loaded.material_display) },
42
43
  color_cells: result_color_cells,
43
44
  corrected_preview_png: sampled.write_to_buffer(".png")
44
45
  )
@@ -52,8 +53,8 @@ module Lithop
52
53
  require "vips"
53
54
 
54
55
  grid = preview_grid(maximum_width:, maximum_height:)
55
- image = normalized_image(path, access: :sequential, sampling_grids: [grid])
56
- @placement.call(image, grid).cast(:uchar).write_to_buffer(".png")
56
+ loaded = normalized_image(path, access: :sequential, sampling_grids: [grid])
57
+ @placement.call(loaded.image, grid).cast(:uchar).write_to_buffer(".png")
57
58
  rescue SystemCallError, Vips::Error => e
58
59
  raise InputError, "could not read image: #{e.message}"
59
60
  end
@@ -61,19 +62,24 @@ module Lithop
61
62
  private
62
63
 
63
64
  def normalized_image(path, access:, sampling_grids: nil)
64
- image = load_image(path, access:, sampling_grids:)
65
+ loaded = load_image(path, access:, sampling_grids:)
66
+ image = loaded.image
65
67
  image = image.autorot
66
68
  validate_input!(image)
67
69
  image = image.flatten(background: [255, 255, 255]) if image.has_alpha?
68
- adjust_image(to_srgb(image))
70
+ LoadedImage.new(image: adjust_image(to_srgb(image)), material_display: loaded.material_display)
69
71
  end
70
72
 
71
73
  def load_image(path, access:, sampling_grids: nil)
72
74
  signature = File.binread(path, 8)
73
75
  raster = signature.start_with?("\xFF\xD8\xFF".b) || signature.start_with?("\x89PNG\r\n\x1A\n".b)
74
- return Vips::Image.new_from_file(path.to_s, access:) if raster
76
+ if raster
77
+ image = Vips::Image.new_from_file(path.to_s, access:)
78
+ return LoadedImage.new(image:, material_display: false)
79
+ end
75
80
 
76
- @svg_loader.call(path, access:, sampling_grids:)
81
+ result = @svg_loader.call(path, access:, sampling_grids:)
82
+ LoadedImage.new(image: result.image, material_display: result.material_display)
77
83
  end
78
84
 
79
85
  def preview_grid(maximum_width:, maximum_height:)
@@ -128,12 +134,12 @@ module Lithop
128
134
  [@settings.brightness, @settings.contrast, @settings.saturation]
129
135
  end
130
136
 
131
- def color_cells(image)
137
+ def color_cells(image, material_display:)
132
138
  return if @settings.color_pixel_size.nil?
133
139
 
134
140
  grid = @settings.color_sampling_grid
135
141
  sampled = @placement.call(image, grid).cast(:uchar)
136
- samples = rgb_components(sampled).map { |rgb| color_sample(rgb) }
142
+ samples = rgb_components(sampled).map { |rgb| color_sample(rgb, material_display:) }
137
143
  coarse_cells = standard_color_cells(samples, grid.columns, grid.rows)
138
144
  expand_color_cells(coarse_cells, grid)
139
145
  end
@@ -153,7 +159,7 @@ module Lithop
153
159
 
154
160
  density = samples.sum(&:last).fdiv(samples.length)
155
161
  cmy = samples.map(&:first).transpose.map { |components| components.sum.fdiv(components.length) }
156
- next [0.0, 0.0, 0.0] if standard_white?(density, cmy)
162
+ next [0.0, 0.0, 0.0] if standard_white?(density)
157
163
 
158
164
  cmy
159
165
  end
@@ -190,16 +196,28 @@ module Lithop
190
196
  end
191
197
  end
192
198
 
193
- def color_sample(rgb)
194
- display_sample = FilamentProfile.display_sample(rgb)
195
- return [display_sample.cmy, display_sample.density] if display_sample
199
+ def color_sample(rgb, material_display:)
200
+ display_sample = FilamentProfile.display_sample(rgb, mixtures: material_display)
201
+ return [mix_yellow(display_sample.cmy), display_sample.density] if display_sample
196
202
 
197
203
  maximum = rgb.max
198
- cmy = standard_cmy(rgb, maximum)
204
+ cmy = mix_yellow(standard_cmy(rgb, maximum))
199
205
  density = rgb.sum { |component| 1.0 - component }.fdiv(3)
200
206
  [cmy, density]
201
207
  end
202
208
 
209
+ def mix_yellow(cmy)
210
+ ratio = @settings.yellow_mix_percent.fdiv(100)
211
+ return cmy if ratio.zero? || cmy.fetch(2).zero?
212
+
213
+ mixed = cmy.dup
214
+ replacement = mixed.fetch(2) * ratio
215
+ mixed[2] -= replacement
216
+ index = @settings.yellow_mix_color == "cyan" ? 0 : 1
217
+ mixed[index] = [mixed.fetch(index) + replacement, 1.0].min
218
+ mixed
219
+ end
220
+
203
221
  def standard_cmy(rgb, maximum)
204
222
  chroma = rgb.map { |component| maximum - component }
205
223
  return chroma unless maximum <= REFERENCE_DARK_MAXIMUM_VALUE &&
@@ -212,20 +230,16 @@ module Lithop
212
230
  cmy.all? { |component| (component - REFERENCE_BROWN_LINE_CMY_COVERAGE).abs < 1e-12 }
213
231
  end
214
232
 
215
- def standard_white?(density, cmy)
216
- return false if density > REFERENCE_WHITE_MAXIMUM_DENSITY
217
-
218
- pale_yellow = cmy.fetch(2) == cmy.max && cmy.fetch(2) > REFERENCE_WHITE_MAXIMUM_CHROMA &&
219
- cmy.fetch(0) <= 0.05
220
- !pale_yellow
233
+ def standard_white?(density)
234
+ @settings.suppress_pale_colors && density <= REFERENCE_WHITE_MAXIMUM_DENSITY
221
235
  end
222
236
 
223
237
  def rec601_density(rgb)
224
238
  1.0 - rgb.zip([0.299, 0.587, 0.114]).sum { |component, weight| component * weight }
225
239
  end
226
240
 
227
- def white_density(rgb)
228
- FilamentProfile.display_sample(rgb)&.density || rec601_density(rgb)
241
+ def white_density(rgb, material_display:)
242
+ FilamentProfile.display_sample(rgb, mixtures: material_display)&.density || rec601_density(rgb)
229
243
  end
230
244
  end
231
245
  end
@@ -6,6 +6,8 @@ require "rexml/xpath"
6
6
  module Lithop
7
7
  # Applies the self-contained, static subset accepted for SVG input.
8
8
  class SVGDocumentValidator
9
+ MATERIAL_DISPLAY_ATTRIBUTE = "data-lithop-color-encoding"
10
+ MATERIAL_DISPLAY_VALUE = "material-display"
9
11
  FORBIDDEN_ELEMENTS = %w[script foreignobject animate animatemotion animatetransform set].freeze
10
12
  LENGTH = /\A\+?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?(?:px|mm|in)?\z/i
11
13
  NUMBER = /[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/
@@ -15,6 +17,7 @@ module Lithop
15
17
  validate_declarations!(data)
16
18
  document = parse(data)
17
19
  validate_document!(document)
20
+ document
18
21
  rescue REXML::ParseException => e
19
22
  raise InputError, "invalid SVG XML: #{e.message.lines.first.to_s.strip}"
20
23
  end
@@ -133,6 +136,7 @@ module Lithop
133
136
 
134
137
  # Rasterizes a validated SVG at the resolution required by the sampling grids.
135
138
  class SVGLoader
139
+ Result = Data.define(:image, :material_display)
136
140
  BASE_DPI = 96.0
137
141
  MAXIMUM_FILE_BYTES = 15_000_000
138
142
 
@@ -143,7 +147,7 @@ module Lithop
143
147
 
144
148
  def call(path, access:, sampling_grids: nil)
145
149
  data = read(path)
146
- @validator.call(data)
150
+ document = @validator.call(data)
147
151
  ensure_loader!
148
152
 
149
153
  probe = render(data, scale: 1.0, access: :sequential)
@@ -153,11 +157,16 @@ module Lithop
153
157
 
154
158
  image = render(data, scale:, access:)
155
159
  validate_rendered_image!(image)
156
- image
160
+ Result.new(image:, material_display: material_display?(document))
157
161
  end
158
162
 
159
163
  private
160
164
 
165
+ def material_display?(document)
166
+ document.root.attributes[SVGDocumentValidator::MATERIAL_DISPLAY_ATTRIBUTE] ==
167
+ SVGDocumentValidator::MATERIAL_DISPLAY_VALUE
168
+ end
169
+
161
170
  def read(path)
162
171
  raise InputError, "SVG input exceeds #{MAXIMUM_FILE_BYTES} bytes" if File.size(path) > MAXIMUM_FILE_BYTES
163
172
 
@@ -64,15 +64,6 @@ module Lithop
64
64
  def color_region_start = base_white_thickness
65
65
  def color_region_end = color_end
66
66
 
67
- def color_region_layer_ranges(part_count)
68
- size, remainder = color_layer_count.divmod(part_count)
69
- start = base_white_layers
70
- Array.new(part_count) do |index|
71
- finish = start + size + (index < remainder ? 1 : 0)
72
- (start...finish).tap { start = finish }
73
- end
74
- end
75
-
76
67
  def materials_for(recipe_layers, height)
77
68
  top_white_layers = top_white_layer_count(height, pigment_layers: recipe_layers.length)
78
69
  [*Array.new(base_white_layers, :white), *recipe_layers, *Array.new(top_white_layers, :white)]
@@ -62,12 +62,12 @@ module Lithop
62
62
 
63
63
  def default = BAMBU_LAB_PLA_CMYK
64
64
 
65
- def display_sample(rgb)
66
- cmy, density = MaterialDisplayMap.call(rgb)
65
+ def display_sample(rgb, mixtures: true)
66
+ cmy, density = MaterialDisplayMap.call(rgb, mixtures:)
67
67
  DisplaySample.new(cmy:, density:) if cmy
68
68
  end
69
69
 
70
- def display_cmy(rgb) = display_sample(rgb)&.cmy
70
+ def display_cmy(rgb, mixtures: true) = display_sample(rgb, mixtures:)&.cmy
71
71
 
72
72
  def validate!(profile)
73
73
  channels = profile.materials.map(&:channel)
@@ -32,12 +32,13 @@ module Lithop
32
32
 
33
33
  module_function
34
34
 
35
- def call(rgb)
35
+ def call(rgb, mixtures: true)
36
36
  channels = rgb.map { |component| (component * 255).round.clamp(0, 255) }
37
37
  packed = channels.reduce(0) { |value, component| (value << 8) | component }
38
38
  return [[0.0, 0.0, 0.0], 0.0] if packed == 0xFFFFFF
39
39
  return [CMY_BLACK, CMY_BLACK_DENSITIES.fetch(packed)] if CMY_BLACK_DENSITIES.key?(packed)
40
40
  return [COLOR_CMYS.fetch(packed), 1.0] if COLOR_CMYS.key?(packed)
41
+ return unless mixtures
41
42
 
42
43
  match = closest_line(channels)
43
44
  return unless match && match.first <= MAXIMUM_ERROR