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.
- checksums.yaml +4 -4
- data/.yamllint +16 -0
- data/CHANGELOG.md +33 -3
- data/Dockerfile +21 -0
- data/Dockerfile.dockerignore +15 -0
- data/README.md +62 -40
- data/docs/REFERENCE.md +115 -0
- data/docs/images/led-netlist.png +0 -0
- data/docs/images/led-schematic.png +0 -0
- data/lib/breadkit/render/apng_encoder.rb +79 -0
- data/lib/breadkit/render/cli.rb +547 -22
- data/lib/breadkit/render/netlist_renderer.rb +138 -0
- data/lib/breadkit/render/orthogonal_router.rb +137 -0
- data/lib/breadkit/render/rasterizer.rb +138 -18
- data/lib/breadkit/render/schematic_renderer.rb +245 -0
- data/lib/breadkit/render/state_selection.rb +23 -0
- data/lib/breadkit/render/svg_renderer.rb +604 -101
- data/lib/breadkit/render/svg_template.rb +84 -0
- data/lib/breadkit/render/theme.rb +46 -0
- data/lib/breadkit/render/version.rb +1 -1
- data/lib/breadkit/render.rb +7 -0
- data/scripts/check-contrast.mjs +28 -0
- data/sig/breadkit/render.rbs +25 -2
- data/site/images/sensor-demo.png +0 -0
- data/site/images/sensor-demo.svg +8 -8
- data/site/index.html +5 -3
- metadata +36 -10
- data/docs/images/sensor-demo.png +0 -0
- data/docs/images/sensor-demo.svg +0 -45
|
@@ -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
|