lithop 0.2.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/Cargo.lock +284 -0
  4. data/Cargo.toml +6 -0
  5. data/README.md +25 -7
  6. data/RELEASING.md +24 -6
  7. data/docs/README.md +3 -1
  8. data/docs/artifacts.md +9 -2
  9. data/docs/bambu-studio.md +5 -1
  10. data/docs/command.md +25 -2
  11. data/docs/config.md +1 -1
  12. data/docs/getting-started.md +8 -5
  13. data/docs/images/cmyw-lithophane-preview.png +0 -0
  14. data/docs/local-ui.md +114 -0
  15. data/docs/tiling.md +4 -2
  16. data/docs/troubleshooting.md +10 -0
  17. data/ext/lithop_native/Cargo.toml +17 -0
  18. data/ext/lithop_native/build.rs +4 -0
  19. data/ext/lithop_native/extconf.rb +6 -0
  20. data/ext/lithop_native/src/column_mesh.rs +388 -0
  21. data/ext/lithop_native/src/lib.rs +37 -0
  22. data/ext/lithop_native/src/mesh.rs +187 -0
  23. data/ext/lithop_native/src/palette.rs +67 -0
  24. data/ext/lithop_native/src/xml.rs +134 -0
  25. data/lib/lithop/calibration/export.rb +2 -2
  26. data/lib/lithop/cli/options.rb +13 -0
  27. data/lib/lithop/cli.rb +28 -0
  28. data/lib/lithop/export/generation.rb +30 -17
  29. data/lib/lithop/export/job.rb +2 -2
  30. data/lib/lithop/export/metadata.rb +8 -3
  31. data/lib/lithop/export/previews.rb +25 -1
  32. data/lib/lithop/formats/three_mf/mesh.rb +72 -3
  33. data/lib/lithop/formats/three_mf/model_writer.rb +19 -16
  34. data/lib/lithop/formats/three_mf/part_writer.rb +37 -0
  35. data/lib/lithop/formats/three_mf.rb +1 -1
  36. data/lib/lithop/image/pipeline.rb +50 -11
  37. data/lib/lithop/image/svg_loader.rb +5 -5
  38. data/lib/lithop/job_execution.rb +132 -4
  39. data/lib/lithop/mesh/column_geometry.rb +8 -0
  40. data/lib/lithop/mesh/column_topology.rb +2 -0
  41. data/lib/lithop/model/material_model.rb +17 -5
  42. data/lib/lithop/model/palette.rb +21 -3
  43. data/lib/lithop/native.rb +39 -0
  44. data/lib/lithop/tiling.rb +3 -2
  45. data/lib/lithop/version.rb +1 -1
  46. data/lib/lithop/web/app.rb +513 -0
  47. data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
  48. data/lib/lithop/web/job_launcher.rb +214 -0
  49. data/lib/lithop/web/job_store.rb +390 -0
  50. data/lib/lithop/web/preview_store.rb +182 -0
  51. data/lib/lithop/web/public/app.css +319 -0
  52. data/lib/lithop/web/public/app.js +1847 -0
  53. data/lib/lithop/web/server.rb +21 -0
  54. data/lib/lithop/web/settings_form.rb +64 -0
  55. data/lib/lithop/web/settings_library.rb +154 -0
  56. data/lib/lithop/web/views/index.erb +364 -0
  57. data/lib/lithop/web/worker.rb +27 -0
  58. data/lib/lithop.rb +1 -0
  59. data/sig/lithop.rbs +25 -0
  60. metadata +84 -2
