lithop 0.1.0 → 0.3.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 +14 -0
- data/Cargo.lock +284 -0
- data/Cargo.toml +6 -0
- data/README.md +31 -7
- data/RELEASING.md +24 -6
- data/docs/README.md +4 -1
- data/docs/artifacts.md +26 -2
- data/docs/bambu-studio.md +7 -1
- data/docs/color-model.md +2 -0
- data/docs/command.md +48 -7
- data/docs/config.md +31 -4
- data/docs/getting-started.md +10 -5
- data/docs/images/cmyw-lithophane-preview.png +0 -0
- data/docs/local-ui.md +114 -0
- data/docs/tiling.md +134 -0
- data/docs/troubleshooting.md +22 -0
- data/ext/lithop_native/Cargo.toml +17 -0
- data/ext/lithop_native/build.rs +4 -0
- data/ext/lithop_native/extconf.rb +6 -0
- data/ext/lithop_native/src/column_mesh.rs +388 -0
- data/ext/lithop_native/src/lib.rs +37 -0
- data/ext/lithop_native/src/mesh.rs +187 -0
- data/ext/lithop_native/src/palette.rs +67 -0
- data/ext/lithop_native/src/xml.rs +134 -0
- data/lib/lithop/calibration/export.rb +21 -8
- data/lib/lithop/cli/calibration.rb +3 -1
- data/lib/lithop/cli/image_options.rb +41 -2
- data/lib/lithop/cli/options.rb +13 -0
- data/lib/lithop/cli.rb +36 -1
- data/lib/lithop/export/color_metadata.rb +55 -0
- data/lib/lithop/export/generation.rb +131 -21
- data/lib/lithop/export/job.rb +30 -16
- data/lib/lithop/export/metadata.rb +54 -4
- data/lib/lithop/export/planning.rb +97 -0
- data/lib/lithop/export/previews.rb +25 -1
- data/lib/lithop/export/print_readme.rb +22 -4
- data/lib/lithop/export/result.rb +26 -4
- data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
- data/lib/lithop/formats/three_mf/mesh.rb +72 -3
- data/lib/lithop/formats/three_mf/model_writer.rb +249 -24
- data/lib/lithop/formats/three_mf/part_writer.rb +51 -6
- data/lib/lithop/formats/three_mf.rb +9 -6
- data/lib/lithop/image/pipeline.rb +66 -10
- data/lib/lithop/image/svg_loader.rb +5 -5
- data/lib/lithop/job_directory.rb +41 -0
- data/lib/lithop/job_execution.rb +378 -0
- data/lib/lithop/layer_structure.rb +11 -0
- data/lib/lithop/mesh/box_geometry.rb +41 -0
- data/lib/lithop/mesh/column_geometry.rb +10 -2
- data/lib/lithop/mesh/column_topology.rb +2 -0
- data/lib/lithop/mesh/geometry.rb +21 -13
- data/lib/lithop/model/layer_region_model.rb +24 -0
- data/lib/lithop/model/material_model.rb +17 -5
- data/lib/lithop/model/palette.rb +21 -3
- data/lib/lithop/native.rb +39 -0
- data/lib/lithop/reference_three_mf.rb +17 -1
- data/lib/lithop/settings.rb +116 -1
- data/lib/lithop/settings_file.rb +10 -4
- data/lib/lithop/tiling.rb +127 -0
- data/lib/lithop/version.rb +1 -1
- data/lib/lithop/web/app.rb +513 -0
- data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
- data/lib/lithop/web/job_launcher.rb +214 -0
- data/lib/lithop/web/job_store.rb +390 -0
- data/lib/lithop/web/preview_store.rb +182 -0
- data/lib/lithop/web/public/app.css +319 -0
- data/lib/lithop/web/public/app.js +1847 -0
- data/lib/lithop/web/server.rb +21 -0
- data/lib/lithop/web/settings_form.rb +64 -0
- data/lib/lithop/web/settings_library.rb +154 -0
- data/lib/lithop/web/views/index.erb +364 -0
- data/lib/lithop/web/worker.rb +27 -0
- data/lib/lithop.rb +5 -0
- data/sig/lithop.rbs +102 -2
- metadata +89 -2
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
module Lithop
|
|
4
4
|
# Indexes vertices and triangles for a 3MF mesh resource.
|
|
5
5
|
class ThreeMFMesh
|
|
6
|
-
|
|
6
|
+
NATIVE_BATCH_SIZE = 8_192
|
|
7
|
+
MAXIMUM_NATIVE_TOPOLOGY_KEY = (2**63) - 1
|
|
8
|
+
class UnsupportedNativeTopology < StandardError; end
|
|
9
|
+
|
|
10
|
+
attr_reader :vertices, :vertex_indices, :triangle_count, :native_mesh
|
|
7
11
|
|
|
8
12
|
def initialize(triangles:)
|
|
9
13
|
@triangles = triangles
|
|
10
|
-
|
|
14
|
+
Native.enabled? ? index_native_resources : index_ruby_resources
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def each_indexed_triangle
|
|
@@ -18,9 +22,74 @@ module Lithop
|
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
def native_indexed? = !native_mesh.nil?
|
|
26
|
+
|
|
27
|
+
def vertex_count = native_indexed? ? native_mesh.vertex_count : vertices.length
|
|
28
|
+
|
|
29
|
+
def center
|
|
30
|
+
return native_mesh.center if native_indexed?
|
|
31
|
+
|
|
32
|
+
axes = vertices.transpose
|
|
33
|
+
axes.map { |values| (values.min + values.max) / 2.0 }
|
|
34
|
+
end
|
|
35
|
+
|
|
21
36
|
private
|
|
22
37
|
|
|
23
|
-
def
|
|
38
|
+
def index_native_resources
|
|
39
|
+
if @triangles.respond_to?(:native_indexed_mesh)
|
|
40
|
+
@native_mesh = @triangles.native_indexed_mesh
|
|
41
|
+
raise InputError, "3MF mesh contains no triangles" if native_mesh.vertex_count.zero?
|
|
42
|
+
|
|
43
|
+
@triangle_count = native_mesh.triangle_count
|
|
44
|
+
@vertices = nil
|
|
45
|
+
@vertex_indices = nil
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@native_mesh = Native::MeshBuilder.new
|
|
50
|
+
coordinates = []
|
|
51
|
+
topology_keys = []
|
|
52
|
+
count = 0
|
|
53
|
+
@triangles.each do |triangle|
|
|
54
|
+
validate_triangle!(triangle)
|
|
55
|
+
triangle.each do |vertex|
|
|
56
|
+
coordinates.concat(coordinates(vertex))
|
|
57
|
+
topology_keys << native_topology_key(vertex)
|
|
58
|
+
end
|
|
59
|
+
count += 1
|
|
60
|
+
flush_native_batch(coordinates, topology_keys) if (count % NATIVE_BATCH_SIZE).zero?
|
|
61
|
+
end
|
|
62
|
+
flush_native_batch(coordinates, topology_keys)
|
|
63
|
+
raise InputError, "3MF mesh contains no triangles" if native_mesh.vertex_count.zero?
|
|
64
|
+
|
|
65
|
+
@triangle_count = native_mesh.triangle_count
|
|
66
|
+
@vertices = nil
|
|
67
|
+
@vertex_indices = nil
|
|
68
|
+
rescue UnsupportedNativeTopology
|
|
69
|
+
@native_mesh = nil
|
|
70
|
+
index_ruby_resources
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def flush_native_batch(coordinates, topology_keys)
|
|
74
|
+
return if coordinates.empty?
|
|
75
|
+
|
|
76
|
+
native_mesh.add_batch(coordinates, topology_keys)
|
|
77
|
+
coordinates.clear
|
|
78
|
+
topology_keys.clear
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def native_topology_key(vertex)
|
|
82
|
+
return -1 unless vertex.respond_to?(:topology)
|
|
83
|
+
|
|
84
|
+
topology = vertex.topology
|
|
85
|
+
unless topology.is_a?(Integer) && topology.between?(0, MAXIMUM_NATIVE_TOPOLOGY_KEY)
|
|
86
|
+
raise UnsupportedNativeTopology
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
topology
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def index_ruby_resources
|
|
24
93
|
@vertices = []
|
|
25
94
|
@vertex_indices = {}
|
|
26
95
|
@triangle_count = 0
|
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module Lithop
|
|
4
4
|
# Serializes model resources and build items into a 3MF model document.
|
|
5
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
5
6
|
class ThreeMFModelWriter
|
|
6
7
|
CUSTOM_NAMESPACE = "https://github.com/ogom/lithop/3mf/v1"
|
|
8
|
+
BAMBU_STUDIO_NAMESPACE = "http://schemas.bambulab.com/package/2021"
|
|
9
|
+
BAMBU_STUDIO_APPLICATION = "BambuStudio-02.08.03.66"
|
|
10
|
+
PRODUCTION_NAMESPACE = "http://schemas.microsoft.com/3dmanufacturing/production/2015/06"
|
|
11
|
+
MODEL_PATH = "3D/3dmodel.model"
|
|
7
12
|
BASE_MATERIALS_ID = 1
|
|
8
13
|
COLOR_GROUP_ID = 2
|
|
9
14
|
FIRST_OBJECT_ID = 3
|
|
10
15
|
Result = Data.define(:vertex_count, :triangle_count, :triangle_counts_by_material)
|
|
16
|
+
Assembly = Data.define(:id, :name, :region, :object_ids, :component_origins)
|
|
11
17
|
|
|
12
18
|
def initialize(archive, parts, profile, instrumentation: Instrumentation.new, settings: nil)
|
|
13
19
|
@archive = archive
|
|
@@ -18,23 +24,20 @@ module Lithop
|
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
def write
|
|
27
|
+
object_ids = part_object_ids
|
|
28
|
+
assemblies = build_assemblies(object_ids)
|
|
29
|
+
return write_bambu_multi_plate(object_ids, assemblies) if bambu_multi_plate_project?
|
|
30
|
+
|
|
21
31
|
measure_xml do
|
|
32
|
+
@archive.put_next_entry(MODEL_PATH)
|
|
22
33
|
@archive.write(model_header)
|
|
23
34
|
write_materials
|
|
24
35
|
end
|
|
25
|
-
|
|
26
|
-
assembly_id = object_ids.max + 1
|
|
36
|
+
result = write_parts(object_ids)
|
|
27
37
|
measure_xml do
|
|
28
|
-
write_assembly(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
archive: @archive,
|
|
32
|
-
assembly_id:,
|
|
33
|
-
object_ids:,
|
|
34
|
-
parts: @parts,
|
|
35
|
-
profile: @profile,
|
|
36
|
-
settings: @settings
|
|
37
|
-
).write
|
|
38
|
+
assemblies.each { |assembly| write_assembly(assembly) }
|
|
39
|
+
write_build(assemblies)
|
|
40
|
+
write_bambu_metadata(object_ids, assemblies)
|
|
38
41
|
end
|
|
39
42
|
result
|
|
40
43
|
end
|
|
@@ -49,42 +52,230 @@ module Lithop
|
|
|
49
52
|
@archive.write(" </m:colorgroup>\n")
|
|
50
53
|
end
|
|
51
54
|
|
|
52
|
-
def
|
|
55
|
+
def part_object_ids
|
|
56
|
+
return @parts.each_index.map { |index| index + FIRST_OBJECT_ID }.freeze unless bambu_multi_plate_project?
|
|
57
|
+
|
|
58
|
+
next_id = 1
|
|
59
|
+
@parts.group_by(&:region).values.flat_map do |parts|
|
|
60
|
+
ids = Array.new(parts.length) do
|
|
61
|
+
value = next_id
|
|
62
|
+
next_id += 1
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
next_id += 1
|
|
66
|
+
ids
|
|
67
|
+
end.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def write_parts(object_ids)
|
|
53
71
|
vertex_count = 0
|
|
54
72
|
triangle_counts = {}
|
|
55
|
-
object_ids
|
|
56
|
-
object_id = index + FIRST_OBJECT_ID
|
|
73
|
+
object_ids.zip(@parts).each do |object_id, part|
|
|
57
74
|
mesh = @instrumentation.measure(:three_mf_indexing) do
|
|
58
75
|
ThreeMFMesh.new(triangles: part.triangles)
|
|
59
76
|
end
|
|
60
77
|
measure_xml do
|
|
61
|
-
ThreeMFPartWriter.new(archive: @archive, profile: @profile).write(object_id, part
|
|
78
|
+
ThreeMFPartWriter.new(archive: @archive, profile: @profile).write(object_id, part, mesh)
|
|
62
79
|
end
|
|
63
80
|
part.triangles.release_cache! if part.triangles.respond_to?(:release_cache!)
|
|
64
|
-
vertex_count += mesh.
|
|
65
|
-
triangle_counts[part.material] = mesh.triangle_count
|
|
66
|
-
|
|
81
|
+
vertex_count += mesh.vertex_count
|
|
82
|
+
triangle_counts[part.material] = triangle_counts.fetch(part.material, 0) + mesh.triangle_count
|
|
83
|
+
end
|
|
84
|
+
Result.new(
|
|
85
|
+
vertex_count:,
|
|
86
|
+
triangle_count: triangle_counts.values.sum,
|
|
87
|
+
triangle_counts_by_material: triangle_counts.freeze
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def write_bambu_multi_plate(object_ids, assemblies)
|
|
92
|
+
result = write_external_parts(object_ids, assemblies)
|
|
93
|
+
write_root_model(assemblies)
|
|
94
|
+
write_model_relationships(assemblies)
|
|
95
|
+
write_bambu_metadata(object_ids, assemblies)
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
|
|
100
|
+
def write_external_parts(object_ids, assemblies)
|
|
101
|
+
parts_by_object_id = object_ids.zip(@parts).to_h
|
|
102
|
+
vertex_count = 0
|
|
103
|
+
triangle_counts = {}
|
|
104
|
+
assemblies.each_with_index do |assembly, index|
|
|
105
|
+
@archive.put_next_entry("3D/Objects/object_#{index + 1}.model")
|
|
106
|
+
measure_xml do
|
|
107
|
+
@archive.write(submodel_header)
|
|
108
|
+
write_materials
|
|
109
|
+
end
|
|
110
|
+
assembly.object_ids.each do |object_id|
|
|
111
|
+
part = parts_by_object_id.fetch(object_id)
|
|
112
|
+
mesh = @instrumentation.measure(:three_mf_indexing) do
|
|
113
|
+
ThreeMFMesh.new(triangles: part.triangles)
|
|
114
|
+
end
|
|
115
|
+
center = mesh_center(mesh)
|
|
116
|
+
assembly.component_origins[object_id] = component_origin(
|
|
117
|
+
center, assembly.component_origins.fetch(object_id)
|
|
118
|
+
)
|
|
119
|
+
measure_xml do
|
|
120
|
+
ThreeMFPartWriter.new(
|
|
121
|
+
archive: @archive, profile: @profile, production: true, origin: center
|
|
122
|
+
).write(object_id, part, mesh)
|
|
123
|
+
end
|
|
124
|
+
part.triangles.release_cache! if part.triangles.respond_to?(:release_cache!)
|
|
125
|
+
vertex_count += mesh.vertex_count
|
|
126
|
+
triangle_counts[part.material] = triangle_counts.fetch(part.material, 0) + mesh.triangle_count
|
|
127
|
+
end
|
|
128
|
+
measure_xml do
|
|
129
|
+
@archive.write(" </resources>\n <build/>\n</model>\n")
|
|
130
|
+
end
|
|
67
131
|
end
|
|
68
|
-
|
|
132
|
+
Result.new(
|
|
69
133
|
vertex_count:,
|
|
70
134
|
triangle_count: triangle_counts.values.sum,
|
|
71
135
|
triangle_counts_by_material: triangle_counts.freeze
|
|
72
136
|
)
|
|
73
|
-
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def write_root_model(assemblies)
|
|
140
|
+
@archive.put_next_entry(MODEL_PATH)
|
|
141
|
+
measure_xml do
|
|
142
|
+
@archive.write(bambu_multi_plate_model_header)
|
|
143
|
+
assemblies.each_with_index { |assembly, index| write_assembly(assembly, index + 1) }
|
|
144
|
+
write_build(assemblies)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def write_model_relationships(assemblies)
|
|
149
|
+
@archive.put_next_entry("3D/_rels/3dmodel.model.rels")
|
|
150
|
+
@archive.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
|
|
151
|
+
@archive.write("<Relationships xmlns=\"#{ThreeMF::RELATIONSHIP_NAMESPACE}\">\n")
|
|
152
|
+
assemblies.each_with_index do |_assembly, index|
|
|
153
|
+
@archive.write(
|
|
154
|
+
" <Relationship Target=\"/3D/Objects/object_#{index + 1}.model\" Id=\"rel-#{index + 1}\" " \
|
|
155
|
+
"Type=\"#{ThreeMF::MODEL_RELATIONSHIP}\"/>\n"
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
@archive.write("</Relationships>\n")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def write_bambu_metadata(object_ids, assemblies)
|
|
162
|
+
ThreeMFBambuMetadata.new(
|
|
163
|
+
archive: @archive,
|
|
164
|
+
object_ids:,
|
|
165
|
+
assemblies:,
|
|
166
|
+
parts: @parts,
|
|
167
|
+
profile: @profile,
|
|
168
|
+
settings: @settings
|
|
169
|
+
).write
|
|
74
170
|
end
|
|
75
171
|
|
|
76
172
|
def measure_xml(&)
|
|
77
173
|
@instrumentation.measure(:three_mf_xml_zip, &)
|
|
78
174
|
end
|
|
79
175
|
|
|
80
|
-
def
|
|
81
|
-
|
|
176
|
+
def build_assemblies(object_ids)
|
|
177
|
+
groups = if separate_z_plates?
|
|
178
|
+
object_ids.zip(@parts).group_by { |_object_id, part| part.region }
|
|
179
|
+
else
|
|
180
|
+
{ nil => object_ids.zip(@parts) }
|
|
181
|
+
end
|
|
182
|
+
groups.map do |region, entries|
|
|
183
|
+
Assembly.new(
|
|
184
|
+
id: entries.map(&:first).max + 1,
|
|
185
|
+
name: assembly_name(region),
|
|
186
|
+
region:,
|
|
187
|
+
object_ids: entries.map(&:first).freeze,
|
|
188
|
+
component_origins: entries.to_h { |object_id, part| [object_id, [0.0, 0.0, part.build_z_offset]] }
|
|
189
|
+
)
|
|
190
|
+
end.freeze
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def separate_z_plates?
|
|
194
|
+
@settings&.separate_z_parts && @parts.all?(&:region)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def bambu_multi_plate_project? = @settings&.bambu_project && separate_z_plates?
|
|
198
|
+
|
|
199
|
+
def assembly_name(region)
|
|
200
|
+
return "Lithop CMYW lithophane" unless region
|
|
201
|
+
return "Top White" if region == "top_white"
|
|
202
|
+
|
|
203
|
+
region.sub("color_region_", "Color region ")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def write_assembly(assembly, submodel_index = nil)
|
|
207
|
+
uuid = submodel_index ? production_uuid(assembly.id) : ""
|
|
208
|
+
@archive.write(" <object id=\"#{assembly.id}\"#{uuid} type=\"model\" name=\"#{assembly.name}\">\n")
|
|
82
209
|
@archive.write(" <components>\n")
|
|
83
|
-
object_ids.
|
|
210
|
+
assembly.object_ids.each_with_index do |object_id, index|
|
|
211
|
+
transform = component_transform(assembly.component_origins.fetch(object_id))
|
|
212
|
+
path = submodel_index ? %( p:path="/3D/Objects/object_#{submodel_index}.model") : ""
|
|
213
|
+
uuid = submodel_index ? production_uuid((assembly.id * 1000) + index + 1) : ""
|
|
214
|
+
@archive.write(" <component#{path} objectid=\"#{object_id}\"#{uuid}#{transform}/>\n")
|
|
215
|
+
end
|
|
84
216
|
@archive.write(" </components>\n </object>\n")
|
|
85
217
|
end
|
|
86
218
|
|
|
219
|
+
def component_transform(origin)
|
|
220
|
+
return "" if origin.all?(&:zero?)
|
|
221
|
+
|
|
222
|
+
%( transform="1 0 0 0 1 0 0 0 1 #{origin.map { |value| format_number(value) }.join(" ")}")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def format_number(number) = format("%.9g", number)
|
|
226
|
+
|
|
227
|
+
def write_build(assemblies)
|
|
228
|
+
@archive.write(" </resources>\n <build>\n")
|
|
229
|
+
assemblies.each do |assembly|
|
|
230
|
+
attributes = if bambu_multi_plate_project?
|
|
231
|
+
%(#{build_uuid(assembly.id)} transform="#{build_transform(assembly)}" printable="1")
|
|
232
|
+
else
|
|
233
|
+
""
|
|
234
|
+
end
|
|
235
|
+
@archive.write(" <item objectid=\"#{assembly.id}\"#{attributes}/>\n")
|
|
236
|
+
end
|
|
237
|
+
@archive.write(" </build>\n</model>\n")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def production_uuid(identifier)
|
|
241
|
+
%( p:UUID="#{format("%08x", identifier)}-61cb-4c03-9d28-80fed5dfa1dc")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def build_uuid(identifier)
|
|
245
|
+
%( p:UUID="#{format("%08x", identifier)}-b1ec-4553-aec9-835e5b724bb4")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def build_transform(assembly)
|
|
249
|
+
x, y = [
|
|
250
|
+
[48.0, 30.0],
|
|
251
|
+
[300.160253, 37.8003226],
|
|
252
|
+
[52.2484968, -205.366274],
|
|
253
|
+
[288.0, -210.0]
|
|
254
|
+
].fetch(plate_index(assembly))
|
|
255
|
+
"1 0 0 0 1 0 0 0 1 #{format_number(x)} #{format_number(y)} 0"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def mesh_center(mesh)
|
|
259
|
+
mesh.center
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def component_origin(center, base_origin)
|
|
263
|
+
[center[0], center[1], center[2] + base_origin[2]]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def plate_index(assembly)
|
|
267
|
+
return 3 if assembly.region == "top_white"
|
|
268
|
+
|
|
269
|
+
assembly.region.delete_prefix("color_region_").to_i - 1
|
|
270
|
+
end
|
|
271
|
+
|
|
87
272
|
def model_header
|
|
273
|
+
return bambu_multi_plate_model_header if bambu_multi_plate_project?
|
|
274
|
+
|
|
275
|
+
inline_model_header
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def inline_model_header
|
|
88
279
|
<<~XML
|
|
89
280
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
90
281
|
<model unit="millimeter" xml:lang="en-US" requiredextensions="m"
|
|
@@ -101,6 +292,40 @@ module Lithop
|
|
|
101
292
|
XML
|
|
102
293
|
end
|
|
103
294
|
|
|
295
|
+
def bambu_multi_plate_model_header
|
|
296
|
+
<<~XML
|
|
297
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
298
|
+
<model unit="millimeter" xml:lang="en-US" requiredextensions="p"
|
|
299
|
+
xmlns="#{ThreeMF::CORE_NAMESPACE}"
|
|
300
|
+
xmlns:BambuStudio="#{BAMBU_STUDIO_NAMESPACE}"
|
|
301
|
+
xmlns:p="#{PRODUCTION_NAMESPACE}"
|
|
302
|
+
xmlns:lithop="#{CUSTOM_NAMESPACE}">
|
|
303
|
+
<metadata name="Title">Lithop CMYW lithophane</metadata>
|
|
304
|
+
<metadata name="Application">#{BAMBU_STUDIO_APPLICATION}</metadata>
|
|
305
|
+
<metadata name="BambuStudio:3mfVersion">1</metadata>
|
|
306
|
+
<metadata name="lithop:Application" preserve="1">Lithop #{Lithop::VERSION}</metadata>
|
|
307
|
+
<metadata name="Description">Physical CMY subtractive layers with variable White luminance thickness.</metadata>
|
|
308
|
+
<metadata name="lithop:FilamentBundle" preserve="1">#{@profile.name}</metadata>
|
|
309
|
+
<metadata name="lithop:FilamentProduct" preserve="1">#{@profile.product_url}</metadata>
|
|
310
|
+
<metadata name="lithop:PrintingGuide" preserve="1">#{@profile.reference_url}</metadata>
|
|
311
|
+
<resources>
|
|
312
|
+
XML
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def submodel_header
|
|
316
|
+
<<~XML
|
|
317
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
318
|
+
<model unit="millimeter" xml:lang="en-US" requiredextensions="m p"
|
|
319
|
+
xmlns="#{ThreeMF::CORE_NAMESPACE}"
|
|
320
|
+
xmlns:m="#{ThreeMF::MATERIAL_NAMESPACE}"
|
|
321
|
+
xmlns:BambuStudio="#{BAMBU_STUDIO_NAMESPACE}"
|
|
322
|
+
xmlns:p="#{PRODUCTION_NAMESPACE}"
|
|
323
|
+
xmlns:lithop="#{CUSTOM_NAMESPACE}">
|
|
324
|
+
<metadata name="BambuStudio:3mfVersion">1</metadata>
|
|
325
|
+
<resources>
|
|
326
|
+
XML
|
|
327
|
+
end
|
|
328
|
+
|
|
104
329
|
def material_xml(material)
|
|
105
330
|
name = "#{@profile.manufacturer} #{material.name} (#{material.color_code})"
|
|
106
331
|
<<~XML
|
|
@@ -3,18 +3,25 @@
|
|
|
3
3
|
module Lithop
|
|
4
4
|
# Serializes one indexed material mesh into a 3MF model document.
|
|
5
5
|
class ThreeMFPartWriter
|
|
6
|
-
|
|
6
|
+
XML_BATCH_SIZE = 8_192
|
|
7
|
+
|
|
8
|
+
def initialize(archive:, profile:, production: false, origin: nil)
|
|
7
9
|
@archive = archive
|
|
8
10
|
@profile = profile
|
|
11
|
+
@production = production
|
|
12
|
+
@origin = origin || [0.0, 0.0, 0.0]
|
|
9
13
|
end
|
|
10
14
|
|
|
11
|
-
def write(object_id,
|
|
15
|
+
def write(object_id, part, mesh)
|
|
16
|
+
material = part.material
|
|
12
17
|
material_index = @profile.materials.index { |candidate| candidate.channel == material }
|
|
13
|
-
name = @profile.materials.fetch(material_index).name
|
|
18
|
+
name = part.name || @profile.materials.fetch(material_index).name
|
|
19
|
+
region = part.region ? " lithop:region=\"#{part.region}\"" : ""
|
|
20
|
+
uuid = @production ? %( p:UUID="00000000-0000-4000-8000-#{format("%012d", object_id)}") : ""
|
|
14
21
|
@archive.write(
|
|
15
|
-
" <object id=\"#{object_id}\" type=\"model\" name=\"#{name}\" " \
|
|
22
|
+
" <object id=\"#{object_id}\"#{uuid} type=\"model\" name=\"#{name}\" " \
|
|
16
23
|
"pid=\"#{ThreeMFModelWriter::COLOR_GROUP_ID}\" pindex=\"#{material_index}\" " \
|
|
17
|
-
"lithop:channel=\"#{material}\">\n"
|
|
24
|
+
"lithop:channel=\"#{material}\"#{region}>\n"
|
|
18
25
|
)
|
|
19
26
|
@archive.write(" <mesh>\n <vertices>\n")
|
|
20
27
|
write_vertices(mesh)
|
|
@@ -26,12 +33,21 @@ module Lithop
|
|
|
26
33
|
private
|
|
27
34
|
|
|
28
35
|
def write_vertices(mesh)
|
|
36
|
+
return write_indexed_native_vertices(mesh) if mesh.native_indexed?
|
|
37
|
+
return write_native_vertices(mesh) if Native.enabled?
|
|
38
|
+
|
|
29
39
|
mesh.vertices.each do |x, y, z|
|
|
30
|
-
@archive.write(
|
|
40
|
+
@archive.write(
|
|
41
|
+
" <vertex x=\"#{number(x - @origin[0])}\" y=\"#{number(y - @origin[1])}\" " \
|
|
42
|
+
"z=\"#{number(z - @origin[2])}\"/>\n"
|
|
43
|
+
)
|
|
31
44
|
end
|
|
32
45
|
end
|
|
33
46
|
|
|
34
47
|
def write_triangles(mesh)
|
|
48
|
+
return write_indexed_native_triangles(mesh) if mesh.native_indexed?
|
|
49
|
+
return write_native_triangles(mesh) if Native.enabled?
|
|
50
|
+
|
|
35
51
|
count = 0
|
|
36
52
|
mesh.each_indexed_triangle do |vertices|
|
|
37
53
|
@archive.write(
|
|
@@ -44,6 +60,35 @@ module Lithop
|
|
|
44
60
|
raise InputError, "3MF triangle enumerables must be repeatable"
|
|
45
61
|
end
|
|
46
62
|
|
|
63
|
+
def write_native_vertices(mesh)
|
|
64
|
+
mesh.vertices.each_slice(XML_BATCH_SIZE) do |vertices|
|
|
65
|
+
@archive.write(Native.vertex_xml(vertices.flatten, @origin))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def write_indexed_native_vertices(mesh)
|
|
70
|
+
0.step(mesh.vertex_count - 1, XML_BATCH_SIZE) do |offset|
|
|
71
|
+
@archive.write(mesh.native_mesh.vertex_xml(offset, XML_BATCH_SIZE, @origin))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write_native_triangles(mesh)
|
|
76
|
+
count = 0
|
|
77
|
+
mesh.each_indexed_triangle.each_slice(XML_BATCH_SIZE) do |triangles|
|
|
78
|
+
@archive.write(Native.triangle_xml(triangles.flatten))
|
|
79
|
+
count += triangles.length
|
|
80
|
+
end
|
|
81
|
+
return if count == mesh.triangle_count
|
|
82
|
+
|
|
83
|
+
raise InputError, "3MF triangle enumerables must be repeatable"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write_indexed_native_triangles(mesh)
|
|
87
|
+
0.step(mesh.triangle_count - 1, XML_BATCH_SIZE) do |offset|
|
|
88
|
+
@archive.write(mesh.native_mesh.triangle_xml(offset, XML_BATCH_SIZE))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
47
92
|
def number(value) = format("%.9g", value)
|
|
48
93
|
end
|
|
49
94
|
end
|
|
@@ -18,8 +18,12 @@ module Lithop
|
|
|
18
18
|
MODEL_RELATIONSHIP = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"
|
|
19
19
|
MODEL_CONTENT_TYPE = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"
|
|
20
20
|
ZIP_MUTEX = Mutex.new
|
|
21
|
-
#
|
|
22
|
-
Part = Data.define(:material, :triangles)
|
|
21
|
+
# The Rust indexer consumes triangles once; the Ruby comparison path requires replayable input.
|
|
22
|
+
Part = Data.define(:material, :triangles, :name, :region, :build_z_offset) do
|
|
23
|
+
def initialize(material:, triangles:, name: nil, region: nil, build_z_offset: 0.0)
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
23
27
|
Result = Data.define(:vertex_count, :triangle_count, :material_count, :object_count,
|
|
24
28
|
:triangle_counts_by_material)
|
|
25
29
|
|
|
@@ -66,13 +70,13 @@ module Lithop
|
|
|
66
70
|
unless valid_materials.include?(part.material)
|
|
67
71
|
raise InputError, "3MF part uses an unknown material: #{part.material.inspect}"
|
|
68
72
|
end
|
|
73
|
+
unless part.build_z_offset.is_a?(Numeric) && part.build_z_offset.finite?
|
|
74
|
+
raise InputError, "3MF part build_z_offset must be a finite number"
|
|
75
|
+
end
|
|
69
76
|
|
|
70
77
|
part
|
|
71
78
|
end
|
|
72
79
|
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
80
|
|
|
77
81
|
validated.freeze
|
|
78
82
|
end
|
|
@@ -87,7 +91,6 @@ module Lithop
|
|
|
87
91
|
@instrumentation.measure(:three_mf_xml_zip) do
|
|
88
92
|
write_entry(archive, "[Content_Types].xml", content_types_xml)
|
|
89
93
|
write_entry(archive, "_rels/.rels", relationships_xml)
|
|
90
|
-
archive.put_next_entry("3D/3dmodel.model")
|
|
91
94
|
end
|
|
92
95
|
package = ThreeMFModelWriter.new(
|
|
93
96
|
archive, @parts, @profile, instrumentation: @instrumentation, settings: @settings
|
|
@@ -9,7 +9,9 @@ module Lithop
|
|
|
9
9
|
REFERENCE_DARK_MAXIMUM_VALUE = 0.75
|
|
10
10
|
REFERENCE_NEUTRAL_MAXIMUM_CHROMA = 0.25
|
|
11
11
|
REFERENCE_BROWN_LINE_CMY_COVERAGE = 1.0 / 7
|
|
12
|
-
Result = Data.define(
|
|
12
|
+
Result = Data.define(
|
|
13
|
+
:width, :height, :cmys, :cmy_densities, :white_densities, :color_cells, :corrected_preview_png
|
|
14
|
+
)
|
|
13
15
|
|
|
14
16
|
def initialize(settings)
|
|
15
17
|
@settings = settings
|
|
@@ -21,12 +23,8 @@ module Lithop
|
|
|
21
23
|
require "vips"
|
|
22
24
|
|
|
23
25
|
access = @settings.color_pixel_size ? :random : :sequential
|
|
24
|
-
image =
|
|
25
|
-
|
|
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)
|
|
26
|
+
image = normalized_image(path, access:)
|
|
27
|
+
sampled = @placement.call(image, @settings.sampling_grid).cast(:uchar).copy_memory
|
|
30
28
|
|
|
31
29
|
rgbs = rgb_components(sampled)
|
|
32
30
|
color_samples = rgbs.map { |rgb| color_sample(rgb) }
|
|
@@ -41,20 +39,62 @@ module Lithop
|
|
|
41
39
|
cmys: color_samples.map(&:first),
|
|
42
40
|
cmy_densities: color_samples.map(&:last),
|
|
43
41
|
white_densities: rgbs.map { |rgb| white_density(rgb) },
|
|
44
|
-
color_cells: result_color_cells
|
|
42
|
+
color_cells: result_color_cells,
|
|
43
|
+
corrected_preview_png: sampled.write_to_buffer(".png")
|
|
45
44
|
)
|
|
46
45
|
rescue SystemCallError, Vips::Error => e
|
|
47
46
|
raise InputError, "could not read image: #{e.message}"
|
|
48
47
|
end
|
|
49
48
|
|
|
49
|
+
# Returns the same normalized and placed image used by conversion, bounded
|
|
50
|
+
# to a browser-friendly sampling grid and without running CMYW or mesh work.
|
|
51
|
+
def preview_png(path, maximum_width: 960, maximum_height: 720)
|
|
52
|
+
require "vips"
|
|
53
|
+
|
|
54
|
+
grid = preview_grid(maximum_width:, maximum_height:)
|
|
55
|
+
image = normalized_image(path, access: :sequential, sampling_grids: [grid])
|
|
56
|
+
@placement.call(image, grid).cast(:uchar).write_to_buffer(".png")
|
|
57
|
+
rescue SystemCallError, Vips::Error => e
|
|
58
|
+
raise InputError, "could not read image: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
50
61
|
private
|
|
51
62
|
|
|
52
|
-
def
|
|
63
|
+
def normalized_image(path, access:, sampling_grids: nil)
|
|
64
|
+
image = load_image(path, access:, sampling_grids:)
|
|
65
|
+
image = image.autorot
|
|
66
|
+
validate_input!(image)
|
|
67
|
+
image = image.flatten(background: [255, 255, 255]) if image.has_alpha?
|
|
68
|
+
adjust_image(to_srgb(image))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def load_image(path, access:, sampling_grids: nil)
|
|
53
72
|
signature = File.binread(path, 8)
|
|
54
73
|
raster = signature.start_with?("\xFF\xD8\xFF".b) || signature.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
55
74
|
return Vips::Image.new_from_file(path.to_s, access:) if raster
|
|
56
75
|
|
|
57
|
-
@svg_loader.call(path, access:)
|
|
76
|
+
@svg_loader.call(path, access:, sampling_grids:)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def preview_grid(maximum_width:, maximum_height:)
|
|
80
|
+
maximum_width = Integer(maximum_width)
|
|
81
|
+
maximum_height = Integer(maximum_height)
|
|
82
|
+
raise InputError, "preview dimensions must be at least 2 pixels" if maximum_width < 2 || maximum_height < 2
|
|
83
|
+
|
|
84
|
+
pixel_size = [
|
|
85
|
+
@settings.width.fdiv(maximum_width - 1),
|
|
86
|
+
@settings.height.fdiv(maximum_height - 1),
|
|
87
|
+
0.01
|
|
88
|
+
].max
|
|
89
|
+
SamplingGrid.new(
|
|
90
|
+
width: @settings.width, height: @settings.height, pixel_size:,
|
|
91
|
+
margins: {
|
|
92
|
+
top: @settings.margin_top, right: @settings.margin_right,
|
|
93
|
+
bottom: @settings.margin_bottom, left: @settings.margin_left
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
rescue ArgumentError, TypeError
|
|
97
|
+
raise InputError, "preview dimensions must be integers"
|
|
58
98
|
end
|
|
59
99
|
|
|
60
100
|
def validate_input!(image)
|
|
@@ -72,6 +112,22 @@ module Lithop
|
|
|
72
112
|
image.icc_import(embedded: true, pcs: :xyz).colourspace(:srgb)
|
|
73
113
|
end
|
|
74
114
|
|
|
115
|
+
def adjust_image(image)
|
|
116
|
+
return image if image_adjustments.none?(&:nonzero?)
|
|
117
|
+
|
|
118
|
+
adjusted = image.cast(:float)
|
|
119
|
+
adjusted += @settings.brightness.fdiv(100) * 255
|
|
120
|
+
contrast_factor = 1.0 + @settings.contrast.fdiv(100)
|
|
121
|
+
adjusted = ((adjusted - 127.5) * contrast_factor) + 127.5
|
|
122
|
+
saturation_factor = 1.0 + @settings.saturation.fdiv(100)
|
|
123
|
+
luminance = (adjusted[0] * 0.299) + (adjusted[1] * 0.587) + (adjusted[2] * 0.114)
|
|
124
|
+
luminance + ((adjusted - luminance) * saturation_factor)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def image_adjustments
|
|
128
|
+
[@settings.brightness, @settings.contrast, @settings.saturation]
|
|
129
|
+
end
|
|
130
|
+
|
|
75
131
|
def color_cells(image)
|
|
76
132
|
return if @settings.color_pixel_size.nil?
|
|
77
133
|
|