breadkit 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +16 -0
- data/SECURITY.md +5 -0
- data/data/boards/full.yml +18 -0
- data/data/boards/half.yml +17 -0
- data/data/boards/mini.yml +8 -0
- data/data/parts/2n3906.yml +10 -0
- data/data/parts/arduino_uno.yml +34 -0
- data/data/parts/bc547.yml +10 -0
- data/data/parts/bc557.yml +10 -0
- data/data/parts/capacitor.yml +7 -0
- data/data/parts/diode.yml +8 -0
- data/data/parts/electrolytic.yml +8 -0
- data/data/parts/led.yml +9 -0
- data/data/parts/ne555.yml +16 -0
- data/data/parts/pot.yml +12 -0
- data/data/parts/resistor.yml +7 -0
- data/data/parts/tact_switch_6mm.yml +19 -0
- data/data/parts/transistor.yml +10 -0
- data/docs/assets/01_led_button.svg +12 -0
- data/docs/dsl.md +62 -0
- data/exe/breadkit +5 -0
- data/lib/breadkit/analysis.rb +382 -0
- data/lib/breadkit/board.rb +163 -0
- data/lib/breadkit/cli.rb +61 -0
- data/lib/breadkit/dsl.rb +182 -0
- data/lib/breadkit/hole_id.rb +46 -0
- data/lib/breadkit/ir.rb +215 -0
- data/lib/breadkit/model.rb +31 -0
- data/lib/breadkit/part_library.rb +125 -0
- data/lib/breadkit/resolver.rb +449 -0
- data/lib/breadkit/value.rb +50 -0
- data/lib/breadkit/version.rb +5 -0
- data/lib/breadkit.rb +26 -0
- data/package-lock.json +1172 -0
- data/package.json +12 -0
- data/schema/ir-v1.json +128 -0
- data/schema/part-v1.json +48 -0
- data/sig/breadkit.rbs +180 -0
- data/site/assets/01_led_button.svg +14 -0
- data/site/assets/03_arduino_blink.svg +14 -0
- data/site/favicon.svg +7 -0
- data/site/guide/components/index.html +113 -0
- data/site/guide/dsl/index.html +132 -0
- data/site/guide/index.html +163 -0
- data/site/index.html +95 -0
- data/site/styles.css +14 -0
- metadata +94 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
State = Struct.new(:name, :closed_switches, keyword_init: true)
|
|
5
|
+
PotentialResult = Struct.new(:values, :conflicts, :components, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
class UnionFind
|
|
8
|
+
def initialize
|
|
9
|
+
@parent, @rank = {}, {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def add(item)
|
|
13
|
+
@parent[item] ||= item
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def find(item)
|
|
17
|
+
add(item)
|
|
18
|
+
@parent[item] = find(@parent[item]) unless @parent[item] == item
|
|
19
|
+
@parent[item]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def union(a, b)
|
|
23
|
+
left, right = find(a), find(b)
|
|
24
|
+
return if left == right
|
|
25
|
+
@rank[left] ||= 0
|
|
26
|
+
@rank[right] ||= 0
|
|
27
|
+
left, right = right, left if @rank[left] < @rank[right]
|
|
28
|
+
@parent[right] = left
|
|
29
|
+
@rank[left] += 1 if @rank[left] == @rank[right]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Circuit
|
|
34
|
+
attr_reader :title, :board, :components, :wires, :supplies, :labels, :expectations,
|
|
35
|
+
:lint_disables, :diagnostics
|
|
36
|
+
|
|
37
|
+
def initialize(title:, board:, components:, wires:, supplies:, labels:, expectations:, lint_disables:, diagnostics:)
|
|
38
|
+
@title, @board, @components, @wires, @supplies, @labels = title, board, components, wires, supplies, labels
|
|
39
|
+
@expectations, @lint_disables, @diagnostics = expectations, lint_disables, diagnostics
|
|
40
|
+
@net_cache, @net_index, @potential_cache = {}, {}, {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def states(mode = "single")
|
|
44
|
+
switches = components.values.select { |component| !Array(component.part.data["switch"]).empty? }
|
|
45
|
+
return [State.new(name: nil, closed_switches: [])] if mode.to_s == "none" || switches.empty?
|
|
46
|
+
# ponytail: exhaustive state generation stops at 8 switches; use a configurable search budget for larger circuits.
|
|
47
|
+
if mode.to_s == "all" && switches.length <= 8
|
|
48
|
+
(0...(1 << switches.length)).map do |bits|
|
|
49
|
+
selected = switches.each_with_index.filter_map { |component, index| component if bits[index] == 1 }
|
|
50
|
+
closed = selected.flat_map { |component| Array(component.part.data["switch"]).map { |pair| [component, pair] } }
|
|
51
|
+
State.new(name: selected.empty? ? nil : selected.map(&:ref).join(","), closed_switches: closed)
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
singles = switches.map do |component|
|
|
55
|
+
pairs = Array(component.part.data["switch"]).map { |pair| [component, pair] }
|
|
56
|
+
State.new(name: component.ref, closed_switches: pairs)
|
|
57
|
+
end
|
|
58
|
+
all_pairs = singles.flat_map(&:closed_switches)
|
|
59
|
+
singles << State.new(name: switches.map(&:ref).join(","), closed_switches: all_pairs) if mode.to_s == "all"
|
|
60
|
+
[State.new(name: nil, closed_switches: [])] + singles
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def nets(state = nil)
|
|
65
|
+
state ||= State.new(name: nil, closed_switches: [])
|
|
66
|
+
key = state_key(state)
|
|
67
|
+
return @net_cache[key] if @net_cache.key?(key)
|
|
68
|
+
|
|
69
|
+
resolved = Connectivity.new(self).build(state)
|
|
70
|
+
@net_index[key] = resolved.each_with_object({}) do |net, index|
|
|
71
|
+
index[net.name] ||= net
|
|
72
|
+
net.members.each do |member|
|
|
73
|
+
index[member] ||= net
|
|
74
|
+
node = node_for_reference(member)
|
|
75
|
+
index[node] ||= net if node
|
|
76
|
+
end
|
|
77
|
+
net.labels.each { |label| index[label] ||= net }
|
|
78
|
+
net.holes.each { |hole| index["hole:#{hole}"] ||= net }
|
|
79
|
+
end
|
|
80
|
+
@net_cache[key] = resolved
|
|
81
|
+
result = PotentialSolver.new(self).solve(state)
|
|
82
|
+
@potential_cache[key] = result
|
|
83
|
+
resolved.each { |net| net.potential = result.values[net.name] }
|
|
84
|
+
resolved
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def potentials(state = nil)
|
|
88
|
+
state ||= State.new(name: nil, closed_switches: [])
|
|
89
|
+
nets(state)
|
|
90
|
+
@potential_cache.fetch(state_key(state))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def net_of(reference, state = nil)
|
|
94
|
+
text = reference.to_s
|
|
95
|
+
nets(state)
|
|
96
|
+
index = @net_index[state_key(state || State.new(name: nil, closed_switches: []))]
|
|
97
|
+
index[text] || index[node_for_reference(text)]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def to_ir
|
|
101
|
+
IR::Writer.new.write(self)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def shortest_path(terminal_a, terminal_b, state = nil)
|
|
105
|
+
start, finish = hole_for_reference(terminal_a.to_s), hole_for_reference(terminal_b.to_s)
|
|
106
|
+
return [] unless start && finish
|
|
107
|
+
adjacency = Hash.new { |hash, key| hash[key] = [] }
|
|
108
|
+
board.strips.each_value do |ids|
|
|
109
|
+
# ponytail: each strip is a small clique; use virtual strip nodes if custom boards make this quadratic cost large.
|
|
110
|
+
ids.combination(2) { |left, right| adjacency[left] << [right, nil]; adjacency[right] << [left, nil] }
|
|
111
|
+
end
|
|
112
|
+
wires.each do |wire|
|
|
113
|
+
next if wire.electrical == false
|
|
114
|
+
|
|
115
|
+
left, right = hole_for_reference(wire.from), hole_for_reference(wire.to)
|
|
116
|
+
next unless left && right
|
|
117
|
+
adjacency[left] << [right, wire.id]
|
|
118
|
+
adjacency[right] << [left, wire.id]
|
|
119
|
+
end
|
|
120
|
+
components.each_value do |component|
|
|
121
|
+
Array(component.part.data["internal"]).each do |pair|
|
|
122
|
+
join_physical_pins(adjacency, component, pair)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
(state || State.new(name: nil, closed_switches: [])).closed_switches.each do |component, pair|
|
|
126
|
+
join_physical_pins(adjacency, component, pair)
|
|
127
|
+
end
|
|
128
|
+
previous, queue = { start => nil }, [start]
|
|
129
|
+
until queue.empty? || previous.key?(finish)
|
|
130
|
+
current = queue.shift
|
|
131
|
+
adjacency[current].each do |neighbor, edge|
|
|
132
|
+
next if previous.key?(neighbor)
|
|
133
|
+
previous[neighbor] = [current, edge]
|
|
134
|
+
queue << neighbor
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
return [terminal_a.to_s, terminal_b.to_s] unless previous.key?(finish)
|
|
138
|
+
path, current = [], finish
|
|
139
|
+
while (entry = previous[current])
|
|
140
|
+
parent, edge = entry
|
|
141
|
+
path << current
|
|
142
|
+
path << edge if edge
|
|
143
|
+
current = parent
|
|
144
|
+
end
|
|
145
|
+
(path << start).reverse.map { |item| item.start_with?("pin:") ? item.delete_prefix("pin:") : item }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def node_for_reference(reference)
|
|
149
|
+
if reference.include?(".")
|
|
150
|
+
prefix, pin = reference.split(".", 2)
|
|
151
|
+
if components[prefix]
|
|
152
|
+
item = components[prefix].pin(pin)
|
|
153
|
+
return item&.node_id
|
|
154
|
+
end
|
|
155
|
+
supply = supplies.find { |item| item.name == prefix }
|
|
156
|
+
return "supply:#{reference}" if supply && %w[+ -].include?(pin)
|
|
157
|
+
end
|
|
158
|
+
return "hole:#{board.hole(reference).id}" if board.hole(reference)
|
|
159
|
+
"label:#{reference}" if labels.any? { |item| item.name == reference }
|
|
160
|
+
rescue ArgumentError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def hole_for_reference(reference)
|
|
167
|
+
if reference.include?(".")
|
|
168
|
+
prefix, pin = reference.split(".", 2)
|
|
169
|
+
supply = supplies.find { |item| item.name == prefix }
|
|
170
|
+
return supply.plus if supply && pin == "+"
|
|
171
|
+
return supply.minus if supply && pin == "-"
|
|
172
|
+
component = components[prefix]
|
|
173
|
+
target = component&.pin(pin)
|
|
174
|
+
return target&.hole_id || target&.node_id
|
|
175
|
+
end
|
|
176
|
+
board.hole(reference)&.id
|
|
177
|
+
rescue ArgumentError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def join_physical_pins(adjacency, component, pair)
|
|
182
|
+
left = component.pin(pair[0])
|
|
183
|
+
right = component.pin(pair[1])
|
|
184
|
+
return unless left && right
|
|
185
|
+
left_node, right_node = left.hole_id || left.node_id, right.hole_id || right.node_id
|
|
186
|
+
adjacency[left_node] << [right_node, component.ref]
|
|
187
|
+
adjacency[right_node] << [left_node, component.ref]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def state_key(state)
|
|
191
|
+
state.closed_switches.map { |component, pair| [component.ref, pair] }.sort_by(&:to_s)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
class Connectivity
|
|
196
|
+
def initialize(circuit)
|
|
197
|
+
@circuit = circuit
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def build(state)
|
|
201
|
+
@uf = UnionFind.new
|
|
202
|
+
circuit.board.strips.each_value { |ids| ids.each_cons(2) { |a, b| @uf.union(hole_node(a), hole_node(b)) } }
|
|
203
|
+
circuit.components.each_value do |component|
|
|
204
|
+
component.pins.each_value { |pin| @uf.union(pin.node_id, hole_node(pin.hole_id)) if pin.hole_id }
|
|
205
|
+
Array(component.part.data["internal"]).each { |pair| join_pins(component, pair) }
|
|
206
|
+
end
|
|
207
|
+
circuit.wires.each do |wire|
|
|
208
|
+
next if wire.electrical == false
|
|
209
|
+
|
|
210
|
+
id = wire_node(wire.id)
|
|
211
|
+
@uf.union(id, endpoint_node(wire.from))
|
|
212
|
+
@uf.union(id, endpoint_node(wire.to))
|
|
213
|
+
end
|
|
214
|
+
circuit.supplies.each do |supply|
|
|
215
|
+
@uf.union(supply_node(supply.name, "+"), hole_node(supply.plus))
|
|
216
|
+
@uf.union(supply_node(supply.name, "-"), hole_node(supply.minus))
|
|
217
|
+
end
|
|
218
|
+
state.closed_switches.each { |component, pair| join_pins(component, pair) }
|
|
219
|
+
|
|
220
|
+
groups = Hash.new { |hash, key| hash[key] = { members: [], holes: [], labels: [], supplies: [] } }
|
|
221
|
+
exposed_nodes.each do |node, member, kind|
|
|
222
|
+
entry = groups[@uf.find(node)]
|
|
223
|
+
entry[:members] << member if member
|
|
224
|
+
entry[:holes] << node.delete_prefix("hole:") if kind == :hole
|
|
225
|
+
entry[:supplies] << member if kind == :supply
|
|
226
|
+
end
|
|
227
|
+
circuit.labels.each do |label|
|
|
228
|
+
node = endpoint_node(label.at)
|
|
229
|
+
groups[@uf.find(node)][:labels] << label if node
|
|
230
|
+
end
|
|
231
|
+
roots = groups.keys.select { |root| !groups[root][:members].empty? || !groups[root][:labels].empty? }
|
|
232
|
+
.sort_by { |root| order_for(groups[root][:holes]) }
|
|
233
|
+
used_names = {}
|
|
234
|
+
roots.map.with_index do |root, index|
|
|
235
|
+
data = groups[root]
|
|
236
|
+
chosen = data[:labels].first&.name || supply_name(data[:supplies])
|
|
237
|
+
chosen ||= "N#{roots.take(index + 1).count { |candidate| groups[candidate][:labels].empty? && groups[candidate][:supplies].empty? }}"
|
|
238
|
+
name = chosen
|
|
239
|
+
if used_names[name]
|
|
240
|
+
# Multiple nets can have the same label; retain deterministic unique display names.
|
|
241
|
+
name = "#{chosen}_#{index + 1}"
|
|
242
|
+
end
|
|
243
|
+
used_names[name] = true
|
|
244
|
+
net = Net.new(name: name, members: data[:members].uniq, holes: data[:holes].uniq.sort_by { |id| hole_order(id) },
|
|
245
|
+
labels: data[:labels].map(&:name).uniq)
|
|
246
|
+
net.instance_variable_set(:@root, root)
|
|
247
|
+
net
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
attr_reader :circuit
|
|
254
|
+
|
|
255
|
+
def exposed_nodes
|
|
256
|
+
list = []
|
|
257
|
+
circuit.board.holes.each_key { |id| list << [hole_node(id), nil, :hole] }
|
|
258
|
+
circuit.components.each_value do |component|
|
|
259
|
+
component.pins.each_value { |pin| list << [pin.node_id, "#{component.ref}.#{pin.name}", :pin] }
|
|
260
|
+
end
|
|
261
|
+
circuit.wires.each { |wire| list << [wire_node(wire.id), wire.id, :wire] unless wire.electrical == false }
|
|
262
|
+
circuit.supplies.each do |supply|
|
|
263
|
+
list << [supply_node(supply.name, "+"), "#{supply.name}.+", :supply]
|
|
264
|
+
list << [supply_node(supply.name, "-"), "#{supply.name}.-", :supply]
|
|
265
|
+
end
|
|
266
|
+
circuit.labels.each do |label|
|
|
267
|
+
node = endpoint_node(label.at)
|
|
268
|
+
list << [node, nil, :label] if node
|
|
269
|
+
end
|
|
270
|
+
list
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def endpoint_node(value)
|
|
274
|
+
parsed = HoleId.parse(value, board: circuit.board)
|
|
275
|
+
if parsed.kind == :pin
|
|
276
|
+
component = circuit.components[parsed.ref]
|
|
277
|
+
pin = component&.pin(parsed.pin)
|
|
278
|
+
return pin.node_id if pin
|
|
279
|
+
supply = circuit.supplies.find { |item| item.name == parsed.ref }
|
|
280
|
+
return supply_node(parsed.ref, parsed.pin) if supply && %w[+ -].include?(parsed.pin)
|
|
281
|
+
return nil
|
|
282
|
+
end
|
|
283
|
+
hole_node(parsed.to_s)
|
|
284
|
+
rescue ArgumentError
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def join_pins(component, pair)
|
|
289
|
+
left = component.pin(pair[0])
|
|
290
|
+
right = component.pin(pair[1])
|
|
291
|
+
@uf.union(left.node_id, right.node_id) if left && right
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def hole_node(id)
|
|
295
|
+
"hole:#{id}"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def wire_node(id)
|
|
299
|
+
"wire:#{id}"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def supply_node(name, side)
|
|
303
|
+
"supply:#{name}.#{side}"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def order_for(holes)
|
|
307
|
+
holes.map { |id| hole_order(id) }.min || [Float::INFINITY, Float::INFINITY]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def hole_order(id)
|
|
311
|
+
hole = circuit.board.hole(id)
|
|
312
|
+
hole ? [hole.x, hole.y] : [Float::INFINITY, Float::INFINITY]
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def supply_name(members)
|
|
316
|
+
members.first&.sub(/\.(?=[+-]\z)/, "")
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
class PotentialSolver
|
|
321
|
+
def initialize(circuit)
|
|
322
|
+
@circuit = circuit
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def solve(state)
|
|
326
|
+
edges = circuit.supplies.map do |supply|
|
|
327
|
+
[supply, circuit.net_of("#{supply.name}.-", state), circuit.net_of("#{supply.name}.+", state)]
|
|
328
|
+
end
|
|
329
|
+
values, conflicts = {}, []
|
|
330
|
+
adjacency = Hash.new { |hash, key| hash[key] = [] }
|
|
331
|
+
edges.each do |supply, from, to|
|
|
332
|
+
next unless from && to
|
|
333
|
+
adjacency[from.name] << [supply, to.name, supply.voltage, "#{supply.name}.+"]
|
|
334
|
+
adjacency[to.name] << [supply, from.name, -supply.voltage, "#{supply.name}.-"]
|
|
335
|
+
end
|
|
336
|
+
ground = circuit.nets(state).find { |net| net.labels.include?("GND") }&.name
|
|
337
|
+
components, witnesses, seen_conflicts = [], {}, {}
|
|
338
|
+
starts = adjacency.keys
|
|
339
|
+
starts = [ground, *(starts - [ground])] if starts.include?(ground)
|
|
340
|
+
starts.each do |start|
|
|
341
|
+
next if values.key?(start)
|
|
342
|
+
components << start
|
|
343
|
+
values[start] = 0.0
|
|
344
|
+
witnesses[start] = edges.lazy.filter_map do |supply, from, to|
|
|
345
|
+
if from&.name == start
|
|
346
|
+
"#{supply.name}.-"
|
|
347
|
+
elsif to&.name == start
|
|
348
|
+
"#{supply.name}.+"
|
|
349
|
+
end
|
|
350
|
+
end.first
|
|
351
|
+
queue = [start]
|
|
352
|
+
until queue.empty?
|
|
353
|
+
current = queue.shift
|
|
354
|
+
adjacency[current].each do |supply, target, delta, terminal|
|
|
355
|
+
proposed = values[current] + delta
|
|
356
|
+
if values.key?(target)
|
|
357
|
+
next if (values[target] - proposed).abs <= 1e-9 || seen_conflicts[supply.name]
|
|
358
|
+
|
|
359
|
+
first, second = witnesses[target], terminal
|
|
360
|
+
path = circuit.shortest_path(first, second, state)
|
|
361
|
+
path_wires = circuit.wires.select { |wire| path.include?(wire.id) }
|
|
362
|
+
conflicts << { supply: supply, net: target, expected: values[target], actual: proposed,
|
|
363
|
+
terminal_a: first, terminal_b: second, path: path,
|
|
364
|
+
wires: path_wires.map(&:id), location: path_wires.max_by { |wire| wire.location&.line.to_i }&.location || supply.location }
|
|
365
|
+
seen_conflicts[supply.name] = true
|
|
366
|
+
else
|
|
367
|
+
values[target] = proposed
|
|
368
|
+
witnesses[target] = terminal
|
|
369
|
+
queue << target
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
values[ground] = 0.0 if ground && !values.key?(ground)
|
|
375
|
+
PotentialResult.new(values: values, conflicts: conflicts, components: components)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
private
|
|
379
|
+
|
|
380
|
+
attr_reader :circuit
|
|
381
|
+
end
|
|
382
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
class BoardDef
|
|
5
|
+
attr_reader :data
|
|
6
|
+
|
|
7
|
+
def initialize(data)
|
|
8
|
+
@data = data
|
|
9
|
+
terminal = data["terminal"] || {}
|
|
10
|
+
unless data["id"] && terminal["columns"].to_i.positive? && terminal["rows"].is_a?(Array) && terminal["groups"].is_a?(Array)
|
|
11
|
+
raise ArgumentError, "invalid board definition: #{data['id'] || '(missing id)'}"
|
|
12
|
+
end
|
|
13
|
+
rows = terminal.fetch("rows").map(&:to_s)
|
|
14
|
+
rails = Array(data["rails"]).map { |rail| rail.fetch("id").to_s }
|
|
15
|
+
unless Array(data["rails"]).all? { |rail| !rail.key?("polarity") || %w[+ -].include?(rail["polarity"]) }
|
|
16
|
+
raise ArgumentError, "invalid rail polarity"
|
|
17
|
+
end
|
|
18
|
+
raise ArgumentError, "duplicate row name" unless rows.map(&:downcase).uniq.length == rows.length
|
|
19
|
+
raise ArgumentError, "duplicate rail name" unless rails.map(&:downcase).uniq.length == rails.length
|
|
20
|
+
[rows, rails].each do |kind_names|
|
|
21
|
+
if kind_names.any? { |name| kind_names.any? { |other| name != other && other.downcase.match?(/\A#{Regexp.escape(name.downcase)}\d+\z/) } }
|
|
22
|
+
raise ArgumentError, "ambiguous board row or rail names"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
names = (rows + rails).map(&:downcase)
|
|
26
|
+
unless names.all? { |name| name.match?(/\A[a-z][a-z0-9_+\-]*\z/) }
|
|
27
|
+
raise ArgumentError, "invalid board row or rail name"
|
|
28
|
+
end
|
|
29
|
+
if rows.any? { |row| rails.any? { |rail| rail.casecmp?(row) || rail.downcase.match?(/\A#{Regexp.escape(row.downcase)}\d+\z/) || row.downcase.match?(/\A#{Regexp.escape(rail.downcase)}\d+\z/) } }
|
|
30
|
+
raise ArgumentError, "board row and rail names must not overlap"
|
|
31
|
+
end
|
|
32
|
+
groups = terminal.fetch("groups")
|
|
33
|
+
grouped_rows = groups.flat_map { |group| Array(group).map(&:to_s) }
|
|
34
|
+
unless groups.all? { |group| group.is_a?(Array) && !group.empty? } && grouped_rows.sort == rows.sort
|
|
35
|
+
raise ArgumentError, "terminal groups must partition rows exactly"
|
|
36
|
+
end
|
|
37
|
+
if terminal.key?("ravine_between")
|
|
38
|
+
ravine = terminal["ravine_between"]
|
|
39
|
+
unless ravine.is_a?(Array) && ravine.length == 2 && rows.each_cons(2).any? { |pair| pair == ravine.map(&:to_s) }
|
|
40
|
+
raise ArgumentError, "ravine_between must name adjacent terminal rows"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def id
|
|
46
|
+
data.fetch("id")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.load(id, extra_paths: [], extra_definitions: [])
|
|
50
|
+
paths = extra_paths.flat_map { |path| Dir.glob(path) }
|
|
51
|
+
paths.concat(Dir.glob(File.expand_path("../../data/boards/*.yml", __dir__)))
|
|
52
|
+
data = extra_definitions.find { |item| item["id"].to_s == id.to_s }
|
|
53
|
+
data ||= paths.uniq.lazy.map { |path| YAML.safe_load(File.read(path, encoding: "UTF-8"), aliases: false) }.find { |item| item && item["id"].to_s == id.to_s }
|
|
54
|
+
raise ArgumentError, "unknown board: #{id}" unless data
|
|
55
|
+
|
|
56
|
+
new(data)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class Board
|
|
61
|
+
attr_reader :definition, :holes, :strips, :split_rails
|
|
62
|
+
|
|
63
|
+
def initialize(definition, split_rails: false)
|
|
64
|
+
@definition, @split_rails = definition, split_rails
|
|
65
|
+
@holes, @strips = {}, {}
|
|
66
|
+
@terminal_rows = definition.data.dig("terminal", "rows")
|
|
67
|
+
ravine = Array(definition.data.dig("terminal", "ravine_between")).map(&:to_s)
|
|
68
|
+
@terminal_row_positions = {}
|
|
69
|
+
position = 0
|
|
70
|
+
@terminal_rows.each_with_index do |row, index|
|
|
71
|
+
position += 2 if index.positive? && [@terminal_rows[index - 1].to_s, row.to_s] == ravine
|
|
72
|
+
@terminal_row_positions[row.to_s] = position
|
|
73
|
+
position += 1
|
|
74
|
+
end
|
|
75
|
+
build_terminal_holes
|
|
76
|
+
build_rail_holes
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def hole(id)
|
|
80
|
+
holes[HoleId.parse(id, board: self).to_s]
|
|
81
|
+
rescue ArgumentError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def strip(id)
|
|
86
|
+
item = hole(id)
|
|
87
|
+
item && strips[item.strip_id]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def width
|
|
91
|
+
definition.data.dig("terminal", "columns").to_i
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def height
|
|
95
|
+
@terminal_rows.empty? ? 0 : @terminal_row_positions.fetch(@terminal_rows.last.to_s) + 1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def row_count
|
|
99
|
+
@terminal_rows.length
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def terminal_rows
|
|
103
|
+
@terminal_rows.map(&:to_s)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ravine_between
|
|
107
|
+
Array(definition.data.dig("terminal", "ravine_between")).map(&:to_s)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def rail_ids
|
|
111
|
+
definition.data.fetch("rails", []).map { |rail| rail.fetch("id").to_s }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def rail_polarity(id)
|
|
115
|
+
rail = definition.data.fetch("rails", []).find { |item| item.fetch("id").to_s.casecmp?(id.to_s) }
|
|
116
|
+
rail && (rail["polarity"] || rail.fetch("id").to_s[/[+-]\z/])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def add_hole(hole)
|
|
122
|
+
holes[hole.id] = hole
|
|
123
|
+
(strips[hole.strip_id] ||= []) << hole.id
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_terminal_holes
|
|
127
|
+
terminal = definition.data.fetch("terminal")
|
|
128
|
+
groups = terminal.fetch("groups")
|
|
129
|
+
terminal.fetch("columns").times do |col|
|
|
130
|
+
groups.each_with_index do |rows, group_index|
|
|
131
|
+
strip_id = "terminal:#{col + 1}:#{group_index}"
|
|
132
|
+
rows.each do |row|
|
|
133
|
+
add_hole(Hole.new(id: "#{row}#{col + 1}", kind: :terminal, row: row.to_s,
|
|
134
|
+
col: col + 1, x: col.to_f, y: @terminal_row_positions.fetch(row.to_s).to_f,
|
|
135
|
+
strip_id: strip_id))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def build_rail_holes
|
|
142
|
+
layout = definition.data["rail_layout"]
|
|
143
|
+
return unless layout
|
|
144
|
+
|
|
145
|
+
definition.data.fetch("rails", []).each do |rail|
|
|
146
|
+
rail_id = rail.fetch("id")
|
|
147
|
+
segments = layout.fetch("segments")
|
|
148
|
+
segments = layout.fetch("split_segments", segments) if split_rails
|
|
149
|
+
segments.each_with_index do |range, segment_index|
|
|
150
|
+
(range[0]..range[1]).each do |index|
|
|
151
|
+
x = layout.fetch("start_column", 1) - 1 + index - 1
|
|
152
|
+
size = layout["group_size"].to_i
|
|
153
|
+
x += (index - 1) / size if size.positive?
|
|
154
|
+
y = rail.fetch("side") == "top" ? height + 1 + rail.fetch("order", 0) : -2 - rail.fetch("order", 0)
|
|
155
|
+
strip_id = "rail:#{rail_id}:#{segment_index}"
|
|
156
|
+
add_hole(Hole.new(id: "#{rail_id}#{index}", kind: :rail, rail: rail_id,
|
|
157
|
+
col: index, x: x.to_f, y: y.to_f, strip_id: strip_id))
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/breadkit/cli.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Breadkit
|
|
6
|
+
class CLI
|
|
7
|
+
USAGE = "Usage: breadkit ir FILE | nets [--state SWITCH] FILE | parts [FILE] | --version".freeze
|
|
8
|
+
|
|
9
|
+
def run(argv)
|
|
10
|
+
args = argv.dup
|
|
11
|
+
command = args.shift
|
|
12
|
+
case command
|
|
13
|
+
when "ir", "nets"
|
|
14
|
+
state_name = nil
|
|
15
|
+
if command == "nets"
|
|
16
|
+
OptionParser.new { |opts| opts.on("--state SWITCH") { |value| state_name = value } }.parse!(args)
|
|
17
|
+
end
|
|
18
|
+
path = args.shift
|
|
19
|
+
raise ArgumentError, "usage: breadkit #{command} FILE" unless path
|
|
20
|
+
raise ArgumentError, "unexpected arguments: #{args.join(' ')}" unless args.empty?
|
|
21
|
+
|
|
22
|
+
circuit = Breadkit.load(path)
|
|
23
|
+
if command == "ir"
|
|
24
|
+
errors = circuit.diagnostics.select { |item| item.severity == "error" }
|
|
25
|
+
errors.each { |item| warn "breadkit: #{item.code}: #{item.message}" }
|
|
26
|
+
return 1 unless errors.empty?
|
|
27
|
+
|
|
28
|
+
puts JSON.pretty_generate(circuit.to_ir)
|
|
29
|
+
0
|
|
30
|
+
else
|
|
31
|
+
state = circuit.states.find { |item| item.name == state_name } if state_name
|
|
32
|
+
raise ArgumentError, "unknown switch state #{state_name}" if state_name && !state
|
|
33
|
+
circuit.nets(state).each { |net| puts "#{net.name}: #{net.members.join(', ')}" }
|
|
34
|
+
circuit.diagnostics.any? { |item| item.severity == "error" } ? 1 : 0
|
|
35
|
+
end
|
|
36
|
+
when "parts"
|
|
37
|
+
path = args.shift
|
|
38
|
+
raise ArgumentError, "unexpected arguments: #{args.join(' ')}" unless args.empty?
|
|
39
|
+
|
|
40
|
+
document = DSL.load_file(path) if path
|
|
41
|
+
PartLibrary.new(extra_paths: document&.part_paths || [], extra_definitions: document&.part_definitions || [])
|
|
42
|
+
.all.each { |part| puts "#{part.id}\t#{part.data['category']}" }
|
|
43
|
+
0
|
|
44
|
+
when "--version", "-v"
|
|
45
|
+
raise ArgumentError, "unexpected arguments: #{args.join(' ')}" unless args.empty?
|
|
46
|
+
|
|
47
|
+
puts Breadkit::VERSION
|
|
48
|
+
0
|
|
49
|
+
when "-h", "--help", "help"
|
|
50
|
+
puts USAGE
|
|
51
|
+
0
|
|
52
|
+
else
|
|
53
|
+
warn USAGE
|
|
54
|
+
2
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
warn "breadkit: #{e.message}"
|
|
58
|
+
2
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|