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,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
|