lithop 0.1.0 → 0.2.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/README.md +9 -3
  4. data/RELEASING.md +3 -3
  5. data/docs/README.md +1 -0
  6. data/docs/artifacts.md +19 -2
  7. data/docs/bambu-studio.md +2 -0
  8. data/docs/color-model.md +2 -0
  9. data/docs/command.md +23 -5
  10. data/docs/config.md +30 -3
  11. data/docs/getting-started.md +5 -3
  12. data/docs/tiling.md +132 -0
  13. data/docs/troubleshooting.md +12 -0
  14. data/lib/lithop/calibration/export.rb +21 -8
  15. data/lib/lithop/cli/calibration.rb +3 -1
  16. data/lib/lithop/cli/image_options.rb +41 -2
  17. data/lib/lithop/cli.rb +8 -1
  18. data/lib/lithop/export/color_metadata.rb +55 -0
  19. data/lib/lithop/export/generation.rb +117 -20
  20. data/lib/lithop/export/job.rb +30 -16
  21. data/lib/lithop/export/metadata.rb +48 -3
  22. data/lib/lithop/export/planning.rb +97 -0
  23. data/lib/lithop/export/print_readme.rb +22 -4
  24. data/lib/lithop/export/result.rb +26 -4
  25. data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
  26. data/lib/lithop/formats/three_mf/model_writer.rb +245 -23
  27. data/lib/lithop/formats/three_mf/part_writer.rb +14 -6
  28. data/lib/lithop/formats/three_mf.rb +8 -5
  29. data/lib/lithop/image/pipeline.rb +17 -0
  30. data/lib/lithop/job_directory.rb +41 -0
  31. data/lib/lithop/job_execution.rb +250 -0
  32. data/lib/lithop/layer_structure.rb +11 -0
  33. data/lib/lithop/mesh/box_geometry.rb +41 -0
  34. data/lib/lithop/mesh/column_geometry.rb +2 -2
  35. data/lib/lithop/mesh/geometry.rb +21 -13
  36. data/lib/lithop/model/layer_region_model.rb +24 -0
  37. data/lib/lithop/reference_three_mf.rb +17 -1
  38. data/lib/lithop/settings.rb +116 -1
  39. data/lib/lithop/settings_file.rb +10 -4
  40. data/lib/lithop/tiling.rb +126 -0
  41. data/lib/lithop/version.rb +1 -1
  42. data/lib/lithop.rb +4 -0
  43. data/sig/lithop.rbs +77 -2
  44. metadata +6 -1
@@ -5,7 +5,10 @@ module Lithop
5
5
  module PrintReadme
6
6
  module_function
7
7
 
8
- def write(path, settings:, material_model:, calibration_profile: nil, profile: FilamentProfile.default)
8
+ def write(
9
+ path, settings:, material_model:, tile_artifacts:, calibration_profile: nil,
10
+ profile: FilamentProfile.default
11
+ )
9
12
  materials = Palette::MATERIALS.select { |material| material_model.materials.include?(material) }
10
13
  names = materials.map { |material| profile_material(profile, material).name }
11
14
  assignments = materials.map do |material|
@@ -18,14 +21,15 @@ module Lithop
18
21
  This is #{calibration_profile ? "a measured-profile calibrated" : "an uncalibrated"} CMYW lithophane
19
22
  generated in millimetres.
20
23
 
21
- - Open `lithop.3mf` for the physical color model; it contains: #{names.join(", ")}.
24
+ - #{model_instruction(tile_artifacts, names)}
22
25
  #{calibration_line(calibration_profile)}
23
26
  - Bambu Studio assignments encoded for this job: #{assignments.join(", ")}.
24
27
  - The model uses #{settings.color_layer_count} CMY/W color layers #{base_layer_description(settings)}.
25
28
  - The backlight-to-view color-layer order is #{settings.pigment_order.map(&:capitalize).join(" -> ")} -> White.
26
29
  - White thickness above the color region follows #{thickness_method(settings, calibration_profile)}.
30
+ #{z_part_line(settings)}
27
31
  - `preview.png` shows nominal recipe colors with relative thickness shading; it is not a colorimetric prediction.
28
- - `transmission.png`, `thickness.png`, and the four material masks use the same cells as `lithop.3mf`.
32
+ - `transmission.png`, `thickness.png`, and the four material masks use the same cells as the 3MF output.
29
33
  - `cmyk_white.stl` is a separate monochrome reference model, not the color model.
