breadkit-render 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.
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ module Render
5
+ # Components are drawn once, with terminals tied to resolved net names.
6
+ class SchematicRenderer
7
+ NET_COLORS = %w[#d96a72 #5b9de3 #52b99a #ba8bd1 #d4a658 #5db6bf].freeze
8
+ TOP = 170
9
+ NET_GAP = 150
10
+ DEVICE_LEFT = 210
11
+ DEVICE_GAP = 220
12
+ BLOCK_GAP = 590
13
+ BLOCK_WIDTH = 250
14
+ PIN_GAP = 28
15
+
16
+ def render(circuit, theme: "light", state: nil)
17
+ raise ArgumentError, "unknown schematic theme: #{theme}" unless %w[light dark print].include?(theme)
18
+
19
+ colors = palette(theme)
20
+ nets = circuit.nets(state)
21
+ components = component_devices(circuit)
22
+ devices = supply_devices(circuit) + components.select { |component| component[:terminals].length == 2 }
23
+ blocks = components.reject { |component| component[:terminals].length == 2 }
24
+ device_terminals = devices.flat_map { |device| device[:terminals].map { |terminal| terminal[:id] } }
25
+ bus_nets = nets.select { |net| (net.members & device_terminals).any? }
26
+ net_by_terminal = nets.each_with_object({}) { |net, index| net.members.each { |member| index[member] = net } }
27
+ net_rows = bus_nets.each_with_index.to_h { |net, index| [net.name, TOP + index * NET_GAP] }
28
+ (devices + blocks).each do |device|
29
+ device[:terminals].each do |terminal|
30
+ net = net_by_terminal[terminal[:id]]
31
+ raise Error, "schematic cannot resolve terminal #{terminal[:id]}" unless net
32
+
33
+ terminal[:net] = net.name
34
+ terminal[:y] = net_rows[net.name]
35
+ terminal[:isolated] = net.members.length == 1 && Array(net.labels).empty?
36
+ end
37
+ end
38
+ devices.each_with_index do |device, index|
39
+ device[:x] = DEVICE_LEFT + index * DEVICE_GAP
40
+ if device[:terminals].map { |terminal| terminal[:net] }.uniq.length != 2
41
+ raise Error, "schematic cannot separate both terminals of #{device[:ref]} on the same net"
42
+ end
43
+ device[:terminals].sort_by! { |terminal| terminal[:y] }
44
+ device[:symbol_y] = device[:terminals].first[:y] + NET_GAP / 2
45
+ end
46
+ width = [900, DEVICE_LEFT + devices.length * DEVICE_GAP].max
47
+ height = [360, TOP + ([bus_nets.length - 1, 0].max * NET_GAP) + 120].max
48
+ if blocks.any?
49
+ block_left = devices.empty? ? DEVICE_LEFT : devices.last[:x] + 170
50
+ width = [width, block_left + ([blocks.length, 2].min - 1) * BLOCK_GAP + BLOCK_WIDTH + 120].max
51
+ block_y = TOP
52
+ blocks.each_slice(2) do |row|
53
+ row.each_with_index { |block, index| block[:x], block[:y] = block_left + index * BLOCK_GAP, block_y }
54
+ block_y += row.map { |block| block_height(block) }.max + 56
55
+ end
56
+ height = [height, block_y].max
57
+ end
58
+
59
+ svg = []
60
+ svg << %(<?xml version="1.0" encoding="UTF-8"?>)
61
+ svg << %(<svg xmlns="http://www.w3.org/2000/svg" width="#{width}" height="#{height}" viewBox="0 0 #{width} #{height}" role="img" aria-label="Circuit schematic" data-view="schematic" data-theme="#{theme}"#{state&.name ? %( data-state="#{xml(state.name)}") : ""}>)
62
+ svg << %(<title>Circuit schematic</title>)
63
+ svg << %(<desc>#{xml((["Each component is drawn once. Colored wires and matching net labels connect terminals. Board hole locations are omitted."] + nets.map { |net| "#{net.name}: #{net.members.join(', ')}" }).join(' '))}</desc>)
64
+ svg << %(<rect width="#{width}" height="#{height}" fill="#{colors[:background]}"/>)
65
+ svg << %(<g font-family="Arial, Helvetica, sans-serif" fill="#{colors[:text]}">)
66
+ svg << %(<text x="48" y="40" fill="#{colors[:muted]}" font-size="11" font-weight="700" letter-spacing="2.4">BREADKIT / SCHEMATIC</text>)
67
+ svg << %(<text x="48" y="80" font-size="29" font-weight="700">Circuit schematic</text>)
68
+ connected_nets = nets.count { |net| net.members.length > 1 || Array(net.labels).any? }
69
+ isolated_nets = nets.length - connected_nets
70
+ svg << %(<text x="#{width - 48}" y="78" text-anchor="end" fill="#{colors[:muted]}" font-size="12">#{devices.length + blocks.length} symbols · #{connected_nets} connected nets#{isolated_nets.positive? ? " · #{isolated_nets} isolated" : ""}#{state&.name ? " · #{xml(state.name)} closed" : ""}</text>)
71
+ svg << %(<line x1="48" y1="99" x2="#{width - 48}" y2="99" stroke="#{colors[:border]}"/>)
72
+ svg << %(<g id="nets">)
73
+ bus_nets.each do |net|
74
+ index = nets.index(net)
75
+ svg << net_svg(net, net_rows.fetch(net.name), devices, NET_COLORS[index % NET_COLORS.length], colors)
76
+ end
77
+ svg << %(</g><g id="devices">)
78
+ devices.each do |device|
79
+ svg << device_svg(device, NET_COLORS, nets, colors)
80
+ end
81
+ svg << %(</g>)
82
+ if blocks.any?
83
+ svg << %(<g id="multi-pin-devices">)
84
+ svg << %(<text x="#{blocks.first[:x]}" y="#{blocks.first[:y] - 32}" fill="#{colors[:muted]}" font-size="12" font-weight="700" letter-spacing="1.2">MATCHING NET LABELS CONNECT PINS</text>)
85
+ blocks.each { |block| svg << block_svg(block, nets, colors) }
86
+ svg << %(</g>)
87
+ end
88
+ svg << %(</g></svg>)
89
+ svg.join("\n")
90
+ end
91
+
92
+ private
93
+
94
+ def component_devices(circuit)
95
+ circuit.components.values.map do |component|
96
+ pins = component.pins.values
97
+ raise Error, "schematic cannot draw #{component.ref} without terminals" if pins.empty?
98
+
99
+ shape = component.part.data.dig("render", "shape")
100
+ detail = component.value || component.part.data.dig("render", "label") || component.part.id
101
+ { ref: component.ref, shape: shape, detail: detail,
102
+ terminals: pins.map { |pin| { id: "#{component.ref}.#{pin.name}", name: pin.name } } }
103
+ end
104
+ end
105
+
106
+ def block_height(block)
107
+ 66 + ((block[:terminals].length + 1) / 2) * PIN_GAP
108
+ end
109
+
110
+ def block_svg(block, nets, colors)
111
+ x, y = block.values_at(:x, :y)
112
+ height = block_height(block)
113
+ kind = %w[offboard module].include?(block[:shape]) ? "module" : "ic"
114
+ half = (block[:terminals].length + 1) / 2
115
+ svg = [%(<g data-ref="#{xml(block[:ref])}" data-symbol="#{kind}">)]
116
+ svg << %(<title>#{xml(block[:ref])}: #{xml(block[:detail])}</title>)
117
+ svg << %(<rect x="#{x}" y="#{y}" width="#{BLOCK_WIDTH}" height="#{height}" rx="8" fill="#{colors[:surface]}" stroke="#{colors[:border]}" stroke-width="2"/>)
118
+ svg << %(<text x="#{x + 14}" y="#{y + 24}" font-size="16" font-weight="700">#{xml(block[:ref])}</text>)
119
+ svg << %(<text x="#{x + BLOCK_WIDTH - 14}" y="#{y + 24}" text-anchor="end" fill="#{colors[:muted]}" font-size="12">#{xml(block[:detail])}</text>)
120
+ svg << %(<line x1="#{x}" y1="#{y + 36}" x2="#{x + BLOCK_WIDTH}" y2="#{y + 36}" stroke="#{colors[:border]}"/>)
121
+ block[:terminals].each_with_index do |terminal, index|
122
+ left = index < half
123
+ row = left ? index : block[:terminals].length - index - 1
124
+ pin_y = y + 61 + row * PIN_GAP
125
+ net_index = nets.index { |net| net.name == terminal[:net] }
126
+ color = terminal[:isolated] ? colors[:muted] : NET_COLORS[net_index % NET_COLORS.length]
127
+ edge = left ? x : x + BLOCK_WIDTH
128
+ tip = left ? x - 24 : edge + 24
129
+ pin_x = left ? x + 12 : edge - 12
130
+ net_x = left ? tip - 8 : tip + 8
131
+ svg << %(<path d="M #{edge} #{pin_y} H #{tip}" fill="none" stroke="#{color}" stroke-width="2.5" data-terminal="#{xml(terminal[:id])}" data-net="#{xml(terminal[:net])}"#{terminal[:isolated] ? %( data-unconnected="true") : ''}/>)
132
+ svg << %(<circle cx="#{tip}" cy="#{pin_y}" r="3" fill="#{color}"/>)
133
+ svg << %(<text x="#{pin_x}" y="#{pin_y + 4}" text-anchor="#{left ? 'start' : 'end'}" font-size="11" data-pin-label="#{xml(terminal[:id])}">#{xml(terminal[:name])}</text>)
134
+ unless terminal[:isolated]
135
+ svg << %(<text x="#{net_x}" y="#{pin_y + 4}" text-anchor="#{left ? 'end' : 'start'}" fill="#{color}" font-size="11" font-weight="700" data-pin-net="#{xml(terminal[:id])}">#{xml(terminal[:net])}</text>)
136
+ end
137
+ end
138
+ svg << %(</g>)
139
+ svg.join
140
+ end
141
+
142
+ def supply_devices(circuit)
143
+ circuit.supplies.map do |supply|
144
+ voltage = supply.voltage_range ? supply.voltage_range.join("–") : format("%g", supply.voltage)
145
+ { ref: supply.name, shape: "voltage-source", detail: "#{voltage} V",
146
+ terminals: [{ id: "#{supply.name}.+", name: "+" }, { id: "#{supply.name}.-", name: "−" }] }
147
+ end
148
+ end
149
+
150
+ def net_svg(net, y, devices, accent, colors)
151
+ xs = devices.filter_map { |device| device[:x] if device[:terminals].any? { |terminal| terminal[:net] == net.name } }
152
+ x1, x2 = xs.empty? ? [180, 240] : [xs.min - 20, xs.max + 20]
153
+ crossings = devices.filter_map do |device|
154
+ top, bottom = device[:terminals].map { |terminal| terminal[:y] }
155
+ device[:x] if top < y && y < bottom && device[:x].between?(x1 + 9, x2 - 9)
156
+ end.sort
157
+ path = "M #{x1} #{y}"
158
+ crossings.each { |x| path << " H #{x - 9} Q #{x} #{y - 12} #{x + 9} #{y}" }
159
+ path << " H #{x2}"
160
+ %(<path d="#{path}" fill="none" stroke="#{accent}" stroke-width="2.5" stroke-linecap="round" data-net="#{xml(net.name)}" data-crossovers="#{crossings.length}"/>) +
161
+ %(<text x="#{x1 - 12}" y="#{y - 15}" text-anchor="end" fill="#{colors[:text]}" font-size="13" font-weight="700" data-net="#{xml(net.name)}" data-net-label="#{xml(net.name)}">#{xml(net.name)}</text>)
162
+ end
163
+
164
+ def device_svg(device, net_colors, nets, colors)
165
+ x, y = device.values_at(:x, :symbol_y)
166
+ first, last = device[:terminals]
167
+ top_color = net_colors[nets.index { |net| net.name == first[:net] } % net_colors.length]
168
+ bottom_color = net_colors[nets.index { |net| net.name == last[:net] } % net_colors.length]
169
+ shape = device[:shape]
170
+ kind = case shape
171
+ when "led_5mm" then "led"
172
+ when "voltage-source" then "voltage-source"
173
+ when "resistor", "diode", "capacitor", "electrolytic" then shape
174
+ else "two-terminal"
175
+ end
176
+ svg = [%(<g data-ref="#{xml(device[:ref])}" data-symbol="#{kind}">)]
177
+ svg << %(<title>#{xml(device[:ref])}: #{xml(device[:detail])}</title>)
178
+ svg << terminal_wire(first, x, y - 20, top_color)
179
+ svg << terminal_wire(last, x, y + 20, bottom_color)
180
+ svg << symbol_svg(device, kind, colors)
181
+ label_x = x + (kind == "led" ? 55 : 39)
182
+ svg << %(<text x="#{label_x}" y="#{y - 3}" font-size="14" font-weight="700">#{xml(device[:ref])}</text>)
183
+ svg << %(<text x="#{label_x}" y="#{y + 15}" fill="#{colors[:muted]}" font-size="12">#{xml(device[:detail])}</text>)
184
+ svg << %(</g>)
185
+ svg.join
186
+ end
187
+
188
+ def terminal_wire(terminal, x, symbol_end, color)
189
+ y = terminal[:y]
190
+ %(<path d="M #{x} #{y} V #{symbol_end}" fill="none" stroke="#{color}" stroke-width="2.5" data-terminal="#{xml(terminal[:id])}" data-net="#{xml(terminal[:net])}"/>) +
191
+ %(<circle cx="#{x}" cy="#{y}" r="4" fill="#{color}"/>)
192
+ end
193
+
194
+ def symbol_svg(device, kind, colors)
195
+ x, y = device.values_at(:x, :symbol_y)
196
+ stroke = colors[:text]
197
+ case kind
198
+ when "resistor"
199
+ points = [[x, y - 20], [x - 9, y - 15], [x + 9, y - 8], [x - 9, y],
200
+ [x + 9, y + 8], [x - 9, y + 15], [x, y + 20]]
201
+ %(<polyline points="#{points.map { |point| point.join(',') }.join(' ')}" fill="none" stroke="#{stroke}" stroke-width="2.5" stroke-linejoin="round"/>)
202
+ when "voltage-source"
203
+ plus_top = device[:terminals].first[:name] == "+"
204
+ %(<circle cx="#{x}" cy="#{y}" r="20" fill="#{colors[:background]}" stroke="#{stroke}" stroke-width="2.5"/>) +
205
+ %(<text x="#{x}" y="#{y - 5}" text-anchor="middle" font-size="15" font-weight="700">#{plus_top ? '+' : '−'}</text>) +
206
+ %(<text x="#{x}" y="#{y + 14}" text-anchor="middle" font-size="15" font-weight="700">#{plus_top ? '−' : '+'}</text>)
207
+ when "led", "diode"
208
+ diode_svg(device, colors, led: kind == "led")
209
+ when "capacitor", "electrolytic"
210
+ %(<path d="M #{x} #{y - 20} V #{y - 6} M #{x - 14} #{y - 6} H #{x + 14} M #{x - 14} #{y + 6} H #{x + 14} M #{x} #{y + 6} V #{y + 20}" fill="none" stroke="#{stroke}" stroke-width="2.5"/>)
211
+ else
212
+ %(<rect x="#{x - 16}" y="#{y - 20}" width="32" height="40" rx="3" fill="#{colors[:background]}" stroke="#{stroke}" stroke-width="2.5"/>)
213
+ end
214
+ end
215
+
216
+ def diode_svg(device, colors, led:)
217
+ x, y = device.values_at(:x, :symbol_y)
218
+ cathode_top = device[:terminals].first[:name].to_s.downcase == "cathode"
219
+ triangle = if cathode_top
220
+ "#{x - 12},#{y + 9} #{x + 12},#{y + 9} #{x},#{y - 8}"
221
+ else
222
+ "#{x - 12},#{y - 9} #{x + 12},#{y - 9} #{x},#{y + 8}"
223
+ end
224
+ bar_y = cathode_top ? y - 10 : y + 10
225
+ svg = %(<polygon points="#{triangle}" fill="#{colors[:background]}" stroke="#{colors[:text]}" stroke-width="2.5"/>)
226
+ svg << %(<path d="M #{x - 13} #{bar_y} H #{x + 13} M #{x} #{y - 20} V #{y - 10} M #{x} #{y + 10} V #{y + 20}" fill="none" stroke="#{colors[:text]}" stroke-width="2.5"/>)
227
+ if led
228
+ svg << %(<path d="M #{x + 16} #{y - 2} l 13 -13 m -5 0 h 5 v 5 M #{x + 20} #{y + 8} l 13 -13 m -5 0 h 5 v 5" fill="none" stroke="#{colors[:text]}" stroke-width="1.8"/>)
229
+ end
230
+ svg
231
+ end
232
+
233
+ def palette(theme)
234
+ return { background: "#ffffff", surface: "#ffffff", border: "#b8c0ca", text: "#111827", muted: "#4b5563" } if theme == "print"
235
+ return { background: "#0d1420", surface: "#192437", border: "#35435a", text: "#f2f6fc", muted: "#a5b4c8" } if theme == "dark"
236
+
237
+ { background: "#f4f7fb", surface: "#ffffff", border: "#d6deea", text: "#172338", muted: "#63738a" }
238
+ end
239
+
240
+ def xml(value)
241
+ CGI.escapeHTML(value.to_s)
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ module Render
5
+ module StateSelection
6
+ module_function
7
+
8
+ def resolve(circuit, name)
9
+ refs = name.to_s.split(",")
10
+ return nil if refs.empty? || refs.any?(&:empty?) || refs.uniq.length != refs.length
11
+
12
+ switches = circuit.components.values.select { |component| !Array(component.part.data["switch"]).empty? }
13
+ return nil unless (refs - switches.map(&:ref)).empty?
14
+
15
+ selected = switches.select { |component| refs.include?(component.ref) }
16
+ closed = selected.flat_map do |component|
17
+ Array(component.part.data["switch"]).map { |pair| [component, pair] }
18
+ end
19
+ Breadkit::State.new(name: selected.map(&:ref).join(","), closed_switches: closed)
20
+ end
21
+ end
22
+ end
23
+ end