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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/Cargo.lock +284 -0
- data/Cargo.toml +6 -0
- data/README.md +25 -7
- data/RELEASING.md +24 -6
- data/docs/README.md +3 -1
- data/docs/artifacts.md +9 -2
- data/docs/bambu-studio.md +5 -1
- data/docs/command.md +25 -2
- data/docs/config.md +1 -1
- data/docs/getting-started.md +8 -5
- data/docs/images/cmyw-lithophane-preview.png +0 -0
- data/docs/local-ui.md +114 -0
- data/docs/tiling.md +4 -2
- data/docs/troubleshooting.md +10 -0
- data/ext/lithop_native/Cargo.toml +17 -0
- data/ext/lithop_native/build.rs +4 -0
- data/ext/lithop_native/extconf.rb +6 -0
- data/ext/lithop_native/src/column_mesh.rs +388 -0
- data/ext/lithop_native/src/lib.rs +37 -0
- data/ext/lithop_native/src/mesh.rs +187 -0
- data/ext/lithop_native/src/palette.rs +67 -0
- data/ext/lithop_native/src/xml.rs +134 -0
- data/lib/lithop/calibration/export.rb +2 -2
- data/lib/lithop/cli/options.rb +13 -0
- data/lib/lithop/cli.rb +28 -0
- data/lib/lithop/export/generation.rb +30 -17
- data/lib/lithop/export/job.rb +2 -2
- data/lib/lithop/export/metadata.rb +8 -3
- data/lib/lithop/export/previews.rb +25 -1
- data/lib/lithop/formats/three_mf/mesh.rb +72 -3
- data/lib/lithop/formats/three_mf/model_writer.rb +19 -16
- data/lib/lithop/formats/three_mf/part_writer.rb +37 -0
- data/lib/lithop/formats/three_mf.rb +1 -1
- data/lib/lithop/image/pipeline.rb +50 -11
- data/lib/lithop/image/svg_loader.rb +5 -5
- data/lib/lithop/job_execution.rb +132 -4
- data/lib/lithop/mesh/column_geometry.rb +8 -0
- data/lib/lithop/mesh/column_topology.rb +2 -0
- data/lib/lithop/model/material_model.rb +17 -5
- data/lib/lithop/model/palette.rb +21 -3
- data/lib/lithop/native.rb +39 -0
- data/lib/lithop/tiling.rb +3 -2
- data/lib/lithop/version.rb +1 -1
- data/lib/lithop/web/app.rb +513 -0
- data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
- data/lib/lithop/web/job_launcher.rb +214 -0
- data/lib/lithop/web/job_store.rb +390 -0
- data/lib/lithop/web/preview_store.rb +182 -0
- data/lib/lithop/web/public/app.css +319 -0
- data/lib/lithop/web/public/app.js +1847 -0
- data/lib/lithop/web/server.rb +21 -0
- data/lib/lithop/web/settings_form.rb +64 -0
- data/lib/lithop/web/settings_library.rb +154 -0
- data/lib/lithop/web/views/index.erb +364 -0
- data/lib/lithop/web/worker.rb +27 -0
- data/lib/lithop.rb +1 -0
- data/sig/lithop.rbs +25 -0
- 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.
|
|
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
|
data/lib/lithop/cli/options.rb
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
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
|
|
77
|
+
def plan_tiles(tile_layout, material_model, white_heights)
|
|
76
78
|
tile_layout.tiles.map do |tile|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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 =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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:
|
|
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
|
-
|
|
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)
|
data/lib/lithop/export/job.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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|
|