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
@@ -0,0 +1,134 @@
1
+ use crate::COMPONENTS;
2
+ use magnus::{Error, Ruby};
3
+ use std::fmt::Write;
4
+
5
+ const NUMBER_FORMAT: &[u8] = b"%.9g\0";
6
+
7
+ fn append_number(output: &mut String, value: f64) -> Result<(), &'static str> {
8
+ if !value.is_finite() {
9
+ return Err("3MF coordinates must be finite");
10
+ }
11
+
12
+ let mut buffer = [0_u8; 64];
13
+ let length = unsafe {
14
+ libc::snprintf(
15
+ buffer.as_mut_ptr().cast(),
16
+ buffer.len(),
17
+ NUMBER_FORMAT.as_ptr().cast(),
18
+ value,
19
+ )
20
+ };
21
+ if length < 0 || length as usize >= buffer.len() {
22
+ return Err("could not format a 3MF coordinate");
23
+ }
24
+ let formatted = std::str::from_utf8(&buffer[..length as usize])
25
+ .map_err(|_| "could not format a 3MF coordinate")?;
26
+ output.push_str(formatted);
27
+ Ok(())
28
+ }
29
+
30
+ fn build_vertex_xml(vertices: &[f64], origin: &[f64]) -> Result<String, &'static str> {
31
+ if vertices.len() % COMPONENTS != 0 {
32
+ return Err("3MF vertices must contain XYZ triples");
33
+ }
34
+ if origin.len() != COMPONENTS || origin.iter().any(|value| !value.is_finite()) {
35
+ return Err("3MF origin must contain three finite values");
36
+ }
37
+
38
+ let mut output = String::with_capacity((vertices.len() / COMPONENTS) * 72);
39
+ for vertex in vertices.chunks_exact(COMPONENTS) {
40
+ output.push_str(" <vertex x=\"");
41
+ append_number(&mut output, vertex[0] - origin[0])?;
42
+ output.push_str("\" y=\"");
43
+ append_number(&mut output, vertex[1] - origin[1])?;
44
+ output.push_str("\" z=\"");
45
+ append_number(&mut output, vertex[2] - origin[2])?;
46
+ output.push_str("\"/>\n");
47
+ }
48
+ Ok(output)
49
+ }
50
+
51
+ pub(crate) fn build_vertex_records(
52
+ vertices: &[[f64; COMPONENTS]],
53
+ origin: &[f64],
54
+ ) -> Result<String, &'static str> {
55
+ if origin.len() != COMPONENTS || origin.iter().any(|value| !value.is_finite()) {
56
+ return Err("3MF origin must contain three finite values");
57
+ }
58
+ let mut output = String::with_capacity(vertices.len() * 72);
59
+ for vertex in vertices {
60
+ output.push_str(" <vertex x=\"");
61
+ append_number(&mut output, vertex[0] - origin[0])?;
62
+ output.push_str("\" y=\"");
63
+ append_number(&mut output, vertex[1] - origin[1])?;
64
+ output.push_str("\" z=\"");
65
+ append_number(&mut output, vertex[2] - origin[2])?;
66
+ output.push_str("\"/>\n");
67
+ }
68
+ Ok(output)
69
+ }
70
+
71
+ pub(crate) fn vertex_xml(
72
+ ruby: &Ruby,
73
+ vertices: Vec<f64>,
74
+ origin: Vec<f64>,
75
+ ) -> Result<String, Error> {
76
+ build_vertex_xml(&vertices, &origin)
77
+ .map_err(|message| Error::new(ruby.exception_arg_error(), message))
78
+ }
79
+
80
+ fn build_triangle_xml(indices: &[usize]) -> Result<String, &'static str> {
81
+ if indices.len() % COMPONENTS != 0 {
82
+ return Err("3MF triangle indices must contain triples");
83
+ }
84
+
85
+ let mut output = String::with_capacity((indices.len() / COMPONENTS) * 56);
86
+ for triangle in indices.chunks_exact(COMPONENTS) {
87
+ writeln!(
88
+ output,
89
+ " <triangle v1=\"{}\" v2=\"{}\" v3=\"{}\"/>",
90
+ triangle[0], triangle[1], triangle[2]
91
+ )
92
+ .map_err(|_| "could not format 3MF triangle indices")?;
93
+ }
94
+ Ok(output)
95
+ }
96
+
97
+ pub(crate) fn build_triangle_records(
98
+ triangles: &[[u32; COMPONENTS]],
99
+ ) -> Result<String, &'static str> {
100
+ let mut output = String::with_capacity(triangles.len() * 56);
101
+ for triangle in triangles {
102
+ writeln!(
103
+ output,
104
+ " <triangle v1=\"{}\" v2=\"{}\" v3=\"{}\"/>",
105
+ triangle[0], triangle[1], triangle[2]
106
+ )
107
+ .map_err(|_| "could not format 3MF triangle indices")?;
108
+ }
109
+ Ok(output)
110
+ }
111
+
112
+ pub(crate) fn triangle_xml(ruby: &Ruby, indices: Vec<usize>) -> Result<String, Error> {
113
+ build_triangle_xml(&indices).map_err(|message| Error::new(ruby.exception_arg_error(), message))
114
+ }
115
+
116
+ #[cfg(test)]
117
+ mod tests {
118
+ use super::{build_triangle_xml, build_vertex_xml};
119
+
120
+ #[test]
121
+ fn formats_vertex_xml_with_ruby_compatible_precision() {
122
+ let xml = build_vertex_xml(&[1.2345678912, 2.0, 3.0], &[1.0, 0.0, 0.5]).unwrap();
123
+ assert_eq!(
124
+ xml,
125
+ " <vertex x=\"0.234567891\" y=\"2\" z=\"2.5\"/>\n"
126
+ );
127
+ }
128
+
129
+ #[test]
130
+ fn formats_triangle_xml() {
131
+ let xml = build_triangle_xml(&[12, 3, 45]).unwrap();
132
+ assert_eq!(xml, " <triangle v1=\"12\" v2=\"3\" v3=\"45\"/>\n");
133
+ }
134
+ }
@@ -10,34 +10,47 @@ module Lithop
10
10
  class Export