30
34
  - Product: #{profile.product_url}
31
35
  - Guide: #{profile.reference_url}
@@ -38,6 +42,20 @@ module Lithop
38
42
  MARKDOWN
39
43
  end
40
44
 
45
+ def model_instruction(tile_artifacts, names)
46
+ return "Open `lithop.3mf` for the physical color model; it contains: #{names.join(", ")}." if tile_artifacts.one?
47
+
48
+ "Open the #{tile_artifacts.length} 3MF files under `tiles/` and assemble them using " \
49
+ "`tile-layout.svg` and `manifest.json`; together they contain: #{names.join(", ")}."
50
+ end
51
+
52
+ def z_part_line(settings)
53
+ return "- Z regions remain combined into the material parts." unless settings.separate_z_parts
54
+
55
+ "- Z regions use #{settings.color_region_parts + 1} plates: each CMYW color region includes " \
56
+ "a Base White carrier, followed by Top White; every plate starts at Z=0."
57
+ end
58
+
41
59
  def profile_material(profile, material)
42
60
  profile.materials.find { |candidate| candidate.channel == material }
43
61
  end
@@ -63,7 +81,7 @@ module Lithop
63
81
  "- Use a #{settings.nozzle_diameter} mm nozzle, #{settings.first_layer_height} mm base layers,\n " \
64
82
  "#{settings.layer_height} mm color layers, and #{upper_height} mm upper White layers."
65
83
  end
66
- private_class_method :profile_material, :calibration_line, :thickness_method,
84
+ private_class_method :model_instruction, :z_part_line, :profile_material, :calibration_line, :thickness_method,
67
85
  :base_layer_description, :layer_height_description
68
86
  end
69
87
  end
@@ -1,13 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lithop
4
+ TileArtifact = Data.define(:tile, :name, :three_mf, :material_model)
5
+
4
6
  ExportArtifacts = Data.define(
5
7
  :stl_name,
6
- :three_mf_name,
8
+ :tile_artifacts,
7
9
  :stl_triangle_count,
8
- :three_mf,
9
- :material_model
10
- )
10
+ :material_model,
11
+ :tile_layout
12
+ ) do
13
+ def three_mf_names = tile_artifacts.map(&:name)
14
+
15
+ def three_mf_name
16
+ three_mf_names.fetch(0) if three_mf_names.one?
17
+ end
18
+
19
+ def three_mf
20
+ results = tile_artifacts.map(&:three_mf)
21
+ counts = results.each_with_object(Hash.new(0)) do |result, totals|
22
+ result.triangle_counts_by_material.each { |material, count| totals[material] += count }
23
+ end
24
+ ThreeMF::Result.new(
25
+ vertex_count: results.sum(&:vertex_count),
26
+ triangle_count: results.sum(&:triangle_count),
27
+ material_count: counts.length,
28
+ object_count: results.sum(&:object_count),
29
+ triangle_counts_by_material: counts.freeze
30
+ )
31
+ end
32
+ end
11
33
 
