Initial parametric Feather case generator

This commit is contained in:
Nick Estes
2026-07-29 11:51:51 -07:00
commit 683697604a
26 changed files with 3949 additions and 0 deletions
+200
View File
@@ -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