#!/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