@@ -0,0 +1,187 @@
1
+ use crate::xml::{build_triangle_records, build_vertex_records};
2
+ use crate::COMPONENTS;
3
+ use magnus::{Error, Ruby};
4
+ use std::cell::RefCell;
5
+ use std::collections::HashMap;
6
+
7
+ #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
8
+ pub(crate) enum VertexKey {
9
+ Coordinates([u64; COMPONENTS]),
10
+ Topology(i64),
11
+ }
12
+
13
+ #[derive(Default)]
14
+ pub(crate) struct MeshData {
15
+ pub(crate) vertices: Vec<[f64; COMPONENTS]>,
16
+ pub(crate) triangles: Vec<[u32; COMPONENTS]>,
17
+ coordinate_indices: HashMap<[u64; COMPONENTS], u32>,
18
+ topology_indices: HashMap<i64, u32>,
19
+ }
20
+
21
+ #[magnus::wrap(class = "Lithop::Native::MeshBuilder", free_immediately, size)]
22
+ pub(crate) struct MeshBuilder(RefCell<MeshData>);
23
+
24
+ impl MeshBuilder {
25
+ pub(crate) fn new() -> Self {
26
+ Self(RefCell::new(MeshData::default()))
27
+ }
28
+
29
+ pub(crate) fn from_data(data: MeshData) -> Self {
30
+ Self(RefCell::new(data))
31
+ }
32
+
33
+ pub(crate) fn add_batch(
34
+ ruby: &Ruby,
35
+ rb_self: &Self,
36
+ coordinates: Vec<f64>,
37
+ topology_keys: Vec<i64>,
38
+ ) -> Result<usize, Error> {
39
+ if coordinates.len() % (COMPONENTS * COMPONENTS) != 0 {
40
+ return Err(Error::new(
41
+ ruby.exception_arg_error(),
42
+ "3MF mesh coordinates must contain complete triangles",
43
+ ));
44
+ }
45
+ if topology_keys.len() * COMPONENTS != coordinates.len() {
46
+ return Err(Error::new(
47
+ ruby.exception_arg_error(),
48
+ "3MF mesh topology key count must match its vertices",
49
+ ));
50
+ }
51
+ if coordinates.iter().any(|value| !value.is_finite()) {
52
+ return Err(Error::new(
53
+ ruby.exception_arg_error(),
54
+ "3MF coordinates must be finite",
55
+ ));
56
+ }
57
+
58
+ let mut data = rb_self.0.borrow_mut();
59
+ let maximum_vertex_count = data.vertices.len().checked_add(topology_keys.len());
60
+ if maximum_vertex_count.is_none_or(|count| count > u32::MAX as usize) {
61
+ return Err(Error::new(
62
+ ruby.exception_arg_error(),
63
+ "3MF mesh contains too many vertices",
64
+ ));
65
+ }
66
+ for (triangle_coordinates, triangle_keys) in coordinates
67
+ .chunks_exact(COMPONENTS * COMPONENTS)
68
+ .zip(topology_keys.chunks_exact(COMPONENTS))
69
+ {
70
+ let mut triangle = [0; COMPONENTS];
71
+ for vertex_offset in 0..COMPONENTS {
72
+ let start = vertex_offset * COMPONENTS;
73
+ let point = [
74
+ triangle_coordinates[start],
75
+ triangle_coordinates[start + 1],
76
+ triangle_coordinates[start + 2],
77
+ ];
78
+ let key = vertex_key(point, triangle_keys[vertex_offset]);
79
+ triangle[vertex_offset] = index_mesh_vertex(&mut data, point, key);
80
+ }
81
+ data.triangles.push(triangle);
82
+ }
83
+ Ok(data.triangles.len())
84
+ }
85
+
86
+ pub(crate) fn vertex_count(&self) -> usize {
87
+ self.0.borrow().vertices.len()
88
+ }
89
+ pub(crate) fn triangle_count(&self) -> usize {
90
+ self.0.borrow().triangles.len()
91
+ }
92
+
93
+ pub(crate) fn center(&self) -> Vec<f64> {
94
+ let data = self.0.borrow();
95
+ let mut minimum = [f64::INFINITY; COMPONENTS];
96
+ let mut maximum = [f64::NEG_INFINITY; COMPONENTS];
97
+ for vertex in &data.vertices {
98
+ for axis in 0..COMPONENTS {
99
+ minimum[axis] = minimum[axis].min(vertex[axis]);
100
+ maximum[axis] = maximum[axis].max(vertex[axis]);
101
+ }
102
+ }
103
+ (0..COMPONENTS)
104
+ .map(|axis| (minimum[axis] + maximum[axis]) / 2.0)
105
+ .collect()
106
+ }
107
+
108
+ pub(crate) fn vertex_xml(
109
+ ruby: &Ruby,
110
+ rb_self: &Self,
111
+ offset: usize,
112
+ count: usize,
113
+ origin: Vec<f64>,
114
+ ) -> Result<String, Error> {
115
+ let data = rb_self.0.borrow();
116
+ let end = offset.saturating_add(count).min(data.vertices.len());
117
+ if offset > end {
118
+ return Err(Error::new(
119
+ ruby.exception_arg_error(),
120
+ "3MF vertex offset is out of range",
121
+ ));
122
+ }
123
+ build_vertex_records(&data.vertices[offset..end], &origin)
124
+ .map_err(|message| Error::new(ruby.exception_arg_error(), message))
125
+ }
126
+
127
+ pub(crate) fn triangle_xml(
128
+ ruby: &Ruby,
129
+ rb_self: &Self,
130
+ offset: usize,
131
+ count: usize,
132
+ ) -> Result<String, Error> {
133
+ let data = rb_self.0.borrow();
134
+ let end = offset.saturating_add(count).min(data.triangles.len());
135
+ if offset > end {
136
+ return Err(Error::new(
137
+ ruby.exception_arg_error(),
138
+ "3MF triangle offset is out of range",
139
+ ));
140
+ }
141
+ build_triangle_records(&data.triangles[offset..end])
142
+ .map_err(|message| Error::new(ruby.exception_arg_error(), message))
143
+ }
144
+
145
+ pub(crate) fn memory_bytes(&self) -> usize {
146
+ let data = self.0.borrow();
147
+ data.vertices.capacity() * std::mem::size_of::<[f64; COMPONENTS]>()
148
+ + data.triangles.capacity() * std::mem::size_of::<[u32; COMPONENTS]>()
149
+ + data.coordinate_indices.capacity()
150
+ * (std::mem::size_of::<[u64; COMPONENTS]>() + std::mem::size_of::<u32>())
151
+ + data.topology_indices.capacity()
152
+ * (std::mem::size_of::<i64>() + std::mem::size_of::<u32>())
153
+ }
154
+ }
155
+
156
+ fn vertex_key(point: [f64; COMPONENTS], topology_key: i64) -> VertexKey {
157
+ if topology_key >= 0 {
158
+ VertexKey::Topology(topology_key)
159
+ } else {
160
+ VertexKey::Coordinates(point.map(|value| if value == 0.0 { 0 } else { value.to_bits() }))
161
+ }
162
+ }
163
+
164
+ pub(crate) fn index_mesh_vertex(
165
+ data: &mut MeshData,
166
+ point: [f64; COMPONENTS],
167
+ key: VertexKey,
168
+ ) -> u32 {
169
+ let existing = match key {
170
+ VertexKey::Coordinates(coordinates) => data.coordinate_indices.get(&coordinates),
171
+ VertexKey::Topology(topology) => data.topology_indices.get(&topology),
172
+ };
173
+ if let Some(index) = existing {
174
+ return *index;
175
+ }
176
+ let index = data.vertices.len() as u32;
177
+ data.vertices.push(point);
178
+ match key {
179
+ VertexKey::Coordinates(coordinates) => {
180
+ data.coordinate_indices.insert(coordinates, index);
181
+ }
182
+ VertexKey::Topology(topology) => {
183
+ data.topology_indices.insert(topology, index);
184
+ }
185
+ }
186
+ index
187
+ }
@@ -0,0 +1,67 @@
1
+ use crate::COMPONENTS;
2
+ use magnus::{Error, Ruby};
3
+
4
+ fn select_palette_indices(cmys: &[f64], coverages: &[f64]) -> Result<Vec<usize>, &'static str> {
5
+ if cmys.len() % COMPONENTS != 0 {
6
+ return Err("CMY values must contain triples");
7
+ }
8
+ if coverages.is_empty() || coverages.len() % COMPONENTS != 0 {
9
+ return Err("recipe coverage must contain triples");
10
+ }
11
+ if cmys
12
+ .iter()
13
+ .any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))
14
+ {
15
+ return Err("CMY values must be finite values between zero and one");
16
+ }
17
+
18
+ let recipes: Vec<&[f64]> = coverages.chunks_exact(COMPONENTS).collect();
19
+ let mut indices = Vec::with_capacity(cmys.len() / COMPONENTS);
20
+ for target in cmys.chunks_exact(COMPONENTS) {
21
+ let mut best_index = 0;
22
+ let mut best_distance = f64::INFINITY;
23
+ for (index, coverage) in recipes.iter().enumerate() {
24
+ let cyan = coverage[0] - target[0];
25
+ let magenta = coverage[1] - target[1];
26
+ let yellow = coverage[2] - target[2];
27
+ let distance = (cyan * cyan) + (magenta * magenta) + (yellow * yellow);
28
+ if distance < best_distance {
29
+ best_index = index;
30
+ best_distance = distance;
31
+ }
32
+ }
33
+ indices.push(best_index);
34
+ }
35
+ Ok(indices)
36
+ }
37
+
38
+ pub(crate) fn palette_indices(
39
+ ruby: &Ruby,
40
+ cmys: Vec<f64>,
41
+ coverages: Vec<f64>,
42
+ ) -> Result<Vec<usize>, Error> {
43
+ select_palette_indices(&cmys, &coverages)
44
+ .map_err(|message| Error::new(ruby.exception_arg_error(), message))
45
+ }
46
+
47
+ #[cfg(test)]
48
+ mod tests {
49
+ use super::select_palette_indices;
50
+
51
+ #[test]
52
+ fn selects_the_first_recipe_at_equal_distance() {
53
+ let indices =
54
+ select_palette_indices(&[0.5, 0.0, 0.0], &[0.0, 0.0, 0.0, 1.0, 0.0, 0.0]).unwrap();
55
+ assert_eq!(indices, vec![0]);
56
+ }
57
+
58
+ #[test]
59
+ fn selects_multiple_samples_in_one_call() {
60
+ let indices = select_palette_indices(
61
+ &[0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
62
+ &[0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
63
+ )
64
+ .unwrap();
65
+ assert_eq!(indices, vec![0, 1]);
66
+ }
67
+ }
@@ -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
+ }
@@ -28,9 +28,9 @@ module Lithop
28
28
  )
