breadkit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.codespellignore +2 -0
  3. data/.yamllint +16 -0
  4. data/README.md +109 -6
  5. data/data/boards/double_full.yml +18 -0
  6. data/data/boards/stripboard.yml +9 -0
  7. data/data/boards/universal.yml +8 -0
  8. data/data/parts/2n3904.yml +11 -0
  9. data/data/parts/2n7000_onsemi_to92.yml +10 -0
  10. data/data/parts/74hc00.yml +22 -0
  11. data/data/parts/74hc04.yml +22 -0
  12. data/data/parts/74hc08.yml +22 -0
  13. data/data/parts/74hc14.yml +22 -0
  14. data/data/parts/74hc165.yml +24 -0
  15. data/data/parts/74hc32.yml +22 -0
  16. data/data/parts/74hc595.yml +24 -0
  17. data/data/parts/active_buzzer.yml +8 -0
  18. data/data/parts/ams1117_3v3.yml +12 -0
  19. data/data/parts/arduino_nano.yml +70 -0
  20. data/data/parts/arduino_uno.yml +29 -26
  21. data/data/parts/atmega328p.yml +37 -0
  22. data/data/parts/attiny85.yml +16 -0
  23. data/data/parts/battery_box.yml +8 -0
  24. data/data/parts/cd4017.yml +24 -0
  25. data/data/parts/ck_bd04.yml +29 -0
  26. data/data/parts/crystal.yml +7 -0
  27. data/data/parts/dc_jack_2wire.yml +8 -0
  28. data/data/parts/diode.yml +1 -0
  29. data/data/parts/esp32_devkitc_v4_wroom32e.yml +86 -0
  30. data/data/parts/irlz44n.yml +10 -0
  31. data/data/parts/l293d.yml +24 -0
  32. data/data/parts/led.yml +1 -0
  33. data/data/parts/lm358.yml +16 -0
  34. data/data/parts/lm393.yml +16 -0
  35. data/data/parts/ne555.yml +2 -2
  36. data/data/parts/ntc_thermistor.yml +7 -0
  37. data/data/parts/photoresistor.yml +8 -0
  38. data/data/parts/pico.yml +89 -0
  39. data/data/parts/resistor.yml +1 -0
  40. data/data/parts/sc56_11ewa.yml +30 -0
  41. data/data/parts/seeed_xiao_samd21.yml +37 -0
  42. data/data/parts/shillehtek_mb102_4pin.yml +23 -0
  43. data/data/parts/slide_switch_spst.yml +8 -0
  44. data/data/parts/sparkfun_pro_micro_5v.yml +58 -0
  45. data/data/parts/transistor.yml +0 -1
  46. data/data/parts/ua7805.yml +10 -0
  47. data/data/parts/uln2003a.yml +23 -0
  48. data/data/parts/wp154a4sureqbfzgc.yml +11 -0
  49. data/docs/CLI.md +108 -0
  50. data/docs/COMPATIBILITY.md +60 -0
  51. data/docs/DECLARATIVE.md +138 -0
  52. data/docs/IR.md +42 -0
  53. data/docs/JUMPER_KIT.md +45 -0
  54. data/docs/LOCK.md +75 -0
  55. data/docs/LSP.md +59 -0
  56. data/docs/MB102_POWER.md +11 -0
  57. data/docs/MCP.md +44 -0
  58. data/docs/RAKE.md +19 -0
  59. data/docs/dsl.md +197 -6
  60. data/exe/breadkit-lsp +13 -0
  61. data/exe/breadkit-mcp +12 -0
  62. data/lib/breadkit/analysis.rb +174 -50
  63. data/lib/breadkit/board.rb +152 -4
  64. data/lib/breadkit/cli.rb +229 -13
  65. data/lib/breadkit/color.rb +38 -0
  66. data/lib/breadkit/dc_analysis.rb +261 -0
  67. data/lib/breadkit/dsl.rb +205 -24
  68. data/lib/breadkit/exporters.rb +202 -0
  69. data/lib/breadkit/formatter.rb +52 -0
  70. data/lib/breadkit/fritzing_export.rb +256 -0
  71. data/lib/breadkit/ir.rb +154 -22
  72. data/lib/breadkit/jumper_kit.rb +127 -0
  73. data/lib/breadkit/lsp_server.rb +248 -0
  74. data/lib/breadkit/mcp_server.rb +133 -0
  75. data/lib/breadkit/model.rb +47 -5
  76. data/lib/breadkit/part_library.rb +156 -3
  77. data/lib/breadkit/part_lock.rb +107 -0
  78. data/lib/breadkit/patterns.rb +119 -0
  79. data/lib/breadkit/rake_task.rb +25 -0
  80. data/lib/breadkit/resolver.rb +160 -28
  81. data/lib/breadkit/structured_input.rb +275 -0
  82. data/lib/breadkit/templates/555.bk.rb +34 -0
  83. data/lib/breadkit/templates/arduino.bk.rb +17 -0
  84. data/lib/breadkit/templates/led.bk.rb +21 -0
  85. data/lib/breadkit/value.rb +42 -3
  86. data/lib/breadkit/version.rb +1 -1
  87. data/lib/breadkit/wire_suggestions.rb +106 -0
  88. data/lib/breadkit.rb +28 -3
  89. data/package.json +1 -1
  90. data/schema/board-v1.json +38 -0
  91. data/schema/ir-v1.json +56 -11
  92. data/schema/ir-v2.json +173 -0
  93. data/schema/part-v1.json +52 -4
  94. data/sig/breadkit.rbs +107 -5
  95. metadata +119 -14
  96. data/docs/assets/01_led_button.svg +0 -12
  97. data/site/assets/01_led_button.svg +0 -14
  98. data/site/assets/03_arduino_blink.svg +0 -14
  99. data/site/favicon.svg +0 -7
  100. data/site/guide/components/index.html +0 -113
  101. data/site/guide/dsl/index.html +0 -132
  102. data/site/guide/index.html +0 -163
  103. data/site/index.html +0 -95
  104. data/site/styles.css +0 -14
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+
5
+ module Breadkit
6
+ module Exporters
7
+ FORMATS = %w[kicad spice wokwi pins fritzing].freeze
8
+
9
+ module_function
10
+
11
+ def call(circuit, format:)
12
+ errors = circuit.diagnostics.select { |item| item.severity == "error" }
13
+ raise ArgumentError, "cannot export a circuit with errors: #{errors.first.message}" unless errors.empty?
14
+
15
+ case format.to_s
16
+ when "kicad" then kicad(circuit)
17
+ when "spice" then spice(circuit)
18
+ when "wokwi" then JSON.pretty_generate(wokwi(circuit))
19
+ when "pins" then pins(circuit)
20
+ when "fritzing" then FritzingExport.call(circuit)
21
+ else raise ArgumentError, "unknown export format #{format}; choose #{FORMATS.join(', ')}"
22
+ end
23
+ end
24
+
25
+ def kicad(circuit)
26
+ components = circuit.components.values.map do |component|
27
+ %( <comp ref="#{xml(component.ref)}"><value>#{xml(component.value || component.part.id)}</value>) +
28
+ %(<libsource lib="breadkit" part="#{xml(component.part.id)}"/></comp>)
29
+ end
30
+ components.concat(circuit.supplies.map do |supply|
31
+ %( <comp ref="#{xml(supply.name)}"><value>#{xml(supply.voltage)}V</value>) +
32
+ %(<libsource lib="breadkit" part="supply"/></comp>)
33
+ end)
34
+ definitions = circuit.components.values.map(&:part).uniq.map do |part|
35
+ pin_type = %w[resistor capacitor electrolytic led diode].include?(part.id) ? "passive" : "unspecified"
36
+ pins = part.pins.map do |pin|
37
+ %( <pin num="#{xml(pin.fetch('num'))}" name="#{xml(pin['name'] || pin['num'])}" type="#{pin_type}"/>)
38
+ end
39
+ %( <libpart lib="breadkit" part="#{xml(part.id)}">\n <pins>\n#{pins.join("\n")}\n </pins>\n </libpart>)
40
+ end
41
+ if circuit.supplies.any?
42
+ definitions << %( <libpart lib="breadkit" part="supply"><pins>) +
43
+ %(<pin num="1" name="+" type="power_out"/>) +
44
+ %(<pin num="2" name="-" type="power_out"/>) +
45
+ %(</pins></libpart>)
46
+ end
47
+ nets = circuit.nets.filter_map.with_index(1) do |net, code|
48
+ nodes = net.members.filter_map do |member|
49
+ ref, name = member.split(".", 2)
50
+ if circuit.components[ref]
51
+ pin = circuit.components[ref].pin(name)
52
+ %( <node ref="#{xml(ref)}" pin="#{xml(pin.number)}"/>) if pin
53
+ elsif circuit.supplies.any? { |supply| supply.name == ref } && %w[+ -].include?(name)
54
+ %( <node ref="#{xml(ref)}" pin="#{name == '+' ? 1 : 2}"/>)
55
+ end
56
+ end
57
+ next if nodes.empty? || (nodes.length == 1 && net.labels.empty?)
58
+
59
+ %( <net code="#{code}" name="#{xml(net.name)}">\n#{nodes.uniq.join("\n")}\n </net>)
60
+ end
61
+ <<~XML
62
+ <?xml version="1.0" encoding="UTF-8"?>
63
+ <export version="E">
64
+ <design><source>breadkit</source><tool>breadkit</tool></design>
65
+ <components>
66
+ #{components.join("\n")}
67
+ </components>
68
+ <libparts>
69
+ #{definitions.join("\n")}
70
+ </libparts>
71
+ <nets>
72
+ #{nets.join("\n")}
73
+ </nets>
74
+ </export>
75
+ XML
76
+ end
77
+
78
+ def spice(circuit)
79
+ unsupported = circuit.components.values.reject { |item| %w[resistor capacitor].include?(item.part.id) }
80
+ raise ArgumentError, "SPICE export supports only resistors and capacitors: #{unsupported.map(&:ref).join(', ')}" unless unsupported.empty?
81
+ raise ArgumentError, "SPICE export needs a supply to establish ground" if circuit.supplies.empty?
82
+ if circuit.supplies.any? { |item| item.voltage_range || !item.voltage.is_a?(Numeric) }
83
+ raise ArgumentError, "SPICE export requires fixed supply voltages"
84
+ end
85
+
86
+ ground = circuit.net_of("#{circuit.supplies.first.name}.-")
87
+ raise ArgumentError, "SPICE export cannot find the ground net" unless ground
88
+ names = circuit.nets.each_with_index.to_h { |net, index| [net.name, net == ground ? "0" : "N#{index + 1}"] }
89
+ lines = ["Breadkit circuit"]
90
+ circuit.supplies.each do |supply|
91
+ safe_identifier!(supply.name, "supply")
92
+ lines << "V#{supply.name} #{spice_node(circuit, names, "#{supply.name}.+")} #{spice_node(circuit, names, "#{supply.name}.-")} #{number(supply.voltage)}"
93
+ end
94
+ circuit.components.each_value do |component|
95
+ prefix = component.part.id == "resistor" ? "R" : "C"
96
+ safe_identifier!(component.ref, "component")
97
+ raise ArgumentError, "#{component.ref} must start with #{prefix} for SPICE" unless component.ref.start_with?(prefix)
98
+ pin_names = component.part.pins.map { |pin| (pin["name"] || pin["num"]).to_s }
99
+ raise ArgumentError, "#{component.ref} must have two pins" unless pin_names.length == 2
100
+ value = Value.parse(component.value)
101
+ raise ArgumentError, "#{component.ref} needs a positive value" unless value.positive? && value.finite?
102
+ nodes = pin_names.map { |pin| spice_node(circuit, names, "#{component.ref}.#{pin}") }
103
+ lines << "#{component.ref} #{nodes.join(' ')} #{number(value)}"
104
+ end
105
+ (lines + [".op", ".end", ""]).join("\n")
106
+ end
107
+
108
+ def wokwi(circuit)
109
+ raise ArgumentError, "Wokwi export requires an offboard controller, not standalone supplies" unless circuit.supplies.empty?
110
+ types = { "resistor" => "wokwi-resistor", "led" => "wokwi-led", "arduino_uno" => "wokwi-arduino-uno" }
111
+ parts = circuit.components.values.each_with_index.map do |component, index|
112
+ type = types[component.part.id]
113
+ raise ArgumentError, "Wokwi export does not support #{component.part.id} (#{component.ref})" unless type
114
+
115
+ attrs = case component.part.id
116
+ when "resistor" then { value: number(Value.parse(component.value)) }
117
+ when "led" then { color: component.attrs.fetch(:color, "red").to_s }
118
+ else {}
119
+ end
120
+ { id: component.ref, type: type, left: index * 180, top: 0, attrs: attrs }
121
+ end
122
+ active = circuit.components.values.flat_map do |component|
123
+ component.pins.values.filter_map { |pin| "#{component.ref}.#{pin.name}" if pin.hole_id }
124
+ end
125
+ active.concat(circuit.wires.flat_map { |wire| [wire.from, wire.to].select { |end_at| end_at.include?(".") } })
126
+ active.uniq!
127
+ connections = circuit.nets.flat_map do |net|
128
+ color = circuit.wires.find { |wire| wire.electrical != false && net.members.include?(wire.id) }&.color || "green"
129
+ endpoints = net.members.filter_map do |member|
130
+ next unless active.include?(member)
131
+ ref, pin_name = member.split(".", 2)
132
+ component = circuit.components[ref]
133
+ next unless component
134
+ mapped = wokwi_pin(component, pin_name)
135
+ raise ArgumentError, "Wokwi has no pin for #{member}" unless mapped
136
+ "#{ref}:#{mapped}"
137
+ end.uniq
138
+ endpoints.drop(1).map { |other| [endpoints.first, other, color, []] }
139
+ end
140
+ { version: 1, author: "Breadkit", editor: "wokwi", parts: parts, connections: connections }
141
+ end
142
+
143
+ def pins(circuit)
144
+ controllers = circuit.components.values.select { |item| %w[arduino_uno arduino_nano pico pico_w rp2040_clone].include?(item.part.id) }
145
+ raise ArgumentError, "pin export needs exactly one Arduino Uno, Nano, or RP2040 controller" unless controllers.length == 1
146
+ controller = controllers.first
147
+ arduino = %w[arduino_uno arduino_nano].include?(controller.part.id)
148
+ declarations = {}
149
+ controller.pins.each_value do |pin|
150
+ number = if arduino
151
+ pin.name[/\A(?:D(\d+)|(A\d+))\z/, 1] || pin.name[/\A(A\d+)\z/, 1]
152
+ else
153
+ pin.name[/\AGP(\d+)\z/, 1]
154
+ end
155
+ next unless number
156
+ net = circuit.net_of("#{controller.ref}.#{pin.name}")
157
+ next unless net
158
+ peers = net.members.grep(/\A(?!#{Regexp.escape(controller.ref)}\.)[^.]+\.[^.]+\z/)
159
+ next if peers.empty? && net.labels.empty?
160
+
161
+ name = net.labels.first || peers.first || net.name
162
+ constant = "PIN_#{name.upcase.gsub(/[^A-Z0-9]+/, '_').sub(/_\z/, '')}"
163
+ raise ArgumentError, "pin name collision for #{constant}; label the nets uniquely" if declarations.key?(constant)
164
+ declarations[constant] = number
165
+ end
166
+ raise ArgumentError, "no connected GPIO pins to export" if declarations.empty?
167
+ lines = declarations.sort.map do |name, number|
168
+ arduino ? "#define #{name} #{number}" : "#{name} = #{number}"
169
+ end
170
+ ([arduino ? "#pragma once" : "# MicroPython GPIO constants", "", *lines, ""]).join("\n")
171
+ end
172
+
173
+ def wokwi_pin(component, name)
174
+ case component.part.id
175
+ when "resistor" then name if %w[1 2].include?(name)
176
+ when "led" then { "anode" => "A", "cathode" => "C" }[name]
177
+ when "arduino_uno"
178
+ return name.delete_prefix("D") if name.match?(/\AD(?:[0-9]|1[0-3])\z/)
179
+ return name if name.match?(/\AA[0-5]\z/) || %w[5V VIN].include?(name)
180
+ { "GND" => "GND.1", "GND2" => "GND.2", "GND3" => "GND.3" }[name]
181
+ end
182
+ end
183
+
184
+ def spice_node(circuit, names, reference)
185
+ net = circuit.net_of(reference)
186
+ raise ArgumentError, "unconnected SPICE terminal #{reference}" unless net
187
+ names.fetch(net.name)
188
+ end
189
+
190
+ def safe_identifier!(value, kind)
191
+ raise ArgumentError, "invalid SPICE #{kind} reference #{value}" unless value.match?(/\A[A-Za-z][A-Za-z0-9_]*\z/)
192
+ end
193
+
194
+ def number(value)
195
+ format("%.12g", value)
196
+ end
197
+
198
+ def xml(value)
199
+ CGI.escapeHTML(value.to_s)
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ module Formatter
5
+ module_function
6
+
7
+ def call(path)
8
+ raise ArgumentError, "fmt supports .bk.yml, .bk.yaml, and .bk.toml files" unless path.end_with?(".bk.yml", ".bk.yaml", ".bk.toml")
9
+
10
+ StructuredInput.load_file(path) # Validate the same fields accepted by the circuit loader.
11
+ source = File.read(path, encoding: "UTF-8")
12
+ if path.end_with?(".bk.toml")
13
+ toml(Tomlrb.parse(source))
14
+ else
15
+ YAML.dump(YAML.safe_load(source, aliases: false), line_width: -1)
16
+ end
17
+ end
18
+
19
+ def toml(data)
20
+ root, sections = data.partition do |_key, value|
21
+ !value.is_a?(Array) || value.empty? || !value.all? { |item| item.is_a?(Hash) }
22
+ end
23
+ lines = root.map { |key, value| "#{key_name(key)} = #{toml_value(value)}" }
24
+ sections.each do |key, items|
25
+ items.each do |item|
26
+ lines << "" unless lines.empty?
27
+ lines << "[[#{key_name(key)}]]"
28
+ item.each { |field, value| lines << "#{key_name(field)} = #{toml_value(value)}" }
29
+ end
30
+ end
31
+ "#{lines.join("\n")}\n"
32
+ end
33
+
34
+ def toml_value(value)
35
+ case value
36
+ when String then JSON.generate(value)
37
+ when Integer then value.to_s
38
+ when Float
39
+ raise ArgumentError, "TOML cannot format nonfinite numbers" unless value.finite?
40
+ value.to_s
41
+ when TrueClass, FalseClass then value.to_s
42
+ when Array then "[#{value.map { |item| toml_value(item) }.join(', ')}]"
43
+ when Hash then "{#{value.map { |key, item| "#{key_name(key)} = #{toml_value(item)}" }.join(', ')}}"
44
+ else raise ArgumentError, "TOML cannot format #{value.class}"
45
+ end
46
+ end
47
+
48
+ def key_name(value)
49
+ value.match?(/\A[A-Za-z0-9_-]+\z/) ? value : JSON.generate(value)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+
5
+ module Breadkit
6
+ # Writes a Fritzing .fz sketch for the exact 830-hole full breadboard and its jumpers.
7
+ class FritzingExport
8
+ BOARD_PART = "Breadboard-RSR03MB102-ModuleID"
9
+ VIEWS = [["breadboardView", "breadboardbreadboard", "breadboardWire"],
10
+ ["pcbView", "breadboardbreadboard", "copper1trace"],
11
+ ["schematicView", "breadboardbreadboard", "schematicTrace"]].freeze
12
+ COLORS = { "red" => "#ff0000", "black" => "#000000", "green" => "#008000",
13
+ "blue" => "#0000ff", "yellow" => "#ffff00", "white" => "#ffffff",
14
+ "orange" => "#ffa500" }.freeze
15
+ RAILS = { "T+" => ["Y", 14.4], "T-" => ["Z", 7.2],
16
+ "B+" => ["W", 144.0], "B-" => ["X", 136.8] }.freeze
17
+ # The official breadboard2.svg uses Illustrator's 72 dpi pixel dimensions.
18
+ # Fritzing's scene uses 90 dpi, unlike the SVG's own coordinate system.
19
+ BOARD_SCALE = 90.0 / 72
20
+ RED_LED_PART = "LED-genedb611bf8177f41ac9c325217070f0c62ColorLEDModuleID"
21
+ PARTS = { "resistor" => ["ResistorModuleID", "resistor.fzp"],
22
+ "led" => [RED_LED_PART, "LED-generic-5mm_6852162_005.fzp"],
23
+ "tact_switch_6mm" => ["20A9BBEE34_ST", "pushbutton_4_horizontal.fzp"] }.freeze
24
+
25
+ def self.call(circuit)
26
+ new(circuit).call
27
+ end
28
+
29
+ def initialize(circuit)
30
+ @circuit = circuit
31
+ end
32
+
33
+ def call
34
+ validate!
35
+ links = Hash.new { |hash, key| hash[key] = [] }
36
+ components = circuit.components.values.each_with_index.map do |component, offset|
37
+ index = offset + 2
38
+ plan = part_plan(component)
39
+ plan.fetch(:pins).each do |connector, location|
40
+ links[location.first] << [index, connector, :part]
41
+ end
42
+ part_instance(component, index, plan)
43
+ end
44
+ wires = circuit.wires.each_with_index.map do |wire, index|
45
+ endpoints = [wire.from, wire.to].map { |reference| terminal(reference) }
46
+ model_index = index + components.length + 2
47
+ endpoints.each_with_index { |(pin, _x, _y), side| links[pin] << [model_index, "connector#{side}", :wire] }
48
+ wire_instance(wire, model_index, endpoints)
49
+ end
50
+ <<~XML
51
+ <?xml version="1.0" encoding="UTF-8"?>
52
+ <module fritzingVersion="1.0.8" icon=".png">
53
+ <views>
54
+ <view name="breadboardView" backgroundColor="#ffffff" gridSize="0.1in" showGrid="1" alignToGrid="1" viewFromBelow="0"/>
55
+ <view name="schematicView" backgroundColor="#ffffff" gridSize="0.1in" showGrid="1" alignToGrid="1" viewFromBelow="0"/>
56
+ <view name="pcbView" backgroundColor="#333333" gridSize="0.05in" showGrid="1" alignToGrid="1" viewFromBelow="0"/>
57
+ </views>
58
+ <instances>
59
+ #{board_instance(links)}
60
+ #{components.join("\n")}
61
+ #{wires.join("\n")}
62
+ </instances>
63
+ </module>
64
+ XML
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :circuit
70
+
71
+ def validate!
72
+ standard = BoardDef.load("full").data
73
+ unless !circuit.multi_board? && circuit.board.definition.data == standard && !circuit.board.split_rails
74
+ raise ArgumentError, "Fritzing export supports only the standard unsplit full board"
75
+ end
76
+ raise ArgumentError, "Fritzing export does not support standalone supplies" unless circuit.supplies.empty?
77
+ raise ArgumentError, "Fritzing export does not support net labels" unless circuit.labels.empty?
78
+
79
+ circuit.components.each_value { |component| part_plan(component) }
80
+
81
+ circuit.wires.each do |wire|
82
+ unless wire.electrical && wire.route == "straight" && Array(wire.layer).empty? && !wire.dashed
83
+ raise ArgumentError, "Fritzing export supports only straight electrical jumpers without layers or dashes (#{wire.id})"
84
+ end
85
+ color(wire.color)
86
+ end
87
+ end
88
+
89
+ def terminal(reference)
90
+ hole = circuit.board.hole(reference)
91
+ raise ArgumentError, "Fritzing export requires a board hole, got #{reference}" unless hole
92
+
93
+ if hole.kind == :terminal
94
+ ["pin#{hole.col}#{hole.row.upcase}", (10.92 + (hole.col - 1) * 7.2) * BOARD_SCALE,
95
+ (115.2 - hole.y * 7.2) * BOARD_SCALE]
96
+ else
97
+ rail, y = RAILS.fetch(hole.rail) { raise ArgumentError, "Fritzing export does not support rail #{hole.rail}" }
98
+ number = 3 + hole.col - 1 + ((hole.col - 1) / 5)
99
+ ["pin#{number}#{rail}", (10.92 + (number - 1) * 7.2) * BOARD_SCALE, y * BOARD_SCALE]
100
+ end
101
+ end
102
+
103
+ def part_plan(component)
104
+ id = component.part.id
105
+ part = PARTS[id]
106
+ raise ArgumentError, "Fritzing export has unsupported part #{component.ref} (#{id})" unless part
107
+ if component.attrs[:rotate].to_i != 0 || component.attrs[:mirror]
108
+ raise ArgumentError, "Fritzing export does not support rotated or mirrored #{component.ref}"
109
+ end
110
+
111
+ names = case id
112
+ when "resistor" then { "connector0" => "1", "connector1" => "2" }
113
+ when "led" then { "connector0" => "cathode", "connector1" => "anode" }
114
+ else { "connector0" => "2", "connector1" => "1", "connector2" => "4", "connector3" => "3" }
115
+ end
116
+ pins = names.to_h do |connector, name|
117
+ pin = component.pin(name)
118
+ hole = pin && circuit.board.hole(pin.hole_id)
119
+ unless hole&.kind == :terminal
120
+ raise ArgumentError, "Fritzing export needs #{component.ref}.#{name} in a terminal hole"
121
+ end
122
+ [connector, terminal(hole.id)]
123
+ end
124
+ case id
125
+ when "resistor"
126
+ if pins.fetch("connector0")[2] != pins.fetch("connector1")[2] ||
127
+ pins.fetch("connector0")[1] >= pins.fetch("connector1")[1]
128
+ raise ArgumentError, "Fritzing export requires #{component.ref} pins 1 and 2 left-to-right in one row"
129
+ end
130
+ unless component.value.to_s.match?(/\A\d+(?:\.\d+)?(?:[kMmunp])?\z/)
131
+ raise ArgumentError, "Fritzing export needs a plain resistance value for #{component.ref}"
132
+ end
133
+ when "led"
134
+ unless component.attrs.fetch(:color, "red").to_s.casecmp?("red")
135
+ raise ArgumentError, "Fritzing export currently supports only a red LED (#{component.ref})"
136
+ end
137
+ if pins.fetch("connector0")[2] != pins.fetch("connector1")[2]
138
+ raise ArgumentError, "Fritzing export requires #{component.ref} leads in one row"
139
+ end
140
+ else
141
+ top_left, bottom_left, top_right, bottom_right = pins.values_at(*%w[connector0 connector1 connector2 connector3])
142
+ unless (top_right[1] - top_left[1] - 18).abs < 0.001 && bottom_right[1] == top_right[1] &&
143
+ bottom_left[1] == top_left[1] && top_left[2] == top_right[2] &&
144
+ (bottom_left[2] - top_left[2] - 27).abs < 0.001 && bottom_right[2] == bottom_left[2]
145
+ raise ArgumentError, "Fritzing export requires #{component.ref} to straddle the gap on a 2-by-3 hole footprint"
146
+ end
147
+ end
148
+ { id: part[0], path: part[1], type: id, pins: pins }
149
+ end
150
+
151
+ def part_instance(component, index, plan)
152
+ pins = plan.fetch(:pins)
153
+ x, y, anchors, mirror_width = part_geometry(plan)
154
+ properties = case plan.fetch(:type)
155
+ when "resistor"
156
+ %(<property name="resistance" value="#{CGI.escapeHTML(component.value.to_s)}"/>) +
157
+ %(<property name="pin spacing" value="400 mil"/>)
158
+ when "led" then %(<property name="color" value="Red (633nm)"/>)
159
+ else ""
160
+ end
161
+ views = VIEWS.map do |view, _board_layer, _wire_layer|
162
+ layer = part_layer(view)
163
+ transform = mirror_width ? %(<transform m11="-1" m12="0" m13="0" m21="0" m22="1" m23="0" m31="#{point(mirror_width)}" m32="0" m33="1"/>) : ""
164
+ geometry = %(<geometry z="2.5" x="#{point(x)}" y="#{point(y)}">#{transform}</geometry>)
165
+ connectors = pins.map do |connector, (pin, target_x, target_y)|
166
+ anchor_x, anchor_y = anchors.fetch(connector)
167
+ tip_x = target_x - x - (mirror_width ? mirror_width - anchor_x : anchor_x)
168
+ tip_x = -tip_x if mirror_width
169
+ tip_y = target_y - y - anchor_y
170
+ leg = if view == "breadboardView" && plan.fetch(:type) != "tact_switch_6mm"
171
+ %(<leg><point x="0" y="0"/><bezier/><point x="#{point(tip_x)}" y="#{point(tip_y)}"/><bezier/></leg>)
172
+ else
173
+ ""
174
+ end
175
+ %(<connector connectorId="#{connector}" layer="#{layer}"><geometry x="#{point(anchor_x)}" y="#{point(anchor_y)}"/>) +
176
+ %(#{leg}<connects><connect connectorId="#{pin}" modelIndex="1" layer="breadboardbreadboard"/></connects></connector>)
177
+ end.join
178
+ %(<#{view} layer="#{layer}">#{geometry}<connectors>#{connectors}</connectors></#{view}>)
179
+ end.join
180
+ %(<instance moduleIdRef="#{plan.fetch(:id)}" modelIndex="#{index}" path="#{plan.fetch(:path)}">) +
181
+ %(#{properties}<title>#{CGI.escapeHTML(component.ref)}</title><views>#{views}</views></instance>)
182
+ end
183
+
184
+ def part_geometry(plan)
185
+ pins = plan.fetch(:pins)
186
+ case plan.fetch(:type)
187
+ when "resistor"
188
+ left, right = pins.values_at("connector0", "connector1")
189
+ [(left[1] + right[1]) / 2 - 19.313, left[2] - 4.541,
190
+ { "connector0" => [2.619, 4.541], "connector1" => [36.006, 4.541] }, false]
191
+ when "led"
192
+ cathode, anode = pins.values_at("connector0", "connector1")
193
+ [(cathode[1] + anode[1]) / 2 - 9.660, cathode[2] - 55,
194
+ { "connector0" => [5.658, 36.509], "connector1" => [14.661, 36.509] },
195
+ anode[1] < cathode[1] ? 19.320 : nil]
196
+ else
197
+ top_left, bottom_left = pins.values_at("connector0", "connector1")
198
+ [top_left[1] - 2.171, (top_left[2] + bottom_left[2]) / 2 - 14.852,
199
+ { "connector0" => [19.895, 1.914], "connector1" => [19.895, 27.789],
200
+ "connector2" => [1.895, 1.914], "connector3" => [1.895, 27.789] }, 22.066]
201
+ end
202
+ end
203
+
204
+ def part_layer(view)
205
+ case view
206
+ when "breadboardView" then "breadboard"
207
+ when "schematicView" then "schematic"
208
+ else "copper0"
209
+ end
210
+ end
211
+
212
+ def board_instance(links)
213
+ views = VIEWS.map do |view, board_layer, wire_layer|
214
+ connectors = links.sort.map do |pin, wires|
215
+ connects = wires.map do |index, connector, type|
216
+ layer = type == :wire ? wire_layer : part_layer(view)
217
+ %(<connect connectorId="#{connector}" modelIndex="#{index}" layer="#{layer}"/>)
218
+ end.join
219
+ %(<connector connectorId="#{pin}" layer="#{board_layer}"><geometry x="0" y="0"/><connects>#{connects}</connects></connector>)
220
+ end.join
221
+ %(<#{view} layer="#{board_layer}"><geometry z="1.5" x="0" y="0"/><connectors>#{connectors}</connectors></#{view}>)
222
+ end.join
223
+ %(<instance moduleIdRef="#{BOARD_PART}" modelIndex="1" path="breadboard2.fzp"><title>Breadboard</title><views>#{views}</views></instance>)
224
+ end
225
+
226
+ def wire_instance(wire, index, endpoints)
227
+ first, last = endpoints
228
+ views = VIEWS.map do |view, board_layer, wire_layer|
229
+ geometry = %(<geometry z="3.5" x="#{point(first[1])}" y="#{point(first[2])}" x1="0" y1="0" ) +
230
+ %(x2="#{point(last[1] - first[1])}" y2="#{point(last[2] - first[2])}" wireFlags="64"/>)
231
+ connectors = endpoints.each_with_index.map do |(pin, _x, _y), side|
232
+ %(<connector connectorId="connector#{side}" layer="#{wire_layer}"><geometry x="0" y="0"/>) +
233
+ %(<connects><connect connectorId="#{pin}" modelIndex="1" layer="#{board_layer}"/></connects></connector>)
234
+ end.join
235
+ %(<#{view} layer="#{wire_layer}">#{geometry}<wireExtras mils="22.2222" color="#{color(wire.color)}" opacity="1" banded="0"/>) +
236
+ %(<connectors>#{connectors}</connectors></#{view}>)
237
+ end.join
238
+ %(<instance moduleIdRef="WireModuleID" modelIndex="#{index}" path="wire.fzp"><title>#{CGI.escapeHTML(wire.id)}</title>) +
239
+ %(<views>#{views}</views></instance>)
240
+ end
241
+
242
+ def color(value)
243
+ return "#404040" unless value
244
+
245
+ text = value.to_s.downcase
246
+ return COLORS.fetch(text) if COLORS.key?(text)
247
+ return text if text.match?(/\A#[0-9a-f]{6}\z/)
248
+
249
+ raise ArgumentError, "Fritzing export supports only basic wire colors or #RRGGBB"
250
+ end
251
+
252
+ def point(value)
253
+ format("%.3f", value)
254
+ end
255
+ end
256
+ end