breadkit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.codespellignore +2 -0
  3. data/.yamllint +16 -0
  4. data/README.md +109 -6
  5. data/data/boards/double_full.yml +18 -0
  6. data/data/boards/stripboard.yml +9 -0
  7. data/data/boards/universal.yml +8 -0
  8. data/data/parts/2n3904.yml +11 -0
  9. data/data/parts/2n7000_onsemi_to92.yml +10 -0
  10. data/data/parts/74hc00.yml +22 -0
  11. data/data/parts/74hc04.yml +22 -0
  12. data/data/parts/74hc08.yml +22 -0
  13. data/data/parts/74hc14.yml +22 -0
  14. data/data/parts/74hc165.yml +24 -0
  15. data/data/parts/74hc32.yml +22 -0
  16. data/data/parts/74hc595.yml +24 -0
  17. data/data/parts/active_buzzer.yml +8 -0
  18. data/data/parts/ams1117_3v3.yml +12 -0
  19. data/data/parts/arduino_nano.yml +70 -0
  20. data/data/parts/arduino_uno.yml +29 -26
  21. data/data/parts/atmega328p.yml +37 -0
  22. data/data/parts/attiny85.yml +16 -0
  23. data/data/parts/battery_box.yml +8 -0
  24. data/data/parts/cd4017.yml +24 -0
  25. data/data/parts/ck_bd04.yml +29 -0
  26. data/data/parts/crystal.yml +7 -0
  27. data/data/parts/dc_jack_2wire.yml +8 -0
  28. data/data/parts/diode.yml +1 -0
  29. data/data/parts/esp32_devkitc_v4_wroom32e.yml +86 -0
  30. data/data/parts/irlz44n.yml +10 -0
  31. data/data/parts/l293d.yml +24 -0
  32. data/data/parts/led.yml +1 -0
  33. data/data/parts/lm358.yml +16 -0
  34. data/data/parts/lm393.yml +16 -0
  35. data/data/parts/ne555.yml +2 -2
  36. data/data/parts/ntc_thermistor.yml +7 -0
  37. data/data/parts/photoresistor.yml +8 -0
  38. data/data/parts/pico.yml +89 -0
  39. data/data/parts/resistor.yml +1 -0
  40. data/data/parts/sc56_11ewa.yml +30 -0
  41. data/data/parts/seeed_xiao_samd21.yml +37 -0
  42. data/data/parts/shillehtek_mb102_4pin.yml +23 -0
  43. data/data/parts/slide_switch_spst.yml +8 -0
  44. data/data/parts/sparkfun_pro_micro_5v.yml +58 -0
  45. data/data/parts/transistor.yml +0 -1
  46. data/data/parts/ua7805.yml +10 -0
  47. data/data/parts/uln2003a.yml +23 -0
  48. data/data/parts/wp154a4sureqbfzgc.yml +11 -0
  49. data/docs/CLI.md +108 -0
  50. data/docs/COMPATIBILITY.md +60 -0
  51. data/docs/DECLARATIVE.md +138 -0
  52. data/docs/IR.md +42 -0
  53. data/docs/JUMPER_KIT.md +45 -0
  54. data/docs/LOCK.md +75 -0
  55. data/docs/LSP.md +59 -0
  56. data/docs/MB102_POWER.md +11 -0
  57. data/docs/MCP.md +44 -0
  58. data/docs/RAKE.md +19 -0
  59. data/docs/dsl.md +197 -6
  60. data/exe/breadkit-lsp +13 -0
  61. data/exe/breadkit-mcp +12 -0
  62. data/lib/breadkit/analysis.rb +174 -50
  63. data/lib/breadkit/board.rb +152 -4
  64. data/lib/breadkit/cli.rb +229 -13
  65. data/lib/breadkit/color.rb +38 -0
  66. data/lib/breadkit/dc_analysis.rb +261 -0
  67. data/lib/breadkit/dsl.rb +205 -24
  68. data/lib/breadkit/exporters.rb +202 -0
  69. data/lib/breadkit/formatter.rb +52 -0
  70. data/lib/breadkit/fritzing_export.rb +256 -0
  71. data/lib/breadkit/ir.rb +154 -22
  72. data/lib/breadkit/jumper_kit.rb +127 -0
  73. data/lib/breadkit/lsp_server.rb +248 -0
  74. data/lib/breadkit/mcp_server.rb +133 -0
  75. data/lib/breadkit/model.rb +47 -5
  76. data/lib/breadkit/part_library.rb +156 -3
  77. data/lib/breadkit/part_lock.rb +107 -0
  78. data/lib/breadkit/patterns.rb +119 -0
  79. data/lib/breadkit/rake_task.rb +25 -0
  80. data/lib/breadkit/resolver.rb +160 -28
  81. data/lib/breadkit/structured_input.rb +275 -0
  82. data/lib/breadkit/templates/555.bk.rb +34 -0
  83. data/lib/breadkit/templates/arduino.bk.rb +17 -0
  84. data/lib/breadkit/templates/led.bk.rb +21 -0
  85. data/lib/breadkit/value.rb +42 -3
  86. data/lib/breadkit/version.rb +1 -1
  87. data/lib/breadkit/wire_suggestions.rb +106 -0
  88. data/lib/breadkit.rb +28 -3
  89. data/package.json +1 -1
  90. data/schema/board-v1.json +38 -0
  91. data/schema/ir-v1.json +56 -11
  92. data/schema/ir-v2.json +173 -0
  93. data/schema/part-v1.json +52 -4
  94. data/sig/breadkit.rbs +107 -5
  95. metadata +119 -14
  96. data/docs/assets/01_led_button.svg +0 -12
  97. data/site/assets/01_led_button.svg +0 -14
  98. data/site/assets/03_arduino_blink.svg +0 -14
  99. data/site/favicon.svg +0 -7
  100. data/site/guide/components/index.html +0 -113
  101. data/site/guide/dsl/index.html +0 -132
  102. data/site/guide/index.html +0 -163
  103. data/site/index.html +0 -95
  104. data/site/styles.css +0 -14
