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
@@ -15,35 +15,49 @@ module Lithop
15
15
  # rubocop:disable-next Metrics/ParameterLists
16
16
  def initialize(
17
17
  settings:, project_dir:, input_settings_path: nil, calibration: nil, progress: nil,
18
- instrumentation: nil, clock: Time, random: SecureRandom
18
+ job_progress: nil, instrumentation: nil, clock: Time, random: SecureRandom, job_id: nil
19
19
  )
20
20
  @settings = settings
21
21
  @project_dir = File.expand_path(project_dir)
22
22
  @input_settings_path = input_settings_path
23
23
  @calibration = calibration
24
24
  @progress = progress
25
+ @job_progress = job_progress
25
26
  @clock = clock
26
27
  @random = random
27
- @generation = ExportGeneration.new(
28
- settings:, input_settings_path:, calibration:, progress:, instrumentation:
29
- )
28
+ @instrumentation = instrumentation
29
+ @job_id = job_id
30
30
  end
31
31
 
32
32
  def call(input_path)
33
33
  source = File.expand_path(input_path)
34
- job_id = "#{@clock.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{@random.hex(4)}"
35
- destination, result = JobDirectory.new(project_dir: @project_dir, job_id:).call do |directory|
36
- @generation.call(source:, directory:, job_id:)
37
- end
38
- Result.new(
39
- job_id:,
40
- path: destination,
41
- files: result.files,
42
- triangle_count: result.triangle_count,
43
- three_mf_triangle_count: result.three_mf_triangle_count,
44
- material_count: result.material_count,
45
- object_count: result.object_count
34
+ job_id = @job_id || "#{@clock.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{@random.hex(4)}"
35
+ execution = JobExecution.new(
36
+ project_dir: @project_dir, job_id:, progress: @job_progress, clock: @clock
46
37
  )
38
+ execution.call do |job|
39
+ generation = ExportGeneration.new(
40
+ settings: @settings,
41
+ input_settings_path: @input_settings_path,
42
+ calibration: @calibration,
43
+ progress: @progress,
44
+ job_progress: job.method(:progress),
45
+ instrumentation: @instrumentation
46
+ )
47
+ destination, generated = JobDirectory.new(project_dir: @project_dir, job_id:).call do |directory|
48
+ generation.call(source:, directory:, job_id:).tap { job.checkpoint! }
49
+ end
50
+ job.publication_committed!
51
+ Result.new(
52
+ job_id:,
53
+ path: destination,
54
+ files: generated.files,
55
+ triangle_count: generated.triangle_count,
56
+ three_mf_triangle_count: generated.three_mf_triangle_count,
57
+ material_count: generated.material_count,
58
+ object_count: generated.object_count
59
+ )
60
+ end
47
61
  end
48
62
  end
49
63
  end
@@ -5,6 +5,7 @@ require "json"
5
5
 
6
6
  module Lithop
7
7
  # Builds and writes the resolved settings and export manifest.
8
+ # rubocop:disable-next Metrics/ClassLength
8
9
  class ExportMetadata
9
10
  def initialize(settings:, input_settings_path:, calibration:)
10
11
  @settings = settings
@@ -29,8 +30,8 @@ module Lithop
29
30
 
30
31
  private
31
32
 
33
+ # rubocop:disable-next Metrics/MethodLength
32
34
  def manifest_for(source, job_id, artifacts)
33
- materials = material_names(used_materials(artifacts))
34
35
  {
35
36
  schema_version: 1,
36
37
  job_id:,
@@ -49,9 +50,21 @@ module Lithop
49
50
  },
50
51
  input_sha256: Digest::SHA256.file(source).hexdigest,
51
52
  filament_profile: FilamentProfile.default.to_h,
53
+ tile_layout: tile_layout_metadata(artifacts),
54
+ tiles: tile_metadata(artifacts),
52
55
  parts: [
53
56
  { file: artifacts.stl_name, material: "White", role: "monochrome_reference" },
54
- { file: artifacts.three_mf_name, materials:, role: "cmyw_lithophane" }
57
+ *artifacts.tile_artifacts.map do |artifact|
58
+ tile_materials = Palette::MATERIALS.select do |material|
59
+ artifact.material_model.materials.include?(material)
60
+ end
61
+ {
62
+ file: artifact.name,
63
+ tile_id: artifact.tile.id,
64
+ materials: material_names(tile_materials),
65
+ role: artifacts.tile_artifacts.one? ? "cmyw_lithophane" : "cmyw_lithophane_tile"
66
+ }
67
+ end
55
68
  ],
56
69
  color_model: @color_metadata.call(artifacts),
57
70
  files: artifact_files(artifacts),
@@ -62,14 +75,51 @@ module Lithop
62
75
 
63
76
  def artifact_files(artifacts)
64
77
  files = [
65
- artifacts.stl_name, artifacts.three_mf_name, "settings.json", "manifest.json",
66
- "preview.png", "transmission.png", "thickness.png",
78
+ artifacts.stl_name, *artifacts.three_mf_names, "settings.json", "manifest.json",
79
+ "corrected-input.png", "preview.png", "transmission.png", "thickness.png",
67
80
  *Palette::MATERIALS.map { |material| "#{material}-mask.png" }, "PRINT_README.md"
68
81
  ]
82
+ if artifacts.tile_artifacts.length > 1
83
+ files.concat(artifacts.tile_artifacts.map { |artifact| ExportPreviews.tile_preview_name(artifact) })
84
+ files << "tile-layout.svg"
85
+ end
69
86
  files << "calibration-profile.json" if @calibration.profile
70
87
  files
71
88
  end
72
89
 
90
+ def tile_layout_metadata(artifacts)
91
+ {
92
+ requested: @settings.tile_layout,
93
+ tile_count: @settings.tile_count,
94
+ rows: artifacts.tile_layout.rows,
95
+ columns: artifacts.tile_layout.columns,
96
+ order: "left_to_right_then_top_to_bottom",
97
+ local_origin: "xy_min",
98
+ assembled_image_top: "+Y"
99
+ }
100
+ end
101
+
102
+ def tile_metadata(artifacts)
103
+ artifacts.tile_artifacts.map do |artifact|
104
+ tile = artifact.tile
105
+ used = Palette::MATERIALS.select { |material| artifact.material_model.materials.include?(material) }
106
+ metadata = {
107
+ id: tile.id,
108
+ file: artifact.name,
109
+ row: tile.row,
110
+ column: tile.column,
111
+ offset_mm: tile.offset,
112
+ bounds_mm: tile.bounds,
113
+ dimensions_mm: { width: tile.width, height: tile.height, depth: @settings.effective_maximum_thickness },
114
+ materials: material_names(used),
115
+ unused_materials: material_names(Palette::MATERIALS - used),
116
+ triangle_count: artifact.three_mf.triangle_count
117
+ }
118
+ metadata[:preview_file] = ExportPreviews.tile_preview_name(artifact) unless artifacts.tile_artifacts.one?
119
+ metadata
120
+ end
121
+ end
122
+
73
123
  def used_materials(artifacts)
74
124
  Palette::MATERIALS.select { |material| artifacts.material_model.materials.include?(material) }
75
125
  end
@@ -1,6 +1,103 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lithop
4
+ # Splits the reference White carrier and CMY pigments into closed Z-region parts.
5
+ class ZSeparatedMaterialMeshPlan
6
+ Entry = Data.define(:material, :geometry, :name, :region, :build_z_offset)
7
+
8
+ def initialize(model:, base_geometry:, top_geometry:, settings:, maximum_triangles:, limit_message:)
9
+ @model = model
10
+ @base_geometry = base_geometry
11
+ @top_geometry = top_geometry
12
+ @settings = settings
13
+ @maximum_triangles = maximum_triangles
14
+ @limit_message = limit_message
15
+ end
16
+
17
+ def call
18
+ entries = build_entries
19
+ entry_counts = entries.map { |entry| triangle_count(entry.geometry) }
20
+ total = entry_counts.sum
21
+ raise ConfigurationError, @limit_message.call(total) if total > @maximum_triangles
22
+
23
+ MaterialMeshPlan::Result.new(
24
+ parts: entries.map do |entry|
25
+ ThreeMF::Part.new(
26
+ material: entry.material, triangles: triangle_source(entry.geometry),
27
+ name: entry.name, region: entry.region, build_z_offset: entry.build_z_offset
28
+ )
29
+ end,
30
+ triangle_count: total,
31
+ triangle_counts_by_material: counts_by_material(entries, entry_counts)
32
+ )
33
+ end
34
+
35
+ private
36
+
37
+ def build_entries
38
+ entries = []
39
+ color_ranges.each_with_index do |range, index|
40
+ region_model = LayerRegionModel.new(model: @model, layer_range: range)
41
+ region = "color_region_#{index + 1}"
42
+ build_z_offset = @settings.layer_structure.base_white_thickness - region_model.z_boundaries.first
43
+ entries << Entry.new(
44
+ material: :white, geometry: @base_geometry, name: "Color region #{index + 1} Base White",
45
+ region:, build_z_offset: 0.0
46
+ )
47
+ entries << Entry.new(
48
+ material: :white,
49
+ geometry: color_region_white_geometry(region_model),
50
+ name: "Color region #{index + 1} White",
51
+ region:,
52
+ build_z_offset:
53
+ )
54
+ Palette::PIGMENTS.each do |material|
55
+ next unless region_model.materials.include?(material)
56
+
57
+ entries << Entry.new(
58
+ material:,
59
+ geometry: ColumnGeometry.new(model: region_model, material:),
60
+ name: "Color region #{index + 1} #{material.to_s.capitalize}",
61
+ region:,
62
+ build_z_offset:
63
+ )
64
+ end
65
+ end
66
+ entries << Entry.new(
67
+ material: :white, geometry: @top_geometry, name: "Top White",
68
+ region: "top_white", build_z_offset: -@settings.layer_structure.color_region_end
69
+ )
70
+ entries
71
+ end
72
+
73
+ def color_ranges
74
+ @settings.layer_structure.color_region_layer_ranges(@settings.color_region_parts)
75
+ end
76
+
77
+ def color_region_white_geometry(model)
78
+ BoxGeometry.new(
79
+ width: model.x_boundaries.last,
80
+ height: model.y_boundaries.last,
81
+ z_min: model.z_boundaries.first,
82
+ z_max: model.z_boundaries.last
83
+ )
84
+ end
85
+
86
+ def triangle_count(geometry)
87
+ geometry.respond_to?(:triangle_count) ? geometry.triangle_count : geometry.each_triangle.count
88
+ end
89
+
90
+ def triangle_source(geometry)
91
+ geometry.is_a?(ColumnGeometry) ? geometry : geometry.each_triangle
92
+ end
93
+
94
+ def counts_by_material(entries, entry_counts)
95
+ entries.zip(entry_counts).each_with_object(Hash.new(0)) do |(entry, count), totals|
96
+ totals[entry.material] += count
97
+ end.freeze
98
+ end
99
+ end
100
+
4
101
  # Builds the reference-style overlapping White height field and contiguous CMY columns.
5
102
  class ReferenceMaterialMeshPlan
6
103
  def initialize(model:, white_geometry:, maximum_triangles:, limit_message:)
@@ -5,7 +5,7 @@ module Lithop
5
5
  module ExportPreviews
6
6
  module_function
7
7
 
8
- def write(directory, model:, settings:, white_heights: nil)
8
+ def write(directory, model:, settings:, white_heights: nil, corrected_input: nil)
9
9
  heights = white_heights && cell_heights(white_heights, settings.columns, settings.rows)
10
10
  options = {
11
11
  model:,
@@ -19,8 +19,32 @@ module Lithop
19
19
  Palette::MATERIALS.each do |material|
20
20
  Preview.write_mask(File.join(directory, "#{material}-mask.png"), model:, material:)
21
21
  end
22
+ File.binwrite(File.join(directory, "corrected-input.png"), corrected_input) if corrected_input
22
23
  end
23
24
 
25
+ def write_tiles(directory, tile_artifacts:, settings:, white_heights:)
26
+ return if tile_artifacts.one?
27
+
28
+ heights = cell_heights(white_heights, settings.columns, settings.rows)
29
+ tile_artifacts.each do |artifact|
30
+ tile = artifact.tile
31
+ tile_heights = (tile.row_start...tile.row_end).flat_map do |row|
32
+ offset = (row * settings.columns) + tile.column_start
33
+ heights.slice(offset, artifact.material_model.columns)
34
+ end
35
+ Preview.write_material(
36
+ File.join(directory, tile_preview_name(artifact)),
37
+ model: artifact.material_model,
38
+ minimum: settings.effective_minimum_thickness,
39
+ maximum: settings.effective_maximum_thickness,
40
+ heights: tile_heights,
41
+ transmission_gamma: 0.5
42
+ )
43
+ end
44
+ end
45
+
46
+ def tile_preview_name(artifact) = artifact.name.sub(/\.3mf\z/, "-preview.png")
47
+
24
48
  def cell_heights(vertex_heights, columns, rows)
25
49
  stride = columns + 1
26
50
  Array.new(columns * rows) do |index|
@@ -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>