12
34
  ExportResult = Data.define(
13
35
  :job_id,
@@ -4,14 +4,14 @@ require "json"
4
4
 
5
5
  module Lithop
6
6
  # Writes Bambu Studio material-assignment metadata into a 3MF package.
7
+ # rubocop:disable-next Metrics/ClassLength
7
8
  class ThreeMFBambuMetadata
8
9
  PATH = "Metadata/model_settings.config"
9
- FIRST_MODEL_OBJECT_INDEX = 1
10
10
 
11
- def initialize(archive:, assembly_id:, object_ids:, parts:, profile:, settings: nil)
11
+ def initialize(archive:, object_ids:, assemblies:, parts:, profile:, settings: nil)
12
12
  @archive = archive
13
- @assembly_id = assembly_id
14
13
  @object_ids = object_ids
14
+ @assemblies = assemblies
15
15
  @parts = parts
16
16
  @profile = profile
17
17
  @settings = settings
@@ -20,61 +20,148 @@ module Lithop
20
20
  def write
21
21
  @archive.put_next_entry(PATH)
22
22
  @archive.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<config>\n")
23
- @archive.write(" <object id=\"#{@assembly_id}\">\n")
24
- @archive.write(" <metadata key=\"name\" value=\"Lithop CMYW lithophane\"/>\n")
23
+ @assemblies.each { |assembly| write_object(assembly) }
25
24
  if bambu_project?
26
- @archive.write(" <metadata key=\"extruder\" value=\"1\"/>\n")
25
+ write_plates
26
+ write_assemble if separate_z_plates?
27
+ end
28
+ @archive.write("</config>\n")
29
+ write_project_entries if bambu_project?
30
+ end
31
+
32
+ private
33
+
34
+ def write_object(assembly)
35
+ @archive.write(" <object id=\"#{assembly.id}\">\n")
36
+ @archive.write(" <metadata key=\"name\" value=\"#{assembly.name}\"/>\n")
37
+ if bambu_project?
38
+ @archive.write(" <metadata key=\"extruder\" value=\"#{root_extruder(assembly)}\"/>\n")
27
39
  @archive.write(" <metadata key=\"layer_height\" value=\"#{@settings.layer_height}\"/>\n")
28
40
  @archive.write(" <metadata key=\"sparse_infill_density\" value=\"100%\"/>\n")
29
41
  @archive.write(" <metadata key=\"sparse_infill_pattern\" value=\"zig-zag\"/>\n")
30
42
  end
31
- @object_ids.zip(@parts).each do |object_id, part|
32
- write_part(object_id, part.material)
43
+ part_map = @object_ids.zip(@parts).to_h
44
+ assembly.object_ids.each_with_index do |object_id, volume_id|
45
+ part = part_map.fetch(object_id)
46
+ write_part(object_id, part, assembly, volume_id)
33
47
  end
34
48
  @archive.write(" </object>\n")
35
- write_plate if bambu_project?
36
- @archive.write("</config>\n")
37
- write_project_entries if bambu_project?
38
49
  end
39
50
 
40
- private
51
+ def root_extruder(assembly)
52
+ return 1 if @assemblies.one?
53
+
54
+ part_map = @object_ids.zip(@parts).to_h
55
+ material = part_map.fetch(assembly.object_ids.first).material
56
+ @profile.materials.index { |candidate| candidate.channel == material } + 1
57
+ end
41
58
 
42
- def write_part(object_id, material)
59
+ def write_part(object_id, part, assembly, volume_id)
60
+ material = part.material
43
61
  profile_material = @profile.materials.find { |candidate| candidate.channel == material }
44
62
  extruder = @profile.materials.index(profile_material) + 1
45
- @archive.write(" <part id=\"#{object_id}\" subtype=\"normal_part\">\n")
46
- @archive.write(" <metadata key=\"name\" value=\"#{profile_material.name}\"/>\n")
63
+ uuid = separate_z_plates? ? %( uuid="#{part_uuid(object_id)}") : ""
64
+ @archive.write(" <part id=\"#{object_id}\" subtype=\"normal_part\"#{uuid}>\n")
65
+ @archive.write(" <metadata key=\"name\" value=\"#{part.name || profile_material.name}\"/>\n")
66
+ if separate_z_plates?
67
+ origin = assembly.component_origins.fetch(object_id)
68
+ @archive.write(" <metadata key=\"matrix\" value=\"#{matrix(origin)}\"/>\n")
69
+ @archive.write(" <metadata key=\"source_file\" value=\"lithop.3mf\"/>\n")
70
+ @archive.write(" <metadata key=\"source_object_id\" value=\"#{source_object_id(assembly)}\"/>\n")
71
+ @archive.write(" <metadata key=\"source_volume_id\" value=\"#{volume_id}\"/>\n")
72
+ @archive.write(" <metadata key=\"source_offset_x\" value=\"#{number(origin[0])}\"/>\n")
73
+ @archive.write(" <metadata key=\"source_offset_y\" value=\"#{number(origin[1])}\"/>\n")
74
+ source_z = number(origin[2] - part.build_z_offset)
75
+ @archive.write(" <metadata key=\"source_offset_z\" value=\"#{source_z}\"/>\n")
76
+ end
47
77
  @archive.write(" <metadata key=\"extruder\" value=\"#{extruder}\"/>\n")
48
78
  @archive.write(" </part>\n")
49
79
  end
50
80
 
51
81
  def bambu_project? = @settings&.bambu_project
52
82
 
53
- def write_plate
54
- @archive.write(<<~XML)
83
+ def part_uuid(object_id)
84
+ format("%08x-7f79-4c88-9311-43ae0fc671e7", object_id)
85
+ end
86
+
87
+ def write_plates
88
+ @assemblies.each_with_index { |assembly, index| write_plate(assembly, index + 1) }
89
+ end
90
+
91
+ def write_plate(assembly, plate_id)
92
+ name = @assemblies.one? ? "lithophane" : assembly.name
93
+ @archive.write(<<~XML.chomp)
55
94
  <plate>
56
- <metadata key="plater_id" value="1"/>
57
- <metadata key="plater_name" value="lithophane"/>
95
+ <metadata key="plater_id" value="#{plate_id}"/>
96
+ <metadata key="plater_name" value="#{name}"/>
58
97
  <metadata key="locked" value="false"/>
59
98
  <model_instance>
60
- <metadata key="object_id" value="#{@assembly_id}"/>
99
+ <metadata key="object_id" value="#{assembly.id}"/>
61
100
  <metadata key="instance_id" value="0"/>
62
- </model_instance>
63
- </plate>
64
101
  XML
102
+ @archive.write("\n <metadata key=\"identify_id\" value=\"#{identify_id(plate_id)}\"/>") if separate_z_plates?
103
+ @archive.write("\n </model_instance>\n</plate>\n")
104
+ end
105
+
106
+ def write_assemble
107
+ @archive.write("<assemble>\n")
108
+ @assemblies.each do |assembly|
109
+ @archive.write(
110
+ " <assemble_item object_id=\"#{assembly.id}\" instance_id=\"0\" " \
111
+ "transform=\"1 0 0 0 1 0 0 0 1 0 0 0\" offset=\"0 0 0\"/>\n"
112
+ )
113
+ assembly.object_ids.each_with_index do |object_id, volume_id|
114
+ transform = volume_transform(assembly.component_origins.fetch(object_id))
115
+ @archive.write(
116
+ " <assemble_item object_id=\"#{assembly.id}\" volume_id=\"#{volume_id}\" " \
117
+ "transform=\"#{transform}\"/>\n"
118
+ )
119
+ end
120
+ end
121
+ @archive.write("</assemble>\n")
122
+ end
123
+
124
+ def separate_z_plates? = @settings&.separate_z_parts && @assemblies.length > 1
125
+
126
+ def identify_id(plate_id)
127
+ [218, 253, 276, 287].fetch(plate_id - 1, 287 + (plate_id * 32))
128
+ end
129
+
130
+ def volume_transform(origin)
131
+ "1 0 0 0 1 0 0 0 1 #{origin.map { |value| number(value) }.join(" ")}"
132
+ end
133
+
134
+ def matrix(origin)
135
+ "1 0 0 #{number(origin[0])} 0 1 0 #{number(origin[1])} 0 0 1 #{number(origin[2])} 0 0 0 1"
136
+ end
137
+
138
+ def source_object_id(assembly)
139
+ return 3 if assembly.region == "top_white"
140
+
141
+ assembly.region.delete_prefix("color_region_").to_i - 1
65
142
  end
66
143
 
144
+ def number(value) = format("%.9g", value)
145
+
67
146
  def write_project_entries
68
147
  write_entry("Metadata/layer_config_ranges.xml", layer_ranges_xml)
69
148
  write_entry("Metadata/project_settings.config", "#{JSON.pretty_generate(project_settings)}\n")
70
149
  end
71
150
 
72
151
  def layer_ranges_xml
152
+ object_id = @assemblies.length
153
+ min_z = @settings.top_white_start
154
+ max_z = @settings.maximum_thickness
155
+ if @settings.separate_z_parts
156
+ color_end = @settings.layer_structure.color_region_end
157
+ min_z = (min_z - color_end).round(9)
158
+ max_z = (max_z - color_end).round(9)
159
+ end
73
160
  <<~XML
74
161
  <?xml version="1.0" encoding="utf-8"?>
75
162
  <objects>
76
- <object id="#{FIRST_MODEL_OBJECT_INDEX}">
77
- <range min_z="#{@settings.top_white_start}" max_z="#{@settings.maximum_thickness}">
163
+ <object id="#{object_id}">
164
+ <range min_z="#{min_z}" max_z="#{max_z}">
78
165
  <option opt_key="extruder">4</option>
79
166
  <option opt_key="layer_height">#{@settings.top_layer_height}</option>
80
167
  </range>
@@ -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,227 @@ 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
81
  vertex_count += mesh.vertices.length
65
- triangle_counts[part.material] = mesh.triangle_count
66
- object_id
82
+ triangle_counts[part.material] = triangle_counts.fetch(part.material, 0) + mesh.triangle_count
67
83
  end
68
- result = Result.new(
84
+ Result.new(
69
85
  vertex_count:,
70
86
  triangle_count: triangle_counts.values.sum,
71
87
  triangle_counts_by_material: triangle_counts.freeze
72
88
  )
73
- [object_ids, result]
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
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
+ assembly.object_ids.each do |object_id|
110
+ part = parts_by_object_id.fetch(object_id)
111
+ mesh = @instrumentation.measure(:three_mf_indexing) do
112
+ ThreeMFMesh.new(triangles: part.triangles)
113
+ end
114
+ center = mesh_center(mesh)
115
+ assembly.component_origins[object_id] = component_origin(
116
+ center, assembly.component_origins.fetch(object_id)
117
+ )
118
+ ThreeMFPartWriter.new(
119
+ archive: @archive, profile: @profile, production: true, origin: center
120
+ ).write(object_id, part, mesh)
121
+ part.triangles.release_cache! if part.triangles.respond_to?(:release_cache!)
122
+ vertex_count += mesh.vertices.length
123
+ triangle_counts[part.material] = triangle_counts.fetch(part.material, 0) + mesh.triangle_count
124
+ end
125
+ @archive.write(" </resources>\n <build/>\n</model>\n")
126
+ end
127
+ end
128
+ Result.new(
129
+ vertex_count:,
130
+ triangle_count: triangle_counts.values.sum,
131
+ triangle_counts_by_material: triangle_counts.freeze
132
+ )
133
+ end
134
+
135
+ def write_root_model(assemblies)
136
+ @archive.put_next_entry(MODEL_PATH)
137
+ measure_xml do
138
+ @archive.write(bambu_multi_plate_model_header)
139
+ assemblies.each_with_index { |assembly, index| write_assembly(assembly, index + 1) }
140
+ write_build(assemblies)
141
+ end
142
+ end
143
+
144
+ def write_model_relationships(assemblies)
145
+ @archive.put_next_entry("3D/_rels/3dmodel.model.rels")
146
+ @archive.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
147
+ @archive.write("<Relationships xmlns=\"#{ThreeMF::RELATIONSHIP_NAMESPACE}\">\n")
148
+ assemblies.each_with_index do |_assembly, index|
149
+ @archive.write(
150
+ " <Relationship Target=\"/3D/Objects/object_#{index + 1}.model\" Id=\"rel-#{index + 1}\" " \
151
+ "Type=\"#{ThreeMF::MODEL_RELATIONSHIP}\"/>\n"
152
+ )
153
+ end
154
+ @archive.write("</Relationships>\n")
155
+ end
156
+
157
+ def write_bambu_metadata(object_ids, assemblies)
158
+ ThreeMFBambuMetadata.new(
159
+ archive: @archive,
160
+ object_ids:,
161
+ assemblies:,
162
+ parts: @parts,
163
+ profile: @profile,
164
+ settings: @settings
165
+ ).write
74
166
  end
75
167
 
76
168
  def measure_xml(&)
77
169
  @instrumentation.measure(:three_mf_xml_zip, &)
78
170
  end
79
171
 
80
- def write_assembly(assembly_id, object_ids)
81
- @archive.write(" <object id=\"#{assembly_id}\" type=\"model\" name=\"Lithop CMYW lithophane\">\n")
172
+ def build_assemblies(object_ids)
173
+ groups = if separate_z_plates?
174
+ object_ids.zip(@parts).group_by { |_object_id, part| part.region }
175
+ else
176
+ { nil => object_ids.zip(@parts) }
177
+ end
178
+ groups.map do |region, entries|
179
+ Assembly.new(
180
+ id: entries.map(&:first).max + 1,
181
+ name: assembly_name(region),
182
+ region:,
183
+ object_ids: entries.map(&:first).freeze,
184
+ component_origins: entries.to_h { |object_id, part| [object_id, [0.0, 0.0, part.build_z_offset]] }
185
+ )
186
+ end.freeze
187
+ end
188
+
189
+ def separate_z_plates?
190
+ @settings&.separate_z_parts && @parts.all?(&:region)
191
+ end
192
+
193
+ def bambu_multi_plate_project? = @settings&.bambu_project && separate_z_plates?
194
+
195
+ def assembly_name(region)
196
+ return "Lithop CMYW lithophane" unless region
197
+ return "Top White" if region == "top_white"
198
+
199
+ region.sub("color_region_", "Color region ")
200
+ end
201
+
202
+ def write_assembly(assembly, submodel_index = nil)
203
+ uuid = submodel_index ? production_uuid(assembly.id) : ""
204
+ @archive.write(" <object id=\"#{assembly.id}\"#{uuid} type=\"model\" name=\"#{assembly.name}\">\n")
82
205
  @archive.write(" <components>\n")
83
- object_ids.each { |object_id| @archive.write(" <component objectid=\"#{object_id}\"/>\n") }
206
+ assembly.object_ids.each_with_index do |object_id, index|
207
+ transform = component_transform(assembly.component_origins.fetch(object_id))
208
+ path = submodel_index ? %( p:path="/3D/Objects/object_#{submodel_index}.model") : ""
209
+ uuid = submodel_index ? production_uuid((assembly.id * 1000) + index + 1) : ""
210
+ @archive.write(" <component#{path} objectid=\"#{object_id}\"#{uuid}#{transform}/>\n")
211
+ end
84
212
  @archive.write(" </components>\n </object>\n")
85
213
  end
86
214
 
215
+ def component_transform(origin)
216
+ return "" if origin.all?(&:zero?)
217
+
218
+ %( transform="1 0 0 0 1 0 0 0 1 #{origin.map { |value| format_number(value) }.join(" ")}")
219
+ end
220
+
221
+ def format_number(number) = format("%.9g", number)
222
+
223
+ def write_build(assemblies)
224
+ @archive.write(" </resources>\n <build>\n")
225
+ assemblies.each do |assembly|
226
+ attributes = if bambu_multi_plate_project?
227
+ %(#{build_uuid(assembly.id)} transform="#{build_transform(assembly)}" printable="1")
228
+ else
229
+ ""
230
+ end
231
+ @archive.write(" <item objectid=\"#{assembly.id}\"#{attributes}/>\n")
232
+ end
233
+ @archive.write(" </build>\n</model>\n")
234
+ end
235
+
236
+ def production_uuid(identifier)
237
+ %( p:UUID="#{format("%08x", identifier)}-61cb-4c03-9d28-80fed5dfa1dc")
238
+ end
239
+
240
+ def build_uuid(identifier)
241
+ %( p:UUID="#{format("%08x", identifier)}-b1ec-4553-aec9-835e5b724bb4")
242
+ end
243
+
244
+ def build_transform(assembly)
245
+ x, y = [
246
+ [48.0, 30.0],
247
+ [300.160253, 37.8003226],
248
+ [52.2484968, -205.366274],
249
+ [288.0, -210.0]
250
+ ].fetch(plate_index(assembly))
251
+ "1 0 0 0 1 0 0 0 1 #{format_number(x)} #{format_number(y)} 0"
252
+ end
253
+
254
+ def mesh_center(mesh)
255
+ axes = mesh.vertices.transpose
256
+ axes.map { |values| (values.min + values.max) / 2.0 }
257
+ end
258
+
259
+ def component_origin(center, base_origin)
260
+ [center[0], center[1], center[2] + base_origin[2]]
261
+ end
262
+
263
+ def plate_index(assembly)
264
+ return 3 if assembly.region == "top_white"
265
+
266
+ assembly.region.delete_prefix("color_region_").to_i - 1
267
+ end
268
+
87
269
  def model_header
270
+ return bambu_multi_plate_model_header if bambu_multi_plate_project?
271
+
272
+ inline_model_header
273
+ end
274
+
275
+ def inline_model_header
88
276
  <<~XML
89
277
  <?xml version="1.0" encoding="UTF-8"?>
90
278
  <model unit="millimeter" xml:lang="en-US" requiredextensions="m"
@@ -101,6 +289,40 @@ module Lithop
101
289
  XML
102
290
  end
103
291
 
292
+ def bambu_multi_plate_model_header
293
+ <<~XML
294
+ <?xml version="1.0" encoding="UTF-8"?>
295
+ <model unit="millimeter" xml:lang="en-US" requiredextensions="p"
296
+ xmlns="#{ThreeMF::CORE_NAMESPACE}"
297
+ xmlns:BambuStudio="#{BAMBU_STUDIO_NAMESPACE}"
298
+ xmlns:p="#{PRODUCTION_NAMESPACE}"
299
+ xmlns:lithop="#{CUSTOM_NAMESPACE}">
300
+ <metadata name="Title">Lithop CMYW lithophane</metadata>
301
+ <metadata name="Application">#{BAMBU_STUDIO_APPLICATION}</metadata>
302
+ <metadata name="BambuStudio:3mfVersion">1</metadata>
303
+ <metadata name="lithop:Application" preserve="1">Lithop #{Lithop::VERSION}</metadata>
304
+ <metadata name="Description">Physical CMY subtractive layers with variable White luminance thickness.</metadata>
305
+ <metadata name="lithop:FilamentBundle" preserve="1">#{@profile.name}</metadata>
306
+ <metadata name="lithop:FilamentProduct" preserve="1">#{@profile.product_url}</metadata>
307
+ <metadata name="lithop:PrintingGuide" preserve="1">#{@profile.reference_url}</metadata>
308
+ <resources>
309
+ XML
310
+ end
311
+
312
+ def submodel_header
313
+ <<~XML
314
+ <?xml version="1.0" encoding="UTF-8"?>
315
+ <model unit="millimeter" xml:lang="en-US" requiredextensions="m p"
316
+ xmlns="#{ThreeMF::CORE_NAMESPACE}"
317
+ xmlns:m="#{ThreeMF::MATERIAL_NAMESPACE}"
318
+ xmlns:BambuStudio="#{BAMBU_STUDIO_NAMESPACE}"
319
+ xmlns:p="#{PRODUCTION_NAMESPACE}"
320
+ xmlns:lithop="#{CUSTOM_NAMESPACE}">
321
+ <metadata name="BambuStudio:3mfVersion">1</metadata>
322
+ <resources>
323
+ XML
324
+ end
325
+
104
326
  def material_xml(material)
105
327
  name = "#{@profile.manufacturer} #{material.name} (#{material.color_code})"
106
328
  <<~XML
@@ -3,18 +3,23 @@
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
+ def initialize(archive:, profile:, production: false, origin: nil)
7
7
  @archive = archive
8
8
  @profile = profile
9
+ @production = production
10
+ @origin = origin || [0.0, 0.0, 0.0]
9
11
  end
10
12
 
11
- def write(object_id, material, mesh)
13
+ def write(object_id, part, mesh)
14
+ material = part.material
12
15
  material_index = @profile.materials.index { |candidate| candidate.channel == material }
13
- name = @profile.materials.fetch(material_index).name
16
+ name = part.name || @profile.materials.fetch(material_index).name
17
+ region = part.region ? " lithop:region=\"#{part.region}\"" : ""
18
+ uuid = @production ? %( p:UUID="00000000-0000-4000-8000-#{format("%012d", object_id)}") : ""
14
19
  @archive.write(
15
- " <object id=\"#{object_id}\" type=\"model\" name=\"#{name}\" " \
20
+ " <object id=\"#{object_id}\"#{uuid} type=\"model\" name=\"#{name}\" " \
16
21
  "pid=\"#{ThreeMFModelWriter::COLOR_GROUP_ID}\" pindex=\"#{material_index}\" " \
17
- "lithop:channel=\"#{material}\">\n"
22
+ "lithop:channel=\"#{material}\"#{region}>\n"
18
23
  )
19
24
  @archive.write(" <mesh>\n <vertices>\n")
20
25
  write_vertices(mesh)
@@ -27,7 +32,10 @@ module Lithop
27
32
 
28
33
  def write_vertices(mesh)
29
34
  mesh.vertices.each do |x, y, z|
30
- @archive.write(" <vertex x=\"#{number(x)}\" y=\"#{number(y)}\" z=\"#{number(z)}\"/>\n")
35
+ @archive.write(
36
+ " <vertex x=\"#{number(x - @origin[0])}\" y=\"#{number(y - @origin[1])}\" " \
37
+ "z=\"#{number(z - @origin[2])}\"/>\n"
38
+ )
31
39
  end
32
40
  end
33
41