11
11
  Result = Data.define(:job_id, :path, :files, :triangle_count, :patch_count)
12
12
 
13
- def initialize(settings:, project_dir:, clock: Time, random: SecureRandom)
13
+ def initialize(
14
+ settings:, project_dir:, job_progress: nil, clock: Time, random: SecureRandom, job_id: nil
15
+ )
14
16
  @settings = settings
15
17
  @project_dir = File.expand_path(project_dir)
16
18
  @clock = clock
17
19
  @random = random
20
+ @job_progress = job_progress
21
+ @job_id = job_id
18
22
  end
19
23
 
20
24
  def call
21
- job_id = "#{@clock.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{@random.hex(4)}-calibration"
22
- destination, generated = JobDirectory.new(project_dir: @project_dir, job_id:).call do |directory|
23
- generate(directory, job_id)
25
+ job_id = @job_id || "#{@clock.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{@random.hex(4)}-calibration"
26
+ execution = JobExecution.new(
27
+ project_dir: @project_dir, job_id:, progress: @job_progress, clock: @clock
28
+ )
29
+ execution.call do |job|
30
+ destination, generated = JobDirectory.new(project_dir: @project_dir, job_id:).call do |directory|
31
+ generate(directory, job_id, job).tap { job.checkpoint! }
32
+ end
33
+ job.publication_committed!(message: "Calibration artifacts published")
34
+ Result.new(job_id:, path: destination, files: generated.fetch(:files),
35
+ triangle_count: generated.fetch(:triangle_count), patch_count: generated.fetch(:patch_count))
24
36
  end
25
- Result.new(job_id:, path: destination, files: generated.fetch(:files),
26
- triangle_count: generated.fetch(:triangle_count), patch_count: generated.fetch(:patch_count))
27
37
  end
28
38
 
29
39
  private
30
40
 
31
- def generate(directory, job_id)
41
+ def generate(directory, job_id, job)
42
+ job.progress(phase: :calibration_planning, percent: 10, message: "Planning calibration target")
32
43
  target = Target.new(settings: @settings)