data/docs/dsl.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Breadkit DSL
2
2
 
3
- Breadkit evaluates a Ruby file into a breadboard circuit, resolves pin and wire locations, and exposes the result as nets and JSON IR. DSL files are executable Ruby; only load files you trust. Use IR JSON when the input must remain data-only.
3
+ Breadkit evaluates a Ruby file into a breadboard circuit, resolves pin and wire locations, and exposes the result as nets and JSON IR. DSL files are executable Ruby; only load files you trust. Use [YAML or TOML circuit files](DECLARATIVE.md) when the input must remain data-only.
4
4
 
5
5
  ## A small circuit
6
6
 
@@ -28,35 +28,226 @@ end
28
28
  | Method | Purpose |
29
29
  | --- | --- |
30
30
  | `title(text)` | Diagram title. |
31
- | `board(id, split_rails: false)` | Select `:full`, `:half`, `:mini`, or a custom board ID. |
32
- | `use_parts(path)` / `use_boards(path)` | Load additional YAML definitions relative to the DSL file. Paths may use globs. |
33
- | `supply(name, voltage:, plus:, minus:)` | Add a DC source. Both terminals occupy board holes. |
31
+ | `board(id, split_rails: false, as: nil)` | Select `:double_full` (1660 holes), `:full`, `:half`, `:mini`, `:universal`, `:stripboard`, or a custom board ID. Name each board with `as:` when using multiple boards. |
32
+ | `use_parts(path)` / `use_boards(path)` | Load additional YAML definitions relative to the DSL file. Paths may use globs. Run `breadkit lock FILE` to pin the matched local definitions. |
33
+ | `include(path)` | Evaluate another trusted DSL file in the same circuit. Relative paths resolve from the including file; circular includes are rejected. |
34
+ | `block(name) { ... }` / `use_block(name, *args, **kwargs)` | Define and expand a reusable group of DSL declarations. Each name is unique within the circuit. |
35
+ | `bus(name, **lines)` | Label the explicit reference for each line as `NAME_LINE`, for example `I2C_SCL`. |
36
+ | `step(number, title: nil) { ... }` | Group assembly declarations under a numbered step. Numbers start at 1 and increase by 1. |
37
+ | `supply(name, voltage:, plus:, minus:, current_limit: nil)` | Add a DC source. Both terminals occupy board holes. Optional `current_limit:` is the supply's positive output limit in amperes. |
38
+ | `supply(from:, plus:, minus:)` | Route a defined offboard power output and its return pin to board holes or rails. |
34
39
  | `net(name, at:)` | Label a hole or component pin. |
35
40
  | `part(ref, type, value = nil, pins: ..., at: ..., **attrs)` | Place a defined part. `pins:` accepts pin order arrays or pin-name hashes. |
36
41
  | `wire(from, to, color: nil, id: nil, route: :straight, layer: nil, electrical: true, dashed: false)` | Connect two holes or pin references. `route: :arc` curves the wire; `route: :edge` routes around an outer board edge or from an external module along its terminal row. `layer:` groups wires in interactive SVG output; `electrical: false` draws a visual alternative without changing circuit connectivity. |
42
+ | `connect(from, to, when: nil)` | Declare that two references should be connected. This is an intent assertion; it does not place a wire. |
37
43
  | `offboard(name, type, side: :left, at: nil, unused: [], **attrs)` | Place a module beside the board; `at:` aligns its first pin to a board position, and attrs such as `address:` are shown on the module. |
38
- | `expect { ... }` | Declare `connected`, `isolated`, or named `net` expectations. `strict: true` also rejects unlisted pins on declared nets. |
44
+ | `expect { ... }` | Declare `connected`, `isolated`, or named `net` expectations. `strict: true` also rejects unlisted pins on declared nets. Use `when: "SW1"` to check a selected switch state. |
45
+ | `expect_voltage(ref, range)` / `expect_current(ref, range)` | Declare inclusive DC ranges for a net or component. Current is compared by magnitude in amperes. These methods also work inside `expect(when: "SW1")`. |
39
46
  | `lint_disable(rule, on: nil, reason: nil)` | Suppress a lint rule, optionally for one target. |
40
47
 
41
48
  Short forms are available for `resistor`, `capacitor`, `electrolytic`, `diode`, `led`, `transistor`, `pot`, `button`, and `ic`.
42
49
 
