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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/RELEASING.md +63 -0
  6. data/bin/lithop +6 -0
  7. data/docs/README.md +44 -0
  8. data/docs/artifacts.md +122 -0
  9. data/docs/bambu-studio.md +93 -0
  10. data/docs/calibration.md +126 -0
  11. data/docs/color-model.md +114 -0
  12. data/docs/command.md +182 -0
  13. data/docs/config.md +201 -0
  14. data/docs/getting-started.md +98 -0
  15. data/docs/troubleshooting.md +112 -0
  16. data/lib/lithop/calibration/export.rb +102 -0
  17. data/lib/lithop/calibration/profile.rb +127 -0
  18. data/lib/lithop/calibration/profile_measurements.rb +82 -0
  19. data/lib/lithop/calibration/recipe_selector.rb +54 -0
  20. data/lib/lithop/calibration/target.rb +135 -0
  21. data/lib/lithop/calibration/target_svg.rb +89 -0
  22. data/lib/lithop/calibration/thickness_selector.rb +35 -0
  23. data/lib/lithop/cli/calibration.rb +40 -0
  24. data/lib/lithop/cli/image_options.rb +69 -0
  25. data/lib/lithop/cli/options.rb +95 -0
  26. data/lib/lithop/cli/settings.rb +34 -0
  27. data/lib/lithop/cli.rb +102 -0
  28. data/lib/lithop/export/calibration.rb +46 -0
  29. data/lib/lithop/export/color_metadata.rb +80 -0
  30. data/lib/lithop/export/generation.rb +122 -0
  31. data/lib/lithop/export/job.rb +49 -0
  32. data/lib/lithop/export/metadata.rb +85 -0
  33. data/lib/lithop/export/planning.rb +78 -0
  34. data/lib/lithop/export/previews.rb +35 -0
  35. data/lib/lithop/export/print_readme.rb +69 -0
  36. data/lib/lithop/export/result.rb +21 -0
  37. data/lib/lithop/formats/stl.rb +60 -0
  38. data/lib/lithop/formats/three_mf/bambu_metadata.rb +105 -0
  39. data/lib/lithop/formats/three_mf/mesh.rb +55 -0
  40. data/lib/lithop/formats/three_mf/model_writer.rb +114 -0
  41. data/lib/lithop/formats/three_mf/part_writer.rb +49 -0
  42. data/lib/lithop/formats/three_mf.rb +128 -0
  43. data/lib/lithop/image/pipeline.rb +175 -0
  44. data/lib/lithop/image/placement.rb +54 -0
  45. data/lib/lithop/image/preview.rb +63 -0
  46. data/lib/lithop/image/svg_loader.rb +218 -0
  47. data/lib/lithop/instrumentation.rb +22 -0
  48. data/lib/lithop/job_directory.rb +55 -0
  49. data/lib/lithop/layer_structure.rb +134 -0
  50. data/lib/lithop/mesh/column_geometry.rb +161 -0
  51. data/lib/lithop/mesh/column_topology.rb +262 -0
  52. data/lib/lithop/mesh/geometry.rb +116 -0
  53. data/lib/lithop/mesh/validator.rb +123 -0
  54. data/lib/lithop/mesh/voxel_geometry.rb +113 -0
  55. data/lib/lithop/mesh/voxel_topology.rb +79 -0
  56. data/lib/lithop/model/filament_profile.rb +79 -0
  57. data/lib/lithop/model/material_display_map.rb +61 -0
  58. data/lib/lithop/model/material_model.rb +106 -0
  59. data/lib/lithop/model/palette.rb +101 -0
  60. data/lib/lithop/model/thickness.rb +45 -0
  61. data/lib/lithop/reference_three_mf.rb +119 -0
  62. data/lib/lithop/sampling_grid.rb +41 -0
  63. data/lib/lithop/settings.rb +295 -0
  64. data/lib/lithop/settings_file.rb +56 -0
  65. data/lib/lithop/version.rb +5 -0
  66. data/lib/lithop.rb +55 -0
  67. data/sig/lithop.rbs +291 -0
  68. 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