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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ VERSION = "0.1.0"
5
+ 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