# 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