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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/Cargo.lock +284 -0
- data/Cargo.toml +6 -0
- data/README.md +31 -7
- data/RELEASING.md +24 -6
- data/docs/README.md +4 -1
- data/docs/artifacts.md +26 -2
- data/docs/bambu-studio.md +7 -1
- data/docs/color-model.md +2 -0
- data/docs/command.md +48 -7
- data/docs/config.md +31 -4
- data/docs/getting-started.md +10 -5
- data/docs/images/cmyw-lithophane-preview.png +0 -0
- data/docs/local-ui.md +114 -0
- data/docs/tiling.md +134 -0
- data/docs/troubleshooting.md +22 -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 +21 -8
- data/lib/lithop/cli/calibration.rb +3 -1
- data/lib/lithop/cli/image_options.rb +41 -2
- data/lib/lithop/cli/options.rb +13 -0
- data/lib/lithop/cli.rb +36 -1
- data/lib/lithop/export/color_metadata.rb +55 -0
- data/lib/lithop/export/generation.rb +131 -21
- data/lib/lithop/export/job.rb +30 -16
- data/lib/lithop/export/metadata.rb +54 -4
- data/lib/lithop/export/planning.rb +97 -0
- data/lib/lithop/export/previews.rb +25 -1
- data/lib/lithop/export/print_readme.rb +22 -4
- data/lib/lithop/export/result.rb +26 -4
- data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
- data/lib/lithop/formats/three_mf/mesh.rb +72 -3
- data/lib/lithop/formats/three_mf/model_writer.rb +249 -24
- data/lib/lithop/formats/three_mf/part_writer.rb +51 -6
- data/lib/lithop/formats/three_mf.rb +9 -6
- data/lib/lithop/image/pipeline.rb +66 -10
- data/lib/lithop/image/svg_loader.rb +5 -5
- data/lib/lithop/job_directory.rb +41 -0
- data/lib/lithop/job_execution.rb +378 -0
- data/lib/lithop/layer_structure.rb +11 -0
- data/lib/lithop/mesh/box_geometry.rb +41 -0
- data/lib/lithop/mesh/column_geometry.rb +10 -2
- data/lib/lithop/mesh/column_topology.rb +2 -0
- data/lib/lithop/mesh/geometry.rb +21 -13
- data/lib/lithop/model/layer_region_model.rb +24 -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/reference_three_mf.rb +17 -1
- data/lib/lithop/settings.rb +116 -1
- data/lib/lithop/settings_file.rb +10 -4
- data/lib/lithop/tiling.rb +127 -0
- 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 +5 -0
- data/sig/lithop.rbs +102 -2
- 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(
|
|
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
|
-
|
|
23
|
-
|
|
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(
|
|
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:
|
|
21
|
-
|
|
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/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)
|
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
167
|
+
report_job_progress(:preview_write, 85, "Writing previews")
|
|
74
168
|
@instrumentation.measure(:preview_write) do
|
|
75
|
-
|
|
169
|
+
write_previews(directory, material_model, tile_artifacts, white_heights, corrected_input)
|
|
76
170
|
end
|
|
77
171
|
ExportArtifacts.new(
|
|
78
172
|
stl_name:,
|
|
79
|
-
|
|
173
|
+
tile_artifacts:,
|
|
80
174
|
stl_triangle_count: triangle_count,
|
|
81
|
-
|
|
82
|
-
|
|
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(
|
|
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:
|
|
106
|
-
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.
|
|
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,
|