lithop 0.1.0 → 0.2.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/README.md +9 -3
  4. data/RELEASING.md +3 -3
  5. data/docs/README.md +1 -0
  6. data/docs/artifacts.md +19 -2
  7. data/docs/bambu-studio.md +2 -0
  8. data/docs/color-model.md +2 -0
  9. data/docs/command.md +23 -5
  10. data/docs/config.md +30 -3
  11. data/docs/getting-started.md +5 -3
  12. data/docs/tiling.md +132 -0
  13. data/docs/troubleshooting.md +12 -0
  14. data/lib/lithop/calibration/export.rb +21 -8
  15. data/lib/lithop/cli/calibration.rb +3 -1
  16. data/lib/lithop/cli/image_options.rb +41 -2
  17. data/lib/lithop/cli.rb +8 -1
  18. data/lib/lithop/export/color_metadata.rb +55 -0
  19. data/lib/lithop/export/generation.rb +117 -20
  20. data/lib/lithop/export/job.rb +30 -16
  21. data/lib/lithop/export/metadata.rb +48 -3
  22. data/lib/lithop/export/planning.rb +97 -0
  23. data/lib/lithop/export/print_readme.rb +22 -4
  24. data/lib/lithop/export/result.rb +26 -4
  25. data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
  26. data/lib/lithop/formats/three_mf/model_writer.rb +245 -23
  27. data/lib/lithop/formats/three_mf/part_writer.rb +14 -6
  28. data/lib/lithop/formats/three_mf.rb +8 -5
  29. data/lib/lithop/image/pipeline.rb +17 -0
  30. data/lib/lithop/job_directory.rb +41 -0
  31. data/lib/lithop/job_execution.rb +250 -0
  32. data/lib/lithop/layer_structure.rb +11 -0
  33. data/lib/lithop/mesh/box_geometry.rb +41 -0
  34. data/lib/lithop/mesh/column_geometry.rb +2 -2
  35. data/lib/lithop/mesh/geometry.rb +21 -13
  36. data/lib/lithop/model/layer_region_model.rb +24 -0
  37. data/lib/lithop/reference_three_mf.rb +17 -1
  38. data/lib/lithop/settings.rb +116 -1
  39. data/lib/lithop/settings_file.rb +10 -4
  40. data/lib/lithop/tiling.rb +126 -0
  41. data/lib/lithop/version.rb +1 -1
  42. data/lib/lithop.rb +4 -0
  43. data/sig/lithop.rbs +77 -2
  44. metadata +6 -1
@@ -7,8 +7,10 @@ module Lithop
7
7
 
8
8
  def add(parser, options)
9
9
  add_dimensions(parser, options)
10
+ add_tiles(parser, options)
10
11
  add_margins(parser, options)
11
12
  add_placement(parser, options)
13
+ add_adjustments(parser, options)
12
14
  add_thickness(parser, options)
13
15
  add_sampling(parser, options)
14
16
  end
@@ -17,11 +19,35 @@ module Lithop
17
19
  parser.on("--layout-profile NAME", "Layout profile: standard") do |value|
18
20
  options[:layout_profile] = value
19
21
  end
20
- parser.on("--width MM", Float, "Model width (default: 130)") { |value| options[:width] = value }
21
- parser.on("--height MM", Float, "Model height (default: 100)") { |value| options[:height] = value }
22
+ parser.on("--width MM", Float, "Model width (default: #{Settings::DEFAULTS.fetch(:width)})") do |value|
23
+ options[:width] = value
24
+ end
25
+ parser.on("--height MM", Float, "Model height (default: #{Settings::DEFAULTS.fetch(:height)})") do |value|
26
+ options[:height] = value
27
+ end
22
28
  end
23
29
  private_class_method :add_dimensions
24
30
 
