lithop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/RELEASING.md +63 -0
  6. data/bin/lithop +6 -0
  7. data/docs/README.md +44 -0
  8. data/docs/artifacts.md +122 -0
  9. data/docs/bambu-studio.md +93 -0
  10. data/docs/calibration.md +126 -0
  11. data/docs/color-model.md +114 -0
  12. data/docs/command.md +182 -0
  13. data/docs/config.md +201 -0
  14. data/docs/getting-started.md +98 -0
  15. data/docs/troubleshooting.md +112 -0
  16. data/lib/lithop/calibration/export.rb +102 -0
  17. data/lib/lithop/calibration/profile.rb +127 -0
  18. data/lib/lithop/calibration/profile_measurements.rb +82 -0
  19. data/lib/lithop/calibration/recipe_selector.rb +54 -0
  20. data/lib/lithop/calibration/target.rb +135 -0
  21. data/lib/lithop/calibration/target_svg.rb +89 -0
  22. data/lib/lithop/calibration/thickness_selector.rb +35 -0
  23. data/lib/lithop/cli/calibration.rb +40 -0
  24. data/lib/lithop/cli/image_options.rb +69 -0
  25. data/lib/lithop/cli/options.rb +95 -0
  26. data/lib/lithop/cli/settings.rb +34 -0
  27. data/lib/lithop/cli.rb +102 -0
  28. data/lib/lithop/export/calibration.rb +46 -0
  29. data/lib/lithop/export/color_metadata.rb +80 -0
  30. data/lib/lithop/export/generation.rb +122 -0
  31. data/lib/lithop/export/job.rb +49 -0
  32. data/lib/lithop/export/metadata.rb +85 -0
  33. data/lib/lithop/export/planning.rb +78 -0
  34. data/lib/lithop/export/previews.rb +35 -0
  35. data/lib/lithop/export/print_readme.rb +69 -0
  36. data/lib/lithop/export/result.rb +21 -0
  37. data/lib/lithop/formats/stl.rb +60 -0
  38. data/lib/lithop/formats/three_mf/bambu_metadata.rb +105 -0
  39. data/lib/lithop/formats/three_mf/mesh.rb +55 -0
  40. data/lib/lithop/formats/three_mf/model_writer.rb +114 -0
  41. data/lib/lithop/formats/three_mf/part_writer.rb +49 -0
  42. data/lib/lithop/formats/three_mf.rb +128 -0
  43. data/lib/lithop/image/pipeline.rb +175 -0
  44. data/lib/lithop/image/placement.rb +54 -0
  45. data/lib/lithop/image/preview.rb +63 -0
  46. data/lib/lithop/image/svg_loader.rb +218 -0
  47. data/lib/lithop/instrumentation.rb +22 -0
  48. data/lib/lithop/job_directory.rb +55 -0
  49. data/lib/lithop/layer_structure.rb +134 -0
  50. data/lib/lithop/mesh/column_geometry.rb +161 -0
  51. data/lib/lithop/mesh/column_topology.rb +262 -0
  52. data/lib/lithop/mesh/geometry.rb +116 -0
  53. data/lib/lithop/mesh/validator.rb +123 -0
  54. data/lib/lithop/mesh/voxel_geometry.rb +113 -0
  55. data/lib/lithop/mesh/voxel_topology.rb +79 -0
  56. data/lib/lithop/model/filament_profile.rb +79 -0
  57. data/lib/lithop/model/material_display_map.rb +61 -0
  58. data/lib/lithop/model/material_model.rb +106 -0
  59. data/lib/lithop/model/palette.rb +101 -0
  60. data/lib/lithop/model/thickness.rb +45 -0
  61. data/lib/lithop/reference_three_mf.rb +119 -0
  62. data/lib/lithop/sampling_grid.rb +41 -0
  63. data/lib/lithop/settings.rb +295 -0
  64. data/lib/lithop/settings_file.rb +56 -0
  65. data/lib/lithop/version.rb +5 -0
  66. data/lib/lithop.rb +55 -0
  67. data/sig/lithop.rbs +291 -0
  68. metadata +155 -0
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Runs the image, mesh, preview, and metadata stages for one regular export.
5
+ # rubocop:disable-next Metrics/ClassLength
6
+ class ExportGeneration
7
+ Result = Data.define(:files, :triangle_count, :three_mf_triangle_count, :material_count, :object_count)
8
+
9
+ def initialize(settings:, input_settings_path:, calibration:, progress:, instrumentation:)
10
+ @settings = settings
11
+ @input_settings_path = input_settings_path
12
+ @calibration_context = ExportCalibration.new(settings:, input: calibration)
13
+ @progress = progress
14
+ @instrumentation = Instrumentation.new(instrumentation)
15
+ @metadata = ExportMetadata.new(
16
+ settings:, input_settings_path:, calibration: @calibration_context
17
+ )
18
+ end
19
+
20
+ def call(source:, directory:, job_id:)
21
+ image = @instrumentation.measure(:image_processing) { ImagePipeline.new(@settings).call(source) }
22
+ white_heights = @instrumentation.measure(:height_selection) do
23
+ @calibration_context.heights(image.white_densities)
24
+ end
25
+ geometry = white_geometry(white_heights)
26
+ material_model = @instrumentation.measure(:material_model) do
27
+ MaterialModel.new(
28
+ cmys: image.cmys, cell_cmys: image.color_cells,
29
+ heights: white_heights, settings: @settings, palette: @calibration_context.palette
30
+ )
31
+ end
32
+ mesh_plan = @instrumentation.measure(:triangle_planning) { material_mesh_plan(material_model, geometry) }
33
+ report_estimate(mesh_plan)
34
+ artifacts = write_artifacts(directory, geometry, material_model, mesh_plan, white_heights)
35
+ write_supporting_files(directory, source, job_id, artifacts)
36
+ result_for(directory, artifacts)
37
+ end
38
+
39
+ private
40
+
41
+ def white_geometry(heights)
42
+ Geometry.new(
43
+ heights:,
44
+ columns: @settings.columns,
45
+ rows: @settings.rows,
46
+ x_boundaries: @settings.x_boundaries,
47
+ y_boundaries: @settings.y_boundaries,
48
+ optimize_bottom: true
49
+ )
50
+ end
51
+
52
+ def material_mesh_plan(material_model, white_geometry)
53
+ ReferenceMaterialMeshPlan.new(
54
+ model: material_model,
55
+ white_geometry:,
56
+ maximum_triangles: @settings.maximum_triangles,
57
+ limit_message: ->(count) { @settings.triangle_limit_message("physical CMYW mesh", count) }
58
+ ).call
59
+ end
60
+
61
+ def write_artifacts(directory, geometry, material_model, mesh_plan, white_heights)
62
+ stl_name = "cmyk_white.stl"
63
+ triangle_count = @instrumentation.measure(:stl_write) do
64
+ STL.write(File.join(directory, stl_name), geometry.each_triangle)
65
+ end
66
+ three_mf_name = "lithop.3mf"
67
+ three_mf = @instrumentation.measure(:three_mf_write) do
68
+ ThreeMF.write(
69
+ File.join(directory, three_mf_name), parts: mesh_plan.parts, instrumentation: @instrumentation,
70
+ settings: @settings
71
+ )
72
+ end
73
+ mesh_plan.validate!(three_mf)
74
+ @instrumentation.measure(:preview_write) do
75
+ ExportPreviews.write(directory, model: material_model, settings: @settings, white_heights:)
76
+ end
77
+ ExportArtifacts.new(
78
+ stl_name:,
79
+ three_mf_name:,
80
+ stl_triangle_count: triangle_count,
81
+ three_mf:,
82
+ material_model:
83
+ )
84
+ end
85
+
86
+ def write_supporting_files(directory, source, job_id, artifacts)
87
+ @instrumentation.measure(:metadata_write) do
88
+ @calibration_context.write(directory)
89
+ @metadata.write(directory:, source:, job_id:, artifacts:)
90
+ PrintReadme.write(
91
+ File.join(directory, "PRINT_README.md"),
92
+ settings: @settings,
93
+ material_model: artifacts.material_model,
94
+ calibration_profile: @calibration_context.profile
95
+ )
96
+ end
97
+ end
98
+
99
+ def report_estimate(plan)
100
+ return unless @progress
101
+
102
+ @progress.call(
103
+ Export::Estimate.new(
104
+ stl_triangle_count: @settings.estimated_triangles,
105
+ three_mf_triangle_count: plan.triangle_count,
106
+ triangle_counts_by_material: plan.triangle_counts_by_material
107
+ )
108
+ )
109
+ end
110
+
111
+ def result_for(directory, artifacts)
112
+ three_mf = artifacts.three_mf
113
+ Result.new(
114
+ files: Dir.children(directory).sort,
115
+ triangle_count: artifacts.stl_triangle_count,
116
+ three_mf_triangle_count: three_mf.triangle_count,
117
+ material_count: three_mf.material_count,
118
+ object_count: three_mf.object_count
119
+ )
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Lithop
7
+ # Coordinates image processing, mesh generation, and artifact export.
8
+ class Export
9
+ CalibrationInput = Data.define(:profile, :path)
10
+ Estimate = Data.define(:stl_triangle_count, :three_mf_triangle_count, :triangle_counts_by_material)
11
+
12
+ Result = ExportResult
13
+
14
+ # Dependency injection keeps job IDs testable; generation options remain explicit keywords.
15
+ # rubocop:disable-next Metrics/ParameterLists
16
+ def initialize(
17
+ settings:, project_dir:, input_settings_path: nil, calibration: nil, progress: nil,
18
+ instrumentation: nil, clock: Time, random: SecureRandom
19
+ )
20
+ @settings = settings
21
+ @project_dir = File.expand_path(project_dir)
22
+ @input_settings_path = input_settings_path
23
+ @calibration = calibration
24
+ @progress = progress
25
+ @clock = clock
26
+ @random = random
27
+ @generation = ExportGeneration.new(
28
+ settings:, input_settings_path:, calibration:, progress:, instrumentation:
29
+ )
30
+ end
31
+
32
+ def call(input_path)
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
46
+ )
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Lithop
7
+ # Builds and writes the resolved settings and export manifest.
8
+ class ExportMetadata
9
+ def initialize(settings:, input_settings_path:, calibration:)
10
+ @settings = settings
11
+ @input_settings_path = input_settings_path
12
+ @calibration = calibration
13
+ @color_metadata = ExportColorMetadata.new(settings:, calibration:)
14
+ end
15
+
16
+ def write(directory:, source:, job_id:, artifacts:)
17
+ resolved_settings = @settings.to_h.merge(
18
+ schema_version: 1,
19
+ generator_version: Lithop::VERSION,
20
+ input_path: source,
21
+ input_settings_path: @input_settings_path,
22
+ calibration_profile_id: @calibration.profile&.profile_id,
23
+ calibration_profile_path: @calibration.path
24
+ )
25
+ manifest = manifest_for(source, job_id, artifacts)
26
+ write_json(File.join(directory, "settings.json"), resolved_settings)
27
+ write_json(File.join(directory, "manifest.json"), manifest)
28
+ end
29
+
30
+ private
31
+
32
+ def manifest_for(source, job_id, artifacts)
33
+ materials = material_names(used_materials(artifacts))
34
+ {
35
+ schema_version: 1,
36
+ job_id:,
37
+ units: "mm",
38
+ viewing_direction: "+Z",
39
+ image_top: "+Y",
40
+ dimensions_mm: {
41
+ width: @settings.width,
42
+ height: @settings.height,
43
+ depth: @settings.effective_maximum_thickness
44
+ },
45
+ print_settings: {
46
+ nozzle_diameter_mm: @settings.nozzle_diameter,
47
+ first_layer_height_mm: @settings.first_layer_height,
48
+ layer_height_mm: @settings.layer_height
49
+ },
50
+ input_sha256: Digest::SHA256.file(source).hexdigest,
51
+ filament_profile: FilamentProfile.default.to_h,
52
+ parts: [
53
+ { file: artifacts.stl_name, material: "White", role: "monochrome_reference" },
54
+ { file: artifacts.three_mf_name, materials:, role: "cmyw_lithophane" }
55
+ ],
56
+ color_model: @color_metadata.call(artifacts),
57
+ files: artifact_files(artifacts),
58
+ triangle_count: artifacts.stl_triangle_count,
59
+ three_mf_triangle_count: artifacts.three_mf.triangle_count
60
+ }
61
+ end
62
+
63
+ def artifact_files(artifacts)
64
+ files = [
65
+ artifacts.stl_name, artifacts.three_mf_name, "settings.json", "manifest.json",
66
+ "preview.png", "transmission.png", "thickness.png",
67
+ *Palette::MATERIALS.map { |material| "#{material}-mask.png" }, "PRINT_README.md"
68
+ ]
69
+ files << "calibration-profile.json" if @calibration.profile
70
+ files
71
+ end
72
+
73
+ def used_materials(artifacts)
74
+ Palette::MATERIALS.select { |material| artifacts.material_model.materials.include?(material) }
75
+ end
76
+
77
+ def material_names(materials)
78
+ materials.map { |material| material.to_s.capitalize }
79
+ end
80
+
81
+ def write_json(path, value)
82
+ File.write(path, "#{JSON.pretty_generate(value)}\n")
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Builds the reference-style overlapping White height field and contiguous CMY columns.
5
+ class ReferenceMaterialMeshPlan
6
+ def initialize(model:, white_geometry:, maximum_triangles:, limit_message:)
7
+ @model = model
8
+ @white_geometry = white_geometry
9
+ @maximum_triangles = maximum_triangles
10
+ @limit_message = limit_message
11
+ end
12
+
13
+ def call
14
+ geometries = [[:white, @white_geometry]]
15
+ Palette::PIGMENTS.each do |material|
16
+ geometries << [material, ColumnGeometry.new(model: @model, material:)] if @model.materials.include?(material)
17
+ end
18
+ counts = geometries.to_h do |material, geometry|
19
+ count = geometry.respond_to?(:triangle_count) ? geometry.triangle_count : geometry.each_triangle.count
20
+ [material, count]
21
+ end
22
+ triangle_count = counts.values.sum
23
+ raise ConfigurationError, @limit_message.call(triangle_count) if triangle_count > @maximum_triangles
24
+
25
+ MaterialMeshPlan::Result.new(
26
+ parts: geometries.map do |material, geometry|
27
+ triangles = geometry.is_a?(ColumnGeometry) ? geometry : geometry.each_triangle
28
+ ThreeMF::Part.new(material:, triangles:)
29
+ end,
30
+ triangle_count:,
31
+ triangle_counts_by_material: counts.freeze
32
+ )
33
+ end
34
+ end
35
+
36
+ # Builds material parts and exact triangle counts before writing a 3MF package.
37
+ class MaterialMeshPlan
38
+ Result = Data.define(:parts, :triangle_count, :triangle_counts_by_material) do
39
+ def validate!(three_mf_result)
40
+ return true if triangle_count == three_mf_result.triangle_count
41
+
42
+ raise Error, "planned and written 3MF triangle counts differ"
43
+ end
44
+ end
45
+
46
+ def initialize(model:, maximum_triangles:, label:, limit_message: nil)
47
+ @model = model
48
+ @maximum_triangles = maximum_triangles
49
+ @label = label
50
+ @limit_message = limit_message
51
+ end
52
+
53
+ def call
54
+ geometries = @model.materials.map do |material|
55
+ geometry = VoxelGeometry.new(model: @model, material:)
56
+ [material, geometry, geometry.triangle_count]
57
+ end
58
+ triangle_count = geometries.sum { |_material, _geometry, count| count }
59
+ raise ConfigurationError, limit_message(triangle_count) if triangle_count > @maximum_triangles
60
+
61
+ Result.new(
62
+ parts: geometries.map do |material, geometry, _count|
63
+ ThreeMF::Part.new(material:, triangles: geometry.each_triangle)
64
+ end,
65
+ triangle_count:,
66
+ triangle_counts_by_material: geometries.to_h do |material, _geometry, count|
67
+ [material, count]
68
+ end.freeze
69
+ )
70
+ end
71
+
72
+ def limit_message(triangle_count)
73
+ return @limit_message.call(triangle_count) if @limit_message
74
+
75
+ "#{@label} has #{triangle_count} triangles; maximum is #{@maximum_triangles}"
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Writes the nominal color, transmission, thickness, and material-mask previews.
5
+ module ExportPreviews
6
+ module_function
7
+
8
+ def write(directory, model:, settings:, white_heights: nil)
9
+ heights = white_heights && cell_heights(white_heights, settings.columns, settings.rows)
10
+ options = {
11
+ model:,
12
+ minimum: settings.effective_minimum_thickness,
13
+ maximum: settings.effective_maximum_thickness,
14
+ heights:
15
+ }
16
+ Preview.write_material(File.join(directory, "preview.png"), **options, transmission_gamma: 0.5)
17
+ Preview.write_transmission(File.join(directory, "transmission.png"), **options)
18
+ Preview.write_thickness(File.join(directory, "thickness.png"), **options)
19
+ Palette::MATERIALS.each do |material|
20
+ Preview.write_mask(File.join(directory, "#{material}-mask.png"), model:, material:)
21
+ end
22
+ end
23
+
24
+ def cell_heights(vertex_heights, columns, rows)
25
+ stride = columns + 1
26
+ Array.new(columns * rows) do |index|
27
+ row, column = index.divmod(columns)
28
+ top_left = (row * stride) + column
29
+ corners = [top_left, top_left + 1, top_left + stride, top_left + stride + 1]
30
+ corners.sum { |corner| vertex_heights.fetch(corner) }.fdiv(4)
31
+ end
32
+ end
33
+ private_class_method :cell_heights
34
+ end
35
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Writes print and material instructions for an exported project.
5
+ module PrintReadme
6
+ module_function
7
+
8
+ def write(path, settings:, material_model:, calibration_profile: nil, profile: FilamentProfile.default)
9
+ materials = Palette::MATERIALS.select { |material| material_model.materials.include?(material) }
10
+ names = materials.map { |material| profile_material(profile, material).name }
11
+ assignments = materials.map do |material|
12
+ "#{profile.materials.index(profile_material(profile, material)) + 1} #{material.to_s.capitalize}"
13
+ end
14
+
15
+ File.write(path, <<~MARKDOWN)
16
+ # Lithop print guide
17
+
18
+ This is #{calibration_profile ? "a measured-profile calibrated" : "an uncalibrated"} CMYW lithophane
19
+ generated in millimetres.
20
+
21
+ - Open `lithop.3mf` for the physical color model; it contains: #{names.join(", ")}.
22
+ #{calibration_line(calibration_profile)}
23
+ - Bambu Studio assignments encoded for this job: #{assignments.join(", ")}.
24
+ - The model uses #{settings.color_layer_count} CMY/W color layers #{base_layer_description(settings)}.
25
+ - The backlight-to-view color-layer order is #{settings.pigment_order.map(&:capitalize).join(" -> ")} -> White.
26
+ - White thickness above the color region follows #{thickness_method(settings, calibration_profile)}.
27
+ - `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`.
29
+ - `cmyk_white.stl` is a separate monochrome reference model, not the color model.
30
+ - Product: #{profile.product_url}
31
+ - Guide: #{profile.reference_url}
32
+ - Import models without scaling, place the flat back at Z=0, and view the print from +Z.
33
+ #{layer_height_description(settings)}
34
+ - White thickness uses #{settings.pixel_size} mm XY cells; CMYW recipes use
35
+ #{settings.effective_color_pixel_size} mm color cells.
36
+ - Use 100% rectilinear infill and verify every layer in the slicer before printing.
37
+ - The effective thickness range is #{settings.effective_minimum_thickness}–#{settings.effective_maximum_thickness} mm.
38
+ MARKDOWN
39
+ end
40
+
41
+ def profile_material(profile, material)
42
+ profile.materials.find { |candidate| candidate.channel == material }
43
+ end
44
+
45
+ def calibration_line(calibration_profile)
46
+ return "- No measured calibration profile was used." unless calibration_profile
47
+
48
+ "- Measured calibration profile: #{calibration_profile.profile_id}."
49
+ end
50
+
51
+ def thickness_method(_settings, calibration_profile)
52
+ return "the closest measured White luminance stage" if calibration_profile
53
+
54
+ "continuous normalized material-display density or Rec. 601 luminance"
55
+ end
56
+
57
+ def base_layer_description(settings)
58
+ "above #{settings.base_white_layers} White base layers"
59
+ end
60
+
61
+ def layer_height_description(settings)
62
+ upper_height = settings.layer_structure.top_layer_height
63
+ "- Use a #{settings.nozzle_diameter} mm nozzle, #{settings.first_layer_height} mm base layers,\n " \
64
+ "#{settings.layer_height} mm color layers, and #{upper_height} mm upper White layers."
65
+ end
66
+ private_class_method :profile_material, :calibration_line, :thickness_method,
67
+ :base_layer_description, :layer_height_description
68
+ end
69
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ ExportArtifacts = Data.define(
5
+ :stl_name,
6
+ :three_mf_name,
7
+ :stl_triangle_count,
8
+ :three_mf,
9
+ :material_model
10
+ )
11
+
12
+ ExportResult = Data.define(
13
+ :job_id,
14
+ :path,
15
+ :files,
16
+ :triangle_count,
17
+ :three_mf_triangle_count,
18
+ :material_count,
19
+ :object_count
20
+ )
21
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Reads and writes binary stereolithography (STL) triangle meshes.
5
+ module STL
6
+ module_function
7
+
8
+ def write(path, triangles)
9
+ count = 0
10
+ File.open(path, "wb") do |file|
11
+ file.write("Lithop binary STL".ljust(80, "\0"))
12
+ file.write([0].pack("V"))
13
+ triangles.each do |triangle|
14
+ normal = normal_for(triangle)
15
+ file.write((normal + triangle.flatten + [0]).pack("e12v"))
16
+ count += 1
17
+ end
18
+ file.seek(80)
19
+ file.write([count].pack("V"))
20
+ end
21
+ count
22
+ end
23
+
24
+ def each_triangle(path)
25
+ return enum_for(__method__, path) unless block_given?
26
+
27
+ File.open(path, "rb") do |file|
28
+ raise InputError, "STL header is truncated" unless file.read(80)&.bytesize == 80
29
+
30
+ count_data = file.read(4)
31
+ raise InputError, "STL triangle count is missing" unless count_data&.bytesize == 4
32
+
33
+ count_data.unpack1("V").times do
34
+ record = file.read(50)
35
+ raise InputError, "STL triangle data is truncated" unless record&.bytesize == 50
36
+
37
+ values = record.unpack("e12v")
38
+ yield values[3, 9].each_slice(3).to_a
39
+ end
40
+ raise InputError, "STL contains trailing data" unless file.eof?
41
+ end
42
+ end
43
+
44
+ def normal_for(triangle)
45
+ a, b, c = triangle
46
+ ab = b.zip(a).map { |left, right| left - right }
47
+ ac = c.zip(a).map { |left, right| left - right }
48
+ cross = [
49
+ (ab[1] * ac[2]) - (ab[2] * ac[1]),
50
+ (ab[2] * ac[0]) - (ab[0] * ac[2]),
51
+ (ab[0] * ac[1]) - (ab[1] * ac[0])
52
+ ]
53
+ magnitude = Math.sqrt(cross.sum { |component| component**2 })
54
+ raise InputError, "cannot write a zero-area triangle" if magnitude.zero?
55
+
56
+ cross.map { |component| component / magnitude }
57
+ end
58
+ private_class_method :normal_for
59
+ end
60
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lithop
6
+ # Writes Bambu Studio material-assignment metadata into a 3MF package.
7
+ class ThreeMFBambuMetadata
8
+ PATH = "Metadata/model_settings.config"
9
+ FIRST_MODEL_OBJECT_INDEX = 1
10
+
11
+ def initialize(archive:, assembly_id:, object_ids:, parts:, profile:, settings: nil)
12
+ @archive = archive
13
+ @assembly_id = assembly_id
14
+ @object_ids = object_ids
15
+ @parts = parts
16
+ @profile = profile
17
+ @settings = settings
18
+ end
19
+
20
+ def write
21
+ @archive.put_next_entry(PATH)
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")
25
+ if bambu_project?
26
+ @archive.write(" <metadata key=\"extruder\" value=\"1\"/>\n")
27
+ @archive.write(" <metadata key=\"layer_height\" value=\"#{@settings.layer_height}\"/>\n")
28
+ @archive.write(" <metadata key=\"sparse_infill_density\" value=\"100%\"/>\n")
29
+ @archive.write(" <metadata key=\"sparse_infill_pattern\" value=\"zig-zag\"/>\n")
30
+ end
31
+ @object_ids.zip(@parts).each do |object_id, part|
32
+ write_part(object_id, part.material)
33
+ end
34
+ @archive.write(" </object>\n")
35
+ write_plate if bambu_project?
36
+ @archive.write("</config>\n")
37
+ write_project_entries if bambu_project?
38
+ end
39
+
40
+ private
41
+
42
+ def write_part(object_id, material)
43
+ profile_material = @profile.materials.find { |candidate| candidate.channel == material }
44
+ 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")
47
+ @archive.write(" <metadata key=\"extruder\" value=\"#{extruder}\"/>\n")
48
+ @archive.write(" </part>\n")
49
+ end
50
+
51
+ def bambu_project? = @settings&.bambu_project
52
+
53
+ def write_plate
54
+ @archive.write(<<~XML)
55
+ <plate>
56
+ <metadata key="plater_id" value="1"/>
57
+ <metadata key="plater_name" value="lithophane"/>
58
+ <metadata key="locked" value="false"/>
59
+ <model_instance>
60
+ <metadata key="object_id" value="#{@assembly_id}"/>
61
+ <metadata key="instance_id" value="0"/>
62
+ </model_instance>
63
+ </plate>
64
+ XML
65
+ end
66
+
67
+ def write_project_entries
68
+ write_entry("Metadata/layer_config_ranges.xml", layer_ranges_xml)
69
+ write_entry("Metadata/project_settings.config", "#{JSON.pretty_generate(project_settings)}\n")
70
+ end
71
+
72
+ def layer_ranges_xml
73
+ <<~XML
74
+ <?xml version="1.0" encoding="utf-8"?>
75
+ <objects>
76
+ <object id="#{FIRST_MODEL_OBJECT_INDEX}">
77
+ <range min_z="#{@settings.top_white_start}" max_z="#{@settings.maximum_thickness}">
78
+ <option opt_key="extruder">4</option>
79
+ <option opt_key="layer_height">#{@settings.top_layer_height}</option>
80
+ </range>
81
+ </object>
82
+ </objects>
83
+ XML
84
+ end
85
+
86
+ def project_settings
87
+ {
88
+ "filament_colour" => %w[#0086D6 #EC008C #FCE300 #FFFFFF],
89
+ "filament_type" => Array.new(4, "PLA"),
90
+ "initial_layer_line_width" => "0.25",
91
+ "initial_layer_print_height" => @settings.first_layer_height.to_s,
92
+ "layer_height" => @settings.layer_height.to_s,
93
+ "line_width" => "0.22",
94
+ "max_layer_height" => ["0.14"],
95
+ "min_layer_height" => [@settings.top_layer_height.to_s],
96
+ "nozzle_diameter" => [@settings.nozzle_diameter.to_s]
97
+ }
98
+ end
99
+
100
+ def write_entry(path, contents)
101
+ @archive.put_next_entry(path)
102
+ @archive.write(contents)
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Indexes vertices and triangles for a 3MF mesh resource.
5
+ class ThreeMFMesh
6
+ attr_reader :vertices, :vertex_indices, :triangle_count
7
+
8
+ def initialize(triangles:)
9
+ @triangles = triangles
10
+ index_resources
11
+ end
12
+
13
+ def each_indexed_triangle
14
+ return enum_for(__method__) unless block_given?
15
+
16
+ @triangles.each do |triangle|
17
+ yield triangle.map { |vertex| vertex_indices.fetch(vertex) }
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def index_resources
24
+ @vertices = []
25
+ @vertex_indices = {}
26
+ @triangle_count = 0
27
+
28
+ @triangles.each do |triangle|
29
+ validate_triangle!(triangle)
30
+ triangle.each { |vertex| index_vertex(vertex) }
31
+ @triangle_count += 1
32
+ end
33
+ raise InputError, "3MF mesh contains no triangles" if vertices.empty?
34
+ end
35
+
36
+ def index_vertex(vertex)
37
+ return if vertex_indices.key?(vertex)
38
+
39
+ vertex_indices[vertex] = vertices.length
40
+ vertices << coordinates(vertex).freeze
41
+ end
42
+
43
+ def validate_triangle!(triangle)
44
+ valid = triangle.is_a?(Array) && triangle.length == 3 && triangle.all? do |vertex|
45
+ point = coordinates(vertex)
46
+ point.length == 3 && point.all? { |value| value.is_a?(Numeric) && value.finite? }
47
+ end
48
+ raise InputError, "3MF triangle must contain three finite XYZ vertices" unless valid
49
+ end
50
+
51
+ def coordinates(vertex)
52
+ vertex.respond_to?(:coordinates) ? vertex.coordinates : vertex
53
+ end
54
+ end
55
+ end