# 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