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.
- checksums.yaml +4 -4
- data/.codespellignore +2 -0
- data/.yamllint +16 -0
- data/README.md +109 -6
- data/data/boards/double_full.yml +18 -0
- data/data/boards/stripboard.yml +9 -0
- data/data/boards/universal.yml +8 -0
- data/data/parts/2n3904.yml +11 -0
- data/data/parts/2n7000_onsemi_to92.yml +10 -0
- data/data/parts/74hc00.yml +22 -0
- data/data/parts/74hc04.yml +22 -0
- data/data/parts/74hc08.yml +22 -0
- data/data/parts/74hc14.yml +22 -0
- data/data/parts/74hc165.yml +24 -0
- data/data/parts/74hc32.yml +22 -0
- data/data/parts/74hc595.yml +24 -0
- data/data/parts/active_buzzer.yml +8 -0
- data/data/parts/ams1117_3v3.yml +12 -0
- data/data/parts/arduino_nano.yml +70 -0
- data/data/parts/arduino_uno.yml +29 -26
- data/data/parts/atmega328p.yml +37 -0
- data/data/parts/attiny85.yml +16 -0
- data/data/parts/battery_box.yml +8 -0
- data/data/parts/cd4017.yml +24 -0
- data/data/parts/ck_bd04.yml +29 -0
- data/data/parts/crystal.yml +7 -0
- data/data/parts/dc_jack_2wire.yml +8 -0
- data/data/parts/diode.yml +1 -0
- data/data/parts/esp32_devkitc_v4_wroom32e.yml +86 -0
- data/data/parts/irlz44n.yml +10 -0
- data/data/parts/l293d.yml +24 -0
- data/data/parts/led.yml +1 -0
- data/data/parts/lm358.yml +16 -0
- data/data/parts/lm393.yml +16 -0
- data/data/parts/ne555.yml +2 -2
- data/data/parts/ntc_thermistor.yml +7 -0
- data/data/parts/photoresistor.yml +8 -0
- data/data/parts/pico.yml +89 -0
- data/data/parts/resistor.yml +1 -0
- data/data/parts/sc56_11ewa.yml +30 -0
- data/data/parts/seeed_xiao_samd21.yml +37 -0
- data/data/parts/shillehtek_mb102_4pin.yml +23 -0
- data/data/parts/slide_switch_spst.yml +8 -0
- data/data/parts/sparkfun_pro_micro_5v.yml +58 -0
- data/data/parts/transistor.yml +0 -1
- data/data/parts/ua7805.yml +10 -0
- data/data/parts/uln2003a.yml +23 -0
- data/data/parts/wp154a4sureqbfzgc.yml +11 -0
- data/docs/CLI.md +108 -0
- data/docs/COMPATIBILITY.md +60 -0
- data/docs/DECLARATIVE.md +138 -0
- data/docs/IR.md +42 -0
- data/docs/JUMPER_KIT.md +45 -0
- data/docs/LOCK.md +75 -0
- data/docs/LSP.md +59 -0
- data/docs/MB102_POWER.md +11 -0
- data/docs/MCP.md +44 -0
- data/docs/RAKE.md +19 -0
- data/docs/dsl.md +197 -6
- data/exe/breadkit-lsp +13 -0
- data/exe/breadkit-mcp +12 -0
- data/lib/breadkit/analysis.rb +174 -50
- data/lib/breadkit/board.rb +152 -4
- data/lib/breadkit/cli.rb +229 -13
- data/lib/breadkit/color.rb +38 -0
- data/lib/breadkit/dc_analysis.rb +261 -0
- data/lib/breadkit/dsl.rb +205 -24
- data/lib/breadkit/exporters.rb +202 -0
- data/lib/breadkit/formatter.rb +52 -0
- data/lib/breadkit/fritzing_export.rb +256 -0
- data/lib/breadkit/ir.rb +154 -22
- data/lib/breadkit/jumper_kit.rb +127 -0
- data/lib/breadkit/lsp_server.rb +248 -0
- data/lib/breadkit/mcp_server.rb +133 -0
- data/lib/breadkit/model.rb +47 -5
- data/lib/breadkit/part_library.rb +156 -3
- data/lib/breadkit/part_lock.rb +107 -0
- data/lib/breadkit/patterns.rb +119 -0
- data/lib/breadkit/rake_task.rb +25 -0
- data/lib/breadkit/resolver.rb +160 -28
- data/lib/breadkit/structured_input.rb +275 -0
- data/lib/breadkit/templates/555.bk.rb +34 -0
- data/lib/breadkit/templates/arduino.bk.rb +17 -0
- data/lib/breadkit/templates/led.bk.rb +21 -0
- data/lib/breadkit/value.rb +42 -3
- data/lib/breadkit/version.rb +1 -1
- data/lib/breadkit/wire_suggestions.rb +106 -0
- data/lib/breadkit.rb +28 -3
- data/package.json +1 -1
- data/schema/board-v1.json +38 -0
- data/schema/ir-v1.json +56 -11
- data/schema/ir-v2.json +173 -0
- data/schema/part-v1.json +52 -4
- data/sig/breadkit.rbs +107 -5
- metadata +119 -14
- data/docs/assets/01_led_button.svg +0 -12
- data/site/assets/01_led_button.svg +0 -14
- data/site/assets/03_arduino_blink.svg +0 -14
- data/site/favicon.svg +0 -7
- data/site/guide/components/index.html +0 -113
- data/site/guide/dsl/index.html +0 -132
- data/site/guide/index.html +0 -163
- data/site/index.html +0 -95
- 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
|