201 lines
7.3 KiB
Ruby
201 lines
7.3 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
module FeatherCaseWizard
|
||
class PackageBuilder
|
||
attr_reader :config, :root, :toolchain, :model_source, :renderer_source
|
||
|
||
def initialize(config, root: CASES_ROOT, toolchain: Toolchain.new,
|
||
model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE,
|
||
reporter: nil)
|
||
@config = config
|
||
@root = Pathname(root)
|
||
@toolchain = toolchain
|
||
@model_source = Pathname(model_source)
|
||
@renderer_source = Pathname(renderer_source)
|
||
@reporter = reporter || ->(_message) {}
|
||
end
|
||
|
||
def build!
|
||
toolchain.check!
|
||
verify_sources!
|
||
config.validate!
|
||
config.generator["sha256"] = Digest::SHA256.file(model_source).hexdigest
|
||
config.generator["created_with"] ||= "feather-case-wizard"
|
||
|
||
FileUtils.mkdir_p(root)
|
||
destination = config.package_dir(root)
|
||
stage = Pathname(Dir.mktmpdir(".#{config.slug}-stage-", root.to_s))
|
||
prepare_package(stage, destination)
|
||
install_atomically(stage, destination)
|
||
destination
|
||
ensure
|
||
FileUtils.rm_rf(stage) if stage&.exist?
|
||
end
|
||
|
||
private
|
||
|
||
def verify_sources!
|
||
raise ToolError, "Missing model source: #{model_source}" unless model_source.file?
|
||
raise ToolError, "Missing preview renderer: #{renderer_source}" unless renderer_source.file?
|
||
end
|
||
|
||
def prepare_package(stage, previous)
|
||
source_dir = stage.join("source")
|
||
FileUtils.mkdir_p(source_dir)
|
||
FileUtils.cp(model_source, source_dir.join("feather_case.scad"))
|
||
FileUtils.cp(renderer_source, source_dir.join("render_stl_previews.py"))
|
||
FileUtils.cp(ROOT.join("templates/standalone_build.rb"), stage.join("build.rb"))
|
||
|
||
copy_history(previous, stage)
|
||
write_configuration(stage)
|
||
generate_exports(stage)
|
||
generate_previews(stage)
|
||
write_summary(stage)
|
||
end
|
||
|
||
def copy_history(previous, stage)
|
||
history = stage.join("history")
|
||
if previous.directory?
|
||
old_history = previous.join("history")
|
||
FileUtils.cp_r(old_history, history) if old_history.directory?
|
||
FileUtils.mkdir_p(history)
|
||
stamp = Time.now.utc.strftime("%Y%m%dT%H%M%S.%6NZ")
|
||
FileUtils.cp(previous.join("case.yml"), history.join("case-#{stamp}.yml")) if
|
||
previous.join("case.yml").file?
|
||
old_source = previous.join("source/feather_case.scad")
|
||
if old_source.file? &&
|
||
Digest::SHA256.file(old_source).hexdigest != config.generator["sha256"]
|
||
FileUtils.cp(old_source, history.join("feather_case-#{stamp}.scad"))
|
||
end
|
||
end
|
||
end
|
||
|
||
def write_configuration(stage)
|
||
File.write(stage.join("case.yml"), config.to_yaml)
|
||
values = config.parameters.to_h do |key, value|
|
||
[key, openscad_literal(value)]
|
||
end
|
||
customizer = {
|
||
"parameterSets" => {config.slug => values},
|
||
"fileFormatVersion" => "1"
|
||
}
|
||
File.write(stage.join("parameters.json"), JSON.pretty_generate(customizer) + "\n")
|
||
FileUtils.chmod(0o755, stage.join("build.rb"))
|
||
end
|
||
|
||
def generate_exports(stage)
|
||
config.outputs["formats"].each do |format|
|
||
directory = stage.join("exports", format)
|
||
FileUtils.mkdir_p(directory)
|
||
config.printable_parts.each do |part|
|
||
report("Exporting #{part}.#{format}")
|
||
export_model(stage, directory.join("#{part}.#{format}"), part)
|
||
end
|
||
end
|
||
end
|
||
|
||
def generate_previews(stage)
|
||
previews = stage.join("previews")
|
||
scratch = stage.join(".preview-meshes")
|
||
FileUtils.mkdir_p([previews, scratch])
|
||
assembly_stl = scratch.join("assembly.stl")
|
||
report("Preparing assembly preview")
|
||
export_model(stage, assembly_stl, "assembly",
|
||
"exploded_view" => config.outputs["assembly_exploded_view"],
|
||
"show_references" => false)
|
||
render(stage, assembly_stl, previews, "assembly")
|
||
|
||
config.printable_parts.each do |part|
|
||
report("Rendering #{part} preview")
|
||
mesh = scratch.join("#{part}.stl")
|
||
export_model(stage, mesh, part) unless mesh.file?
|
||
render(stage, mesh, previews, part, ["iso"])
|
||
end
|
||
FileUtils.rm_rf(scratch)
|
||
end
|
||
|
||
def export_model(stage, output, part, overrides = {})
|
||
model = stage.join("source/feather_case.scad")
|
||
parameters = config.model_parameters.merge(overrides).merge("part" => part)
|
||
command = [toolchain.openscad, "-o", output.to_s]
|
||
parameters.each do |name, value|
|
||
command.concat(["-D", "#{name}=#{openscad_literal(value)}"])
|
||
end
|
||
command << model.to_s
|
||
toolchain.run!(*command)
|
||
raise ToolError, "OpenSCAD did not create #{output}" unless output.file? && output.size.positive?
|
||
end
|
||
|
||
def render(stage, mesh, output_dir, basename, views = nil)
|
||
renderer = stage.join("source/render_stl_previews.py")
|
||
command = [
|
||
toolchain.blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
|
||
mesh.to_s, output_dir.to_s, basename
|
||
]
|
||
command << views.join(",") if views
|
||
toolchain.run!(*command, env: {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil})
|
||
end
|
||
|
||
def write_summary(stage)
|
||
dims = config.dimensions
|
||
parts = config.printable_parts.map { |part| "`#{part}`" }.join(", ")
|
||
text = <<~MARKDOWN
|
||
# #{config.name}
|
||
|
||
Generated by the Feather Case Wizard.
|
||
|
||
- Top profile: `#{config.parameters["top_profile"]}`
|
||
- FeatherWings: #{config.parameters["wing_count"]}
|
||
- Battery compartment: #{config.parameters["battery_enabled"] ? "yes" : "no"}
|
||
- 1/4-20 insert: #{config.parameters["tripod_insert_enabled"] ? "yes" : "no"}
|
||
- Approximate exterior: #{format("%.1f", dims["length"])} × #{format("%.1f", dims["width"])} × #{format("%.1f", dims["height"] + config.parameters["lid_thickness"])} mm
|
||
- Formats: #{config.outputs["formats"].join(", ")}
|
||
- Parts: #{parts}
|
||
- Custom cutouts: #{config.cutouts.length}
|
||
|
||
Run `ruby build.rb` in this directory to regenerate exports and previews.
|
||
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
|
||
override their executable paths.
|
||
|
||
To open the saved Customizer values manually:
|
||
|
||
```bash
|
||
openscad -p parameters.json -P #{config.slug} source/feather_case.scad
|
||
```
|
||
|
||
The 3MF files contain portable geometry only. Choose printer, filament,
|
||
plate, and slicing settings in Bambu Studio.
|
||
MARKDOWN
|
||
File.write(stage.join("README.md"), text)
|
||
end
|
||
|
||
def install_atomically(stage, destination)
|
||
old = root.join(".#{config.slug}-old-#{Process.pid}")
|
||
if destination.exist?
|
||
FileUtils.mv(destination, old)
|
||
end
|
||
FileUtils.mv(stage, destination)
|
||
FileUtils.rm_rf(old) if old.exist?
|
||
rescue StandardError
|
||
FileUtils.mv(old, destination) if old&.exist? && !destination.exist?
|
||
raise
|
||
end
|
||
|
||
def openscad_literal(value)
|
||
case value
|
||
when String then JSON.generate(value)
|
||
when TrueClass then "true"
|
||
when FalseClass then "false"
|
||
when Numeric then value.to_s
|
||
when Array then "[#{value.map { |item| openscad_literal(item) }.join(",")}]"
|
||
else
|
||
raise ConfigError, "Cannot convert #{value.inspect} to OpenSCAD"
|
||
end
|
||
end
|
||
|
||
def report(message)
|
||
@reporter.call(message)
|
||
end
|
||
end
|
||
end
|