# frozen_string_literal: true module FeatherCaseWizard class PackageBuilder attr_reader :config, :root, :toolchain, :model_source, :renderer_source, :multicolor_renderer_source, :multicolor_packager_source, :logo_source def initialize(config, root: CASES_ROOT, toolchain: Toolchain.new, model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE, multicolor_renderer_source: MULTICOLOR_RENDERER_SOURCE, multicolor_packager_source: MULTICOLOR_PACKAGER_SOURCE, logo_source: LOGO_SOURCE, reporter: nil) @config = config @root = Pathname(root) @toolchain = toolchain @model_source = Pathname(model_source) @renderer_source = Pathname(renderer_source) @multicolor_renderer_source = Pathname(multicolor_renderer_source) @multicolor_packager_source = Pathname(multicolor_packager_source) @logo_source = Pathname(logo_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? raise ToolError, "Missing multicolor renderer: #{multicolor_renderer_source}" unless multicolor_renderer_source.file? raise ToolError, "Missing multicolor packager: #{multicolor_packager_source}" unless multicolor_packager_source.file? raise ToolError, "Missing lid logo: #{logo_source}" unless logo_source.file? end def prepare_package(stage, previous) source_dir = stage.join("source") tools_dir = stage.join("tools") FileUtils.mkdir_p([source_dir, source_dir.join("assets"), tools_dir]) FileUtils.cp(model_source, source_dir.join("feather_case.scad")) FileUtils.cp(renderer_source, source_dir.join("render_stl_previews.py")) FileUtils.cp(multicolor_renderer_source, source_dir.join("render_multicolor_lid.py")) FileUtils.cp(multicolor_packager_source, tools_dir.join("package_multimaterial_3mf.py")) FileUtils.cp(logo_source, source_dir.join("assets/NME-fixed.svg")) 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 package_multicolor_lid(stage, directory) if format == "3mf" && config.lid_artwork? end end def package_multicolor_lid(stage, directory) report("Packaging lid_multicolor.3mf") command = [ toolchain.python, stage.join("tools/package_multimaterial_3mf.py").to_s, directory.join("lid_base.3mf").to_s, directory.join("lid_accent.3mf").to_s, directory.join("lid_multicolor.3mf").to_s, "--title", "#{config.name} Lid" ] toolchain.run!(*command) output = directory.join("lid_multicolor.3mf") raise ToolError, "Multicolor packager did not create #{output}" unless output.file? && output.size.positive? 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 render_multicolor_lid(stage, scratch, previews) if config.lid_artwork? FileUtils.rm_rf(scratch) end def render_multicolor_lid(stage, scratch, previews) report("Rendering multicolor lid preview") command = [ toolchain.blender, "--background", "-noaudio", "--python", stage.join("source/render_multicolor_lid.py").to_s, "--", scratch.join("lid_base.stl").to_s, scratch.join("lid_accent.stl").to_s, previews.to_s, "lid_multicolor", config.parameters["lid_art_orientation"] ] toolchain.run!(*command, env: {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}) 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(", ") artwork = if config.lid_artwork? <<~ARTWORK.chomp For a two-color lid, open `exports/3mf/lid_multicolor.3mf` as one multipart object. Assign “Lid base — Filament 1” and “Accent inlays — Filament 2” to the desired filaments. `lid.3mf` is the complete single-color fallback; `lid_base.3mf` and `lid_accent.3mf` are also provided separately. ARTWORK else "" end 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, Blender, and Python 3 must be installed. Set `OPENSCAD`, `BLENDER`, or `PYTHON` 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. #{artwork} MARKDOWN File.write(stage.join("README.md"), text.rstrip + "\n") 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