Files
feather-case/lib/feather_case_wizard/package_builder.rb
T
2026-08-06 18:42:21 -07:00

260 lines
10 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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