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
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "fileutils"
require "json"
require "open3"
require "pathname"
require "tmpdir"
require "yaml"
ROOT = Pathname(__dir__).expand_path
def executable(env_name, command)
override = ENV[env_name]
return File.expand_path(override) if override && File.executable?(override)
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
candidate = File.join(directory, command)
return candidate if File.file?(candidate) && File.executable?(candidate)
end
nil
end
def 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| literal(item) }.join(",")}]"
else raise "Cannot convert #{value.inspect} to OpenSCAD"
end
end
def run!(*command, env: {})
stdout, stderr, status = Open3.capture3(env, *command)
return if status.success?
warn [stdout, stderr].reject(&:empty?).join("\n")
raise "Command failed (#{status.exitstatus}): #{command.first}"
end
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
parts << "buttons"
end
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
begin
exports = stage.join("exports")
previews = stage.join("previews")
scratch = stage.join("meshes")
FileUtils.mkdir_p([exports, previews, scratch])
model = ROOT.join("source/feather_case.scad")
renderer = ROOT.join("source/render_stl_previews.py")
export = lambda do |output, part, overrides = {}|
values = parameters.merge(overrides).merge("part" => part)
command = [openscad, "-o", output.to_s]
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
run!(*command, model.to_s)
end
config.fetch("outputs").fetch("formats").each do |format|
directory = exports.join(format)
FileUtils.mkdir_p(directory)
parts.each do |part|
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
end
assembly = scratch.join("assembly.stl")
puts "Preparing assembly preview…"
export.call(assembly, "assembly",
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
"show_references" => false)
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
assembly.to_s, previews.to_s, "assembly", env: render_env)
parts.each do |part|
puts "Rendering #{part} preview…"
mesh = scratch.join("#{part}.stl")
export.call(mesh, part) unless mesh.file?
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
%w[exports previews].each do |name|
current = ROOT.join(name)
FileUtils.mv(current, backup.join(name)) if current.exist?
end
begin
FileUtils.mv(exports, ROOT.join("exports"))
FileUtils.mv(previews, ROOT.join("previews"))
FileUtils.rm_rf(backup)
rescue StandardError
FileUtils.rm_rf(ROOT.join("exports"))
FileUtils.rm_rf(ROOT.join("previews"))
%w[exports previews].each do |name|
saved = backup.join(name)
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
end
raise
end
puts "Rebuilt #{ROOT}"
ensure
FileUtils.rm_rf(stage)
end