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,112 @@
1
+ # トラブルシューティング
2
+
3
+ 既知のCLIエラーは`lithop: MESSAGE`として表示されます。まずメッセージ全文と実行したコマンドを保存してください。
4
+
5
+ ## `ruby-vips`または`libvips`を読み込めない
6
+
7
+ **最初の確認:** `ruby`、`bundle`、`lithop`が同じRuby環境を使っているか確認します。
8
+
9
+ **原因候補:** libvips未導入、Bundler未実行、複数Ruby環境の混在です。
10
+
11
+ **対処:** macOSでは次を実行します。
12
+
13
+ ```sh
14
+ brew install vips
15
+ bundle install
16
+ bundle exec ruby -e 'require "vips"; puts Vips.version_string'
17
+ which ruby
18
+ which bundle
19
+ bundle exec lithop --version
20
+ ```
21
+
22
+ **関連:** [導入と基本操作](getting-started.md#インストール)
23
+
24
+ ## 入力画像を読めない
25
+
26
+ **最初の確認:** ファイルが実在し、JPEG、PNG、またはSVGとして画像アプリで開けるか確認します。
27
+
28
+ **原因候補:** 拡張子だけを変更したファイル、壊れた画像、設定した`maximum_input_pixels`を超える画像です。SVGではlibrsvgローダー不足、寸法不正、15,000,000バイト(15 MB)超過、SVGZ、外部参照や動的要素も入力エラーになります。
29
+
30
+ **対処:** JPEG、PNG、または自己完結した非圧縮SVGへ正しく書き出し、必要なら画像寸法や生成格子を下げます。macOSでSVGローダー不足が表示された場合は`brew install vips`で導入したlibvipsを使っているか確認します。SVGから別ファイル、HTTP(S)、`data:`、外部CSSへの参照を取り除き、`width`・`height`または`viewBox`を設定します。透明部分はWhiteへ合成されるため、別の背景色が必要なら入力側で先に合成します。
31
+
32
+ **関連:** [入力画像の扱い](getting-started.md#入力画像の扱い)、[生成設定](config.md#生成設定)
33
+
34
+ ## 3MFの材料が期待した色に分かれない
35
+
36
+ **最初の確認:** `cmyk_white.stl`ではなく`lithop.3mf`を開き、一つの親オブジェクトの下に材料部品があるか確認します。
37
+
38
+ **原因候補:** White参照STLを開いた、部品を結合・修復・自動整列した、材料割り当てを変更した可能性があります。未使用顔料の省略は正常です。
39
+
40
+ **対処:** 3MFを読み直し、部品を動かさず、Cyan=1、Magenta=2、Yellow=3、White=4を確認します。省略された番号は詰めません。
41
+
42
+ **関連:** [3MFを読み込む](bambu-studio.md#3mfを読み込む)、[材料別メッシュの関係](color-model.md#材料別メッシュの関係)
43
+
44
+ ## 色が予想と違う
45
+
46
+ **最初の確認:** 材料、ExtruderとAMS、ノズル、レイヤー高さ、観察方向、白色バックライトが生成記録と一致するか確認します。
47
+
48
+ **原因候補:** 材料割り当て、スライス境界、フィラメント状態やロット、光源の違いです。未校正出力では、正しく印刷しても測色的一致は保証されません。
49
+
50
+ **対処:** `settings.json`と`PRINT_README.md`に条件を合わせ、レイヤープレビューでCMY領域を確認します。必要な場合だけ実測プロファイルを作成します。
51
+
52
+ **関連:** [スライス条件を合わせる](bambu-studio.md#スライス条件を合わせる)、[色校正](calibration.md)
53
+
54
+ ## `physical CMYW output requires at least 1.0 mm thickness`
55
+
56
+ **最初の確認:** 色領域の層数、第1層、通常レイヤー、要求最小・最大厚さを確認します。
57
+
58
+ **原因候補:** 選択した層構造に、下地White、色領域、`top_white_start`までのWhite領域、設定された最小上部White層を収容できません。メッセージ中の最小値は現在の設定から計算されます。標準設定の`minimum_top_white_layers`は`0`です。
59
+
60
+ **対処:** `--min-thickness`または`--max-thickness`を必要最小厚さ以上にします。標準の7層構造では1.0 mmです。
61
+
62
+ **関連:** [レイヤー境界と実効厚さ](config.md#レイヤー境界と実効厚さ)
63
+
64
+ ## `estimated mesh exceeds maximum_triangles`
65
+
66
+ **最初の確認:** エラーメッセージに表示された三角形数と推奨`pixel_size`を確認します。
67
+
68
+ **原因候補:** 幅と高さに対してXYセルが細かすぎます。
69
+
70
+ **対処:** `pixel_size`を大きくするか、完成寸法を小さくします。最終寸法の前に小型・0.5 mm程度で確認してください。
71
+
72
+ ```sh
73
+ bundle exec lithop image.png --width 20 --height 15 --pixel-size 0.5
74
+ ```
75
+
76
+ **関連:** [生成設定](config.md#生成設定)
77
+
78
+ ## カラーチャートのYellowだけ明るく見える
79
+
80
+ **最初の確認:** `preview.png`と`thickness.png`を開き、同じ明暗行を比較します。
81
+
82
+ **原因候補:** `preview.png`は公称材料色を表示するため、`#FCE300`のYellowはCyanやMagentaより明るく見えます。これはWhite高さの差とは限りません。
83
+
84
+ **対処:** White高さは`thickness.png`で確認します。同梱の`cmy-color-chart.svg`では、Yellowを含む全色列の同じ明暗行を共通のWhite高さへ変換します。古いジョブは生成時点の処理結果を保持するため、現行版で3MFを再生成してください。
85
+
86
+ **関連:** [明暗を作る上部White](color-model.md#明暗を作る上部white)、[プレビューの座標と値](artifacts.md#プレビューの座標と値)
87
+
88
+ ## `clean`が削除を拒否する
89
+
90
+ **最初の確認:** 選択したプロジェクトと`build/`の実体を確認します。
91
+
92
+ **原因候補:** プロジェクトがファイルシステムのルート、`build`がシンボリックリンク、またはディレクトリではありません。
93
+
94
+ **対処:** 正しい`--project-dir`を指定します。安全検査を回避しないでください。`clean`は個別ジョブではなく`build/`全体を削除します。
95
+
96
+ **関連:** [生成物を削除する](command.md#生成物を削除する)
97
+
98
+ ## 不具合を報告する
99
+
100
+ **最初の確認:** 入力画像、ローカルパス、ハッシュを公開してよいか判断します。公開できない画像は添付しないでください。
101
+
102
+ **原因候補:** 再現情報が不足すると、設定、入力、libvips、3MF、Bambu Studioのどこで問題が起きたか切り分けられません。
103
+
104
+ **対処:** 次を記録します。
105
+
106
+ - `bundle exec lithop --version`
107
+ - 実行コマンドとエラーメッセージ全文
108
+ - 機密情報を除いた`settings.json`と`manifest.json`
109
+ - Ruby、libvips、Bambu Studioのバージョン
110
+ - 必要に応じてオブジェクト一覧とレイヤープレビューの画像
111
+
112
+ **関連:** [ローカルパスと共有時の注意](artifacts.md#ローカルパスと共有時の注意)
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module Lithop
8
+ module Calibration
9
+ # Exports a printable calibration target and its measurement profile.
10
+ class Export
11
+ Result = Data.define(:job_id, :path, :files, :triangle_count, :patch_count)
12
+
13
+ def initialize(settings:, project_dir:, clock: Time, random: SecureRandom)
14
+ @settings = settings
15
+ @project_dir = File.expand_path(project_dir)
16
+ @clock = clock
17
+ @random = random
18
+ end
19
+
20
+ def call
21
+ job_id = "#{@clock.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{@random.hex(4)}-calibration"
22
+ destination, generated = JobDirectory.new(project_dir: @project_dir, job_id:).call do |directory|
23
+ generate(directory, job_id)
24
+ 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
+ end
28
+
29
+ private
30
+
31
+ def generate(directory, job_id)
32
+ target = Target.new(settings: @settings)
33
+ result = write_three_mf(File.join(directory, "calibration.3mf"), target)
34
+ TargetSVG.write(File.join(directory, "calibration.svg"), target)
35
+ profile = Profile.template(profile_id: job_id, settings: @settings, target:)
36
+ profile.write(File.join(directory, "calibration-profile.json"))
37
+ write_manifest(directory, job_id, target, result)
38
+ write_readme(directory, target)
39
+ {
40
+ files: Dir.children(directory).sort,
41
+ triangle_count: result.triangle_count,
42
+ patch_count: target.patches.length
43
+ }
44
+ end
45
+
46
+ def write_three_mf(path, target)
47
+ plan = MaterialMeshPlan.new(
48
+ model: target,
49
+ maximum_triangles: @settings.maximum_triangles,
50
+ label: "calibration mesh"
51
+ ).call
52
+ result = ThreeMF.write(path, parts: plan.parts)
53
+ plan.validate!(result)
54
+
55
+ result
56
+ end
57
+
58
+ def write_manifest(directory, job_id, target, result)
59
+ manifest = {
60
+ schema_version: 1,
61
+ artifact_type: "calibration_target",
62
+ job_id:,
63
+ generator_version: Lithop::VERSION,
64
+ structure: Profile.structure_for(@settings),
65
+ layout: {
66
+ width_mm: target.width, height_mm: target.height,
67
+ patch_width_mm: target.x_boundaries[1], patch_height_mm: target.y_boundaries[1],
68
+ columns: target.columns, rows: target.rows
69
+ },
70
+ recipe_patch_count: target.recipe_patches.length,
71
+ white_thickness_patch_count: target.white_patches.length,
72
+ triangle_count: result.triangle_count,
73
+ material_count: result.material_count,
74
+ object_count: result.object_count
75
+ }
76
+ File.write(File.join(directory, "manifest.json"), "#{JSON.pretty_generate(manifest)}\n")
77
+ end
78
+
79
+ def write_readme(directory, target)
80
+ contents = <<~MARKDOWN
81
+ # Calibration target
82
+
83
+ Use `calibration.svg` to review the patch IDs and layout. Print `calibration.3mf` without changing
84
+ its dimensions, layer heights, or material order. The plate is #{target.width} × #{target.height} mm.
85
+
86
+ - nozzle: #{@settings.nozzle_diameter} mm
87
+ - first layer: #{@settings.first_layer_height} mm
88
+ - later layers: #{@settings.layer_height} mm
89
+ - pigment order: #{@settings.pigment_order.join(" -> ")}
90
+
91
+ - `R000`–`R119`: all 120 standard seven-layer CMYW recipes, row-major from the upper-left corner
92
+ - `W00`–`W07`: White thickness stages from left to right on the bottom row
93
+ - the extra right column makes the plate orientation unambiguous
94
+
95
+ Add repeated transmitted-sRGB and luminance readings to each patch's `measurements` array in
96
+ `calibration-profile.json`. Record white and black references in the top-level `measurement` object.
97
+ MARKDOWN
98
+ File.write(File.join(directory, "CALIBRATION_README.md"), contents)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lithop
6
+ module Calibration
7
+ # Reads, validates, and writes measured calibration data.
8
+ class Profile
9
+ include ProfileMeasurements
10
+
11
+ SCHEMA_VERSION = 1
12
+
13
+ attr_reader :data
14
+
15
+ def self.template(profile_id:, settings:, target:)
16
+ new(
17
+ "schema_version" => SCHEMA_VERSION,
18
+ "profile_id" => profile_id,
19
+ "status" => "draft",
20
+ "generator_version" => Lithop::VERSION,
21
+ "structure" => structure_for(settings),
22
+ "materials" => Palette::MATERIALS.to_h { |material| [material.to_s, { "lot" => nil }] },
23
+ "lighting" => { "source" => nil, "color_temperature_k" => nil, "illuminance_lux" => nil },
24
+ "measurement" => {
25
+ "method" => nil,
26
+ "device" => nil,
27
+ "white_point" => nil,
28
+ "black_point" => nil,
29
+ "uncertainty" => nil
30
+ },
31
+ "patches" => target.patches.map { |patch| patch_data(patch) }
32
+ )
33
+ end
34
+
35
+ def self.load(path)
36
+ new(JSON.parse(File.read(path)))
37
+ rescue JSON::ParserError => e
38
+ raise InputError, "invalid calibration profile JSON: #{e.message}"
39
+ rescue Errno::ENOENT
40
+ raise InputError, "calibration profile does not exist: #{File.expand_path(path)}"
41
+ end
42
+
43
+ def self.structure_for(settings)
44
+ {
45
+ "color_model" => settings.color_model,
46
+ "color_layer_count" => settings.color_layer_count,
47
+ "pigment_order" => settings.pigment_order.map(&:to_s),
48
+ "nozzle_diameter_mm" => settings.nozzle_diameter,
49
+ "first_layer_height_mm" => settings.first_layer_height,
50
+ "layer_height_mm" => settings.layer_height
51
+ }
52
+ end
53
+
54
+ def self.patch_data(patch)
55
+ {
56
+ "patch_id" => patch.id,
57
+ "kind" => patch.kind.to_s,
58
+ "row" => patch.row,
59
+ "column" => patch.column,
60
+ "recipe" => patch.recipe.to_h.fetch(:counts).transform_keys(&:to_s),
61
+ "total_thickness_mm" => patch.height,
62
+ "top_white_thickness_mm" => patch.top_white_thickness,
63
+ "measurements" => []
64
+ }
65
+ end
66
+ private_class_method :patch_data
67
+
68
+ def initialize(data)
69
+ @data = data
70
+ validate!
71
+ end
72
+
73
+ def write(path)
74
+ File.write(path, "#{JSON.pretty_generate(data)}\n")
75
+ end
76
+
77
+ def validate_for!(settings)
78
+ expected = self.class.send(:structure_for, settings)
79
+ return true if data.fetch("structure") == expected
80
+
81
+ raise ConfigurationError, "calibration profile does not match the active layer structure"
82
+ end
83
+
84
+ private
85
+
86
+ def validate!
87
+ raise InputError, "calibration profile must be a JSON object" unless data.is_a?(Hash)
88
+
89
+ unless data["schema_version"] == SCHEMA_VERSION
90
+ version = data["schema_version"]
91
+ raise InputError, "unsupported calibration profile schema_version: #{version.inspect}"
92
+ end
93
+ raise InputError, "calibration profile_id must be a non-empty string" unless present_string?(data["profile_id"])
94
+ raise InputError, "calibration profile structure must be an object" unless data["structure"].is_a?(Hash)
95
+ raise InputError, "calibration profile patches must be an array" unless data["patches"].is_a?(Array)
96
+
97
+ data.fetch("patches").each { |patch| validate_patch!(patch) }
98
+ end
99
+
100
+ def validate_patch!(patch)
101
+ valid = patch.is_a?(Hash) && present_string?(patch["patch_id"]) &&
102
+ patch["recipe"].is_a?(Hash) && patch["measurements"].is_a?(Array)
103
+ raise InputError, "calibration profile contains an invalid patch" unless valid
104
+
105
+ patch.fetch("measurements").each { |measurement| validate_measurement!(measurement) }
106
+ end
107
+
108
+ def validate_measurement!(measurement)
109
+ return if measurement.is_a?(Hash) && valid_rgb?(measurement["transmitted_srgb"]) &&
110
+ nonnegative?(measurement["luminance"]) && optional_nonnegative?(measurement["error"])
111
+
112
+ raise InputError,
113
+ "calibration measurements require transmitted_srgb, non-negative luminance, and optional error"
114
+ end
115
+
116
+ def valid_rgb?(rgb)
117
+ rgb.is_a?(Array) && rgb.length == 3 &&
118
+ rgb.all? { |value| value.is_a?(Numeric) && value.finite? && value.between?(0.0, 1.0) }
119
+ end
120
+
121
+ def nonnegative?(value) = value.is_a?(Numeric) && value.finite? && value >= 0
122
+ def optional_nonnegative?(value) = value.nil? || nonnegative?(value)
123
+
124
+ def present_string?(value) = value.is_a?(String) && !value.empty?
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ module Calibration
5
+ PatchMeasurement = Data.define(:patch_id, :recipe, :height, :transmitted_srgb, :luminance)
6
+
7
+ # Exposes averaged measurements and completeness checks for a profile.
8
+ module ProfileMeasurements
9
+ def profile_id = data.fetch("profile_id")
10
+ def status = data.fetch("status")
11
+
12
+ def measurements(kind)
13
+ data.fetch("patches").select { |patch| patch.fetch("kind") == kind.to_s }.map do |patch|
14
+ samples = patch.fetch("measurements")
15
+ PatchMeasurement.new(
16
+ patch_id: patch.fetch("patch_id"),
17
+ recipe: patch.fetch("recipe"),
18
+ height: patch.fetch("total_thickness_mm"),
19
+ transmitted_srgb: mean_rgb(samples),
20
+ luminance: mean(samples.map { |sample| sample.fetch("luminance") })
21
+ )
22
+ end
23
+ end
24
+
25
+ def validate_complete!
26
+ issues = []
27
+ patches = data.fetch("patches")
28
+ missing = missing_patch_ids(patches)
29
+ issues << "missing measurements for #{missing.join(", ")}" if missing.any?
30
+ issues << "recipe set does not match the active palette" unless complete_recipe_set?(patches)
31
+ issues << "expected #{Target::WHITE_STAGE_COUNT} White thickness patches" unless complete_white_stages?(patches)
32
+ issues << "white_point is missing" unless reference_present?("white_point")
33
+ issues << "black_point is missing" unless reference_present?("black_point")
34
+ return true if issues.empty?
35
+
36
+ raise InputError, "calibration profile is incomplete: #{issues.join("; ")}"
37
+ end
38
+
39
+ private
40
+
41
+ def missing_patch_ids(patches)
42
+ patches.filter_map { |patch| patch.fetch("patch_id") if patch.fetch("measurements").empty? }
43
+ end
44
+
45
+ def complete_recipe_set?(patches)
46
+ palette = Palette.new(
47
+ layer_count: data.dig("structure", "color_layer_count"),
48
+ pigment_order: data.dig("structure", "pigment_order").map(&:to_sym)
49
+ )
50
+ expected = palette.recipes.map { |recipe| recipe_key(recipe.to_h.fetch(:counts).transform_keys(&:to_s)) }
51
+ actual = patches.filter_map do |patch|
52
+ recipe_key(patch.fetch("recipe")) if patch.fetch("kind") == "recipe"
53
+ end
54
+ actual.length == expected.length && actual.uniq.sort == expected.sort
55
+ end
56
+
57
+ def complete_white_stages?(patches)
58
+ stages = patches.select { |patch| patch.fetch("kind") == "white_thickness" }
59
+ thicknesses = stages.map { |patch| patch.fetch("total_thickness_mm") }
60
+ stages.length == Target::WHITE_STAGE_COUNT && thicknesses.uniq.length == stages.length
61
+ end
62
+
63
+ def reference_present?(key)
64
+ reference = data.fetch("measurement").fetch(key)
65
+ validate_measurement!(reference)
66
+ true
67
+ rescue InputError, KeyError
68
+ false
69
+ end
70
+
71
+ def recipe_key(counts)
72
+ Palette::MATERIALS.map { |material| counts.fetch(material.to_s) }.join(":")
73
+ end
74
+
75
+ def mean_rgb(samples)
76
+ samples.map { |sample| sample.fetch("transmitted_srgb") }.transpose.map { |values| mean(values) }
77
+ end
78
+
79
+ def mean(values) = values.sum.fdiv(values.length)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ module Calibration
5
+ # Selects a printable recipe from measured transmitted colors.
6
+ class RecipeSelector
7
+ attr_reader :layer_count, :pigment_order, :recipes
8
+
9
+ def initialize(profile:, palette:)
10
+ profile.validate_complete!
11
+ @layer_count = palette.layer_count
12
+ @pigment_order = palette.pigment_order
13
+ @recipes = palette.recipes
14
+ @entries = build_entries(profile.measurements(:recipe))
15
+ end
16
+
17
+ def nearest(cmy)
18
+ validate_cmy!(cmy)
19
+ target = cmy.map { |component| 1.0 - component }
20
+ @entries.min_by do |_recipe, measured|
21
+ measured.zip(target).sum { |component, expected| (component - expected)**2 }
22
+ end.first
23
+ end
24
+
25
+ private
26
+
27
+ def build_entries(measurements)
28
+ measurements.map do |measurement|
29
+ recipe = recipes.find { |candidate| recipe_counts(candidate) == measurement.recipe }
30
+ raise InputError, "calibration profile contains an unknown recipe" unless recipe
31
+
32
+ [recipe, normalize(measurement.transmitted_srgb)]
33
+ end
34
+ end
35
+
36
+ def recipe_counts(recipe)
37
+ recipe.to_h.fetch(:counts).transform_keys(&:to_s)
38
+ end
39
+
40
+ def normalize(rgb)
41
+ maximum = rgb.max
42
+ return [0.0, 0.0, 0.0] if maximum.zero?
43
+
44
+ rgb.map { |component| component.fdiv(maximum) }
45
+ end
46
+
47
+ def validate_cmy!(cmy)
48
+ valid = cmy.is_a?(Array) && cmy.length == 3 &&
49
+ cmy.all? { |value| value.is_a?(Numeric) && value.finite? && value.between?(0.0, 1.0) }
50
+ raise InputError, "CMY must contain three values between 0 and 1" unless valid
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ module Calibration
5
+ # Validates the fixed layout requirements of a calibration target.
6
+ module TargetValidation
7
+ private
8
+
9
+ def validate_structure!
10
+ unless @settings.color_layer_count == 7 &&
11
+ @settings.pigment_order == Palette::PIGMENTS
12
+ raise ConfigurationError,
13
+ "calibration target requires seven layers in cyan,magenta,yellow pigment order"
14
+ end
15
+
16
+ levels = @settings.layer_structure.normal_layer_count(@settings.effective_maximum_thickness) -
17
+ @settings.layer_structure.normal_layer_count(@settings.effective_minimum_thickness) + 1
18
+ return if levels >= Target::WHITE_STAGE_COUNT
19
+
20
+ raise ConfigurationError, "calibration target requires at least eight printable thickness levels"
21
+ end
22
+ end
23
+
24
+ # Builds a directly-addressed patch grid without image resampling.
25
+ class Target
26
+ include TargetValidation
27
+
28
+ WIDTH = 140.0
29
+ HEIGHT = 104.0
30
+ RECIPE_COLUMNS = 15
31
+ RECIPE_ROWS = 8
32
+ WHITE_STAGE_COUNT = 8
33
+ COLUMNS = RECIPE_COLUMNS + 1
34
+ ROWS = RECIPE_ROWS + 1
35
+
36
+ Patch = Data.define(
37
+ :id, :kind, :row, :column, :recipe, :height, :top_white_thickness
38
+ )
39
+
40
+ attr_reader :columns, :rows, :x_boundaries, :y_boundaries, :z_boundaries,
41
+ :cells, :patches, :recipe_patches, :white_patches
42
+
43
+ def initialize(settings:)
44
+ @settings = settings
45
+ validate_structure!
46
+ @palette = Palette.new(
47
+ layer_count: settings.color_layer_count,
48
+ pigment_order: settings.pigment_order
49
+ )
50
+ @columns = COLUMNS
51
+ @rows = ROWS
52
+ @x_boundaries = boundaries(columns, WIDTH)
53
+ @y_boundaries = boundaries(rows, HEIGHT)
54
+ @recipe_patches = build_recipe_patches.freeze
55
+ @white_patches = build_white_patches.freeze
56
+ @patches = [*recipe_patches, *white_patches].freeze
57
+ @cells = build_cells.freeze
58
+ maximum_layers = cells.map { |cell| cell.materials.length }.max
59
+ @z_boundaries = @settings.layer_structure.z_boundaries(maximum_layers).freeze
60
+ end
61
+
62
+ def material_at(layer, row, column)
63
+ return if layer.negative? || row.negative? || column.negative? || row >= rows || column >= columns
64
+
65
+ cells.fetch((row * columns) + column).materials[layer]
66
+ end
67
+
68
+ def materials
69
+ @materials ||= cells.flat_map(&:materials).uniq.freeze
70
+ end
71
+
72
+ def width = x_boundaries.last
73
+ def height = y_boundaries.last
74
+
75
+ private
76
+
77
+ def boundaries(count, extent)
78
+ Array.new(count + 1) { |index| (extent * index.fdiv(count)).round(9) }
79
+ end
80
+
81
+ def build_recipe_patches
82
+ @palette.recipes.each_with_index.map do |recipe, index|
83
+ row, column = index.divmod(RECIPE_COLUMNS)
84
+ patch(format("R%03d", index), :recipe, row, column, recipe, @settings.effective_minimum_thickness)
85
+ end
86
+ end
87
+
88
+ def build_white_patches
89
+ white = @palette.recipes.find { |recipe| recipe.white == @palette.layer_count }
90
+ white_stage_heights.each_with_index.map do |height, column|
91
+ patch(format("W%02d", column), :white_thickness, RECIPE_ROWS, column, white, height)
92
+ end
93
+ end
94
+
95
+ def patch(id, kind, row, column, recipe, height)
96
+ Patch.new(
97
+ id:, kind:, row:, column:, recipe:, height:,
98
+ top_white_thickness: @settings.layer_structure.top_white_thickness(
99
+ height, pigment_layers: @palette.layer_count
100
+ )
101
+ )
102
+ end
103
+
104
+ def white_stage_heights
105
+ minimum = @settings.effective_minimum_thickness
106
+ maximum = @settings.effective_maximum_thickness
107
+ Array.new(WHITE_STAGE_COUNT) do |index|
108
+ candidate = minimum + ((maximum - minimum) * index.fdiv(WHITE_STAGE_COUNT - 1))
109
+ @settings.quantize_thickness(candidate)
110
+ end
111
+ end
112
+
113
+ def build_cells
114
+ indexed = patches.to_h { |patch| [[patch.row, patch.column], patch] }
115
+ marker = recipe_patches.first
116
+ Array.new(rows * columns) do |index|
117
+ row, column = index.divmod(columns)
118
+ cell_for(indexed.fetch([row, column], marker))
119
+ end
120
+ end
121
+
122
+ def cell_for(patch)
123
+ top_white_layers = @settings.layer_structure.top_white_layer_count(
124
+ patch.height, pigment_layers: @palette.layer_count
125
+ )
126
+ materials = [
127
+ *Array.new(@settings.base_white_layers, :white),
128
+ *patch.recipe.layers,
129
+ *Array.new(top_white_layers, :white)
130
+ ].freeze
131
+ MaterialModel::Cell.new(height: patch.height, recipe: patch.recipe, materials:)
132
+ end
133
+ end
134
+ end
135
+ end