50
+ Standalone supplies require a positive voltage. Use the positive and negative
51
+ terminal positions to express polarity; an inclusive voltage range must have
52
+ two positive, ascending limits. By default, net labels `GND`, `0V`, `VSS`, and
53
+ `GROUND` anchor the potential calculation at 0 V regardless of case. A custom
54
+ board definition can replace that list with `ground_labels: [RETURN, AGND]`.
55
+
56
+ ## Connection intent before placement
57
+
58
+ Declare an intended connection before placing parts:
59
+
60
+ ```ruby
61
+ connect "R1.1", "D1.anode"
62
+ board :mini
63
+ resistor :R1, "330", pins: %w[a1 a3]
64
+ led :D1, color: :red, anode: "b5", cathode: "b6"
65
+ wire "b1", "a5"
66
+ ```
67
+
68
+ `connect` records an expectation and does not add a physical wire or change
69
+ the resolved nets. Run `bklint` to find an `Intent/ConnectionMismatch` when
70
+ the placed circuit fails to join the references. Add or correct a `wire`
71
+ declaration to satisfy it. The references may be declared before their parts;
72
+ unknown pins are reported after resolution. Use `when: "SW1"` to check a
73
+ selected switch state. This declaration uses the existing `connected`
74
+ expectation in JSON IR. Run `breadkit suggest FILE` to see conservative
75
+ free-hole candidates for unmet default-state intent. If the wire above is
76
+ omitted, it prints JSON such as
77
+ `[{"from":"c1","to":"c5","refs":["R1.1","D1.anode"]}]` without editing the
78
+ circuit. State-specific intent, offboard pins, occupied strips, holes under
79
+ component bodies, and known voltage conflicts are skipped. The proposed route
80
+ and physical fit still need review before adding a `wire` declaration.
81
+
82
+ ## Power from an offboard module
83
+
84
+ The Arduino Uno definition declares 5 V and 3.3 V outputs relative to GND.
85
+ Connect one output and its return to breadboard rails with:
86
+
87
+ ```ruby
88
+ board :half
89
+ offboard :UNO, :arduino_uno
90
+ supply from: "UNO.5V", plus: "T+", minus: "T-"
91
+ ```
92
+
93
+ `T+` and `T-` each select a free rail hole. The call expands to wires from
94
+ `UNO.5V` and `UNO.GND`; it does not add another voltage source. Use
95
+ `from: "UNO.3V3"` for the 3.3 V output. The source must match a `provides`
96
+ entry on the placed offboard part, and both destinations are required. For
97
+ named boards, qualify the destinations, for example `B1.T+` and `B1.T-`.
98
+ The resolved IR stores the two ordinary wires.
99
+
100
+ ## Reusable blocks and buses
101
+
102
+ A block runs in the same DSL context each time it is used. Pass the references
103
+ and holes for each instance explicitly so every component gets a distinct name
104
+ and placement:
105
+
106
+ ```ruby
107
+ board :mini
108
+
109
+ block :indicator do |number, resistor_pins:, led_pins:|
110
+ resistor "R#{number}", "330", pins: resistor_pins
111
+ led "D#{number}", color: :red, **led_pins
112
+ end
113
+
114
+ use_block :indicator, 1,
115
+ resistor_pins: %w[a1 a3], led_pins: { anode: "b3", cathode: "b4" }
116
+ use_block :indicator, 2,
117
+ resistor_pins: %w[a6 a8], led_pins: { anode: "b8", cathode: "b9" }
118
+ ```
119
+
120
+ Blocks defined in an included DSL file are available to the including file.
121
+ Recursive calls and duplicate block names are rejected. The resolved circuit
122
+ and JSON IR contain the expanded parts and wires, with no separate block
123
+ record.
124
+
125
+ Use a bus to name related nets at their explicit board holes or pin references:
126
+
127
+ ```ruby
128
+ bus :I2C, scl: "f22", sda: "f24"
129
+ # Creates I2C_SCL at f22 and I2C_SDA at f24.
130
+ ```
131
+
132
+ The call adds labels. Add `wire` declarations for physical connections to
133
+ devices on each line. The bus helper accepts any named lines; it does not
134
+ perform I2C protocol analysis.
135
+
136
+ ## Assembly steps
137
+
138
+ Place the declarations for each assembly stage inside a numbered `step` block:
139
+
140
+ ```ruby
141
+ board :mini
142
+
143
+ step 1, title: "Install the resistor" do
144
+ resistor :R1, "330", pins: %w[b1 b3]
145
+ end
146
+
147
+ step 2, title: "Add the LED and return wire" do
148
+ led :D1, color: :red, anode: "c3", cathode: "c4"
149
+ wire "d4", "b2", color: :black
150
+ end
151
+ ```
152
+
153
+ Steps must be declared in order, starting at 1. Nested steps are rejected.
154
+ Supplies, net labels, parts, and wires declared inside a step keep that step
155
+ number; declarations outside steps have no number. Normal placement and
156
+ connectivity checks still apply. `circuit.steps` holds each number and title,
157
+ while each resolved item exposes `.step`. JSON IR carries the same `steps`
158
+ list and optional `step` field on those items. Existing IR files without steps
159
+ remain valid.
160
+
161
+ ## Multiple boards
162
+
163
+ Name each board and qualify every physical hole or rail reference when a
164
+ circuit has more than one breadboard:
165
+
166
+ ```ruby
167
+ board :half, as: :B1
168
+ board :mini, as: :B2
169
+
170
+ supply :BAT, voltage: 5, plus: "B1.b1", minus: "B2.b2"
171
+ resistor :R1, "330", pins: %w[B1.a1 B1.a3]
172
+ led :D1, color: :red, anode: "B2.a1", cathode: "B2.a2"
173
+ wire "B1.b3", "B2.b1", color: :red
174
+ ```
175
+
176
+ The boards have separate conductive strips. A wire may cross between boards;
177
+ one component's placed pins must stay on one board. Board names are unique and
178
+ use letters, digits, and underscores, starting with a letter. Unqualified
179
+ holes such as `a1` and rails such as `T+` are invalid in a named-board circuit.
180
+ Component pin references such as `R1.1` remain global. An unindexed named rail
181
+ such as `B2.T+` still selects a free hole on that board's rail.
182
+
183
+ `circuit.boards` maps names to boards, and `circuit.board` exposes the combined
184
+ qualified hole and strip lookup. Named-board circuits export IR schema version
185
+ 2 with a `boards` array of names, definitions, and options. Existing unnamed
186
+ single-board circuits continue to export version 1. The version 2 schema is
187
+ [`schema/ir-v2.json`](../schema/ir-v2.json).
188
+
43
189
  ## Hole and pin references