29
29
  execution.call do |job|
30
30
  destination, generated = JobDirectory.new(project_dir: @project_dir, job_id:).call do |directory|
31
- generate(directory, job_id, job)
31
+ generate(directory, job_id, job).tap { job.checkpoint! }
32
32
  end
33
- job.progress(phase: :publishing, percent: 99, message: "Calibration artifacts published")
33
+ job.publication_committed!(message: "Calibration artifacts published")
34
34
  Result.new(job_id:, path: destination, files: generated.fetch(:files),
35
35
  triangle_count: generated.fetch(:triangle_count), patch_count: generated.fetch(:patch_count))
36
36
  end
@@ -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)
@@ -58,6 +63,13 @@ module Lithop
58
63
 
59
64
  private
60
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
+
61
73
  def report_estimate(estimate)
62
74
  @out.puts(
63
75
  "Estimated #{estimate.stl_triangle_count} STL and " \
@@ -105,5 +117,21 @@ module Lithop
105
117
  end
106
118
 
107
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
108
136
  end
109
137
  end
@@ -6,6 +6,7 @@ module Lithop
6
6
  class ExportGeneration
7
7
  Result = Data.define(:files, :triangle_count, :three_mf_triangle_count, :material_count, :object_count)
8
8
  TileEstimate = Data.define(:triangle_count, :triangle_counts_by_material)
9
+ PlannedTile = Data.define(:tile, :material_model, :mesh_plan, :estimate)
9
10
 
10
11
  def initialize(settings:, input_settings_path:, calibration:, progress:, job_progress:, instrumentation:)
11
12
  @settings = settings
@@ -36,14 +37,15 @@ module Lithop
36
37
  end
37
38
  tile_layout = TileLayout.new(@settings)
38
39
  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)
