498 lines
20 KiB
Ruby
498 lines
20 KiB
Ruby
# 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
|
|
LID_ART_ORIENTATIONS = %w[usb_left usb_front usb_right usb_rear].freeze
|
|
LEGACY_SWITCH_FIELDS = %w[switch_hole_diameter switch_hole_compensation
|
|
switch_cap_diameter switch_flange_diameter
|
|
switch_body_length switch_body_diameter
|
|
switch_wire_allowance].freeze
|
|
|
|
DEFAULT_PARAMETERS = {
|
|
"top_profile" => "generic",
|
|
"wing_count" => 1,
|
|
"wing_pitch" => 12.25,
|
|
"stack_height_override" => 0.0,
|
|
"generic_component_height" => 3.0,
|
|
"generic_top_gap" => 2.0,
|
|
"gps_antenna_height" => 6.0,
|
|
"gps_top_gap" => 5.0,
|
|
"oled_component_height" => 2.4,
|
|
"oled_display_height" => 1.5,
|
|
"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,
|
|
"battery_partition_thickness" => 1.8,
|
|
"battery_board_clearance" => 3.0,
|
|
"rocker_cutout_size" => [12.9, 19.4],
|
|
"rocker_corner_radius" => 0.8,
|
|
"rocker_body_size" => [15.0, 21.0, 21.0],
|
|
"rocker_artwork_gap" => 2.0,
|
|
"rocker_partition_offset" => 2.0,
|
|
"rocker_tab_panel_thickness" => 2.0,
|
|
"rocker_tab_land" => 2.0,
|
|
"rocker_tab_taper_length" => 0.6,
|
|
"tripod_insert_enabled" => false,
|
|
"tripod_insert_length" => 5.0,
|
|
"tripod_insert_hole_diameter" => 7.2,
|
|
"tripod_insert_outer_diameter" => 8.0,
|
|
"tripod_thread_clearance_diameter" => 6.8,
|
|
"tripod_boss_diameter" => 14.0,
|
|
"m25_insert_length" => 4.0,
|
|
"m25_insert_hole_diameter" => 3.1,
|
|
"m25_insert_outer_diameter" => 3.5,
|
|
"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" => 2.0,
|
|
"lid_lip_depth" => 2.4,
|
|
"lid_lip_thickness" => 1.2,
|
|
"fit_clearance" => 0.35,
|
|
"cutout_min_web" => 1.2,
|
|
"hole_compensation" => 0.2,
|
|
"lid_screw_head_diameter" => 5.2,
|
|
"lid_screw_head_height" => 2.5,
|
|
"usb_opening_enabled" => true,
|
|
"usb_guide_enabled" => true,
|
|
"usb_connector_overhang" => 2.0,
|
|
"usb_guide_wall_thickness" => 1.2,
|
|
"usb_guide_end_gap" => 1.0,
|
|
"lid_label" => "",
|
|
"lid_logo_enabled" => false,
|
|
"lid_art_orientation" => "usb_left",
|
|
"lid_inlay_depth" => 0.6,
|
|
"lid_inlay_fit_clearance" => 0.08,
|
|
"lid_label_size" => 5.0,
|
|
"lid_label_logo_gap" => 2.0,
|
|
"lid_art_margin" => 8.5,
|
|
"usb_opening_width" => 13.5,
|
|
"usb_opening_height" => 8.0,
|
|
"usb_opening_radius" => 1.5,
|
|
"usb_vertical_offset" => 0.5
|
|
}.freeze
|
|
|
|
ADVANCED_FIELDS = {
|
|
"Stack" => %w[wing_pitch generic_component_height generic_top_gap
|
|
gps_antenna_height gps_top_gap oled_component_height
|
|
oled_display_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
|
|
cutout_min_web hole_compensation lid_screw_head_diameter
|
|
lid_screw_head_height],
|
|
"Lid artwork" => %w[lid_inlay_depth lid_inlay_fit_clearance lid_label_size
|
|
lid_label_logo_gap lid_art_margin],
|
|
"M2.5 inserts" => %w[m25_insert_length m25_insert_hole_diameter
|
|
m25_insert_outer_diameter
|
|
m25_screw_clearance m25_boss_diameter
|
|
insert_entry_chamfer insert_melt_relief],
|
|
"Tripod insert" => %w[tripod_insert_length tripod_insert_hole_diameter
|
|
tripod_insert_outer_diameter
|
|
tripod_thread_clearance_diameter tripod_boss_diameter],
|
|
"Battery and switch" => %w[battery_length battery_diameter battery_radial_clearance
|
|
battery_end_clearance battery_partition_thickness
|
|
battery_board_clearance
|
|
rocker_cutout_size rocker_corner_radius
|
|
rocker_body_size rocker_artwork_gap
|
|
rocker_partition_offset
|
|
rocker_tab_panel_thickness rocker_tab_land
|
|
rocker_tab_taper_length],
|
|
"USB-C opening" => %w[usb_opening_width usb_opening_height
|
|
usb_opening_radius usb_vertical_offset usb_connector_overhang
|
|
usb_guide_wall_thickness usb_guide_end_gap],
|
|
"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]
|
|
parts.concat(%w[lid_base lid_accent]) if lid_artwork?
|
|
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 lid_artwork?
|
|
!parameters["lid_label"].empty? || parameters["lid_logo_enabled"]
|
|
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"]
|
|
combined_tripod_board_gutter = p["battery_enabled"] && p["tripod_insert_enabled"] ?
|
|
2.4 : 0.0
|
|
board_origin_y = p["wall_thickness"] + p["board_side_clearance"] +
|
|
combined_tripod_board_gutter
|
|
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 + p["battery_board_clearance"]
|
|
battery_y = partition_y + p["battery_partition_thickness"] +
|
|
p["battery_radial_clearance"] + p["battery_diameter"] / 2
|
|
lid_boss_wall_overlap = 0.6
|
|
lid_boss_front_x = p["wall_thickness"] + p["m25_boss_diameter"] / 2 -
|
|
lid_boss_wall_overlap
|
|
battery_start_x = lid_boss_front_x + p["m25_boss_diameter"] / 2 +
|
|
p["battery_end_clearance"]
|
|
battery_end_x = battery_start_x + p["battery_length"]
|
|
battery_length_required = battery_end_x + p["battery_end_clearance"] +
|
|
p["wall_thickness"] + p["m25_boss_diameter"] -
|
|
lid_boss_wall_overlap
|
|
length = [plain_length, battery_length_required].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
|
|
battery_ceiling = battery_z + p["battery_diameter"] / 2 +
|
|
p["battery_radial_clearance"]
|
|
rocker_ceiling = effective_floor + p["rocker_body_size"][2] +
|
|
p["fit_clearance"]
|
|
else
|
|
length = plain_length
|
|
width = plain_width
|
|
battery_ceiling = 0
|
|
rocker_ceiling = 0
|
|
end
|
|
{
|
|
"length" => length,
|
|
"width" => width,
|
|
"height" => [top_pcb_z + component_height + top_gap,
|
|
battery_ceiling, rocker_ceiling].max,
|
|
"floor" => effective_floor,
|
|
"lip_bottom" => [top_pcb_z + component_height + top_gap,
|
|
battery_ceiling, rocker_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"])
|
|
unless LID_ART_ORIENTATIONS.include?(parameters["lid_art_orientation"])
|
|
raise ConfigError, "Unsupported lid art orientation"
|
|
end
|
|
unless parameters["lid_label"].is_a?(String)
|
|
raise ConfigError, "lid_label must be text"
|
|
end
|
|
if lid_artwork? && parameters["top_profile"] == "oled128x64"
|
|
raise ConfigError, "Lid artwork is not supported with the OLED profile"
|
|
end
|
|
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
|
|
if parameters["usb_guide_enabled"] && !parameters["usb_opening_enabled"]
|
|
raise ConfigError, "USB guide requires the USB opening"
|
|
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, "battery_partition_thickness must be at least 1.2 mm" if
|
|
parameters["battery_partition_thickness"] < 1.2
|
|
raise ConfigError, "rocker_tab_panel_thickness must be at least 1.2 mm" if
|
|
parameters["rocker_tab_panel_thickness"] < 1.2
|
|
if parameters["rocker_tab_panel_thickness"] >= parameters["lid_thickness"]
|
|
raise ConfigError, "rocker_tab_panel_thickness must be less than lid_thickness"
|
|
end
|
|
raise ConfigError, "lid_lip_thickness must be at least 1.2 mm" if
|
|
parameters["lid_lip_thickness"] < 1.2
|
|
raise ConfigError, "lid_inlay_depth must be less than lid_thickness" if
|
|
parameters["lid_inlay_depth"] >= parameters["lid_thickness"]
|
|
if parameters["m25_insert_outer_diameter"] < parameters["m25_insert_hole_diameter"]
|
|
raise ConfigError, "m25_insert_outer_diameter must not be smaller than the pilot"
|
|
end
|
|
if parameters["tripod_insert_outer_diameter"] <
|
|
parameters["tripod_insert_hole_diameter"]
|
|
raise ConfigError,
|
|
"tripod_insert_outer_diameter must not be smaller than the pilot"
|
|
end
|
|
|
|
validate_usb_opening!
|
|
validate_cutouts!
|
|
self
|
|
end
|
|
|
|
private
|
|
|
|
def normalize!
|
|
data["schema_version"] = Integer(data.fetch("schema_version", SCHEMA_VERSION))
|
|
data["generator"] ||= {}
|
|
supplied_parameters = data["parameters"] || {}
|
|
legacy_usb_guide = !supplied_parameters.key?("usb_guide_enabled")
|
|
data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(supplied_parameters)
|
|
data["parameters"]["usb_guide_enabled"] = false if legacy_usb_guide
|
|
LEGACY_SWITCH_FIELDS.each { |key| data["parameters"].delete(key) }
|
|
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 = {}
|
|
bounds = []
|
|
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?
|
|
bounds << {
|
|
"name" => name, "face" => face,
|
|
"left" => horizontal - width / 2, "right" => horizontal + width / 2,
|
|
"bottom" => z - height / 2, "top" => z + height / 2
|
|
}
|
|
rescue ArgumentError, TypeError, KeyError => e
|
|
raise ConfigError, "Invalid cutout #{name.inspect}: #{e.message}"
|
|
end
|
|
validate_cutout_spacing!(bounds, dims)
|
|
end
|
|
|
|
def validate_usb_opening!
|
|
return unless parameters["usb_opening_enabled"]
|
|
|
|
dims = dimensions
|
|
bounds = usb_wall_bounds(dims)
|
|
if bounds["bottom"] < dims["floor"]
|
|
raise ConfigError, "USB opening or guide intersects the case floor"
|
|
end
|
|
if bounds["top"] > dims["lip_bottom"]
|
|
raise ConfigError, "USB opening or guide intersects the lid lip"
|
|
end
|
|
end
|
|
|
|
def validate_cutout_spacing!(bounds, dims)
|
|
web = parameters["cutout_min_web"]
|
|
bounds.combination(2) do |first, second|
|
|
next unless first["face"] == second["face"]
|
|
next if bounds_separated?(first, second, web)
|
|
|
|
raise ConfigError,
|
|
"#{first["name"]} and #{second["name"]} are too close on the #{first["face"]} wall"
|
|
end
|
|
return unless parameters["usb_opening_enabled"]
|
|
|
|
usb = usb_wall_bounds(dims)
|
|
bounds.select { |item| item["face"] == "front" }.each do |cutout|
|
|
next if bounds_separated?(cutout, usb, web)
|
|
|
|
raise ConfigError, "#{cutout["name"]} is too close to the USB opening or guide"
|
|
end
|
|
end
|
|
|
|
def bounds_separated?(first, second, web)
|
|
first["right"] + web <= second["left"] ||
|
|
second["right"] + web <= first["left"] ||
|
|
first["top"] + web <= second["bottom"] ||
|
|
second["top"] + web <= first["bottom"]
|
|
end
|
|
|
|
def usb_wall_bounds(dims)
|
|
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_top_z = effective_floor + effective_board_clearance + 1.6
|
|
global_y = p["wall_thickness"] + p["board_side_clearance"] + 11.43
|
|
horizontal = dims["width"] - global_y
|
|
z = board_top_z + 1.6 + p["usb_vertical_offset"]
|
|
guide_length = [
|
|
p["usb_board_wall_gap"] - p["usb_connector_overhang"] - p["usb_guide_end_gap"], 0
|
|
].max
|
|
guide_active = p["usb_guide_enabled"] && guide_length.positive?
|
|
shell = guide_active ? 2 * p["usb_guide_wall_thickness"] : 0
|
|
width = p["usb_opening_width"] + p["hole_compensation"] + shell
|
|
height = p["usb_opening_height"] + p["hole_compensation"] + shell
|
|
{
|
|
"name" => "USB opening", "face" => "front",
|
|
"left" => horizontal - width / 2, "right" => horizontal + width / 2,
|
|
"bottom" => z - height / 2, "top" => z + height / 2
|
|
}
|
|
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
|