31
+ def add_tiles(parser, options)
32
+ parser.on("--tile-count COUNT", Integer, "Number of 3MF tiles (default: 1)") do |value|
33
+ options[:tile_count] = value
34
+ end
35
+ parser.on("--tile-layout MODE", "Tile layout: auto/columns/rows/grid (default: auto)") do |value|
36
+ options[:tile_layout] = value.downcase
37
+ end
38
+ parser.on("--tile-rows COUNT", Integer, "Tile rows for grid layout") { |value| options[:tile_rows] = value }
39
+ parser.on("--tile-columns COUNT", Integer, "Tile columns for grid layout") do |value|
40
+ options[:tile_columns] = value
41
+ end
42
+ parser.on("--separate-z-parts", "Put base White, color regions, and top White on separate plates") do
43
+ options[:separate_z_parts] = true
44
+ end
45
+ parser.on("--color-region-parts COUNT", Integer, "Number of CMYW color-region Z parts (default: 3)") do |value|
46
+ options[:color_region_parts] = value
47
+ end
48
+ end
49
+ private_class_method :add_tiles
50
+
25
51
  def add_margins(parser, options)
26
52
  parser.on("--margin-top MM", Float, "Top margin (default: 0)") { |value| options[:margin_top] = value }
27
53
  parser.on("--margin-right MM", Float, "Right margin (default: 0)") { |value| options[:margin_right] = value }
@@ -46,6 +72,19 @@ module Lithop
46
72
  end
47
73
  private_class_method :add_placement
48
74
 
75
+ def add_adjustments(parser, options)
76
+ parser.on("--brightness PERCENT", Float, "Brightness: -100..100 (default: 0)") do |value|
77
+ options[:brightness] = value
78
+ end
79
+ parser.on("--contrast PERCENT", Float, "Contrast: -100..100 (default: 0)") do |value|
80
+ options[:contrast] = value
81
+ end
82
+ parser.on("--saturation PERCENT", Float, "Saturation: -100..100 (default: 0)") do |value|
83
+ options[:saturation] = value
84
+ end
85
+ end
86
+ private_class_method :add_adjustments
87
+
49
88
  def add_thickness(parser, options)
50
89
  parser.on("--min-thickness MM", Float, "Requested minimum model thickness (default: 1.0)") do |value|
51
90
  options[:minimum_thickness] = value
data/lib/lithop/cli.rb CHANGED
@@ -44,7 +44,8 @@ module Lithop
44
44
  project_dir:,
45
45
  input_settings_path: settings_path,
46
46
  calibration:,
47
- progress: method(:report_estimate)
47
+ progress: method(:report_estimate),
48
+ job_progress: method(:report_job_progress)
48
49
  ).call(input)
49
50
  @out.puts "Generated #{result.triangle_count} STL and #{result.three_mf_triangle_count} CMYW 3MF triangles"
50
51
  @out.puts "in #{result.path}"
@@ -66,6 +67,12 @@ module Lithop
66
67
  @out.puts " CMYW by material: #{counts}"
67
68
  end
68
69
 
70
+ def report_job_progress(progress)
71
+ return unless progress.state == "running" && progress.percent.positive?
72
+
73
+ @out.puts "Progress #{progress.percent.round}%: #{progress.message}"
74
+ end
75
+
69
76
  def load_settings(options, keys: nil)
70
77
  resolved = CLISettings.new(cwd: @cwd).call(options, keys:)
71
78
  [resolved.project_dir, resolved.settings, resolved.input_settings_path]
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Lithop
4
4
  # Builds the color-model portion of an export manifest.
5
+ # rubocop:disable-next Metrics/ClassLength
5
6
  class ExportColorMetadata
6
7
  def initialize(settings:, calibration:)
7
8
  @settings = settings
@@ -12,6 +13,7 @@ module Lithop
12
13
  def call(artifacts)
13
14
  {
14
15
  file: artifacts.three_mf_name,
16
+ files: artifacts.three_mf_names,
15
17
  format: "3MF",
16
18
  property: "3MF color group and Bambu Studio part extruder assignments",
17
19
  calibration: @calibration.metadata,
@@ -23,6 +25,7 @@ module Lithop
23
25
  layout_profile: @settings.layout_profile,
24
26
  base_white_layers: @settings.base_white_layers,
25
27
  minimum_top_white_layers: @settings.minimum_top_white_layers,
28
+ z_part_separation: z_part_separation,
26
29
  thickness_mm: thickness_structure,
27
30
  physical_material_layers_encoded: true,
28
31
  bambu_studio_material_assignments_encoded: true,
@@ -63,6 +66,58 @@ module Lithop
63
66
  }