44
190
 
191
+ The built-in `universal` model has 30 × 20 isolated pads. The built-in
192
+ `stripboard` model has 20 continuous copper strips, one per row, with 30 holes
193
+ per strip. The stripboard model assumes uncut tracks; cuts and custom copper
194
+ patterns require a custom board definition or explicit layout support. These
195
+ are example sizes, not a universal hardware standard. A custom board can set
196
+ `terminal.strip_direction: row` to connect each terminal row across its
197
+ columns; the default `column` connects each declared group within a column.
198
+ Set `terminal.wire_attachment: solder` for a soldered board. A wire naming a
199
+ placed component pin uses another free hole on the same strip when available;
200
+ when none exists, it joins that pin's occupied pad. Explicit wires can also
201
+ share solder pads. The default `socket` behavior keeps breadboard holes
202
+ exclusive to one inserted lead or wire. Component leads still cannot share a
203
+ hole in either model. For example:
204
+
205
+ ```ruby
206
+ board :universal
207
+ resistor :R1, "330", pins: %w[a1 a2]
208
+ wire "R1.1", "b1"
209
+ ```
210
+
211
+ No generic MB102 power-supply model is included because jumper-selected
212
+ output voltages and pin locations vary by module.
213
+
45
214
  - Terminal holes use rows `a` through `j` and 1-based columns, such as `a10` or `J30`.
46
215
  - Rail holes use `T+`, `T-`, `B+`, and `B-`, optionally followed by a 1-based index. A rail without an index selects the nearest free hole.
47
216
  - A custom board may define other row and rail IDs in its YAML. For example, rows `u` and `v` use `u1` and `v1`; a rail with `id: PWR` uses `PWR1` or the unindexed `PWR`. Set each rail's `polarity:` to `+` or `-` for polarity-aware rendering.
217
+ - Custom rails use `side: top|bottom|left|right|center` and a zero-based `order`. Left and right rails run vertically; `rail_layout.start_row` (default `1`) positions their first hole. Center rails occupy the two free rows of the declared `terminal.ravine_between` gap, so only orders `0` and `1` fit. Horizontal rails use `rail_layout.start_column` as before.
48
218
  - Component pins use `R1.1`, `D1.anode`, `U1.8`, or `U1.VCC`. A wire endpoint naming a placed pin selects a free hole in that pin's conductive strip.
49
219
  - The built-in `ne555` and generic `dip` definitions must straddle the center gap. For a generic package, set `pin_count`, for example `part :U2, :dip, pin_count: 14, at: "e20"`.
50
220
  - A generic pin header can be sized with `part :J1, :pin_header, pin_count: 4, pins: %w[a1 a2 a3 a4]`.
221
+ - Footprint parts accept `rotate: 0|90|180|270` and `mirror: true|false`, for example `part :J1, :pin_header, pin_count: 3, at: "c10", rotate: 90`. Rotation is clockwise on the board; mirroring reflects left to right before rotation. The same orientation is checked when pins are placed explicitly.
222
+ - A custom `placement: footprint` part may use `footprint_mm` instead of `footprint` to declare every pin offset in millimeters, for example `footprint_mm: {"1": [0, 0], "2": [5.08, 0]}`. Offsets must land on the 2.54 mm hole grid; Breadkit rejects a dimension that cannot fit rather than snapping it to another hole. Set the anchor at the first pin with `at:`. Use `render.size_mm` for body dimensions, which need not be multiples of the hole pitch.
223
+ - A placed part with `render.size_mm` exposes its physical rectangle as `component.body_bounds(circuit.board)`, in board hole pitch units (`[x, y, width, height]`).
224
+ - A custom two-pin part with `placement: leads` may set `max_lead_span_mm` to a positive number. This is the maximum supported distance between the two occupied hole centers after bending its leads. Set it from the actual package and usable lead length; generic built-in parts leave it unspecified. The linter can check placed parts that provide this limit.
51
225
 
