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.
@@ -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