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