Initial parametric Feather case generator
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "digest"
|
||||
require "fileutils"
|
||||
require "json"
|
||||
require "open3"
|
||||
require "pathname"
|
||||
require "time"
|
||||
require "tmpdir"
|
||||
require "yaml"
|
||||
|
||||
require "highline"
|
||||
|
||||
require_relative "feather_case_wizard/config"
|
||||
require_relative "feather_case_wizard/toolchain"
|
||||
require_relative "feather_case_wizard/package_builder"
|
||||
require_relative "feather_case_wizard/wizard"
|
||||
require_relative "feather_case_wizard/cli"
|
||||
|
||||
module FeatherCaseWizard
|
||||
ROOT = Pathname(__dir__).join("..").expand_path.freeze
|
||||
CASES_ROOT = ROOT.join("cases").freeze
|
||||
MODEL_SOURCE = ROOT.join("feather_case.scad").freeze
|
||||
RENDERER_SOURCE = ROOT.join("tools/render_stl_previews.py").freeze
|
||||
end
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module FeatherCaseWizard
|
||||
class CLI
|
||||
def initialize(ui: HighLine.new, cases_root: CASES_ROOT,
|
||||
model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE,
|
||||
toolchain: Toolchain.new)
|
||||
@ui = ui
|
||||
@cases_root = Pathname(cases_root)
|
||||
@model_source = Pathname(model_source)
|
||||
@renderer_source = Pathname(renderer_source)
|
||||
@toolchain = toolchain
|
||||
@wizard = Wizard.new(ui: ui)
|
||||
end
|
||||
|
||||
def run
|
||||
@ui.say(@ui.color("Feather Case Wizard", :bold))
|
||||
loop do
|
||||
action = @ui.choose("\nWhat would you like to do?") do |menu|
|
||||
menu.choice("Create a new case") { :create }
|
||||
menu.choice("Edit an existing case") { :edit }
|
||||
menu.choice("Rebuild an existing case") { :rebuild }
|
||||
menu.choice("Exit") { :exit }
|
||||
end
|
||||
return 0 if action == :exit
|
||||
|
||||
case action
|
||||
when :create then create_case
|
||||
when :edit then edit_case
|
||||
when :rebuild then rebuild_case
|
||||
end
|
||||
rescue ConfigError, ToolError, Errno::EACCES => e
|
||||
@ui.say(@ui.color("\n#{e.message}", :red))
|
||||
rescue Interrupt
|
||||
@ui.say("\nCancelled.")
|
||||
return 130
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_case
|
||||
config = @wizard.create
|
||||
destination = config.package_dir(@cases_root)
|
||||
if destination.exist? &&
|
||||
!@ui.agree("#{destination} already exists. Replace it? (y/n) ") { |q| q.default = "no" }
|
||||
return
|
||||
end
|
||||
build(config, @model_source, @renderer_source) if @wizard.confirm_build(config)
|
||||
end
|
||||
|
||||
def edit_case
|
||||
path = choose_case
|
||||
return unless path
|
||||
|
||||
config = Config.load(path)
|
||||
package = path.dirname
|
||||
source = package.join("source/feather_case.scad")
|
||||
renderer = package.join("source/render_stl_previews.py")
|
||||
source, renderer = choose_generator(config, source, renderer)
|
||||
config = @wizard.edit(config)
|
||||
build(config, source, renderer) if @wizard.confirm_build(config)
|
||||
end
|
||||
|
||||
def rebuild_case
|
||||
path = choose_case
|
||||
return unless path
|
||||
|
||||
config = Config.load(path)
|
||||
return unless @wizard.confirm_build(config)
|
||||
|
||||
package = path.dirname
|
||||
build(config, package.join("source/feather_case.scad"),
|
||||
package.join("source/render_stl_previews.py"))
|
||||
end
|
||||
|
||||
def choose_case
|
||||
paths = Dir.glob(@cases_root.join("*/case.yml")).sort.map { |item| Pathname(item) }
|
||||
if paths.empty?
|
||||
@ui.say("No saved cases found under #{@cases_root}.")
|
||||
return nil
|
||||
end
|
||||
@ui.choose("Choose a saved case") do |menu|
|
||||
paths.each do |path|
|
||||
label = Config.load(path).name
|
||||
menu.choice("#{label} (#{path.dirname.basename})") { path }
|
||||
rescue ConfigError
|
||||
menu.choice("Invalid configuration (#{path.dirname.basename})") { path }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def choose_generator(config, snapshot, renderer)
|
||||
return [@model_source, @renderer_source] unless snapshot.file?
|
||||
|
||||
snapshot_sha = Digest::SHA256.file(snapshot).hexdigest
|
||||
current_sha = Digest::SHA256.file(@model_source).hexdigest
|
||||
return [snapshot, renderer] if snapshot_sha == current_sha
|
||||
|
||||
@ui.say("This case uses a different generator snapshot.")
|
||||
upgrade = @ui.agree("Upgrade it to the current generator? (y/n) ") do |question|
|
||||
question.default = "no"
|
||||
end
|
||||
upgrade ? [@model_source, @renderer_source] : [snapshot, renderer]
|
||||
end
|
||||
|
||||
def build(config, source, renderer)
|
||||
destination = PackageBuilder.new(
|
||||
config, root: @cases_root, toolchain: @toolchain,
|
||||
model_source: source, renderer_source: renderer,
|
||||
reporter: ->(message) { @ui.say(" #{message}…") }
|
||||
).build!
|
||||
@ui.say(@ui.color("\nGenerated #{destination}", :green))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,347 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module FeatherCaseWizard
|
||||
class ConfigError < StandardError; end
|
||||
|
||||
class Config
|
||||
SCHEMA_VERSION = 1
|
||||
PROFILES = %w[generic gps oled128x64].freeze
|
||||
FORMATS = %w[stl 3mf].freeze
|
||||
FACES = %w[front rear left right].freeze
|
||||
SHAPES = %w[circle rect roundrect slot].freeze
|
||||
COUPONS = %w[insert_coupon switch_coupon fit_coupon].freeze
|
||||
|
||||
DEFAULT_PARAMETERS = {
|
||||
"top_profile" => "generic",
|
||||
"wing_count" => 1,
|
||||
"wing_pitch" => 11.0,
|
||||
"stack_height_override" => 0.0,
|
||||
"generic_component_height" => 3.0,
|
||||
"generic_top_gap" => 2.0,
|
||||
"gps_antenna_height" => 4.0,
|
||||
"gps_top_gap" => 5.0,
|
||||
"oled_component_height" => 2.4,
|
||||
"oled_face_gap" => 0.8,
|
||||
"button_a" => true,
|
||||
"button_b" => true,
|
||||
"button_c" => true,
|
||||
"button_reset" => false,
|
||||
"button_projection" => 1.5,
|
||||
"button_cap_size" => [5.0, 3.2],
|
||||
"button_flange_size" => [6.6, 3.6],
|
||||
"button_flange_thickness" => 0.9,
|
||||
"button_travel" => 0.5,
|
||||
"button_contact_extension" => 0.7,
|
||||
"battery_enabled" => false,
|
||||
"battery_length" => 69.0,
|
||||
"battery_diameter" => 18.0,
|
||||
"battery_radial_clearance" => 0.6,
|
||||
"battery_end_clearance" => 1.0,
|
||||
"switch_hole_diameter" => 12.0,
|
||||
"switch_hole_compensation" => 0.3,
|
||||
"switch_cap_diameter" => 8.0,
|
||||
"switch_flange_diameter" => 16.2,
|
||||
"switch_body_length" => 24.0,
|
||||
"switch_body_diameter" => 12.0,
|
||||
"switch_wire_allowance" => 3.0,
|
||||
"tripod_insert_enabled" => false,
|
||||
"tripod_insert_length" => 6.4,
|
||||
"tripod_insert_hole_diameter" => 8.8,
|
||||
"tripod_thread_clearance_diameter" => 6.8,
|
||||
"tripod_boss_diameter" => 14.0,
|
||||
"m25_insert_length" => 4.0,
|
||||
"m25_insert_hole_diameter" => 3.7,
|
||||
"m25_screw_clearance" => 2.8,
|
||||
"m25_boss_diameter" => 7.0,
|
||||
"insert_entry_chamfer" => 0.6,
|
||||
"insert_melt_relief" => 0.8,
|
||||
"wall_thickness" => 2.4,
|
||||
"floor_thickness" => 2.4,
|
||||
"lid_thickness" => 3.2,
|
||||
"corner_radius" => 4.0,
|
||||
"board_bottom_clearance" => 5.4,
|
||||
"board_side_clearance" => 7.0,
|
||||
"board_rear_clearance" => 4.0,
|
||||
"usb_board_wall_gap" => 1.2,
|
||||
"lid_lip_depth" => 2.4,
|
||||
"lid_lip_thickness" => 1.2,
|
||||
"fit_clearance" => 0.35,
|
||||
"hole_compensation" => 0.2,
|
||||
"lid_screw_head_diameter" => 5.2,
|
||||
"lid_screw_head_height" => 2.5,
|
||||
"usb_opening_width" => 12.5,
|
||||
"usb_opening_height" => 7.0,
|
||||
"usb_opening_radius" => 1.5,
|
||||
"usb_vertical_offset" => 0.0
|
||||
}.freeze
|
||||
|
||||
ADVANCED_FIELDS = {
|
||||
"Stack" => %w[wing_pitch generic_component_height generic_top_gap
|
||||
gps_antenna_height gps_top_gap oled_component_height oled_face_gap],
|
||||
"Shell and fit" => %w[wall_thickness floor_thickness lid_thickness corner_radius
|
||||
board_bottom_clearance board_side_clearance board_rear_clearance
|
||||
usb_board_wall_gap lid_lip_depth lid_lip_thickness fit_clearance
|
||||
hole_compensation lid_screw_head_diameter lid_screw_head_height],
|
||||
"M2.5 inserts" => %w[m25_insert_length m25_insert_hole_diameter
|
||||
m25_screw_clearance m25_boss_diameter
|
||||
insert_entry_chamfer insert_melt_relief],
|
||||
"Tripod insert" => %w[tripod_insert_length tripod_insert_hole_diameter
|
||||
tripod_thread_clearance_diameter tripod_boss_diameter],
|
||||
"Battery and switch" => %w[battery_length battery_diameter battery_radial_clearance
|
||||
battery_end_clearance switch_hole_diameter
|
||||
switch_hole_compensation switch_cap_diameter
|
||||
switch_flange_diameter switch_body_length
|
||||
switch_body_diameter switch_wire_allowance],
|
||||
"USB-C opening" => %w[usb_opening_width usb_opening_height
|
||||
usb_opening_radius usb_vertical_offset],
|
||||
"OLED buttons" => %w[button_projection button_flange_thickness
|
||||
button_travel button_contact_extension]
|
||||
}.freeze
|
||||
|
||||
attr_reader :data
|
||||
|
||||
def self.create(name:, slug:)
|
||||
new(
|
||||
"schema_version" => SCHEMA_VERSION,
|
||||
"name" => name,
|
||||
"slug" => slug,
|
||||
"generator" => {},
|
||||
"parameters" => deep_copy(DEFAULT_PARAMETERS),
|
||||
"cutouts" => [],
|
||||
"outputs" => {
|
||||
"formats" => ["3mf"],
|
||||
"coupons" => [],
|
||||
"assembly_exploded_view" => 8
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def self.load(path)
|
||||
raw = YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [],
|
||||
aliases: false)
|
||||
raise ConfigError, "Configuration must be a YAML mapping" unless raw.is_a?(Hash)
|
||||
|
||||
new(raw)
|
||||
rescue Psych::Exception => e
|
||||
raise ConfigError, "Cannot read configuration: #{e.message}"
|
||||
end
|
||||
|
||||
def self.deep_copy(value)
|
||||
Marshal.load(Marshal.dump(value))
|
||||
end
|
||||
|
||||
def initialize(data)
|
||||
@data = stringify_keys(data)
|
||||
normalize!
|
||||
validate!
|
||||
end
|
||||
|
||||
def name = data.fetch("name")
|
||||
def slug = data.fetch("slug")
|
||||
def parameters = data.fetch("parameters")
|
||||
def cutouts = data.fetch("cutouts")
|
||||
def outputs = data.fetch("outputs")
|
||||
def generator = data.fetch("generator")
|
||||
|
||||
def package_dir(root = CASES_ROOT)
|
||||
root.join(slug)
|
||||
end
|
||||
|
||||
def printable_parts
|
||||
parts = %w[body lid]
|
||||
if parameters["top_profile"] == "oled128x64" &&
|
||||
%w[button_a button_b button_c button_reset].any? { |key| parameters[key] }
|
||||
parts << "buttons"
|
||||
end
|
||||
parts + outputs["coupons"]
|
||||
end
|
||||
|
||||
def model_parameters
|
||||
parameters.merge("extra_cutouts" => scad_cutouts)
|
||||
end
|
||||
|
||||
def dimensions
|
||||
p = parameters
|
||||
effective_floor = p["tripod_insert_enabled"] ?
|
||||
[p["floor_thickness"], p["tripod_insert_length"] + p["insert_melt_relief"] + 1.2].max :
|
||||
p["floor_thickness"]
|
||||
effective_board_clearance = [
|
||||
p["board_bottom_clearance"],
|
||||
p["m25_insert_length"] + p["insert_melt_relief"] + 0.6
|
||||
].max
|
||||
board_origin_x = p["wall_thickness"] + p["usb_board_wall_gap"]
|
||||
board_origin_y = p["wall_thickness"] + p["board_side_clearance"]
|
||||
board_top_z = effective_floor + effective_board_clearance + 1.6
|
||||
top_pcb_z = board_top_z + (
|
||||
p["stack_height_override"].positive? ?
|
||||
p["stack_height_override"] : p["wing_count"] * p["wing_pitch"]
|
||||
)
|
||||
component_height, top_gap = case p["top_profile"]
|
||||
when "gps"
|
||||
[p["gps_antenna_height"], p["gps_top_gap"]]
|
||||
when "oled128x64"
|
||||
[p["oled_component_height"], p["oled_face_gap"]]
|
||||
else
|
||||
[p["generic_component_height"], p["generic_top_gap"]]
|
||||
end
|
||||
plain_length = board_origin_x + 50.8 + p["board_rear_clearance"] +
|
||||
p["wall_thickness"]
|
||||
plain_width = board_origin_y + 22.86 + p["board_side_clearance"] +
|
||||
p["wall_thickness"]
|
||||
if p["battery_enabled"]
|
||||
partition_y = board_origin_y + 22.86 + 1
|
||||
battery_y = partition_y + p["wall_thickness"] +
|
||||
p["battery_radial_clearance"] + p["battery_diameter"] / 2
|
||||
battery_start_x = board_origin_x + p["battery_end_clearance"]
|
||||
battery_end_x = battery_start_x + p["battery_length"]
|
||||
switch_x = battery_end_x + [
|
||||
p["switch_body_diameter"] / 2 + 0.8,
|
||||
p["switch_flange_diameter"] / 2 + 0.5
|
||||
].max
|
||||
length = [plain_length, switch_x + p["switch_flange_diameter"] / 2 +
|
||||
1.2 + p["wall_thickness"]].max
|
||||
width = [plain_width, battery_y + p["battery_diameter"] / 2 +
|
||||
p["battery_radial_clearance"] + p["wall_thickness"]].max
|
||||
battery_z = effective_floor + p["battery_radial_clearance"] +
|
||||
p["battery_diameter"] / 2
|
||||
switch_z = [
|
||||
effective_floor + p["switch_flange_diameter"] / 2 + 1,
|
||||
battery_z
|
||||
].max
|
||||
battery_ceiling = [
|
||||
battery_z + p["battery_diameter"] / 2 + p["battery_radial_clearance"],
|
||||
switch_z + p["switch_flange_diameter"] / 2 + 1
|
||||
].max
|
||||
else
|
||||
length = plain_length
|
||||
width = plain_width
|
||||
battery_ceiling = 0
|
||||
end
|
||||
{
|
||||
"length" => length,
|
||||
"width" => width,
|
||||
"height" => [top_pcb_z + component_height + top_gap, battery_ceiling].max,
|
||||
"floor" => effective_floor,
|
||||
"lip_bottom" => [top_pcb_z + component_height + top_gap, battery_ceiling].max -
|
||||
p["lid_lip_depth"]
|
||||
}
|
||||
end
|
||||
|
||||
def scad_cutouts
|
||||
dims = dimensions
|
||||
cutouts.map do |cutout|
|
||||
face = cutout.fetch("face")
|
||||
horizontal = cutout.fetch("horizontal")
|
||||
u = case face
|
||||
when "front" then dims["width"] - horizontal
|
||||
when "rear", "left" then horizontal
|
||||
when "right" then dims["length"] - horizontal
|
||||
end
|
||||
width = cutout.fetch("width")
|
||||
height = cutout.fetch("height", width)
|
||||
radius = cutout.fetch("radius", [width, height].min / 2)
|
||||
[face, cutout.fetch("shape"), u, cutout.fetch("z"), width, height, radius]
|
||||
end
|
||||
end
|
||||
|
||||
def to_yaml
|
||||
data["resolved"] = {"extra_cutouts" => scad_cutouts}
|
||||
data.to_yaml(line_width: -1)
|
||||
end
|
||||
|
||||
def validate!
|
||||
raise ConfigError, "Unsupported schema_version" unless data["schema_version"] == SCHEMA_VERSION
|
||||
raise ConfigError, "Project name cannot be empty" if name.to_s.strip.empty?
|
||||
unless slug.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
|
||||
raise ConfigError, "Slug must contain lowercase letters, numbers, and single hyphens"
|
||||
end
|
||||
raise ConfigError, "Unsupported top profile" unless PROFILES.include?(parameters["top_profile"])
|
||||
raise ConfigError, "wing_count must be between 1 and 8" unless (1..8).cover?(parameters["wing_count"])
|
||||
raise ConfigError, "At least one export format is required" if outputs["formats"].empty?
|
||||
unless (outputs["formats"] - FORMATS).empty?
|
||||
raise ConfigError, "Unsupported export format"
|
||||
end
|
||||
unless (outputs["coupons"] - COUPONS).empty?
|
||||
raise ConfigError, "Unsupported calibration coupon"
|
||||
end
|
||||
|
||||
positive = DEFAULT_PARAMETERS.keys - %w[usb_vertical_offset stack_height_override]
|
||||
positive.each do |key|
|
||||
value = parameters[key]
|
||||
next if value == true || value == false
|
||||
next if value.is_a?(Array) && value.all? { |item| item.to_f.positive? }
|
||||
next unless value.is_a?(Numeric)
|
||||
raise ConfigError, "#{key} must be positive" unless value.to_f.positive?
|
||||
end
|
||||
raise ConfigError, "stack_height_override cannot be negative" if
|
||||
parameters["stack_height_override"].negative?
|
||||
raise ConfigError, "wall_thickness must be at least 1.2 mm" if
|
||||
parameters["wall_thickness"] < 1.2
|
||||
raise ConfigError, "lid_lip_thickness must be at least 1.2 mm" if
|
||||
parameters["lid_lip_thickness"] < 1.2
|
||||
|
||||
validate_cutouts!
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize!
|
||||
data["schema_version"] = Integer(data.fetch("schema_version", SCHEMA_VERSION))
|
||||
data["generator"] ||= {}
|
||||
data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(data["parameters"] || {})
|
||||
data["cutouts"] ||= []
|
||||
data["outputs"] ||= {}
|
||||
data["outputs"]["formats"] ||= ["3mf"]
|
||||
data["outputs"]["coupons"] ||= []
|
||||
data["outputs"]["assembly_exploded_view"] ||= 8
|
||||
rescue ArgumentError, TypeError => e
|
||||
raise ConfigError, "Invalid configuration value: #{e.message}"
|
||||
end
|
||||
|
||||
def validate_cutouts!
|
||||
dims = dimensions
|
||||
names = {}
|
||||
cutouts.each do |cutout|
|
||||
name = cutout["name"].to_s.strip
|
||||
raise ConfigError, "Every cutout needs a name" if name.empty?
|
||||
raise ConfigError, "Duplicate cutout name: #{name}" if names[name]
|
||||
names[name] = true
|
||||
face = cutout["face"]
|
||||
shape = cutout["shape"]
|
||||
raise ConfigError, "Invalid face for #{name}" unless FACES.include?(face)
|
||||
raise ConfigError, "Invalid shape for #{name}" unless SHAPES.include?(shape)
|
||||
width = Float(cutout["width"])
|
||||
height = Float(cutout.fetch("height", width))
|
||||
horizontal = Float(cutout["horizontal"])
|
||||
z = Float(cutout["z"])
|
||||
wall_width = %w[front rear].include?(face) ? dims["width"] : dims["length"]
|
||||
unless horizontal - width / 2 >= 0 && horizontal + width / 2 <= wall_width
|
||||
raise ConfigError, "#{name} extends beyond the #{face} wall"
|
||||
end
|
||||
if z - height / 2 < dims["floor"]
|
||||
raise ConfigError, "#{name} intersects the case floor"
|
||||
end
|
||||
if z + height / 2 > dims["lip_bottom"]
|
||||
raise ConfigError, "#{name} intersects the lid locating lip"
|
||||
end
|
||||
radius = Float(cutout.fetch("radius", [width, height].min / 2))
|
||||
raise ConfigError, "#{name} dimensions must be positive" unless
|
||||
width.positive? && height.positive? && radius.positive?
|
||||
rescue ArgumentError, TypeError, KeyError => e
|
||||
raise ConfigError, "Invalid cutout #{name.inspect}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def stringify_keys(value)
|
||||
case value
|
||||
when Hash
|
||||
value.to_h { |key, item| [key.to_s, stringify_keys(item)] }
|
||||
when Array
|
||||
value.map { |item| stringify_keys(item) }
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,200 @@
|
||||
# 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
|
||||
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module FeatherCaseWizard
|
||||
class ToolError < StandardError; end
|
||||
|
||||
class Toolchain
|
||||
attr_reader :openscad, :blender
|
||||
|
||||
def initialize(env: ENV)
|
||||
@env = env
|
||||
@openscad = discover("OPENSCAD", "openscad")
|
||||
@blender = discover("BLENDER", "blender")
|
||||
end
|
||||
|
||||
def check!
|
||||
missing = []
|
||||
missing << "OpenSCAD (`sudo apt-get install openscad` or set OPENSCAD)" unless openscad
|
||||
missing << "Blender (`sudo apt-get install blender` or set BLENDER)" unless blender
|
||||
return self if missing.empty?
|
||||
|
||||
raise ToolError, "Missing required tools:\n - #{missing.join("\n - ")}"
|
||||
end
|
||||
|
||||
def run!(*command, chdir: nil, env: {})
|
||||
options = chdir ? {chdir: chdir} : {}
|
||||
stdout, stderr, status = Open3.capture3(env, *command, **options)
|
||||
return [stdout, stderr] if status.success?
|
||||
|
||||
detail = [stdout, stderr].reject(&:empty?).join("\n").strip
|
||||
result = status.exitstatus || "signal #{status.termsig}"
|
||||
raise ToolError, "Command failed (#{result}): #{command.first}\n#{detail}"
|
||||
rescue Errno::ENOENT => e
|
||||
raise ToolError, "Cannot run #{command.first}: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def discover(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
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,289 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module FeatherCaseWizard
|
||||
class Wizard
|
||||
attr_reader :ui
|
||||
|
||||
def initialize(ui: HighLine.new)
|
||||
@ui = ui
|
||||
end
|
||||
|
||||
def create
|
||||
name = required_text("Case name")
|
||||
slug = ask_slug(slugify(name))
|
||||
configure(Config.create(name: name, slug: slug), new_case: true)
|
||||
end
|
||||
|
||||
def edit(config)
|
||||
ui.say("\nEditing #{config.name} (`#{config.slug}`)")
|
||||
config.data["name"] = text("Case name", config.name)
|
||||
configure(config, new_case: false)
|
||||
end
|
||||
|
||||
def confirm_build(config)
|
||||
dims = config.dimensions
|
||||
ui.say("\n#{ui.color("Build summary", :bold)}")
|
||||
ui.say(" Case: #{config.name} (#{config.slug})")
|
||||
ui.say(" Profile: #{config.parameters["top_profile"]}")
|
||||
ui.say(" Wings: #{config.parameters["wing_count"]}")
|
||||
ui.say(" Battery: #{yes_no(config.parameters["battery_enabled"])}")
|
||||
ui.say(" Tripod insert: #{yes_no(config.parameters["tripod_insert_enabled"])}")
|
||||
ui.say(" Cutouts: #{config.cutouts.length}")
|
||||
ui.say(" Size: #{format("%.1f × %.1f × %.1f mm", dims["length"], dims["width"],
|
||||
dims["height"] + config.parameters["lid_thickness"])}")
|
||||
ui.say(" Parts: #{config.printable_parts.join(", ")}")
|
||||
ui.say(" Formats: #{config.outputs["formats"].join(", ")}")
|
||||
ui.agree("Generate this case? (y/n) ") { |question| question.default = "yes" }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def configure(config, new_case:)
|
||||
choose_formats(config)
|
||||
choose_profile(config)
|
||||
choose_stack(config)
|
||||
choose_features(config)
|
||||
manage_cutouts(config)
|
||||
choose_coupons(config)
|
||||
advanced(config) if ui.agree("Adjust advanced dimensions? (y/n) ") do |question|
|
||||
question.default = "no"
|
||||
end
|
||||
config.validate!
|
||||
config
|
||||
rescue ConfigError => e
|
||||
ui.say(ui.color("Configuration error: #{e.message}", :red))
|
||||
retry unless new_case && !ui.agree("Return to the questions? (y/n) ") { |q| q.default = "yes" }
|
||||
raise
|
||||
end
|
||||
|
||||
def choose_formats(config)
|
||||
current = config.outputs["formats"].sort
|
||||
label = current == %w[3mf stl] ? "both" : current.first
|
||||
selected = ui.choose("Export format (current: #{label})") do |menu|
|
||||
menu.choice("3MF") { ["3mf"] }
|
||||
menu.choice("STL") { ["stl"] }
|
||||
menu.choice("Both 3MF and STL") { %w[3mf stl] }
|
||||
menu.default = label == "both" ? 3 : (label == "stl" ? 2 : 1)
|
||||
end
|
||||
config.outputs["formats"] = selected
|
||||
end
|
||||
|
||||
def choose_profile(config)
|
||||
current = config.parameters["top_profile"]
|
||||
config.parameters["top_profile"] = ui.choose("Top FeatherWing profile") do |menu|
|
||||
menu.choice("Generic / other") { "generic" }
|
||||
menu.choice("GPS") { "gps" }
|
||||
menu.choice("128×64 OLED") { "oled128x64" }
|
||||
menu.default = {"generic" => 1, "gps" => 2, "oled128x64" => 3}.fetch(current)
|
||||
end
|
||||
end
|
||||
|
||||
def choose_stack(config)
|
||||
p = config.parameters
|
||||
p["wing_count"] = integer("Number of FeatherWings", p["wing_count"], 1..8)
|
||||
measured = p["stack_height_override"].positive?
|
||||
if ui.agree("Use a measured board-to-top-PCB stack height? (y/n) ") do |question|
|
||||
question.default = measured ? "yes" : "no"
|
||||
end
|
||||
p["stack_height_override"] = number(
|
||||
"Measured height in mm", measured ? p["stack_height_override"] :
|
||||
p["wing_count"] * p["wing_pitch"], min: 0.1
|
||||
)
|
||||
else
|
||||
p["stack_height_override"] = 0.0
|
||||
end
|
||||
end
|
||||
|
||||
def choose_features(config)
|
||||
p = config.parameters
|
||||
p["battery_enabled"] = agreement("Include 18650 battery compartment and switch?",
|
||||
p["battery_enabled"])
|
||||
p["tripod_insert_enabled"] = agreement("Include centered 1/4-20 insert?",
|
||||
p["tripod_insert_enabled"])
|
||||
return unless p["top_profile"] == "oled128x64"
|
||||
|
||||
ui.say("\nOLED captive buttons")
|
||||
p["button_a"] = agreement("Enable button A?", p["button_a"])
|
||||
p["button_b"] = agreement("Enable button B?", p["button_b"])
|
||||
p["button_c"] = agreement("Enable button C?", p["button_c"])
|
||||
p["button_reset"] = agreement("Enable reset button?", p["button_reset"])
|
||||
end
|
||||
|
||||
def manage_cutouts(config)
|
||||
loop do
|
||||
ui.say("\nCustom cable cutouts: #{config.cutouts.empty? ? "none" :
|
||||
config.cutouts.map { |cutout| cutout["name"] }.join(", ")}")
|
||||
action = ui.choose("Cable cutouts") do |menu|
|
||||
menu.choice("Done") { :done }
|
||||
menu.choice("Add cutout") { :add }
|
||||
menu.choice("Edit cutout") { :edit } unless config.cutouts.empty?
|
||||
menu.choice("Remove cutout") { :remove } unless config.cutouts.empty?
|
||||
end
|
||||
break if action == :done
|
||||
|
||||
case action
|
||||
when :add
|
||||
config.cutouts << ask_cutout(config)
|
||||
when :edit
|
||||
index = choose_cutout(config, "Edit which cutout?")
|
||||
old = config.cutouts.delete_at(index)
|
||||
config.cutouts.insert(index, ask_cutout(config, old))
|
||||
when :remove
|
||||
config.cutouts.delete_at(choose_cutout(config, "Remove which cutout?"))
|
||||
end
|
||||
rescue ConfigError => e
|
||||
ui.say(ui.color("Cutout not accepted: #{e.message}", :red))
|
||||
end
|
||||
end
|
||||
|
||||
def ask_cutout(config, existing = nil)
|
||||
existing ||= {}
|
||||
dims = config.dimensions
|
||||
name = text("Cutout name", existing["name"] || "cable")
|
||||
face = ui.choose("Wall") do |menu|
|
||||
Config::FACES.each { |item| menu.choice(item) { item } }
|
||||
menu.default = Config::FACES.index(existing["face"]).to_i + 1 if existing["face"]
|
||||
end
|
||||
shape = ui.choose("Shape") do |menu|
|
||||
Config::SHAPES.each { |item| menu.choice(item) { item } }
|
||||
menu.default = Config::SHAPES.index(existing["shape"]).to_i + 1 if existing["shape"]
|
||||
end
|
||||
wall_width = %w[front rear].include?(face) ? dims["width"] : dims["length"]
|
||||
ui.say("Viewing the #{face} wall from outside: width #{format("%.1f", wall_width)} mm; " \
|
||||
"usable vertical centers lie between the floor and lid lip.")
|
||||
width = number(shape == "circle" ? "Diameter (mm)" : "Width (mm)",
|
||||
existing["width"] || 8.0, min: 0.1)
|
||||
height = shape == "circle" ? width :
|
||||
number("Height (mm)", existing["height"] || 6.0, min: 0.1)
|
||||
horizontal = number("Center from the wall's left edge (mm)",
|
||||
existing["horizontal"] || wall_width / 2, min: width / 2,
|
||||
max: wall_width - width / 2)
|
||||
min_z = dims["floor"] + height / 2
|
||||
max_z = dims["lip_bottom"] - height / 2
|
||||
z = number("Center height from exterior bottom (mm)",
|
||||
existing["z"] || (min_z + max_z) / 2, min: min_z, max: max_z)
|
||||
radius = if shape == "roundrect"
|
||||
number("Corner radius (mm)", existing["radius"] ||
|
||||
[width, height].min / 4, min: 0.01, max: [width, height].min / 2)
|
||||
else
|
||||
[width, height].min / 2
|
||||
end
|
||||
candidate = {
|
||||
"name" => name, "face" => face, "shape" => shape,
|
||||
"horizontal" => horizontal, "z" => z, "width" => width,
|
||||
"height" => height, "radius" => radius
|
||||
}
|
||||
temporary = Config.new(Config.deep_copy(config.data).tap do |data|
|
||||
data["cutouts"] = config.cutouts + [candidate]
|
||||
end)
|
||||
temporary.validate!
|
||||
candidate
|
||||
end
|
||||
|
||||
def choose_cutout(config, prompt)
|
||||
ui.choose(prompt) do |menu|
|
||||
config.cutouts.each_with_index do |cutout, index|
|
||||
menu.choice(cutout["name"]) { index }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def choose_coupons(config)
|
||||
selected = []
|
||||
selected << "insert_coupon" if agreement(
|
||||
"Export heat-set insert calibration coupon?",
|
||||
config.outputs["coupons"].include?("insert_coupon")
|
||||
)
|
||||
selected << "switch_coupon" if agreement(
|
||||
"Export switch-hole calibration coupon?",
|
||||
config.outputs["coupons"].include?("switch_coupon")
|
||||
)
|
||||
selected << "fit_coupon" if agreement(
|
||||
"Export lid-fit calibration coupon?",
|
||||
config.outputs["coupons"].include?("fit_coupon")
|
||||
)
|
||||
config.outputs["coupons"] = selected
|
||||
end
|
||||
|
||||
def advanced(config)
|
||||
Config::ADVANCED_FIELDS.each do |section, fields|
|
||||
next unless ui.agree("Adjust #{section.downcase}? (y/n) ") { |q| q.default = "no" }
|
||||
|
||||
ui.say("\n#{section}")
|
||||
fields.each do |field|
|
||||
config.parameters[field] = number(field.tr("_", " "), config.parameters[field],
|
||||
min: field == "usb_vertical_offset" ? nil : 0.01)
|
||||
end
|
||||
next unless section == "OLED buttons"
|
||||
|
||||
config.parameters["button_cap_size"] = pair(
|
||||
"button cap width,length", config.parameters["button_cap_size"]
|
||||
)
|
||||
config.parameters["button_flange_size"] = pair(
|
||||
"button flange width,length", config.parameters["button_flange_size"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def agreement(prompt, default)
|
||||
ui.agree("#{prompt} (y/n) ") { |question| question.default = default ? "yes" : "no" }
|
||||
end
|
||||
|
||||
def required_text(prompt)
|
||||
loop do
|
||||
value = ui.ask("#{prompt}: ").to_s.strip
|
||||
return value unless value.empty?
|
||||
ui.say(ui.color("A value is required.", :red))
|
||||
end
|
||||
end
|
||||
|
||||
def text(prompt, default)
|
||||
ui.ask("#{prompt}: ") { |question| question.default = default }.to_s.strip
|
||||
end
|
||||
|
||||
def ask_slug(default)
|
||||
loop do
|
||||
value = text("Directory slug", default)
|
||||
return value if value.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
|
||||
ui.say(ui.color("Use lowercase letters, numbers, and single hyphens.", :red))
|
||||
end
|
||||
end
|
||||
|
||||
def integer(prompt, default, range)
|
||||
loop do
|
||||
value = ui.ask("#{prompt}: ", Integer) { |question| question.default = default }
|
||||
return value if range.cover?(value)
|
||||
ui.say(ui.color("Enter a value from #{range.begin} through #{range.end}.", :red))
|
||||
end
|
||||
end
|
||||
|
||||
def number(prompt, default, min: nil, max: nil)
|
||||
loop do
|
||||
value = ui.ask("#{prompt}: ", Float) { |question| question.default = default }.to_f
|
||||
return value if (min.nil? || value >= min) && (max.nil? || value <= max)
|
||||
bounds = [min && "at least #{min}", max && "at most #{max}"].compact.join(" and ")
|
||||
ui.say(ui.color("Enter a value #{bounds}.", :red))
|
||||
end
|
||||
end
|
||||
|
||||
def pair(prompt, default)
|
||||
loop do
|
||||
raw = text(prompt, default.join(","))
|
||||
values = raw.split(",").map { |item| Float(item.strip) }
|
||||
return values if values.length == 2 && values.all?(&:positive?)
|
||||
ui.say(ui.color("Enter two positive comma-separated dimensions.", :red))
|
||||
rescue ArgumentError
|
||||
ui.say(ui.color("Enter two numeric comma-separated dimensions.", :red))
|
||||
end
|
||||
end
|
||||
|
||||
def slugify(name)
|
||||
name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-?\z/, "").yield_self do |slug|
|
||||
slug.empty? ? "feather-case" : slug
|
||||
end
|
||||
end
|
||||
|
||||
def yes_no(value) = value ? "yes" : "no"
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user