Initial parametric Feather case generator
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user