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,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/document"
|
|
4
|
+
require "rexml/xpath"
|
|
5
|
+
|
|
6
|
+
module Lithop
|
|
7
|
+
# Applies the self-contained, static subset accepted for SVG input.
|
|
8
|
+
class SVGDocumentValidator
|
|
9
|
+
FORBIDDEN_ELEMENTS = %w[script foreignobject animate animatemotion animatetransform set].freeze
|
|
10
|
+
LENGTH = /\A\+?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?(?:px|mm|in)?\z/i
|
|
11
|
+
NUMBER = /[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/
|
|
12
|
+
URL_REFERENCE = /url\s*\(\s*(?:(['"])(.*?)\1|([^)]*))\s*\)/im
|
|
13
|
+
|
|
14
|
+
def call(data)
|
|
15
|
+
validate_declarations!(data)
|
|
16
|
+
document = parse(data)
|
|
17
|
+
validate_document!(document)
|
|
18
|
+
rescue REXML::ParseException => e
|
|
19
|
+
raise InputError, "invalid SVG XML: #{e.message.lines.first.to_s.strip}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def parse(data)
|
|
25
|
+
utf8 = data.dup.force_encoding(Encoding::UTF_8)
|
|
26
|
+
raise InputError, "SVG input must use UTF-8 XML" unless utf8.valid_encoding?
|
|
27
|
+
|
|
28
|
+
document = REXML::Document.new(utf8)
|
|
29
|
+
root = document.root
|
|
30
|
+
raise InputError, "input must be a JPEG, PNG, or SVG image" unless root&.name&.casecmp?("svg")
|
|
31
|
+
|
|
32
|
+
encoding = document.xml_decl&.encoding
|
|
33
|
+
raise InputError, "SVG input must use UTF-8 XML" if encoding && !encoding.casecmp?("UTF-8")
|
|
34
|
+
|
|
35
|
+
document
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_declarations!(data)
|
|
39
|
+
return unless data.match?(/<!DOCTYPE\b|<!ENTITY\b|<\?xml-stylesheet\b/i)
|
|
40
|
+
|
|
41
|
+
raise InputError, "SVG DTDs, entities, and external stylesheets are not supported"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate_document!(document)
|
|
45
|
+
validate_dimensions!(document.root)
|
|
46
|
+
REXML::XPath.each(document, "//*") { |element| validate_element!(element) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_dimensions!(root)
|
|
50
|
+
width = root.attributes["width"]
|
|
51
|
+
height = root.attributes["height"]
|
|
52
|
+
view_box = root.attributes["viewBox"]
|
|
53
|
+
|
|
54
|
+
validate_explicit_dimensions!(width, height) if width || height
|
|
55
|
+
raise InputError, "SVG must define positive width and height or a viewBox" unless width || view_box
|
|
56
|
+
|
|
57
|
+
validate_view_box!(view_box) if view_box
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_explicit_dimensions!(width, height)
|
|
61
|
+
return if width && height && valid_length?(width) && valid_length?(height)
|
|
62
|
+
|
|
63
|
+
raise InputError, "SVG width and height must be positive values in px, mm, or in"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_view_box!(view_box)
|
|
67
|
+
values = view_box.scan(NUMBER)
|
|
68
|
+
remainder = view_box.gsub(NUMBER, "").gsub(/[\s,]/, "")
|
|
69
|
+
valid = remainder.empty? && values.length == 4 && values.all? { |value| finite_number?(value) }
|
|
70
|
+
valid &&= values[2].to_f.positive? && values[3].to_f.positive?
|
|
71
|
+
raise InputError, "SVG viewBox must contain four finite numbers with positive width and height" unless valid
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid_length?(value)
|
|
75
|
+
value = value.strip
|
|
76
|
+
value.match?(LENGTH) && finite_number?(value.sub(/(?:px|mm|in)\z/i, "")) && value.to_f.positive?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def finite_number?(value)
|
|
80
|
+
Float(value).finite?
|
|
81
|
+
rescue ArgumentError
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_element!(element)
|
|
86
|
+
if FORBIDDEN_ELEMENTS.include?(element.name.downcase)
|
|
87
|
+
raise InputError, "SVG element <#{element.name}> is not supported"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
element.attributes.each_attribute do |attribute|
|
|
91
|
+
name = attribute.expanded_name.downcase
|
|
92
|
+
value = attribute.value.to_s
|
|
93
|
+
validate_href!(value) if name == "href" || name.end_with?(":href")
|
|
94
|
+
raise InputError, "SVG xml:base is not supported" if name == "xml:base"
|
|
95
|
+
|
|
96
|
+
validate_css_references!(value)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return unless element.name.casecmp?("style")
|
|
100
|
+
|
|
101
|
+
validate_css_references!(element.texts.map(&:value).join)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_href!(value)
|
|
105
|
+
value = value.strip
|
|
106
|
+
return if value.match?(/\A#[^\s#]+\z/)
|
|
107
|
+
|
|
108
|
+
raise InputError, "SVG external and data references are not supported"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_css_references!(value)
|
|
112
|
+
css = normalize_css(value)
|
|
113
|
+
raise InputError, "SVG external CSS imports are not supported" if css.match?(/@import\b/i)
|
|
114
|
+
|
|
115
|
+
remainder = css.gsub(URL_REFERENCE) do
|
|
116
|
+
reference = Regexp.last_match(2) || Regexp.last_match(3)
|
|
117
|
+
validate_href!(reference.strip)
|
|
118
|
+
""
|
|
119
|
+
end
|
|
120
|
+
return unless remainder.match?(/url\s*\(/i)
|
|
121
|
+
|
|
122
|
+
raise InputError, "SVG contains an invalid CSS URL reference"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def normalize_css(value)
|
|
126
|
+
css = value.gsub(%r{/\*.*?\*/}m, "").gsub(/\\(?:\r\n|[\n\r\f])/, "")
|
|
127
|
+
css = css.gsub(/\\([0-9a-f]{1,6})[ \t\r\n\f]?/i) { [Regexp.last_match(1).to_i(16)].pack("U") }
|
|
128
|
+
css.gsub(/\\(.)/m, "\\1")
|
|
129
|
+
rescue RangeError
|
|
130
|
+
raise InputError, "SVG contains an invalid CSS escape"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Rasterizes a validated SVG at the resolution required by the sampling grids.
|
|
135
|
+
class SVGLoader
|
|
136
|
+
BASE_DPI = 96.0
|
|
137
|
+
MAXIMUM_FILE_BYTES = 15_000_000
|
|
138
|
+
|
|
139
|
+
def initialize(settings)
|
|
140
|
+
@settings = settings
|
|
141
|
+
@validator = SVGDocumentValidator.new
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def call(path, access:)
|
|
145
|
+
data = read(path)
|
|
146
|
+
@validator.call(data)
|
|
147
|
+
ensure_loader!
|
|
148
|
+
|
|
149
|
+
probe = render(data, scale: 1.0, access: :sequential)
|
|
150
|
+
validate_rendered_image!(probe, pixels: false)
|
|
151
|
+
scale = render_scale(probe)
|
|
152
|
+
validate_planned_pixels!(probe, scale)
|
|
153
|
+
|
|
154
|
+
image = render(data, scale:, access:)
|
|
155
|
+
validate_rendered_image!(image)
|
|
156
|
+
image
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def read(path)
|
|
162
|
+
raise InputError, "SVG input exceeds #{MAXIMUM_FILE_BYTES} bytes" if File.size(path) > MAXIMUM_FILE_BYTES
|
|
163
|
+
|
|
164
|
+
data = File.binread(path, MAXIMUM_FILE_BYTES + 1)
|
|
165
|
+
raise InputError, "SVG input exceeds #{MAXIMUM_FILE_BYTES} bytes" if data.bytesize > MAXIMUM_FILE_BYTES
|
|
166
|
+
raise InputError, "compressed SVG (SVGZ) is not supported" if data.start_with?("\x1F\x8B".b)
|
|
167
|
+
|
|
168
|
+
data
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def ensure_loader!
|
|
172
|
+
Vips::Operation.new("svgload_buffer")
|
|
173
|
+
rescue Vips::Error
|
|
174
|
+
raise InputError, "SVG input requires libvips with librsvg support; install libvips with SVG support"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def render(data, scale:, access:)
|
|
178
|
+
Vips::Image.new_from_buffer(data, "", dpi: BASE_DPI, scale:, unlimited: false, access:)
|
|
179
|
+
rescue Vips::Error => e
|
|
180
|
+
raise InputError, "could not render SVG: #{e.message}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def render_scale(image)
|
|
184
|
+
scales = sampling_grids.map do |grid|
|
|
185
|
+
width, height = grid.content_sample_size
|
|
186
|
+
ratios = [width.fdiv(image.width), height.fdiv(image.height)]
|
|
187
|
+
@settings.image_fit == "contain" ? ratios.min * @settings.crop_scale : ratios.max / @settings.crop_scale
|
|
188
|
+
end
|
|
189
|
+
[*scales, 1.0 / image.width, 1.0 / image.height].max
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def sampling_grids
|
|
193
|
+
grids = [@settings.sampling_grid]
|
|
194
|
+
grids << @settings.color_sampling_grid if @settings.color_pixel_size
|
|
195
|
+
grids
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_planned_pixels!(image, scale)
|
|
199
|
+
width = [(image.width * scale).ceil, 1].max
|
|
200
|
+
height = [(image.height * scale).ceil, 1].max
|
|
201
|
+
validate_pixel_count!(width, height, "SVG rasterization would exceed")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def validate_rendered_image!(image, pixels: true)
|
|
205
|
+
unless image.get("vips-loader") == "svgload_buffer" && image.width.positive? && image.height.positive?
|
|
206
|
+
raise InputError, "could not render SVG with positive dimensions"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
validate_pixel_count!(image.width, image.height, "SVG rasterization exceeds") if pixels
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def validate_pixel_count!(width, height, prefix)
|
|
213
|
+
return unless width * height > @settings.maximum_input_pixels
|
|
214
|
+
|
|
215
|
+
raise InputError, "#{prefix} #{@settings.maximum_input_pixels} pixels"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Measures named generation stages for an optional benchmark listener.
|
|
5
|
+
class Instrumentation
|
|
6
|
+
def initialize(listener = nil)
|
|
7
|
+
@listener = listener
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def measure(stage)
|
|
11
|
+
return yield unless @listener
|
|
12
|
+
|
|
13
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
14
|
+
yield
|
|
15
|
+
ensure
|
|
16
|
+
if @listener && started
|
|
17
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
18
|
+
@listener.call(stage, elapsed)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Lithop
|
|
6
|
+
# Owns one temporary job directory and publishes it with an atomic rename.
|
|
7
|
+
class JobDirectory
|
|
8
|
+
attr_reader :destination, :temporary
|
|
9
|
+
|
|
10
|
+
def initialize(project_dir:, job_id:)
|
|
11
|
+
build_dir = File.join(File.expand_path(project_dir), "build")
|
|
12
|
+
@destination = File.join(build_dir, job_id)
|
|
13
|
+
@temporary = File.join(build_dir, ".tmp-#{job_id}")
|
|
14
|
+
@owned = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
acquire
|
|
19
|
+
result = yield temporary
|
|
20
|
+
publish
|
|
21
|
+
[destination, result]
|
|
22
|
+
rescue StandardError, Interrupt
|
|
23
|
+
cleanup
|
|
24
|
+
raise
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def acquire
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(temporary))
|
|
31
|
+
raise Error, "output already exists: #{destination}" if File.exist?(destination)
|
|
32
|
+
|
|
33
|
+
Dir.mkdir(temporary)
|
|
34
|
+
@owned = true
|
|
35
|
+
rescue Errno::EEXIST
|
|
36
|
+
raise Error, "output already exists: #{File.exist?(destination) ? destination : temporary}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def publish
|
|
40
|
+
raise Error, "output already exists: #{destination}" if File.exist?(destination)
|
|
41
|
+
|
|
42
|
+
File.rename(temporary, destination)
|
|
43
|
+
@owned = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def cleanup
|
|
47
|
+
return unless @owned
|
|
48
|
+
|
|
49
|
+
FileUtils.rm_rf(temporary)
|
|
50
|
+
@owned = false
|
|
51
|
+
rescue StandardError
|
|
52
|
+
@owned = false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Calculates printable Z boundaries and the physical CMYW layer structure.
|
|
5
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
6
|
+
class LayerStructure
|
|
7
|
+
MICRONS_PER_MM = 1_000
|
|
8
|
+
MINIMUM_TOP_WHITE_LAYERS = 3
|
|
9
|
+
|
|
10
|
+
attr_reader :first_layer_height, :layer_height, :color_layer_count, :base_white_layers,
|
|
11
|
+
:top_layer_height, :top_white_start, :minimum_top_white_layers,
|
|
12
|
+
:effective_minimum_thickness, :effective_maximum_thickness
|
|
13
|
+
|
|
14
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
15
|
+
def initialize(
|
|
16
|
+
first_layer_height:, layer_height:, color_layer_count:, minimum_thickness:, maximum_thickness:,
|
|
17
|
+
base_white_layers: 1, top_layer_height: nil, top_white_start: nil,
|
|
18
|
+
minimum_top_white_layers: MINIMUM_TOP_WHITE_LAYERS, exact_maximum_thickness: false
|
|
19
|
+
)
|
|
20
|
+
@first_layer_height = first_layer_height
|
|
21
|
+
@layer_height = layer_height
|
|
22
|
+
@color_layer_count = color_layer_count
|
|
23
|
+
@base_white_layers = base_white_layers
|
|
24
|
+
@top_layer_height = top_layer_height || layer_height
|
|
25
|
+
@top_white_start = top_white_start || color_end
|
|
26
|
+
@minimum_top_white_layers = minimum_top_white_layers
|
|
27
|
+
@effective_minimum_thickness = aligned_at_or_above(minimum_thickness)
|
|
28
|
+
@effective_maximum_thickness = if exact_maximum_thickness
|
|
29
|
+
maximum_thickness.round(9)
|
|
30
|
+
else
|
|
31
|
+
aligned_at_or_below(maximum_thickness)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def minimum_physical_thickness
|
|
36
|
+
(top_white_start + (minimum_top_white_layers * top_layer_height)).round(9)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def quantize_thickness(value)
|
|
40
|
+
printable_thicknesses.min_by { |boundary| [(boundary - value).abs.round(12), -boundary] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def normal_layer_count(height)
|
|
44
|
+
boundary_index(height) - base_white_layers
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def top_white_layer_count(height, pigment_layers: color_layer_count)
|
|
48
|
+
boundary_index(height) - base_white_layers - pigment_layers
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def top_white_thickness(height, pigment_layers: color_layer_count)
|
|
52
|
+
return 0.0 if pigment_layers != color_layer_count
|
|
53
|
+
|
|
54
|
+
(height - color_end).round(9)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def z_boundaries(material_layer_count)
|
|
58
|
+
all_boundaries.first(material_layer_count + 1)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def color_region_thickness = (color_layer_count * layer_height).round(9)
|
|
62
|
+
def base_white_thickness = (base_white_layers * first_layer_height).round(9)
|
|
63
|
+
def minimum_top_white_thickness = (minimum_physical_thickness - color_end).round(9)
|
|
64
|
+
|
|
65
|
+
def materials_for(recipe_layers, height)
|
|
66
|
+
top_white_layers = top_white_layer_count(height, pigment_layers: recipe_layers.length)
|
|
67
|
+
[*Array.new(base_white_layers, :white), *recipe_layers, *Array.new(top_white_layers, :white)]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def color_end
|
|
73
|
+
((base_white_layers * first_layer_height) + color_region_thickness).round(9)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def aligned_at_or_above(value)
|
|
77
|
+
return top_white_start if value <= top_white_start
|
|
78
|
+
|
|
79
|
+
steps = (((value - top_white_start) / top_layer_height) - 1e-9).ceil
|
|
80
|
+
(top_white_start + (steps * top_layer_height)).round(9)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def aligned_at_or_below(value)
|
|
84
|
+
return 0.0 if value < top_white_start
|
|
85
|
+
|
|
86
|
+
steps = (((value - top_white_start) / top_layer_height) + 1e-9).floor
|
|
87
|
+
(top_white_start + (steps * top_layer_height)).round(9)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def printable_thicknesses
|
|
91
|
+
@printable_thicknesses ||= begin
|
|
92
|
+
values = []
|
|
93
|
+
value = effective_minimum_thickness
|
|
94
|
+
while value <= effective_maximum_thickness + 1e-9
|
|
95
|
+
values << value.round(9)
|
|
96
|
+
value += top_layer_height
|
|
97
|
+
end
|
|
98
|
+
values << effective_maximum_thickness unless values.any? do |candidate|
|
|
99
|
+
same_boundary?(candidate, effective_maximum_thickness)
|
|
100
|
+
end
|
|
101
|
+
values.freeze
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
106
|
+
def all_boundaries
|
|
107
|
+
@all_boundaries ||= begin
|
|
108
|
+
values = [0.0]
|
|
109
|
+
base_white_layers.times { |index| values << ((index + 1) * first_layer_height).round(9) }
|
|
110
|
+
color_layer_count.times do |index|
|
|
111
|
+
values << (color_end - ((color_layer_count - index - 1) * layer_height)).round(9)
|
|
112
|
+
end
|
|
113
|
+
values << top_white_start unless same_boundary?(values.last, top_white_start)
|
|
114
|
+
value = (top_white_start + top_layer_height).round(9)
|
|
115
|
+
while value <= effective_maximum_thickness + 1e-9
|
|
116
|
+
values << value unless values.any? { |candidate| same_boundary?(candidate, value) }
|
|
117
|
+
value = (value + top_layer_height).round(9)
|
|
118
|
+
end
|
|
119
|
+
values << effective_maximum_thickness unless
|
|
120
|
+
values.any? { |candidate| same_boundary?(candidate, effective_maximum_thickness) }
|
|
121
|
+
values.freeze
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def boundary_index(height)
|
|
126
|
+
index = all_boundaries.index { |boundary| same_boundary?(boundary, height) }
|
|
127
|
+
return index if index
|
|
128
|
+
|
|
129
|
+
raise ConfigurationError, "thickness #{height} is not a printable layer boundary"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def same_boundary?(left, right) = (left - right).abs < 1e-8
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lithop
|
|
4
|
+
# Builds one closed pigment solid from a single contiguous Z interval per XY cell.
|
|
5
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
6
|
+
class ColumnGeometry
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
def initialize(model:, material:)
|
|
10
|
+
unless Palette::PIGMENTS.include?(material)
|
|
11
|
+
raise InputError, "column geometry requires a CMY pigment: #{material.inspect}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
@model = model
|
|
15
|
+
@material = material
|
|
16
|
+
@intervals = model.cells.map { |cell| interval(cell.materials) }.freeze
|
|
17
|
+
@topology = ColumnTopology.new(model:, intervals: @intervals)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def each_triangle(&)
|
|
21
|
+
return enum_for(__method__) unless block_given?
|
|
22
|
+
|
|
23
|
+
@model.rows.times do |row|
|
|
24
|
+
@model.columns.times do |column|
|
|
25
|
+
current = interval_at(row, column)
|
|
26
|
+
next unless current
|
|
27
|
+
|
|
28
|
+
emit_caps(row, column, current, &)
|
|
29
|
+
emit_sides(row, column, current, &)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def each(&) = each_triangle(&)
|
|
35
|
+
|
|
36
|
+
def release_cache! = @topology.release_cache!
|
|
37
|
+
|
|
38
|
+
def triangle_count
|
|
39
|
+
@model.rows.times.sum do |row|
|
|
40
|
+
@model.columns.times.sum do |column|
|
|
41
|
+
current = interval_at(row, column)
|
|
42
|
+
next 0 unless current
|
|
43
|
+
|
|
44
|
+
side_layers = exposed_layer_count(current, grouped_interval(row, column, row, column - 1)) +
|
|
45
|
+
exposed_layer_count(current, grouped_interval(row, column, row, column + 1)) +
|
|
46
|
+
exposed_layer_count(current, grouped_interval(row, column, row + 1, column)) +
|
|
47
|
+
exposed_layer_count(current, grouped_interval(row, column, row - 1, column))
|
|
48
|
+
4 + (side_layers * 2)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def interval(materials)
|
|
56
|
+
first = materials.index(@material)
|
|
57
|
+
return unless first
|
|
58
|
+
|
|
59
|
+
last = materials.rindex(@material)
|
|
60
|
+
[first, last + 1]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def interval_at(row, column)
|
|
64
|
+
return if row.negative? || column.negative? || row >= @model.rows || column >= @model.columns
|
|
65
|
+
|
|
66
|
+
@intervals.fetch((row * @model.columns) + column)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def emit_caps(row, column, current, &)
|
|
70
|
+
bottom, top = current
|
|
71
|
+
bottom_owner = [bottom, row, column]
|
|
72
|
+
top_owner = [top - 1, row, column]
|
|
73
|
+
quad(
|
|
74
|
+
vertex(column, row + 1, bottom, bottom_owner), vertex(column, row, bottom, bottom_owner),
|
|
75
|
+
vertex(column + 1, row, bottom, bottom_owner), vertex(column + 1, row + 1, bottom, bottom_owner), &
|
|
76
|
+
)
|
|
77
|
+
quad(
|
|
78
|
+
vertex(column, row + 1, top, top_owner), vertex(column + 1, row + 1, top, top_owner),
|
|
79
|
+
vertex(column + 1, row, top, top_owner), vertex(column, row, top, top_owner), &
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Four directions intentionally stay together so their winding order is easy to compare.
|
|
84
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
85
|
+
def emit_sides(row, column, current, &)
|
|
86
|
+
emit_side(current, grouped_interval(row, column, row, column - 1)) do |layer|
|
|
87
|
+
owner = [layer, row, column]
|
|
88
|
+
quad(
|
|
89
|
+
vertex(column, row + 1, layer, owner), vertex(column, row + 1, layer + 1, owner),
|
|
90
|
+
vertex(column, row, layer + 1, owner), vertex(column, row, layer, owner), &
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
emit_side(current, grouped_interval(row, column, row, column + 1)) do |layer|
|
|
94
|
+
owner = [layer, row, column]
|
|
95
|
+
quad(
|
|
96
|
+
vertex(column + 1, row + 1, layer, owner), vertex(column + 1, row, layer, owner),
|
|
97
|
+
vertex(column + 1, row, layer + 1, owner), vertex(column + 1, row + 1, layer + 1, owner), &
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
emit_side(current, grouped_interval(row, column, row + 1, column)) do |layer|
|
|
101
|
+
owner = [layer, row, column]
|
|
102
|
+
quad(
|
|
103
|
+
vertex(column, row + 1, layer, owner), vertex(column + 1, row + 1, layer, owner),
|
|
104
|
+
vertex(column + 1, row + 1, layer + 1, owner), vertex(column, row + 1, layer + 1, owner), &
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
emit_side(current, grouped_interval(row, column, row - 1, column)) do |layer|
|
|
108
|
+
owner = [layer, row, column]
|
|
109
|
+
quad(
|
|
110
|
+
vertex(column, row, layer, owner), vertex(column, row, layer + 1, owner),
|
|
111
|
+
vertex(column + 1, row, layer + 1, owner), vertex(column + 1, row, layer, owner), &
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def emit_side(current, neighbour, &)
|
|
117
|
+
bottom, top = current
|
|
118
|
+
unless neighbour
|
|
119
|
+
emit_layers(bottom, top, &)
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
neighbour_bottom, neighbour_top = neighbour
|
|
123
|
+
if neighbour_top <= bottom || neighbour_bottom >= top
|
|
124
|
+
emit_layers(bottom, top, &)
|
|
125
|
+
return
|
|
126
|
+
end
|
|
127
|
+
emit_layers(bottom, [top, neighbour_bottom].min, &) if neighbour_bottom > bottom
|
|
128
|
+
return unless neighbour_top < top
|
|
129
|
+
|
|
130
|
+
emit_layers([bottom, neighbour_top].max, top, &)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def exposed_layer_count(current, neighbour)
|
|
134
|
+
bottom, top = current
|
|
135
|
+
return top - bottom unless neighbour
|
|
136
|
+
|
|
137
|
+
neighbour_bottom, neighbour_top = neighbour
|
|
138
|
+
overlap = [[top, neighbour_top].min - [bottom, neighbour_bottom].max, 0].max
|
|
139
|
+
(top - bottom) - overlap
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def grouped_interval(row, column, neighbour_row, neighbour_column)
|
|
143
|
+
return unless @topology.same_group?(row, column, neighbour_row, neighbour_column)
|
|
144
|
+
|
|
145
|
+
interval_at(neighbour_row, neighbour_column)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def emit_layers(bottom, top, &)
|
|
149
|
+
(bottom...top).each(&)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def vertex(x_index, row_boundary, z_index, owner)
|
|
153
|
+
@topology.vertex(x_index, row_boundary, z_index, owner)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def quad(a, b, c, d)
|
|
157
|
+
yield [a, b, c]
|
|
158
|
+
yield [a, c, d]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|