52
226
  Values accept SI suffixes and RKM notation such as `4.7k`, `4k7`, `1M`, `100n`, `10uF`, and `4.7kΩ`.
53
227
 
54
228
  ## Switch states and IR
55
229
 
56
- `circuit.states("none")`, `circuit.states("single")`, and `circuit.states("all")` control switch contact simulation. `Breadkit.load(path)` reads `.bk.rb` DSL or `.json` IR. `circuit.to_ir` returns the resolved circuit representation; automatically selected holes are fixed in IR and are not selected again when loaded.
230
+ `circuit.states("none")`, `circuit.states("single")`, and `circuit.states("all")` control switch contact simulation. `Breadkit.load(path)` reads `.bk.rb` DSL, `.bk.yml` / `.bk.yaml` / `.bk.toml` circuit files, or `.json` IR. `circuit.to_ir` returns the resolved circuit representation; automatically selected holes are fixed in IR and are not selected again when loaded.
231
+
232
+ For independent SPST positions in a custom part, set `switch: [[P1A, P1B], [P2A, P2B]]` and `independent_switches: true`. Every contact pair must use distinct pins. The corresponding state names are `SW1.1` and `SW1.2` for an instance named `SW1`; `circuit.state("SW1.1,SW1.2")` selects both. The built-in C&K BD04 uses this model. Place it with `part :SW1, :ck_bd04, at: "e20"`; its four contacts occupy rows 20–23 across the center gap. The `PnA`/`PnB` labels are logical position-side names because the [C&K BD datasheet](https://www.littelfuse.com/assetdocs/littelfuse-ck-dip-bd-series-datasheet?assetguid=c1d4e4f2-7607-4309-afba-ad965b566d35) does not specify every terminal number in its drawing. Check the physical switch's marked position 1 and orientation before wiring it.
233
+
234
+ `old_circuit.diff(new_circuit)` returns a hash of changed board, supply, component, label, and wire entries. Each value is `[before, after]`, with `nil` for an added or removed entry. The CLI `breadkit diff OLD NEW` prints the same changes.
57
235
 
58
236
  The core CLI provides `breadkit nets`, `breadkit parts`, and `breadkit ir`.
59
237
 
60
238
  Custom module pins can declare their kind with `type:` in the part YAML, for example `power`, `ground`, `clock`, `data`, `address`, or `interrupt`. The renderer colors typed pin markers and dims pins without a wire connection.
239
+ For a rail-mounted supply with independent output selectors, see the [ShillehTek MB102 four-pin guide](MB102_POWER.md). This variant requires explicit rail-hole positions and jumper settings; its data file does not assume a universal MB102 contact layout.
240
+ Custom part YAML may set `datasheet_url: https://example.com/part.pdf` to
241
+ retain a source link in part listings and JSON IR. Use an actual HTTPS
242
+ datasheet URL for a published part; URLs with embedded credentials are rejected.
243
+ An individual pin may also declare `max_current: 0.012` for a 12 mA limit.
244
+ Use a verified limit from that part's datasheet; pins without this field have
245
+ no inferred current limit. The value is preserved in JSON IR for lint tools.
246
+ For a pin explicitly named as `provides.positive`, DC analysis reports the
247
+ source current under `REF.PIN`. Other GPIO output states remain unknown to the
248
+ DC solver; `max_current` alone does not assign an output voltage.
249
+ Use `type:` for new definitions; the legacy `role:` spelling is accepted and
250
+ normalized to `type:`. A pin cannot declare both. Unknown part and pin keys,
251
+ and unsupported pin types, are rejected when the definition is loaded.
61
252
 
62
253
  Assign the same `layer:` to wires and components to make them appear together in the interactive SVG layer controls. A layer may be a string or a list of strings when an item belongs to multiple views. Components without a layer remain visible in every view. Use `electrical: false, dashed: true` for an alternate connection that must not affect connectivity analysis.
data/exe/breadkit-lsp ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "breadkit/lsp_server"
6
+
7
+ options = { trusted_ruby: false }
8
+ OptionParser.new do |parser|
9
+ parser.banner = "Usage: breadkit-lsp [--root DIRECTORY] [--trusted-ruby]"
10
+ parser.on("--root DIRECTORY") { |value| options[:root] = value }
11
+ parser.on("--trusted-ruby") { options[:trusted_ruby] = true }
12
+ end.parse!
13
+ Breadkit::LSPServer.new(**options).run
data/exe/breadkit-mcp ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "breadkit/mcp_server"
5
+
6
+ unless ARGV.empty? || (ARGV.length == 2 && ARGV.first == "--root")
7
+ warn "Usage: breadkit-mcp [--root DIRECTORY]"
8
+ exit 2
9
+ end
10
+
11
+ root = ARGV.empty? ? Dir.pwd : ARGV.last
12
+ Breadkit::MCPServer.new(root: root).run
@@ -28,45 +28,70 @@ module Breadkit
28
28
  @parent[right] = left
29
29
  @rank[left] += 1 if @rank[left] == @rank[right]
30
30
  end
31
+
32
+ def snapshot
33
+ copy = dup
34
+ copy.instance_variable_set(:@parent, @parent.dup)
35
+ copy.instance_variable_set(:@rank, @rank.dup)
36
+ copy
37
+ end
31
38
  end
32
39
 
33
40
  class Circuit
34
41
  attr_reader :title, :board, :components, :wires, :supplies, :labels, :expectations,
35
- :lint_disables, :diagnostics
42
+ :lint_disables, :diagnostics, :source_root, :steps
36
43
 
37
- def initialize(title:, board:, components:, wires:, supplies:, labels:, expectations:, lint_disables:, diagnostics:)
44
+ def initialize(title:, board:, components:, wires:, supplies:, labels:, expectations:, lint_disables:, diagnostics:, steps: [], source_root: nil)
38
45
  @title, @board, @components, @wires, @supplies, @labels = title, board, components, wires, supplies, labels
39
46
  @expectations, @lint_disables, @diagnostics = expectations, lint_disables, diagnostics
47
+ @steps = steps
48
+ @source_root = source_root
40
49
  @net_cache, @net_index, @potential_cache = {}, {}, {}
41
50
  end
42
51
 
43
- def states(mode = "single")
44
- switches = components.values.select { |component| !Array(component.part.data["switch"]).empty? }
52
+ def states(mode = "single", budget: nil)
53
+ raise ArgumentError, "state budget must be a positive integer" if budget && (!budget.is_a?(Integer) || budget <= 0)
54
+
55
+ switches = switch_units
45
56
  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
57
+ combinations = 1 << switches.length
58
+ raise ArgumentError, "#{combinations} switch states exceed budget #{budget}" if mode.to_s == "all" && budget && combinations > budget
59
+
60
+ # ponytail: calls without an explicit budget keep the legacy eight-switch fallback.
61
+ if mode.to_s == "all" && (budget ? combinations <= budget : switches.length <= 8)
48
62
  (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)
63
+ selected = switches.each_with_index.filter_map { |unit, index| unit if bits[index] == 1 }
64
+ State.new(name: selected.empty? ? nil : selected.map(&:first).join(","), closed_switches: selected.flat_map(&:last))
52
65
  end
53
66
  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)
