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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Serializes model resources and build items into a 3MF model document.
5
+ class ThreeMFModelWriter
6
+ CUSTOM_NAMESPACE = "https://github.com/ogom/lithop/3mf/v1"
7
+ BASE_MATERIALS_ID = 1
8
+ COLOR_GROUP_ID = 2
9
+ FIRST_OBJECT_ID = 3
10
+ Result = Data.define(:vertex_count, :triangle_count, :triangle_counts_by_material)
11
+
12
+ def initialize(archive, parts, profile, instrumentation: Instrumentation.new, settings: nil)
13
+ @archive = archive
14
+ @parts = parts
15
+ @profile = profile
16
+ @instrumentation = instrumentation
17
+ @settings = settings
18
+ end
19
+
20
+ def write
21
+ measure_xml do
22
+ @archive.write(model_header)
23
+ write_materials
24
+ end
25
+ object_ids, result = write_parts
26
+ assembly_id = object_ids.max + 1
27
+ measure_xml do
28
+ write_assembly(assembly_id, object_ids)
29
+ @archive.write(" </resources>\n <build>\n <item objectid=\"#{assembly_id}\"/>\n </build>\n</model>\n")
30
+ ThreeMFBambuMetadata.new(
31
+ archive: @archive,
32
+ assembly_id:,
33
+ object_ids:,
34
+ parts: @parts,
35
+ profile: @profile,
36
+ settings: @settings
37
+ ).write
38
+ end
39
+ result
40
+ end
41
+
42
+ private
43
+
44
+ def write_materials
45
+ @archive.write(" <m:basematerials id=\"#{BASE_MATERIALS_ID}\">\n")
46
+ @profile.materials.each { |material| @archive.write(material_xml(material)) }
47
+ @archive.write(" </m:basematerials>\n <m:colorgroup id=\"#{COLOR_GROUP_ID}\">\n")
48
+ @profile.materials.each { |material| @archive.write(" <m:color color=\"#{material.display_color}\"/>\n") }
49
+ @archive.write(" </m:colorgroup>\n")
50
+ end
51
+
52
+ def write_parts
53
+ vertex_count = 0
54
+ triangle_counts = {}
55
+ object_ids = @parts.each_with_index.map do |part, index|
56
+ object_id = index + FIRST_OBJECT_ID
57
+ mesh = @instrumentation.measure(:three_mf_indexing) do
58
+ ThreeMFMesh.new(triangles: part.triangles)
59
+ end
60
+ measure_xml do
61
+ ThreeMFPartWriter.new(archive: @archive, profile: @profile).write(object_id, part.material, mesh)
62
+ end
63
+ part.triangles.release_cache! if part.triangles.respond_to?(:release_cache!)
64
+ vertex_count += mesh.vertices.length
65
+ triangle_counts[part.material] = mesh.triangle_count
66
+ object_id
67
+ end
68
+ result = Result.new(
69
+ vertex_count:,
70
+ triangle_count: triangle_counts.values.sum,
71
+ triangle_counts_by_material: triangle_counts.freeze
72
+ )
73
+ [object_ids, result]
74
+ end
75
+
76
+ def measure_xml(&)
77
+ @instrumentation.measure(:three_mf_xml_zip, &)
78
+ end
79
+
80
+ def write_assembly(assembly_id, object_ids)
81
+ @archive.write(" <object id=\"#{assembly_id}\" type=\"model\" name=\"Lithop CMYW lithophane\">\n")
82
+ @archive.write(" <components>\n")
83
+ object_ids.each { |object_id| @archive.write(" <component objectid=\"#{object_id}\"/>\n") }
84
+ @archive.write(" </components>\n </object>\n")
85
+ end
86
+
87
+ def model_header
88
+ <<~XML
89
+ <?xml version="1.0" encoding="UTF-8"?>
90
+ <model unit="millimeter" xml:lang="en-US" requiredextensions="m"
91
+ xmlns="#{ThreeMF::CORE_NAMESPACE}"
92
+ xmlns:m="#{ThreeMF::MATERIAL_NAMESPACE}"
93
+ xmlns:lithop="#{CUSTOM_NAMESPACE}">
94
+ <metadata name="Title">Lithop CMYW lithophane</metadata>
95
+ <metadata name="Application">Lithop #{Lithop::VERSION}</metadata>
96
+ <metadata name="Description">Physical CMY subtractive layers with variable White luminance thickness.</metadata>
97
+ <metadata name="lithop:FilamentBundle" preserve="1">#{@profile.name}</metadata>
98
+ <metadata name="lithop:FilamentProduct" preserve="1">#{@profile.product_url}</metadata>
99
+ <metadata name="lithop:PrintingGuide" preserve="1">#{@profile.reference_url}</metadata>
100
+ <resources>
101
+ XML
102
+ end
103
+
104
+ def material_xml(material)
105
+ name = "#{@profile.manufacturer} #{material.name} (#{material.color_code})"
106
+ <<~XML
107
+ <m:base name="#{name}" displaycolor="#{material.display_color}"
108
+ lithop:channel="#{material.channel}" lithop:manufacturer="#{@profile.manufacturer}"
109
+ lithop:product="#{material.name}" lithop:colorcode="#{material.color_code}"
110
+ lithop:diameter="#{material.diameter_mm}"/>
111
+ XML
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Serializes one indexed material mesh into a 3MF model document.
5
+ class ThreeMFPartWriter
6
+ def initialize(archive:, profile:)
7
+ @archive = archive
8
+ @profile = profile
9
+ end
10
+
11
+ def write(object_id, material, mesh)
12
+ material_index = @profile.materials.index { |candidate| candidate.channel == material }
13
+ name = @profile.materials.fetch(material_index).name
14
+ @archive.write(
15
+ " <object id=\"#{object_id}\" type=\"model\" name=\"#{name}\" " \
16
+ "pid=\"#{ThreeMFModelWriter::COLOR_GROUP_ID}\" pindex=\"#{material_index}\" " \
17
+ "lithop:channel=\"#{material}\">\n"
18
+ )
19
+ @archive.write(" <mesh>\n <vertices>\n")
20
+ write_vertices(mesh)
21
+ @archive.write(" </vertices>\n <triangles>\n")
22
+ write_triangles(mesh)
23
+ @archive.write(" </triangles>\n </mesh>\n </object>\n")
24
+ end
25
+
26
+ private
27
+
28
+ def write_vertices(mesh)
29
+ mesh.vertices.each do |x, y, z|
30
+ @archive.write(" <vertex x=\"#{number(x)}\" y=\"#{number(y)}\" z=\"#{number(z)}\"/>\n")
31
+ end
32
+ end
33
+
34
+ def write_triangles(mesh)
35
+ count = 0
36
+ mesh.each_indexed_triangle do |vertices|
37
+ @archive.write(
38
+ " <triangle v1=\"#{vertices[0]}\" v2=\"#{vertices[1]}\" v3=\"#{vertices[2]}\"/>\n"
39
+ )
40
+ count += 1
41
+ end
42
+ return if count == mesh.triangle_count
43
+
44
+ raise InputError, "3MF triangle enumerables must be repeatable"
45
+ end
46
+
47
+ def number(value) = format("%.9g", value)
48
+ end
49
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "zip"
5
+ require_relative "three_mf/mesh"
6
+ require_relative "three_mf/bambu_metadata"
7
+ require_relative "three_mf/part_writer"
8
+ require_relative "three_mf/model_writer"
9
+
10
+ module Lithop
11
+ # Writes multipart 3D Manufacturing Format (3MF) packages.
12
+ # rubocop:disable-next Metrics/ClassLength
13
+ class ThreeMF
14
+ CORE_NAMESPACE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
15
+ MATERIAL_NAMESPACE = "http://schemas.microsoft.com/3dmanufacturing/material/2015/02"
16
+ RELATIONSHIP_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships"
17
+ CONTENT_TYPE_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types"
18
+ MODEL_RELATIONSHIP = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"
19
+ MODEL_CONTENT_TYPE = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"
20
+ ZIP_MUTEX = Mutex.new
21
+ # Triangles are indexed and then serialized, so their enumerable must be replayable.
22
+ Part = Data.define(:material, :triangles)
23
+ Result = Data.define(:vertex_count, :triangle_count, :material_count, :object_count,
24
+ :triangle_counts_by_material)
25
+
26
+ def self.write(path, parts:, profile: FilamentProfile.default, instrumentation: nil, settings: nil)
27
+ new(parts:, profile:, instrumentation:, settings:).write(path)
28
+ end
29
+
30
+ def initialize(parts:, profile:, instrumentation: nil, settings: nil)
31
+ FilamentProfile.validate!(profile)
32
+ @profile = profile
33
+ @parts = build_parts(parts)
34
+ @settings = settings
35
+ @instrumentation = if instrumentation.is_a?(Instrumentation)
36
+ instrumentation
37
+ else
38
+ Instrumentation.new(instrumentation)
39
+ end
40
+ end
41
+
42
+ def write(path)
43
+ destination = File.expand_path(path)
44
+ FileUtils.rm_f(destination)
45
+ package = write_package(destination)
46
+ Result.new(
47
+ vertex_count: package.vertex_count,
48
+ triangle_count: package.triangle_count,
49
+ material_count: @parts.map(&:material).uniq.length,
50
+ object_count: @parts.length,
51
+ triangle_counts_by_material: package.triangle_counts_by_material
52
+ )
53
+ rescue Zip::Error => e
54
+ FileUtils.rm_f(destination) if destination
55
+ raise Error, "could not write 3MF: #{e.message}"
56
+ rescue StandardError
57
+ FileUtils.rm_f(destination) if destination
58
+ raise
59
+ end
60
+
61
+ private
62
+
63
+ def build_parts(parts)
64
+ valid_materials = @profile.materials.map(&:channel)
65
+ validated = parts.map do |part|
66
+ unless valid_materials.include?(part.material)
67
+ raise InputError, "3MF part uses an unknown material: #{part.material.inspect}"
68
+ end
69
+
70
+ part
71
+ end
72
+ raise InputError, "3MF requires at least one material part" if validated.empty?
73
+ if validated.map(&:material).uniq.length != validated.length
74
+ raise InputError, "3MF material parts must have unique materials"
75
+ end
76
+
77
+ validated.freeze
78
+ end
79
+
80
+ def write_package(destination)
81
+ package = nil
82
+ ZIP_MUTEX.synchronize do
83
+ zip64 = Zip.write_zip64_support
84
+ begin
85
+ Zip.write_zip64_support = false
86
+ Zip::OutputStream.open(destination, suppress_extra_fields: true) do |archive|
87
+ @instrumentation.measure(:three_mf_xml_zip) do
88
+ write_entry(archive, "[Content_Types].xml", content_types_xml)
89
+ write_entry(archive, "_rels/.rels", relationships_xml)
90
+ archive.put_next_entry("3D/3dmodel.model")
91
+ end
92
+ package = ThreeMFModelWriter.new(
93
+ archive, @parts, @profile, instrumentation: @instrumentation, settings: @settings
94
+ ).write
95
+ end
96
+ ensure
97
+ Zip.write_zip64_support = zip64
98
+ end
99
+ end
100
+ package
101
+ end
102
+
103
+ def content_types_xml
104
+ <<~XML
105
+ <?xml version="1.0" encoding="UTF-8"?>
106
+ <Types xmlns="#{CONTENT_TYPE_NAMESPACE}">
107
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
108
+ <Default Extension="model" ContentType="#{MODEL_CONTENT_TYPE}"/>
109
+ <Default Extension="config" ContentType="application/xml"/>
110
+ </Types>
111
+ XML
112
+ end
113
+
114
+ def relationships_xml
115
+ <<~XML
116
+ <?xml version="1.0" encoding="UTF-8"?>
117
+ <Relationships xmlns="#{RELATIONSHIP_NAMESPACE}">
118
+ <Relationship Target="/3D/3dmodel.model" Id="rel-1" Type="#{MODEL_RELATIONSHIP}"/>
119
+ </Relationships>
120
+ XML
121
+ end
122
+
123
+ def write_entry(archive, name, contents)
124
+ archive.put_next_entry(name)
125
+ archive.write(contents)
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Loads, normalizes, and samples an input image for model generation.
5
+ # rubocop:disable-next Metrics/ClassLength
6
+ class ImagePipeline
7
+ REFERENCE_WHITE_MAXIMUM_DENSITY = 0.04
8
+ REFERENCE_WHITE_MAXIMUM_CHROMA = 0.08
9
+ REFERENCE_DARK_MAXIMUM_VALUE = 0.75
10
+ REFERENCE_NEUTRAL_MAXIMUM_CHROMA = 0.25
11
+ REFERENCE_BROWN_LINE_CMY_COVERAGE = 1.0 / 7
12
+ Result = Data.define(:width, :height, :cmys, :cmy_densities, :white_densities, :color_cells)
13
+
14
+ def initialize(settings)
15
+ @settings = settings
16
+ @placement = ImagePlacement.new(settings)
17
+ @svg_loader = SVGLoader.new(settings)
18
+ end
19
+
20
+ def call(path)
21
+ require "vips"
22
+
23
+ access = @settings.color_pixel_size ? :random : :sequential
24
+ image = load_image(path, access:)
25
+ image = image.autorot
26
+ validate_input!(image)
27
+ image = image.flatten(background: [255, 255, 255]) if image.has_alpha?
28
+ image = to_srgb(image)
29
+ sampled = @placement.call(image, @settings.sampling_grid).cast(:uchar)
30
+
31
+ rgbs = rgb_components(sampled)
32
+ color_samples = rgbs.map { |rgb| color_sample(rgb) }
33
+ result_color_cells = if @settings.color_pixel_size
34
+ color_cells(image)
35
+ else
36
+ standard_color_cells(color_samples, @settings.columns, @settings.rows)
37
+ end
38
+ Result.new(
39
+ width: sampled.width,
40
+ height: sampled.height,
41
+ cmys: color_samples.map(&:first),
42
+ cmy_densities: color_samples.map(&:last),
43
+ white_densities: rgbs.map { |rgb| white_density(rgb) },
44
+ color_cells: result_color_cells
45
+ )
46
+ rescue SystemCallError, Vips::Error => e
47
+ raise InputError, "could not read image: #{e.message}"
48
+ end
49
+
50
+ private
51
+
52
+ def load_image(path, access:)
53
+ signature = File.binread(path, 8)
54
+ raster = signature.start_with?("\xFF\xD8\xFF".b) || signature.start_with?("\x89PNG\r\n\x1A\n".b)
55
+ return Vips::Image.new_from_file(path.to_s, access:) if raster
56
+
57
+ @svg_loader.call(path, access:)
58
+ end
59
+
60
+ def validate_input!(image)
61
+ unless %w[jpegload pngload svgload_buffer].include?(image.get("vips-loader"))
62
+ raise InputError, "input must be a JPEG, PNG, or SVG image"
63
+ end
64
+ return unless image.width * image.height > @settings.maximum_input_pixels
65
+
66
+ raise InputError, "input image exceeds #{@settings.maximum_input_pixels} pixels"
67
+ end
68
+
69
+ def to_srgb(image)
70
+ return image.colourspace(:srgb) if image.get_typeof("icc-profile-data").zero?
71
+
72
+ image.icc_import(embedded: true, pcs: :xyz).colourspace(:srgb)
73
+ end
74
+
75
+ def color_cells(image)
76
+ return if @settings.color_pixel_size.nil?
77
+
78
+ grid = @settings.color_sampling_grid
79
+ sampled = @placement.call(image, grid).cast(:uchar)
80
+ samples = rgb_components(sampled).map { |rgb| color_sample(rgb) }
81
+ coarse_cells = standard_color_cells(samples, grid.columns, grid.rows)
82
+ expand_color_cells(coarse_cells, grid)
83
+ end
84
+
85
+ def standard_color_cells(color_samples, columns, rows)
86
+ stride = columns + 1
87
+ Array.new(rows * columns) do |index|
88
+ row, column = index.divmod(columns)
89
+ top_left = (row * stride) + column
90
+ samples = [
91
+ color_samples.fetch(top_left), color_samples.fetch(top_left + 1),
92
+ color_samples.fetch(top_left + stride), color_samples.fetch(top_left + stride + 1)
93
+ ]
94
+ if samples.any? { |sample| standard_brown_line_sample?(sample.first) }
95
+ next Array.new(3, REFERENCE_BROWN_LINE_CMY_COVERAGE)
96
+ end
97
+
98
+ density = samples.sum(&:last).fdiv(samples.length)
99
+ cmy = samples.map(&:first).transpose.map { |components| components.sum.fdiv(components.length) }
100
+ next [0.0, 0.0, 0.0] if standard_white?(density, cmy)
101
+
102
+ cmy
103
+ end
104
+ end
105
+
106
+ def expand_color_cells(coarse_cells, grid)
107
+ Array.new(@settings.rows * @settings.columns) do |index|
108
+ row, column = index.divmod(@settings.columns)
109
+ x = cell_center(@settings.x_boundaries, column)
110
+ y = cell_center(@settings.y_boundaries, row)
111
+ coarse_column = containing_cell(grid.x_boundaries, x)
112
+ coarse_row = containing_cell(grid.y_boundaries, y)
113
+ coarse_cells.fetch((coarse_row * grid.columns) + coarse_column)
114
+ end
115
+ end
116
+
117
+ def cell_center(boundaries, index)
118
+ (boundaries.fetch(index) + boundaries.fetch(index + 1)).fdiv(2)
119
+ end
120
+
121
+ def containing_cell(boundaries, coordinate)
122
+ upper = boundaries.bsearch_index { |boundary| boundary > coordinate } || (boundaries.length - 1)
123
+ (upper - 1).clamp(0, boundaries.length - 2)
124
+ end
125
+
126
+ def rgb_components(image)
127
+ pixels = image.write_to_memory
128
+ bands = image.bands
129
+ count = image.width * image.height
130
+
131
+ Array.new(count) do |index|
132
+ offset = index * bands
133
+ 3.times.map { |band| pixels.getbyte(offset + band).fdiv(255) }
134
+ end
135
+ end
136
+
137
+ def color_sample(rgb)
138
+ display_sample = FilamentProfile.display_sample(rgb)
139
+ return [display_sample.cmy, display_sample.density] if display_sample
140
+
141
+ maximum = rgb.max
142
+ cmy = standard_cmy(rgb, maximum)
143
+ density = rgb.sum { |component| 1.0 - component }.fdiv(3)
144
+ [cmy, density]
145
+ end
146
+
147
+ def standard_cmy(rgb, maximum)
148
+ chroma = rgb.map { |component| maximum - component }
149
+ return chroma unless maximum <= REFERENCE_DARK_MAXIMUM_VALUE &&
150
+ chroma.max <= REFERENCE_NEUTRAL_MAXIMUM_CHROMA
151
+
152
+ Array.new(3, REFERENCE_BROWN_LINE_CMY_COVERAGE)
153
+ end
154
+
155
+ def standard_brown_line_sample?(cmy)
156
+ cmy.all? { |component| (component - REFERENCE_BROWN_LINE_CMY_COVERAGE).abs < 1e-12 }
157
+ end
158
+
159
+ def standard_white?(density, cmy)
160
+ return false if density > REFERENCE_WHITE_MAXIMUM_DENSITY
161
+
162
+ pale_yellow = cmy.fetch(2) == cmy.max && cmy.fetch(2) > REFERENCE_WHITE_MAXIMUM_CHROMA &&
163
+ cmy.fetch(0) <= 0.05
164
+ !pale_yellow
165
+ end
166
+
167
+ def rec601_density(rgb)
168
+ 1.0 - rgb.zip([0.299, 0.587, 0.114]).sum { |component, weight| component * weight }
169
+ end
170
+
171
+ def white_density(rgb)
172
+ FilamentProfile.display_sample(rgb)&.density || rec601_density(rgb)
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Fits an image to one sampling grid and adds configured White margins.
5
+ class ImagePlacement
6
+ def initialize(settings)
7
+ @settings = settings
8
+ end
9
+
10
+ def call(image, grid)
11
+ target_width = grid.sample_width
12
+ target_height = grid.sample_height
13
+ margins = grid.margin_samples
14
+ content_width, content_height = grid.content_sample_size
15
+ raise InputError, "margins leave no image samples" unless content_width.positive? && content_height.positive?
16
+
17
+ place(image, content_width, content_height).embed(
18
+ margins.fetch(:left), margins.fetch(:top), target_width, target_height,
19
+ extend: :background, background: Array.new(image.bands, 255)
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def place(image, target_width, target_height)
26
+ return contain(image, target_width, target_height) if @settings.image_fit == "contain"
27
+
28
+ cover(image, target_width, target_height)
29
+ end
30
+
31
+ def cover(image, target_width, target_height)
32
+ scale = [target_width.fdiv(image.width), target_height.fdiv(image.height)].max / @settings.crop_scale
33
+ resized = image.resize(scale)
34
+ left = position_offset(resized.width - target_width, @settings.crop_position_x)
35
+ top = position_offset(resized.height - target_height, @settings.crop_position_y)
36
+ resized.crop(left, top, target_width, target_height)
37
+ end
38
+
39
+ def contain(image, target_width, target_height)
40
+ scale = [target_width.fdiv(image.width), target_height.fdiv(image.height)].min * @settings.crop_scale
41
+ resized = image.resize(scale)
42
+ left = position_offset(target_width - resized.width, @settings.crop_position_x)
43
+ top = position_offset(target_height - resized.height, @settings.crop_position_y)
44
+ resized.embed(
45
+ left, top, target_width, target_height,
46
+ extend: :background, background: Array.new(image.bands, 255)
47
+ )
48
+ end
49
+
50
+ def position_offset(available, position)
51
+ [(available * position).floor, 0].max
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Writes cell-based previews from the same material model as the 3MF.
5
+ module Preview
6
+ module_function
7
+
8
+ def write_thickness(path, model:, minimum:, maximum:, heights: nil)
9
+ range = maximum - minimum
10
+ pixels = resolved_heights(model, heights).map { |height| thickness_level(height, minimum, range) }
11
+ write(path, model, pixels, bands: 1)
12
+ end
13
+
14
+ def write_transmission(path, model:, minimum:, maximum:, heights: nil)
15
+ range = maximum - minimum
16
+ pixels = resolved_heights(model, heights).map { |height| 255 - thickness_level(height, minimum, range) }
17
+ write(path, model, pixels, bands: 1)
18
+ end
19
+
20
+ def write_material(path, model:, minimum:, maximum:, heights: nil, transmission_gamma: 1.0)
21
+ range = maximum - minimum
22
+ cell_heights = resolved_heights(model, heights)
23
+ pixels = model.cells.each_with_index.flat_map do |cell, index|
24
+ transmission = (255 - thickness_level(cell_heights.fetch(index), minimum, range)).fdiv(255)
25
+ transmission **= transmission_gamma
26
+ cell.recipe.coverage_cmy.map do |coverage|
27
+ ((1.0 - coverage) * transmission * 255).round.clamp(0, 255)
28
+ end
29
+ end
30
+ write(path, model, pixels, bands: 3)
31
+ end
32
+
33
+ def write_mask(path, model:, material:)
34
+ maximum_layers = model.cells.map { |cell| cell.materials.length }.max
35
+ pixels = model.cells.map do |cell|
36
+ (cell.materials.count(material).fdiv(maximum_layers) * 255).round.clamp(0, 255)
37
+ end
38
+ write(path, model, pixels, bands: 1)
39
+ end
40
+
41
+ def thickness_level(height, minimum, range)
42
+ return 0 if range.zero?
43
+
44
+ (((height - minimum) / range) * 255).round.clamp(0, 255)
45
+ end
46
+
47
+ def resolved_heights(model, heights)
48
+ return model.cells.map(&:height) unless heights
49
+ return heights if heights.length == model.cells.length
50
+
51
+ raise InputError, "expected #{model.cells.length} preview heights, got #{heights.length}"
52
+ end
53
+ private_class_method :thickness_level, :resolved_heights
54
+
55
+ def write(path, model, pixels, bands:)
56
+ require "vips"
57
+
58
+ data = pixels.pack("C*")
59
+ Vips::Image.new_from_memory(data, model.columns, model.rows, bands, :uchar).pngsave(path.to_s)
60
+ end
61
+ private_class_method :write
62
+ end
63
+ end