lithop 0.1.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 +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/RELEASING.md +63 -0
- data/bin/lithop +6 -0
- data/docs/README.md +44 -0
- data/docs/artifacts.md +122 -0
- data/docs/bambu-studio.md +93 -0
- data/docs/calibration.md +126 -0
- data/docs/color-model.md +114 -0
- data/docs/command.md +182 -0
- data/docs/config.md +201 -0
- data/docs/getting-started.md +98 -0
- data/docs/troubleshooting.md +112 -0
- data/lib/lithop/calibration/export.rb +102 -0
- data/lib/lithop/calibration/profile.rb +127 -0
- data/lib/lithop/calibration/profile_measurements.rb +82 -0
- data/lib/lithop/calibration/recipe_selector.rb +54 -0
- data/lib/lithop/calibration/target.rb +135 -0
- data/lib/lithop/calibration/target_svg.rb +89 -0
- data/lib/lithop/calibration/thickness_selector.rb +35 -0
- data/lib/lithop/cli/calibration.rb +40 -0
- data/lib/lithop/cli/image_options.rb +69 -0
- data/lib/lithop/cli/options.rb +95 -0
- data/lib/lithop/cli/settings.rb +34 -0
- data/lib/lithop/cli.rb +102 -0
- data/lib/lithop/export/calibration.rb +46 -0
- data/lib/lithop/export/color_metadata.rb +80 -0
- data/lib/lithop/export/generation.rb +122 -0
- data/lib/lithop/export/job.rb +49 -0
- data/lib/lithop/export/metadata.rb +85 -0
- data/lib/lithop/export/planning.rb +78 -0
- data/lib/lithop/export/previews.rb +35 -0
- data/lib/lithop/export/print_readme.rb +69 -0
- data/lib/lithop/export/result.rb +21 -0
- data/lib/lithop/formats/stl.rb +60 -0
- data/lib/lithop/formats/three_mf/bambu_metadata.rb +105 -0
- data/lib/lithop/formats/three_mf/mesh.rb +55 -0
- data/lib/lithop/formats/three_mf/model_writer.rb +114 -0
- data/lib/lithop/formats/three_mf/part_writer.rb +49 -0
- data/lib/lithop/formats/three_mf.rb +128 -0
- data/lib/lithop/image/pipeline.rb +175 -0
- data/lib/lithop/image/placement.rb +54 -0
- data/lib/lithop/image/preview.rb +63 -0
- data/lib/lithop/image/svg_loader.rb +218 -0
- data/lib/lithop/instrumentation.rb +22 -0
- data/lib/lithop/job_directory.rb +55 -0
- data/lib/lithop/layer_structure.rb +134 -0
- data/lib/lithop/mesh/column_geometry.rb +161 -0
- data/lib/lithop/mesh/column_topology.rb +262 -0
- data/lib/lithop/mesh/geometry.rb +116 -0
- data/lib/lithop/mesh/validator.rb +123 -0
- data/lib/lithop/mesh/voxel_geometry.rb +113 -0
- data/lib/lithop/mesh/voxel_topology.rb +79 -0
- data/lib/lithop/model/filament_profile.rb +79 -0
- data/lib/lithop/model/material_display_map.rb +61 -0
- data/lib/lithop/model/material_model.rb +106 -0
- data/lib/lithop/model/palette.rb +101 -0
- data/lib/lithop/model/thickness.rb +45 -0
- data/lib/lithop/reference_three_mf.rb +119 -0
- data/lib/lithop/sampling_grid.rb +41 -0
- data/lib/lithop/settings.rb +295 -0
- data/lib/lithop/settings_file.rb +56 -0
- data/lib/lithop/version.rb +5 -0
- data/lib/lithop.rb +55 -0
- data/sig/lithop.rbs +291 -0
- metadata +155 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
module Calibration
|
|
5
|
+
# Writes an editable SVG reference with the same patch layout as the calibration 3MF.
|
|
6
|
+
module TargetSVG
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def write(path, target)
|
|
10
|
+
File.write(path, document(target))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def document(target)
|
|
14
|
+
patches = target.patches.to_h { |patch| [[patch.row, patch.column], patch] }
|
|
15
|
+
cells = Array.new(target.rows * target.columns) do |index|
|
|
16
|
+
row, column = index.divmod(target.columns)
|
|
17
|
+
patch = patches[[row, column]]
|
|
18
|
+
cell(target, row, column, patch)
|
|
19
|
+
end
|
|
20
|
+
dimensions = "width=\"#{number(target.width)}mm\" height=\"#{number(target.height)}mm\" " \
|
|
21
|
+
"viewBox=\"0 0 #{number(target.width)} #{number(target.height)}\""
|
|
22
|
+
|
|
23
|
+
<<~SVG
|
|
24
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
25
|
+
<svg xmlns="http://www.w3.org/2000/svg" #{dimensions}
|
|
26
|
+
role="img" aria-labelledby="title desc">
|
|
27
|
+
<title id="title">CMYW calibration target</title>
|
|
28
|
+
<desc id="desc">56 CMYW recipes, eight White thickness stages, and a right orientation column.</desc>
|
|
29
|
+
<rect width="#{number(target.width)}" height="#{number(target.height)}" fill="#fff"/>
|
|
30
|
+
#{cells.join}
|
|
31
|
+
</svg>
|
|
32
|
+
SVG
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cell(target, row, column, patch)
|
|
36
|
+
x0 = target.x_boundaries.fetch(column)
|
|
37
|
+
x1 = target.x_boundaries.fetch(column + 1)
|
|
38
|
+
y0 = target.y_boundaries.fetch(row)
|
|
39
|
+
y1 = target.y_boundaries.fetch(row + 1)
|
|
40
|
+
return orientation_cell(x0, y0, x1 - x0, y1 - y0, row) unless patch
|
|
41
|
+
|
|
42
|
+
fill = patch_color(patch)
|
|
43
|
+
text_fill = dark_color?(fill) ? "#fff" : "#111"
|
|
44
|
+
<<~SVG.lines.map { |line| " #{line}" }.join
|
|
45
|
+
<g class="patch #{patch.kind}" id="#{patch.id}">
|
|
46
|
+
<rect x="#{number(x0)}" y="#{number(y0)}" width="#{number(x1 - x0)}" height="#{number(y1 - y0)}"
|
|
47
|
+
fill="#{fill}" stroke="#777" stroke-width="0.2"/>
|
|
48
|
+
<text x="#{number((x0 + x1) / 2)}" y="#{number((y0 + y1) / 2)}" fill="#{text_fill}"
|
|
49
|
+
font-family="monospace" font-size="3" text-anchor="middle" dominant-baseline="middle">#{patch.id}</text>
|
|
50
|
+
</g>
|
|
51
|
+
SVG
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def orientation_cell(x, y, width, height, row)
|
|
55
|
+
label = row.zero? ? "TOP" : ""
|
|
56
|
+
<<~SVG.lines.map { |line| " #{line}" }.join
|
|
57
|
+
<g class="patch orientation">
|
|
58
|
+
<rect x="#{number(x)}" y="#{number(y)}" width="#{number(width)}" height="#{number(height)}"
|
|
59
|
+
fill="#fff" stroke="#777" stroke-width="0.2"/>
|
|
60
|
+
<text x="#{number(x + (width / 2))}" y="#{number(y + (height / 2))}" fill="#111"
|
|
61
|
+
font-family="monospace" font-size="3" text-anchor="middle" dominant-baseline="middle">#{label}</text>
|
|
62
|
+
</g>
|
|
63
|
+
SVG
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def patch_color(patch)
|
|
67
|
+
return recipe_color(patch.recipe) if patch.kind == :recipe
|
|
68
|
+
|
|
69
|
+
stage = patch.id.delete_prefix("W").to_i
|
|
70
|
+
value = (255 * (1.0 - stage.fdiv(Target::WHITE_STAGE_COUNT - 1))).round
|
|
71
|
+
format("#%<value>02X%<value>02X%<value>02X", value:)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def recipe_color(recipe)
|
|
75
|
+
channels = recipe.coverage_cmy.map { |component| ((1.0 - component) * 255).round }
|
|
76
|
+
format("#%<red>02X%<green>02X%<blue>02X", red: channels[0], green: channels[1], blue: channels[2])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def dark_color?(color)
|
|
80
|
+
red, green, blue = color.scan(/[0-9A-F]{2}/).map { |channel| channel.to_i(16) }
|
|
81
|
+
((red * 299) + (green * 587) + (blue * 114)) < 128_000
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def number(value)
|
|
85
|
+
format("%.6f", value).sub(/\.?0+\z/, "")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
module Calibration
|
|
5
|
+
# Maps White display density to the closest measured White thickness stage.
|
|
6
|
+
class ThicknessSelector
|
|
7
|
+
def initialize(profile:, settings:)
|
|
8
|
+
profile.validate_complete!
|
|
9
|
+
@settings = settings
|
|
10
|
+
@stages = profile.measurements(:white_thickness)
|
|
11
|
+
@brightest = @stages.map(&:luminance).max
|
|
12
|
+
@darkest = @stages.map(&:luminance).min
|
|
13
|
+
return unless @brightest == @darkest
|
|
14
|
+
|
|
15
|
+
raise InputError, "calibration White thickness measurements must span more than one luminance"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(densities)
|
|
19
|
+
densities.map { |density| thickness_for(density) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def thickness_for(density)
|
|
25
|
+
unless density.is_a?(Numeric) && density.finite? && density.between?(0.0, 1.0)
|
|
26
|
+
raise InputError, "White density must be between 0 and 1"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
target = @brightest - (density * (@brightest - @darkest))
|
|
30
|
+
stage = @stages.min_by { |candidate| (candidate.luminance - target).abs }
|
|
31
|
+
@settings.quantize_thickness(stage.height)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Handles calibration target generation and measured profile loading for the CLI.
|
|
5
|
+
module CLICalibration
|
|
6
|
+
CALIBRATION_SETTING_KEYS = %i[
|
|
7
|
+
minimum_thickness maximum_thickness first_layer_height layer_height nozzle_diameter
|
|
8
|
+
color_model color_layer_count pigment_order maximum_triangles
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def calibrate(argv)
|
|
14
|
+
options = {}
|
|
15
|
+
parser = CLIOptions.calibration(options, @out)
|
|
16
|
+
arguments = parser.parse(argv)
|
|
17
|
+
raise OptionParser::InvalidArgument, arguments.join(" ") unless arguments.empty?
|
|
18
|
+
|
|
19
|
+
project_dir, settings, = load_settings(options, keys: CALIBRATION_SETTING_KEYS)
|
|
20
|
+
result = Calibration::Export.new(settings:, project_dir:).call
|
|
21
|
+
@out.puts "Generated #{result.patch_count} calibration patches and #{result.triangle_count} 3MF triangles"
|
|
22
|
+
@out.puts "in #{result.path}"
|
|
23
|
+
result.files.each { |file| @out.puts " #{file}" }
|
|
24
|
+
0
|
|
25
|
+
rescue OptionParser::ParseError, Error, SystemCallError => e
|
|
26
|
+
@err.puts "lithop: #{e.message}"
|
|
27
|
+
1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def load_calibration_profile(path, settings)
|
|
31
|
+
return [nil, nil] unless path
|
|
32
|
+
|
|
33
|
+
expanded = File.expand_path(path, @cwd)
|
|
34
|
+
profile = Calibration::Profile.load(expanded)
|
|
35
|
+
profile.validate_for!(settings)
|
|
36
|
+
profile.validate_complete!
|
|
37
|
+
[profile, expanded]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Adds dimensions, image placement, thickness, and sampling options.
|
|
5
|
+
module CLIImageOptions
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def add(parser, options)
|
|
9
|
+
add_dimensions(parser, options)
|
|
10
|
+
add_margins(parser, options)
|
|
11
|
+
add_placement(parser, options)
|
|
12
|
+
add_thickness(parser, options)
|
|
13
|
+
add_sampling(parser, options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_dimensions(parser, options)
|
|
17
|
+
parser.on("--layout-profile NAME", "Layout profile: standard") do |value|
|
|
18
|
+
options[:layout_profile] = value
|
|
19
|
+
end
|
|
20
|
+
parser.on("--width MM", Float, "Model width (default: 130)") { |value| options[:width] = value }
|
|
21
|
+
parser.on("--height MM", Float, "Model height (default: 100)") { |value| options[:height] = value }
|
|
22
|
+
end
|
|
23
|
+
private_class_method :add_dimensions
|
|
24
|
+
|
|
25
|
+
def add_margins(parser, options)
|
|
26
|
+
parser.on("--margin-top MM", Float, "Top margin (default: 0)") { |value| options[:margin_top] = value }
|
|
27
|
+
parser.on("--margin-right MM", Float, "Right margin (default: 0)") { |value| options[:margin_right] = value }
|
|
28
|
+
parser.on("--margin-bottom MM", Float, "Bottom margin (default: 0)") { |value| options[:margin_bottom] = value }
|
|
29
|
+
parser.on("--margin-left MM", Float, "Left margin (default: 0)") { |value| options[:margin_left] = value }
|
|
30
|
+
end
|
|
31
|
+
private_class_method :add_margins
|
|
32
|
+
|
|
33
|
+
def add_placement(parser, options)
|
|
34
|
+
parser.on("--image-fit MODE", "Image fit: cover/contain (default: cover)") do |value|
|
|
35
|
+
options[:image_fit] = value.downcase
|
|
36
|
+
end
|
|
37
|
+
parser.on("--crop-position-x FRACTION", Float, "Horizontal image position: 0..1 (default: 0.5)") do |value|
|
|
38
|
+
options[:crop_position_x] = value
|
|
39
|
+
end
|
|
40
|
+
parser.on("--crop-position-y FRACTION", Float, "Vertical image position: 0..1 (default: 0.5)") do |value|
|
|
41
|
+
options[:crop_position_y] = value
|
|
42
|
+
end
|
|
43
|
+
parser.on("--crop-scale FRACTION", Float, "Crop rectangle scale: >0..1 (default: 1)") do |value|
|
|
44
|
+
options[:crop_scale] = value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
private_class_method :add_placement
|
|
48
|
+
|
|
49
|
+
def add_thickness(parser, options)
|
|
50
|
+
parser.on("--min-thickness MM", Float, "Requested minimum model thickness (default: 1.0)") do |value|
|
|
51
|
+
options[:minimum_thickness] = value
|
|
52
|
+
end
|
|
53
|
+
parser.on("--max-thickness MM", Float, "Requested maximum model thickness (default: 2.14689422)") do |value|
|
|
54
|
+
options[:maximum_thickness] = value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
private_class_method :add_thickness
|
|
58
|
+
|
|
59
|
+
def add_sampling(parser, options)
|
|
60
|
+
parser.on("--pixel-size MM", Float, "XY pixel size (default: 0.22)") do |value|
|
|
61
|
+
options[:pixel_size] = value
|
|
62
|
+
end
|
|
63
|
+
parser.on("--color-pixel-size MM", Float, "Color-cell size >= pixel size (default: pixel size)") do |value|
|
|
64
|
+
options[:color_pixel_size] = value
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
private_class_method :add_sampling
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Lithop
|
|
6
|
+
# Builds parsers for Lithop's generation and cleanup commands.
|
|
7
|
+
module CLIOptions
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def generation(options, out)
|
|
11
|
+
OptionParser.new do |parser|
|
|
12
|
+
parser.banner = "Usage: lithop IMAGE [options]"
|
|
13
|
+
CLIImageOptions.add(parser, options)
|
|
14
|
+
add_print_settings(parser, options)
|
|
15
|
+
add_configuration(parser, options)
|
|
16
|
+
parser.on("--calibration-profile FILE", "Measured calibration profile JSON") do |value|
|
|
17
|
+
options[:calibration_profile] = value
|
|
18
|
+
end
|
|
19
|
+
add_information(parser, out)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clean(options, out)
|
|
24
|
+
OptionParser.new do |parser|
|
|
25
|
+
parser.banner = "Usage: lithop clean [--project-dir DIR]"
|
|
26
|
+
parser.on("--project-dir DIR", "Project directory containing build/") do |value|
|
|
27
|
+
options[:project_dir] = value
|
|
28
|
+
end
|
|
29
|
+
parser.on("-h", "--help", "Print help") do
|
|
30
|
+
out.puts parser
|
|
31
|
+
exit 0
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def calibration(options, out)
|
|
37
|
+
OptionParser.new do |parser|
|
|
38
|
+
parser.banner = "Usage: lithop calibrate [options]"
|
|
39
|
+
parser.on("--min-thickness MM", Float, "Requested minimum thickness (default: 1.0)") do |value|
|
|
40
|
+
options[:minimum_thickness] = value
|
|
41
|
+
end
|
|
42
|
+
parser.on("--max-thickness MM", Float, "Requested maximum thickness (default: 2.14689422)") do |value|
|
|
43
|
+
options[:maximum_thickness] = value
|
|
44
|
+
end
|
|
45
|
+
add_print_settings(parser, options)
|
|
46
|
+
add_configuration(parser, options)
|
|
47
|
+
add_information(parser, out)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def add_print_settings(parser, options)
|
|
52
|
+
parser.on("--first-layer-height MM", Float, "First layer height (default: 0.1)") do |value|
|
|
53
|
+
options[:first_layer_height] = value
|
|
54
|
+
end
|
|
55
|
+
parser.on("--layer-height MM", Float, "Layer height after first layer (default: 0.1)") do |value|
|
|
56
|
+
options[:layer_height] = value
|
|
57
|
+
end
|
|
58
|
+
parser.on("--nozzle-diameter MM", Float, "Nozzle diameter: 0.2/0.4 (default: 0.2)") do |value|
|
|
59
|
+
options[:nozzle_diameter] = value
|
|
60
|
+
end
|
|
61
|
+
parser.on("--color-model MODEL", "Color model (default: cmy)") do |value|
|
|
62
|
+
options[:color_model] = value.downcase
|
|
63
|
+
end
|
|
64
|
+
parser.on("--color-layer-count COUNT", Integer, "CMY/W color layers: 4/5/7 (default: 7)") do |value|
|
|
65
|
+
options[:color_layer_count] = value
|
|
66
|
+
end
|
|
67
|
+
parser.on("--pigment-order LIST", "Pigment order, e.g. cyan,magenta,yellow") do |value|
|
|
68
|
+
options[:pigment_order] = value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
private_class_method :add_print_settings
|
|
72
|
+
|
|
73
|
+
def add_configuration(parser, options)
|
|
74
|
+
parser.on("--config FILE", "Input settings JSON (default: PROJECT/config/settings.json)") do |value|
|
|
75
|
+
options[:settings_file] = value
|
|
76
|
+
end
|
|
77
|
+
parser.on("--project-dir DIR", "Project directory containing config/ and build/") do |value|
|
|
78
|
+
options[:project_dir] = value
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
private_class_method :add_configuration
|
|
82
|
+
|
|
83
|
+
def add_information(parser, out)
|
|
84
|
+
parser.on("-v", "--version", "Print version") do
|
|
85
|
+
out.puts Lithop::VERSION
|
|
86
|
+
exit 0
|
|
87
|
+
end
|
|
88
|
+
parser.on("-h", "--help", "Print help") do
|
|
89
|
+
out.puts parser
|
|
90
|
+
exit 0
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
private_class_method :add_information
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Resolves project paths and merges CLI, JSON, and default generation settings.
|
|
5
|
+
class CLISettings
|
|
6
|
+
Result = Data.define(:project_dir, :settings, :input_settings_path)
|
|
7
|
+
|
|
8
|
+
def initialize(cwd:)
|
|
9
|
+
@cwd = File.expand_path(cwd)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(options, keys: nil)
|
|
13
|
+
values = options.dup
|
|
14
|
+
project_dir = File.expand_path(values.delete(:project_dir) || @cwd, @cwd)
|
|
15
|
+
explicit_settings_file = values.delete(:settings_file)
|
|
16
|
+
input_settings_path = settings_path(project_dir, explicit_settings_file)
|
|
17
|
+
file_settings = SettingsFile.load(input_settings_path, required: !explicit_settings_file.nil?)
|
|
18
|
+
configured = keys ? file_settings.values.slice(*keys) : file_settings.values
|
|
19
|
+
Result.new(
|
|
20
|
+
project_dir:,
|
|
21
|
+
settings: Settings.new(**configured, **values),
|
|
22
|
+
input_settings_path: file_settings.path
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def settings_path(project_dir, explicit_settings_file)
|
|
29
|
+
return File.expand_path(explicit_settings_file, @cwd) if explicit_settings_file
|
|
30
|
+
|
|
31
|
+
File.join(project_dir, SettingsFile::DEFAULT_PATH)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/lithop/cli.rb
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Lithop
|
|
7
|
+
# Command-line interface for generating and cleaning lithophane projects.
|
|
8
|
+
class CLI
|
|
9
|
+
include CLICalibration
|
|
10
|
+
|
|
11
|
+
def self.run(argv, out: $stdout, err: $stderr, cwd: Dir.pwd)
|
|
12
|
+
new(out:, err:, cwd:).run(argv)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(out:, err:, cwd:)
|
|
16
|
+
@out = out
|
|
17
|
+
@err = err
|
|
18
|
+
@cwd = File.expand_path(cwd)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(argv)
|
|
22
|
+
return clean(argv.drop(1)) if argv.first == "clean"
|
|
23
|
+
return calibrate(argv.drop(1)) if argv.first == "calibrate"
|
|
24
|
+
|
|
25
|
+
options = {}
|
|
26
|
+
parser = CLIOptions.generation(options, @out)
|
|
27
|
+
arguments = parser.parse(argv)
|
|
28
|
+
if arguments.length != 1
|
|
29
|
+
@err.puts parser
|
|
30
|
+
return 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
input = File.expand_path(arguments.first, @cwd)
|
|
34
|
+
raise InputError, "input file does not exist: #{input}" unless File.file?(input)
|
|
35
|
+
|
|
36
|
+
calibration_profile_path = options.delete(:calibration_profile)
|
|
37
|
+
project_dir, settings, settings_path = load_settings(options)
|
|
38
|
+
calibration_profile, calibration_profile_path = load_calibration_profile(calibration_profile_path, settings)
|
|
39
|
+
if calibration_profile
|
|
40
|
+
calibration = Export::CalibrationInput.new(profile: calibration_profile, path: calibration_profile_path)
|
|
41
|
+
end
|
|
42
|
+
result = Export.new(
|
|
43
|
+
settings:,
|
|
44
|
+
project_dir:,
|
|
45
|
+
input_settings_path: settings_path,
|
|
46
|
+
calibration:,
|
|
47
|
+
progress: method(:report_estimate)
|
|
48
|
+
).call(input)
|
|
49
|
+
@out.puts "Generated #{result.triangle_count} STL and #{result.three_mf_triangle_count} CMYW 3MF triangles"
|
|
50
|
+
@out.puts "in #{result.path}"
|
|
51
|
+
result.files.each { |file| @out.puts " #{file}" }
|
|
52
|
+
0
|
|
53
|
+
rescue OptionParser::ParseError, Error, SystemCallError => e
|
|
54
|
+
@err.puts "lithop: #{e.message}"
|
|
55
|
+
1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def report_estimate(estimate)
|
|
61
|
+
@out.puts(
|
|
62
|
+
"Estimated #{estimate.stl_triangle_count} STL and " \
|
|
63
|
+
"#{estimate.three_mf_triangle_count} CMYW 3MF triangles before writing"
|
|
64
|
+
)
|
|
65
|
+
counts = estimate.triangle_counts_by_material.map { |material, count| "#{material}=#{count}" }.join(", ")
|
|
66
|
+
@out.puts " CMYW by material: #{counts}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def load_settings(options, keys: nil)
|
|
70
|
+
resolved = CLISettings.new(cwd: @cwd).call(options, keys:)
|
|
71
|
+
[resolved.project_dir, resolved.settings, resolved.input_settings_path]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def clean(argv)
|
|
75
|
+
options = {}
|
|
76
|
+
parser = CLIOptions.clean(options, @out)
|
|
77
|
+
arguments = parser.parse(argv)
|
|
78
|
+
raise OptionParser::InvalidArgument, arguments.join(" ") unless arguments.empty?
|
|
79
|
+
|
|
80
|
+
project_dir = File.expand_path(options.fetch(:project_dir, @cwd), @cwd)
|
|
81
|
+
validate_clean_target!(project_dir)
|
|
82
|
+
build_dir = File.join(project_dir, "build")
|
|
83
|
+
return report_missing_build(build_dir) unless File.exist?(build_dir) || File.symlink?(build_dir)
|
|
84
|
+
|
|
85
|
+
raise Error, "refusing to remove a symbolic link: #{build_dir}" if File.symlink?(build_dir)
|
|
86
|
+
raise Error, "build path is not a directory: #{build_dir}" unless File.directory?(build_dir)
|
|
87
|
+
|
|
88
|
+
FileUtils.rm_r(build_dir)
|
|
89
|
+
@out.puts "Removed #{build_dir}"
|
|
90
|
+
0
|
|
91
|
+
rescue OptionParser::ParseError, Error, SystemCallError => e
|
|
92
|
+
@err.puts "lithop: #{e.message}"
|
|
93
|
+
1
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_clean_target!(project_dir)
|
|
97
|
+
raise Error, "refusing to clean the filesystem root" if project_dir == File::SEPARATOR
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def report_missing_build(build_dir) = 0.tap { @out.puts "No build directory: #{build_dir}" }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Selects recipes and thicknesses for an optional measured calibration profile.
|
|
5
|
+
class ExportCalibration
|
|
6
|
+
attr_reader :profile, :path
|
|
7
|
+
|
|
8
|
+
def initialize(settings:, input:)
|
|
9
|
+
@settings = settings
|
|
10
|
+
@profile = input&.profile
|
|
11
|
+
@path = input&.path
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def heights(densities)
|
|
15
|
+
return Thickness.continuous_from_density(densities, @settings) unless profile
|
|
16
|
+
|
|
17
|
+
Calibration::ThicknessSelector.new(profile:, settings: @settings).call(densities)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def palette
|
|
21
|
+
palette = Palette.new(
|
|
22
|
+
layer_count: @settings.color_layer_count,
|
|
23
|
+
pigment_order: @settings.pigment_order
|
|
24
|
+
)
|
|
25
|
+
return palette unless profile
|
|
26
|
+
|
|
27
|
+
Calibration::RecipeSelector.new(profile:, palette:)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def write(directory)
|
|
31
|
+
profile&.write(File.join(directory, "calibration-profile.json"))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def metadata
|
|
35
|
+
return "uncalibrated" unless profile
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
status: "measured",
|
|
39
|
+
profile_id: profile.profile_id,
|
|
40
|
+
source_path: path,
|
|
41
|
+
recipe_method: "nearest_normalized_transmitted_srgb",
|
|
42
|
+
thickness_method: "nearest_measured_white_luminance"
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Builds the color-model portion of an export manifest.
|
|
5
|
+
class ExportColorMetadata
|
|
6
|
+
def initialize(settings:, calibration:)
|
|
7
|
+
@settings = settings
|
|
8
|
+
@calibration = calibration
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
12
|
+
def call(artifacts)
|
|
13
|
+
{
|
|
14
|
+
file: artifacts.three_mf_name,
|
|
15
|
+
format: "3MF",
|
|
16
|
+
property: "3MF color group and Bambu Studio part extruder assignments",
|
|
17
|
+
calibration: @calibration.metadata,
|
|
18
|
+
calibration_profile_id: @calibration.profile&.profile_id,
|
|
19
|
+
id: @settings.color_model,
|
|
20
|
+
color_layer_count: @settings.color_layer_count,
|
|
21
|
+
pigment_order: @settings.pigment_order,
|
|
22
|
+
color_layer_sequence: [*@settings.pigment_order, :white],
|
|
23
|
+
layout_profile: @settings.layout_profile,
|
|
24
|
+
base_white_layers: @settings.base_white_layers,
|
|
25
|
+
minimum_top_white_layers: @settings.minimum_top_white_layers,
|
|
26
|
+
thickness_mm: thickness_structure,
|
|
27
|
+
physical_material_layers_encoded: true,
|
|
28
|
+
bambu_studio_material_assignments_encoded: true,
|
|
29
|
+
pigments: Palette::PIGMENTS,
|
|
30
|
+
subtractive_mixes: Palette::SUBTRACTIVE_MIXES,
|
|
31
|
+
luminance_control: {
|
|
32
|
+
material: "White",
|
|
33
|
+
method: luminance_method
|
|
34
|
+
},
|
|
35
|
+
preview: {
|
|
36
|
+
method: "nominal_recipe_color_with_relative_thickness",
|
|
37
|
+
colorimetric: false,
|
|
38
|
+
source: "same_material_model_as_3mf",
|
|
39
|
+
calibration_profile_id: @calibration.profile&.profile_id
|
|
40
|
+
},
|
|
41
|
+
material_count: artifacts.three_mf.material_count,
|
|
42
|
+
object_count: artifacts.three_mf.object_count,
|
|
43
|
+
used_materials: material_names(used_materials(artifacts)),
|
|
44
|
+
unused_materials: material_names(Palette::MATERIALS - used_materials(artifacts)),
|
|
45
|
+
triangle_counts_by_material: artifacts.three_mf.triangle_counts_by_material,
|
|
46
|
+
recipe_counts: artifacts.material_model.recipe_counts,
|
|
47
|
+
recipe_summaries: artifacts.material_model.recipe_summaries
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def thickness_structure
|
|
54
|
+
structure = @settings.layer_structure
|
|
55
|
+
{
|
|
56
|
+
base_white: structure.base_white_thickness,
|
|
57
|
+
color_region: structure.color_region_thickness,
|
|
58
|
+
minimum_top_white: structure.minimum_top_white_thickness,
|
|
59
|
+
top_layer_height: structure.top_layer_height,
|
|
60
|
+
minimum_total: structure.minimum_physical_thickness,
|
|
61
|
+
effective_minimum: @settings.effective_minimum_thickness,
|
|
62
|
+
effective_maximum: @settings.effective_maximum_thickness
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def used_materials(artifacts)
|
|
67
|
+
Palette::MATERIALS.select { |material| artifacts.material_model.materials.include?(material) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def luminance_method
|
|
71
|
+
return "measured_white_luminance" if @calibration.profile
|
|
72
|
+
|
|
73
|
+
"normalized_material_display_density_or_rec601_luminance_continuous"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def material_names(materials)
|
|
77
|
+
materials.map { |material| material.to_s.capitalize }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|