44
+ job.progress(phase: :three_mf_write, percent: 35, message: "Writing calibration 3MF")
33
45
  result = write_three_mf(File.join(directory, "calibration.3mf"), target)
46
+ job.progress(phase: :supporting_files, percent: 80, message: "Writing calibration files")
34
47
  TargetSVG.write(File.join(directory, "calibration.svg"), target)
35
48
  profile = Profile.template(profile_id: job_id, settings: @settings, target:)
36
49
  profile.write(File.join(directory, "calibration-profile.json"))
37
50
  write_manifest(directory, job_id, target, result)
38
51
  write_readme(directory, target)
39
52
  {
40
- files: Dir.children(directory).sort,
53
+ files: Dir.children(directory).reject { |name| name == JobDirectory::OWNER_FILE }.sort,
41
54
  triangle_count: result.triangle_count,
42
55
  patch_count: target.patches.length
43
56
  }
@@ -17,7 +17,9 @@ module Lithop
17
17
  raise OptionParser::InvalidArgument, arguments.join(" ") unless arguments.empty?
18
18
 
19
19
  project_dir, settings, = load_settings(options, keys: CALIBRATION_SETTING_KEYS)
20
- result = Calibration::Export.new(settings:, project_dir:).call
20
+ result = Calibration::Export.new(
21
+ settings:, project_dir:, job_progress: method(:report_job_progress)
22
+ ).call
21
23
  @out.puts "Generated #{result.patch_count} calibration patches and #{result.triangle_count} 3MF triangles"
22
24
  @out.puts "in #{result.path}"
23
25
  result.files.each { |file| @out.puts " #{file}" }
@@ -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
@@ -48,6 +48,19 @@ module Lithop
48
48
  end
49
49
  end
50
50
 
51
+ def ui(options, out)
52
+ OptionParser.new do |parser|
53
+ parser.banner = "Usage: lithop ui [--project-dir DIR] [--port PORT]"
54
+ parser.on("--project-dir DIR", "Project directory containing config/ and build/") do |value|
55
+ options[:project_dir] = value
56
+ end
57
+ parser.on("--port PORT", Integer, "Local UI port (default: 4567)") do |value|
58
+ options[:port] = value
59
+ end
60
+ add_information(parser, out)
61
+ end
62
+ end
63
+
51
64
  def add_print_settings(parser, options)
52
65
  parser.on("--first-layer-height MM", Float, "First layer height (default: 0.1)") do |value|
53
66
  options[:first_layer_height] = value
data/lib/lithop/cli.rb CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  require "optparse"
4
4
  require "fileutils"
5
+ require "json"
5
6
 
6
7
  module Lithop
7
8
  # Command-line interface for generating and cleaning lithophane projects.
9
+ # rubocop:disable-next Metrics/ClassLength
8
10
  class CLI
9
11
  include CLICalibration
10
12
 
@@ -18,9 +20,12 @@ module Lithop
18
20
  @cwd = File.expand_path(cwd)
19
21
  end
20
22
 
23
+ # rubocop:disable-next Metrics/AbcSize
21
24
  def run(argv)
22
25
  return clean(argv.drop(1)) if argv.first == "clean"
23
26
  return calibrate(argv.drop(1)) if argv.first == "calibrate"
27
+ return web_ui(argv.drop(1)) if argv.first == "ui"
28
+ return diagnostics(argv.drop(1)) if argv.first == "doctor"
24
29
 
25
30
  options = {}
26
31
  parser = CLIOptions.generation(options, @out)
@@ -44,7 +49,8 @@ module Lithop
44
49
  project_dir:,
45
50
  input_settings_path: settings_path,
46
51
  calibration:,
47
- progress: method(:report_estimate)
52
+ progress: method(:report_estimate),
53
+ job_progress: method(:report_job_progress)
48
54
  ).call(input)
49
55
  @out.puts "Generated #{result.triangle_count} STL and #{result.three_mf_triangle_count} CMYW 3MF triangles"
50
56
  @out.puts "in #{result.path}"
@@ -57,6 +63,13 @@ module Lithop
57
63
 
