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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +16 -0
  4. data/SECURITY.md +5 -0
  5. data/data/boards/full.yml +18 -0
  6. data/data/boards/half.yml +17 -0
  7. data/data/boards/mini.yml +8 -0
  8. data/data/parts/2n3906.yml +10 -0
  9. data/data/parts/arduino_uno.yml +34 -0
  10. data/data/parts/bc547.yml +10 -0
  11. data/data/parts/bc557.yml +10 -0
  12. data/data/parts/capacitor.yml +7 -0
  13. data/data/parts/diode.yml +8 -0
  14. data/data/parts/electrolytic.yml +8 -0
  15. data/data/parts/led.yml +9 -0
  16. data/data/parts/ne555.yml +16 -0
  17. data/data/parts/pot.yml +12 -0
  18. data/data/parts/resistor.yml +7 -0
  19. data/data/parts/tact_switch_6mm.yml +19 -0
  20. data/data/parts/transistor.yml +10 -0
  21. data/docs/assets/01_led_button.svg +12 -0
  22. data/docs/dsl.md +62 -0
  23. data/exe/breadkit +5 -0
  24. data/lib/breadkit/analysis.rb +382 -0
  25. data/lib/breadkit/board.rb +163 -0
  26. data/lib/breadkit/cli.rb +61 -0
  27. data/lib/breadkit/dsl.rb +182 -0
  28. data/lib/breadkit/hole_id.rb +46 -0
  29. data/lib/breadkit/ir.rb +215 -0
  30. data/lib/breadkit/model.rb +31 -0
  31. data/lib/breadkit/part_library.rb +125 -0
  32. data/lib/breadkit/resolver.rb +449 -0
  33. data/lib/breadkit/value.rb +50 -0
  34. data/lib/breadkit/version.rb +5 -0
  35. data/lib/breadkit.rb +26 -0
  36. data/package-lock.json +1172 -0
  37. data/package.json +12 -0
  38. data/schema/ir-v1.json +128 -0
  39. data/schema/part-v1.json +48 -0
  40. data/sig/breadkit.rbs +180 -0
  41. data/site/assets/01_led_button.svg +14 -0
  42. data/site/assets/03_arduino_blink.svg +14 -0
  43. data/site/favicon.svg +7 -0
  44. data/site/guide/components/index.html +113 -0
  45. data/site/guide/dsl/index.html +132 -0
  46. data/site/guide/index.html +163 -0
  47. data/site/index.html +95 -0
  48. data/site/styles.css +14 -0
  49. 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
@@ -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