64
67
  end
65
68
 
69
+ def z_part_separation
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|
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)
80
+ }
81
+ end
82
+ metadata = {
83
+ enabled: @settings.separate_z_parts,
84
+ base_white: { id: "base_white", z_min: 0.0, z_max: structure.base_white_thickness },
85
+ color_regions:,
86
+ top_white: {
87
+ id: "top_white", z_min: structure.color_region_end,
88
+ z_max: @settings.effective_maximum_thickness
89
+ }
90
+ }
91
+ metadata[:plates] = z_plates(metadata) if @settings.separate_z_parts
92
+ metadata
93
+ end
94
+
95
+ 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)
104
+ {
105
+ plate_id: index + 1,
106
+ name: plate_name(region.fetch(:id)),
107
+ source_z_min: z_min,
108
+ source_z_max: z_max,
109
+ build_z_min: 0.0,
110
+ build_z_max: (region[:build_z_max] || (z_max - z_min)).round(9)
111
+ }
112
+ end
113
+ end
114
+
115
+ def plate_name(id)
116
+ return "Top White" if id == "top_white"
117
+
118
+ id.sub("color_region_", "Color region ")
119
+ end
120
+
66
121
  def used_materials(artifacts)
67
122
  Palette::MATERIALS.select { |material| artifacts.material_model.materials.include?(material) }
68
123
  end
@@ -5,12 +5,14 @@ module Lithop
5
5
  # rubocop:disable-next Metrics/ClassLength
6
6
  class ExportGeneration
7
7
  Result = Data.define(:files, :triangle_count, :three_mf_triangle_count, :material_count, :object_count)
8
+ TileEstimate = Data.define(:triangle_count, :triangle_counts_by_material)
8
9
 
9
- def initialize(settings:, input_settings_path:, calibration:, progress:, instrumentation:)
10
+ def initialize(settings:, input_settings_path:, calibration:, progress:, job_progress:, instrumentation:)
10
11
  @settings = settings
11
12
  @input_settings_path = input_settings_path
12
13
  @calibration_context = ExportCalibration.new(settings:, input: calibration)
13
14
  @progress = progress
15
+ @job_progress = job_progress
14
16
  @instrumentation = Instrumentation.new(instrumentation)
15
17
  @metadata = ExportMetadata.new(
16
18
  settings:, input_settings_path:, calibration: @calibration_context
@@ -18,20 +20,32 @@ module Lithop
18
20
  end
19
21
 
20
22
  def call(source:, directory:, job_id:)
23
+ report_job_progress(:image_processing, 5, "Processing input image")
21
24
  image = @instrumentation.measure(:image_processing) { ImagePipeline.new(@settings).call(source) }
25
+ report_job_progress(:height_selection, 20, "Selecting White heights")
22
26
  white_heights = @instrumentation.measure(:height_selection) do
23
27
  @calibration_context.heights(image.white_densities)
24
28
  end
25
29
  geometry = white_geometry(white_heights)
30
+ report_job_progress(:material_model, 30, "Building CMYW material model")
26
31
  material_model = @instrumentation.measure(:material_model) do
27
32
  MaterialModel.new(
28
33
  cmys: image.cmys, cell_cmys: image.color_cells,
29
34
  heights: white_heights, settings: @settings, palette: @calibration_context.palette
30
35
  )
31
36
  end
32
- mesh_plan = @instrumentation.measure(:triangle_planning) { material_mesh_plan(material_model, geometry) }
33
- report_estimate(mesh_plan)
34
- artifacts = write_artifacts(directory, geometry, material_model, mesh_plan, white_heights)
37
+ tile_layout = TileLayout.new(@settings)
38
+ report_job_progress(:triangle_planning, 40, "Planning mesh triangles")
39
+ tile_estimates = @instrumentation.measure(:triangle_planning) do
40
+ estimate_tiles(tile_layout, material_model, white_heights)
41
+ end
42
+ validate_total_triangle_count!(tile_estimates)
43
+ report_estimate(tile_estimates)
44
+ report_job_progress(:artifact_write, 50, "Writing model artifacts")
45
+ artifacts = write_artifacts(
46
+ directory, geometry, material_model, tile_layout, white_heights
47
+ )
48
+ report_job_progress(:metadata_write, 95, "Writing job metadata")
35
49
  write_supporting_files(directory, source, job_id, artifacts)
36
50
  result_for(directory, artifacts)
37
51
  end
@@ -58,28 +72,102 @@ module Lithop
58
72
  ).call
