lithop 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +9 -3
- data/RELEASING.md +3 -3
- data/docs/README.md +1 -0
- data/docs/artifacts.md +19 -2
- data/docs/bambu-studio.md +2 -0
- data/docs/color-model.md +2 -0
- data/docs/command.md +23 -5
- data/docs/config.md +30 -3
- data/docs/getting-started.md +5 -3
- data/docs/tiling.md +132 -0
- data/docs/troubleshooting.md +12 -0
- data/lib/lithop/calibration/export.rb +21 -8
- data/lib/lithop/cli/calibration.rb +3 -1
- data/lib/lithop/cli/image_options.rb +41 -2
- data/lib/lithop/cli.rb +8 -1
- data/lib/lithop/export/color_metadata.rb +55 -0
- data/lib/lithop/export/generation.rb +117 -20
- data/lib/lithop/export/job.rb +30 -16
- data/lib/lithop/export/metadata.rb +48 -3
- data/lib/lithop/export/planning.rb +97 -0
- data/lib/lithop/export/print_readme.rb +22 -4
- data/lib/lithop/export/result.rb +26 -4
- data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
- data/lib/lithop/formats/three_mf/model_writer.rb +245 -23
- data/lib/lithop/formats/three_mf/part_writer.rb +14 -6
- data/lib/lithop/formats/three_mf.rb +8 -5
- data/lib/lithop/image/pipeline.rb +17 -0
- data/lib/lithop/job_directory.rb +41 -0
- data/lib/lithop/job_execution.rb +250 -0
- data/lib/lithop/layer_structure.rb +11 -0
- data/lib/lithop/mesh/box_geometry.rb +41 -0
- data/lib/lithop/mesh/column_geometry.rb +2 -2
- data/lib/lithop/mesh/geometry.rb +21 -13
- data/lib/lithop/model/layer_region_model.rb +24 -0
- data/lib/lithop/reference_three_mf.rb +17 -1
- data/lib/lithop/settings.rb +116 -1
- data/lib/lithop/settings_file.rb +10 -4
- data/lib/lithop/tiling.rb +126 -0
- data/lib/lithop/version.rb +1 -1
- data/lib/lithop.rb +4 -0
- data/sig/lithop.rbs +77 -2
- metadata +6 -1
|
@@ -19,7 +19,11 @@ module Lithop
|
|
|
19
19
|
MODEL_CONTENT_TYPE = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"
|
|
20
20
|
ZIP_MUTEX = Mutex.new
|
|
21
21
|
# Triangles are indexed and then serialized, so their enumerable must be replayable.
|
|
22
|
-
Part = Data.define(:material, :triangles)
|
|
22
|
+
Part = Data.define(:material, :triangles, :name, :region, :build_z_offset) do
|
|
23
|
+
def initialize(material:, triangles:, name: nil, region: nil, build_z_offset: 0.0)
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
23
27
|
Result = Data.define(:vertex_count, :triangle_count, :material_count, :object_count,
|
|
24
28
|
:triangle_counts_by_material)
|
|
25
29
|
|
|
@@ -66,13 +70,13 @@ module Lithop
|
|
|
66
70
|
unless valid_materials.include?(part.material)
|
|
67
71
|
raise InputError, "3MF part uses an unknown material: #{part.material.inspect}"
|
|
68
72
|
end
|
|
73
|
+
unless part.build_z_offset.is_a?(Numeric) && part.build_z_offset.finite?
|
|
74
|
+
raise InputError, "3MF part build_z_offset must be a finite number"
|
|
75
|
+
end
|
|
69
76
|
|
|
70
77
|
part
|
|
71
78
|
end
|
|
72
79
|
raise InputError, "3MF requires at least one material part" if validated.empty?
|
|
73
|
-
if validated.map(&:material).uniq.length != validated.length
|
|
74
|
-
raise InputError, "3MF material parts must have unique materials"
|
|
75
|
-
end
|
|
76
80
|
|
|
77
81
|
validated.freeze
|
|
78
82
|
end
|
|
@@ -87,7 +91,6 @@ module Lithop
|
|
|
87
91
|
@instrumentation.measure(:three_mf_xml_zip) do
|
|
88
92
|
write_entry(archive, "[Content_Types].xml", content_types_xml)
|
|
89
93
|
write_entry(archive, "_rels/.rels", relationships_xml)
|
|
90
|
-
archive.put_next_entry("3D/3dmodel.model")
|
|
91
94
|
end
|
|
92
95
|
package = ThreeMFModelWriter.new(
|
|
93
96
|
archive, @parts, @profile, instrumentation: @instrumentation, settings: @settings
|
|
@@ -26,6 +26,7 @@ module Lithop
|
|
|
26
26
|
validate_input!(image)
|
|
27
27
|
image = image.flatten(background: [255, 255, 255]) if image.has_alpha?
|
|
28
28
|
image = to_srgb(image)
|
|
29
|
+
image = adjust_image(image)
|
|
29
30
|
sampled = @placement.call(image, @settings.sampling_grid).cast(:uchar)
|
|
30
31
|
|
|
31
32
|
rgbs = rgb_components(sampled)
|
|
@@ -72,6 +73,22 @@ module Lithop
|
|
|
72
73
|
image.icc_import(embedded: true, pcs: :xyz).colourspace(:srgb)
|
|
73
74
|
end
|
|
74
75
|
|
|
76
|
+
def adjust_image(image)
|
|
77
|
+
return image if image_adjustments.none?(&:nonzero?)
|
|
78
|
+
|
|
79
|
+
adjusted = image.cast(:float)
|
|
80
|
+
adjusted += @settings.brightness.fdiv(100) * 255
|
|
81
|
+
contrast_factor = 1.0 + @settings.contrast.fdiv(100)
|
|
82
|
+
adjusted = ((adjusted - 127.5) * contrast_factor) + 127.5
|
|
83
|
+
saturation_factor = 1.0 + @settings.saturation.fdiv(100)
|
|
84
|
+
luminance = (adjusted[0] * 0.299) + (adjusted[1] * 0.587) + (adjusted[2] * 0.114)
|
|
85
|
+
luminance + ((adjusted - luminance) * saturation_factor)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def image_adjustments
|
|
89
|
+
[@settings.brightness, @settings.contrast, @settings.saturation]
|
|
90
|
+
end
|
|
91
|
+
|
|
75
92
|
def color_cells(image)
|
|
76
93
|
return if @settings.color_pixel_size.nil?
|
|
77
94
|
|
data/lib/lithop/job_directory.rb
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
4
6
|
|
|
5
7
|
module Lithop
|
|
6
8
|
# Owns one temporary job directory and publishes it with an atomic rename.
|
|
7
9
|
class JobDirectory
|
|
10
|
+
OWNER_FILE = ".lithop-owner.json"
|
|
11
|
+
Recovered = Data.define(:job_id, :path)
|
|
12
|
+
|
|
8
13
|
attr_reader :destination, :temporary
|
|
9
14
|
|
|
10
15
|
def initialize(project_dir:, job_id:)
|
|
@@ -24,6 +29,37 @@ module Lithop
|
|
|
24
29
|
raise
|
|
25
30
|
end
|
|
26
31
|
|
|
32
|
+
def self.recover(project_dir:)
|
|
33
|
+
build_dir = File.join(File.expand_path(project_dir), "build")
|
|
34
|
+
Dir.glob(File.join(build_dir, ".tmp-*")).filter_map do |path|
|
|
35
|
+
recover_directory(path)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.recover_directory(path)
|
|
40
|
+
return if File.symlink?(path) || !File.directory?(path)
|
|
41
|
+
|
|
42
|
+
owner = JSON.parse(File.read(File.join(path, OWNER_FILE)))
|
|
43
|
+
job_id = owner.fetch("job_id")
|
|
44
|
+
return unless File.basename(path) == ".tmp-#{job_id}"
|
|
45
|
+
return if process_alive?(Integer(owner.fetch("pid")))
|
|
46
|
+
|
|
47
|
+
FileUtils.rm_rf(path)
|
|
48
|
+
Recovered.new(job_id:, path:)
|
|
49
|
+
rescue JSON::ParserError, KeyError, ArgumentError, SystemCallError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.process_alive?(pid)
|
|
54
|
+
Process.kill(0, pid)
|
|
55
|
+
true
|
|
56
|
+
rescue Errno::ESRCH
|
|
57
|
+
false
|
|
58
|
+
rescue Errno::EPERM
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
private_class_method :recover_directory, :process_alive?
|
|
62
|
+
|
|
27
63
|
private
|
|
28
64
|
|
|
29
65
|
def acquire
|
|
@@ -32,6 +68,10 @@ module Lithop
|
|
|
32
68
|
|
|
33
69
|
Dir.mkdir(temporary)
|
|
34
70
|
@owned = true
|
|
71
|
+
File.write(
|
|
72
|
+
File.join(temporary, OWNER_FILE),
|
|
73
|
+
JSON.generate(job_id: File.basename(destination), pid: Process.pid, created_at: Time.now.utc.iso8601(6))
|
|
74
|
+
)
|
|
35
75
|
rescue Errno::EEXIST
|
|
36
76
|
raise Error, "output already exists: #{File.exist?(destination) ? destination : temporary}"
|
|
37
77
|
end
|
|
@@ -39,6 +79,7 @@ module Lithop
|
|
|
39
79
|
def publish
|
|
40
80
|
raise Error, "output already exists: #{destination}" if File.exist?(destination)
|
|
41
81
|
|
|
82
|
+
FileUtils.rm_f(File.join(temporary, OWNER_FILE))
|
|
42
83
|
File.rename(temporary, destination)
|
|
43
84
|
@owned = false
|
|
44
85
|
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Lithop
|
|
8
|
+
class JobCancelled < Error; end
|
|
9
|
+
|
|
10
|
+
# Persists one conversion's state and limits a project to one active conversion.
|
|
11
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
12
|
+
class JobExecution
|
|
13
|
+
SCHEMA_VERSION = 1
|
|
14
|
+
ACTIVE_STATES = %w[queued running cancelling].freeze
|
|
15
|
+
FINAL_STATES = %w[completed failed cancelled].freeze
|
|
16
|
+
Progress = Data.define(:job_id, :state, :phase, :percent, :message, :updated_at)
|
|
17
|
+
|
|
18
|
+
attr_reader :job_id, :status_path
|
|
19
|
+
|
|
20
|
+
def initialize(project_dir:, job_id:, progress: nil, clock: Time)
|
|
21
|
+
validate_job_id!(job_id)
|
|
22
|
+
@project_dir = File.expand_path(project_dir)
|
|
23
|
+
@job_id = job_id
|
|
24
|
+
@progress_callback = progress
|
|
25
|
+
@clock = clock
|
|
26
|
+
@build_dir = File.join(@project_dir, "build")
|
|
27
|
+
@jobs_dir = File.join(@build_dir, ".jobs")
|
|
28
|
+
@status_path = File.join(@jobs_dir, "#{job_id}.json")
|
|
29
|
+
@cancel_path = File.join(@jobs_dir, "#{job_id}.cancel")
|
|
30
|
+
@lock_path = File.join(@build_dir, ".lithop.lock")
|
|
31
|
+
@lock = nil
|
|
32
|
+
@record = base_record
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
prepare
|
|
37
|
+
transition("queued", phase: "queued", percent: 0, message: "Waiting to start")
|
|
38
|
+
acquire_lock
|
|
39
|
+
recover_temporary_directories
|
|
40
|
+
raise_if_cancelled!
|
|
41
|
+
transition("running", phase: "starting", percent: 0, message: "Conversion started", started_at: timestamp)
|
|
42
|
+
result = yield self
|
|
43
|
+
raise_if_cancelled!
|
|
44
|
+
complete(result)
|
|
45
|
+
result
|
|
46
|
+
rescue JobCancelled, Interrupt => e
|
|
47
|
+
record_terminal_state(
|
|
48
|
+
"cancelled", phase: "cancelled", message: "Conversion cancelled", error: e
|
|
49
|
+
)
|
|
50
|
+
raise
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
record_terminal_state("failed", phase: "failed", message: "Conversion failed", error: e)
|
|
53
|
+
raise
|
|
54
|
+
ensure
|
|
55
|
+
release_lock
|
|
56
|
+
remove_cancel_request if prepared?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def progress(phase:, percent:, message:)
|
|
60
|
+
raise_if_cancelled!
|
|
61
|
+
transition("running", phase: phase.to_s, percent: normalize_percent(percent), message: message.to_s)
|
|
62
|
+
raise_if_cancelled!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.request_cancel(project_dir:, job_id:, clock: Time)
|
|
66
|
+
validate_job_id!(job_id)
|
|
67
|
+
jobs_dir = File.join(File.expand_path(project_dir), "build", ".jobs")
|
|
68
|
+
FileUtils.mkdir_p(jobs_dir)
|
|
69
|
+
status_path = File.join(jobs_dir, "#{job_id}.json")
|
|
70
|
+
path = File.join(jobs_dir, "#{job_id}.cancel")
|
|
71
|
+
requested_at = clock.now.utc.iso8601(6)
|
|
72
|
+
atomic_write(path, JSON.generate(job_id:, requested_at:))
|
|
73
|
+
mark_cancelling(status_path, requested_at)
|
|
74
|
+
path
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class << self
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def validate_job_id!(job_id)
|
|
81
|
+
return if job_id.is_a?(String) && job_id.match?(/\A[0-9A-Za-z][0-9A-Za-z._-]*\z/)
|
|
82
|
+
|
|
83
|
+
raise Error, "invalid job ID: #{job_id.inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def atomic_write(path, contents)
|
|
87
|
+
temporary = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
88
|
+
File.write(temporary, contents)
|
|
89
|
+
File.rename(temporary, path)
|
|
90
|
+
ensure
|
|
91
|
+
FileUtils.rm_f(temporary) if defined?(temporary)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mark_cancelling(path, requested_at)
|
|
95
|
+
return unless File.file?(path)
|
|
96
|
+
|
|
97
|
+
record = JSON.parse(File.read(path))
|
|
98
|
+
return unless ACTIVE_STATES.include?(record["state"])
|
|
99
|
+
|
|
100
|
+
record.merge!(
|
|
101
|
+
"state" => "cancelling",
|
|
102
|
+
"phase" => "cancelling",
|
|
103
|
+
"message" => "Cancellation requested",
|
|
104
|
+
"updated_at" => requested_at
|
|
105
|
+
)
|
|
106
|
+
atomic_write(path, JSON.pretty_generate(record) << "\n")
|
|
107
|
+
rescue JSON::ParserError, SystemCallError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def validate_job_id!(value) = self.class.send(:validate_job_id!, value)
|
|
115
|
+
|
|
116
|
+
def prepare
|
|
117
|
+
FileUtils.mkdir_p(@jobs_dir)
|
|
118
|
+
raise Error, "job state already exists: #{@status_path}" if File.exist?(@status_path)
|
|
119
|
+
|
|
120
|
+
@prepared = true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def prepared? = @prepared
|
|
124
|
+
|
|
125
|
+
def acquire_lock
|
|
126
|
+
@lock = File.open(@lock_path, File::RDWR | File::CREAT, 0o600)
|
|
127
|
+
return if @lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
128
|
+
|
|
129
|
+
raise Error, "another conversion is already running in project: #{@project_dir}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def release_lock
|
|
133
|
+
return unless @lock
|
|
134
|
+
|
|
135
|
+
@lock.flock(File::LOCK_UN)
|
|
136
|
+
@lock.close
|
|
137
|
+
@lock = nil
|
|
138
|
+
rescue IOError, SystemCallError
|
|
139
|
+
@lock = nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def recover_temporary_directories
|
|
143
|
+
JobDirectory.recover(project_dir: @project_dir).each do |recovered|
|
|
144
|
+
mark_recovered_job(recovered)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def mark_recovered_job(recovered)
|
|
149
|
+
path = File.join(@jobs_dir, "#{recovered.job_id}.json")
|
|
150
|
+
return unless File.file?(path)
|
|
151
|
+
|
|
152
|
+
record = JSON.parse(File.read(path))
|
|
153
|
+
return unless ACTIVE_STATES.include?(record["state"])
|
|
154
|
+
|
|
155
|
+
now = timestamp
|
|
156
|
+
record.merge!(
|
|
157
|
+
"state" => "failed",
|
|
158
|
+
"phase" => "recovered",
|
|
159
|
+
"message" => "Abandoned temporary data recovered",
|
|
160
|
+
"updated_at" => now,
|
|
161
|
+
"finished_at" => now,
|
|
162
|
+
"error" => { "class" => "Lithop::Error", "message" => "conversion process ended before completion" }
|
|
163
|
+
)
|
|
164
|
+
self.class.send(:atomic_write, path, JSON.pretty_generate(record) << "\n")
|
|
165
|
+
rescue JSON::ParserError, SystemCallError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def complete(result)
|
|
170
|
+
additions = { finished_at: timestamp }
|
|
171
|
+
additions[:output_path] = result.path if result.respond_to?(:path)
|
|
172
|
+
additions[:files] = result.files if result.respond_to?(:files)
|
|
173
|
+
transition("completed", phase: "completed", percent: 100, message: "Conversion completed", **additions)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def transition(state, phase:, percent:, message:, **additions)
|
|
177
|
+
now = timestamp
|
|
178
|
+
@record.merge!(
|
|
179
|
+
state:,
|
|
180
|
+
phase:,
|
|
181
|
+
percent: normalize_percent(percent),
|
|
182
|
+
message:,
|
|
183
|
+
updated_at: now,
|
|
184
|
+
**additions
|
|
185
|
+
)
|
|
186
|
+
self.class.send(:atomic_write, @status_path, JSON.pretty_generate(@record) << "\n")
|
|
187
|
+
notify
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def notify
|
|
191
|
+
return unless @progress_callback
|
|
192
|
+
|
|
193
|
+
@progress_callback.call(
|
|
194
|
+
Progress.new(
|
|
195
|
+
job_id:,
|
|
196
|
+
state: @record.fetch(:state),
|
|
197
|
+
phase: @record.fetch(:phase),
|
|
198
|
+
percent: @record.fetch(:percent),
|
|
199
|
+
message: @record.fetch(:message),
|
|
200
|
+
updated_at: @record.fetch(:updated_at)
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
rescue StandardError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def record_terminal_state(state, phase:, message:, error:)
|
|
208
|
+
return unless prepared?
|
|
209
|
+
|
|
210
|
+
transition(state, phase:, percent: current_percent, message:, finished_at: timestamp,
|
|
211
|
+
error: error_record(error))
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def raise_if_cancelled!
|
|
217
|
+
raise JobCancelled, "job #{job_id} was cancelled" if File.file?(@cancel_path)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def remove_cancel_request
|
|
221
|
+
FileUtils.rm_f(@cancel_path) if @cancel_path
|
|
222
|
+
rescue SystemCallError
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def base_record
|
|
227
|
+
now = timestamp
|
|
228
|
+
{
|
|
229
|
+
schema_version: SCHEMA_VERSION,
|
|
230
|
+
job_id:,
|
|
231
|
+
state: "queued",
|
|
232
|
+
phase: "queued",
|
|
233
|
+
percent: 0,
|
|
234
|
+
message: "Waiting to start",
|
|
235
|
+
created_at: now,
|
|
236
|
+
updated_at: now,
|
|
237
|
+
started_at: nil,
|
|
238
|
+
finished_at: nil,
|
|
239
|
+
output_path: nil,
|
|
240
|
+
files: nil,
|
|
241
|
+
error: nil
|
|
242
|
+
}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def error_record(error) = { class: error.class.name, message: error.message }
|
|
246
|
+
def timestamp = @clock.now.utc.iso8601(6)
|
|
247
|
+
def current_percent = @record.fetch(:percent, 0)
|
|
248
|
+
def normalize_percent(value) = Float(value).clamp(0.0, 100.0)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -61,6 +61,17 @@ module Lithop
|
|
|
61
61
|
def color_region_thickness = (color_layer_count * layer_height).round(9)
|
|
62
62
|
def base_white_thickness = (base_white_layers * first_layer_height).round(9)
|
|
63
63
|
def minimum_top_white_thickness = (minimum_physical_thickness - color_end).round(9)
|
|
64
|
+
def color_region_start = base_white_thickness
|
|
65
|
+
def color_region_end = color_end
|
|
66
|
+
|
|
67
|
+
def color_region_layer_ranges(part_count)
|
|
68
|
+
size, remainder = color_layer_count.divmod(part_count)
|
|
69
|
+
start = base_white_layers
|
|
70
|
+
Array.new(part_count) do |index|
|
|
71
|
+
finish = start + size + (index < remainder ? 1 : 0)
|
|
72
|
+
(start...finish).tap { start = finish }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
64
75
|
|
|
65
76
|
def materials_for(recipe_layers, height)
|
|
66
77
|
top_white_layers = top_white_layer_count(height, pigment_layers: recipe_layers.length)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Generates a closed axis-aligned box with outward-facing triangles.
|
|
5
|
+
class BoxGeometry
|
|
6
|
+
attr_reader :triangle_count
|
|
7
|
+
|
|
8
|
+
def initialize(width:, height:, z_min:, z_max:)
|
|
9
|
+
@width = width
|
|
10
|
+
@height = height
|
|
11
|
+
@z_min = z_min
|
|
12
|
+
@z_max = z_max
|
|
13
|
+
@triangle_count = 12
|
|
14
|
+
raise InputError, "box dimensions must be positive" unless width.positive? && height.positive? && z_max > z_min
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def each_triangle(&block)
|
|
18
|
+
return enum_for(__method__) unless block
|
|
19
|
+
|
|
20
|
+
x0 = 0.0
|
|
21
|
+
y0 = 0.0
|
|
22
|
+
x1 = @width
|
|
23
|
+
y1 = @height
|
|
24
|
+
z0 = @z_min
|
|
25
|
+
z1 = @z_max
|
|
26
|
+
quad([x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0], &block)
|
|
27
|
+
quad([x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], &block)
|
|
28
|
+
quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], &block)
|
|
29
|
+
quad([x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1], &block)
|
|
30
|
+
quad([x1, y1, z0], [x0, y1, z0], [x0, y1, z1], [x1, y1, z1], &block)
|
|
31
|
+
quad([x0, y1, z0], [x0, y0, z0], [x0, y0, z1], [x0, y1, z1], &block)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def quad(a, b, c, d)
|
|
37
|
+
yield [a, b, c]
|
|
38
|
+
yield [a, c, d]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -7,8 +7,8 @@ module Lithop
|
|
|
7
7
|
include Enumerable
|
|
8
8
|
|
|
9
9
|
def initialize(model:, material:)
|
|
10
|
-
unless Palette::
|
|
11
|
-
raise InputError, "column geometry requires a
|
|
10
|
+
unless Palette::MATERIALS.include?(material)
|
|
11
|
+
raise InputError, "column geometry requires a CMYW material: #{material.inspect}"
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
@model = model
|
data/lib/lithop/mesh/geometry.rb
CHANGED
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
module Lithop
|
|
4
4
|
# Generates the closed triangle mesh for a monochrome height field.
|
|
5
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
5
6
|
class Geometry
|
|
6
|
-
|
|
7
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
8
|
+
def initialize(
|
|
9
|
+
heights:, columns:, rows:, x_boundaries:, y_boundaries:, optimize_bottom: false, base_height: 0.0
|
|
10
|
+
)
|
|
7
11
|
@heights = heights
|
|
8
12
|
@columns = columns
|
|
9
13
|
@rows = rows
|
|
10
14
|
@x = x_boundaries
|
|
11
15
|
@y = y_boundaries
|
|
12
16
|
@optimize_bottom = optimize_bottom
|
|
17
|
+
@base_height = base_height
|
|
13
18
|
expected = (columns + 1) * (rows + 1)
|
|
14
19
|
raise InputError, "expected #{expected} vertex heights, got #{heights.length}" unless heights.length == expected
|
|
20
|
+
raise InputError, "base height must be finite" unless base_height.is_a?(Numeric) && base_height.finite?
|
|
21
|
+
raise InputError, "vertex heights must not be below the base" if heights.any? { |height| height < base_height }
|
|
15
22
|
end
|
|
16
23
|
|
|
17
24
|
def each_triangle(&block)
|
|
@@ -40,7 +47,8 @@ module Lithop
|
|
|
40
47
|
yield [top_left, bottom_right, top_right]
|
|
41
48
|
return if @optimize_bottom
|
|
42
49
|
|
|
43
|
-
quad([x0, y1,
|
|
50
|
+
quad([x0, y1, @base_height], [x1, y1, @base_height],
|
|
51
|
+
[x1, y0, @base_height], [x0, y0, @base_height], &)
|
|
44
52
|
end
|
|
45
53
|
|
|
46
54
|
def emit_perimeter(&)
|
|
@@ -58,8 +66,8 @@ module Lithop
|
|
|
58
66
|
y0 = @y.last - @y.fetch(row + 1)
|
|
59
67
|
y1 = @y.last - @y.fetch(row)
|
|
60
68
|
quad(
|
|
61
|
-
[0.0, y0,
|
|
62
|
-
[0.0, y1, height(row, 0)], [0.0, y1,
|
|
69
|
+
[0.0, y0, @base_height], [0.0, y0, height(row + 1, 0)],
|
|
70
|
+
[0.0, y1, height(row, 0)], [0.0, y1, @base_height], &
|
|
63
71
|
)
|
|
64
72
|
end
|
|
65
73
|
|
|
@@ -68,7 +76,7 @@ module Lithop
|
|
|
68
76
|
y0 = @y.last - @y.fetch(row + 1)
|
|
69
77
|
y1 = @y.last - @y.fetch(row)
|
|
70
78
|
quad(
|
|
71
|
-
[x, y0,
|
|
79
|
+
[x, y0, @base_height], [x, y1, @base_height],
|
|
72
80
|
[x, y1, height(row, @columns)], [x, y0, height(row + 1, @columns)], &
|
|
73
81
|
)
|
|
74
82
|
end
|
|
@@ -77,7 +85,7 @@ module Lithop
|
|
|
77
85
|
x0 = @x.fetch(column)
|
|
78
86
|
x1 = @x.fetch(column + 1)
|
|
79
87
|
quad(
|
|
80
|
-
[x0, 0.0,
|
|
88
|
+
[x0, 0.0, @base_height], [x1, 0.0, @base_height],
|
|
81
89
|
[x1, 0.0, height(@rows, column + 1)], [x0, 0.0, height(@rows, column)], &
|
|
82
90
|
)
|
|
83
91
|
end
|
|
@@ -87,18 +95,18 @@ module Lithop
|
|
|
87
95
|
x1 = @x.fetch(column + 1)
|
|
88
96
|
y = @y.last
|
|
89
97
|
quad(
|
|
90
|
-
[x0, y,
|
|
91
|
-
[x1, y, height(0, column + 1)], [x1, y,
|
|
98
|
+
[x0, y, @base_height], [x0, y, height(0, column)],
|
|
99
|
+
[x1, y, height(0, column + 1)], [x1, y, @base_height], &
|
|
92
100
|
)
|
|
93
101
|
end
|
|
94
102
|
|
|
95
103
|
def emit_bottom_fan
|
|
96
|
-
center = [@x.last.fdiv(2), @y.last.fdiv(2),
|
|
104
|
+
center = [@x.last.fdiv(2), @y.last.fdiv(2), @base_height]
|
|
97
105
|
rendered_y = @y.reverse.map { |y| @y.last - y }
|
|
98
|
-
perimeter = rendered_y.map { |y| [0.0, y,
|
|
99
|
-
@x.drop(1).each { |x| perimeter << [x, @y.last,
|
|
100
|
-
rendered_y.reverse.drop(1).each { |y| perimeter << [@x.last, y,
|
|
101
|
-
@x.reverse.drop(1).first(@x.length - 2).each { |x| perimeter << [x, 0.0,
|
|
106
|
+
perimeter = rendered_y.map { |y| [0.0, y, @base_height] }
|
|
107
|
+
@x.drop(1).each { |x| perimeter << [x, @y.last, @base_height] }
|
|
108
|
+
rendered_y.reverse.drop(1).each { |y| perimeter << [@x.last, y, @base_height] }
|
|
109
|
+
@x.reverse.drop(1).first(@x.length - 2).each { |x| perimeter << [x, 0.0, @base_height] }
|
|
102
110
|
perimeter.each_with_index do |from, index|
|
|
103
111
|
yield [center, from, perimeter.fetch((index + 1) % perimeter.length)]
|
|
104
112
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Restricts a material model to one contiguous Z-layer range while preserving global Z coordinates.
|
|
5
|
+
class LayerRegionModel
|
|
6
|
+
Cell = Data.define(:materials)
|
|
7
|
+
|
|
8
|
+
attr_reader :columns, :rows, :x_boundaries, :y_boundaries, :z_boundaries, :cells, :layer_range
|
|
9
|
+
|
|
10
|
+
def initialize(model:, layer_range:)
|
|
11
|
+
@columns = model.columns
|
|
12
|
+
@rows = model.rows
|
|
13
|
+
@x_boundaries = model.x_boundaries
|
|
14
|
+
@y_boundaries = model.y_boundaries
|
|
15
|
+
@layer_range = layer_range
|
|
16
|
+
@z_boundaries = model.z_boundaries[layer_range.begin..layer_range.end].freeze
|
|
17
|
+
@cells = model.cells.map do |cell|
|
|
18
|
+
Cell.new(materials: cell.materials.slice(layer_range).to_a.freeze)
|
|
19
|
+
end.freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def materials = @materials ||= cells.flat_map(&:materials).uniq.freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -7,6 +7,7 @@ require "zip"
|
|
|
7
7
|
|
|
8
8
|
module Lithop
|
|
9
9
|
# Streams structural measurements from an existing 3MF without retaining its meshes.
|
|
10
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
10
11
|
class ReferenceThreeMF
|
|
11
12
|
def initialize(path)
|
|
12
13
|
@path = File.expand_path(path)
|
|
@@ -63,6 +64,7 @@ module Lithop
|
|
|
63
64
|
def start_object(event)
|
|
64
65
|
{
|
|
65
66
|
id: event[1]["id"], type: event[1]["type"], vertices: 0, triangles: 0, components: 0,
|
|
67
|
+
component_refs: [],
|
|
66
68
|
minimum: [nil, nil, nil], maximum: [nil, nil, nil]
|
|
67
69
|
}
|
|
68
70
|
end
|
|
@@ -80,6 +82,10 @@ module Lithop
|
|
|
80
82
|
current[:triangles] += 1
|
|
81
83
|
when "component"
|
|
82
84
|
current[:components] += 1
|
|
85
|
+
current[:component_refs] << {
|
|
86
|
+
object_id: event[1]["objectid"],
|
|
87
|
+
transform: transform_values(event[1]["transform"])
|
|
88
|
+
}
|
|
83
89
|
end
|
|
84
90
|
end
|
|
85
91
|
|
|
@@ -105,7 +111,11 @@ module Lithop
|
|
|
105
111
|
|
|
106
112
|
document.get_elements("config/plate").map do |plate|
|
|
107
113
|
metadata = plate.get_elements("metadata").to_h { |entry| [entry.attributes["key"], entry.attributes["value"]] }
|
|
108
|
-
|
|
114
|
+
object_ids = plate.get_elements("model_instance").filter_map do |instance|
|
|
115
|
+
entry = instance.get_elements("metadata").find { |item| item.attributes["key"] == "object_id" }
|
|
116
|
+
entry && entry.attributes["value"]
|
|
117
|
+
end
|
|
118
|
+
{ id: integer_or_nil(metadata["plater_id"]), name: metadata["plater_name"], object_ids: }
|
|
109
119
|
end
|
|
110
120
|
end
|
|
111
121
|
|
|
@@ -115,5 +125,11 @@ module Lithop
|
|
|
115
125
|
end
|
|
116
126
|
|
|
117
127
|
def integer_or_nil(value) = value && Integer(value)
|
|
128
|
+
|
|
129
|
+
def transform_values(value)
|
|
130
|
+
return unless value
|
|
131
|
+
|
|
132
|
+
value.split.map { |number| Float(number) }
|
|
133
|
+
end
|
|
118
134
|
end
|
|
119
135
|
end
|