260 lines
10 KiB
Ruby
260 lines
10 KiB
Ruby
# 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)
|
||
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
|