59
73
  end
60
74
 
61
- def write_artifacts(directory, geometry, material_model, mesh_plan, white_heights)
75
+ def estimate_tiles(tile_layout, material_model, white_heights)
76
+ tile_layout.tiles.map do |tile|
77
+ _tile_model, plan = build_tile_plan(tile, material_model, white_heights)
78
+ TileEstimate.new(
79
+ triangle_count: plan.triangle_count,
80
+ triangle_counts_by_material: plan.triangle_counts_by_material
81
+ )
82
+ end
83
+ end
84
+
85
+ def build_tile_plan(tile, material_model, white_heights)
86
+ tile_model = TileMaterialModel.new(model: material_model, tile:)
87
+ plan = if @settings.separate_z_parts
88
+ top_geometry = tile_white_geometry(
89
+ tile, tile_model, white_heights,
90
+ base_height: @settings.layer_structure.color_region_end
91
+ )
92
+ z_separated_material_mesh_plan(tile_model, top_geometry)
93
+ else
94
+ geometry = tile_white_geometry(tile, tile_model, white_heights)
95
+ material_mesh_plan(tile_model, geometry)
96
+ end
97
+ [tile_model, plan]
98
+ end
99
+
100
+ def tile_white_geometry(tile, model, heights, base_height: 0.0)
101
+ stride = @settings.columns + 1
102
+ sliced_heights = (tile.row_start..tile.row_end).flat_map do |row|
103
+ heights.slice((row * stride) + tile.column_start, model.columns + 1)
104
+ end
105
+ Geometry.new(
106
+ heights: sliced_heights,
107
+ columns: model.columns,
108
+ rows: model.rows,
109
+ x_boundaries: model.x_boundaries,
110
+ y_boundaries: model.y_boundaries,
111
+ optimize_bottom: true,
112
+ base_height:
113
+ )
114
+ end
115
+
116
+ def z_separated_material_mesh_plan(model, top_geometry)
117
+ base_height = @settings.layer_structure.base_white_thickness
118
+ base_geometry = BoxGeometry.new(
119
+ width: model.x_boundaries.last,
120
+ height: model.y_boundaries.last,
121
+ z_min: 0.0,
122
+ z_max: base_height
123
+ )
124
+ ZSeparatedMaterialMeshPlan.new(
125
+ model:, base_geometry:, top_geometry:, settings: @settings,
126
+ maximum_triangles: @settings.maximum_triangles,
127
+ limit_message: ->(count) { @settings.triangle_limit_message("physical Z-separated CMYW mesh", count) }
128
+ ).call
129
+ end
130
+
131
+ def validate_total_triangle_count!(tile_estimates)
132
+ count = tile_estimates.sum(&:triangle_count)
133
+ return if count <= @settings.maximum_triangles
134
+
135
+ raise ConfigurationError, @settings.triangle_limit_message("physical CMYW tiled mesh", count)
136
+ end
137
+
138
+ def write_artifacts(directory, geometry, material_model, tile_layout, white_heights)
62
139
  stl_name = "cmyk_white.stl"
63
140
  triangle_count = @instrumentation.measure(:stl_write) do
64
141
  STL.write(File.join(directory, stl_name), geometry.each_triangle)
65
142
  end
