lithop 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/Cargo.lock +284 -0
- data/Cargo.toml +6 -0
- data/README.md +25 -7
- data/RELEASING.md +24 -6
- data/docs/README.md +3 -1
- data/docs/artifacts.md +9 -2
- data/docs/bambu-studio.md +5 -1
- data/docs/command.md +25 -2
- data/docs/config.md +1 -1
- data/docs/getting-started.md +8 -5
- data/docs/images/cmyw-lithophane-preview.png +0 -0
- data/docs/local-ui.md +114 -0
- data/docs/tiling.md +4 -2
- data/docs/troubleshooting.md +10 -0
- data/ext/lithop_native/Cargo.toml +17 -0
- data/ext/lithop_native/build.rs +4 -0
- data/ext/lithop_native/extconf.rb +6 -0
- data/ext/lithop_native/src/column_mesh.rs +388 -0
- data/ext/lithop_native/src/lib.rs +37 -0
- data/ext/lithop_native/src/mesh.rs +187 -0
- data/ext/lithop_native/src/palette.rs +67 -0
- data/ext/lithop_native/src/xml.rs +134 -0
- data/lib/lithop/calibration/export.rb +2 -2
- data/lib/lithop/cli/options.rb +13 -0
- data/lib/lithop/cli.rb +28 -0
- data/lib/lithop/export/generation.rb +30 -17
- data/lib/lithop/export/job.rb +2 -2
- data/lib/lithop/export/metadata.rb +8 -3
- data/lib/lithop/export/previews.rb +25 -1
- data/lib/lithop/formats/three_mf/mesh.rb +72 -3
- data/lib/lithop/formats/three_mf/model_writer.rb +19 -16
- data/lib/lithop/formats/three_mf/part_writer.rb +37 -0
- data/lib/lithop/formats/three_mf.rb +1 -1
- data/lib/lithop/image/pipeline.rb +50 -11
- data/lib/lithop/image/svg_loader.rb +5 -5
- data/lib/lithop/job_execution.rb +132 -4
- data/lib/lithop/mesh/column_geometry.rb +8 -0
- data/lib/lithop/mesh/column_topology.rb +2 -0
- data/lib/lithop/model/material_model.rb +17 -5
- data/lib/lithop/model/palette.rb +21 -3
- data/lib/lithop/native.rb +39 -0
- data/lib/lithop/tiling.rb +3 -2
- data/lib/lithop/version.rb +1 -1
- data/lib/lithop/web/app.rb +513 -0
- data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
- data/lib/lithop/web/job_launcher.rb +214 -0
- data/lib/lithop/web/job_store.rb +390 -0
- data/lib/lithop/web/preview_store.rb +182 -0
- data/lib/lithop/web/public/app.css +319 -0
- data/lib/lithop/web/public/app.js +1847 -0
- data/lib/lithop/web/server.rb +21 -0
- data/lib/lithop/web/settings_form.rb +64 -0
- data/lib/lithop/web/settings_library.rb +154 -0
- data/lib/lithop/web/views/index.erb +364 -0
- data/lib/lithop/web/worker.rb +27 -0
- data/lib/lithop.rb +1 -0
- data/sig/lithop.rbs +25 -0
- metadata +84 -2
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
module Lithop
|
|
4
4
|
# Indexes vertices and triangles for a 3MF mesh resource.
|
|
5
5
|
class ThreeMFMesh
|
|
6
|
-
|
|
6
|
+
NATIVE_BATCH_SIZE = 8_192
|
|
7
|
+
MAXIMUM_NATIVE_TOPOLOGY_KEY = (2**63) - 1
|
|
8
|
+
class UnsupportedNativeTopology < StandardError; end
|
|
9
|
+
|
|
10
|
+
attr_reader :vertices, :vertex_indices, :triangle_count, :native_mesh
|
|
7
11
|
|
|
8
12
|
def initialize(triangles:)
|
|
9
13
|
@triangles = triangles
|
|
10
|
-
|
|
14
|
+
Native.enabled? ? index_native_resources : index_ruby_resources
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def each_indexed_triangle
|
|
@@ -18,9 +22,74 @@ module Lithop
|
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
def native_indexed? = !native_mesh.nil?
|
|
26
|
+
|
|
27
|
+
def vertex_count = native_indexed? ? native_mesh.vertex_count : vertices.length
|
|
28
|
+
|
|
29
|
+
def center
|
|
30
|
+
return native_mesh.center if native_indexed?
|
|
31
|
+
|
|
32
|
+
axes = vertices.transpose
|
|
33
|
+
axes.map { |values| (values.min + values.max) / 2.0 }
|
|
34
|
+
end
|
|
35
|
+
|
|
21
36
|
private
|
|
22
37
|
|
|
23
|
-
def
|
|
38
|
+
def index_native_resources
|
|
39
|
+
if @triangles.respond_to?(:native_indexed_mesh)
|
|
40
|
+
@native_mesh = @triangles.native_indexed_mesh
|
|
41
|
+
raise InputError, "3MF mesh contains no triangles" if native_mesh.vertex_count.zero?
|
|
42
|
+
|
|
43
|
+
@triangle_count = native_mesh.triangle_count
|
|
44
|
+
@vertices = nil
|
|
45
|
+
@vertex_indices = nil
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@native_mesh = Native::MeshBuilder.new
|
|
50
|
+
coordinates = []
|
|
51
|
+
topology_keys = []
|
|
52
|
+
count = 0
|
|
53
|
+
@triangles.each do |triangle|
|
|
54
|
+
validate_triangle!(triangle)
|
|
55
|
+
triangle.each do |vertex|
|
|
56
|
+
coordinates.concat(coordinates(vertex))
|
|
57
|
+
topology_keys << native_topology_key(vertex)
|
|
58
|
+
end
|
|
59
|
+
count += 1
|
|
60
|
+
flush_native_batch(coordinates, topology_keys) if (count % NATIVE_BATCH_SIZE).zero?
|
|
61
|
+
end
|
|
62
|
+
flush_native_batch(coordinates, topology_keys)
|
|
63
|
+
raise InputError, "3MF mesh contains no triangles" if native_mesh.vertex_count.zero?
|
|
64
|
+
|
|
65
|
+
@triangle_count = native_mesh.triangle_count
|
|
66
|
+
@vertices = nil
|
|
67
|
+
@vertex_indices = nil
|
|
68
|
+
rescue UnsupportedNativeTopology
|
|
69
|
+
@native_mesh = nil
|
|
70
|
+
index_ruby_resources
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def flush_native_batch(coordinates, topology_keys)
|
|
74
|
+
return if coordinates.empty?
|
|
75
|
+
|
|
76
|
+
native_mesh.add_batch(coordinates, topology_keys)
|
|
77
|
+
coordinates.clear
|
|
78
|
+
topology_keys.clear
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def native_topology_key(vertex)
|
|
82
|
+
return -1 unless vertex.respond_to?(:topology)
|
|
83
|
+
|
|
84
|
+
topology = vertex.topology
|
|
85
|
+
unless topology.is_a?(Integer) && topology.between?(0, MAXIMUM_NATIVE_TOPOLOGY_KEY)
|
|
86
|
+
raise UnsupportedNativeTopology
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
topology
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def index_ruby_resources
|
|
24
93
|
@vertices = []
|
|
25
94
|
@vertex_indices = {}
|
|
26
95
|
@triangle_count = 0
|
|
@@ -78,7 +78,7 @@ module Lithop
|
|
|
78
78
|
ThreeMFPartWriter.new(archive: @archive, profile: @profile).write(object_id, part, mesh)
|
|
79
79
|
end
|
|
80
80
|
part.triangles.release_cache! if part.triangles.respond_to?(:release_cache!)
|
|
81
|
-
vertex_count += mesh.
|
|
81
|
+
vertex_count += mesh.vertex_count
|
|
82
82
|
triangle_counts[part.material] = triangle_counts.fetch(part.material, 0) + mesh.triangle_count
|
|
83
83
|
end
|
|
84
84
|
Result.new(
|
|
@@ -96,7 +96,7 @@ module Lithop
|
|
|
96
96
|
result
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
# rubocop:disable-next Metrics/AbcSize
|
|
99
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
|
|
100
100
|
def write_external_parts(object_ids, assemblies)
|
|
101
101
|
parts_by_object_id = object_ids.zip(@parts).to_h
|
|
102
102
|
vertex_count = 0
|
|
@@ -106,22 +106,26 @@ module Lithop
|
|
|
106
106
|
measure_xml do
|
|
107
107
|
@archive.write(submodel_header)
|
|
108
108
|
write_materials
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
)
|
|
109
|
+
end
|
|
110
|
+
assembly.object_ids.each do |object_id|
|
|
111
|
+
part = parts_by_object_id.fetch(object_id)
|
|
112
|
+
mesh = @instrumentation.measure(:three_mf_indexing) do
|
|
113
|
+
ThreeMFMesh.new(triangles: part.triangles)
|
|
114
|
+
end
|
|
115
|
+
center = mesh_center(mesh)
|
|
116
|
+
assembly.component_origins[object_id] = component_origin(
|
|
117
|
+
center, assembly.component_origins.fetch(object_id)
|
|
118
|
+
)
|
|
119
|
+
measure_xml do
|
|
118
120
|
ThreeMFPartWriter.new(
|
|
119
121
|
archive: @archive, profile: @profile, production: true, origin: center
|
|
120
122
|
).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
123
|
end
|
|
124
|
+
part.triangles.release_cache! if part.triangles.respond_to?(:release_cache!)
|
|
125
|
+
vertex_count += mesh.vertex_count
|
|
126
|
+
triangle_counts[part.material] = triangle_counts.fetch(part.material, 0) + mesh.triangle_count
|
|
127
|
+
end
|
|
128
|
+
measure_xml do
|
|
125
129
|
@archive.write(" </resources>\n <build/>\n</model>\n")
|
|
126
130
|
end
|
|
127
131
|
end
|
|
@@ -252,8 +256,7 @@ module Lithop
|
|
|
252
256
|
end
|
|
253
257
|
|
|
254
258
|
def mesh_center(mesh)
|
|
255
|
-
|
|
256
|
-
axes.map { |values| (values.min + values.max) / 2.0 }
|
|
259
|
+
mesh.center
|
|
257
260
|
end
|
|
258
261
|
|
|
259
262
|
def component_origin(center, base_origin)
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Lithop
|
|
4
4
|
# Serializes one indexed material mesh into a 3MF model document.
|
|
5
5
|
class ThreeMFPartWriter
|
|
6
|
+
XML_BATCH_SIZE = 8_192
|
|
7
|
+
|
|
6
8
|
def initialize(archive:, profile:, production: false, origin: nil)
|
|
7
9
|
@archive = archive
|
|
8
10
|
@profile = profile
|
|
@@ -31,6 +33,9 @@ module Lithop
|
|
|
31
33
|
private
|
|
32
34
|
|
|
33
35
|
def write_vertices(mesh)
|
|
36
|
+
return write_indexed_native_vertices(mesh) if mesh.native_indexed?
|
|
37
|
+
return write_native_vertices(mesh) if Native.enabled?
|
|
38
|
+
|
|
34
39
|
mesh.vertices.each do |x, y, z|
|
|
35
40
|
@archive.write(
|
|
36
41
|
" <vertex x=\"#{number(x - @origin[0])}\" y=\"#{number(y - @origin[1])}\" " \
|
|
@@ -40,6 +45,9 @@ module Lithop
|
|
|
40
45
|
end
|
|
41
46
|
|
|
42
47
|
def write_triangles(mesh)
|
|
48
|
+
return write_indexed_native_triangles(mesh) if mesh.native_indexed?
|
|
49
|
+
return write_native_triangles(mesh) if Native.enabled?
|
|
50
|
+
|
|
43
51
|
count = 0
|
|
44
52
|
mesh.each_indexed_triangle do |vertices|
|
|
45
53
|
@archive.write(
|
|
@@ -52,6 +60,35 @@ module Lithop
|
|
|
52
60
|
raise InputError, "3MF triangle enumerables must be repeatable"
|
|
53
61
|
end
|
|
54
62
|
|
|
63
|
+
def write_native_vertices(mesh)
|
|
64
|
+
mesh.vertices.each_slice(XML_BATCH_SIZE) do |vertices|
|
|
65
|
+
@archive.write(Native.vertex_xml(vertices.flatten, @origin))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def write_indexed_native_vertices(mesh)
|
|
70
|
+
0.step(mesh.vertex_count - 1, XML_BATCH_SIZE) do |offset|
|
|
71
|
+
@archive.write(mesh.native_mesh.vertex_xml(offset, XML_BATCH_SIZE, @origin))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write_native_triangles(mesh)
|
|
76
|
+
count = 0
|
|
77
|
+
mesh.each_indexed_triangle.each_slice(XML_BATCH_SIZE) do |triangles|
|
|
78
|
+
@archive.write(Native.triangle_xml(triangles.flatten))
|
|
79
|
+
count += triangles.length
|
|
80
|
+
end
|
|
81
|
+
return if count == mesh.triangle_count
|
|
82
|
+
|
|
83
|
+
raise InputError, "3MF triangle enumerables must be repeatable"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write_indexed_native_triangles(mesh)
|
|
87
|
+
0.step(mesh.triangle_count - 1, XML_BATCH_SIZE) do |offset|
|
|
88
|
+
@archive.write(mesh.native_mesh.triangle_xml(offset, XML_BATCH_SIZE))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
55
92
|
def number(value) = format("%.9g", value)
|
|
56
93
|
end
|
|
57
94
|
end
|
|
@@ -18,7 +18,7 @@ module Lithop
|
|
|
18
18
|
MODEL_RELATIONSHIP = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"
|
|
19
19
|
MODEL_CONTENT_TYPE = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"
|
|
20
20
|
ZIP_MUTEX = Mutex.new
|
|
21
|
-
#
|
|
21
|
+
# The Rust indexer consumes triangles once; the Ruby comparison path requires replayable input.
|
|
22
22
|
Part = Data.define(:material, :triangles, :name, :region, :build_z_offset) do
|
|
23
23
|
def initialize(material:, triangles:, name: nil, region: nil, build_z_offset: 0.0)
|
|
24
24
|
super
|
|
@@ -9,7 +9,9 @@ module Lithop
|
|
|
9
9
|
REFERENCE_DARK_MAXIMUM_VALUE = 0.75
|
|
10
10
|
REFERENCE_NEUTRAL_MAXIMUM_CHROMA = 0.25
|
|
11
11
|
REFERENCE_BROWN_LINE_CMY_COVERAGE = 1.0 / 7
|
|
12
|
-
Result = Data.define(
|
|
12
|
+
Result = Data.define(
|
|
13
|
+
:width, :height, :cmys, :cmy_densities, :white_densities, :color_cells, :corrected_preview_png
|
|
14
|
+
)
|
|
13
15
|
|
|
14
16
|
def initialize(settings)
|
|
15
17
|
@settings = settings
|
|
@@ -21,13 +23,8 @@ module Lithop
|
|
|
21
23
|
require "vips"
|
|
22
24
|
|
|
23
25
|
access = @settings.color_pixel_size ? :random : :sequential
|
|
24
|
-
image =
|
|
25
|
-
|
|
26
|
-
validate_input!(image)
|
|
27
|
-
image = image.flatten(background: [255, 255, 255]) if image.has_alpha?
|
|
28
|
-
image = to_srgb(image)
|
|
29
|
-
image = adjust_image(image)
|
|
30
|
-
sampled = @placement.call(image, @settings.sampling_grid).cast(:uchar)
|
|
26
|
+
image = normalized_image(path, access:)
|
|
27
|
+
sampled = @placement.call(image, @settings.sampling_grid).cast(:uchar).copy_memory
|
|
31
28
|
|
|
32
29
|
rgbs = rgb_components(sampled)
|
|
33
30
|
color_samples = rgbs.map { |rgb| color_sample(rgb) }
|
|
@@ -42,20 +39,62 @@ module Lithop
|
|
|
42
39
|
cmys: color_samples.map(&:first),
|
|
43
40
|
cmy_densities: color_samples.map(&:last),
|
|
44
41
|
white_densities: rgbs.map { |rgb| white_density(rgb) },
|
|
45
|
-
color_cells: result_color_cells
|
|
42
|
+
color_cells: result_color_cells,
|
|
43
|
+
corrected_preview_png: sampled.write_to_buffer(".png")
|
|
46
44
|
)
|
|
47
45
|
rescue SystemCallError, Vips::Error => e
|
|
48
46
|
raise InputError, "could not read image: #{e.message}"
|
|
49
47
|
end
|
|
50
48
|
|
|
49
|
+
# Returns the same normalized and placed image used by conversion, bounded
|
|
50
|
+
# to a browser-friendly sampling grid and without running CMYW or mesh work.
|
|
51
|
+
def preview_png(path, maximum_width: 960, maximum_height: 720)
|
|
52
|
+
require "vips"
|
|
53
|
+
|
|
54
|
+
grid = preview_grid(maximum_width:, maximum_height:)
|
|
55
|
+
image = normalized_image(path, access: :sequential, sampling_grids: [grid])
|
|
56
|
+
@placement.call(image, grid).cast(:uchar).write_to_buffer(".png")
|
|
57
|
+
rescue SystemCallError, Vips::Error => e
|
|
58
|
+
raise InputError, "could not read image: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
51
61
|
private
|
|
52
62
|
|
|
53
|
-
def
|
|
63
|
+
def normalized_image(path, access:, sampling_grids: nil)
|
|
64
|
+
image = load_image(path, access:, sampling_grids:)
|
|
65
|
+
image = image.autorot
|
|
66
|
+
validate_input!(image)
|
|
67
|
+
image = image.flatten(background: [255, 255, 255]) if image.has_alpha?
|
|
68
|
+
adjust_image(to_srgb(image))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def load_image(path, access:, sampling_grids: nil)
|
|
54
72
|
signature = File.binread(path, 8)
|
|
55
73
|
raster = signature.start_with?("\xFF\xD8\xFF".b) || signature.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
56
74
|
return Vips::Image.new_from_file(path.to_s, access:) if raster
|
|
57
75
|
|
|
58
|
-
@svg_loader.call(path, access:)
|
|
76
|
+
@svg_loader.call(path, access:, sampling_grids:)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def preview_grid(maximum_width:, maximum_height:)
|
|
80
|
+
maximum_width = Integer(maximum_width)
|
|
81
|
+
maximum_height = Integer(maximum_height)
|
|
82
|
+
raise InputError, "preview dimensions must be at least 2 pixels" if maximum_width < 2 || maximum_height < 2
|
|
83
|
+
|
|
84
|
+
pixel_size = [
|
|
85
|
+
@settings.width.fdiv(maximum_width - 1),
|
|
86
|
+
@settings.height.fdiv(maximum_height - 1),
|
|
87
|
+
0.01
|
|
88
|
+
].max
|
|
89
|
+
SamplingGrid.new(
|
|
90
|
+
width: @settings.width, height: @settings.height, pixel_size:,
|
|
91
|
+
margins: {
|
|
92
|
+
top: @settings.margin_top, right: @settings.margin_right,
|
|
93
|
+
bottom: @settings.margin_bottom, left: @settings.margin_left
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
rescue ArgumentError, TypeError
|
|
97
|
+
raise InputError, "preview dimensions must be integers"
|
|
59
98
|
end
|
|
60
99
|
|
|
61
100
|
def validate_input!(image)
|
|
@@ -141,14 +141,14 @@ module Lithop
|
|
|
141
141
|
@validator = SVGDocumentValidator.new
|
|
142
142
|
end
|
|
143
143
|
|
|
144
|
-
def call(path, access:)
|
|
144
|
+
def call(path, access:, sampling_grids: nil)
|
|
145
145
|
data = read(path)
|
|
146
146
|
@validator.call(data)
|
|
147
147
|
ensure_loader!
|
|
148
148
|
|
|
149
149
|
probe = render(data, scale: 1.0, access: :sequential)
|
|
150
150
|
validate_rendered_image!(probe, pixels: false)
|
|
151
|
-
scale = render_scale(probe)
|
|
151
|
+
scale = render_scale(probe, sampling_grids || default_sampling_grids)
|
|
152
152
|
validate_planned_pixels!(probe, scale)
|
|
153
153
|
|
|
154
154
|
image = render(data, scale:, access:)
|
|
@@ -180,8 +180,8 @@ module Lithop
|
|
|
180
180
|
raise InputError, "could not render SVG: #{e.message}"
|
|
181
181
|
end
|
|
182
182
|
|
|
183
|
-
def render_scale(image)
|
|
184
|
-
scales =
|
|
183
|
+
def render_scale(image, grids)
|
|
184
|
+
scales = grids.map do |grid|
|
|
185
185
|
width, height = grid.content_sample_size
|
|
186
186
|
ratios = [width.fdiv(image.width), height.fdiv(image.height)]
|
|
187
187
|
@settings.image_fit == "contain" ? ratios.min * @settings.crop_scale : ratios.max / @settings.crop_scale
|
|
@@ -189,7 +189,7 @@ module Lithop
|
|
|
189
189
|
[*scales, 1.0 / image.width, 1.0 / image.height].max
|
|
190
190
|
end
|
|
191
191
|
|
|
192
|
-
def
|
|
192
|
+
def default_sampling_grids
|
|
193
193
|
grids = [@settings.sampling_grid]
|
|
194
194
|
grids << @settings.color_sampling_grid if @settings.color_pixel_size
|
|
195
195
|
grids
|
data/lib/lithop/job_execution.rb
CHANGED
|
@@ -27,8 +27,10 @@ module Lithop
|
|
|
27
27
|
@jobs_dir = File.join(@build_dir, ".jobs")
|
|
28
28
|
@status_path = File.join(@jobs_dir, "#{job_id}.json")
|
|
29
29
|
@cancel_path = File.join(@jobs_dir, "#{job_id}.cancel")
|
|
30
|
+
@transition_lock_path = File.join(@jobs_dir, "#{job_id}.transition.lock")
|
|
30
31
|
@lock_path = File.join(@build_dir, ".lithop.lock")
|
|
31
32
|
@lock = nil
|
|
33
|
+
@transition_lock = nil
|
|
32
34
|
@record = base_record
|
|
33
35
|
end
|
|
34
36
|
|
|
@@ -40,7 +42,7 @@ module Lithop
|
|
|
40
42
|
raise_if_cancelled!
|
|
41
43
|
transition("running", phase: "starting", percent: 0, message: "Conversion started", started_at: timestamp)
|
|
42
44
|
result = yield self
|
|
43
|
-
raise_if_cancelled!
|
|
45
|
+
raise_if_cancelled! unless @publication_committed
|
|
44
46
|
complete(result)
|
|
45
47
|
result
|
|
46
48
|
rescue JobCancelled, Interrupt => e
|
|
@@ -53,6 +55,7 @@ module Lithop
|
|
|
53
55
|
raise
|
|
54
56
|
ensure
|
|
55
57
|
release_lock
|
|
58
|
+
release_transition_lock
|
|
56
59
|
remove_cancel_request if prepared?
|
|
57
60
|
end
|
|
58
61
|
|
|
@@ -62,21 +65,95 @@ module Lithop
|
|
|
62
65
|
raise_if_cancelled!
|
|
63
66
|
end
|
|
64
67
|
|
|
68
|
+
def checkpoint!
|
|
69
|
+
@transition_mutex = self.class.send(:transition_mutex, @transition_lock_path)
|
|
70
|
+
@transition_mutex.lock
|
|
71
|
+
@transition_mutex_locked = true
|
|
72
|
+
@transition_lock = File.open(@transition_lock_path, File::RDWR | File::CREAT, 0o600)
|
|
73
|
+
@transition_lock.flock(File::LOCK_EX)
|
|
74
|
+
raise_if_cancelled!
|
|
75
|
+
rescue StandardError
|
|
76
|
+
release_transition_lock
|
|
77
|
+
raise
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Once JobDirectory has atomically published the output, late cancellation
|
|
81
|
+
# must not turn a complete artifact set into a cancelled job.
|
|
82
|
+
def publication_committed!(message: "Artifacts published")
|
|
83
|
+
@publication_committed = true
|
|
84
|
+
transition("running", phase: "publishing", percent: 99, message:)
|
|
85
|
+
end
|
|
86
|
+
|
|
65
87
|
def self.request_cancel(project_dir:, job_id:, clock: Time)
|
|
66
88
|
validate_job_id!(job_id)
|
|
67
89
|
jobs_dir = File.join(File.expand_path(project_dir), "build", ".jobs")
|
|
68
90
|
FileUtils.mkdir_p(jobs_dir)
|
|
69
91
|
status_path = File.join(jobs_dir, "#{job_id}.json")
|
|
70
92
|
path = File.join(jobs_dir, "#{job_id}.cancel")
|
|
93
|
+
lock_path = File.join(jobs_dir, "#{job_id}.transition.lock")
|
|
71
94
|
requested_at = clock.now.utc.iso8601(6)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
95
|
+
requested = false
|
|
96
|
+
transition_mutex(lock_path).synchronize do
|
|
97
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
98
|
+
lock.flock(File::LOCK_EX)
|
|
99
|
+
unless final_status?(status_path)
|
|
100
|
+
atomic_write(path, JSON.generate(job_id:, requested_at:))
|
|
101
|
+
mark_cancelling(status_path, requested_at)
|
|
102
|
+
requested = true
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
path if requested
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.finalize_process_exit(project_dir:, job_id:, clock: Time)
|
|
110
|
+
validate_job_id!(job_id)
|
|
111
|
+
build_dir = File.join(File.expand_path(project_dir), "build")
|
|
112
|
+
jobs_dir = File.join(build_dir, ".jobs")
|
|
113
|
+
status_path = File.join(jobs_dir, "#{job_id}.json")
|
|
114
|
+
cancel_path = File.join(jobs_dir, "#{job_id}.cancel")
|
|
115
|
+
record = if File.file?(status_path)
|
|
116
|
+
JSON.parse(File.read(status_path))
|
|
117
|
+
else
|
|
118
|
+
process_exit_record(job_id:, cancelled: File.file?(cancel_path), clock:)
|
|
119
|
+
end
|
|
120
|
+
return record unless ACTIVE_STATES.include?(record["state"])
|
|
121
|
+
|
|
122
|
+
now = clock.now.utc.iso8601(6)
|
|
123
|
+
output_path = File.join(build_dir, job_id)
|
|
124
|
+
finalize_exit_record(record, output_path:, cancelled: File.file?(cancel_path), now:)
|
|
125
|
+
atomic_write(status_path, JSON.pretty_generate(record) << "\n")
|
|
126
|
+
record
|
|
127
|
+
rescue JSON::ParserError, SystemCallError
|
|
128
|
+
nil
|
|
129
|
+
ensure
|
|
130
|
+
FileUtils.rm_f(cancel_path) if defined?(cancel_path)
|
|
75
131
|
end
|
|
76
132
|
|
|
77
133
|
class << self
|
|
78
134
|
private
|
|
79
135
|
|
|
136
|
+
def finalize_exit_record(record, output_path:, cancelled:, now:)
|
|
137
|
+
if File.directory?(output_path) && !File.symlink?(output_path)
|
|
138
|
+
record.merge!(
|
|
139
|
+
"state" => "completed", "phase" => "completed", "percent" => 100.0,
|
|
140
|
+
"message" => "Conversion completed", "updated_at" => now, "finished_at" => now,
|
|
141
|
+
"output_path" => output_path, "files" => artifact_files(output_path), "error" => nil
|
|
142
|
+
)
|
|
143
|
+
elsif record["state"] == "cancelling" || cancelled
|
|
144
|
+
record.merge!(
|
|
145
|
+
"state" => "cancelled", "phase" => "cancelled", "message" => "Conversion cancelled",
|
|
146
|
+
"updated_at" => now, "finished_at" => now
|
|
147
|
+
)
|
|
148
|
+
else
|
|
149
|
+
record.merge!(
|
|
150
|
+
"state" => "failed", "phase" => "failed", "message" => "Conversion process exited",
|
|
151
|
+
"updated_at" => now, "finished_at" => now,
|
|
152
|
+
"error" => { "class" => "Lithop::Error", "message" => "conversion process exited before completion" }
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
80
157
|
def validate_job_id!(job_id)
|
|
81
158
|
return if job_id.is_a?(String) && job_id.match?(/\A[0-9A-Za-z][0-9A-Za-z._-]*\z/)
|
|
82
159
|
|
|
@@ -107,6 +184,39 @@ module Lithop
|
|
|
107
184
|
rescue JSON::ParserError, SystemCallError
|
|
108
185
|
nil
|
|
109
186
|
end
|
|
187
|
+
|
|
188
|
+
def final_status?(path)
|
|
189
|
+
File.file?(path) && FINAL_STATES.include?(JSON.parse(File.read(path))["state"])
|
|
190
|
+
rescue JSON::ParserError, SystemCallError
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def transition_mutex(path)
|
|
195
|
+
@transition_mutex_guard ||= Mutex.new
|
|
196
|
+
@transition_mutex_guard.synchronize do
|
|
197
|
+
@transition_mutexes ||= {}
|
|
198
|
+
@transition_mutexes[path] ||= Mutex.new
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def artifact_files(output_path)
|
|
203
|
+
Dir.glob("**/*", base: output_path).select do |name|
|
|
204
|
+
path = File.join(output_path, name)
|
|
205
|
+
File.file?(path) && !File.symlink?(path)
|
|
206
|
+
end.sort
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def process_exit_record(job_id:, cancelled:, clock:)
|
|
210
|
+
now = clock.now.utc.iso8601(6)
|
|
211
|
+
{
|
|
212
|
+
"schema_version" => SCHEMA_VERSION, "job_id" => job_id,
|
|
213
|
+
"state" => cancelled ? "cancelling" : "running",
|
|
214
|
+
"phase" => cancelled ? "cancelling" : "starting", "percent" => 0,
|
|
215
|
+
"message" => cancelled ? "Cancellation requested" : "Conversion process started",
|
|
216
|
+
"created_at" => now, "updated_at" => now, "started_at" => nil,
|
|
217
|
+
"finished_at" => nil, "output_path" => nil, "files" => nil, "error" => nil
|
|
218
|
+
}
|
|
219
|
+
end
|
|
110
220
|
end
|
|
111
221
|
|
|
112
222
|
private
|
|
@@ -139,6 +249,24 @@ module Lithop
|
|
|
139
249
|
@lock = nil
|
|
140
250
|
end
|
|
141
251
|
|
|
252
|
+
def release_transition_lock
|
|
253
|
+
if @transition_lock
|
|
254
|
+
@transition_lock.flock(File::LOCK_UN)
|
|
255
|
+
@transition_lock.close
|
|
256
|
+
@transition_lock = nil
|
|
257
|
+
end
|
|
258
|
+
if @transition_mutex_locked
|
|
259
|
+
@transition_mutex.unlock
|
|
260
|
+
@transition_mutex_locked = false
|
|
261
|
+
end
|
|
262
|
+
rescue IOError, SystemCallError
|
|
263
|
+
@transition_lock = nil
|
|
264
|
+
if @transition_mutex_locked
|
|
265
|
+
@transition_mutex.unlock
|
|
266
|
+
@transition_mutex_locked = false
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
142
270
|
def recover_temporary_directories
|
|
143
271
|
JobDirectory.recover(project_dir: @project_dir).each do |recovered|
|
|
144
272
|
mark_recovered_job(recovered)
|
|
@@ -35,6 +35,14 @@ module Lithop
|
|
|
35
35
|
|
|
36
36
|
def release_cache! = @topology.release_cache!
|
|
37
37
|
|
|
38
|
+
def native_indexed_mesh
|
|
39
|
+
Native.column_mesh(
|
|
40
|
+
@model.columns, @model.rows, @model.x_boundaries, @model.y_boundaries, @model.z_boundaries,
|
|
41
|
+
@intervals.flat_map { |value| value || [-1, -1] },
|
|
42
|
+
@topology.group_ids, @topology.group_count
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
38
46
|
def triangle_count
|
|
39
47
|
@model.rows.times.sum do |row|
|
|
40
48
|
@model.columns.times.sum do |column|
|
|
@@ -17,6 +17,7 @@ module Lithop
|
|
|
17
17
|
pigment_order: settings.pigment_order
|
|
18
18
|
)
|
|
19
19
|
@settings = settings
|
|
20
|
+
@material_arrays = {}
|
|
20
21
|
validate_samples!(cmys, heights, cell_cmys)
|
|
21
22
|
@cells = build_cells(cmys, heights, cell_cmys).freeze
|
|
22
23
|
maximum_layers = cells.map { |cell| cell.materials.length }.max
|
|
@@ -31,7 +32,9 @@ module Lithop
|
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def materials
|
|
34
|
-
@materials ||= cells.
|
|
35
|
+
@materials ||= cells.each_with_object({}) do |cell, result|
|
|
36
|
+
cell.materials.each { |material| result[material] = true }
|
|
37
|
+
end.keys.freeze
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
def recipe_counts
|
|
@@ -63,12 +66,21 @@ module Lithop
|
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def build_cells(cmys, heights, cell_cmys)
|
|
66
|
-
Array.new(rows * columns)
|
|
69
|
+
cell_heights = Array.new(rows * columns)
|
|
70
|
+
cell_colors = Array.new(rows * columns) do |index|
|
|
67
71
|
row, column = index.divmod(columns)
|
|
68
72
|
cmy = cell_cmys ? cell_cmys.fetch(index) : average(corners(cmys, row, column))
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
cell_heights[index] = @settings.quantize_thickness(average(corners(heights, row, column)))
|
|
74
|
+
cmy
|
|
75
|
+
end
|
|
76
|
+
recipes = if @palette.respond_to?(:nearest_many)
|
|
77
|
+
@palette.nearest_many(cell_colors)
|
|
78
|
+
else
|
|
79
|
+
cell_colors.map { |cmy| @palette.nearest(cmy) }
|
|
80
|
+
end
|
|
81
|
+
cell_heights.zip(recipes).map do |height, recipe|
|
|
82
|
+
materials = @material_arrays[[recipe, height]] ||= materials_for(recipe, height).freeze
|
|
83
|
+
Cell.new(height:, recipe:, materials:)
|
|
72
84
|
end
|
|
73
85
|
end
|
|
74
86
|
|
data/lib/lithop/model/palette.rb
CHANGED
|
@@ -49,13 +49,20 @@ module Lithop
|
|
|
49
49
|
@layer_count = layer_count
|
|
50
50
|
@pigment_order = pigment_order.dup.freeze
|
|
51
51
|
@recipes = build_recipes.freeze
|
|
52
|
+
@native_coverages = recipes.flat_map(&:coverage_cmy).freeze
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def nearest(cmy)
|
|
55
56
|
validate_cmy!(cmy)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
nearest_validated(cmy)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def nearest_many(cmys)
|
|
61
|
+
cmys.each { |cmy| validate_cmy!(cmy) }
|
|
62
|
+
return cmys.map { |cmy| nearest_validated(cmy) } unless Native.enabled?
|
|
63
|
+
|
|
64
|
+
indices = Native.palette_indices(cmys.flatten, @native_coverages)
|
|
65
|
+
indices.map { |index| recipes.fetch(index) }
|
|
59
66
|
end
|
|
60
67
|
|
|
61
68
|
def display_color(recipe)
|
|
@@ -97,5 +104,16 @@ module Lithop
|
|
|
97
104
|
|
|
98
105
|
raise InputError, "CMY must contain three values between 0 and 1"
|
|
99
106
|
end
|
|
107
|
+
|
|
108
|
+
def squared_distance(coverage, target)
|
|
109
|
+
cyan = coverage[0] - target[0]
|
|
110
|
+
magenta = coverage[1] - target[1]
|
|
111
|
+
yellow = coverage[2] - target[2]
|
|
112
|
+
(cyan * cyan) + (magenta * magenta) + (yellow * yellow)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def nearest_validated(cmy)
|
|
116
|
+
recipes.min_by { |recipe| squared_distance(recipe.coverage_cmy, cmy) }
|
|
117
|
+
end
|
|
100
118
|
end
|
|
101
119
|
end
|