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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/Cargo.lock +284 -0
  4. data/Cargo.toml +6 -0
  5. data/README.md +31 -7
  6. data/RELEASING.md +24 -6
  7. data/docs/README.md +4 -1
  8. data/docs/artifacts.md +26 -2
  9. data/docs/bambu-studio.md +7 -1
  10. data/docs/color-model.md +2 -0
  11. data/docs/command.md +48 -7
  12. data/docs/config.md +31 -4
  13. data/docs/getting-started.md +10 -5
  14. data/docs/images/cmyw-lithophane-preview.png +0 -0
  15. data/docs/local-ui.md +114 -0
  16. data/docs/tiling.md +134 -0
  17. data/docs/troubleshooting.md +22 -0
  18. data/ext/lithop_native/Cargo.toml +17 -0
  19. data/ext/lithop_native/build.rs +4 -0
  20. data/ext/lithop_native/extconf.rb +6 -0
  21. data/ext/lithop_native/src/column_mesh.rs +388 -0
  22. data/ext/lithop_native/src/lib.rs +37 -0
  23. data/ext/lithop_native/src/mesh.rs +187 -0
  24. data/ext/lithop_native/src/palette.rs +67 -0
  25. data/ext/lithop_native/src/xml.rs +134 -0
  26. data/lib/lithop/calibration/export.rb +21 -8
  27. data/lib/lithop/cli/calibration.rb +3 -1
  28. data/lib/lithop/cli/image_options.rb +41 -2
  29. data/lib/lithop/cli/options.rb +13 -0
  30. data/lib/lithop/cli.rb +36 -1
  31. data/lib/lithop/export/color_metadata.rb +55 -0
  32. data/lib/lithop/export/generation.rb +131 -21
  33. data/lib/lithop/export/job.rb +30 -16
  34. data/lib/lithop/export/metadata.rb +54 -4
  35. data/lib/lithop/export/planning.rb +97 -0
  36. data/lib/lithop/export/previews.rb +25 -1
  37. data/lib/lithop/export/print_readme.rb +22 -4
  38. data/lib/lithop/export/result.rb +26 -4
  39. data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
  40. data/lib/lithop/formats/three_mf/mesh.rb +72 -3
  41. data/lib/lithop/formats/three_mf/model_writer.rb +249 -24
  42. data/lib/lithop/formats/three_mf/part_writer.rb +51 -6
  43. data/lib/lithop/formats/three_mf.rb +9 -6
  44. data/lib/lithop/image/pipeline.rb +66 -10
  45. data/lib/lithop/image/svg_loader.rb +5 -5
  46. data/lib/lithop/job_directory.rb +41 -0
  47. data/lib/lithop/job_execution.rb +378 -0
  48. data/lib/lithop/layer_structure.rb +11 -0
  49. data/lib/lithop/mesh/box_geometry.rb +41 -0
  50. data/lib/lithop/mesh/column_geometry.rb +10 -2
  51. data/lib/lithop/mesh/column_topology.rb +2 -0
  52. data/lib/lithop/mesh/geometry.rb +21 -13
  53. data/lib/lithop/model/layer_region_model.rb +24 -0
  54. data/lib/lithop/model/material_model.rb +17 -5
  55. data/lib/lithop/model/palette.rb +21 -3
  56. data/lib/lithop/native.rb +39 -0
  57. data/lib/lithop/reference_three_mf.rb +17 -1
  58. data/lib/lithop/settings.rb +116 -1
  59. data/lib/lithop/settings_file.rb +10 -4
  60. data/lib/lithop/tiling.rb +127 -0
  61. data/lib/lithop/version.rb +1 -1
  62. data/lib/lithop/web/app.rb +513 -0
  63. data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
  64. data/lib/lithop/web/job_launcher.rb +214 -0
  65. data/lib/lithop/web/job_store.rb +390 -0
  66. data/lib/lithop/web/preview_store.rb +182 -0
  67. data/lib/lithop/web/public/app.css +319 -0
  68. data/lib/lithop/web/public/app.js +1847 -0
  69. data/lib/lithop/web/server.rb +21 -0
  70. data/lib/lithop/web/settings_form.rb +64 -0
  71. data/lib/lithop/web/settings_library.rb +154 -0
  72. data/lib/lithop/web/views/index.erb +364 -0
  73. data/lib/lithop/web/worker.rb +27 -0
  74. data/lib/lithop.rb +5 -0
  75. data/sig/lithop.rbs +102 -2
  76. 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
- attr_reader :vertices, :vertex_indices, :triangle_count
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
- index_resources
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 index_resources
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
- object_ids, result = write_parts
26
- assembly_id = object_ids.max + 1
36
+ result = write_parts(object_ids)
27
37
  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
+ 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 write_parts
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 = @parts.each_with_index.map do |part, index|
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.material, mesh)
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.vertices.length
65
- triangle_counts[part.material] = mesh.triangle_count
66
- object_id
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
- result = Result.new(
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
- [object_ids, result]
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 write_assembly(assembly_id, object_ids)
81
- @archive.write(" <object id=\"#{assembly_id}\" type=\"model\" name=\"Lithop CMYW lithophane\">\n")
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.each { |object_id| @archive.write(" <component objectid=\"#{object_id}\"/>\n") }
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
- def initialize(archive:, profile:)
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, material, mesh)
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(" <vertex x=\"#{number(x)}\" y=\"#{number(y)}\" z=\"#{number(z)}\"/>\n")
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
- # Triangles are indexed and then serialized, so their enumerable must be replayable.
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(:width, :height, :cmys, :cmy_densities, :white_densities, :color_cells)
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 = 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)
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 load_image(path, access:)
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