58
64
  private
59
65
 
66
+ def diagnostics(arguments)
67
+ raise InputError, "doctor does not accept arguments" unless arguments.empty?
68
+
69
+ @out.puts JSON.pretty_generate(Native.diagnostics)
70
+ 0
71
+ end
72
+
60
73
  def report_estimate(estimate)
61
74
  @out.puts(
62
75
  "Estimated #{estimate.stl_triangle_count} STL and " \
@@ -66,6 +79,12 @@ module Lithop
66
79
  @out.puts " CMYW by material: #{counts}"
67
80
  end
68
81
 
82
+ def report_job_progress(progress)
83
+ return unless progress.state == "running" && progress.percent.positive?
84
+
85
+ @out.puts "Progress #{progress.percent.round}%: #{progress.message}"
86
+ end
87
+
69
88
  def load_settings(options, keys: nil)
70
89
  resolved = CLISettings.new(cwd: @cwd).call(options, keys:)
71
90
  [resolved.project_dir, resolved.settings, resolved.input_settings_path]
@@ -98,5 +117,21 @@ module Lithop
98
117
  end
99
118
 
100
119
  def report_missing_build(build_dir) = 0.tap { @out.puts "No build directory: #{build_dir}" }
120
+
121
+ def web_ui(argv)
122
+ options = {}
123
+ parser = CLIOptions.ui(options, @out)
124
+ arguments = parser.parse(argv)
125
+ raise OptionParser::InvalidArgument, arguments.join(" ") unless arguments.empty?
126
+
127
+ project_dir = File.expand_path(options.fetch(:project_dir, @cwd), @cwd)
128
+ port = options.fetch(:port, 4567)
129
+ raise OptionParser::InvalidArgument, "port must be from 1 to 65535" unless port.between?(1, 65_535)
130
+
131
+ require_relative "web/server"
132
+ @out.puts "Lithop Local UI: http://127.0.0.1:#{port}"
133
+ Web::Server.run(project_dir:, port:)
134
+ 0
135
+ end
101
136
  end
102
137
  end
@@ -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,15 @@ 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)
9
+ PlannedTile = Data.define(:tile, :material_model, :mesh_plan, :estimate)
8
10
 
9
- def initialize(settings:, input_settings_path:, calibration:, progress:, instrumentation:)
11
+ def initialize(settings:, input_settings_path:, calibration:, progress:, job_progress:, instrumentation:)
10
12
  @settings = settings
11
13
  @input_settings_path = input_settings_path
12
14
  @calibration_context = ExportCalibration.new(settings:, input: calibration)
13
15
  @progress = progress
16
+ @job_progress = job_progress
14
17
  @instrumentation = Instrumentation.new(instrumentation)
15
18
  @metadata = ExportMetadata.new(
16
19
  settings:, input_settings_path:, calibration: @calibration_context
@@ -18,20 +21,33 @@ module Lithop
18
21
  end
19
22
 
20
23
  def call(source:, directory:, job_id:)
24
+ report_job_progress(:image_processing, 5, "Processing input image")
21
25
  image = @instrumentation.measure(:image_processing) { ImagePipeline.new(@settings).call(source) }
26
+ report_job_progress(:height_selection, 20, "Selecting White heights")
22
27
  white_heights = @instrumentation.measure(:height_selection) do
23
28
  @calibration_context.heights(image.white_densities)
24
29
  end
25
30
  geometry = white_geometry(white_heights)
31
+ report_job_progress(:material_model, 30, "Building CMYW material model")
26
32
  material_model = @instrumentation.measure(:material_model) do
27
33
  MaterialModel.new(
28
34
  cmys: image.cmys, cell_cmys: image.color_cells,
29
35
  heights: white_heights, settings: @settings, palette: @calibration_context.palette
30
36
  )
31
37
  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)