66
- three_mf_name = "lithop.3mf"
67
- three_mf = @instrumentation.measure(:three_mf_write) do
68
- ThreeMF.write(
69
- File.join(directory, three_mf_name), parts: mesh_plan.parts, instrumentation: @instrumentation,
70
- settings: @settings
71
- )
143
+ FileUtils.mkdir_p(File.join(directory, "tiles")) if tile_layout.tiles.length > 1
144
+ tile_artifacts = tile_layout.tiles.each_with_index.map do |tile, index|
145
+ percent = 60 + ((index * 20.0) / tile_layout.tiles.length)
146
+ report_job_progress(:three_mf_write, percent, "Writing 3MF tile #{index + 1}/#{tile_layout.tiles.length}")
147
+ tile_model, mesh_plan = build_tile_plan(tile, material_model, white_heights)
148
+ name = tile_layout.tiles.one? ? "lithop.3mf" : "tiles/#{tile.id}.3mf"
149
+ three_mf = @instrumentation.measure(:three_mf_write) do
150
+ ThreeMF.write(
151
+ File.join(directory, name), parts: mesh_plan.parts, instrumentation: @instrumentation,
152
+ settings: @settings
153
+ )
154
+ end
155
+ mesh_plan.validate!(three_mf)
156
+ TileArtifact.new(tile:, name:, three_mf:, material_model: tile_model)
157
+ end
158
+ if tile_layout.tiles.length > 1
159
+ TileLayoutSVG.write(File.join(directory, "tile-layout.svg"), settings: @settings, layout: tile_layout)
72
160
  end
73
- mesh_plan.validate!(three_mf)
161
+ report_job_progress(:preview_write, 85, "Writing previews")
74
162
  @instrumentation.measure(:preview_write) do
75
163
  ExportPreviews.write(directory, model: material_model, settings: @settings, white_heights:)
76
164
  end
77
165
  ExportArtifacts.new(
78
166
  stl_name:,
79
- three_mf_name:,
167
+ tile_artifacts:,
80
168
  stl_triangle_count: triangle_count,
81
- three_mf:,
82
- material_model:
169
+ material_model:,
170
+ tile_layout:
83
171
  )
84
172
  end
85
173
 
@@ -91,27 +179,36 @@ module Lithop
91
179
  File.join(directory, "PRINT_README.md"),
92
180
  settings: @settings,
93
181
  material_model: artifacts.material_model,
94
- calibration_profile: @calibration_context.profile
182
+ calibration_profile: @calibration_context.profile,
183
+ tile_artifacts: artifacts.tile_artifacts
95
184
  )
96
185
  end
97
186
  end
98
187
 
99
- def report_estimate(plan)
188
+ def report_estimate(tile_estimates)
100
189
  return unless @progress
101
190
 
191
+ counts = tile_estimates.each_with_object(Hash.new(0)) do |estimate, totals|
192
+ estimate.triangle_counts_by_material.each { |material, count| totals[material] += count }
193
+ end
194
+
102
195
  @progress.call(
103
196
  Export::Estimate.new(
104
197
  stl_triangle_count: @settings.estimated_triangles,
105
- three_mf_triangle_count: plan.triangle_count,
106
- triangle_counts_by_material: plan.triangle_counts_by_material
198
+ three_mf_triangle_count: tile_estimates.sum(&:triangle_count),
199
+ triangle_counts_by_material: counts.freeze
107
200
  )
108
201
  )
109
202
  end
110
203
 
204
+ def report_job_progress(phase, percent, message)
205
+ @job_progress&.call(phase:, percent:, message:)
206
+ end
207
+
111
208
  def result_for(directory, artifacts)
112
209
  three_mf = artifacts.three_mf
