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,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Describes and validates the filament materials used by multipart models.
|
|
5
|
+
module FilamentProfile
|
|
6
|
+
REQUIRED_CHANNELS = %i[cyan magenta yellow white].freeze
|
|
7
|
+
|
|
8
|
+
Material = Data.define(:channel, :name, :color_code, :display_color, :diameter_mm) do
|
|
9
|
+
def to_h
|
|
10
|
+
{
|
|
11
|
+
channel:,
|
|
12
|
+
name:,
|
|
13
|
+
color_code:,
|
|
14
|
+
display_color:,
|
|
15
|
+
diameter_mm:
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
DisplaySample = Data.define(:cmy, :density)
|
|
21
|
+
|
|
22
|
+
Profile = Data.define(:id, :name, :manufacturer, :product_url, :reference_url, :materials) do
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
id:,
|
|
26
|
+
name:,
|
|
27
|
+
manufacturer:,
|
|
28
|
+
product_url:,
|
|
29
|
+
reference_url:,
|
|
30
|
+
materials: materials.map(&:to_h)
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
BAMBU_LAB_PLA_CMYK = Profile.new(
|
|
36
|
+
id: "bambu_lab_pla_basic_cmyw",
|
|
37
|
+
name: "Bambu Lab PLA Basic CMYW",
|
|
38
|
+
manufacturer: "Bambu Lab",
|
|
39
|
+
product_url: "https://jp.store.bambulab.com/products/pla-cmyk-lithophane",
|
|
40
|
+
reference_url: "https://wiki.bambulab.com/en/knowledge-sharing/CMYK-color-lithophane-printing-instructions",
|
|
41
|
+
materials: [
|
|
42
|
+
Material.new(
|
|
43
|
+
channel: :cyan, name: "PLA Basic Cyan", color_code: "10603",
|
|
44
|
+
display_color: "#0086D6FF", diameter_mm: 1.75
|
|
45
|
+
),
|
|
46
|
+
Material.new(
|
|
47
|
+
channel: :magenta, name: "PLA Basic Magenta", color_code: "10202",
|
|
48
|
+
display_color: "#EC008CFF", diameter_mm: 1.75
|
|
49
|
+
),
|
|
50
|
+
Material.new(
|
|
51
|
+
channel: :yellow, name: "PLA Basic Yellow", color_code: "10400",
|
|
52
|
+
display_color: "#FCE300FF", diameter_mm: 1.75
|
|
53
|
+
),
|
|
54
|
+
Material.new(
|
|
55
|
+
channel: :white, name: "PLA Basic White", color_code: "10100",
|
|
56
|
+
display_color: "#FFFFFFFF", diameter_mm: 1.75
|
|
57
|
+
)
|
|
58
|
+
].freeze
|
|
59
|
+
).freeze
|
|
60
|
+
|
|
61
|
+
module_function
|
|
62
|
+
|
|
63
|
+
def default = BAMBU_LAB_PLA_CMYK
|
|
64
|
+
|
|
65
|
+
def display_sample(rgb)
|
|
66
|
+
cmy, density = MaterialDisplayMap.call(rgb)
|
|
67
|
+
DisplaySample.new(cmy:, density:) if cmy
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def display_cmy(rgb) = display_sample(rgb)&.cmy
|
|
71
|
+
|
|
72
|
+
def validate!(profile)
|
|
73
|
+
channels = profile.materials.map(&:channel)
|
|
74
|
+
return true if channels == REQUIRED_CHANNELS
|
|
75
|
+
|
|
76
|
+
raise ConfigurationError, "filament profile must contain only Cyan, Magenta, Yellow, and White"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Recognizes the material-display mixtures used by the bundled CMY chart.
|
|
5
|
+
module MaterialDisplayMap
|
|
6
|
+
CMY_BLACK = [0.2, 0.4, 0.4].freeze
|
|
7
|
+
CMY_BLACK_DENSITIES = {
|
|
8
|
+
0xF4F4F5 => 0.05, 0xE8E8E9 => 0.1, 0xCFD1D2 => 0.2, 0xB8BABD => 0.3,
|
|
9
|
+
0x9FA3A6 => 0.4, 0x878B8F => 0.5, 0x707479 => 0.6, 0x575D62 => 0.7,
|
|
10
|
+
0x40464D => 0.8, 0x272F36 => 0.9, 0x101820 => 1.0
|
|
11
|
+
}.freeze
|
|
12
|
+
COLOR_CMYS = {
|
|
13
|
+
0x760081 => [0.4, 0.6, 0.0].freeze,
|
|
14
|
+
0x000075 => [0.4, 0.6, 0.0].freeze,
|
|
15
|
+
0x0043A6 => [0.6, 0.4, 0.0].freeze,
|
|
16
|
+
0x0086D6 => [1.0, 0.0, 0.0].freeze,
|
|
17
|
+
0x007F6B => [0.6, 0.0, 0.4].freeze,
|
|
18
|
+
0x007700 => [0.4, 0.0, 0.6].freeze,
|
|
19
|
+
0x7EAD00 => [0.4, 0.0, 0.6].freeze,
|
|
20
|
+
0xFCE300 => [0.0, 0.0, 1.0].freeze,
|
|
21
|
+
0xF37200 => [0.0, 0.4, 0.6].freeze,
|
|
22
|
+
0xE90000 => [0.0, 0.4, 0.6].freeze,
|
|
23
|
+
0xEB0046 => [0.0, 0.6, 0.4].freeze,
|
|
24
|
+
0xEC008C => [0.0, 1.0, 0.0].freeze
|
|
25
|
+
}.freeze
|
|
26
|
+
COLOR_LINES = COLOR_CMYS.map do |packed, cmy|
|
|
27
|
+
rgb = [packed >> 16, (packed >> 8) & 0xFF, packed & 0xFF]
|
|
28
|
+
direction = rgb.map { |component| component - 255 }
|
|
29
|
+
[direction.freeze, direction.sum { |component| component * component }, cmy]
|
|
30
|
+
end.freeze
|
|
31
|
+
MAXIMUM_ERROR = 3.0
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def call(rgb)
|
|
36
|
+
channels = rgb.map { |component| (component * 255).round.clamp(0, 255) }
|
|
37
|
+
packed = channels.reduce(0) { |value, component| (value << 8) | component }
|
|
38
|
+
return [[0.0, 0.0, 0.0], 0.0] if packed == 0xFFFFFF
|
|
39
|
+
return [CMY_BLACK, CMY_BLACK_DENSITIES.fetch(packed)] if CMY_BLACK_DENSITIES.key?(packed)
|
|
40
|
+
return [COLOR_CMYS.fetch(packed), 1.0] if COLOR_CMYS.key?(packed)
|
|
41
|
+
|
|
42
|
+
match = closest_line(channels)
|
|
43
|
+
return unless match && match.first <= MAXIMUM_ERROR
|
|
44
|
+
|
|
45
|
+
_error, density, cmy = match
|
|
46
|
+
[cmy, density]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def closest_line(channels)
|
|
50
|
+
offset = channels.map { |component| component - 255 }
|
|
51
|
+
COLOR_LINES.filter_map do |direction, magnitude, cmy|
|
|
52
|
+
density = offset.zip(direction).sum { |value, axis| value * axis }.fdiv(magnitude)
|
|
53
|
+
next unless density.between?(0.0, 1.0)
|
|
54
|
+
|
|
55
|
+
error = offset.zip(direction).sum { |value, axis| (value - (density * axis))**2 }
|
|
56
|
+
[error, density, cmy]
|
|
57
|
+
end.min_by(&:first)
|
|
58
|
+
end
|
|
59
|
+
private_class_method :closest_line
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Builds the printable material grid from sampled colors and thicknesses.
|
|
5
|
+
class MaterialModel
|
|
6
|
+
Cell = Data.define(:height, :recipe, :materials)
|
|
7
|
+
|
|
8
|
+
attr_reader :columns, :rows, :x_boundaries, :y_boundaries, :z_boundaries, :cells
|
|
9
|
+
|
|
10
|
+
def initialize(cmys:, heights:, settings:, palette: nil, cell_cmys: nil)
|
|
11
|
+
@columns = settings.columns
|
|
12
|
+
@rows = settings.rows
|
|
13
|
+
@x_boundaries = settings.x_boundaries
|
|
14
|
+
@y_boundaries = settings.y_boundaries
|
|
15
|
+
@palette = palette || Palette.new(
|
|
16
|
+
layer_count: settings.color_layer_count,
|
|
17
|
+
pigment_order: settings.pigment_order
|
|
18
|
+
)
|
|
19
|
+
@settings = settings
|
|
20
|
+
validate_samples!(cmys, heights, cell_cmys)
|
|
21
|
+
@cells = build_cells(cmys, heights, cell_cmys).freeze
|
|
22
|
+
maximum_layers = cells.map { |cell| cell.materials.length }.max
|
|
23
|
+
@z_boundaries = @settings.layer_structure.z_boundaries(maximum_layers).freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def material_at(layer, row, column)
|
|
27
|
+
return if layer.negative? || row.negative? || column.negative?
|
|
28
|
+
return if row >= rows || column >= columns
|
|
29
|
+
|
|
30
|
+
cells.fetch((row * columns) + column).materials[layer]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def materials
|
|
34
|
+
@materials ||= cells.flat_map(&:materials).uniq.freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def recipe_counts
|
|
38
|
+
cells.each_with_object(Hash.new(0)) do |cell, counts|
|
|
39
|
+
key = Palette::MATERIALS.map { |material| cell.recipe.public_send(material) }.join(":")
|
|
40
|
+
counts[key] += 1
|
|
41
|
+
end.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def recipe_summaries
|
|
45
|
+
counts = cells.each_with_object(Hash.new(0)) { |cell, result| result[cell.recipe] += 1 }
|
|
46
|
+
@palette.recipes.filter_map do |recipe|
|
|
47
|
+
cell_count = counts[recipe]
|
|
48
|
+
next if cell_count.zero?
|
|
49
|
+
|
|
50
|
+
{ counts: recipe.to_h.fetch(:counts), cells: cell_count }
|
|
51
|
+
end.freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate_samples!(cmys, heights, cell_cmys)
|
|
57
|
+
expected = (columns + 1) * (rows + 1)
|
|
58
|
+
raise InputError, "expected #{expected} CMY samples, got #{cmys.length}" unless cmys.length == expected
|
|
59
|
+
raise InputError, "expected #{expected} height samples, got #{heights.length}" unless heights.length == expected
|
|
60
|
+
return if cell_cmys.nil? || cell_cmys.length == columns * rows
|
|
61
|
+
|
|
62
|
+
raise InputError, "expected #{columns * rows} color cells, got #{cell_cmys.length}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_cells(cmys, heights, cell_cmys)
|
|
66
|
+
Array.new(rows * columns) do |index|
|
|
67
|
+
row, column = index.divmod(columns)
|
|
68
|
+
cmy = cell_cmys ? cell_cmys.fetch(index) : average(corners(cmys, row, column))
|
|
69
|
+
height = @settings.quantize_thickness(average(corners(heights, row, column)))
|
|
70
|
+
recipe = @palette.nearest(cmy)
|
|
71
|
+
Cell.new(height:, recipe:, materials: materials_for(recipe, height).freeze)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def corners(values, row, column)
|
|
76
|
+
stride = columns + 1
|
|
77
|
+
top_left = (row * stride) + column
|
|
78
|
+
[
|
|
79
|
+
values.fetch(top_left),
|
|
80
|
+
values.fetch(top_left + 1),
|
|
81
|
+
values.fetch(top_left + stride),
|
|
82
|
+
values.fetch(top_left + stride + 1)
|
|
83
|
+
]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def average(values)
|
|
87
|
+
return values.sum.fdiv(values.length) unless values.first.is_a?(Array)
|
|
88
|
+
|
|
89
|
+
values.transpose.map { |components| components.sum.fdiv(components.length) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def materials_for(recipe, height)
|
|
93
|
+
top_white_layers = @settings.layer_structure.top_white_layer_count(height, pigment_layers: @palette.layer_count)
|
|
94
|
+
if top_white_layers < @settings.minimum_top_white_layers
|
|
95
|
+
raise ConfigurationError,
|
|
96
|
+
"physical CMYW output requires at least #{minimum_thickness} mm thickness"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@settings.layer_structure.materials_for(recipe.layers, height)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def minimum_thickness
|
|
103
|
+
@settings.layer_structure.minimum_physical_thickness
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Maps CMY samples to printable CMYW layer recipes.
|
|
5
|
+
class Palette
|
|
6
|
+
PIGMENTS = %i[cyan magenta yellow].freeze
|
|
7
|
+
MATERIALS = [*PIGMENTS, :white].freeze
|
|
8
|
+
SUBTRACTIVE_MIXES = {
|
|
9
|
+
green: %i[cyan yellow].freeze,
|
|
10
|
+
purple: %i[cyan magenta].freeze,
|
|
11
|
+
red: %i[magenta yellow].freeze
|
|
12
|
+
}.freeze
|
|
13
|
+
DEFAULT_LAYER_COUNT = 7
|
|
14
|
+
|
|
15
|
+
Recipe = Data.define(:cyan, :magenta, :yellow, :white, :coverage_cmy, :ordered_layers) do
|
|
16
|
+
def layer_count
|
|
17
|
+
cyan + magenta + yellow + white
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def layers = ordered_layers
|
|
21
|
+
|
|
22
|
+
def pigments
|
|
23
|
+
PIGMENTS.select { |pigment| public_send(pigment).positive? }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{
|
|
28
|
+
counts: MATERIALS.to_h { |material| [material, public_send(material)] },
|
|
29
|
+
layers:,
|
|
30
|
+
pigments:,
|
|
31
|
+
coverage_cmy:
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :layer_count, :pigment_order, :recipes
|
|
37
|
+
|
|
38
|
+
def initialize(layer_count: DEFAULT_LAYER_COUNT, pigment_order: PIGMENTS)
|
|
39
|
+
unless layer_count.is_a?(Integer) && layer_count.positive?
|
|
40
|
+
raise ConfigurationError, "palette layer_count must be a positive integer"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
valid_pigment_order = pigment_order.is_a?(Array) && pigment_order.length == PIGMENTS.length &&
|
|
44
|
+
pigment_order.sort == PIGMENTS.sort
|
|
45
|
+
unless valid_pigment_order
|
|
46
|
+
raise ConfigurationError, "palette pigment_order must contain cyan, magenta, and yellow exactly once"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@layer_count = layer_count
|
|
50
|
+
@pigment_order = pigment_order.dup.freeze
|
|
51
|
+
@recipes = build_recipes.freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def nearest(cmy)
|
|
55
|
+
validate_cmy!(cmy)
|
|
56
|
+
recipes.min_by do |recipe|
|
|
57
|
+
recipe.coverage_cmy.zip(cmy).sum { |coverage, target| (coverage - target)**2 }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def display_color(recipe)
|
|
62
|
+
channels = recipe.coverage_cmy.map { |component| ((1.0 - component) * 255).round.clamp(0, 255) }
|
|
63
|
+
format("#%<red>02X%<green>02X%<blue>02XFF", red: channels[0], green: channels[1], blue: channels[2])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def build_recipes
|
|
69
|
+
recipes = []
|
|
70
|
+
(0..layer_count).each do |cyan|
|
|
71
|
+
(0..(layer_count - cyan)).each do |magenta|
|
|
72
|
+
(0..(layer_count - cyan - magenta)).each do |yellow|
|
|
73
|
+
white = layer_count - cyan - magenta - yellow
|
|
74
|
+
counts = { cyan:, magenta:, yellow: }
|
|
75
|
+
recipes << Recipe.new(
|
|
76
|
+
cyan:,
|
|
77
|
+
magenta:,
|
|
78
|
+
yellow:,
|
|
79
|
+
white:,
|
|
80
|
+
coverage_cmy: coverage_cmy(cyan:, magenta:, yellow:),
|
|
81
|
+
ordered_layers: pigment_order.flat_map { |pigment| [pigment] * counts.fetch(pigment) } +
|
|
82
|
+
Array.new(white, :white)
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
recipes
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def coverage_cmy(cyan:, magenta:, yellow:)
|
|
91
|
+
[cyan, magenta, yellow].map { |layers| layers.fdiv(layer_count) }.freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_cmy!(cmy)
|
|
95
|
+
return if cmy.is_a?(Array) && cmy.length == 3 &&
|
|
96
|
+
cmy.all? { |value| value.is_a?(Numeric) && value.finite? && value.between?(0.0, 1.0) }
|
|
97
|
+
|
|
98
|
+
raise InputError, "CMY must contain three values between 0 and 1"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Converts image density values into printable, layer-aligned thicknesses.
|
|
5
|
+
module Thickness
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def from_cmy_density(densities, settings)
|
|
9
|
+
validate_densities!(densities)
|
|
10
|
+
densities = normalize_reference_densities(densities)
|
|
11
|
+
minimum = settings.effective_minimum_thickness
|
|
12
|
+
range = settings.effective_maximum_thickness - minimum
|
|
13
|
+
|
|
14
|
+
densities.map do |density|
|
|
15
|
+
unless density.is_a?(Numeric) && density.finite? && density.between?(0.0, 1.0)
|
|
16
|
+
raise InputError, "CMY density must be between 0 and 1"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
settings.quantize_thickness(minimum + (density * range))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def continuous_from_density(densities, settings)
|
|
24
|
+
validate_densities!(densities)
|
|
25
|
+
normalized = normalize_reference_densities(densities)
|
|
26
|
+
minimum = settings.effective_minimum_thickness
|
|
27
|
+
range = settings.effective_maximum_thickness - minimum
|
|
28
|
+
normalized.map { |density| minimum + (density * range) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_densities!(densities)
|
|
32
|
+
return if densities.all? { |density| density.is_a?(Numeric) && density.finite? && density.between?(0.0, 1.0) }
|
|
33
|
+
|
|
34
|
+
raise InputError, "CMY density must be between 0 and 1"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def normalize_reference_densities(densities)
|
|
38
|
+
minimum, maximum = densities.minmax
|
|
39
|
+
return Array.new(densities.length, 0.0) if maximum == minimum
|
|
40
|
+
|
|
41
|
+
densities.map { |density| (density - minimum).fdiv(maximum - minimum) }
|
|
42
|
+
end
|
|
43
|
+
private_class_method :normalize_reference_densities, :validate_densities!
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "rexml/document"
|
|
5
|
+
require "rexml/parsers/pullparser"
|
|
6
|
+
require "zip"
|
|
7
|
+
|
|
8
|
+
module Lithop
|
|
9
|
+
# Streams structural measurements from an existing 3MF without retaining its meshes.
|
|
10
|
+
class ReferenceThreeMF
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@path = File.expand_path(path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
raise InputError, "reference 3MF does not exist: #{@path}" unless File.file?(@path)
|
|
17
|
+
|
|
18
|
+
Zip::File.open(@path) do |archive|
|
|
19
|
+
entries = archive.entries
|
|
20
|
+
{
|
|
21
|
+
path: @path,
|
|
22
|
+
sha256: Digest::SHA256.file(@path).hexdigest,
|
|
23
|
+
bytes: File.size(@path),
|
|
24
|
+
entry_count: entries.length,
|
|
25
|
+
entries: entries.map(&:name).sort,
|
|
26
|
+
uncompressed_bytes: entries.sum(&:size),
|
|
27
|
+
models: model_entries(archive),
|
|
28
|
+
parts: parts(archive),
|
|
29
|
+
plates: plates(archive)
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
rescue Zip::Error, REXML::ParseException => e
|
|
33
|
+
raise InputError, "could not inspect reference 3MF: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def model_entries(archive)
|
|
39
|
+
archive.entries.filter_map do |entry|
|
|
40
|
+
next unless entry.name.start_with?("3D/") && entry.name.end_with?(".model")
|
|
41
|
+
|
|
42
|
+
entry.get_input_stream { |stream| inspect_model(entry.name, stream) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def inspect_model(name, stream)
|
|
47
|
+
parser = REXML::Parsers::PullParser.new(stream)
|
|
48
|
+
objects = []
|
|
49
|
+
current = nil
|
|
50
|
+
while parser.has_next?
|
|
51
|
+
event = parser.pull
|
|
52
|
+
if event.start_element?
|
|
53
|
+
current = start_object(event) if event[0] == "object"
|
|
54
|
+
inspect_mesh_element(current, event) if current
|
|
55
|
+
elsif event.end_element? && event[0] == "object" && current
|
|
56
|
+
objects << finish_object(current)
|
|
57
|
+
current = nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
{ file: name, objects: }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def start_object(event)
|
|
64
|
+
{
|
|
65
|
+
id: event[1]["id"], type: event[1]["type"], vertices: 0, triangles: 0, components: 0,
|
|
66
|
+
minimum: [nil, nil, nil], maximum: [nil, nil, nil]
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def inspect_mesh_element(current, event)
|
|
71
|
+
case event[0]
|
|
72
|
+
when "vertex"
|
|
73
|
+
coordinates = %w[x y z].map { |axis| Float(event[1].fetch(axis)) }
|
|
74
|
+
current[:vertices] += 1
|
|
75
|
+
coordinates.each_index do |index|
|
|
76
|
+
current[:minimum][index] = [current[:minimum][index], coordinates[index]].compact.min
|
|
77
|
+
current[:maximum][index] = [current[:maximum][index], coordinates[index]].compact.max
|
|
78
|
+
end
|
|
79
|
+
when "triangle"
|
|
80
|
+
current[:triangles] += 1
|
|
81
|
+
when "component"
|
|
82
|
+
current[:components] += 1
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def finish_object(object)
|
|
87
|
+
object.merge(size: object[:maximum].zip(object[:minimum]).map do |maximum, minimum|
|
|
88
|
+
maximum && minimum && (maximum - minimum)
|
|
89
|
+
end)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parts(archive)
|
|
93
|
+
document = metadata_document(archive)
|
|
94
|
+
return [] unless document
|
|
95
|
+
|
|
96
|
+
document.get_elements("config/object/part").map do |part|
|
|
97
|
+
metadata = part.get_elements("metadata").to_h { |entry| [entry.attributes["key"], entry.attributes["value"]] }
|
|
98
|
+
{ id: part.attributes["id"], name: metadata["name"], extruder: integer_or_nil(metadata["extruder"]) }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def plates(archive)
|
|
103
|
+
document = metadata_document(archive)
|
|
104
|
+
return [] unless document
|
|
105
|
+
|
|
106
|
+
document.get_elements("config/plate").map do |plate|
|
|
107
|
+
metadata = plate.get_elements("metadata").to_h { |entry| [entry.attributes["key"], entry.attributes["value"]] }
|
|
108
|
+
{ id: integer_or_nil(metadata["plater_id"]), name: metadata["plater_name"] }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def metadata_document(archive)
|
|
113
|
+
entry = archive.find_entry("Metadata/model_settings.config")
|
|
114
|
+
entry && REXML::Document.new(entry.get_input_stream.read)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def integer_or_nil(value) = value && Integer(value)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Calculates the XY sampling grid, partial edge cells, and sampled margins.
|
|
5
|
+
class SamplingGrid
|
|
6
|
+
attr_reader :columns, :rows, :x_boundaries, :y_boundaries, :margin_samples
|
|
7
|
+
|
|
8
|
+
def initialize(width:, height:, pixel_size:, margins:, columns: nil, rows: nil)
|
|
9
|
+
@columns = columns || (width / pixel_size).ceil
|
|
10
|
+
@rows = rows || (height / pixel_size).ceil
|
|
11
|
+
@x_boundaries = boundaries(width, pixel_size, @columns, fitted: !columns.nil?).freeze
|
|
12
|
+
@y_boundaries = boundaries(height, pixel_size, @rows, fitted: !rows.nil?).freeze
|
|
13
|
+
x_size = width.fdiv(@columns)
|
|
14
|
+
y_size = height.fdiv(@rows)
|
|
15
|
+
@margin_samples = {
|
|
16
|
+
top: (margins.fetch(:top) / y_size).round,
|
|
17
|
+
right: (margins.fetch(:right) / x_size).round,
|
|
18
|
+
bottom: (margins.fetch(:bottom) / y_size).round,
|
|
19
|
+
left: (margins.fetch(:left) / x_size).round
|
|
20
|
+
}.freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sample_width = columns + 1
|
|
24
|
+
def sample_height = rows + 1
|
|
25
|
+
|
|
26
|
+
def content_sample_size
|
|
27
|
+
[
|
|
28
|
+
sample_width - margin_samples.fetch(:left) - margin_samples.fetch(:right),
|
|
29
|
+
sample_height - margin_samples.fetch(:top) - margin_samples.fetch(:bottom)
|
|
30
|
+
]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def boundaries(length, pixel_size, count, fitted:)
|
|
36
|
+
return Array.new(count + 1) { |index| (length * index.fdiv(count)).round(9) } if fitted
|
|
37
|
+
|
|
38
|
+
Array.new(count + 1) { |index| index == count ? length : [index * pixel_size, length].min }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|