38
+ tile_layout = TileLayout.new(@settings)
39
+ report_job_progress(:triangle_planning, 40, "Planning mesh triangles")
40
+ planned_tiles = @instrumentation.measure(:triangle_planning) do
41
+ plan_tiles(tile_layout, material_model, white_heights)
42
+ end
43
+ tile_estimates = planned_tiles.map(&:estimate)
44
+ validate_total_triangle_count!(tile_estimates)
45
+ report_estimate(tile_estimates)
46
+ report_job_progress(:artifact_write, 50, "Writing model artifacts")
47
+ artifacts = write_artifacts(
48
+ directory, geometry, material_model, tile_layout, planned_tiles, white_heights, image.corrected_preview_png
49
+ )
50
+ report_job_progress(:metadata_write, 95, "Writing job metadata")
35
51
  write_supporting_files(directory, source, job_id, artifacts)
36
52
  result_for(directory, artifacts)
37
53
  end
@@ -58,31 +74,116 @@ module Lithop
58
74
  ).call
59
75
  end
60
76
 
61
- def write_artifacts(directory, geometry, material_model, mesh_plan, white_heights)
77
+ def plan_tiles(tile_layout, material_model, white_heights)
78
+ tile_layout.tiles.map do |tile|
79
+ tile_model, mesh_plan = @instrumentation.measure(:tile_plan_estimate) do
80
+ build_tile_plan(tile, material_model, white_heights)
81
+ end
82
+ estimate = TileEstimate.new(
83
+ triangle_count: mesh_plan.triangle_count,
84
+ triangle_counts_by_material: mesh_plan.triangle_counts_by_material
85
+ )
86
+ PlannedTile.new(tile:, material_model: tile_model, mesh_plan:, estimate:)
87
+ end
88
+ end
89
+
90
+ def build_tile_plan(tile, material_model, white_heights)
91
+ tile_model = TileMaterialModel.new(model: material_model, tile:)
92
+ plan = if @settings.separate_z_parts
93
+ top_geometry = tile_white_geometry(
94
+ tile, tile_model, white_heights,
95
+ base_height: @settings.layer_structure.color_region_end
96
+ )
97
+ z_separated_material_mesh_plan(tile_model, top_geometry)
98
+ else
99
+ geometry = tile_white_geometry(tile, tile_model, white_heights)
100
+ material_mesh_plan(tile_model, geometry)
101
+ end
102
+ [tile_model, plan]
103
+ end
104
+
105
+ def tile_white_geometry(tile, model, heights, base_height: 0.0)
106
+ stride = @settings.columns + 1
107
+ sliced_heights = (tile.row_start..tile.row_end).flat_map do |row|
108
+ heights.slice((row * stride) + tile.column_start, model.columns + 1)
109
+ end
110
+ Geometry.new(
111
+ heights: sliced_heights,
112
+ columns: model.columns,
113
+ rows: model.rows,
114
+ x_boundaries: model.x_boundaries,
115
+ y_boundaries: model.y_boundaries,
116
+ optimize_bottom: true,
117
+ base_height:
118
+ )
119
+ end
120
+
121
+ 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
+ ZSeparatedMaterialMeshPlan.new(
130
+ model:, base_geometry:, top_geometry:, settings: @settings,
131
+ maximum_triangles: @settings.maximum_triangles,
132
+ limit_message: ->(count) { @settings.triangle_limit_message("physical Z-separated CMYW mesh", count) }
133
+ ).call
134
+ end
135
+
136
+ def validate_total_triangle_count!(tile_estimates)
137
+ count = tile_estimates.sum(&:triangle_count)
138
+ return if count <= @settings.maximum_triangles
139
+
140
+ raise ConfigurationError, @settings.triangle_limit_message("physical CMYW tiled mesh", count)
141
+ end
142
+
143
+ # rubocop:disable-next Metrics/ParameterLists
144
+ def write_artifacts(directory, geometry, material_model, tile_layout, planned_tiles, white_heights, corrected_input)
62
145
  stl_name = "cmyk_white.stl"
63
146
  triangle_count = @instrumentation.measure(:stl_write) do
64
147
  STL.write(File.join(directory, stl_name), geometry.each_triangle)
65
148
  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