113
210
  Result.new(
114
- files: Dir.children(directory).sort,
211
+ files: Dir.glob("**/*", base: directory).select { |name| File.file?(File.join(directory, name)) }.sort,
115
212
  triangle_count: artifacts.stl_triangle_count,
116
213
  three_mf_triangle_count: three_mf.triangle_count,
117
214
  material_count: three_mf.material_count,
@@ -15,35 +15,49 @@ module Lithop
15
15
  # rubocop:disable-next Metrics/ParameterLists
16
16
  def initialize(
17
17
  settings:, project_dir:, input_settings_path: nil, calibration: nil, progress: nil,
18
- instrumentation: nil, clock: Time, random: SecureRandom
18
+ job_progress: nil, instrumentation: nil, clock: Time, random: SecureRandom, job_id: nil
19
19
  )
20
20
  @settings = settings
21
21
  @project_dir = File.expand_path(project_dir)
22
22
  @input_settings_path = input_settings_path
23
23
  @calibration = calibration
24
24
  @progress = progress
25
+ @job_progress = job_progress
25
26
  @clock = clock
26
27
  @random = random
27
- @generation = ExportGeneration.new(
28
- settings:, input_settings_path:, calibration:, progress:, instrumentation:
29
- )
28
+ @instrumentation = instrumentation
29
+ @job_id = job_id
30
30
  end
31
31
 
32
32
  def call(input_path)
33
33
  source = File.expand_path(input_path)
34
- job_id = "#{@clock.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{@random.hex(4)}"
35
- destination, result = JobDirectory.new(project_dir: @project_dir, job_id:).call do |directory|
36
- @generation.call(source:, directory:, job_id:)
37
- end
38
- Result.new(
39
- job_id:,
40
- path: destination,
41
- files: result.files,
42
- triangle_count: result.triangle_count,
43
- three_mf_triangle_count: result.three_mf_triangle_count,
44
- material_count: result.material_count,
45
- object_count: result.object_count
34
+ job_id = @job_id || "#{@clock.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{@random.hex(4)}"
35
+ execution = JobExecution.new(
36
+ project_dir: @project_dir, job_id:, progress: @job_progress, clock: @clock
46
37
  )
38
+ execution.call do |job|
39
+ generation = ExportGeneration.new(
40
+ settings: @settings,
41
+ input_settings_path: @input_settings_path,
42
+ calibration: @calibration,
43
+ progress: @progress,
44
+ job_progress: job.method(:progress),
45
+ instrumentation: @instrumentation
46
+ )
47
+ destination, generated = JobDirectory.new(project_dir: @project_dir, job_id:).call do |directory|
48
+ generation.call(source:, directory:, job_id:)
49
+ end
50
+ job.progress(phase: :publishing, percent: 99, message: "Artifacts published")
51
+ Result.new(
52
+ job_id:,
53
+ path: destination,
54
+ files: generated.files,
55
+ triangle_count: generated.triangle_count,
56
+ three_mf_triangle_count: generated.three_mf_triangle_count,
57
+ material_count: generated.material_count,
58
+ object_count: generated.object_count
59
+ )
60
+ end
47
61
  end
48
62
  end
49
63
  end
@@ -5,6 +5,7 @@ require "json"
5
5
 
6
6
  module Lithop
7
7
  # Builds and writes the resolved settings and export manifest.
8
+ # rubocop:disable-next Metrics/ClassLength
8
9
  class ExportMetadata
9
10
  def initialize(settings:, input_settings_path:, calibration:)
10
11
  @settings = settings
@@ -29,8 +30,8 @@ module Lithop
29
30
 
30
31
  private
31
32
 
33
+ # rubocop:disable-next Metrics/MethodLength
32
34
  def manifest_for(source, job_id, artifacts)
33
- materials = material_names(used_materials(artifacts))
34
35
  {
35
36
  schema_version: 1,
36
37
  job_id:,
@@ -49,9 +50,21 @@ module Lithop
49
50
  },
50
51
  input_sha256: Digest::SHA256.file(source).hexdigest,
51
52
  filament_profile: FilamentProfile.default.to_h,
53
+ tile_layout: tile_layout_metadata(artifacts),
54
+ tiles: tile_metadata(artifacts),
52
55
  parts: [
53
56
  { file: artifacts.stl_name, material: "White", role: "monochrome_reference" },
54
- { file: artifacts.three_mf_name, materials:, role: "cmyw_lithophane" }
57
+ *artifacts.tile_artifacts.map do |artifact|
58
+ tile_materials = Palette::MATERIALS.select do |material|
59
+ artifact.material_model.materials.include?(material)
60
+ end
61
+ {
62
+ file: artifact.name,
63
+ tile_id: artifact.tile.id,
64
+ materials: material_names(tile_materials),
65
+ role: artifacts.tile_artifacts.one? ? "cmyw_lithophane" : "cmyw_lithophane_tile"
66
+ }
67
+ end
55
68
  ],
56
69
  color_model: @color_metadata.call(artifacts),
57
70
  files: artifact_files(artifacts),
@@ -62,14 +75,46 @@ module Lithop
62
75
 
63
76
  def artifact_files(artifacts)
64
77
  files = [
65
- artifacts.stl_name, artifacts.three_mf_name, "settings.json", "manifest.json",
78
+ artifacts.stl_name, *artifacts.three_mf_names, "settings.json", "manifest.json",
66
79
  "preview.png", "transmission.png", "thickness.png",
67
80
  *Palette::MATERIALS.map { |material| "#{material}-mask.png" }, "PRINT_README.md"
68
81
  ]
82
+ files << "tile-layout.svg" if artifacts.tile_artifacts.length > 1
69
83
  files << "calibration-profile.json" if @calibration.profile
70
84
  files
71
85
  end
72
86
 
87
+ def tile_layout_metadata(artifacts)
88
+ {
89
+ requested: @settings.tile_layout,
90
+ tile_count: @settings.tile_count,
91
+ rows: artifacts.tile_layout.rows,
92
+ columns: artifacts.tile_layout.columns,
93
+ order: "left_to_right_then_top_to_bottom",
94
+ local_origin: "xy_min",
95
+ assembled_image_top: "+Y"
96
+ }
97
+ end
98
+
99
+ def tile_metadata(artifacts)
100
+ artifacts.tile_artifacts.map do |artifact|
101
+ tile = artifact.tile
102
+ used = Palette::MATERIALS.select { |material| artifact.material_model.materials.include?(material) }
103
+ {
104
+ id: tile.id,
105
+ file: artifact.name,
106
+ row: tile.row,
107
+ column: tile.column,
108
+ offset_mm: tile.offset,
109
+ bounds_mm: tile.bounds,
110
+ dimensions_mm: { width: tile.width, height: tile.height, depth: @settings.effective_maximum_thickness },
111
+ materials: material_names(used),
112
+ unused_materials: material_names(Palette::MATERIALS - used),
113
+ triangle_count: artifact.three_mf.triangle_count
114
+ }
115
+ end
116
+ end
117
+
73
118
  def used_materials(artifacts)
74
119
  Palette::MATERIALS.select { |material| artifacts.material_model.materials.include?(material) }
75
120
  end
@@ -1,6 +1,103 @@
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.
5
+ class ZSeparatedMaterialMeshPlan
6
+ Entry = Data.define(:material, :geometry, :name, :region, :build_z_offset)
7
+
8
+ def initialize(model:, base_geometry:, top_geometry:, settings:, maximum_triangles:, limit_message:)
9
+ @model = model
10
+ @base_geometry = base_geometry
11
+ @top_geometry = top_geometry
12
+ @settings = settings
13
+ @maximum_triangles = maximum_triangles
14
+ @limit_message = limit_message
15
+ end
16
+
17
+ def call
18
+ entries = build_entries
19
+ entry_counts = entries.map { |entry| triangle_count(entry.geometry) }
20
+ total = entry_counts.sum
21
+ raise ConfigurationError, @limit_message.call(total) if total > @maximum_triangles
22
+
23
+ MaterialMeshPlan::Result.new(
24
+ parts: entries.map do |entry|
25
+ ThreeMF::Part.new(
26
+ material: entry.material, triangles: triangle_source(entry.geometry),
27
+ name: entry.name, region: entry.region, build_z_offset: entry.build_z_offset
28
+ )
29
+ end,
30
+ triangle_count: total,
31
+ triangle_counts_by_material: counts_by_material(entries, entry_counts)
32
+ )
33
+ end
34
+
35
+ private
36
+
37
+ def build_entries
38
+ 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
+ )
47
+ 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:
53
+ )
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
+ end
66
+ 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
69
+ )
70
+ entries
71
+ end
72
+
73
+ def color_ranges
74
+ @settings.layer_structure.color_region_layer_ranges(@settings.color_region_parts)
75
+ end
76
+
77
+ def color_region_white_geometry(model)
78
+ BoxGeometry.new(
79
+ width: model.x_boundaries.last,
80
+ height: model.y_boundaries.last,
81
+ z_min: model.z_boundaries.first,
82
+ z_max: model.z_boundaries.last
83
+ )
84
+ 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
+ end
100
+
4
101
  # Builds the reference-style overlapping White height field and contiguous CMY columns.
5
102
  class ReferenceMaterialMeshPlan
6
103
  def initialize(model:, white_geometry:, maximum_triangles:, limit_message:)