67
+ singles = switches.map do |name, pairs|
68
+ State.new(name: name, closed_switches: pairs)
57
69
  end
58
70
  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"
71
+ singles << State.new(name: switches.map(&:first).join(","), closed_switches: all_pairs) if mode.to_s == "all"
60
72
  [State.new(name: nil, closed_switches: [])] + singles
61
73
  end
62
74
  end
63
75
 
76
+ def state(name)
77
+ return nil unless name
78
+
79
+ names = name.split(",", -1)
80
+ units = switch_units.to_h
81
+ if names.uniq.length != names.length || names.any? { |unit| !units.key?(unit) }
82
+ raise ArgumentError, "unknown switch state #{name}"
83
+ end
84
+
85
+ State.new(name: name, closed_switches: names.flat_map { |unit| units.fetch(unit) })
86
+ end
87
+
64
88
  def nets(state = nil)
65
89
  state ||= State.new(name: nil, closed_switches: [])
66
90
  key = state_key(state)
67
91
  return @net_cache[key] if @net_cache.key?(key)
68
92
 
69
- resolved = Connectivity.new(self).build(state)
93
+ @connectivity ||= Connectivity.new(self)
94
+ resolved = @connectivity.build(state)
70
95
  @net_index[key] = resolved.each_with_object({}) do |net, index|
71
96
  index[net.name] ||= net
72
97
  net.members.each do |member|
@@ -90,6 +115,17 @@ module Breadkit
90
115
  @potential_cache.fetch(state_key(state))
91
116
  end
92
117
 
118
+ def voltage_sources
119
+ supplies + components.values.flat_map do |component|
120
+ component.provided_sources.map do |source|
121
+ Supply.new(name: "#{component.ref}.#{source.fetch('positive')}", voltage: Value.parse(source.fetch("voltage")),
122
+ plus: "#{component.ref}.#{source.fetch('positive')}",
123
+ minus: "#{component.ref}.#{source.fetch('negative')}", location: component.location,
124
+ isolated: false, voltage_range: nil)
125
+ end
126
+ end
127
+ end
128
+
93
129
  def net_of(reference, state = nil)
94
130
  text = reference.to_s
95
131
  nets(state)
@@ -101,6 +137,55 @@ module Breadkit
101
137
  IR::Writer.new.write(self)
102
138
  end
103
139
 