- )
149
+ FileUtils.mkdir_p(File.join(directory, "tiles")) if tile_layout.tiles.length > 1
150
+ tile_artifacts = planned_tiles.each_with_index.map do |planned, index|
151
+ tile = planned.tile
152
+ percent = 60 + ((index * 20.0) / planned_tiles.length)
153
+ report_job_progress(:three_mf_write, percent, "Writing 3MF tile #{index + 1}/#{planned_tiles.length}")
154
+ name = tile_layout.tiles.one? ? "lithop.3mf" : "tiles/#{tile.id}.3mf"
155
+ three_mf = @instrumentation.measure(:three_mf_write) do
156
+ ThreeMF.write(
157
+ File.join(directory, name), parts: planned.mesh_plan.parts, instrumentation: @instrumentation,
158
+ settings: @settings
159
+ )
160
+ end
161
+ planned.mesh_plan.validate!(three_mf)
162
+ TileArtifact.new(tile:, name:, three_mf:, material_model: planned.material_model)
163
+ end
164
+ if tile_layout.tiles.length > 1
165
+ TileLayoutSVG.write(File.join(directory, "tile-layout.svg"), settings: @settings, layout: tile_layout)
72
166
  end
73
- mesh_plan.validate!(three_mf)
167
+ report_job_progress(:preview_write, 85, "Writing previews")
74
168
  @instrumentation.measure(:preview_write) do
75
- ExportPreviews.write(directory, model: material_model, settings: @settings, white_heights:)
169
+ write_previews(directory, material_model, tile_artifacts, white_heights, corrected_input)
76
170
  end
77
171
  ExportArtifacts.new(
78
172
  stl_name:,
79
- three_mf_name:,
173
+ tile_artifacts:,
80
174
  stl_triangle_count: triangle_count,
81
- three_mf:,
82
- material_model:
175
+ material_model:,
176
+ tile_layout:
83
177
  )
84
178
  end
85
179
 
180
+ def write_previews(directory, material_model, tile_artifacts, white_heights, corrected_input)
181
+ ExportPreviews.write(
182
+ directory, model: material_model, settings: @settings, white_heights:, corrected_input:
183
+ )
184
+ ExportPreviews.write_tiles(directory, tile_artifacts:, settings: @settings, white_heights:)
185
+ end
186
+
86
187
  def write_supporting_files(directory, source, job_id, artifacts)
87
188
  @instrumentation.measure(:metadata_write) do
88
189
  @calibration_context.write(directory)
@@ -91,27 +192,36 @@ module Lithop
91
192
  File.join(directory, "PRINT_README.md"),
92
193
  settings: @settings,
93
194
  material_model: artifacts.material_model,
94
- calibration_profile: @calibration_context.profile
195
+ calibration_profile: @calibration_context.profile,
196
+ tile_artifacts: artifacts.tile_artifacts
95
197
  )
96
198
  end
97
199
  end
98
200
 
99
- def report_estimate(plan)
201
+ def report_estimate(tile_estimates)
100
202
  return unless @progress
101
203
 
204
+ counts = tile_estimates.each_with_object(Hash.new(0)) do |estimate, totals|
205
+ estimate.triangle_counts_by_material.each { |material, count| totals[material] += count }
206
+ end
207
+
102
208
  @progress.call(
103
209
  Export::Estimate.new(
104
210
  stl_triangle_count: @settings.estimated_triangles,
105
- three_mf_triangle_count: plan.triangle_count,
106
- triangle_counts_by_material: plan.triangle_counts_by_material
211
+ three_mf_triangle_count: tile_estimates.sum(&:triangle_count),
212
+ triangle_counts_by_material: counts.freeze
107
213
  )
108
214
  )
109
215
  end
110
216
 
217
+ def report_job_progress(phase, percent, message)
218
+ @job_progress&.call(phase:, percent:, message:)
219
+ end
220
+
111
221
  def result_for(directory, artifacts)
112
222
  three_mf = artifacts.three_mf
113
223
  Result.new(
114
- files: Dir.children(directory).sort,
224
+ files: Dir.glob("**/*", base: directory).select { |name| File.file?(File.join(directory, name)) }.sort,
115
225
  triangle_count: artifacts.stl_triangle_count,
116
226
  three_mf_triangle_count: three_mf.triangle_count,
117
227
  material_count: three_mf.material_count,