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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/RELEASING.md +63 -0
- data/bin/lithop +6 -0
- data/docs/README.md +44 -0
- data/docs/artifacts.md +122 -0
- data/docs/bambu-studio.md +93 -0
- data/docs/calibration.md +126 -0
- data/docs/color-model.md +114 -0
- data/docs/command.md +182 -0
- data/docs/config.md +201 -0
- data/docs/getting-started.md +98 -0
- data/docs/troubleshooting.md +112 -0
- data/lib/lithop/calibration/export.rb +102 -0
- data/lib/lithop/calibration/profile.rb +127 -0
- data/lib/lithop/calibration/profile_measurements.rb +82 -0
- data/lib/lithop/calibration/recipe_selector.rb +54 -0
- data/lib/lithop/calibration/target.rb +135 -0
- data/lib/lithop/calibration/target_svg.rb +89 -0
- data/lib/lithop/calibration/thickness_selector.rb +35 -0
- data/lib/lithop/cli/calibration.rb +40 -0
- data/lib/lithop/cli/image_options.rb +69 -0
- data/lib/lithop/cli/options.rb +95 -0
- data/lib/lithop/cli/settings.rb +34 -0
- data/lib/lithop/cli.rb +102 -0
- data/lib/lithop/export/calibration.rb +46 -0
- data/lib/lithop/export/color_metadata.rb +80 -0
- data/lib/lithop/export/generation.rb +122 -0
- data/lib/lithop/export/job.rb +49 -0
- data/lib/lithop/export/metadata.rb +85 -0
- data/lib/lithop/export/planning.rb +78 -0
- data/lib/lithop/export/previews.rb +35 -0
- data/lib/lithop/export/print_readme.rb +69 -0
- data/lib/lithop/export/result.rb +21 -0
- data/lib/lithop/formats/stl.rb +60 -0
- data/lib/lithop/formats/three_mf/bambu_metadata.rb +105 -0
- data/lib/lithop/formats/three_mf/mesh.rb +55 -0
- data/lib/lithop/formats/three_mf/model_writer.rb +114 -0
- data/lib/lithop/formats/three_mf/part_writer.rb +49 -0
- data/lib/lithop/formats/three_mf.rb +128 -0
- data/lib/lithop/image/pipeline.rb +175 -0
- data/lib/lithop/image/placement.rb +54 -0
- data/lib/lithop/image/preview.rb +63 -0
- data/lib/lithop/image/svg_loader.rb +218 -0
- data/lib/lithop/instrumentation.rb +22 -0
- data/lib/lithop/job_directory.rb +55 -0
- data/lib/lithop/layer_structure.rb +134 -0
- data/lib/lithop/mesh/column_geometry.rb +161 -0
- data/lib/lithop/mesh/column_topology.rb +262 -0
- data/lib/lithop/mesh/geometry.rb +116 -0
- data/lib/lithop/mesh/validator.rb +123 -0
- data/lib/lithop/mesh/voxel_geometry.rb +113 -0
- data/lib/lithop/mesh/voxel_topology.rb +79 -0
- data/lib/lithop/model/filament_profile.rb +79 -0
- data/lib/lithop/model/material_display_map.rb +61 -0
- data/lib/lithop/model/material_model.rb +106 -0
- data/lib/lithop/model/palette.rb +101 -0
- data/lib/lithop/model/thickness.rb +45 -0
- data/lib/lithop/reference_three_mf.rb +119 -0
- data/lib/lithop/sampling_grid.rb +41 -0
- data/lib/lithop/settings.rb +295 -0
- data/lib/lithop/settings_file.rb +56 -0
- data/lib/lithop/version.rb +5 -0
- data/lib/lithop.rb +55 -0
- data/sig/lithop.rbs +291 -0
- 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
|