40
+ planned_tiles = @instrumentation.measure(:triangle_planning) do
41
+ plan_tiles(tile_layout, material_model, white_heights)
41
42
  end
43
+ tile_estimates = planned_tiles.map(&:estimate)
42
44
  validate_total_triangle_count!(tile_estimates)
43
45
  report_estimate(tile_estimates)
44
46
  report_job_progress(:artifact_write, 50, "Writing model artifacts")
45
47
  artifacts = write_artifacts(
46
- directory, geometry, material_model, tile_layout, white_heights
48
+ directory, geometry, material_model, tile_layout, planned_tiles, white_heights, image.corrected_preview_png
47
49
  )
48
50
  report_job_progress(:metadata_write, 95, "Writing job metadata")
49
51
  write_supporting_files(directory, source, job_id, artifacts)
@@ -72,13 +74,16 @@ module Lithop
72
74
  ).call
73
75
  end
74
76
 
75
- def estimate_tiles(tile_layout, material_model, white_heights)
77
+ def plan_tiles(tile_layout, material_model, white_heights)
76
78
  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
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
81
85
  )
86
+ PlannedTile.new(tile:, material_model: tile_model, mesh_plan:, estimate:)
82
87
  end
83
88
  end
84
89
 
@@ -135,32 +140,33 @@ module Lithop
135
140
  raise ConfigurationError, @settings.triangle_limit_message("physical CMYW tiled mesh", count)