140
+ def boards
141
+ board.is_a?(BoardSet) ? board.boards : {}
142
+ end
143
+
144
+ def multi_board?
145
+ board.is_a?(BoardSet)
146
+ end
147
+
148
+ def diff(other)
149
+ raise ArgumentError, "expected a Breadkit::Circuit" unless other.is_a?(Circuit)
150
+
151
+ before, after = diff_items, other.diff_items
152
+ (before.keys | after.keys).sort.each_with_object({}) do |key, changes|
153
+ changes[key] = [before[key], after[key]] unless before[key] == after[key]
154
+ end
155
+ end
156
+
157
+ protected
158
+
159
+ def diff_items
160
+ items = if multi_board?
161
+ boards.to_h { |name, item| ["board #{name}", "#{item.definition.id}#{' (split rails)' if item.split_rails}"] }
162
+ else
163
+ { "board" => "#{board.definition.id}#{' (split rails)' if board.split_rails}" }
164
+ end
165
+ supplies.each do |supply|
166
+ voltage = supply.voltage_range ? supply.voltage_range.join("..") : supply.voltage
167
+ limit = supply.current_limit ? ", #{supply.current_limit} A limit" : ""
168
+ items["supply #{supply.name}"] = "#{voltage} V, #{supply.plus} to #{supply.minus}#{limit}"
169
+ end
170
+ labels.each { |label| items["label #{label.name}@#{label.at}"] = true }
171
+ components.each_value do |component|
172
+ pins = component.pins.values.map { |pin| "#{pin.name}=#{pin.hole_id || '-'}" }.join(", ")
173
+ attrs = component.attrs.map { |key, value| [key.to_s, value] }.sort_by(&:first).to_h
174
+ details = [component.part.id, component.value, pins]
175
+ details << "attrs=#{attrs.inspect}" unless attrs.empty?
176
+ details << "unused=#{component.unused.inspect}" unless component.unused.empty?
177
+ items["component #{component.ref}"] = details.compact.join(" ")
178
+ end
179
+ wires.each do |wire|
180
+ options = [wire.color, wire.route, wire.layer, wire.electrical, wire.dashed]
181
+ key = "wire #{[wire.from, wire.to].sort.join(' ↔ ')} #{options.inspect}"
182
+ items[key] = items.fetch(key, 0) + 1
183
+ end
184
+ items
185
+ end
186
+
187
+ public
188
+
104
189
  def shortest_path(terminal_a, terminal_b, state = nil)
105
190
  start, finish = hole_for_reference(terminal_a.to_s), hole_for_reference(terminal_b.to_s)
106
191
  return [] unless start && finish
@@ -146,6 +231,9 @@ module Breadkit
146
231
  end
147
232
 
148
233
  def node_for_reference(reference)
234
+ hole = board.hole(reference)
235
+ return "hole:#{hole.id}" if hole
236
+
149
237
  if reference.include?(".")
150
238
  prefix, pin = reference.split(".", 2)
151
239
  if components[prefix]
@@ -155,7 +243,6 @@ module Breadkit
155
243
  supply = supplies.find { |item| item.name == prefix }
156
244
  return "supply:#{reference}" if supply && %w[+ -].include?(pin)
157
245
  end
158
- return "hole:#{board.hole(reference).id}" if board.hole(reference)
159
246
  "label:#{reference}" if labels.any? { |item| item.name == reference }
160
247
  rescue ArgumentError
161
248
  nil
@@ -163,7 +250,23 @@ module Breadkit
163
250
 
164
251
  private
165
252
 
253
+ def switch_units
254
+ components.values.flat_map do |component|
255
+ pairs = Array(component.part.data["switch"])
256
+ next [] if pairs.empty?
257
+
258
+ if component.part.data["independent_switches"]
259
+ pairs.each_with_index.map { |pair, index| ["#{component.ref}.#{index + 1}", [[component, pair]]] }
260
+ else
261
+ [[component.ref, pairs.map { |pair| [component, pair] }]]
262
+ end
263
+ end
264
+ end
265
+
166
266
  def hole_for_reference(reference)
267
+ hole = board.hole(reference)
268
+ return hole.id if hole
269
+
167
270
  if reference.include?(".")
168
271
  prefix, pin = reference.split(".", 2)
169
272
  supply = supplies.find { |item| item.name == prefix }
@@ -195,30 +298,18 @@ module Breadkit
195
298
  class Connectivity
196
299
  def initialize(circuit)
197
300
  @circuit = circuit
301
+ @uf = UnionFind.new
302
+ build_static_connections
303
+ @base_uf = @uf
304
+ @exposed_nodes = exposed_nodes
198
305
  end
199
306
 
200
307
  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
308
+ @uf = @base_uf.snapshot
218
309
  state.closed_switches.each { |component, pair| join_pins(component, pair) }
219
310
 
220
311
  groups = Hash.new { |hash, key| hash[key] = { members: [], holes: [], labels: [], supplies: [] } }
221
- exposed_nodes.each do |node, member, kind|
312
+ @exposed_nodes.each do |node, member, kind|
222
313
  entry = groups[@uf.find(node)]
223
314
  entry[:members] << member if member
224
315
  entry[:holes] << node.delete_prefix("hole:") if kind == :hole
@@ -231,10 +322,14 @@ module Breadkit
231
322
  roots = groups.keys.select { |root| !groups[root][:members].empty? || !groups[root][:labels].empty? }
232
323
  .sort_by { |root| order_for(groups[root][:holes]) }
233
324
  used_names = {}
325
+ unnamed_count = 0
234
326
  roots.map.with_index do |root, index|
235
327
  data = groups[root]
236
328
  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? }}"
329
+ unless chosen
330
+ unnamed_count += 1
331
+ chosen = "N#{unnamed_count}"
332
+ end
238
333
  name = chosen
239
334
  if used_names[name]
240
335
  # Multiple nets can have the same label; retain deterministic unique display names.
@@ -252,6 +347,25 @@ module Breadkit
252
347
 
253
348
  attr_reader :circuit
254
349
 
