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,449 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
class Resolver
|
|
5
|
+
def call(document)
|
|
6
|
+
@document = document
|
|
7
|
+
@diagnostics = []
|
|
8
|
+
@board = load_board
|
|
9
|
+
@terminal_positions = @board.holes.values.select { |hole| hole.kind == :terminal }.to_h { |hole| [[hole.x, hole.y], hole] }
|
|
10
|
+
@library = PartLibrary.new(extra_paths: document.part_paths, extra_definitions: document.part_definitions)
|
|
11
|
+
components = resolve_components
|
|
12
|
+
wires = resolve_wires(components)
|
|
13
|
+
supplies = resolve_supplies
|
|
14
|
+
validate_names(components, wires, supplies)
|
|
15
|
+
labels = document.labels.map { |item| Label.new(name: item[:name], at: item[:at], location: item[:location]) }
|
|
16
|
+
validate_references(components, supplies, labels)
|
|
17
|
+
circuit = Circuit.new(title: document.title, board: @board, components: components, wires: wires,
|
|
18
|
+
supplies: supplies, labels: labels, expectations: document.expectations,
|
|
19
|
+
lint_disables: document.lint_disables, diagnostics: @diagnostics)
|
|
20
|
+
validate_split_labels(circuit) if labels.length > 1
|
|
21
|
+
circuit
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def load_board
|
|
27
|
+
Board.new(BoardDef.load(@document.board[:type], extra_paths: @document.board_paths,
|
|
28
|
+
extra_definitions: @document.board_definitions),
|
|
29
|
+
split_rails: @document.board[:options][:split_rails] || false)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
@diagnostics << diagnostic(:unknown_board, "error", e.message, nil)
|
|
32
|
+
Board.new(BoardDef.load("full"))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolve_components
|
|
36
|
+
refs = {}
|
|
37
|
+
@document.components.each_with_object({}) do |item, result|
|
|
38
|
+
ref = item[:ref]
|
|
39
|
+
if refs[ref]
|
|
40
|
+
@diagnostics << diagnostic(:duplicate_ref, "error", "duplicate component reference #{ref}", item[:location], [ref])
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
refs[ref] = true
|
|
44
|
+
part = @library.find(item[:type], pin_count: item.dig(:attrs, :pin_count) || item.dig(:attrs, "pin_count"))
|
|
45
|
+
if item[:type].to_s == "transistor" && item[:value]
|
|
46
|
+
model = @library.find(item[:value])
|
|
47
|
+
if model && model.data["category"] == "transistor"
|
|
48
|
+
part = model
|
|
49
|
+
else
|
|
50
|
+
@diagnostics << diagnostic(:unknown_transistor_model, "warning", "unknown transistor model #{item[:value]}; using generic EBC pinout", item[:location], [ref])
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
unless part
|
|
54
|
+
@diagnostics << diagnostic(:unknown_part, "error", "unknown part #{item[:type]}", item[:location], [ref])
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
pins = resolve_pins(item, part)
|
|
58
|
+
result[ref] = Component.new(ref: ref, part: part, value: item[:value], attrs: item[:attrs] || {},
|
|
59
|
+
pins: pins, unused: Array(item[:unused]).map(&:to_s), location: item[:location])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_pins(item, part)
|
|
64
|
+
requested = item[:pins]
|
|
65
|
+
result = {}
|
|
66
|
+
if requested.is_a?(Hash)
|
|
67
|
+
requested.each_key do |key|
|
|
68
|
+
@diagnostics << diagnostic(:unknown_pin, "error", "unknown pin #{item[:ref]}.#{key}", item[:location], [item[:ref]]) unless part.pin(key)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
attrs = item[:attrs] || {}
|
|
72
|
+
pin_names = part.pins.flat_map { |pin| [pin["num"], pin["name"], *Array(pin["aliases"])] }.compact.map(&:to_s)
|
|
73
|
+
attrs.each_key do |key|
|
|
74
|
+
next if part.pin(key) || %w[pin_count color layer address label side at].include?(key.to_s)
|
|
75
|
+
|
|
76
|
+
suggestion = DidYouMean::SpellChecker.new(dictionary: pin_names).correct(key.to_s).first
|
|
77
|
+
if suggestion
|
|
78
|
+
@diagnostics << diagnostic(:unknown_pin, "error", "unknown pin #{item[:ref]}.#{key}; did you mean #{suggestion}?", item[:location], [item[:ref]])
|
|
79
|
+
else
|
|
80
|
+
@diagnostics << diagnostic(:unknown_option, "error", "unknown component option #{item[:ref]}.#{key}", item[:location], [item[:ref]])
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
if item[:at] && part.placement == "leads"
|
|
84
|
+
placement_error(item, "#{part.id} has no footprint; place its pins explicitly")
|
|
85
|
+
end
|
|
86
|
+
part.pins.each_with_index do |definition, index|
|
|
87
|
+
name, number = definition["name"] || definition["num"].to_s, definition["num"].to_s
|
|
88
|
+
anchor = explicit_pin(requested, definition, index)
|
|
89
|
+
anchor ||= explicit_pin(item[:attrs], definition, index)
|
|
90
|
+
if anchor.nil? && item[:at] && part.placement != "leads"
|
|
91
|
+
anchor = footprint_pin(item, definition, part)
|
|
92
|
+
end
|
|
93
|
+
if anchor
|
|
94
|
+
hole = @board.hole(anchor)
|
|
95
|
+
unless hole
|
|
96
|
+
if part.placement == "dip"
|
|
97
|
+
placement_error(item, "DIP #{item[:ref]} extends beyond the board")
|
|
98
|
+
else
|
|
99
|
+
@diagnostics << diagnostic(:invalid_hole, "error", "invalid hole #{anchor.inspect}", item[:location], [item[:ref], anchor])
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
result[name.to_s] = Pin.new(name: name.to_s, number: number, hole_id: hole&.id,
|
|
103
|
+
node_id: "pin:#{item[:ref]}.#{name}", role: definition["type"] || definition["role"])
|
|
104
|
+
else
|
|
105
|
+
result[name.to_s] = Pin.new(name: name.to_s, number: number, node_id: "pin:#{item[:ref]}.#{name}",
|
|
106
|
+
role: definition["type"] || definition["role"])
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
validate_footprint_geometry(item, part, result) if requested && part.placement == "footprint"
|
|
110
|
+
if part.data["straddle"] && (item[:at] || requested)
|
|
111
|
+
groups = @board.definition.data.dig("terminal", "groups")
|
|
112
|
+
occupied_groups = result.values.filter_map do |pin|
|
|
113
|
+
row = @board.hole(pin.hole_id)&.row
|
|
114
|
+
groups&.index { |rows| rows.include?(row) } if row
|
|
115
|
+
end.uniq
|
|
116
|
+
placement_error(item, "#{item[:ref]} must straddle the center gap") if occupied_groups.length < 2
|
|
117
|
+
end
|
|
118
|
+
placement_requested = requested || item[:at] || attrs.keys.any? { |key| part.pin(key) }
|
|
119
|
+
if placement_requested && part.placement != "offboard" && !@diagnostics.any? { |entry| entry.code == "invalid_placement" && entry.targets.include?(item[:ref]) }
|
|
120
|
+
missing = result.values.reject(&:hole_id).map(&:name)
|
|
121
|
+
@diagnostics << diagnostic(:unplaced_pin, "error", "#{item[:ref]} has unplaced pins: #{missing.join(', ')}", item[:location], [item[:ref]]) unless missing.empty?
|
|
122
|
+
end
|
|
123
|
+
result
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def explicit_pin(source, definition, index)
|
|
127
|
+
return nil unless source
|
|
128
|
+
if source.is_a?(Array)
|
|
129
|
+
return source[index]
|
|
130
|
+
end
|
|
131
|
+
return nil unless source.respond_to?(:each_pair)
|
|
132
|
+
names = ([definition["num"], definition["name"]] + Array(definition["aliases"])).compact.map { |key| key.to_s.downcase }
|
|
133
|
+
source.each_pair.find { |key, _value| names.include?(key.to_s.downcase) }&.last
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate_footprint_geometry(item, part, result)
|
|
137
|
+
footprint = part.data["footprint"]
|
|
138
|
+
return unless footprint
|
|
139
|
+
|
|
140
|
+
first = part.pins.first
|
|
141
|
+
first_key = (first["name"] || first["num"]).to_s
|
|
142
|
+
origin = @board.hole(item[:at] || result[first_key]&.hole_id)
|
|
143
|
+
return unless origin
|
|
144
|
+
|
|
145
|
+
first_offset = footprint[first["num"].to_s] || [0, 0]
|
|
146
|
+
part.pins.each do |definition|
|
|
147
|
+
pin = result[(definition["name"] || definition["num"]).to_s]
|
|
148
|
+
next unless pin&.hole_id
|
|
149
|
+
|
|
150
|
+
offset = footprint[definition["num"].to_s]
|
|
151
|
+
next unless offset
|
|
152
|
+
|
|
153
|
+
expected = @terminal_positions[[origin.x + offset[0].to_i - first_offset[0].to_i,
|
|
154
|
+
origin.y + offset[1].to_i - first_offset[1].to_i]]
|
|
155
|
+
unless pin.hole_id == expected&.id
|
|
156
|
+
placement_error(item, "#{item[:ref]} pins do not match its footprint")
|
|
157
|
+
break
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def footprint_pin(item, definition, part)
|
|
163
|
+
at = HoleId.parse(item[:at], board: @board)
|
|
164
|
+
unless at.kind == :terminal
|
|
165
|
+
placement_error(item, "#{item[:ref]} needs a terminal hole anchor")
|
|
166
|
+
return nil
|
|
167
|
+
end
|
|
168
|
+
pin_num = definition["num"].to_s
|
|
169
|
+
if part.placement == "dip"
|
|
170
|
+
count = part.data.dig("package", "pins").to_i
|
|
171
|
+
count = part.pins.length if count.zero?
|
|
172
|
+
half = count / 2
|
|
173
|
+
first_row = at.row
|
|
174
|
+
near_row, far_row = @board.ravine_between
|
|
175
|
+
return invalid_placement(item, at) unless [near_row, far_row].include?(first_row)
|
|
176
|
+
row = first_row
|
|
177
|
+
col = at.col
|
|
178
|
+
if pin_num.to_i <= half
|
|
179
|
+
col += pin_num.to_i - 1 if first_row == near_row
|
|
180
|
+
col -= pin_num.to_i - 1 if first_row == far_row
|
|
181
|
+
else
|
|
182
|
+
row = first_row == near_row ? far_row : near_row
|
|
183
|
+
reverse_index = count - pin_num.to_i
|
|
184
|
+
col += first_row == near_row ? reverse_index : -reverse_index
|
|
185
|
+
end
|
|
186
|
+
return "#{row}#{col}"
|
|
187
|
+
end
|
|
188
|
+
footprint = part.data["footprint"]
|
|
189
|
+
return nil unless footprint
|
|
190
|
+
offset = footprint[pin_num]
|
|
191
|
+
return item[:at] if !offset && pin_num == part.pins.first["num"].to_s
|
|
192
|
+
return nil unless offset
|
|
193
|
+
anchor = @board.hole(at.to_s)
|
|
194
|
+
target = anchor && @terminal_positions[[anchor.x + offset[0].to_i, anchor.y + offset[1].to_i]]
|
|
195
|
+
unless target
|
|
196
|
+
placement_error(item, "#{item[:ref]} footprint extends beyond the board or into the center gap")
|
|
197
|
+
return nil
|
|
198
|
+
end
|
|
199
|
+
target.id
|
|
200
|
+
rescue ArgumentError
|
|
201
|
+
placement_error(item, "invalid component anchor #{item[:at]}")
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def placement_error(item, message)
|
|
206
|
+
return if @diagnostics.any? { |entry| entry.code == "invalid_placement" && entry.targets.include?(item[:ref]) }
|
|
207
|
+
|
|
208
|
+
@diagnostics << diagnostic(:invalid_placement, "error", message, item[:location], [item[:ref]])
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def invalid_placement(item, at)
|
|
212
|
+
unless @diagnostics.any? { |entry| entry.code == "invalid_placement" && entry.targets.include?(item[:ref]) }
|
|
213
|
+
@diagnostics << diagnostic(:invalid_placement, "error", "DIP #{item[:ref]} must straddle the center gap (#{@board.ravine_between.join('/')} row)", item[:location], [item[:ref], at.to_s])
|
|
214
|
+
end
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def resolve_wires(components)
|
|
219
|
+
wires = []
|
|
220
|
+
occupied = {}
|
|
221
|
+
components.each_value do |component|
|
|
222
|
+
component.pins.each_value do |pin|
|
|
223
|
+
next unless pin.hole_id
|
|
224
|
+
if occupied[pin.hole_id]
|
|
225
|
+
@diagnostics << diagnostic(:hole_conflict, "error", "hole #{pin.hole_id} is occupied by multiple leads", component.location,
|
|
226
|
+
[component.ref, pin.hole_id])
|
|
227
|
+
else
|
|
228
|
+
occupied[pin.hole_id] = component.ref
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
@document.supplies.each do |item|
|
|
233
|
+
[item[:plus], item[:minus]].each do |id|
|
|
234
|
+
next unless @board.hole(id)
|
|
235
|
+
if occupied[id]
|
|
236
|
+
@diagnostics << diagnostic(:hole_conflict, "error", "hole #{id} is already occupied", item[:location], [item[:name], id])
|
|
237
|
+
else
|
|
238
|
+
occupied[id] = item[:name]
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
reserved = {}
|
|
243
|
+
@document.wires.each do |item|
|
|
244
|
+
next if item[:electrical] == false
|
|
245
|
+
|
|
246
|
+
[item[:from], item[:to]].each do |endpoint|
|
|
247
|
+
parsed = HoleId.parse(endpoint, board: @board) rescue nil
|
|
248
|
+
next unless parsed && (parsed.kind == :terminal || (parsed.kind == :rail && parsed.index))
|
|
249
|
+
reserved[parsed.to_s] = true if @board.hole(parsed.to_s)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
ids = {}
|
|
253
|
+
reserved_ids = components.keys + @document.supplies.map { |item| item[:name] } + @document.wires.filter_map { |item| item[:id] }
|
|
254
|
+
next_id = 1
|
|
255
|
+
@document.wires.each do |item|
|
|
256
|
+
unless item[:id]
|
|
257
|
+
next_id += 1 while reserved_ids.include?("W#{next_id}") || ids["W#{next_id}"]
|
|
258
|
+
end
|
|
259
|
+
wire_id = item[:id] || "W#{next_id}"
|
|
260
|
+
next_id += 1 unless item[:id]
|
|
261
|
+
if ids[wire_id]
|
|
262
|
+
@diagnostics << diagnostic(:duplicate_ref, "error", "duplicate wire ID #{wire_id}", item[:location], [wire_id])
|
|
263
|
+
next
|
|
264
|
+
end
|
|
265
|
+
ids[wire_id] = true
|
|
266
|
+
endpoints = [item[:from], item[:to]]
|
|
267
|
+
if item[:electrical] == false
|
|
268
|
+
endpoints.each do |endpoint|
|
|
269
|
+
parsed = HoleId.parse(endpoint, board: @board) rescue nil
|
|
270
|
+
valid = if parsed&.kind == :pin
|
|
271
|
+
pin_component, pin = component_pin(endpoint, components)
|
|
272
|
+
unless pin
|
|
273
|
+
@diagnostics << diagnostic(:unknown_pin, "error", "unknown pin #{endpoint}", item[:location], [wire_id, endpoint])
|
|
274
|
+
next
|
|
275
|
+
end
|
|
276
|
+
pin && (pin.hole_id || pin_component&.part&.placement == "offboard")
|
|
277
|
+
else
|
|
278
|
+
parsed && @board.hole(parsed.to_s)
|
|
279
|
+
end
|
|
280
|
+
@diagnostics << diagnostic(:invalid_hole, "error", "invalid visual wire endpoint #{endpoint.inspect}", item[:location], [wire_id, endpoint]) unless valid
|
|
281
|
+
end
|
|
282
|
+
wires << Wire.new(id: wire_id, from: endpoints[0], to: endpoints[1], color: item[:color],
|
|
283
|
+
route: item[:route], layer: item[:layer], electrical: false, dashed: item[:dashed],
|
|
284
|
+
location: item[:location])
|
|
285
|
+
next
|
|
286
|
+
end
|
|
287
|
+
parsed = endpoints.map { |endpoint| HoleId.parse(endpoint, board: @board) rescue nil }
|
|
288
|
+
endpoints.each_with_index do |endpoint, side|
|
|
289
|
+
if parsed[side]&.kind == :pin
|
|
290
|
+
pin_component, pin = component_pin(endpoint, components)
|
|
291
|
+
unless pin
|
|
292
|
+
@diagnostics << diagnostic(:unknown_pin, "error", "unknown pin #{endpoint}", item[:location], [wire_id, endpoint])
|
|
293
|
+
next
|
|
294
|
+
end
|
|
295
|
+
next if pin_component&.part&.placement == "offboard" && pin
|
|
296
|
+
pin_hole = endpoint_hole(endpoint, components)
|
|
297
|
+
target_x = explicit_x(endpoints[1 - side], components)
|
|
298
|
+
candidates = @board.strip(pin_hole)
|
|
299
|
+
picked = candidates && candidates.map { |id| @board.hole(id) }
|
|
300
|
+
.compact.reject { |hole| occupied[hole.id] || reserved[hole.id] }
|
|
301
|
+
.min_by { |hole| [(target_x ? (hole.x - target_x).abs : 0), hole_order(hole)] }
|
|
302
|
+
if picked
|
|
303
|
+
endpoints[side], parsed[side] = picked.id, HoleId.parse(picked.id, board: @board)
|
|
304
|
+
occupied[picked.id] = wire_id
|
|
305
|
+
else
|
|
306
|
+
@diagnostics << diagnostic(:no_free_hole, "error", "no free hole in the strip for #{endpoint}", item[:location], [wire_id, endpoint])
|
|
307
|
+
end
|
|
308
|
+
next
|
|
309
|
+
end
|
|
310
|
+
next if parsed[side]&.kind == :terminal && @board.hole(endpoint)
|
|
311
|
+
next if parsed[side]&.kind == :rail && parsed[side].index && @board.hole(endpoint)
|
|
312
|
+
if parsed[side]&.kind == :rail && parsed[side].index.nil?
|
|
313
|
+
target = explicit_x(endpoints[1 - side], components)
|
|
314
|
+
picked = nearest_free(parsed[side].rail, target, occupied, reserved)
|
|
315
|
+
if picked
|
|
316
|
+
endpoints[side] = picked.id
|
|
317
|
+
parsed[side] = HoleId.parse(picked.id, board: @board)
|
|
318
|
+
occupied[picked.id] = wire_id
|
|
319
|
+
else
|
|
320
|
+
@diagnostics << diagnostic(:no_free_hole, "error", "no free hole on rail #{parsed[side].rail}", item[:location], [wire_id])
|
|
321
|
+
end
|
|
322
|
+
next
|
|
323
|
+
end
|
|
324
|
+
@diagnostics << diagnostic(:invalid_hole, "error", "invalid or unknown wire endpoint #{endpoint.inspect}", item[:location], [wire_id, endpoint])
|
|
325
|
+
end
|
|
326
|
+
endpoints.each do |endpoint|
|
|
327
|
+
id = endpoint_hole(endpoint, components)
|
|
328
|
+
next unless id && @board.hole(id)
|
|
329
|
+
if occupied[id] && occupied[id] != wire_id
|
|
330
|
+
@diagnostics << diagnostic(:hole_conflict, "error", "hole #{id} is already occupied", item[:location], [wire_id, id])
|
|
331
|
+
end
|
|
332
|
+
occupied[id] = true
|
|
333
|
+
end
|
|
334
|
+
wires << Wire.new(id: wire_id, from: endpoints[0], to: endpoints[1], color: item[:color],
|
|
335
|
+
route: item[:route], layer: item[:layer], electrical: true, dashed: item[:dashed],
|
|
336
|
+
location: item[:location])
|
|
337
|
+
end
|
|
338
|
+
wires
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def explicit_x(endpoint, components)
|
|
342
|
+
id = endpoint_hole(endpoint, components)
|
|
343
|
+
@board.hole(id)&.x if id
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def endpoint_hole(endpoint, components)
|
|
347
|
+
id = HoleId.parse(endpoint, board: @board)
|
|
348
|
+
return id.to_s unless id.kind == :pin
|
|
349
|
+
_component, pin = component_pin(endpoint, components)
|
|
350
|
+
pin&.hole_id
|
|
351
|
+
rescue ArgumentError
|
|
352
|
+
nil
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def component_pin(endpoint, components)
|
|
356
|
+
id = HoleId.parse(endpoint, board: @board)
|
|
357
|
+
return [nil, nil] unless id.kind == :pin
|
|
358
|
+
component = components[id.ref]
|
|
359
|
+
pin = component&.pin(id.pin)
|
|
360
|
+
[component, pin]
|
|
361
|
+
rescue ArgumentError
|
|
362
|
+
[nil, nil]
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def nearest_free(rail, target_x, occupied, reserved)
|
|
366
|
+
candidates = @board.holes.values.select { |hole| hole.rail == rail && !occupied[hole.id] && !reserved[hole.id] }
|
|
367
|
+
candidates.min_by { |hole| [(target_x ? (hole.x - target_x).abs : 0), hole.col] }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def hole_order(hole)
|
|
371
|
+
[hole.x, hole.y]
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def resolve_supplies
|
|
375
|
+
@document.supplies.each do |item|
|
|
376
|
+
[item[:plus], item[:minus]].each do |id|
|
|
377
|
+
unless @board.hole(id)
|
|
378
|
+
@diagnostics << diagnostic(:invalid_hole, "error", "invalid supply hole #{id}", item[:location], [item[:name], id])
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
@document.supplies.map do |item|
|
|
383
|
+
Supply.new(name: item[:name], voltage: item[:voltage], plus: item[:plus], minus: item[:minus], location: item[:location])
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def validate_names(components, wires, supplies)
|
|
388
|
+
names = {}
|
|
389
|
+
entries = components.values.map { |item| [item.ref, item.location] } +
|
|
390
|
+
wires.map { |item| [item.id, item.location] } +
|
|
391
|
+
supplies.map { |item| [item.name, item.location] }
|
|
392
|
+
entries.each do |name, location|
|
|
393
|
+
if names[name]
|
|
394
|
+
@diagnostics << diagnostic(:duplicate_ref, "error", "duplicate name #{name}", location, [name])
|
|
395
|
+
else
|
|
396
|
+
names[name] = true
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def validate_split_labels(circuit)
|
|
402
|
+
by_name = Hash.new { |hash, name| hash[name] = [] }
|
|
403
|
+
circuit.nets.each { |net| net.labels.each { |name| by_name[name] << net } }
|
|
404
|
+
by_name.each do |name, nets|
|
|
405
|
+
next if nets.length < 2
|
|
406
|
+
|
|
407
|
+
label = circuit.labels.find { |item| item.name == name }
|
|
408
|
+
@diagnostics << diagnostic(:split_net_label, "error", "label #{name} is used on disconnected nets", label.location,
|
|
409
|
+
[name, *nets.map(&:name)])
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def validate_references(components, supplies, labels)
|
|
414
|
+
labels.each { |label| validate_reference(label.at, label.location, components, supplies, labels) }
|
|
415
|
+
@document.expectations.each do |expectation|
|
|
416
|
+
entries = expectation[:entries] || expectation["entries"] || []
|
|
417
|
+
entries.each do |entry|
|
|
418
|
+
refs = entry[:refs] || entry["refs"] || []
|
|
419
|
+
location = entry[:location] || entry["location"]
|
|
420
|
+
Array(refs).each { |reference| validate_reference(reference, location, components, supplies, labels) }
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def validate_reference(reference, location, components, supplies, labels)
|
|
426
|
+
parsed = HoleId.parse(reference, board: @board)
|
|
427
|
+
case parsed.kind
|
|
428
|
+
when :terminal, :rail
|
|
429
|
+
@diagnostics << diagnostic(:invalid_hole, "error", "unknown hole #{reference}", location, [reference]) unless @board.hole(parsed.to_s)
|
|
430
|
+
when :pin
|
|
431
|
+
component = components[parsed.ref]
|
|
432
|
+
if component
|
|
433
|
+
valid = component.pin(parsed.pin)
|
|
434
|
+
@diagnostics << diagnostic(:unknown_pin, "error", "unknown pin #{reference}", location, [reference]) unless valid
|
|
435
|
+
elsif supplies.none? { |supply| supply.name == parsed.ref && %w[+ -].include?(parsed.pin) }
|
|
436
|
+
@diagnostics << diagnostic(:unknown_pin, "error", "unknown pin or supply terminal #{reference}", location, [reference])
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
rescue ArgumentError
|
|
440
|
+
return if labels.any? { |label| label.name == reference.to_s }
|
|
441
|
+
@diagnostics << diagnostic(:unknown_net, "error", "unknown net #{reference}", location, [reference])
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def diagnostic(code, severity, message, location, targets = [])
|
|
445
|
+
Diagnostic.new(code: code.to_s, severity: severity, message: message, location: location,
|
|
446
|
+
targets: Array(targets))
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
class Value
|
|
5
|
+
MULTIPLIERS = { "p" => 1e-12, "n" => 1e-9, "u" => 1e-6, "m" => 1e-3,
|
|
6
|
+
"R" => 1.0, "r" => 1.0, "k" => 1e3, "K" => 1e3,
|
|
7
|
+
"M" => 1e6, "G" => 1e9 }.freeze
|
|
8
|
+
PREFIXES = [[1e9, "G"], [1e6, "M"], [1e3, "k"], [1, ""], [1e-3, "m"],
|
|
9
|
+
[1e-6, "u"], [1e-9, "n"], [1e-12, "p"]].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :value, :unit
|
|
12
|
+
|
|
13
|
+
def self.parse(input)
|
|
14
|
+
return input.to_f if input.is_a?(Numeric)
|
|
15
|
+
|
|
16
|
+
text = input.to_s.strip.sub(/(?:Ω|Ω|ohm|[FfHhVv])\z/i, "").tr("µμ", "uu")
|
|
17
|
+
if (match = /\A(\d*)([pnuRrmkKMG])(\d+)\z/.match(text))
|
|
18
|
+
whole = match[1].empty? ? 0 : match[1].to_i
|
|
19
|
+
return (whole + match[3].to_f / (10**match[3].length)) * MULTIPLIERS.fetch(match[2])
|
|
20
|
+
end
|
|
21
|
+
match = /\A([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*([pnumrRkKMG]?)\z/.match(text)
|
|
22
|
+
raise ArgumentError, "invalid value: #{input.inspect}" unless match
|
|
23
|
+
|
|
24
|
+
match[1].to_f * MULTIPLIERS.fetch(match[2], 1.0)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(value)
|
|
28
|
+
@unit = value.to_s.strip[/\A.*?(Ω|Ω|ohm|[FfHhVv])\z/i, 1]&.then { |suffix| %w[Ω Ω ohm].include?(suffix.downcase) ? "Ω" : suffix.upcase } || "Ω"
|
|
29
|
+
@value = self.class.parse(value)
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_s
|
|
34
|
+
return "0#{unit}" if value.zero?
|
|
35
|
+
|
|
36
|
+
PREFIXES.each_with_index do |(factor, suffix), index|
|
|
37
|
+
scaled = value / factor
|
|
38
|
+
next unless scaled.abs >= 1 && (scaled.abs < 1000 || index.zero?)
|
|
39
|
+
|
|
40
|
+
rounded = format("%.3g", scaled)
|
|
41
|
+
if rounded.to_f.abs >= 1000 && index.positive?
|
|
42
|
+
higher_factor, higher_suffix = PREFIXES[index - 1]
|
|
43
|
+
return "#{format('%.3g', value / higher_factor)}#{higher_suffix}#{unit}"
|
|
44
|
+
end
|
|
45
|
+
return "#{rounded}#{suffix}#{unit}"
|
|
46
|
+
end
|
|
47
|
+
"#{value}#{unit}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/breadkit.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "did_you_mean"
|
|
6
|
+
|
|
7
|
+
require_relative "breadkit/version"
|
|
8
|
+
require_relative "breadkit/value"
|
|
9
|
+
require_relative "breadkit/hole_id"
|
|
10
|
+
require_relative "breadkit/model"
|
|
11
|
+
require_relative "breadkit/board"
|
|
12
|
+
require_relative "breadkit/part_library"
|
|
13
|
+
require_relative "breadkit/dsl"
|
|
14
|
+
require_relative "breadkit/resolver"
|
|
15
|
+
require_relative "breadkit/analysis"
|
|
16
|
+
require_relative "breadkit/ir"
|
|
17
|
+
require_relative "breadkit/cli"
|
|
18
|
+
|
|
19
|
+
module Breadkit
|
|
20
|
+
class Error < StandardError; end
|
|
21
|
+
class DSLError < Error; end
|
|
22
|
+
|
|
23
|
+
def self.load(path)
|
|
24
|
+
path.end_with?(".json") ? IR::Reader.new.read_file(path) : Resolver.new.call(DSL.load_file(path))
|
|
25
|
+
end
|
|
26
|
+
end
|