136
141
  end
137
142
 
138
- def write_artifacts(directory, geometry, material_model, tile_layout, white_heights)
143
+ # rubocop:disable-next Metrics/ParameterLists
144
+ def write_artifacts(directory, geometry, material_model, tile_layout, planned_tiles, white_heights, corrected_input)
139
145
  stl_name = "cmyk_white.stl"
140
146
  triangle_count = @instrumentation.measure(:stl_write) do
141
147
  STL.write(File.join(directory, stl_name), geometry.each_triangle)
142
148
  end
143
149
  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)
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}")
148
154
  name = tile_layout.tiles.one? ? "lithop.3mf" : "tiles/#{tile.id}.3mf"
149
155
  three_mf = @instrumentation.measure(:three_mf_write) do
150
156
  ThreeMF.write(
151
- File.join(directory, name), parts: mesh_plan.parts, instrumentation: @instrumentation,
157
+ File.join(directory, name), parts: planned.mesh_plan.parts, instrumentation: @instrumentation,
152
158
  settings: @settings
153
159
  )
154
160
  end
155
- mesh_plan.validate!(three_mf)
156
- TileArtifact.new(tile:, name:, three_mf:, material_model: tile_model)
161
+ planned.mesh_plan.validate!(three_mf)
162
+ TileArtifact.new(tile:, name:, three_mf:, material_model: planned.material_model)
157
163
  end
158
164
  if tile_layout.tiles.length > 1
159
165
  TileLayoutSVG.write(File.join(directory, "tile-layout.svg"), settings: @settings, layout: tile_layout)
160
166
  end
161
167
  report_job_progress(:preview_write, 85, "Writing previews")
162
168
  @instrumentation.measure(:preview_write) do
163
- ExportPreviews.write(directory, model: material_model, settings: @settings, white_heights:)
169
+ write_previews(directory, material_model, tile_artifacts, white_heights, corrected_input)
164
170
  end
165
171
  ExportArtifacts.new(
166
172
  stl_name:,
@@ -171,6 +177,13 @@ module Lithop
171
177
  )
172
178
  end
173
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
+
174
187
  def write_supporting_files(directory, source, job_id, artifacts)
175
188
  @instrumentation.measure(:metadata_write) do
176
189
  @calibration_context.write(directory)
@@ -45,9 +45,9 @@ module Lithop
45
45
  instrumentation: @instrumentation
46
46
  )
47
47
  destination, generated = JobDirectory.new(project_dir: @project_dir, job_id:).call do |directory|
