breadkit-render 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +58 -0
- data/SECURITY.md +5 -0
- data/docs/images/sensor-demo.png +0 -0
- data/docs/images/sensor-demo.svg +45 -0
- data/exe/bkrender +5 -0
- data/lib/breadkit/render/cli.rb +90 -0
- data/lib/breadkit/render/rasterizer.rb +127 -0
- data/lib/breadkit/render/svg_renderer.rb +1160 -0
- data/lib/breadkit/render/version.rb +7 -0
- data/lib/breadkit/render.rb +15 -0
- data/package-lock.json +1172 -0
- data/package.json +12 -0
- data/sig/breadkit/render.rbs +38 -0
- data/site/favicon.svg +7 -0
- data/site/images/sensor-demo.png +0 -0
- data/site/images/sensor-demo.svg +45 -0
- data/site/index.html +94 -0
- data/site/styles.css +14 -0
- metadata +81 -0
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Breadkit
|
|
6
|
+
module Render
|
|
7
|
+
class SvgRenderer
|
|
8
|
+
PITCH = 10.0
|
|
9
|
+
RAIL_PATTERNS = %w[+--+ +-+- -+-+ -++-].freeze
|
|
10
|
+
OFFBOARD_PIN_PITCH = 1.0
|
|
11
|
+
PALETTE = %w[#d62728 #1f77b4 #2ca02c #9467bd #ff7f0e #17becf].freeze
|
|
12
|
+
MODULE_PIN_COLORS = {
|
|
13
|
+
"power" => "#E24B4A", "ground" => "#888780", "clock" => "#EF9F27",
|
|
14
|
+
"data" => "#378ADD", "interrupt" => "#D4537E", "address" => "#888780"
|
|
15
|
+
}.freeze
|
|
16
|
+
CSS_COLOR_NAMES = %w[
|
|
17
|
+
aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown
|
|
18
|
+
burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan
|
|
19
|
+
darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred
|
|
20
|
+
darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue
|
|
21
|
+
dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray
|
|
22
|
+
green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen
|
|
23
|
+
lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink
|
|
24
|
+
lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen
|
|
25
|
+
linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue
|
|
26
|
+
mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy
|
|
27
|
+
oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip
|
|
28
|
+
peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown
|
|
29
|
+
seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle
|
|
30
|
+
tomato turquoise violet wheat white whitesmoke yellow yellowgreen
|
|
31
|
+
].freeze
|
|
32
|
+
COLORS = {
|
|
33
|
+
"light" => { board: "#f2f5f2", border: "#b8c4bc", groove: "#e0e6e2", hole: "#9aa69f", used_hole: "#394a40",
|
|
34
|
+
connected_hole: "#71877a", resistor_bg: "#f0d79c", resistor_border: "#654f36",
|
|
35
|
+
diode_bg: "#35383b", diode_border: "#191b1d", diode_mark: "#eeeeee",
|
|
36
|
+
dip_bg: "#33393d", dip_border: "#151719", dip_notch: "#aaaaaa", dip_text: "#ffffff",
|
|
37
|
+
switch_bg: "#ece7dd", switch_border: "#504c45", switch_button: "#c7c0b5", switch_button_border: "#6c655b",
|
|
38
|
+
text: "#25312b", label: "#627168", positive: "#cf5955", negative: "#71847e", negative_wire: "#315ea8", lead: "#68766f",
|
|
39
|
+
component_bg: "#e8eeea", component_border: "#aab7af", module_bg: "#f1efe8",
|
|
40
|
+
module_border: "#888780", muted: "#92918b", accent: "#1D6B45" },
|
|
41
|
+
"dark" => { board: "#202a27", border: "#52625b", groove: "#141c19", hole: "#718178", used_hole: "#d4e2d9",
|
|
42
|
+
connected_hole: "#9bab9e", resistor_bg: "#6b6248", resistor_border: "#c9ac73",
|
|
43
|
+
diode_bg: "#4b5550", diode_border: "#aab7af", diode_mark: "#dde6df",
|
|
44
|
+
dip_bg: "#39433e", dip_border: "#91a199", dip_notch: "#c0cdc5", dip_text: "#e8efeb",
|
|
45
|
+
switch_bg: "#3d4540", switch_border: "#819188", switch_button: "#58665d", switch_button_border: "#a4b6a9",
|
|
46
|
+
text: "#e8efeb", label: "#c0cdc5", positive: "#ed7168", negative: "#99aaa4", negative_wire: "#76a9f2", lead: "#91a199",
|
|
47
|
+
component_bg: "#2d3935", component_border: "#607168", module_bg: "#343a3d",
|
|
48
|
+
module_border: "#687176", muted: "#889095", accent: "#8fbea1" },
|
|
49
|
+
"print" => { board: "#ffffff", border: "#555555", groove: "#eeeeee", hole: "#aaaaaa", used_hole: "#333333",
|
|
50
|
+
connected_hole: "#777777", resistor_bg: "#dddddd", resistor_border: "#555555",
|
|
51
|
+
diode_bg: "#555555", diode_border: "#333333", diode_mark: "#ffffff",
|
|
52
|
+
dip_bg: "#555555", dip_border: "#222222", dip_notch: "#eeeeee", dip_text: "#ffffff",
|
|
53
|
+
switch_bg: "#eeeeee", switch_border: "#444444", switch_button: "#cccccc", switch_button_border: "#444444",
|
|
54
|
+
text: "#222222", label: "#444444", positive: "#333333", negative: "#777777", negative_wire: "#555555", lead: "#333333",
|
|
55
|
+
component_bg: "#f4f4f4", component_border: "#555555", module_bg: "#f4f4f4",
|
|
56
|
+
module_border: "#666666", muted: "#777777", accent: "#333333" }
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
def render(circuit, crop: "auto", theme: "light", orientation: "portrait", show_nets: false, legend: false, color_by: "wire", annotations: [], rail_pattern: nil, interactive_layers: true)
|
|
60
|
+
@circuit, @theme, @orientation, @show_nets, @legend_enabled, @color_by, @annotations = circuit, theme.to_s, orientation.to_s, show_nets, legend, color_by, annotations
|
|
61
|
+
@wire_indexes = circuit.wires.each_with_index.to_h
|
|
62
|
+
@wires_by_id = circuit.wires.to_h { |wire| [wire.id, wire] }
|
|
63
|
+
@rail_definitions = Array(circuit.board.definition.data["rails"]).to_h { |rail| [rail["id"].to_s, rail] }
|
|
64
|
+
@wire_endpoint_refs = circuit.wires.each_with_object({}) { |wire, refs| refs[wire.from] = refs[wire.to] = true }
|
|
65
|
+
@nets_by_member, @nets_by_hole = {}, {}
|
|
66
|
+
circuit.nets.each do |net|
|
|
67
|
+
net.members.each { |member| @nets_by_member[member] = net }
|
|
68
|
+
net.holes.each { |hole| @nets_by_hole[hole] = net }
|
|
69
|
+
end
|
|
70
|
+
@net_indexes = circuit.nets.each_with_index.to_h
|
|
71
|
+
@board_edge_wires = circuit.wires.select do |wire|
|
|
72
|
+
wire.route == "edge" && !offboard_component(wire.from) && !offboard_component(wire.to)
|
|
73
|
+
end
|
|
74
|
+
@board_edge_indexes = @board_edge_wires.each_with_index.to_h
|
|
75
|
+
@board_extents = { x: circuit.board.holes.values.map(&:x).minmax,
|
|
76
|
+
y: circuit.board.holes.values.map(&:y).minmax }
|
|
77
|
+
@rail_y_extents = circuit.board.holes.values.select { |hole| hole.kind == :rail }.map(&:y).minmax
|
|
78
|
+
@offboard_terminal_wires = {}
|
|
79
|
+
@offboard_terminal_indexes = {}
|
|
80
|
+
@offboard_terminal_breakouts = {}
|
|
81
|
+
@offboard_rail_tracks = nil
|
|
82
|
+
@offboard_layouts = {}
|
|
83
|
+
@edge_breakout_offsets = @annotation_states = nil
|
|
84
|
+
raise ArgumentError, "orientation must be portrait or landscape" unless %w[portrait landscape].include?(@orientation)
|
|
85
|
+
@layers = if interactive_layers
|
|
86
|
+
(circuit.wires.flat_map { |wire| layer_names(wire.layer) } +
|
|
87
|
+
circuit.components.values.flat_map { |component| layer_names(read(component.attrs, "layer")) }).uniq
|
|
88
|
+
else
|
|
89
|
+
[]
|
|
90
|
+
end
|
|
91
|
+
@layer_controls_height = 0
|
|
92
|
+
@rail_pattern = rail_pattern&.to_s
|
|
93
|
+
configure_rail_pattern
|
|
94
|
+
|
|
95
|
+
@colors = COLORS.fetch(@theme, COLORS.fetch("light"))
|
|
96
|
+
@conductive_holes = @nets_by_hole.keys.to_set
|
|
97
|
+
@used_holes = (circuit.components.values.flat_map { |component| component.pins.values.map(&:hole_id) } +
|
|
98
|
+
circuit.wires.flat_map { |wire| [wire.from, wire.to].filter_map { |endpoint| circuit.board.hole(endpoint)&.id } } +
|
|
99
|
+
circuit.supplies.flat_map { |supply| [supply.plus, supply.minus] }).compact.to_set
|
|
100
|
+
@view_box = view_box(crop)
|
|
101
|
+
@layer_controls_height = layer_controls_height unless @layers.empty?
|
|
102
|
+
left, top, width, height = @view_box
|
|
103
|
+
extra_height = legend_height
|
|
104
|
+
output_width, output_height, output_view_box = if @orientation == "portrait"
|
|
105
|
+
[height, width + @layer_controls_height + extra_height,
|
|
106
|
+
"0 0 #{fmt(height)} #{fmt(width + @layer_controls_height + extra_height)}"]
|
|
107
|
+
else
|
|
108
|
+
[width, height + @layer_controls_height + extra_height,
|
|
109
|
+
"#{fmt(left)} #{fmt(top)} #{fmt(width)} #{fmt(height + @layer_controls_height + extra_height)}"]
|
|
110
|
+
end
|
|
111
|
+
svg = ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
112
|
+
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"#{fmt(output_width)}\" height=\"#{fmt(output_height)}\" viewBox=\"#{output_view_box}\" font-family=\"Helvetica, Arial, 'Noto Sans JP', sans-serif\" fill=\"#{@colors[:text]}\" role=\"img\"#{@rail_pattern ? " data-rail-pattern=\"#{@rail_pattern}\"" : ""}>",
|
|
113
|
+
"<title>#{escape(circuit.title || "Breadkit circuit")}</title>",
|
|
114
|
+
"<desc>Breadboard wiring diagram generated by Breadkit.</desc>"]
|
|
115
|
+
transform = if @orientation == "portrait"
|
|
116
|
+
"matrix(0 1 -1 0 #{fmt(top + height)} #{fmt(-left + @layer_controls_height)})"
|
|
117
|
+
elsif @layer_controls_height.positive?
|
|
118
|
+
"translate(0 #{fmt(@layer_controls_height)})"
|
|
119
|
+
end
|
|
120
|
+
svg << "<g transform=\"#{transform}\">" if transform
|
|
121
|
+
@rotated_scene = @orientation == "portrait"
|
|
122
|
+
svg << "<g id=\"board\">#{board_svg}</g>"
|
|
123
|
+
svg << "<g id=\"holes\">#{holes_svg}</g>"
|
|
124
|
+
svg << "<g id=\"components\">#{components_svg}</g>"
|
|
125
|
+
svg << "<g id=\"offboard\">#{offboard_svg}</g>"
|
|
126
|
+
svg << "<g id=\"wires\">#{wires_svg}</g>"
|
|
127
|
+
svg << "<g id=\"labels\">#{labels_svg}</g>"
|
|
128
|
+
svg << "<g id=\"nets\">#{nets_svg}</g><g id=\"annotations\">#{annotations_svg}</g>"
|
|
129
|
+
svg << "</g>" if transform
|
|
130
|
+
@rotated_scene = false
|
|
131
|
+
svg << "<g id=\"layer-controls\">#{layer_controls_svg}</g>" if @layer_controls_height.positive?
|
|
132
|
+
svg << layer_controls_script if @layer_controls_height.positive?
|
|
133
|
+
svg << "<g id=\"legend\">#{legend || !@annotations.empty? ? legend_svg : ""}</g></svg>"
|
|
134
|
+
svg.join("\n")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def configure_rail_pattern
|
|
140
|
+
@rail_position_map = {}
|
|
141
|
+
return unless @rail_pattern
|
|
142
|
+
raise ArgumentError, "unsupported rail pattern: #{@rail_pattern}" unless RAIL_PATTERNS.include?(@rail_pattern)
|
|
143
|
+
|
|
144
|
+
rails = @circuit.board.holes.values.select { |hole| hole.kind == :rail }.group_by(&:rail)
|
|
145
|
+
positions = rails.map { |id, holes| [id, holes.sum(&:y) / holes.length] }
|
|
146
|
+
pairs = %w[bottom top].map do |side|
|
|
147
|
+
positions.select { |id, _y| rail_definition(id)["side"].to_s == side }.sort_by(&:last)
|
|
148
|
+
end
|
|
149
|
+
valid_pairs = pairs.all? do |pair|
|
|
150
|
+
polarities = pair.map { |id, _y| rail_polarity(id) }
|
|
151
|
+
pair.length == 2 && polarities.compact.length == 2 && polarities.sort == %w[+ -]
|
|
152
|
+
end
|
|
153
|
+
raise ArgumentError, "rail pattern requires two +/− rail pairs" unless positions.length == 4 && valid_pairs
|
|
154
|
+
|
|
155
|
+
slots = pairs.flatten(1).map(&:first)
|
|
156
|
+
@rail_pattern_slots = slots.zip(@rail_pattern.chars)
|
|
157
|
+
polarity_by_rail = @rail_pattern_slots.to_h
|
|
158
|
+
pairs.each do |pair|
|
|
159
|
+
pair.each do |id, _y|
|
|
160
|
+
polarity = rail_polarity(id)
|
|
161
|
+
@rail_position_map[id] = pair.find { |rail_id, _rail_y| polarity_by_rail[rail_id] == polarity }.first
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def rail_definition(id)
|
|
167
|
+
@rail_definitions.fetch(id.to_s, {})
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def rail_polarity(id)
|
|
171
|
+
rail_definition(id)["polarity"]&.to_s || (id.end_with?("+", "-") ? id[-1] : nil)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def display_hole(hole)
|
|
175
|
+
return hole unless @rail_pattern && hole&.kind == :rail
|
|
176
|
+
|
|
177
|
+
rail_id = @rail_position_map.fetch(hole.rail, hole.rail)
|
|
178
|
+
@circuit.board.hole("#{rail_id}#{hole.col}") || hole
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def rail_marks_svg
|
|
182
|
+
polarity_by_rail = @rail_pattern_slots.to_h
|
|
183
|
+
@circuit.board.strips.values.filter_map do |ids|
|
|
184
|
+
holes = ids.map { |id| @circuit.board.hole(id) }
|
|
185
|
+
next unless holes.first&.kind == :rail
|
|
186
|
+
|
|
187
|
+
hole = holes.min_by(&:x)
|
|
188
|
+
polarity = polarity_by_rail.fetch(hole.rail)
|
|
189
|
+
text(px(hole.x) - 5, py(hole.y) + 2, polarity, "font-size" => 7, "font-weight" => 700,
|
|
190
|
+
"fill" => polarity == "+" ? @colors[:positive] : @colors[:negative], "text-anchor" => "middle",
|
|
191
|
+
"data-rail" => hole.rail)
|
|
192
|
+
end.join
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def board_svg
|
|
196
|
+
x, y, width, height = board_bounds
|
|
197
|
+
out = [rect(x, y, width, height, rx: 8, fill: @colors[:board], stroke: @colors[:border], stroke_width: 1)]
|
|
198
|
+
ravine = Array(@circuit.board.definition.data.dig("terminal", "ravine_between"))
|
|
199
|
+
rows = ravine.map { |row| @circuit.board.holes.values.find { |hole| hole.row == row.to_s } }
|
|
200
|
+
if rows.length == 2 && rows.all?
|
|
201
|
+
groove_y = rows.sum { |hole| py(hole.y) } / rows.length
|
|
202
|
+
groove_width = PITCH * 2
|
|
203
|
+
out << rect(x + PITCH / 2, groove_y - groove_width / 2, width - PITCH, groove_width,
|
|
204
|
+
rx: 2, fill: @colors[:groove])
|
|
205
|
+
end
|
|
206
|
+
@circuit.board.strips.each do |id, ids|
|
|
207
|
+
next unless id.start_with?("rail:")
|
|
208
|
+
holes = ids.map { |hole_id| @circuit.board.hole(hole_id) }
|
|
209
|
+
next if holes.empty?
|
|
210
|
+
rail_id = holes.first.rail
|
|
211
|
+
rail_color = { "+" => @colors[:positive], "-" => @colors[:negative] }.fetch(rail_polarity(rail_id), @colors[:label])
|
|
212
|
+
positions = holes.map { |hole| display_hole(hole) }
|
|
213
|
+
first, last = positions.map { |hole| px(hole.x) }.minmax
|
|
214
|
+
out << rect(first - 3, py(positions.first.y) - 3, last - first + 6, 6,
|
|
215
|
+
rx: 3, fill: rail_color, opacity: 0.14, data_rail: rail_id)
|
|
216
|
+
end
|
|
217
|
+
out.join
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def holes_svg
|
|
221
|
+
@circuit.board.holes.values.map do |hole|
|
|
222
|
+
used = @used_holes.include?(hole.id)
|
|
223
|
+
connected = @conductive_holes.include?(hole.id)
|
|
224
|
+
display = display_hole(hole)
|
|
225
|
+
attrs = { fill: used ? @colors[:used_hole] : (connected ? @colors[:connected_hole] : @colors[:hole]),
|
|
226
|
+
data_hole: hole.id, opacity: used ? 1 : 0.75 }
|
|
227
|
+
attrs[:data_occupied] = true if used
|
|
228
|
+
attrs[:data_connected] = true if connected
|
|
229
|
+
circle(px(display.x), py(display.y), used ? 1.8 : 1.3, **attrs)
|
|
230
|
+
end.join
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def labels_svg
|
|
234
|
+
out = []
|
|
235
|
+
number_y = @orientation == "portrait" ? py(-1) : py(@circuit.board.height)
|
|
236
|
+
@circuit.board.definition.data.dig("terminal", "rows").each do |row|
|
|
237
|
+
y = @circuit.board.holes.values.find { |hole| hole.row == row }&.y
|
|
238
|
+
out << text(-7, py(y), row, "font-size" => 8, "font-weight" => 500, "fill" => @colors[:label], "text-anchor" => "middle") if y
|
|
239
|
+
end
|
|
240
|
+
(1..@circuit.board.width).each do |col|
|
|
241
|
+
next unless col == 1 || (col % 5).zero?
|
|
242
|
+
number_x = px(col - 1) + (@orientation == "portrait" ? 5 : 0)
|
|
243
|
+
out << rect(number_x - 3, number_y - 5, 6, 10, rx: 2,
|
|
244
|
+
fill: @colors[:board], stroke: @colors[:border], stroke_width: 0.6) if @orientation == "portrait"
|
|
245
|
+
out << text(number_x + (@orientation == "portrait" ? 3 : 0), number_y, col,
|
|
246
|
+
"font-size" => @orientation == "portrait" ? 6 : 7, "font-weight" => 500,
|
|
247
|
+
"fill" => @colors[:text], "text-anchor" => "middle")
|
|
248
|
+
end
|
|
249
|
+
out << rail_marks_svg if @rail_pattern
|
|
250
|
+
out.join
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def components_svg
|
|
254
|
+
@circuit.components.values.map do |component|
|
|
255
|
+
pins = component.pins.values.filter_map { |pin| [pin, @circuit.board.hole(pin.hole_id)] if pin.hole_id }
|
|
256
|
+
next if pins.empty?
|
|
257
|
+
xs, ys = pins.map { |_pin, hole| px(display_hole(hole).x) }, pins.map { |_pin, hole| py(display_hole(hole).y) }
|
|
258
|
+
center_x, center_y = (xs.min + xs.max) / 2.0, (ys.min + ys.max) / 2.0
|
|
259
|
+
shape = component.part.data.dig("render", "shape")
|
|
260
|
+
body = case shape
|
|
261
|
+
when "resistor" then resistor_svg(component, pins, center_x, center_y)
|
|
262
|
+
when "led_5mm" then led_svg(component, pins, center_x, center_y)
|
|
263
|
+
when "diode" then diode_svg(component, pins, center_x, center_y)
|
|
264
|
+
when "capacitor", "electrolytic" then capacitor_svg(component, pins, center_x, center_y, electrolytic: shape == "electrolytic")
|
|
265
|
+
when "dip" then dip_svg(component, pins)
|
|
266
|
+
when "module" then module_svg(component, pins)
|
|
267
|
+
when "tact_switch" then switch_svg(component, pins, center_x, center_y)
|
|
268
|
+
else generic_svg(component, pins, center_x, center_y)
|
|
269
|
+
end
|
|
270
|
+
body = "<g data-ref=\"#{escape(component.ref)}\">#{body}</g>"
|
|
271
|
+
layer_group(body, read(component.attrs, "layer"))
|
|
272
|
+
end.compact.join
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def resistor_svg(component, pins, x, y)
|
|
276
|
+
out = [lead_lines(pins, x, y)]
|
|
277
|
+
out << rect(x - 12, y - 4, 24, 8, rx: 3, fill: @colors[:resistor_bg], stroke: @colors[:resistor_border], data_ref: component.ref)
|
|
278
|
+
bands = resistor_bands(component.value, bands: component.attrs[:bands] || 4)
|
|
279
|
+
bands.each_with_index { |color, index| out << rect(x - (bands.length == 5 ? 9 : 7) + index * 4, y - 4, 1.7, 8, fill: color) }
|
|
280
|
+
out << text(x, y - 7, "#{component.ref} #{component.value}", "font-size" => 5, "font-weight" => 500, "text-anchor" => "middle")
|
|
281
|
+
out.join
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def led_svg(component, pins, x, y)
|
|
285
|
+
out = [lead_lines(pins, x, y)]
|
|
286
|
+
color = component.attrs[:color].to_s.downcase
|
|
287
|
+
color = "red" if color.empty?
|
|
288
|
+
valid_color = CSS_COLOR_NAMES.include?(color) || /\A#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\z/.match?(color)
|
|
289
|
+
raise Error, "invalid LED color: #{color}" unless valid_color
|
|
290
|
+
color = { "red" => "#df5550", "green" => "#5fbf69", "blue" => "#6a9df0", "yellow" => "#f0d958" }.fetch(color, color)
|
|
291
|
+
out << circle(x, y, 5.5, fill: color, stroke: @colors[:component_border], data_ref: component.ref)
|
|
292
|
+
out << polarity_marker(component, pins, x, y, 5.0, @colors[:component_border])
|
|
293
|
+
out << text(x, y - 8, component.ref, "font-size" => 5, "font-weight" => 500, "text-anchor" => "middle")
|
|
294
|
+
out.join
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def diode_svg(component, pins, x, y)
|
|
298
|
+
[lead_lines(pins, x, y), rect(x - 7, y - 3, 14, 6, rx: 1, fill: @colors[:diode_bg], stroke: @colors[:diode_border], data_ref: component.ref),
|
|
299
|
+
polarity_marker(component, pins, x, y, 3.0, @colors[:diode_mark]),
|
|
300
|
+
text(x, y - 5, component.ref, "font-size" => 4.5, "font-weight" => 500, "text-anchor" => "middle")].join
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def capacitor_svg(component, pins, x, y, electrolytic:)
|
|
304
|
+
out = [lead_lines(pins, x, y)]
|
|
305
|
+
out << line(x - 2, y - 4, x - 2, y + 4, @colors[:lead], 1.1)
|
|
306
|
+
out << line(x + 2, y - 4, x + 2, y + 4, @colors[:lead], 1.1)
|
|
307
|
+
if electrolytic
|
|
308
|
+
positive, negative = polarity_points(component, pins)
|
|
309
|
+
offset_x = positive && negative && (positive[0] - negative[0]).abs < (positive[1] - negative[1]).abs ? 0 : (positive && positive[0] > x ? 5 : -5)
|
|
310
|
+
offset_y = offset_x.zero? ? (positive && positive[1] > y ? 5 : -5) : 0
|
|
311
|
+
out << text(x + offset_x, y + offset_y + 1.5, "+", "font-size" => 4, "text-anchor" => "middle")
|
|
312
|
+
end
|
|
313
|
+
out << text(x, y - 6, component.ref, "font-size" => 4.5, "font-weight" => 500, "text-anchor" => "middle")
|
|
314
|
+
out.join
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def dip_svg(component, pins)
|
|
318
|
+
xs, ys = pins.map { |_pin, hole| px(display_hole(hole).x) }, pins.map { |_pin, hole| py(display_hole(hole).y) }
|
|
319
|
+
x1, x2, middle = xs.min - 4, xs.max + 4, (ys.min + ys.max) / 2.0
|
|
320
|
+
out = [rect(x1, middle - 4, x2 - x1, 8, rx: 2, fill: @colors[:dip_bg], stroke: @colors[:dip_border], data_ref: component.ref)]
|
|
321
|
+
first_pin = pins.find { |pin, _hole| pin.number == "1" }&.last
|
|
322
|
+
notch_x = first_pin && px(first_pin.x) > xs.sum / xs.length ? x2 - 2 : x1 + 2
|
|
323
|
+
out << tag("path", d: "M #{fmt(notch_x)} #{fmt(middle - 2)} Q #{fmt(notch_x + (notch_x == x1 + 2 ? 2 : -2))} #{fmt(middle)} #{fmt(notch_x)} #{fmt(middle + 2)}",
|
|
324
|
+
fill: "none", stroke: @colors[:dip_notch], stroke_width: 0.8)
|
|
325
|
+
out << text((x1 + x2) / 2, middle + 1.5, component.part.data.dig("render", "label") || component.ref,
|
|
326
|
+
"font-size" => 4.5, "fill" => @colors[:dip_text], "text-anchor" => "middle")
|
|
327
|
+
pins.each do |pin, hole|
|
|
328
|
+
display = display_hole(hole)
|
|
329
|
+
out << text(px(display.x), py(display.y) + (display.y > 4 ? 4 : -2), pin.number, "font-size" => 3.5, "text-anchor" => "middle")
|
|
330
|
+
end
|
|
331
|
+
out.join
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def module_svg(component, pins)
|
|
335
|
+
x, y, width, height = module_bounds(component, pins)
|
|
336
|
+
x, y, width, height = px(x), py(y + height), px(width), px(height)
|
|
337
|
+
center_x, center_y = x + width / 2, y + height / 2
|
|
338
|
+
render = component.part.data.fetch("render")
|
|
339
|
+
out = [rect(x, y, width, height, rx: 4, fill: render["fill"] || @colors[:module_bg],
|
|
340
|
+
stroke: render["stroke"] || @colors[:module_border], stroke_width: 1,
|
|
341
|
+
data_ref: component.ref)]
|
|
342
|
+
pin_definitions = component.part.data["pins"].to_h { |definition| [definition["num"].to_s, definition] }
|
|
343
|
+
pins.each do |pin, hole|
|
|
344
|
+
display = display_hole(hole)
|
|
345
|
+
pin_x, pin_y = px(display.x), py(display.y)
|
|
346
|
+
definition = pin_definitions[pin.number]
|
|
347
|
+
unused = component.unused.include?(pin.name) || component.unused.include?(pin.number)
|
|
348
|
+
pin_color = unused ? @colors[:muted] : MODULE_PIN_COLORS.fetch(pin.role.to_s, "#d7bd79")
|
|
349
|
+
out << circle(pin_x, pin_y, 2.3, fill: pin_color, stroke: render["stroke"] || @colors[:module_border], stroke_width: 0.7,
|
|
350
|
+
data_pin: "#{component.ref}.#{pin.name}")
|
|
351
|
+
out << circle(pin_x, pin_y, 0.8, fill: render["stroke"] || @colors[:module_border])
|
|
352
|
+
label = definition && definition["label"]
|
|
353
|
+
next unless label
|
|
354
|
+
label = pin.name if label == true
|
|
355
|
+
label_gap = @orientation == "portrait" ? 10 : 3.8
|
|
356
|
+
label_y = pin_y + (center_y > pin_y ? label_gap : -label_gap)
|
|
357
|
+
label_anchor = @orientation == "portrait" ? (center_y > pin_y ? "end" : "start") : "middle"
|
|
358
|
+
out << text(pin_x, label_y, label, "font-size" => 5.8, "font-weight" => 500,
|
|
359
|
+
"fill" => render["text_color"] || @colors[:text], "text-anchor" => label_anchor)
|
|
360
|
+
end
|
|
361
|
+
label = render["label"] || component.ref
|
|
362
|
+
font_size = [[ [width, height].min / 6, 6].max, 10].min
|
|
363
|
+
label_units = label.to_s.each_char.sum { |char| char.ascii_only? ? 0.6 : 1.0 }
|
|
364
|
+
label_space = @orientation == "portrait" ? height : width
|
|
365
|
+
font_size = [font_size, (label_space - 4) / label_units].min if label_units.positive? && label_space > 4
|
|
366
|
+
label_x, label_y = center_x, center_y + (@orientation == "portrait" ? 0 : font_size / 3)
|
|
367
|
+
label_width = label_units * font_size
|
|
368
|
+
half_width, half_height = @orientation == "portrait" ? [font_size / 2, label_width / 2] : [label_width / 2, font_size / 2]
|
|
369
|
+
if pins.any? { |_pin, hole| (px(display_hole(hole).x) - label_x).abs < half_width + 2.3 &&
|
|
370
|
+
(py(display_hole(hole).y) - label_y).abs < half_height + 2.3 }
|
|
371
|
+
label_x = x + width - font_size - 1 if @orientation == "portrait"
|
|
372
|
+
label_y = y + height - font_size - 1 if @orientation == "landscape"
|
|
373
|
+
end
|
|
374
|
+
out << text(label_x, label_y, label, "font-size" => font_size,
|
|
375
|
+
"font-weight" => 600, "fill" => render["text_color"] || @colors[:text], "text-anchor" => "middle")
|
|
376
|
+
out.join
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def module_bounds(component, pins)
|
|
380
|
+
xs, ys = pins.map { |_pin, hole| hole.x }, pins.map { |_pin, hole| hole.y }
|
|
381
|
+
render = component.part.data.fetch("render")
|
|
382
|
+
width_mm, height_mm = render.fetch("size_mm").map { |value| Float(value) }
|
|
383
|
+
offset_x, offset_y = Array(render["body_offset_mm"]).map { |value| Float(value) / 2.54 }
|
|
384
|
+
offset_x ||= 0
|
|
385
|
+
offset_y ||= 0
|
|
386
|
+
width, height = width_mm / 2.54, height_mm / 2.54
|
|
387
|
+
center_x, center_y = (xs.min + xs.max) / 2.0 + offset_x, (ys.min + ys.max) / 2.0 + offset_y
|
|
388
|
+
[center_x - width / 2, center_y - height / 2, width, height]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def switch_svg(component, pins, x, y)
|
|
392
|
+
out = [lead_lines(pins, x, y)]
|
|
393
|
+
out << rect(x - 12, y - 10, 24, 20, rx: 2, fill: @colors[:switch_bg], stroke: @colors[:switch_border], data_ref: component.ref)
|
|
394
|
+
out << circle(x, y, 5, fill: @colors[:switch_button], stroke: @colors[:switch_button_border])
|
|
395
|
+
out << text(x, y - 13, component.ref, "font-size" => 5, "font-weight" => 500, "text-anchor" => "middle")
|
|
396
|
+
out.join
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def generic_svg(component, pins, x, y)
|
|
400
|
+
out = [lead_lines(pins, x, y)]
|
|
401
|
+
out << rect(x - 9, y - 5, 18, 10, rx: 2, fill: @colors[:component_bg], stroke: @colors[:component_border], data_ref: component.ref)
|
|
402
|
+
out << text(x, y + 1.5, component.ref, "font-size" => 4.5, "font-weight" => 500, "text-anchor" => "middle")
|
|
403
|
+
out.join
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def offboard_svg
|
|
407
|
+
@circuit.components.values.filter_map do |component|
|
|
408
|
+
next unless component.part.placement == "offboard"
|
|
409
|
+
pins = component.pins.values
|
|
410
|
+
x, y, width, height, _pin_edge = offboard_layout(component)
|
|
411
|
+
out = [rect(px(x), py(y + height), px(width), px(height), rx: 2,
|
|
412
|
+
fill: @colors[:module_bg], stroke: @colors[:module_border], data_ref: component.ref)]
|
|
413
|
+
label = component.attrs[:label] || component.part.data.dig("render", "label") || component.ref
|
|
414
|
+
if @orientation == "portrait"
|
|
415
|
+
label_x, label_y = px(x + width / 2 - 0.45), py(y + height / 2)
|
|
416
|
+
out << text(label_x, label_y, label, "font-size" => 6.5, "font-weight" => 600,
|
|
417
|
+
"text-anchor" => "middle", "fill" => @colors[:text])
|
|
418
|
+
out << text(label_x + px(0.9), label_y, component.attrs[:address], "font-size" => 5.2,
|
|
419
|
+
"text-anchor" => "middle", "fill" => @colors[:label]) if component.attrs[:address]
|
|
420
|
+
else
|
|
421
|
+
label_x, label_y = px(x + width / 2), py(y + height / 2)
|
|
422
|
+
out << text(label_x, label_y - 3, label, "font-size" => 6.5, "font-weight" => 600,
|
|
423
|
+
"text-anchor" => "middle", "fill" => @colors[:text])
|
|
424
|
+
out << text(label_x, label_y + 5, component.attrs[:address], "font-size" => 5.2,
|
|
425
|
+
"text-anchor" => "middle", "fill" => @colors[:label]) if component.attrs[:address]
|
|
426
|
+
end
|
|
427
|
+
pins.each_with_index do |pin, index|
|
|
428
|
+
pin_x, pin_y = offboard_pin_point(component, index)
|
|
429
|
+
pin_color = MODULE_PIN_COLORS.fetch(pin.role.to_s, @colors[:lead])
|
|
430
|
+
out << circle(px(pin_x), py(pin_y), 1.7, fill: pin_color, stroke: @colors[:module_bg],
|
|
431
|
+
stroke_width: 0.6, data_pin: "#{component.ref}.#{pin.name}", data_pin_type: pin.role)
|
|
432
|
+
references = ["#{component.ref}.#{pin.name}", "#{component.ref}.#{pin.number}"]
|
|
433
|
+
connected = references.any? { |reference| @wire_endpoint_refs.key?(reference) }
|
|
434
|
+
label_side = component.attrs[:side].to_s == "left"
|
|
435
|
+
label_x = if @orientation == "portrait"
|
|
436
|
+
px(pin_x)
|
|
437
|
+
else
|
|
438
|
+
px(pin_x + (label_side ? -0.8 : 0.8))
|
|
439
|
+
end
|
|
440
|
+
label_y = @orientation == "portrait" ? py(pin_y) + (label_side ? 5.5 : -4.5) : py(pin_y) + 1
|
|
441
|
+
anchor = if @orientation == "portrait"
|
|
442
|
+
label_side ? "end" : "start"
|
|
443
|
+
else
|
|
444
|
+
label_side ? "end" : "start"
|
|
445
|
+
end
|
|
446
|
+
out << text(label_x, label_y, pin.name, "font-size" => 5.4, "fill" => connected ? @colors[:text] : @colors[:muted],
|
|
447
|
+
"text-anchor" => anchor, data_pin_label: "#{component.ref}.#{pin.name}")
|
|
448
|
+
end
|
|
449
|
+
layer_group(out.join, read(component.attrs, "layer"))
|
|
450
|
+
end.join
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def lead_lines(pins, x, y)
|
|
454
|
+
pins.map { |_pin, hole| display = display_hole(hole); line(px(display.x), py(display.y), x, y, @colors[:lead], 0.9) }.join
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def polarity_points(component, pins)
|
|
458
|
+
polarity = component.part.data["polarity"] || {}
|
|
459
|
+
[polarity["positive"], polarity["negative"]].map do |name|
|
|
460
|
+
pin, hole = pins.find { |candidate, _hole| candidate.name == name }
|
|
461
|
+
display = display_hole(hole)
|
|
462
|
+
[px(display.x), py(display.y)] if pin && hole
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def polarity_marker(component, pins, x, y, extent, color)
|
|
467
|
+
positive, negative = polarity_points(component, pins)
|
|
468
|
+
if positive && negative && (negative[0] - positive[0]).abs >= (negative[1] - positive[1]).abs
|
|
469
|
+
marker_x = negative[0] > positive[0] ? x + extent / 2 : x - extent / 2
|
|
470
|
+
line(marker_x, y - extent, marker_x, y + extent, color, 1.2)
|
|
471
|
+
elsif positive && negative
|
|
472
|
+
marker_y = negative[1] > positive[1] ? y + extent / 2 : y - extent / 2
|
|
473
|
+
line(x - extent, marker_y, x + extent, marker_y, color, 1.2)
|
|
474
|
+
else
|
|
475
|
+
line(x - extent / 2, y - extent, x - extent / 2, y + extent, color, 1.2)
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def wires_svg
|
|
480
|
+
casings, lines, markers = [], [], []
|
|
481
|
+
@circuit.wires.each do |wire|
|
|
482
|
+
from, to = endpoint_point(wire.from), endpoint_point(wire.to)
|
|
483
|
+
next unless from && to
|
|
484
|
+
color = @theme == "print" ? "#222222" : (wire.color || wire_color(wire))
|
|
485
|
+
path = if wire.route == "arc"
|
|
486
|
+
control_y = (py(from[1]) + py(to[1])) / 2.0 - 8
|
|
487
|
+
"M #{fmt(px(from[0]))} #{fmt(py(from[1]))} Q #{fmt((px(from[0]) + px(to[0])) / 2)} #{fmt(control_y)} #{fmt(px(to[0]))} #{fmt(py(to[1]))}"
|
|
488
|
+
elsif wire.route == "edge"
|
|
489
|
+
edge_route(wire, from, to)
|
|
490
|
+
else
|
|
491
|
+
"M #{fmt(px(from[0]))} #{fmt(py(from[1]))} L #{fmt(px(to[0]))} #{fmt(py(to[1]))}"
|
|
492
|
+
end
|
|
493
|
+
dash = if wire.dashed
|
|
494
|
+
" stroke-dasharray=\"5 4\""
|
|
495
|
+
elsif @theme == "print"
|
|
496
|
+
" stroke-dasharray=\"#{["8 3", "3 3", "1 3"][@wire_indexes.fetch(wire) % 3]}\""
|
|
497
|
+
else
|
|
498
|
+
""
|
|
499
|
+
end
|
|
500
|
+
casing = "<path d=\"#{path}\" fill=\"none\" stroke=\"#{@colors[:board]}\" stroke-width=\"3.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>"
|
|
501
|
+
wire_svg = "<path d=\"#{path}\" fill=\"none\" stroke=\"#{escape(color)}\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" data-ref=\"#{escape(wire.id)}\"#{dash}/>"
|
|
502
|
+
dots = [from, to].uniq.map do |x, y|
|
|
503
|
+
circle(px(x), py(y), 2.8, fill: color, stroke: @colors[:board], stroke_width: 1.2,
|
|
504
|
+
data_wire: wire.id)
|
|
505
|
+
end
|
|
506
|
+
alternative = wire.electrical == false
|
|
507
|
+
casings << layer_group(casing, wire.layer, alternative: alternative)
|
|
508
|
+
lines << layer_group(wire_svg, wire.layer, alternative: alternative)
|
|
509
|
+
markers << layer_group(dots.join, wire.layer, alternative: alternative)
|
|
510
|
+
end
|
|
511
|
+
(casings + lines + markers).join
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def layer_names(value)
|
|
515
|
+
Array(value).filter_map { |name| name.to_s unless name.nil? || name.to_s.empty? }
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def layer_group(content, names, alternative: false)
|
|
519
|
+
names = layer_names(names)
|
|
520
|
+
return content if names.empty?
|
|
521
|
+
|
|
522
|
+
attributes = "data-layers=\"#{escape(JSON.generate(names))}\""
|
|
523
|
+
attributes += " data-alternative=\"true\" opacity=\"0\"" if alternative
|
|
524
|
+
"<g #{attributes}>#{content}</g>"
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def layer_controls_height
|
|
528
|
+
rows = layer_control_rows.length
|
|
529
|
+
rows * 21.0 + 4
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def layer_control_rows
|
|
533
|
+
items = [["All", "*"]] + @layers.map { |name| [name, name] }
|
|
534
|
+
available = (@orientation == "portrait" ? @view_box[3] : @view_box[2]) - 10
|
|
535
|
+
rows = [[]]
|
|
536
|
+
row_width = 0.0
|
|
537
|
+
items.each do |label, value|
|
|
538
|
+
width = layer_button_width(label)
|
|
539
|
+
if row_width.positive? && row_width + width > available
|
|
540
|
+
rows << []
|
|
541
|
+
row_width = 0
|
|
542
|
+
end
|
|
543
|
+
rows.last << [label, value, width]
|
|
544
|
+
row_width += width + 2
|
|
545
|
+
end
|
|
546
|
+
rows
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def layer_button_width(label)
|
|
550
|
+
label.each_char.sum { |char| char.match?(/[\p{Han}\p{Hiragana}\p{Katakana}]/) ? 9.5 : 6.5 } + 14
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def layer_controls_svg
|
|
554
|
+
x = @orientation == "portrait" ? 5.0 : @view_box[0] + 5.0
|
|
555
|
+
y = @orientation == "portrait" ? 3.0 : @view_box[1] + 3.0
|
|
556
|
+
out = ["<style>g[data-layer-button]{cursor:pointer}g[data-layer-button]:focus rect{stroke-width:2.5}</style>"]
|
|
557
|
+
layer_control_rows.each_with_index do |row, row_index|
|
|
558
|
+
row_x = x
|
|
559
|
+
row.each do |label, value, width|
|
|
560
|
+
button_y = y + row_index * 21.0
|
|
561
|
+
active = value == "*"
|
|
562
|
+
out << "<g data-layer-button=\"#{escape(value)}\" role=\"button\" tabindex=\"0\" aria-label=\"#{escape(label)}\" aria-pressed=\"#{active}\" cursor=\"pointer\">"
|
|
563
|
+
out << "<title>#{escape(label)}</title>"
|
|
564
|
+
out << rect(row_x, button_y, width, 18, rx: 9,
|
|
565
|
+
fill: active ? @colors[:component_bg] : @colors[:board],
|
|
566
|
+
stroke: active ? @colors[:accent] : @colors[:component_border], stroke_width: active ? 1.2 : 0.7)
|
|
567
|
+
out << text(row_x + width / 2, button_y + 12.2, label, "font-size" => 9,
|
|
568
|
+
"font-weight" => active ? 600 : 400,
|
|
569
|
+
"fill" => active ? @colors[:accent] : @colors[:text], "text-anchor" => "middle")
|
|
570
|
+
out << "</g>"
|
|
571
|
+
row_x += width + 2
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
out.join
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def layer_controls_script
|
|
578
|
+
<<~SVG
|
|
579
|
+
<script><![CDATA[
|
|
580
|
+
(function () {
|
|
581
|
+
var svg = document.currentScript.parentElement;
|
|
582
|
+
var buttons = svg.querySelectorAll("[data-layer-button]");
|
|
583
|
+
function selectLayer(selected) {
|
|
584
|
+
svg.querySelectorAll("[data-layers]").forEach(function (item) {
|
|
585
|
+
var matches = JSON.parse(item.getAttribute("data-layers")).includes(selected);
|
|
586
|
+
var opacity = selected === "*" ? (item.getAttribute("data-alternative") === "true" ? "0" : "1") :
|
|
587
|
+
(matches ? "1" : (item.getAttribute("data-alternative") === "true" ? "0" : "0.12"));
|
|
588
|
+
item.setAttribute("opacity", opacity);
|
|
589
|
+
});
|
|
590
|
+
buttons.forEach(function (button) {
|
|
591
|
+
var active = button.getAttribute("data-layer-button") === selected;
|
|
592
|
+
button.setAttribute("aria-pressed", active ? "true" : "false");
|
|
593
|
+
button.querySelector("rect").setAttribute("stroke", active ? "#{@colors[:accent]}" : "#{@colors[:component_border]}");
|
|
594
|
+
button.querySelector("rect").setAttribute("fill", active ? "#{@colors[:component_bg]}" : "#{@colors[:board]}");
|
|
595
|
+
button.querySelector("text").setAttribute("fill", active ? "#{@colors[:accent]}" : "#{@colors[:text]}");
|
|
596
|
+
button.querySelector("text").setAttribute("font-weight", active ? "600" : "400");
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
buttons.forEach(function (button) {
|
|
600
|
+
var activate = function () { selectLayer(button.getAttribute("data-layer-button")); };
|
|
601
|
+
button.addEventListener("click", activate);
|
|
602
|
+
button.addEventListener("keydown", function (event) {
|
|
603
|
+
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); }
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
})();
|
|
607
|
+
]]></script>
|
|
608
|
+
SVG
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def nets_svg
|
|
612
|
+
return "" unless @show_nets
|
|
613
|
+
@circuit.nets.map do |net|
|
|
614
|
+
hole = @circuit.board.hole(net.holes.first)
|
|
615
|
+
next unless hole
|
|
616
|
+
display = display_hole(hole)
|
|
617
|
+
text(px(display.x) + 5, py(display.y) - 5, net.name, "font-size" => 5, "font-weight" => 500,
|
|
618
|
+
"fill" => @colors[:text], "data-net" => net.name)
|
|
619
|
+
end.compact.join
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def legend_svg
|
|
623
|
+
return "" unless @legend_enabled || !@annotations.empty?
|
|
624
|
+
|
|
625
|
+
x = @orientation == "portrait" ? 5 : @view_box[0] + 5
|
|
626
|
+
y = @orientation == "portrait" ? @view_box[2] + @layer_controls_height + 9 : @view_box[1] + @view_box[3] + @layer_controls_height + 9
|
|
627
|
+
panel_x = @orientation == "portrait" ? 0 : @view_box[0]
|
|
628
|
+
panel_y = y - 9
|
|
629
|
+
panel_width = @orientation == "portrait" ? @view_box[3] : @view_box[2]
|
|
630
|
+
out = [rect(panel_x, panel_y, panel_width, legend_height, fill: @colors[:board]),
|
|
631
|
+
text(x, y, @circuit.title || "Breadkit circuit", "font-size" => 8, "font-weight" => "bold")]
|
|
632
|
+
y += 11
|
|
633
|
+
if @legend_enabled
|
|
634
|
+
legend_nets.each do |net|
|
|
635
|
+
out << line(x, y - 1.5, x + 10, y - 1.5, legend_color(net), 2.2)
|
|
636
|
+
out << text(x + 15, y, net.name, "font-size" => 5.5)
|
|
637
|
+
y += 9
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
@annotations.each_with_index do |item, index|
|
|
641
|
+
message = read(item, "message") || read(item, "rule")
|
|
642
|
+
annotation_lines("#{index + 1}. #{message}").each do |line|
|
|
643
|
+
out << text(x, y, line, "font-size" => 5)
|
|
644
|
+
y += 7
|
|
645
|
+
end
|
|
646
|
+
y += 2
|
|
647
|
+
end
|
|
648
|
+
out.join
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def legend_color(net)
|
|
652
|
+
return "#222222" if @theme == "print"
|
|
653
|
+
|
|
654
|
+
wire = net.members.filter_map { |member| @wires_by_id[member] }.first
|
|
655
|
+
wire ? (wire.color || wire_color(wire)) : PALETTE[@net_indexes.fetch(net) % PALETTE.length]
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def legend_nets
|
|
659
|
+
@circuit.nets.reject { |net| net.members.length == 1 && net.holes.empty? }
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def annotations_svg
|
|
663
|
+
badge_counts = Hash.new(0)
|
|
664
|
+
@annotations.each_with_index.map do |item, index|
|
|
665
|
+
targets = read(item, "targets") || {}
|
|
666
|
+
severity = read(item, "severity")
|
|
667
|
+
color = { "error" => "#d62728", "warning" => "#e67e22", "info" => "#2474c2" }.fetch(severity, "#d62728")
|
|
668
|
+
out = []
|
|
669
|
+
anchor = nil
|
|
670
|
+
Array(read(targets, "holes")).each do |id|
|
|
671
|
+
hole = @circuit.board.hole(id)
|
|
672
|
+
if hole
|
|
673
|
+
display = display_hole(hole)
|
|
674
|
+
x, y = px(display.x), py(display.y)
|
|
675
|
+
anchor ||= [x, y]
|
|
676
|
+
out << circle(x, y, 4, fill: "none", stroke: color, stroke_width: 1.5)
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
Array(read(targets, "wires")).each do |id|
|
|
680
|
+
wire = @wires_by_id[id]
|
|
681
|
+
next unless wire
|
|
682
|
+
point = endpoint_point(wire.from)
|
|
683
|
+
if point
|
|
684
|
+
x, y = px(point[0]), py(point[1])
|
|
685
|
+
anchor ||= [x, y]
|
|
686
|
+
out << circle(x, y, 5, fill: "none", stroke: color, stroke_width: 1.5)
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
Array(read(targets, "components")).each do |ref|
|
|
690
|
+
component = @circuit.components[ref]
|
|
691
|
+
holes = component && component.pins.values.map { |pin| @circuit.board.hole(pin.hole_id) }.compact
|
|
692
|
+
next if holes.nil? || holes.empty?
|
|
693
|
+
xs, ys = holes.map { |hole| px(display_hole(hole).x) }, holes.map { |hole| py(display_hole(hole).y) }
|
|
694
|
+
anchor ||= [(xs.min + xs.max) / 2, (ys.min + ys.max) / 2]
|
|
695
|
+
out << rect(xs.min - 7, ys.min - 7, xs.max - xs.min + 14, ys.max - ys.min + 14,
|
|
696
|
+
fill: "none", stroke: color, stroke_width: 1.5)
|
|
697
|
+
end
|
|
698
|
+
Array(read(targets, "nets")).each do |name|
|
|
699
|
+
state_name = read(item, "state")
|
|
700
|
+
if state_name
|
|
701
|
+
@annotation_states ||= @circuit.states("all").to_h { |candidate| [candidate.name, candidate] }
|
|
702
|
+
state = @annotation_states[state_name]
|
|
703
|
+
next unless state
|
|
704
|
+
end
|
|
705
|
+
net = @circuit.nets(state).find { |candidate| candidate.name == name }
|
|
706
|
+
next unless net
|
|
707
|
+
if net.holes.empty?
|
|
708
|
+
point = net.members.filter_map { |member| endpoint_point(member) }.first
|
|
709
|
+
if point
|
|
710
|
+
x, y = px(point[0]), py(point[1])
|
|
711
|
+
anchor ||= [x, y]
|
|
712
|
+
out << circle(x, y, 4, fill: "none", stroke: color, stroke_width: 1.5)
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
net.holes.each do |id|
|
|
716
|
+
hole = @circuit.board.hole(id)
|
|
717
|
+
next unless hole
|
|
718
|
+
display = display_hole(hole)
|
|
719
|
+
x, y = px(display.x), py(display.y)
|
|
720
|
+
anchor ||= [x, y]
|
|
721
|
+
out << circle(x, y, 3, fill: "none", stroke: color, stroke_width: 1)
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
if anchor
|
|
725
|
+
x, y = anchor
|
|
726
|
+
key = [x.round, y.round]
|
|
727
|
+
badge_x = x + 7 + badge_counts[key] * 11
|
|
728
|
+
badge_counts[key] += 1
|
|
729
|
+
out << circle(badge_x, y - 7, 5, fill: color, stroke: @colors[:board], stroke_width: 0.8)
|
|
730
|
+
out << text(badge_x, y - 5.5, index + 1, "font-size" => 5, "font-weight" => "bold",
|
|
731
|
+
"fill" => "#ffffff", "text-anchor" => "middle")
|
|
732
|
+
end
|
|
733
|
+
out.join
|
|
734
|
+
end.join
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def annotation_lines(message)
|
|
738
|
+
max_width = (@orientation == "portrait" ? @view_box[3] : @view_box[2]) - 20
|
|
739
|
+
lines, line, width = [], +"", 0
|
|
740
|
+
message.each_grapheme_cluster do |char|
|
|
741
|
+
if char == "\n"
|
|
742
|
+
lines << line
|
|
743
|
+
line, width = +"", 0
|
|
744
|
+
next
|
|
745
|
+
end
|
|
746
|
+
char_width = if char.match?(/[\u{1F000}-\u{1FAFF}]/)
|
|
747
|
+
10
|
|
748
|
+
elsif !char.ascii_only?
|
|
749
|
+
6
|
|
750
|
+
elsif char.match?(/[MW@%]/)
|
|
751
|
+
5
|
|
752
|
+
else
|
|
753
|
+
4
|
|
754
|
+
end
|
|
755
|
+
if width + char_width > max_width && !line.empty?
|
|
756
|
+
lines << line
|
|
757
|
+
line, width = +"", 0
|
|
758
|
+
end
|
|
759
|
+
line << char
|
|
760
|
+
width += char_width
|
|
761
|
+
end
|
|
762
|
+
lines << line unless line.empty?
|
|
763
|
+
lines
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def read(hash, key)
|
|
767
|
+
hash[key] || hash[key.to_sym] if hash.respond_to?(:[])
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def view_box(mode)
|
|
771
|
+
offboard = @circuit.components.values.any? { |component| component.part.placement == "offboard" }
|
|
772
|
+
wire_points = @circuit.wires.flat_map do |wire|
|
|
773
|
+
from, to = endpoint_point(wire.from), endpoint_point(wire.to)
|
|
774
|
+
from && to ? wire_route_points(wire, from, to) : []
|
|
775
|
+
end
|
|
776
|
+
if mode == "none" || (@used_holes.empty? && !offboard)
|
|
777
|
+
left, top, width, height = board_bounds
|
|
778
|
+
module_points = wire_points + @circuit.components.values.flat_map do |component|
|
|
779
|
+
if component.part.placement == "offboard"
|
|
780
|
+
offboard_bounds(component)
|
|
781
|
+
elsif component.part.data.dig("render", "shape") == "module"
|
|
782
|
+
pins = component.pins.values.filter_map { |pin| [pin, @circuit.board.hole(pin.hole_id)] if pin.hole_id }
|
|
783
|
+
next [] if pins.empty?
|
|
784
|
+
x, y, module_width, module_height = module_bounds(component, pins)
|
|
785
|
+
[[x, y], [x + module_width, y + module_height]]
|
|
786
|
+
else
|
|
787
|
+
[]
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
unless module_points.empty?
|
|
791
|
+
xs = module_points.map { |x, _y| px(x) }
|
|
792
|
+
ys = module_points.map { |_x, y| py(y) }
|
|
793
|
+
right = [left + width, xs.max].max
|
|
794
|
+
bottom = [top + height, ys.max].max
|
|
795
|
+
left = [left, xs.min].min
|
|
796
|
+
top = [top, ys.min].min
|
|
797
|
+
width, height = right - left, bottom - top
|
|
798
|
+
end
|
|
799
|
+
return [left - PITCH, top - PITCH, width + PITCH * 2, height + PITCH * 2]
|
|
800
|
+
end
|
|
801
|
+
points = @used_holes.filter_map do |id|
|
|
802
|
+
hole = @circuit.board.hole(id)
|
|
803
|
+
display = display_hole(hole) if hole
|
|
804
|
+
[display.x, display.y] if display
|
|
805
|
+
end
|
|
806
|
+
points.concat(wire_points)
|
|
807
|
+
points.concat(@circuit.components.values.select { |component| component.part.placement == "offboard" }
|
|
808
|
+
.flat_map { |component| offboard_bounds(component) })
|
|
809
|
+
points.concat(@circuit.components.values.flat_map do |component|
|
|
810
|
+
next [] unless component.part.data.dig("render", "shape") == "module"
|
|
811
|
+
pins = component.pins.values.filter_map { |pin| [pin, @circuit.board.hole(pin.hole_id)] if pin.hole_id }
|
|
812
|
+
next [] if pins.empty?
|
|
813
|
+
x, y, width, height = module_bounds(component, pins)
|
|
814
|
+
[[x, y], [x + width, y + height]]
|
|
815
|
+
end)
|
|
816
|
+
xs, ys = points.map { |x, _y| px(x) }, points.map { |_x, y| py(y) }
|
|
817
|
+
return board_bounds if xs.empty?
|
|
818
|
+
[xs.min - 25, ys.min - 30, [xs.max - xs.min + 50, 100].max, [ys.max - ys.min + 65, 100].max]
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def legend_height
|
|
822
|
+
return 0 unless @legend_enabled || !@annotations.empty?
|
|
823
|
+
|
|
824
|
+
20 + (@legend_enabled ? legend_nets.length * 9 : 0) +
|
|
825
|
+
@annotations.each_with_index.sum { |item, index| annotation_lines("#{index + 1}. #{read(item, "message") || read(item, "rule")}").length * 7 + 2 }
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def board_bounds
|
|
829
|
+
points = @circuit.board.holes.values
|
|
830
|
+
xs = points.map { |hole| px(hole.x) }
|
|
831
|
+
ys = points.map { |hole| py(hole.y) }
|
|
832
|
+
margin = PITCH * 1.5
|
|
833
|
+
[xs.min - margin, ys.min - margin, xs.max - xs.min + margin * 2, ys.max - ys.min + margin * 2]
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def endpoint_point(endpoint)
|
|
837
|
+
parsed = Breadkit::HoleId.parse(endpoint, board: @circuit.board)
|
|
838
|
+
if parsed.kind == :pin
|
|
839
|
+
component = @circuit.components[parsed.ref]
|
|
840
|
+
if component&.part&.placement == "offboard"
|
|
841
|
+
pins = component.pins.values
|
|
842
|
+
pin = pins.find { |item| item.name == parsed.pin || item.number == parsed.pin }
|
|
843
|
+
return unless pin
|
|
844
|
+
return offboard_pin_point(component, pins.index(pin))
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
hole = @circuit.board.hole(endpoint)
|
|
848
|
+
display = display_hole(hole) if hole
|
|
849
|
+
[display.x, display.y] if display
|
|
850
|
+
rescue ArgumentError
|
|
851
|
+
nil
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
def edge_route(wire, from, to)
|
|
855
|
+
points = edge_route_points(wire, from, to)
|
|
856
|
+
"M #{points.map { |x, y| "#{fmt(px(x))} #{fmt(py(y))}" }.join(' L ')}"
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
def wire_route_points(wire, from, to)
|
|
860
|
+
if wire.route == "edge"
|
|
861
|
+
edge_route_points(wire, from, to)
|
|
862
|
+
elsif wire.route == "arc"
|
|
863
|
+
[from, [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0 + 0.8], to]
|
|
864
|
+
else
|
|
865
|
+
[from, to]
|
|
866
|
+
end
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
def edge_route_points(wire, from, to)
|
|
870
|
+
module_from = offboard_component(wire.from)
|
|
871
|
+
module_to = offboard_component(wire.to)
|
|
872
|
+
if module_from || module_to
|
|
873
|
+
module_point, board_point = module_from ? [from, to] : [to, from]
|
|
874
|
+
board_endpoint = module_from ? wire.to : wire.from
|
|
875
|
+
hole = @circuit.board.hole(board_endpoint)
|
|
876
|
+
if hole&.kind == :terminal
|
|
877
|
+
component = module_from || module_to
|
|
878
|
+
side = component.attrs[:side].to_s == "left" ? "left" : "right"
|
|
879
|
+
@offboard_terminal_indexes[side] ||= offboard_terminal_wires(side).each_with_index.to_h
|
|
880
|
+
index = @offboard_terminal_indexes.fetch(side).fetch(wire)
|
|
881
|
+
axis = @orientation == "portrait" ? :y : :x
|
|
882
|
+
minimum, maximum = @board_extents.fetch(axis)
|
|
883
|
+
lane = (side == "right" ? maximum : minimum) + (side == "right" ? 1 : -1) * (1.5 + index * 0.7)
|
|
884
|
+
target_axis = @orientation == "portrait" ? 0 : 1
|
|
885
|
+
@offboard_terminal_breakouts[side] ||= begin
|
|
886
|
+
groups = offboard_terminal_wires(side).group_by do |item|
|
|
887
|
+
endpoint = offboard_component(item.from) ? item.to : item.from
|
|
888
|
+
endpoint_point(endpoint)&.[](target_axis)
|
|
889
|
+
end
|
|
890
|
+
groups.values.each_with_object({}) do |peers, offsets|
|
|
891
|
+
peers.each_with_index { |item, position| offsets[item] = (position - (peers.length - 1) / 2.0) * 0.6 }
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
breakout = @offboard_terminal_breakouts.fetch(side).fetch(wire)
|
|
895
|
+
points = if @orientation == "portrait"
|
|
896
|
+
x = board_point[0] + breakout
|
|
897
|
+
[module_point, [module_point[0], lane], [x, lane], [x, board_point[1]], board_point]
|
|
898
|
+
else
|
|
899
|
+
y = board_point[1] + breakout
|
|
900
|
+
[module_point, [lane, module_point[1]], [lane, y], [board_point[0], y], board_point]
|
|
901
|
+
end
|
|
902
|
+
elsif hole&.kind == :rail && @orientation == "portrait"
|
|
903
|
+
component = module_from || module_to
|
|
904
|
+
side = component.attrs[:side].to_s == "left" ? "left" : "right"
|
|
905
|
+
track = offboard_rail_tracks(side, board_point[1]).fetch(wire, 0)
|
|
906
|
+
outer = side == "right" ? @rail_y_extents.last : @rail_y_extents.first
|
|
907
|
+
direction = board_point[1] == outer ? (side == "right" ? 1 : -1) : (side == "right" ? -1 : 1)
|
|
908
|
+
lane = board_point[1] + direction * track * 0.7
|
|
909
|
+
points = [module_point, [module_point[0], lane], [board_point[0], lane], board_point]
|
|
910
|
+
else
|
|
911
|
+
points = [module_point, [module_point[0], board_point[1]], board_point]
|
|
912
|
+
end
|
|
913
|
+
points.reverse! unless module_from
|
|
914
|
+
else
|
|
915
|
+
offset = (@board_edge_indexes.fetch(wire) - (@board_edge_wires.length - 1) / 2.0) * 0.8
|
|
916
|
+
lane = from[1] <= @circuit.board.height / 2.0 ? -4.5 + offset : @circuit.board.height + 4.5 + offset
|
|
917
|
+
from_offset = edge_breakout_offset(wire, from[0])
|
|
918
|
+
to_offset = edge_breakout_offset(wire, to[0])
|
|
919
|
+
points = [from]
|
|
920
|
+
points << [from[0] + from_offset, from[1]] unless from_offset.zero?
|
|
921
|
+
points.concat([[from[0] + from_offset, lane], [to[0] + to_offset, lane], [to[0] + to_offset, to[1]]])
|
|
922
|
+
points << to unless to_offset.zero?
|
|
923
|
+
end
|
|
924
|
+
points
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def edge_breakout_offset(wire, x)
|
|
928
|
+
@edge_breakout_offsets ||= begin
|
|
929
|
+
groups = Hash.new { |hash, key| hash[key] = [] }
|
|
930
|
+
@board_edge_wires.each do |item|
|
|
931
|
+
first, last = endpoint_point(item.from), endpoint_point(item.to)
|
|
932
|
+
[first&.first, last&.first].compact.uniq.each { |position| groups[position] << item }
|
|
933
|
+
end
|
|
934
|
+
groups.transform_values do |peers|
|
|
935
|
+
peers.each_with_index.to_h { |item, index| [item, (index - (peers.length - 1) / 2.0) * 0.4] }
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
@edge_breakout_offsets.fetch(x, {}).fetch(wire, 0)
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
def offboard_terminal_wires(side)
|
|
942
|
+
@offboard_terminal_wires[side] ||= @circuit.wires.select do |wire|
|
|
943
|
+
next false unless wire.route == "edge"
|
|
944
|
+
module_from = offboard_component(wire.from)
|
|
945
|
+
module_to = offboard_component(wire.to)
|
|
946
|
+
component = module_from || module_to
|
|
947
|
+
next false unless component && (component.attrs[:side].to_s == "left" ? "left" : "right") == side
|
|
948
|
+
@circuit.board.hole(module_from ? wire.to : wire.from)&.kind == :terminal
|
|
949
|
+
end
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
def offboard_rail_tracks(side, target_y)
|
|
953
|
+
@offboard_rail_tracks ||= begin
|
|
954
|
+
groups = Hash.new { |hash, key| hash[key] = [] }
|
|
955
|
+
@circuit.wires.each do |wire|
|
|
956
|
+
next unless wire.route == "edge"
|
|
957
|
+
module_from = offboard_component(wire.from)
|
|
958
|
+
module_to = offboard_component(wire.to)
|
|
959
|
+
component = module_from || module_to
|
|
960
|
+
next unless component
|
|
961
|
+
board_endpoint = module_from ? wire.to : wire.from
|
|
962
|
+
next unless @circuit.board.hole(board_endpoint)&.kind == :rail
|
|
963
|
+
from, to = endpoint_point(wire.from), endpoint_point(wire.to)
|
|
964
|
+
next unless from && to
|
|
965
|
+
wire_side = component.attrs[:side].to_s == "left" ? "left" : "right"
|
|
966
|
+
y = (module_from ? to : from)[1]
|
|
967
|
+
groups[[wire_side, y]] << [wire, [from[0], to[0]].minmax]
|
|
968
|
+
end
|
|
969
|
+
groups.transform_values do |items|
|
|
970
|
+
assigned = {}
|
|
971
|
+
active, free_tracks, next_track = [], [], 0
|
|
972
|
+
items.sort_by { |wire, range| [range[0], @wire_indexes.fetch(wire)] }.each do |wire, range|
|
|
973
|
+
while active.any? && active.first[0] < range[0]
|
|
974
|
+
heap_push(free_tracks, [heap_pop(active)[1]])
|
|
975
|
+
end
|
|
976
|
+
track = if free_tracks.empty?
|
|
977
|
+
next_track.tap { next_track += 1 }
|
|
978
|
+
else
|
|
979
|
+
heap_pop(free_tracks)[0]
|
|
980
|
+
end
|
|
981
|
+
heap_push(active, [range[1], track])
|
|
982
|
+
assigned[wire] = track
|
|
983
|
+
end
|
|
984
|
+
assigned
|
|
985
|
+
end
|
|
986
|
+
end
|
|
987
|
+
@offboard_rail_tracks.fetch([side, target_y], {})
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
def heap_push(heap, item)
|
|
991
|
+
child = heap.length
|
|
992
|
+
heap << item
|
|
993
|
+
while child.positive?
|
|
994
|
+
parent = (child - 1) / 2
|
|
995
|
+
break if (heap[parent] <=> item) <= 0
|
|
996
|
+
heap[child] = heap[parent]
|
|
997
|
+
child = parent
|
|
998
|
+
end
|
|
999
|
+
heap[child] = item
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
def heap_pop(heap)
|
|
1003
|
+
first = heap.first
|
|
1004
|
+
last = heap.pop
|
|
1005
|
+
return first if heap.empty?
|
|
1006
|
+
|
|
1007
|
+
parent = 0
|
|
1008
|
+
while (child = parent * 2 + 1) < heap.length
|
|
1009
|
+
child += 1 if child + 1 < heap.length && (heap[child + 1] <=> heap[child]).negative?
|
|
1010
|
+
break if (last <=> heap[child]) <= 0
|
|
1011
|
+
heap[parent] = heap[child]
|
|
1012
|
+
parent = child
|
|
1013
|
+
end
|
|
1014
|
+
heap[parent] = last
|
|
1015
|
+
first
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
def offboard_component(endpoint)
|
|
1019
|
+
parsed = Breadkit::HoleId.parse(endpoint, board: @circuit.board)
|
|
1020
|
+
component = @circuit.components[parsed.ref] if parsed.kind == :pin
|
|
1021
|
+
component if component&.part&.placement == "offboard"
|
|
1022
|
+
rescue ArgumentError
|
|
1023
|
+
nil
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
def offboard_bounds(component)
|
|
1027
|
+
x, y, width, height, = offboard_layout(component)
|
|
1028
|
+
[[x, y], [x + width, y + height]]
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
def offboard_layout(component)
|
|
1032
|
+
return @offboard_layouts[component] if @offboard_layouts.key?(component)
|
|
1033
|
+
|
|
1034
|
+
side = component.attrs[:side].to_s == "left" ? "left" : "right"
|
|
1035
|
+
pins = component.pins.length
|
|
1036
|
+
height = [(pins - 1) * OFFBOARD_PIN_PITCH + 1.5, 4.0].max
|
|
1037
|
+
pin_span = [pins - 1, 0].max * OFFBOARD_PIN_PITCH
|
|
1038
|
+
margin = (height - pin_span) / 2.0
|
|
1039
|
+
row_anchor = component.attrs[:at]
|
|
1040
|
+
anchor = Breadkit::HoleId.parse(row_anchor, board: @circuit.board) if row_anchor
|
|
1041
|
+
start = anchor&.kind == :terminal ? anchor.col - 1 - margin : automatic_offboard_start(component)
|
|
1042
|
+
|
|
1043
|
+
axis = @orientation == "portrait" ? :y : :x
|
|
1044
|
+
minimum, maximum = @board_extents.fetch(axis)
|
|
1045
|
+
depth = 7.0
|
|
1046
|
+
clearance = [6.0, 2.5 + [offboard_terminal_wires(side).length - 1, 0].max * 0.7].max
|
|
1047
|
+
position = side == "right" ? maximum + clearance : minimum - clearance - depth
|
|
1048
|
+
pin_edge = position + (side == "right" ? 0 : depth)
|
|
1049
|
+
|
|
1050
|
+
@offboard_layouts[component] = @orientation == "portrait" ? [start, position, height, depth, pin_edge] :
|
|
1051
|
+
[position, start, depth, height, pin_edge]
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
def automatic_offboard_start(component)
|
|
1055
|
+
side = component.attrs[:side].to_s == "left" ? "left" : "right"
|
|
1056
|
+
peers = @circuit.components.values.select do |item|
|
|
1057
|
+
item.part.placement == "offboard" && item.attrs[:side].to_s == side
|
|
1058
|
+
end
|
|
1059
|
+
gap = 1.2
|
|
1060
|
+
total = peers.sum { |item| [(item.pins.length - 1) * OFFBOARD_PIN_PITCH + 1.5, 4.0].max } + gap * (peers.length - 1)
|
|
1061
|
+
cursor = ((@orientation == "portrait" ? @circuit.board.width : @circuit.board.height) - total) / 2.0
|
|
1062
|
+
peers.each do |item|
|
|
1063
|
+
item_height = [(item.pins.length - 1) * OFFBOARD_PIN_PITCH + 1.5, 4.0].max
|
|
1064
|
+
return cursor if item.equal?(component)
|
|
1065
|
+
cursor += item_height + gap
|
|
1066
|
+
end
|
|
1067
|
+
cursor
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
def offboard_pin_point(component, index)
|
|
1071
|
+
x, y, width, height, pin_edge = offboard_layout(component)
|
|
1072
|
+
pins = component.pins.length
|
|
1073
|
+
span = @orientation == "portrait" ? width : height
|
|
1074
|
+
margin = (span - [pins - 1, 0].max * OFFBOARD_PIN_PITCH) / 2.0
|
|
1075
|
+
position = x + margin + index * OFFBOARD_PIN_PITCH if @orientation == "portrait"
|
|
1076
|
+
position = y + margin + index * OFFBOARD_PIN_PITCH if @orientation == "landscape"
|
|
1077
|
+
@orientation == "portrait" ? [position, pin_edge] : [pin_edge, position]
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
def wire_color(wire)
|
|
1081
|
+
if @color_by == "net"
|
|
1082
|
+
net = @nets_by_member[wire.id] || @nets_by_hole[wire.from]
|
|
1083
|
+
voltage = net && @circuit.potentials.values[net.name]
|
|
1084
|
+
return voltage.positive? ? @colors[:positive] : (voltage.negative? ? @colors[:negative_wire] : @colors[:text]) if voltage
|
|
1085
|
+
if net
|
|
1086
|
+
hash = net.name.each_byte.reduce(2_166_136_261) { |value, byte| ((value ^ byte) * 16_777_619) & 0xffffffff }
|
|
1087
|
+
return PALETTE[hash % PALETTE.length]
|
|
1088
|
+
end
|
|
1089
|
+
end
|
|
1090
|
+
PALETTE[@wire_indexes.fetch(wire) % PALETTE.length]
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
def resistor_bands(value, bands: 4)
|
|
1094
|
+
raise Error, "resistor bands must be 4 or 5" unless [4, 5].include?(bands)
|
|
1095
|
+
|
|
1096
|
+
number = begin
|
|
1097
|
+
Breadkit::Value.parse(value || 0)
|
|
1098
|
+
rescue ArgumentError
|
|
1099
|
+
return Array.new(bands - 1, "#a0522d") + ["#d4af37"]
|
|
1100
|
+
end
|
|
1101
|
+
return Array.new(bands - 1, "#a0522d") + ["#d4af37"] if number <= 0
|
|
1102
|
+
significant = bands - 2
|
|
1103
|
+
exponent = Math.log10(number).floor - significant + 1
|
|
1104
|
+
digits = (number / (10.0**exponent)).round
|
|
1105
|
+
if digits >= 10**significant
|
|
1106
|
+
digits /= 10
|
|
1107
|
+
exponent += 1
|
|
1108
|
+
end
|
|
1109
|
+
colors = %w[#000000 #8b4513 #ff0000 #ff8c00 #ffff00 #008000 #0000ff #800080 #808080 #ffffff]
|
|
1110
|
+
multiplier = exponent.negative? ? { -3 => "#ff69b4", -2 => "#c0c0c0", -1 => "#d4af37" }[exponent] : colors[exponent]
|
|
1111
|
+
raise Error, "resistor value is outside color-band range" unless multiplier
|
|
1112
|
+
|
|
1113
|
+
digits.digits.reverse.map { |digit| colors[digit] }.then do |result|
|
|
1114
|
+
result.unshift(colors[0]) until result.length == significant
|
|
1115
|
+
result + [multiplier, bands == 5 ? "#8b4513" : "#d4af37"]
|
|
1116
|
+
end
|
|
1117
|
+
end
|
|
1118
|
+
|
|
1119
|
+
def px(x)
|
|
1120
|
+
x.to_f * PITCH
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
def py(y)
|
|
1124
|
+
(@circuit.board.height - 1 - y.to_f) * PITCH
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
def fmt(value)
|
|
1128
|
+
format("%.2f", value.to_f)
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
def escape(value)
|
|
1132
|
+
CGI.escapeHTML(value.to_s)
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
def tag(name, attrs = nil, **options)
|
|
1136
|
+
attrs = (attrs || {}).merge(options)
|
|
1137
|
+
attributes = attrs.map { |key, value| "#{key.to_s.tr('_', '-')}=\"#{escape(value)}\"" }.join(" ")
|
|
1138
|
+
"<#{name} #{attributes}/>"
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
def rect(x, y, width, height, **attrs)
|
|
1142
|
+
tag("rect", { x: fmt(x), y: fmt(y), width: fmt(width), height: fmt(height) }.merge(attrs))
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
def circle(x, y, radius, **attrs)
|
|
1146
|
+
tag("circle", { cx: fmt(x), cy: fmt(y), r: fmt(radius) }.merge(attrs))
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
def line(x1, y1, x2, y2, color, width)
|
|
1150
|
+
tag("line", x1: fmt(x1), y1: fmt(y1), x2: fmt(x2), y2: fmt(y2), stroke: color, stroke_width: fmt(width))
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
def text(x, y, value, attrs = {})
|
|
1154
|
+
attrs = { "transform" => "rotate(-90 #{fmt(x)} #{fmt(y)})" }.merge(attrs) if @rotated_scene
|
|
1155
|
+
attributes = { x: fmt(x), y: fmt(y) }.merge(attrs).map { |key, item| "#{key.to_s.tr('_', '-')}=\"#{escape(item)}\"" }.join(" ")
|
|
1156
|
+
"<text #{attributes}>#{escape(value)}</text>"
|
|
1157
|
+
end
|
|
1158
|
+
end
|
|
1159
|
+
end
|
|
1160
|
+
end
|