350
+ def build_static_connections
351
+ circuit.board.strips.each_value { |ids| ids.each_cons(2) { |a, b| @uf.union(hole_node(a), hole_node(b)) } }
352
+ circuit.components.each_value do |component|
353
+ component.pins.each_value { |pin| @uf.union(pin.node_id, hole_node(pin.hole_id)) if pin.hole_id }
354
+ Array(component.part.data["internal"]).each { |pair| join_pins(component, pair) }
355
+ end
356
+ circuit.wires.each do |wire|
357
+ next if wire.electrical == false
358
+
359
+ id = wire_node(wire.id)
360
+ @uf.union(id, endpoint_node(wire.from))
361
+ @uf.union(id, endpoint_node(wire.to))
362
+ end
363
+ circuit.supplies.each do |supply|
364
+ @uf.union(supply_node(supply.name, "+"), hole_node(supply.plus))
365
+ @uf.union(supply_node(supply.name, "-"), hole_node(supply.minus))
366
+ end
367
+ end
368
+
255
369
  def exposed_nodes
256
370
  list = []
257
371
  circuit.board.holes.each_key { |id| list << [hole_node(id), nil, :hole] }
@@ -323,54 +437,60 @@ module Breadkit
323
437
  end
324
438
 
325
439
  def solve(state)
326
- edges = circuit.supplies.map do |supply|
327
- [supply, circuit.net_of("#{supply.name}.-", state), circuit.net_of("#{supply.name}.+", state)]
440
+ edges = circuit.voltage_sources.map do |supply|
441
+ [supply, circuit.net_of(supply.minus, state), circuit.net_of(supply.plus, state)]
328
442
  end
329
443
  values, conflicts = {}, []
330
444
  adjacency = Hash.new { |hash, key| hash[key] = [] }
331
445
  edges.each do |supply, from, to|
332
446
  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}.-"]
447
+ adjacency[from.name] << [supply, to.name, supply.voltage, terminal_for(supply, "+")]
448
+ adjacency[to.name] << [supply, from.name, -supply.voltage, terminal_for(supply, "-")]
335
449
  end
336
- ground = circuit.nets(state).find { |net| net.labels.include?("GND") }&.name
337
- components, witnesses, seen_conflicts = [], {}, {}
450
+ ground_labels = Array(circuit.board.definition.data["ground_labels"] || %w[GND 0V VSS GROUND]).map(&:upcase)
451
+ ground = circuit.nets(state).find { |net| net.labels.any? { |label| ground_labels.include?(label.upcase) } }&.name
452
+ components, witnesses, witness_sources, seen_conflicts = [], {}, {}, {}
338
453
  starts = adjacency.keys
339
454
  starts = [ground, *(starts - [ground])] if starts.include?(ground)
340
455
  starts.each do |start|
341
456
  next if values.key?(start)
342
457
  components << start
343
458
  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
459
+ origin, from = edges.find { |_supply, negative, positive| negative&.name == start || positive&.name == start }
460
+ witnesses[start] = terminal_for(origin, from&.name == start ? "-" : "+")
461
+ witness_sources[start] = origin
351
462
  queue = [start]
352
463
  until queue.empty?
353
464
  current = queue.shift
354
465
  adjacency[current].each do |supply, target, delta, terminal|
355
466
  proposed = values[current] + delta
356
467
  if values.key?(target)
357
- next if (values[target] - proposed).abs <= 1e-9 || seen_conflicts[supply.name]
468
+ next if (values[target] - proposed).abs <= 1e-9
358
469
 
359
470
  first, second = witnesses[target], terminal
471
+ next if first == second
472
+ pair = [first, second].sort
473
+ next if seen_conflicts[pair]
360
474
  path = circuit.shortest_path(first, second, state)
361
475
  path_wires = circuit.wires.select { |wire| path.include?(wire.id) }
362
476
  conflicts << { supply: supply, net: target, expected: values[target], actual: proposed,
363
477
  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
478
+ wires: path_wires.map(&:id), location: path_wires.max_by { |wire| wire.location&.line.to_i }&.location || supply.location,
479
+ source_pair: [witness_sources[target].name, supply.name].sort }
480
+ seen_conflicts[pair] = true
366
481
  else
367
482
  values[target] = proposed
368
483
  witnesses[target] = terminal
484
+ witness_sources[target] = supply
369
485
  queue << target
370
486
  end
371
487
  end
372
488
  end
373
489
  end
490
+ # A wired short can be seen again on an already shared return or series junction.
491
+ wired_pairs = conflicts.filter_map { |item| item[:source_pair] unless item[:wires].empty? }
492
+ conflicts.reject! { |item| item[:wires].empty? && wired_pairs.include?(item[:source_pair]) }
493
+ conflicts.each { |item| item.delete(:source_pair) }
374
494
  values[ground] = 0.0 if ground && !values.key?(ground)
375
495
  PotentialResult.new(values: values, conflicts: conflicts, components: components)
376
496
  end
@@ -378,5 +498,9 @@ module Breadkit
378
498
  private
379
499
 
380
500
  attr_reader :circuit
501
+
502
+ def terminal_for(supply, side)
503
+ circuit.supplies.include?(supply) ? "#{supply.name}.#{side}" : (side == "+" ? supply.plus : supply.minus)
504
+ end
381
505
  end
382
506
  end