48
- generation.call(source:, directory:, job_id:)
48
+ generation.call(source:, directory:, job_id:).tap { job.checkpoint! }
49
49
  end
50
- job.progress(phase: :publishing, percent: 99, message: "Artifacts published")
50
+ job.publication_committed!
51
51
  Result.new(
52
52
  job_id:,
53
53
  path: destination,
@@ -76,10 +76,13 @@ module Lithop
76
76
  def artifact_files(artifacts)
77
77
  files = [
78
78
  artifacts.stl_name, *artifacts.three_mf_names, "settings.json", "manifest.json",
79
- "preview.png", "transmission.png", "thickness.png",
79
+ "corrected-input.png", "preview.png", "transmission.png", "thickness.png",
80
80
  *Palette::MATERIALS.map { |material| "#{material}-mask.png" }, "PRINT_README.md"
81
81
  ]
82
- files << "tile-layout.svg" if artifacts.tile_artifacts.length > 1
82
+ if artifacts.tile_artifacts.length > 1
83
+ files.concat(artifacts.tile_artifacts.map { |artifact| ExportPreviews.tile_preview_name(artifact) })
84
+ files << "tile-layout.svg"
85
+ end
83
86
  files << "calibration-profile.json" if @calibration.profile
84
87
  files
85
88
  end
@@ -100,7 +103,7 @@ module Lithop
100
103
  artifacts.tile_artifacts.map do |artifact|
101
104
  tile = artifact.tile
102
105
  used = Palette::MATERIALS.select { |material| artifact.material_model.materials.include?(material) }
103
- {
106
+ metadata = {
104
107
  id: tile.id,
105
108
  file: artifact.name,
106
109
  row: tile.row,
@@ -112,6 +115,8 @@ module Lithop
112
115
  unused_materials: material_names(Palette::MATERIALS - used),
113
116
  triangle_count: artifact.three_mf.triangle_count
114
117
  }
118
+ metadata[:preview_file] = ExportPreviews.tile_preview_name(artifact) unless artifacts.tile_artifacts.one?
119
+ metadata
115
120
  end
116
121
  end
117
122
 
@@ -5,7 +5,7 @@ module Lithop
5
5
  module ExportPreviews
6
6
  module_function
7
7
 
8
- def write(directory, model:, settings:, white_heights: nil)
8
+ def write(directory, model:, settings:, white_heights: nil, corrected_input: nil)
9
9
  heights = white_heights && cell_heights(white_heights, settings.columns, settings.rows)
10
10
  options = {
11
11
  model:,
@@ -19,8 +19,32 @@ module Lithop
19
19
  Palette::MATERIALS.each do |material|
20
20
  Preview.write_mask(File.join(directory, "#{material}-mask.png"), model:, material:)
21
21
  end
22
+ File.binwrite(File.join(directory, "corrected-input.png"), corrected_input) if corrected_input
22
23
  end
23
24
 
25
+ def write_tiles(directory, tile_artifacts:, settings:, white_heights:)
26
+ return if tile_artifacts.one?
27
+
28
+ heights = cell_heights(white_heights, settings.columns, settings.rows)
29
+ tile_artifacts.each do |artifact|
30
+ tile = artifact.tile
31
+ tile_heights = (tile.row_start...tile.row_end).flat_map do |row|
32
+ offset = (row * settings.columns) + tile.column_start
33
+ heights.slice(offset, artifact.material_model.columns)
34
+ end
35
+ Preview.write_material(
36
+ File.join(directory, tile_preview_name(artifact)),
37
+ model: artifact.material_model,
38
+ minimum: settings.effective_minimum_thickness,
39
+ maximum: settings.effective_maximum_thickness,
40
+ heights: tile_heights,
41
+ transmission_gamma: 0.5
42
+ )
43
+ end
44
+ end
45
+
46
+ def tile_preview_name(artifact) = artifact.name.sub(/\.3mf\z/, "-preview.png")
47
+
24
48
  def cell_heights(vertex_heights, columns, rows)
25
49
  stride = columns + 1
26
50
  Array.new(columns * rows) do |index|