lithop 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +7 -0
- data/Cargo.lock +1 -1
- data/README.md +14 -11
- data/RELEASING.md +3 -3
- data/docs/README.md +4 -3
- data/docs/artifacts.md +27 -15
- data/docs/bambu-studio.md +12 -10
- data/docs/calibration.md +4 -4
- data/docs/color-model.md +20 -18
- data/docs/command.md +28 -12
- data/docs/config.md +42 -30
- data/docs/getting-started.md +12 -6
- data/docs/local-ui.md +12 -9
- data/docs/tiling.md +18 -19
- data/docs/troubleshooting.md +3 -3
- data/ext/lithop_native/Cargo.toml +1 -1
- data/lib/lithop/calibration/target.rb +5 -5
- data/lib/lithop/cli/image_adjustment_options.rb +33 -0
- data/lib/lithop/cli/image_options.rb +5 -18
- data/lib/lithop/cli/options.rb +4 -4
- data/lib/lithop/export/color_metadata.rb +46 -23
- data/lib/lithop/export/generation.rb +8 -10
- data/lib/lithop/export/planning.rb +82 -45
- data/lib/lithop/export/print_readme.rb +20 -4
- data/lib/lithop/formats/three_mf/bambu_metadata.rb +11 -8
- data/lib/lithop/formats/three_mf/model_writer.rb +2 -5
- data/lib/lithop/image/pipeline.rb +40 -26
- data/lib/lithop/image/svg_loader.rb +11 -2
- data/lib/lithop/layer_structure.rb +0 -9
- data/lib/lithop/model/filament_profile.rb +3 -3
- data/lib/lithop/model/material_display_map.rb +2 -1
- data/lib/lithop/model/z_region_model.rb +44 -0
- data/lib/lithop/settings.rb +147 -15
- data/lib/lithop/settings_file.rb +1 -0
- data/lib/lithop/version.rb +1 -1
- data/lib/lithop/web/app.rb +9 -5
- data/lib/lithop/web/public/app.css +48 -37
- data/lib/lithop/web/public/app.js +149 -47
- data/lib/lithop/web/settings_form.rb +8 -5
- data/lib/lithop/web/views/index.erb +129 -75
- data/lib/lithop.rb +2 -1
- data/sig/lithop.rbs +16 -0
- metadata +3 -2
- data/lib/lithop/model/layer_region_model.rb +0 -24
|
@@ -68,20 +68,31 @@ module Lithop
|
|
|
68
68
|
|
|
69
69
|
def z_part_separation
|
|
70
70
|
structure = @settings.layer_structure
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
color_regions = @settings.resolved_z_color_ranges.each_with_index.filter_map do |range, index|
|
|
72
|
+
next unless range.begin < range.end
|
|
73
|
+
|
|
74
74
|
{
|
|
75
|
-
id: "
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
z_min: boundaries.fetch(range.begin),
|
|
79
|
-
z_max: boundaries.fetch(range.end)
|
|
75
|
+
id: "z_part_#{index + 1}",
|
|
76
|
+
z_min: range.begin,
|
|
77
|
+
z_max: range.end
|
|
80
78
|
}
|
|
81
79
|
end
|
|
82
80
|
metadata = {
|
|
83
81
|
enabled: @settings.separate_z_parts,
|
|
84
|
-
|
|
82
|
+
requested_parts: @settings.color_region_parts,
|
|
83
|
+
resolved_parts: @settings.resolved_z_part_count,
|
|
84
|
+
minimum_source_plate_thickness: Settings::MINIMUM_Z_PART_THICKNESS,
|
|
85
|
+
minimum_plate_thickness: @settings.z_part_minimum_build_thickness,
|
|
86
|
+
adjusted_minimum_total_thickness: @settings.z_separated_output_minimum_thickness,
|
|
87
|
+
base_white: {
|
|
88
|
+
id: "base_white", included: true,
|
|
89
|
+
layers_per_plate: base_white_layers_per_plate,
|
|
90
|
+
thickness_per_plate: base_white_thickness_per_plate,
|
|
91
|
+
repeated_per_plate: true
|
|
92
|
+
},
|
|
93
|
+
source_base_white: {
|
|
94
|
+
id: "source_base_white", included: false, z_min: 0.0, z_max: structure.base_white_thickness
|
|
95
|
+
},
|
|
85
96
|
color_regions:,
|
|
86
97
|
top_white: {
|
|
87
98
|
id: "top_white", z_min: structure.color_region_end,
|
|
@@ -92,30 +103,42 @@ module Lithop
|
|
|
92
103
|
metadata
|
|
93
104
|
end
|
|
94
105
|
|
|
106
|
+
def base_white_layers_per_plate
|
|
107
|
+
return Settings::Z_PART_BASE_WHITE_LAYERS unless @settings.separate_z_parts
|
|
108
|
+
|
|
109
|
+
@settings.z_part_base_white_layers_by_plate
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def base_white_thickness_per_plate
|
|
113
|
+
return @settings.z_part_base_white_thickness unless @settings.separate_z_parts
|
|
114
|
+
|
|
115
|
+
@settings.z_part_base_white_thicknesses
|
|
116
|
+
end
|
|
117
|
+
|
|
95
118
|
def z_plates(metadata)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
regions.each_with_index.map do |region, index|
|
|
102
|
-
z_min = region.fetch(:z_min)
|
|
103
|
-
z_max = region.fetch(:z_max)
|
|
119
|
+
top_white = metadata.fetch(:top_white)
|
|
120
|
+
ranges = @settings.resolved_z_part_ranges
|
|
121
|
+
ranges.each_with_index.map do |range, index|
|
|
122
|
+
z_min = range.begin
|
|
123
|
+
z_max = index == ranges.length - 1 ? top_white.fetch(:z_max) : range.end
|
|
104
124
|
{
|
|
105
125
|
plate_id: index + 1,
|
|
106
|
-
name:
|
|
126
|
+
name: "Z plate #{index + 1}",
|
|
107
127
|
source_z_min: z_min,
|
|
108
128
|
source_z_max: z_max,
|
|
109
129
|
build_z_min: 0.0,
|
|
110
|
-
build_z_max: (
|
|
130
|
+
build_z_max: (z_max - z_min + @settings.z_part_base_white_thickness(index)).round(9),
|
|
131
|
+
minimum_build_thickness: minimum_plate_build_thickness(range, index),
|
|
132
|
+
base_white_layers: @settings.z_part_base_white_layers(index),
|
|
133
|
+
base_white_thickness: @settings.z_part_base_white_thickness(index)
|
|
111
134
|
}
|
|
112
135
|
end
|
|
113
136
|
end
|
|
114
137
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
def minimum_plate_build_thickness(range, index)
|
|
139
|
+
source_end = [range.end, @settings.effective_minimum_thickness].min
|
|
140
|
+
source_thickness = [source_end - range.begin, 0.0].max
|
|
141
|
+
(source_thickness + @settings.z_part_base_white_thickness(index)).round(9)
|
|
119
142
|
end
|
|
120
143
|
|
|
121
144
|
def used_materials(artifacts)
|
|
@@ -92,7 +92,11 @@ module Lithop
|
|
|
92
92
|
plan = if @settings.separate_z_parts
|
|
93
93
|
top_geometry = tile_white_geometry(
|
|
94
94
|
tile, tile_model, white_heights,
|
|
95
|
-
base_height:
|
|
95
|
+
base_height: [
|
|
96
|
+
@settings.layer_structure.color_region_end,
|
|
97
|
+
@settings.resolved_z_part_ranges.last.begin
|
|
98
|
+
].max,
|
|
99
|
+
minimum_height: @settings.effective_minimum_thickness
|
|
96
100
|
)
|
|
97
101
|
z_separated_material_mesh_plan(tile_model, top_geometry)
|
|
98
102
|
else
|
|
@@ -102,11 +106,12 @@ module Lithop
|
|
|
102
106
|
[tile_model, plan]
|
|
103
107
|
end
|
|
104
108
|
|
|
105
|
-
def tile_white_geometry(tile, model, heights, base_height: 0.0)
|
|
109
|
+
def tile_white_geometry(tile, model, heights, base_height: 0.0, minimum_height: nil)
|
|
106
110
|
stride = @settings.columns + 1
|
|
107
111
|
sliced_heights = (tile.row_start..tile.row_end).flat_map do |row|
|
|
108
112
|
heights.slice((row * stride) + tile.column_start, model.columns + 1)
|
|
109
113
|
end
|
|
114
|
+
sliced_heights.map! { |height| [height, minimum_height].max } if minimum_height
|
|
110
115
|
Geometry.new(
|
|
111
116
|
heights: sliced_heights,
|
|
112
117
|
columns: model.columns,
|
|
@@ -119,15 +124,8 @@ module Lithop
|
|
|
119
124
|
end
|
|
120
125
|
|
|
121
126
|
def z_separated_material_mesh_plan(model, top_geometry)
|
|
122
|
-
base_height = @settings.layer_structure.base_white_thickness
|
|
123
|
-
base_geometry = BoxGeometry.new(
|
|
124
|
-
width: model.x_boundaries.last,
|
|
125
|
-
height: model.y_boundaries.last,
|
|
126
|
-
z_min: 0.0,
|
|
127
|
-
z_max: base_height
|
|
128
|
-
)
|
|
129
127
|
ZSeparatedMaterialMeshPlan.new(
|
|
130
|
-
model:,
|
|
128
|
+
model:, top_geometry:, settings: @settings,
|
|
131
129
|
maximum_triangles: @settings.maximum_triangles,
|
|
132
130
|
limit_message: ->(count) { @settings.triangle_limit_message("physical Z-separated CMYW mesh", count) }
|
|
133
131
|
).call
|
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Lithop
|
|
4
|
-
#
|
|
4
|
+
# Shared mesh counting and conversion helpers for export plans.
|
|
5
|
+
module MaterialMeshPlanningHelpers
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def triangle_count(geometry)
|
|
9
|
+
geometry.respond_to?(:triangle_count) ? geometry.triangle_count : geometry.each_triangle.count
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def triangle_source(geometry)
|
|
13
|
+
geometry.is_a?(ColumnGeometry) ? geometry : geometry.each_triangle
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def counts_by_material(entries, entry_counts)
|
|
17
|
+
entries.zip(entry_counts).each_with_object(Hash.new(0)) do |(entry, count), totals|
|
|
18
|
+
totals[entry.material] += count
|
|
19
|
+
end.freeze
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Slices the CMYW model into contiguous plates over strength-adjusted White supports.
|
|
5
24
|
class ZSeparatedMaterialMeshPlan
|
|
25
|
+
include MaterialMeshPlanningHelpers
|
|
26
|
+
|
|
6
27
|
Entry = Data.define(:material, :geometry, :name, :region, :build_z_offset)
|
|
7
28
|
|
|
8
|
-
def initialize(model:,
|
|
29
|
+
def initialize(model:, top_geometry:, settings:, maximum_triangles:, limit_message:)
|
|
9
30
|
@model = model
|
|
10
|
-
@base_geometry = base_geometry
|
|
11
31
|
@top_geometry = top_geometry
|
|
12
32
|
@settings = settings
|
|
13
33
|
@maximum_triangles = maximum_triangles
|
|
@@ -36,42 +56,73 @@ module Lithop
|
|
|
36
56
|
|
|
37
57
|
def build_entries
|
|
38
58
|
entries = []
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
build_z_offset = @settings.
|
|
43
|
-
entries
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
59
|
+
plate_ranges.each_with_index do |plate_range, index|
|
|
60
|
+
region = "z_part_#{index + 1}"
|
|
61
|
+
source_z_min = plate_range.begin
|
|
62
|
+
build_z_offset = @settings.z_part_base_white_thickness(index) - source_z_min
|
|
63
|
+
append_base_white_entry(entries, index:, region:)
|
|
64
|
+
append_color_entries(entries, index:, region:, build_z_offset:)
|
|
65
|
+
append_top_white_entry(entries, index:, plate_range:, region:, build_z_offset:)
|
|
66
|
+
end
|
|
67
|
+
entries
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def append_base_white_entry(entries, index:, region:)
|
|
71
|
+
range = 0.0...@settings.z_part_base_white_thickness(index)
|
|
72
|
+
entries << Entry.new(
|
|
73
|
+
material: :white, geometry: box_geometry(range),
|
|
74
|
+
name: "Z plate #{index + 1} Base White", region:, build_z_offset: 0.0
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def append_color_entries(entries, index:, region:, build_z_offset:)
|
|
79
|
+
range = color_ranges.fetch(index)
|
|
80
|
+
return unless range.begin < range.end
|
|
81
|
+
|
|
82
|
+
region_model = ZRegionModel.new(model: @model, z_range: range)
|
|
83
|
+
entries << Entry.new(
|
|
84
|
+
material: :white, geometry: color_region_white_geometry(region_model),
|
|
85
|
+
name: "Z plate #{index + 1} White", region:, build_z_offset:
|
|
86
|
+
)
|
|
87
|
+
Palette::PIGMENTS.each do |material|
|
|
88
|
+
next unless region_model.materials.include?(material)
|
|
89
|
+
|
|
47
90
|
entries << Entry.new(
|
|
48
|
-
material: :
|
|
49
|
-
|
|
50
|
-
name: "Color region #{index + 1} White",
|
|
51
|
-
region:,
|
|
52
|
-
build_z_offset:
|
|
91
|
+
material:, geometry: ColumnGeometry.new(model: region_model, material:),
|
|
92
|
+
name: "Z plate #{index + 1} #{material.to_s.capitalize}", region:, build_z_offset:
|
|
53
93
|
)
|
|
54
|
-
Palette::PIGMENTS.each do |material|
|
|
55
|
-
next unless region_model.materials.include?(material)
|
|
56
|
-
|
|
57
|
-
entries << Entry.new(
|
|
58
|
-
material:,
|
|
59
|
-
geometry: ColumnGeometry.new(model: region_model, material:),
|
|
60
|
-
name: "Color region #{index + 1} #{material.to_s.capitalize}",
|
|
61
|
-
region:,
|
|
62
|
-
build_z_offset:
|
|
63
|
-
)
|
|
64
|
-
end
|
|
65
94
|
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def append_top_white_entry(entries, index:, plate_range:, region:, build_z_offset:)
|
|
98
|
+
top_start = @settings.layer_structure.color_region_end
|
|
99
|
+
range = intersection(plate_range, top_start...@settings.z_separated_source_end)
|
|
100
|
+
return unless range
|
|
101
|
+
|
|
102
|
+
geometry = index == plate_ranges.length - 1 ? @top_geometry : box_geometry(range)
|
|
66
103
|
entries << Entry.new(
|
|
67
|
-
material: :white, geometry
|
|
68
|
-
region
|
|
104
|
+
material: :white, geometry:, name: "Z plate #{index + 1} Top White",
|
|
105
|
+
region:, build_z_offset:
|
|
69
106
|
)
|
|
70
|
-
entries
|
|
71
107
|
end
|
|
72
108
|
|
|
73
109
|
def color_ranges
|
|
74
|
-
@settings.
|
|
110
|
+
@settings.resolved_z_color_ranges
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def plate_ranges = @settings.resolved_z_part_ranges
|
|
114
|
+
|
|
115
|
+
def intersection(left, right)
|
|
116
|
+
start = [left.begin, right.begin].max
|
|
117
|
+
finish = [left.end, right.end].min
|
|
118
|
+
start < finish ? start...finish : nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def box_geometry(range)
|
|
122
|
+
BoxGeometry.new(
|
|
123
|
+
width: @model.x_boundaries.last, height: @model.y_boundaries.last,
|
|
124
|
+
z_min: range.begin, z_max: range.end
|
|
125
|
+
)
|
|
75
126
|
end
|
|
76
127
|
|
|
77
128
|
def color_region_white_geometry(model)
|
|
@@ -82,20 +133,6 @@ module Lithop
|
|
|
82
133
|
z_max: model.z_boundaries.last
|
|
83
134
|
)
|
|
84
135
|
end
|
|
85
|
-
|
|
86
|
-
def triangle_count(geometry)
|
|
87
|
-
geometry.respond_to?(:triangle_count) ? geometry.triangle_count : geometry.each_triangle.count
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def triangle_source(geometry)
|
|
91
|
-
geometry.is_a?(ColumnGeometry) ? geometry : geometry.each_triangle
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def counts_by_material(entries, entry_counts)
|
|
95
|
-
entries.zip(entry_counts).each_with_object(Hash.new(0)) do |(entry, count), totals|
|
|
96
|
-
totals[entry.material] += count
|
|
97
|
-
end.freeze
|
|
98
|
-
end
|
|
99
136
|
end
|
|
100
137
|
|
|
101
138
|
# Builds the reference-style overlapping White height field and contiguous CMY columns.
|
|
@@ -38,7 +38,7 @@ module Lithop
|
|
|
38
38
|
- White thickness uses #{settings.pixel_size} mm XY cells; CMYW recipes use
|
|
39
39
|
#{settings.effective_color_pixel_size} mm color cells.
|
|
40
40
|
- Use 100% rectilinear infill and verify every layer in the slicer before printing.
|
|
41
|
-
- The effective thickness range is #{settings
|
|
41
|
+
- The effective thickness range is #{output_minimum_thickness(settings)}–#{output_maximum_thickness(settings)} mm.
|
|
42
42
|
MARKDOWN
|
|
43
43
|
end
|
|
44
44
|
|
|
@@ -52,8 +52,23 @@ module Lithop
|
|
|
52
52
|
def z_part_line(settings)
|
|
53
53
|
return "- Z regions remain combined into the material parts." unless settings.separate_z_parts
|
|
54
54
|
|
|
55
|
-
"-
|
|
56
|
-
"
|
|
55
|
+
"- The separated model uses #{settings.resolved_z_part_count} contiguous Z plates with Base White " \
|
|
56
|
+
"supports of #{settings.z_part_base_white_layers_by_plate.join("/")} layers " \
|
|
57
|
+
"(#{settings.z_part_base_white_thicknesses.join("/")} mm); every plate is at least " \
|
|
58
|
+
"#{settings.z_part_minimum_build_thickness} mm thick. The assembled thickness range is " \
|
|
59
|
+
"#{settings.z_separated_output_minimum_thickness}–#{settings.z_separated_output_maximum_thickness} mm."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def output_minimum_thickness(settings)
|
|
63
|
+
return settings.effective_minimum_thickness unless settings.separate_z_parts
|
|
64
|
+
|
|
65
|
+
settings.z_separated_output_minimum_thickness
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def output_maximum_thickness(settings)
|
|
69
|
+
return settings.effective_maximum_thickness unless settings.separate_z_parts
|
|
70
|
+
|
|
71
|
+
settings.z_separated_output_maximum_thickness
|
|
57
72
|
end
|
|
58
73
|
|
|
59
74
|
def profile_material(profile, material)
|
|
@@ -81,7 +96,8 @@ module Lithop
|
|
|
81
96
|
"- Use a #{settings.nozzle_diameter} mm nozzle, #{settings.first_layer_height} mm base layers,\n " \
|
|
82
97
|
"#{settings.layer_height} mm color layers, and #{upper_height} mm upper White layers."
|
|
83
98
|
end
|
|
84
|
-
private_class_method :model_instruction, :z_part_line, :
|
|
99
|
+
private_class_method :model_instruction, :z_part_line, :output_minimum_thickness, :profile_material,
|
|
100
|
+
:calibration_line, :thickness_method,
|
|
85
101
|
:base_layer_description, :layer_height_description
|
|
86
102
|
end
|
|
87
103
|
end
|
|
@@ -136,9 +136,7 @@ module Lithop
|
|
|
136
136
|
end
|
|
137
137
|
|
|
138
138
|
def source_object_id(assembly)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
assembly.region.delete_prefix("color_region_").to_i - 1
|
|
139
|
+
assembly.region.delete_prefix("z_part_").to_i - 1
|
|
142
140
|
end
|
|
143
141
|
|
|
144
142
|
def number(value) = format("%.9g", value)
|
|
@@ -150,12 +148,13 @@ module Lithop
|
|
|
150
148
|
|
|
151
149
|
def layer_ranges_xml
|
|
152
150
|
object_id = @assemblies.length
|
|
153
|
-
min_z = @settings.top_white_start
|
|
154
|
-
max_z = @settings.
|
|
151
|
+
min_z = @settings.layer_structure.top_white_start
|
|
152
|
+
max_z = @settings.effective_maximum_thickness
|
|
155
153
|
if @settings.separate_z_parts
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
source_z_min = last_plate_source_z_min
|
|
155
|
+
base_white = @settings.z_part_base_white_thickness(@settings.resolved_z_part_count - 1)
|
|
156
|
+
min_z = (min_z - source_z_min + base_white).round(9)
|
|
157
|
+
max_z = (max_z - source_z_min + base_white).round(9)
|
|
159
158
|
end
|
|
160
159
|
<<~XML
|
|
161
160
|
<?xml version="1.0" encoding="utf-8"?>
|
|
@@ -170,6 +169,10 @@ module Lithop
|
|
|
170
169
|
XML
|
|
171
170
|
end
|
|
172
171
|
|
|
172
|
+
def last_plate_source_z_min
|
|
173
|
+
@settings.resolved_z_part_ranges.last.begin
|
|
174
|
+
end
|
|
175
|
+
|
|
173
176
|
def project_settings
|
|
174
177
|
{
|
|
175
178
|
"filament_colour" => %w[#0086D6 #EC008C #FCE300 #FFFFFF],
|
|
@@ -198,9 +198,8 @@ module Lithop
|
|
|
198
198
|
|
|
199
199
|
def assembly_name(region)
|
|
200
200
|
return "Lithop CMYW lithophane" unless region
|
|
201
|
-
return "Top White" if region == "top_white"
|
|
202
201
|
|
|
203
|
-
region.sub("
|
|
202
|
+
region.sub("z_part_", "Z plate ")
|
|
204
203
|
end
|
|
205
204
|
|
|
206
205
|
def write_assembly(assembly, submodel_index = nil)
|
|
@@ -264,9 +263,7 @@ module Lithop
|
|
|
264
263
|
end
|
|
265
264
|
|
|
266
265
|
def plate_index(assembly)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
assembly.region.delete_prefix("color_region_").to_i - 1
|
|
266
|
+
assembly.region.delete_prefix("z_part_").to_i - 1
|
|
270
267
|
end
|
|
271
268
|
|
|
272
269
|
def model_header
|
|
@@ -5,13 +5,13 @@ module Lithop
|
|
|
5
5
|
# rubocop:disable-next Metrics/ClassLength
|
|
6
6
|
class ImagePipeline
|
|
7
7
|
REFERENCE_WHITE_MAXIMUM_DENSITY = 0.04
|
|
8
|
-
REFERENCE_WHITE_MAXIMUM_CHROMA = 0.08
|
|
9
8
|
REFERENCE_DARK_MAXIMUM_VALUE = 0.75
|
|
10
9
|
REFERENCE_NEUTRAL_MAXIMUM_CHROMA = 0.25
|
|
11
10
|
REFERENCE_BROWN_LINE_CMY_COVERAGE = 1.0 / 7
|
|
12
11
|
Result = Data.define(
|
|
13
12
|
:width, :height, :cmys, :cmy_densities, :white_densities, :color_cells, :corrected_preview_png
|
|
14
13
|
)
|
|
14
|
+
LoadedImage = Data.define(:image, :material_display)
|
|
15
15
|
|
|
16
16
|
def initialize(settings)
|
|
17
17
|
@settings = settings
|
|
@@ -23,13 +23,14 @@ module Lithop
|
|
|
23
23
|
require "vips"
|
|
24
24
|
|
|
25
25
|
access = @settings.color_pixel_size ? :random : :sequential
|
|
26
|
-
|
|
26
|
+
loaded = normalized_image(path, access:)
|
|
27
|
+
image = loaded.image
|
|
27
28
|
sampled = @placement.call(image, @settings.sampling_grid).cast(:uchar).copy_memory
|
|
28
29
|
|
|
29
30
|
rgbs = rgb_components(sampled)
|
|
30
|
-
color_samples = rgbs.map { |rgb| color_sample(rgb) }
|
|
31
|
+
color_samples = rgbs.map { |rgb| color_sample(rgb, material_display: loaded.material_display) }
|
|
31
32
|
result_color_cells = if @settings.color_pixel_size
|
|
32
|
-
color_cells(image)
|
|
33
|
+
color_cells(image, material_display: loaded.material_display)
|
|
33
34
|
else
|
|
34
35
|
standard_color_cells(color_samples, @settings.columns, @settings.rows)
|
|
35
36
|
end
|
|
@@ -38,7 +39,7 @@ module Lithop
|
|
|
38
39
|
height: sampled.height,
|
|
39
40
|
cmys: color_samples.map(&:first),
|
|
40
41
|
cmy_densities: color_samples.map(&:last),
|
|
41
|
-
white_densities: rgbs.map { |rgb| white_density(rgb) },
|
|
42
|
+
white_densities: rgbs.map { |rgb| white_density(rgb, material_display: loaded.material_display) },
|
|
42
43
|
color_cells: result_color_cells,
|
|
43
44
|
corrected_preview_png: sampled.write_to_buffer(".png")
|
|
44
45
|
)
|
|
@@ -52,8 +53,8 @@ module Lithop
|
|
|
52
53
|
require "vips"
|
|
53
54
|
|
|
54
55
|
grid = preview_grid(maximum_width:, maximum_height:)
|
|
55
|
-
|
|
56
|
-
@placement.call(image, grid).cast(:uchar).write_to_buffer(".png")
|
|
56
|
+
loaded = normalized_image(path, access: :sequential, sampling_grids: [grid])
|
|
57
|
+
@placement.call(loaded.image, grid).cast(:uchar).write_to_buffer(".png")
|
|
57
58
|
rescue SystemCallError, Vips::Error => e
|
|
58
59
|
raise InputError, "could not read image: #{e.message}"
|
|
59
60
|
end
|
|
@@ -61,19 +62,24 @@ module Lithop
|
|
|
61
62
|
private
|
|
62
63
|
|
|
63
64
|
def normalized_image(path, access:, sampling_grids: nil)
|
|
64
|
-
|
|
65
|
+
loaded = load_image(path, access:, sampling_grids:)
|
|
66
|
+
image = loaded.image
|
|
65
67
|
image = image.autorot
|
|
66
68
|
validate_input!(image)
|
|
67
69
|
image = image.flatten(background: [255, 255, 255]) if image.has_alpha?
|
|
68
|
-
adjust_image(to_srgb(image))
|
|
70
|
+
LoadedImage.new(image: adjust_image(to_srgb(image)), material_display: loaded.material_display)
|
|
69
71
|
end
|
|
70
72
|
|
|
71
73
|
def load_image(path, access:, sampling_grids: nil)
|
|
72
74
|
signature = File.binread(path, 8)
|
|
73
75
|
raster = signature.start_with?("\xFF\xD8\xFF".b) || signature.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
74
|
-
|
|
76
|
+
if raster
|
|
77
|
+
image = Vips::Image.new_from_file(path.to_s, access:)
|
|
78
|
+
return LoadedImage.new(image:, material_display: false)
|
|
79
|
+
end
|
|
75
80
|
|
|
76
|
-
@svg_loader.call(path, access:, sampling_grids:)
|
|
81
|
+
result = @svg_loader.call(path, access:, sampling_grids:)
|
|
82
|
+
LoadedImage.new(image: result.image, material_display: result.material_display)
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
def preview_grid(maximum_width:, maximum_height:)
|
|
@@ -128,12 +134,12 @@ module Lithop
|
|
|
128
134
|
[@settings.brightness, @settings.contrast, @settings.saturation]
|
|
129
135
|
end
|
|
130
136
|
|
|
131
|
-
def color_cells(image)
|
|
137
|
+
def color_cells(image, material_display:)
|
|
132
138
|
return if @settings.color_pixel_size.nil?
|
|
133
139
|
|
|
134
140
|
grid = @settings.color_sampling_grid
|
|
135
141
|
sampled = @placement.call(image, grid).cast(:uchar)
|
|
136
|
-
samples = rgb_components(sampled).map { |rgb| color_sample(rgb) }
|
|
142
|
+
samples = rgb_components(sampled).map { |rgb| color_sample(rgb, material_display:) }
|
|
137
143
|
coarse_cells = standard_color_cells(samples, grid.columns, grid.rows)
|
|
138
144
|
expand_color_cells(coarse_cells, grid)
|
|
139
145
|
end
|
|
@@ -153,7 +159,7 @@ module Lithop
|
|
|
153
159
|
|
|
154
160
|
density = samples.sum(&:last).fdiv(samples.length)
|
|
155
161
|
cmy = samples.map(&:first).transpose.map { |components| components.sum.fdiv(components.length) }
|
|
156
|
-
next [0.0, 0.0, 0.0] if standard_white?(density
|
|
162
|
+
next [0.0, 0.0, 0.0] if standard_white?(density)
|
|
157
163
|
|
|
158
164
|
cmy
|
|
159
165
|
end
|
|
@@ -190,16 +196,28 @@ module Lithop
|
|
|
190
196
|
end
|
|
191
197
|
end
|
|
192
198
|
|
|
193
|
-
def color_sample(rgb)
|
|
194
|
-
display_sample = FilamentProfile.display_sample(rgb)
|
|
195
|
-
return [display_sample.cmy, display_sample.density] if display_sample
|
|
199
|
+
def color_sample(rgb, material_display:)
|
|
200
|
+
display_sample = FilamentProfile.display_sample(rgb, mixtures: material_display)
|
|
201
|
+
return [mix_yellow(display_sample.cmy), display_sample.density] if display_sample
|
|
196
202
|
|
|
197
203
|
maximum = rgb.max
|
|
198
|
-
cmy = standard_cmy(rgb, maximum)
|
|
204
|
+
cmy = mix_yellow(standard_cmy(rgb, maximum))
|
|
199
205
|
density = rgb.sum { |component| 1.0 - component }.fdiv(3)
|
|
200
206
|
[cmy, density]
|
|
201
207
|
end
|
|
202
208
|
|
|
209
|
+
def mix_yellow(cmy)
|
|
210
|
+
ratio = @settings.yellow_mix_percent.fdiv(100)
|
|
211
|
+
return cmy if ratio.zero? || cmy.fetch(2).zero?
|
|
212
|
+
|
|
213
|
+
mixed = cmy.dup
|
|
214
|
+
replacement = mixed.fetch(2) * ratio
|
|
215
|
+
mixed[2] -= replacement
|
|
216
|
+
index = @settings.yellow_mix_color == "cyan" ? 0 : 1
|
|
217
|
+
mixed[index] = [mixed.fetch(index) + replacement, 1.0].min
|
|
218
|
+
mixed
|
|
219
|
+
end
|
|
220
|
+
|
|
203
221
|
def standard_cmy(rgb, maximum)
|
|
204
222
|
chroma = rgb.map { |component| maximum - component }
|
|
205
223
|
return chroma unless maximum <= REFERENCE_DARK_MAXIMUM_VALUE &&
|
|
@@ -212,20 +230,16 @@ module Lithop
|
|
|
212
230
|
cmy.all? { |component| (component - REFERENCE_BROWN_LINE_CMY_COVERAGE).abs < 1e-12 }
|
|
213
231
|
end
|
|
214
232
|
|
|
215
|
-
def standard_white?(density
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
pale_yellow = cmy.fetch(2) == cmy.max && cmy.fetch(2) > REFERENCE_WHITE_MAXIMUM_CHROMA &&
|
|
219
|
-
cmy.fetch(0) <= 0.05
|
|
220
|
-
!pale_yellow
|
|
233
|
+
def standard_white?(density)
|
|
234
|
+
@settings.suppress_pale_colors && density <= REFERENCE_WHITE_MAXIMUM_DENSITY
|
|
221
235
|
end
|
|
222
236
|
|
|
223
237
|
def rec601_density(rgb)
|
|
224
238
|
1.0 - rgb.zip([0.299, 0.587, 0.114]).sum { |component, weight| component * weight }
|
|
225
239
|
end
|
|
226
240
|
|
|
227
|
-
def white_density(rgb)
|
|
228
|
-
FilamentProfile.display_sample(rgb)&.density || rec601_density(rgb)
|
|
241
|
+
def white_density(rgb, material_display:)
|
|
242
|
+
FilamentProfile.display_sample(rgb, mixtures: material_display)&.density || rec601_density(rgb)
|
|
229
243
|
end
|
|
230
244
|
end
|
|
231
245
|
end
|
|
@@ -6,6 +6,8 @@ require "rexml/xpath"
|
|
|
6
6
|
module Lithop
|
|
7
7
|
# Applies the self-contained, static subset accepted for SVG input.
|
|
8
8
|
class SVGDocumentValidator
|
|
9
|
+
MATERIAL_DISPLAY_ATTRIBUTE = "data-lithop-color-encoding"
|
|
10
|
+
MATERIAL_DISPLAY_VALUE = "material-display"
|
|
9
11
|
FORBIDDEN_ELEMENTS = %w[script foreignobject animate animatemotion animatetransform set].freeze
|
|
10
12
|
LENGTH = /\A\+?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?(?:px|mm|in)?\z/i
|
|
11
13
|
NUMBER = /[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/
|
|
@@ -15,6 +17,7 @@ module Lithop
|
|
|
15
17
|
validate_declarations!(data)
|
|
16
18
|
document = parse(data)
|
|
17
19
|
validate_document!(document)
|
|
20
|
+
document
|
|
18
21
|
rescue REXML::ParseException => e
|
|
19
22
|
raise InputError, "invalid SVG XML: #{e.message.lines.first.to_s.strip}"
|
|
20
23
|
end
|
|
@@ -133,6 +136,7 @@ module Lithop
|
|
|
133
136
|
|
|
134
137
|
# Rasterizes a validated SVG at the resolution required by the sampling grids.
|
|
135
138
|
class SVGLoader
|
|
139
|
+
Result = Data.define(:image, :material_display)
|
|
136
140
|
BASE_DPI = 96.0
|
|
137
141
|
MAXIMUM_FILE_BYTES = 15_000_000
|
|
138
142
|
|
|
@@ -143,7 +147,7 @@ module Lithop
|
|
|
143
147
|
|
|
144
148
|
def call(path, access:, sampling_grids: nil)
|
|
145
149
|
data = read(path)
|
|
146
|
-
@validator.call(data)
|
|
150
|
+
document = @validator.call(data)
|
|
147
151
|
ensure_loader!
|
|
148
152
|
|
|
149
153
|
probe = render(data, scale: 1.0, access: :sequential)
|
|
@@ -153,11 +157,16 @@ module Lithop
|
|
|
153
157
|
|
|
154
158
|
image = render(data, scale:, access:)
|
|
155
159
|
validate_rendered_image!(image)
|
|
156
|
-
image
|
|
160
|
+
Result.new(image:, material_display: material_display?(document))
|
|
157
161
|
end
|
|
158
162
|
|
|
159
163
|
private
|
|
160
164
|
|
|
165
|
+
def material_display?(document)
|
|
166
|
+
document.root.attributes[SVGDocumentValidator::MATERIAL_DISPLAY_ATTRIBUTE] ==
|
|
167
|
+
SVGDocumentValidator::MATERIAL_DISPLAY_VALUE
|
|
168
|
+
end
|
|
169
|
+
|
|
161
170
|
def read(path)
|
|
162
171
|
raise InputError, "SVG input exceeds #{MAXIMUM_FILE_BYTES} bytes" if File.size(path) > MAXIMUM_FILE_BYTES
|
|
163
172
|
|
|
@@ -64,15 +64,6 @@ module Lithop
|
|
|
64
64
|
def color_region_start = base_white_thickness
|
|
65
65
|
def color_region_end = color_end
|
|
66
66
|
|
|
67
|
-
def color_region_layer_ranges(part_count)
|
|
68
|
-
size, remainder = color_layer_count.divmod(part_count)
|
|
69
|
-
start = base_white_layers
|
|
70
|
-
Array.new(part_count) do |index|
|
|
71
|
-
finish = start + size + (index < remainder ? 1 : 0)
|
|
72
|
-
(start...finish).tap { start = finish }
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
67
|
def materials_for(recipe_layers, height)
|
|
77
68
|
top_white_layers = top_white_layer_count(height, pigment_layers: recipe_layers.length)
|
|
78
69
|
[*Array.new(base_white_layers, :white), *recipe_layers, *Array.new(top_white_layers, :white)]
|
|
@@ -62,12 +62,12 @@ module Lithop
|
|
|
62
62
|
|
|
63
63
|
def default = BAMBU_LAB_PLA_CMYK
|
|
64
64
|
|
|
65
|
-
def display_sample(rgb)
|
|
66
|
-
cmy, density = MaterialDisplayMap.call(rgb)
|
|
65
|
+
def display_sample(rgb, mixtures: true)
|
|
66
|
+
cmy, density = MaterialDisplayMap.call(rgb, mixtures:)
|
|
67
67
|
DisplaySample.new(cmy:, density:) if cmy
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
def display_cmy(rgb) = display_sample(rgb)&.cmy
|
|
70
|
+
def display_cmy(rgb, mixtures: true) = display_sample(rgb, mixtures:)&.cmy
|
|
71
71
|
|
|
72
72
|
def validate!(profile)
|
|
73
73
|
channels = profile.materials.map(&:channel)
|
|
@@ -32,12 +32,13 @@ module Lithop
|
|
|
32
32
|
|
|
33
33
|
module_function
|
|
34
34
|
|
|
35
|
-
def call(rgb)
|
|
35
|
+
def call(rgb, mixtures: true)
|
|
36
36
|
channels = rgb.map { |component| (component * 255).round.clamp(0, 255) }
|
|
37
37
|
packed = channels.reduce(0) { |value, component| (value << 8) | component }
|
|
38
38
|
return [[0.0, 0.0, 0.0], 0.0] if packed == 0xFFFFFF
|
|
39
39
|
return [CMY_BLACK, CMY_BLACK_DENSITIES.fetch(packed)] if CMY_BLACK_DENSITIES.key?(packed)
|
|
40
40
|
return [COLOR_CMYS.fetch(packed), 1.0] if COLOR_CMYS.key?(packed)
|
|
41
|
+
return unless mixtures
|
|
41
42
|
|
|
42
43
|
match = closest_line(channels)
|
|
43
44
|
return unless match && match.first <= MAXIMUM_ERROR
|