breadkit-render 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,10 @@ module Breadkit
7
7
  class SvgRenderer
8
8
  PITCH = 10.0
9
9
  RAIL_PATTERNS = %w[+--+ +-+- -+-+ -++-].freeze
10
+ LABEL_DENSITIES = %w[full compact none].freeze
10
11
  OFFBOARD_PIN_PITCH = 1.0
11
12
  PALETTE = %w[#d62728 #1f77b4 #2ca02c #9467bd #ff7f0e #17becf].freeze
13
+ COLORBLIND_PALETTE = %w[#005b96 #a33d00 #006a4e #8a3c68 #176780 #704f9a].freeze
12
14
  MODULE_PIN_COLORS = {
13
15
  "power" => "#E24B4A", "ground" => "#888780", "clock" => "#EF9F27",
14
16
  "data" => "#378ADD", "interrupt" => "#D4537E", "address" => "#888780"
@@ -37,7 +39,7 @@ module Breadkit
37
39
  switch_bg: "#ece7dd", switch_border: "#504c45", switch_button: "#c7c0b5", switch_button_border: "#6c655b",
38
40
  text: "#25312b", label: "#627168", positive: "#cf5955", negative: "#71847e", negative_wire: "#315ea8", lead: "#68766f",
39
41
  component_bg: "#e8eeea", component_border: "#aab7af", module_bg: "#f1efe8",
40
- module_border: "#888780", muted: "#92918b", accent: "#1D6B45" },
42
+ module_border: "#888780", muted: "#66665f", accent: "#1D6B45" },
41
43
  "dark" => { board: "#202a27", border: "#52625b", groove: "#141c19", hole: "#718178", used_hole: "#d4e2d9",
42
44
  connected_hole: "#9bab9e", resistor_bg: "#6b6248", resistor_border: "#c9ac73",
43
45
  diode_bg: "#4b5550", diode_border: "#aab7af", diode_mark: "#dde6df",
@@ -45,7 +47,7 @@ module Breadkit
45
47
  switch_bg: "#3d4540", switch_border: "#819188", switch_button: "#58665d", switch_button_border: "#a4b6a9",
46
48
  text: "#e8efeb", label: "#c0cdc5", positive: "#ed7168", negative: "#99aaa4", negative_wire: "#76a9f2", lead: "#91a199",
47
49
  component_bg: "#2d3935", component_border: "#607168", module_bg: "#343a3d",
48
- module_border: "#687176", muted: "#889095", accent: "#8fbea1" },
50
+ module_border: "#687176", muted: "#b1bfc2", accent: "#8fbea1" },
49
51
  "print" => { board: "#ffffff", border: "#555555", groove: "#eeeeee", hole: "#aaaaaa", used_hole: "#333333",
50
52
  connected_hole: "#777777", resistor_bg: "#dddddd", resistor_border: "#555555",
51
53
  diode_bg: "#555555", diode_border: "#333333", diode_mark: "#ffffff",
@@ -53,21 +55,47 @@ module Breadkit
53
55
  switch_bg: "#eeeeee", switch_border: "#444444", switch_button: "#cccccc", switch_button_border: "#444444",
54
56
  text: "#222222", label: "#444444", positive: "#333333", negative: "#777777", negative_wire: "#555555", lead: "#333333",
55
57
  component_bg: "#f4f4f4", component_border: "#555555", module_bg: "#f4f4f4",
56
- module_border: "#666666", muted: "#777777", accent: "#333333" }
57
- }.freeze
58
+ module_border: "#666666", muted: "#666666", accent: "#333333" }
59
+ }.tap do |palettes|
60
+ palettes["colorblind"] = palettes.fetch("light").merge(
61
+ board: "#f1f5f2", text: "#192923", label: "#43584a", positive: "#a33d00",
62
+ negative: "#53616a", negative_wire: "#005b96", accent: "#006a4e"
63
+ )
64
+ end.freeze
65
+
66
+ def render(circuit, crop: "auto", theme: "light", orientation: "portrait", show_nets: false, legend: false, color_by: "wire", annotations: [], rail_pattern: nil, interactive_layers: true, state: nil, active_layer: nil, focus: nil, highlight_net: nil, label_density: "full", diff_wires: {}, theme_colors: {}, font_css: nil, wire_routing: "declared", wire_style: "raised")
67
+ @multi_board = circuit.respond_to?(:multi_board?) && circuit.multi_board?
68
+ raise ArgumentError, "--rail-pattern is unavailable for multi-board circuits" if @multi_board && rail_pattern
69
+ raise ArgumentError, "unknown label density: #{label_density}" unless LABEL_DENSITIES.include?(label_density)
70
+ raise ArgumentError, "unknown wire routing: #{wire_routing}" unless %w[declared auto].include?(wire_routing)
71
+ raise ArgumentError, "unknown wire style: #{wire_style}" unless %w[raised flat].include?(wire_style)
58
72
 
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
73
  @circuit, @theme, @orientation, @show_nets, @legend_enabled, @color_by, @annotations = circuit, theme.to_s, orientation.to_s, show_nets, legend, color_by, annotations
74
+ @label_density = label_density
75
+ @wire_routing, @wire_style = wire_routing, wire_style
76
+ @state, @active_layer = state, active_layer&.to_s
77
+ @diff_wires = diff_wires
78
+ @nets = circuit.nets(state)
79
+ @focus_ref = focus&.to_s
80
+ raise ArgumentError, "unknown component: #{@focus_ref}" if @focus_ref && !circuit.components.key?(@focus_ref)
81
+ selected_net = @nets.find { |net| net.name == highlight_net.to_s } if highlight_net
82
+ raise ArgumentError, "unknown net: #{highlight_net}" if highlight_net && !selected_net
83
+ @emphasis_active = !!(@focus_ref || selected_net)
84
+ emphasized_nets = @nets.select { |net| net.members.any? { |member| member.start_with?("#{@focus_ref}.") } } if @focus_ref
85
+ emphasized_nets = emphasized_nets&.select { |net| net == selected_net } if selected_net && @focus_ref
86
+ emphasized_nets ||= [selected_net].compact
87
+ @emphasized_wire_ids = emphasized_nets.flat_map(&:members).to_set
88
+ @emphasized_component_refs = emphasized_nets.flat_map { |net| net.members.filter_map { |member| member.split(".", 2).first if member.include?(".") } }.to_set
61
89
  @wire_indexes = circuit.wires.each_with_index.to_h
62
90
  @wires_by_id = circuit.wires.to_h { |wire| [wire.id, wire] }
63
91
  @rail_definitions = Array(circuit.board.definition.data["rails"]).to_h { |rail| [rail["id"].to_s, rail] }
64
92
  @wire_endpoint_refs = circuit.wires.each_with_object({}) { |wire, refs| refs[wire.from] = refs[wire.to] = true }
65
93
  @nets_by_member, @nets_by_hole = {}, {}
66
- circuit.nets.each do |net|
94
+ @nets.each do |net|
67
95
  net.members.each { |member| @nets_by_member[member] = net }
68
96
  net.holes.each { |hole| @nets_by_hole[hole] = net }
69
97
  end
70
- @net_indexes = circuit.nets.each_with_index.to_h
98
+ @net_indexes = @nets.each_with_index.to_h
71
99
  @board_edge_wires = circuit.wires.select do |wire|
72
100
  wire.route == "edge" && !offboard_component(wire.from) && !offboard_component(wire.to)
73
101
  end
@@ -81,23 +109,36 @@ module Breadkit
81
109
  @offboard_rail_tracks = nil
82
110
  @offboard_layouts = {}
83
111
  @edge_breakout_offsets = @annotation_states = nil
112
+ @invalid_colors_warned = Set.new
84
113
  raise ArgumentError, "orientation must be portrait or landscape" unless %w[portrait landscape].include?(@orientation)
114
+ available_layers = circuit.wires.flat_map { |wire| layer_names(wire.layer) } +
115
+ circuit.components.values.flat_map { |component| layer_names(read(component.attrs, "layer")) }
116
+ raise ArgumentError, "unknown layer: #{@active_layer}" if @active_layer && !available_layers.include?(@active_layer)
85
117
  @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
118
+ available_layers.uniq
88
119
  else
89
120
  []
90
121
  end
122
+ @visible_wires = circuit.wires.select { |wire| visible_in_layer?(wire.layer) }
123
+ @visible_wire_ids = @visible_wires.map(&:id).to_set
124
+ @visible_components = circuit.components.values.select { |component| visible_in_layer?(read(component.attrs, "layer")) }
125
+ @auto_routes = {}
91
126
  @layer_controls_height = 0
92
127
  @rail_pattern = rail_pattern&.to_s
93
128
  configure_rail_pattern
129
+ @auto_router = OrthogonalRouter.new(auto_obstacles) if @wire_routing == "auto"
94
130
 
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 } } +
131
+ @colors = COLORS.fetch(@theme, COLORS.fetch("light")).merge(theme_colors)
132
+ @font_css = font_css
133
+ @used_holes = (@visible_components.flat_map { |component| component.pins.values.map(&:hole_id) } +
134
+ @visible_wires.flat_map { |wire| [wire.from, wire.to].filter_map { |endpoint| circuit.board.hole(endpoint)&.id } } +
99
135
  circuit.supplies.flat_map { |supply| [supply.plus, supply.minus] }).compact.to_set
100
- @view_box = view_box(crop)
136
+ @conductive_holes = if @active_layer
137
+ @nets.select { |net| visible_net?(net) }.flat_map(&:holes).to_set
138
+ else
139
+ @nets_by_hole.keys.to_set
140
+ end
141
+ @view_box = view_box(@multi_board ? "none" : crop)
101
142
  @layer_controls_height = layer_controls_height unless @layers.empty?
102
143
  left, top, width, height = @view_box
103
144
  extra_height = legend_height
@@ -109,9 +150,10 @@ module Breadkit
109
150
  "#{fmt(left)} #{fmt(top)} #{fmt(width)} #{fmt(height + @layer_controls_height + extra_height)}"]
110
151
  end
111
152
  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}\"" : ""}>",
153
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"#{fmt(output_width)}\" height=\"#{fmt(output_height)}\" viewBox=\"#{output_view_box}\" font-family=\"#{@font_css ? 'BreadkitEmbedded, ' : ''}Helvetica, Arial, 'Noto Sans JP', sans-serif\" fill=\"#{@colors[:text]}\" role=\"img\"#{@rail_pattern ? " data-rail-pattern=\"#{@rail_pattern}\"" : ""}#{@state ? " data-state=\"#{escape(@state.name)}\"" : ""}>",
113
154
  "<title>#{escape(circuit.title || "Breadkit circuit")}</title>",
114
- "<desc>Breadboard wiring diagram generated by Breadkit.</desc>"]
155
+ "<desc>#{escape((["Breadboard wiring diagram generated by Breadkit.", (@active_layer ? "Showing layer #{@active_layer}; netlist includes all layers." : nil)] + @nets.map { |net| "#{net.name}: #{net.members.join(', ')}" }).compact.join(' '))}</desc>"]
156
+ svg << "<style>#{@font_css}</style>" if @font_css
115
157
  transform = if @orientation == "portrait"
116
158
  "matrix(0 1 -1 0 #{fmt(top + height)} #{fmt(-left + @layer_controls_height)})"
117
159
  elsif @layer_controls_height.positive?
@@ -124,6 +166,8 @@ module Breadkit
124
166
  svg << "<g id=\"components\">#{components_svg}</g>"
125
167
  svg << "<g id=\"offboard\">#{offboard_svg}</g>"
126
168
  svg << "<g id=\"wires\">#{wires_svg}</g>"
169
+ labels = component_labels_overlay_svg
170
+ svg << "<g id=\"component-labels\">#{labels}</g>" unless labels.empty?
127
171
  svg << "<g id=\"labels\">#{labels_svg}</g>"
128
172
  svg << "<g id=\"nets\">#{nets_svg}</g><g id=\"annotations\">#{annotations_svg}</g>"
129
173
  svg << "</g>" if transform
@@ -168,6 +212,8 @@ module Breadkit
168
212
  end
169
213
 
170
214
  def rail_polarity(id)
215
+ return @circuit.board.rail_polarity(id) if @multi_board
216
+
171
217
  rail_definition(id)["polarity"]&.to_s || (id.end_with?("+", "-") ? id[-1] : nil)
172
218
  end
173
219
 
@@ -193,6 +239,8 @@ module Breadkit
193
239
  end
194
240
 
195
241
  def board_svg
242
+ return named_boards_svg if @multi_board
243
+
196
244
  x, y, width, height = board_bounds
197
245
  out = [rect(x, y, width, height, rx: 8, fill: @colors[:board], stroke: @colors[:border], stroke_width: 1)]
198
246
  ravine = Array(@circuit.board.definition.data.dig("terminal", "ravine_between"))
@@ -207,35 +255,82 @@ module Breadkit
207
255
  next unless id.start_with?("rail:")
208
256
  holes = ids.map { |hole_id| @circuit.board.hole(hole_id) }
209
257
  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)
258
+ out << rail_strip_svg(holes)
259
+ end
260
+ out.join
261
+ end
262
+
263
+ def named_boards_svg
264
+ board = @circuit.board
265
+ out = board.boards.map do |name, definition|
266
+ holes = board.holes.values.select { |hole| hole.id.start_with?("#{name}.") }
267
+ xs, ys = holes.map { |hole| px(hole.x) }, holes.map { |hole| py(hole.y) }
268
+ x, y = xs.min - PITCH * 1.5, ys.min - PITCH * 1.5
269
+ width, height = xs.max - xs.min + PITCH * 3, ys.max - ys.min + PITCH * 3
270
+ plate = [rect(x, y, width, height, rx: 8, fill: @colors[:board], stroke: @colors[:border], stroke_width: 1)]
271
+ ravine = Array(definition.definition.data.dig("terminal", "ravine_between"))
272
+ rows = ravine.map { |row| holes.find { |hole| hole.row == row.to_s } }
273
+ if rows.length == 2 && rows.all?
274
+ groove_y = rows.sum { |hole| py(hole.y) } / rows.length
275
+ plate << rect(x + PITCH / 2, groove_y - PITCH, width - PITCH, PITCH * 2,
276
+ rx: 2, fill: @colors[:groove])
277
+ end
278
+ plate << text(x + width - 17, y + 9, name, "font-size" => 8, "font-weight" => 700,
279
+ "fill" => @colors[:text], "text-anchor" => "middle")
280
+ "<g data-board=\"#{escape(name)}\">#{plate.join}</g>"
281
+ end
282
+ board.strips.each do |id, ids|
283
+ next unless id.include?("rail:")
284
+
285
+ holes = ids.map { |hole_id| board.hole(hole_id) }
286
+ next if holes.empty?
287
+ out << rail_strip_svg(holes)
216
288
  end
217
289
  out.join
218
290
  end
219
291
 
292
+ def rail_strip_svg(holes)
293
+ rail_id = holes.first.rail
294
+ color = { "+" => @colors[:positive], "-" => @colors[:negative] }.fetch(rail_polarity(rail_id), @colors[:label])
295
+ positions = holes.map { |hole| display_hole(hole) }
296
+ left, right = positions.map { |hole| px(hole.x) }.minmax
297
+ top, bottom = positions.map { |hole| py(hole.y) }.minmax
298
+ if bottom - top > right - left
299
+ rect((left + right) / 2 - 3, top - 3, 6, bottom - top + 6,
300
+ rx: 3, fill: color, opacity: 0.14, data_rail: rail_id)
301
+ else
302
+ rect(left - 3, (top + bottom) / 2 - 3, right - left + 6, 6,
303
+ rx: 3, fill: color, opacity: 0.14, data_rail: rail_id)
304
+ end
305
+ end
306
+
220
307
  def holes_svg
221
- @circuit.board.holes.values.map do |hole|
308
+ output = ["<defs><circle id=\"h\" r=\"1.3\"/><circle id=\"u\" r=\"1.8\"/></defs>",
309
+ "<style>#holes use{fill:#{@colors[:hole]};opacity:.75}#holes use.c{fill:#{@colors[:connected_hole]}}#holes use.o{fill:#{@colors[:used_hole]};opacity:1}</style>"]
310
+ output.concat(@circuit.board.holes.values.map do |hole|
222
311
  used = @used_holes.include?(hole.id)
223
312
  connected = @conductive_holes.include?(hole.id)
224
313
  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
314
+ dot = used ? "u" : "h"
315
+ class_name = used ? " class=\"o\" data-occupied=\"true\"" : (connected ? " class=\"c\"" : "")
316
+ class_name += " data-connected=\"true\"" if connected
317
+ attrs = "href=\"##{dot}\" x=\"#{coord(px(display.x))}\" y=\"#{coord(py(display.y))}\" data-hole=\"#{escape(hole.id)}\"#{class_name}"
318
+ attrs += " data-net=\"#{escape(@nets_by_hole[hole.id].name)}\"" if @nets_by_hole[hole.id]
319
+ title = [hole.id, @nets_by_hole[hole.id]&.name].compact.join(" / ")
320
+ "<use #{attrs}><title>#{escape(title)}</title></use>"
321
+ end)
322
+ output.join
231
323
  end
232
324
 
233
325
  def labels_svg
326
+ return named_board_labels_svg if @multi_board
327
+
234
328
  out = []
235
329
  number_y = @orientation == "portrait" ? py(-1) : py(@circuit.board.height)
236
330
  @circuit.board.definition.data.dig("terminal", "rows").each do |row|
237
331
  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
332
+ row_x = @orientation == "landscape" ? board_bounds.then { |left, _top, width, _height| left + width - 6 } : -7
333
+ out << text(row_x, py(y), row, "font-size" => 8, "font-weight" => 500, "fill" => @colors[:label], "text-anchor" => "middle") if y
239
334
  end
240
335
  (1..@circuit.board.width).each do |col|
241
336
  next unless col == 1 || (col % 5).zero?
@@ -250,6 +345,30 @@ module Breadkit
250
345
  out.join
251
346
  end
252
347
 
348
+ def named_board_labels_svg
349
+ out = []
350
+ @circuit.board.boards.each do |name, definition|
351
+ holes = @circuit.board.holes.values.select { |hole| hole.id.start_with?("#{name}.") }
352
+ terminal_holes = holes.select { |hole| hole.kind == :terminal }
353
+ left_x = terminal_holes.map(&:x).min
354
+ Array(definition.definition.data.dig("terminal", "rows")).each do |row|
355
+ hole = terminal_holes.find { |item| item.row == row.to_s }
356
+ out << text(px(left_x) - 7, py(hole.y), row, "font-size" => 8, "font-weight" => 500,
357
+ "fill" => @colors[:label], "text-anchor" => "middle") if hole
358
+ end
359
+ number_y = @orientation == "portrait" ? py(-1) : py(definition.height)
360
+ (1..definition.width).each do |col|
361
+ next unless col == 1 || (col % 5).zero?
362
+
363
+ number_x = px(left_x + col - 1) + (@orientation == "portrait" ? 5 : 0)
364
+ out << text(number_x + (@orientation == "portrait" ? 3 : 0), number_y, col,
365
+ "font-size" => @orientation == "portrait" ? 6 : 7, "font-weight" => 500,
366
+ "fill" => @colors[:text], "text-anchor" => "middle")
367
+ end
368
+ end
369
+ out.join
370
+ end
371
+
253
372
  def components_svg
254
373
  @circuit.components.values.map do |component|
255
374
  pins = component.pins.values.filter_map { |pin| [pin, @circuit.board.hole(pin.hole_id)] if pin.hole_id }
@@ -257,7 +376,20 @@ module Breadkit
257
376
  xs, ys = pins.map { |_pin, hole| px(display_hole(hole).x) }, pins.map { |_pin, hole| py(display_hole(hole).y) }
258
377
  center_x, center_y = (xs.min + xs.max) / 2.0, (ys.min + ys.max) / 2.0
259
378
  shape = component.part.data.dig("render", "shape")
260
- body = case shape
379
+ shape = "to92" if shape == "generic" && %w[transistor 2n3904 2n3906 bc547 bc557].include?(component.part.id)
380
+ shape = "potentiometer" if shape == "generic" && component.part.id == "pot"
381
+ template = component.part.data.dig("render", "svg")
382
+ body = if rail_module?(component)
383
+ rail_module_svg(component, pins)
384
+ elsif template
385
+ values = { "ref" => component.ref, "value" => component.value,
386
+ "fill" => component.part.data.dig("render", "fill") || @colors[:component_bg],
387
+ "stroke" => component.part.data.dig("render", "stroke") || @colors[:component_border],
388
+ "text_color" => component.part.data.dig("render", "text_color") || @colors[:text] }
389
+ lead_lines(pins, center_x, center_y) +
390
+ %(<g data-part-template="#{escape(component.part.id)}" transform="translate(#{fmt(center_x)} #{fmt(center_y)})">#{SvgTemplate.new(template).render(values)}</g>)
391
+ else
392
+ case shape
261
393
  when "resistor" then resistor_svg(component, pins, center_x, center_y)
262
394
  when "led_5mm" then led_svg(component, pins, center_x, center_y)
263
395
  when "diode" then diode_svg(component, pins, center_x, center_y)
@@ -265,9 +397,16 @@ module Breadkit
265
397
  when "dip" then dip_svg(component, pins)
266
398
  when "module" then module_svg(component, pins)
267
399
  when "tact_switch" then switch_svg(component, pins, center_x, center_y)
400
+ when "seven_segment" then seven_segment_svg(component, pins, center_x, center_y)
401
+ when "rgb_led_5mm" then rgb_led_svg(component, pins, center_x, center_y)
402
+ when "to92" then to92_svg(component, pins, center_x, center_y)
403
+ when "potentiometer" then potentiometer_svg(component, pins, center_x, center_y)
268
404
  else generic_svg(component, pins, center_x, center_y)
405
+ end
269
406
  end
270
- body = "<g data-ref=\"#{escape(component.ref)}\">#{body}</g>"
407
+ opacity = @emphasis_active && !emphasized_component?(component.ref) ? " opacity=\"0.18\"" : ""
408
+ switch = Array(component.part.data["switch"]).empty? ? "" : " data-switch=\"#{escape(component.ref)}\""
409
+ body = "<g data-ref=\"#{escape(component.ref)}\"#{switch}#{opacity}>#{body}</g>"
271
410
  layer_group(body, read(component.attrs, "layer"))
272
411
  end.compact.join
273
412
  end
@@ -277,27 +416,95 @@ module Breadkit
277
416
  out << rect(x - 12, y - 4, 24, 8, rx: 3, fill: @colors[:resistor_bg], stroke: @colors[:resistor_border], data_ref: component.ref)
278
417
  bands = resistor_bands(component.value, bands: component.attrs[:bands] || 4)
279
418
  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
419
  out.join
282
420
  end
283
421
 
422
+ def component_labels_overlay_svg
423
+ return "" if @label_density == "none"
424
+
425
+ @label_wire_bounds = @visible_wires.filter_map do |wire|
426
+ from, to = endpoint_point(wire.from), endpoint_point(wire.to)
427
+ next unless from && to
428
+
429
+ wire_route_points(wire, from, to).each_cons(2).map do |first, last|
430
+ [*([px(first[0]), px(last[0])].minmax), *([py(first[1]), py(last[1])].minmax)]
431
+ end
432
+ end
433
+ @visible_components.filter_map do |component|
434
+ holes = component.pins.values.filter_map { |pin| @circuit.board.hole(pin.hole_id) }
435
+ next if holes.empty?
436
+
437
+ xs, ys = holes.map { |hole| px(display_hole(hole).x) }, holes.map { |hole| py(display_hole(hole).y) }
438
+ x, y = xs.minmax.sum / 2.0, ys.minmax.sum / 2.0
439
+ shape = component.part.data.dig("render", "shape")
440
+ label, baseline, font_size, background, foreground = case shape
441
+ when "resistor" then [value_label(component, shape), y - 7, 5, @colors[:board], @colors[:text]]
442
+ when "led_5mm" then [component.ref, y - 8, 5, @colors[:board], @colors[:text]]
443
+ when "diode" then [component.ref, y - 5, 4.5, @colors[:board], @colors[:text]]
444
+ when "capacitor", "electrolytic" then [value_label(component, shape), y - 6, 4.5, @colors[:board], @colors[:text]]
445
+ when "dip" then [component.part.data.dig("render", "label") || component.ref, y + 1.5, 4.5, @colors[:dip_bg], @colors[:dip_text]]
446
+ else next
447
+ end
448
+ width = [label.to_s.length * font_size * 0.62 + 6, 13].max
449
+ baseline = clear_label_baseline(x, baseline, width, font_size)
450
+ background_options = { rx: 2, fill: background }
451
+ background_options[:transform] = "rotate(-90 #{fmt(x)} #{fmt(baseline)})" if @rotated_scene
452
+ opacity = @emphasis_active && !emphasized_component?(component.ref) ? " opacity=\"0.18\"" : ""
453
+ body = rect(x - width / 2, baseline - font_size - 2, width, font_size + 4, **background_options)
454
+ body += text(x, baseline, label, "font-size" => font_size, "font-weight" => 500,
455
+ "fill" => foreground, "text-anchor" => "middle", "paint-order" => "stroke",
456
+ "stroke" => background, "stroke-width" => 1.5, "stroke-linejoin" => "round")
457
+ layer_group("<g data-ref=\"#{escape(component.ref)}\"#{opacity}>#{body}</g>", read(component.attrs, "layer"))
458
+ end.join
459
+ end
460
+
461
+ def value_label(component, shape)
462
+ return component.ref if @label_density == "compact" || !component.value
463
+
464
+ "#{component.ref} #{Breadkit::Value.new(component.value, category: shape)}"
465
+ end
466
+
467
+ def clear_label_baseline(x, baseline, width, font_size)
468
+ near = width / 2 + 5
469
+ far = width / 2 + 12
470
+ [baseline, baseline - near, baseline + near, baseline - far, baseline + far].min_by do |candidate|
471
+ bounds = label_bounds(x, candidate, width, font_size)
472
+ view_left, view_top, view_width, view_height = @view_box
473
+ clipped = bounds[0] < view_left || bounds[1] < view_top ||
474
+ bounds[2] > view_left + view_width || bounds[3] > view_top + view_height
475
+ [clipped ? 1 : 0, label_wire_overlaps(bounds), (candidate - baseline).abs]
476
+ end
477
+ end
478
+
479
+ def label_bounds(x, baseline, width, font_size)
480
+ return [x - font_size - 2, baseline - width / 2, x + 2, baseline + width / 2] if @rotated_scene
481
+
482
+ [x - width / 2, baseline - font_size - 2, x + width / 2, baseline + 2]
483
+ end
484
+
485
+ def label_wire_overlaps(bounds)
486
+ left, top, right, bottom = bounds
487
+ @label_wire_bounds.count do |segments|
488
+ segments.any? do |x1, x2, y1, y2|
489
+ x1 <= right + 1 && x2 >= left - 1 && y1 <= bottom + 1 && y2 >= top - 1
490
+ end
491
+ end
492
+ end
493
+
284
494
  def led_svg(component, pins, x, y)
285
495
  out = [lead_lines(pins, x, y)]
286
496
  color = component.attrs[:color].to_s.downcase
287
497
  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
498
+ color = render_color(color, "LED #{component.ref}") { "red" }
290
499
  color = { "red" => "#df5550", "green" => "#5fbf69", "blue" => "#6a9df0", "yellow" => "#f0d958" }.fetch(color, color)
291
500
  out << circle(x, y, 5.5, fill: color, stroke: @colors[:component_border], data_ref: component.ref)
292
501
  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
502
  out.join
295
503
  end
296
504
 
297
505
  def diode_svg(component, pins, x, y)
298
506
  [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
507
+ polarity_marker(component, pins, x, y, 3.0, @colors[:diode_mark])].join
301
508
  end
302
509
 
303
510
  def capacitor_svg(component, pins, x, y, electrolytic:)
@@ -310,7 +517,6 @@ module Breadkit
310
517
  offset_y = offset_x.zero? ? (positive && positive[1] > y ? 5 : -5) : 0
311
518
  out << text(x + offset_x, y + offset_y + 1.5, "+", "font-size" => 4, "text-anchor" => "middle")
312
519
  end
313
- out << text(x, y - 6, component.ref, "font-size" => 4.5, "font-weight" => 500, "text-anchor" => "middle")
314
520
  out.join
315
521
  end
316
522
 
@@ -322,11 +528,11 @@ module Breadkit
322
528
  notch_x = first_pin && px(first_pin.x) > xs.sum / xs.length ? x2 - 2 : x1 + 2
323
529
  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
530
  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
531
  pins.each do |pin, hole|
328
532
  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")
533
+ number = text(px(display.x), py(display.y) + (display.y > 4 ? 4 : -2), pin.number,
534
+ "font-size" => 3.5, "text-anchor" => "middle")
535
+ out << %(<g data-dip-pin="#{escape(component.ref)}.#{escape(pin.name)}"><title>Pin #{escape(pin.number)}: #{escape(pin.name)}</title>#{number}</g>)
330
536
  end
331
537
  out.join
332
538
  end
@@ -340,6 +546,9 @@ module Breadkit
340
546
  stroke: render["stroke"] || @colors[:module_border], stroke_width: 1,
341
547
  data_ref: component.ref)]
342
548
  pin_definitions = component.part.data["pins"].to_h { |definition| [definition["num"].to_s, definition] }
549
+ pad_points = pins.map { |_pin, hole| display_hole(hole).then { |display| [px(display.x), py(display.y)] } }
550
+ rotated_footprint = [90, 270].include?(component.attrs[:rotate])
551
+ portrait_labels, landscape_labels = [], []
343
552
  pins.each do |pin, hole|
344
553
  display = display_hole(hole)
345
554
  pin_x, pin_y = px(display.x), py(display.y)
@@ -352,11 +561,42 @@ module Breadkit
352
561
  label = definition && definition["label"]
353
562
  next unless label
354
563
  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)
564
+ length = label.to_s.length * 5.8 * 0.6
565
+ label_transform = nil
566
+ if @orientation == "landscape" && width > height * 1.5 && pins.length >= 8
567
+ inward = center_y > pin_y
568
+ label_x, label_y = pin_x, pin_y + (inward ? 6 : -6)
569
+ label_anchor = inward ? "start" : "end"
570
+ label_transform = "rotate(90 #{fmt(label_x)} #{fmt(label_y)})"
571
+ elsif @orientation == "landscape" && rotated_footprint
572
+ label_x = pin_x
573
+ label_y = [pin_y - 9, pin_y + 9, pin_y - 18, pin_y + 18].find do |candidate|
574
+ candidate.between?(y + 4, y + height - 4) &&
575
+ landscape_labels.none? { |other_x, other_y, other_length| (label_x - other_x).abs < (length + other_length) / 2 &&
576
+ (candidate - other_y).abs < 5.8 } &&
577
+ pad_points.none? { |pad_x, pad_y| (label_x - pad_x).abs < length / 2 + 3.3 && (candidate - pad_y).abs < 6.2 }
578
+ end || pin_y
579
+ landscape_labels << [label_x, label_y, length]
580
+ label_anchor = "middle"
581
+ elsif @orientation == "portrait" && rotated_footprint
582
+ label_y = pin_y - length / 2 - 9
583
+ label_x = [pin_x, pin_x - 9, pin_x + 9, pin_x - 18, pin_x + 18].find do |candidate|
584
+ candidate.between?(x + 3, x + width - 3) &&
585
+ portrait_labels.none? { |other_x, other_y, other_length| (candidate - other_x).abs < 5.8 &&
586
+ (label_y - other_y).abs < (length + other_length) / 2 } &&
587
+ pad_points.none? { |pad_x, pad_y| (candidate - pad_x).abs < 6.2 && (label_y - pad_y).abs < length / 2 + 3.3 }
588
+ end || pin_x
589
+ portrait_labels << [label_x, label_y, length]
590
+ label_anchor = "middle"
591
+ else
592
+ label_gap = @orientation == "portrait" ? 10 : 3.8
593
+ label_x, label_y = pin_x, pin_y + (center_y > pin_y ? label_gap : -label_gap)
594
+ label_anchor = @orientation == "portrait" ? (center_y > pin_y ? "end" : "start") : "middle"
595
+ end
596
+ attrs = { "font-size" => 5.8, "font-weight" => 500, "fill" => render["text_color"] || @colors[:text],
597
+ "text-anchor" => label_anchor }
598
+ attrs["transform"] = label_transform if label_transform
599
+ out << text(label_x, label_y, label, attrs)
360
600
  end
361
601
  label = render["label"] || component.ref
362
602
  font_size = [[ [width, height].min / 6, 6].max, 10].min
@@ -377,6 +617,8 @@ module Breadkit
377
617
  end
378
618
 
379
619
  def module_bounds(component, pins)
620
+ return component.body_bounds(@circuit.board) if component.respond_to?(:body_bounds)
621
+
380
622
  xs, ys = pins.map { |_pin, hole| hole.x }, pins.map { |_pin, hole| hole.y }
381
623
  render = component.part.data.fetch("render")
382
624
  width_mm, height_mm = render.fetch("size_mm").map { |value| Float(value) }
@@ -388,10 +630,64 @@ module Breadkit
388
630
  [center_x - width / 2, center_y - height / 2, width, height]
389
631
  end
390
632
 
633
+ def rail_module?(component)
634
+ definitions = component.part.data["pins"] || []
635
+ !definitions.empty? && definitions.all? { |pin| pin["mount"] == "rail" }
636
+ end
637
+
638
+ def rail_module_bounds(pins)
639
+ xs = pins.map { |_pin, hole| hole.x }
640
+ ys = pins.map { |_pin, hole| hole.y }
641
+ board_left, board_right = @board_extents.fetch(:x)
642
+ width, height = 19.0, 11.0
643
+ x = xs.sum / xs.length.to_f <= (board_left + board_right) / 2.0 ? board_left - 21.5 : board_right + 2.5
644
+ [x, (ys.min + ys.max) / 2.0 - height / 2, width, height]
645
+ end
646
+
647
+ def rail_pin_label(pin, index)
648
+ { "LEFT_POS" => "L+", "LEFT_GND" => "L−", "RIGHT_POS" => "R+", "RIGHT_GND" => "R−" }.fetch(pin.name) { ("A".ord + index).chr }
649
+ end
650
+
651
+ def rail_module_svg(component, pins)
652
+ x, y, width, height = rail_module_bounds(pins)
653
+ title = component.part.data.dig("render", "label") || component.part.id
654
+ out = ["<title>#{escape(title)} · logical pin map; verify physical fit</title>",
655
+ rect(px(x), py(y + height), px(width), px(height), rx: 3,
656
+ fill: @colors[:module_bg], stroke: @colors[:module_border], stroke_width: 1,
657
+ data_ref: component.ref)]
658
+ out << text(px(x + 0.8), py(y + height - 1.4), component.ref, "font-size" => 13,
659
+ "font-weight" => 700, "fill" => @colors[:text])
660
+ out << text(px(x + 0.8), py(y + height - 2.5), "Logical pins · verify fit", "font-size" => 10,
661
+ "fill" => @colors[:label])
662
+ master = read(component.attrs, "master")
663
+ left = read(component.attrs, "left")
664
+ right = read(component.attrs, "right")
665
+ if master || left || right
666
+ mode = ->(value) { { "v3_3" => "3.3 V", "v5" => "5 V", "off" => "OFF" }.fetch(value.to_s, value.to_s) }
667
+ out << text(px(x + 0.8), py(y + height - 3.5), "Master #{master.to_s.upcase} · Left #{mode.call(left)}", "font-size" => 9.2,
668
+ "fill" => @colors[:text])
669
+ out << text(px(x + 0.8), py(y + height - 4.4), "Right #{mode.call(right)}", "font-size" => 9.2,
670
+ "fill" => @colors[:text])
671
+ end
672
+ pins.each_with_index do |(pin, hole), index|
673
+ display = display_hole(hole)
674
+ color = MODULE_PIN_COLORS.fetch(pin.role.to_s, @colors[:lead])
675
+ short = rail_pin_label(pin, index)
676
+ out << circle(px(display.x), py(display.y), 6, fill: color, stroke: @colors[:module_border],
677
+ stroke_width: 0.8, data_pin: "#{component.ref}.#{pin.name}")
678
+ out << text(px(display.x), py(display.y) + 2.2, short, "font-size" => 6.5,
679
+ "font-weight" => 700, "fill" => "#ffffff", "text-anchor" => "middle")
680
+ out << text(px(x + 0.8), py(y + height - 6.0 - index),
681
+ "#{short} #{pin.name} #{hole.id}", "font-size" => 10, "fill" => @colors[:text])
682
+ end
683
+ %(<g data-rail-module="logical">#{out.join}</g>)
684
+ end
685
+
391
686
  def switch_svg(component, pins, x, y)
392
687
  out = [lead_lines(pins, x, y)]
393
688
  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])
689
+ closed = @state&.closed_switches&.any? { |item, _pair| item.equal?(component) }
690
+ out << circle(x, y, 5, fill: closed ? @colors[:accent] : @colors[:switch_button], stroke: @colors[:switch_button_border])
395
691
  out << text(x, y - 13, component.ref, "font-size" => 5, "font-weight" => 500, "text-anchor" => "middle")
396
692
  out.join
397
693
  end
@@ -403,6 +699,53 @@ module Breadkit
403
699
  out.join
404
700
  end
405
701
 
702
+ def to92_svg(component, pins, x, y)
703
+ out = [lead_lines(pins, x, y)]
704
+ path = "M #{fmt(x - 8)} #{fmt(y - 4)} H #{fmt(x + 8)} V #{fmt(y)} A 8 8 0 0 1 #{fmt(x - 8)} #{fmt(y)} Z"
705
+ out << tag("path", d: path, fill: @colors[:dip_bg], stroke: @colors[:dip_border], stroke_width: 1,
706
+ data_shape: "to92")
707
+ out << text(x, y + 1.5, component.ref, "font-size" => 4.5, "fill" => @colors[:dip_text], "text-anchor" => "middle")
708
+ out.join
709
+ end
710
+
711
+ def potentiometer_svg(component, pins, x, y)
712
+ out = [lead_lines(pins, x, y)]
713
+ out << rect(x - 12, y - 8, 24, 16, rx: 2, fill: @colors[:component_bg], stroke: @colors[:component_border])
714
+ out << circle(x, y, 6, fill: @colors[:switch_button], stroke: @colors[:switch_button_border],
715
+ data_shape: "potentiometer")
716
+ out << line(x - 3, y + 3, x + 3, y - 3, @colors[:switch_button_border], 1.2)
717
+ out << text(x, y - 11, component.ref, "font-size" => 4.5, "text-anchor" => "middle")
718
+ out.join
719
+ end
720
+
721
+ def seven_segment_svg(component, pins, x, y)
722
+ out = [lead_lines(pins, x, y)]
723
+ out << rect(x - 13, y - 23, 26, 46, rx: 2, fill: @colors[:component_bg],
724
+ stroke: @colors[:component_border], data_ref: component.ref)
725
+ out << rect(x - 9, y - 19, 18, 38, rx: 1, fill: "#201c20")
726
+ segments = {
727
+ "A" => [x - 6, y - 16, 12, 2], "G" => [x - 6, y - 1, 12, 2], "D" => [x - 6, y + 14, 12, 2],
728
+ "F" => [x - 8, y - 14, 2, 12], "B" => [x + 6, y - 14, 2, 12],
729
+ "E" => [x - 8, y + 2, 2, 12], "C" => [x + 6, y + 2, 2, 12]
730
+ }
731
+ segments.each do |name, (left, top, width, height)|
732
+ out << rect(left, top, width, height, rx: 0.5, fill: "#d96960", opacity: 0.45, data_segment: name)
733
+ end
734
+ out << circle(x + 6, y + 16, 1, fill: "#d96960", opacity: 0.45, data_segment: "DP")
735
+ out << text(x, y - 26, component.ref, "font-size" => 5, "text-anchor" => "middle")
736
+ out.join
737
+ end
738
+
739
+ def rgb_led_svg(component, pins, x, y)
740
+ out = [lead_lines(pins, x, y)]
741
+ out << circle(x, y, 7, fill: @colors[:component_bg], stroke: @colors[:component_border], data_ref: component.ref)
742
+ { "R" => [-3.3, "#e35d59"], "G" => [0, "#66c786"], "B" => [3.3, "#6a9df0"] }.each do |channel, (offset, color)|
743
+ out << circle(x + offset, y, 2.4, fill: color, opacity: 0.75, data_channel: channel)
744
+ end
745
+ out << text(x, y - 10, component.ref, "font-size" => 5, "text-anchor" => "middle")
746
+ out.join
747
+ end
748
+
406
749
  def offboard_svg
407
750
  @circuit.components.values.filter_map do |component|
408
751
  next unless component.part.placement == "offboard"
@@ -428,7 +771,8 @@ module Breadkit
428
771
  pin_x, pin_y = offboard_pin_point(component, index)
429
772
  pin_color = MODULE_PIN_COLORS.fetch(pin.role.to_s, @colors[:lead])
430
773
  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)
774
+ stroke_width: 0.6, data_pin: "#{component.ref}.#{pin.name}", data_pin_type: pin.role,
775
+ data_net: @nets_by_member["#{component.ref}.#{pin.name}"]&.name)
432
776
  references = ["#{component.ref}.#{pin.name}", "#{component.ref}.#{pin.number}"]
433
777
  connected = references.any? { |reference| @wire_endpoint_refs.key?(reference) }
434
778
  label_side = component.attrs[:side].to_s == "left"
@@ -446,7 +790,8 @@ module Breadkit
446
790
  out << text(label_x, label_y, pin.name, "font-size" => 5.4, "fill" => connected ? @colors[:text] : @colors[:muted],
447
791
  "text-anchor" => anchor, data_pin_label: "#{component.ref}.#{pin.name}")
448
792
  end
449
- layer_group(out.join, read(component.attrs, "layer"))
793
+ opacity = @emphasis_active && !emphasized_component?(component.ref) ? " opacity=\"0.18\"" : ""
794
+ layer_group("<g data-ref=\"#{escape(component.ref)}\"#{opacity}>#{out.join}</g>", read(component.attrs, "layer"))
450
795
  end.join
451
796
  end
452
797
 
@@ -481,15 +826,10 @@ module Breadkit
481
826
  @circuit.wires.each do |wire|
482
827
  from, to = endpoint_point(wire.from), endpoint_point(wire.to)
483
828
  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
829
+ chosen = @color_by == "net" ? wire_color(wire) : (wire.color || wire_color(wire))
830
+ color = @theme == "print" ? "#222222" : render_color(chosen, "wire #{wire.id}") { wire_color(wire) }
831
+ color = { "removed" => "#d94c48", "added" => "#23a86b" }.fetch(@diff_wires[wire.id], color)
832
+ path = wire_path(wire, from, to)
493
833
  dash = if wire.dashed
494
834
  " stroke-dasharray=\"5 4\""
495
835
  elsif @theme == "print"
@@ -497,26 +837,63 @@ module Breadkit
497
837
  else
498
838
  ""
499
839
  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}/>"
840
+ casing = @wire_style == "flat" ? "" : "<path d=\"#{path}\" fill=\"none\" stroke=\"#{@colors[:board]}\" stroke-width=\"3.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>"
841
+ net_attr = @nets_by_member[wire.id] ? " data-net=\"#{escape(@nets_by_member[wire.id].name)}\"" : ""
842
+ diff_attr = @diff_wires[wire.id] ? " data-diff=\"#{@diff_wires[wire.id]}\"" : ""
843
+ wire_svg = "<path d=\"#{path}\" fill=\"none\" stroke=\"#{escape(color)}\" stroke-width=\"#{@wire_style == 'flat' ? '1.8' : '2.4'}\" stroke-linecap=\"round\" stroke-linejoin=\"round\" data-ref=\"#{escape(wire.id)}\"#{net_attr}#{diff_attr}#{dash}/>"
502
844
  dots = [from, to].uniq.map do |x, y|
503
845
  circle(px(x), py(y), 2.8, fill: color, stroke: @colors[:board], stroke_width: 1.2,
504
846
  data_wire: wire.id)
505
847
  end
506
848
  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)
849
+ casings << layer_group(emphasis_group(casing, wire.id), wire.layer, alternative: alternative)
850
+ lines << layer_group(emphasis_group(wire_svg, wire.id), wire.layer, alternative: alternative)
851
+ markers << layer_group(emphasis_group(dots.join, wire.id), wire.layer, alternative: alternative)
510
852
  end
511
853
  (casings + lines + markers).join
512
854
  end
513
855
 
856
+ def wire_path(wire, from, to)
857
+ if wire.route == "arc"
858
+ control_y = (py(from[1]) + py(to[1])) / 2.0 - 8
859
+ "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]))}"
860
+ elsif wire.route == "edge"
861
+ edge_route(wire, from, to)
862
+ elsif @wire_routing == "auto" && !offboard_component(wire.from) && !offboard_component(wire.to)
863
+ points = wire_route_points(wire, from, to)
864
+ "M #{points.map { |x, y| "#{fmt(px(x))} #{fmt(py(y))}" }.join(' L ')}"
865
+ else
866
+ "M #{fmt(px(from[0]))} #{fmt(py(from[1]))} L #{fmt(px(to[0]))} #{fmt(py(to[1]))}"
867
+ end
868
+ end
869
+
514
870
  def layer_names(value)
515
871
  Array(value).filter_map { |name| name.to_s unless name.nil? || name.to_s.empty? }
516
872
  end
517
873
 
874
+ def emphasized_component?(ref)
875
+ @focus_ref ? ref == @focus_ref : @emphasized_component_refs.include?(ref)
876
+ end
877
+
878
+ def emphasis_group(svg, wire_id)
879
+ return svg unless @emphasis_active && !@emphasized_wire_ids.include?(wire_id)
880
+
881
+ "<g opacity=\"0.18\">#{svg}</g>"
882
+ end
883
+
884
+ def visible_in_layer?(names)
885
+ layers = layer_names(names)
886
+ !@active_layer || layers.empty? || layers.include?(@active_layer)
887
+ end
888
+
889
+ def visible_net?(net)
890
+ !@active_layer || net.holes.any? { |id| @used_holes.include?(id) } ||
891
+ net.members.any? { |member| @visible_wire_ids.include?(member) }
892
+ end
893
+
518
894
  def layer_group(content, names, alternative: false)
519
895
  names = layer_names(names)
896
+ return names.include?(@active_layer) ? content : "" if @active_layer && !names.empty?
520
897
  return content if names.empty?
521
898
 
522
899
  attributes = "data-layers=\"#{escape(JSON.generate(names))}\""
@@ -610,7 +987,7 @@ module Breadkit
610
987
 
611
988
  def nets_svg
612
989
  return "" unless @show_nets
613
- @circuit.nets.map do |net|
990
+ @nets.select { |net| visible_net?(net) }.map do |net|
614
991
  hole = @circuit.board.hole(net.holes.first)
615
992
  next unless hole
616
993
  display = display_hole(hole)
@@ -636,6 +1013,11 @@ module Breadkit
636
1013
  out << text(x + 15, y, net.name, "font-size" => 5.5)
637
1014
  y += 9
638
1015
  end
1016
+ dip_pin_map_lines.each do |pin_line|
1017
+ out << text(x, y, pin_line, "font-size" => 5.5)
1018
+ y += 7
1019
+ end
1020
+ y += 2 unless dip_pin_map_lines.empty?
639
1021
  end
640
1022
  @annotations.each_with_index do |item, index|
641
1023
  message = read(item, "message") || read(item, "rule")
@@ -652,11 +1034,35 @@ module Breadkit
652
1034
  return "#222222" if @theme == "print"
653
1035
 
654
1036
  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]
1037
+ chosen = @color_by == "net" ? wire_color(wire) : (wire&.color || wire_color(wire)) if wire
1038
+ wire ? render_color(chosen, "wire #{wire.id}") { wire_color(wire) } : wire_palette[@net_indexes.fetch(net) % wire_palette.length]
1039
+ end
1040
+
1041
+ def render_color(value, context)
1042
+ valid = if Breadkit.const_defined?(:Color)
1043
+ Breadkit::Color.valid?(value)
1044
+ else
1045
+ CSS_COLOR_NAMES.include?(value.to_s.downcase) || /\A#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\z/i.match?(value.to_s)
1046
+ end
1047
+ return value if valid
1048
+
1049
+ fallback = yield
1050
+ unless @invalid_colors_warned.include?(context)
1051
+ warn "bkrender: invalid color #{value.inspect} for #{context}; using #{fallback}"
1052
+ @invalid_colors_warned << context
1053
+ end
1054
+ fallback
656
1055
  end
657
1056
 
658
1057
  def legend_nets
659
- @circuit.nets.reject { |net| net.members.length == 1 && net.holes.empty? }
1058
+ @nets.select { |net| visible_net?(net) && !(net.members.length == 1 && net.holes.empty?) }
1059
+ end
1060
+
1061
+ def dip_pin_map_lines
1062
+ @visible_components.select { |component| component.part.data.dig("render", "shape") == "dip" }.flat_map do |component|
1063
+ names = component.pins.values.sort_by { |pin| pin.number.to_i }.map { |pin| "#{pin.number} #{pin.name}" }
1064
+ annotation_lines("#{component.ref} pins: #{names.join(' · ')}")
1065
+ end
660
1066
  end
661
1067
 
662
1068
  def annotations_svg
@@ -684,41 +1090,70 @@ module Breadkit
684
1090
  x, y = px(point[0]), py(point[1])
685
1091
  anchor ||= [x, y]
686
1092
  out << circle(x, y, 5, fill: "none", stroke: color, stroke_width: 1.5)
1093
+ annotation_state = read(item, "state")
1094
+ if read(item, "rule") == "Electrical/ShortCircuit" && @visible_wire_ids.include?(id) &&
1095
+ (annotation_state.nil? || annotation_state.to_s == @state&.name.to_s)
1096
+ finish = endpoint_point(wire.to)
1097
+ out << tag("path", d: wire_path(wire, point, finish), fill: "none", stroke: color,
1098
+ stroke_width: 5, stroke_linecap: "round", stroke_dasharray: "5 4", opacity: 0.85,
1099
+ data_short_path: id) if finish
1100
+ end
687
1101
  end
688
1102
  end
1103
+ Array(read(targets, "pins")).each do |ref|
1104
+ point = endpoint_point(ref)
1105
+ next unless point
1106
+
1107
+ x, y = px(point[0]), py(point[1])
1108
+ anchor ||= [x, y]
1109
+ out << circle(x, y, 4, fill: "none", stroke: color, stroke_width: 1.5, data_pin: ref)
1110
+ end
689
1111
  Array(read(targets, "components")).each do |ref|
690
1112
  component = @circuit.components[ref]
1113
+ next unless component
1114
+
1115
+ if component.part.placement == "offboard"
1116
+ x, y, width, height, = offboard_layout(component)
1117
+ left, top = px(x), py(y + height)
1118
+ anchor ||= [left + px(width) / 2, top + px(height) / 2]
1119
+ out << rect(left - 3, top - 3, px(width) + 6, px(height) + 6,
1120
+ fill: "none", stroke: color, stroke_width: 1.5, data_component: ref)
1121
+ next
1122
+ end
691
1123
  holes = component && component.pins.values.map { |pin| @circuit.board.hole(pin.hole_id) }.compact
692
- next if holes.nil? || holes.empty?
1124
+ next if holes.empty?
693
1125
  xs, ys = holes.map { |hole| px(display_hole(hole).x) }, holes.map { |hole| py(display_hole(hole).y) }
694
1126
  anchor ||= [(xs.min + xs.max) / 2, (ys.min + ys.max) / 2]
695
1127
  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)
1128
+ fill: "none", stroke: color, stroke_width: 1.5, data_component: ref)
697
1129
  end
698
1130
  Array(read(targets, "nets")).each do |name|
699
1131
  state_name = read(item, "state")
1132
+ state = @state
700
1133
  if state_name
701
- @annotation_states ||= @circuit.states("all").to_h { |candidate| [candidate.name, candidate] }
702
- state = @annotation_states[state_name]
1134
+ @annotation_states ||= {}
1135
+ state = @annotation_states.fetch(state_name) do
1136
+ @annotation_states[state_name] = StateSelection.resolve(@circuit, state_name)
1137
+ end
703
1138
  next unless state
704
1139
  end
705
1140
  net = @circuit.nets(state).find { |candidate| candidate.name == name }
706
1141
  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|
1142
+ points = net.holes.filter_map do |id|
716
1143
  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)
1144
+ display = display_hole(hole) if hole
1145
+ [px(display.x), py(display.y)] if display
1146
+ end
1147
+ points = net.members.filter_map { |member| endpoint_point(member)&.then { |x, y| [px(x), py(y)] } } if points.empty?
1148
+ next if points.empty?
1149
+
1150
+ xs, ys = points.map(&:first), points.map(&:last)
1151
+ anchor ||= points.first
1152
+ if points.length == 1
1153
+ out << circle(xs.first, ys.first, 4, fill: "none", stroke: color, stroke_width: 1.5, data_net: name)
1154
+ else
1155
+ out << rect(xs.min - 4, ys.min - 4, xs.max - xs.min + 8, ys.max - ys.min + 8,
1156
+ rx: 4, fill: "none", stroke: color, stroke_width: 1.2, data_net: name)
722
1157
  end
723
1158
  end
724
1159
  if anchor
@@ -768,16 +1203,21 @@ module Breadkit
768
1203
  end
769
1204
 
770
1205
  def view_box(mode)
771
- offboard = @circuit.components.values.any? { |component| component.part.placement == "offboard" }
772
- wire_points = @circuit.wires.flat_map do |wire|
1206
+ offboard = @visible_components.any? { |component| component.part.placement == "offboard" }
1207
+ wire_points = @visible_wires.flat_map do |wire|
773
1208
  from, to = endpoint_point(wire.from), endpoint_point(wire.to)
774
1209
  from && to ? wire_route_points(wire, from, to) : []
775
1210
  end
776
1211
  if mode == "none" || (@used_holes.empty? && !offboard)
777
1212
  left, top, width, height = board_bounds
778
- module_points = wire_points + @circuit.components.values.flat_map do |component|
1213
+ module_points = wire_points + @visible_components.flat_map do |component|
779
1214
  if component.part.placement == "offboard"
780
1215
  offboard_bounds(component)
1216
+ elsif rail_module?(component)
1217
+ pins = component.pins.values.filter_map { |pin| [pin, @circuit.board.hole(pin.hole_id)] if pin.hole_id }
1218
+ next [] if pins.empty?
1219
+ x, y, module_width, module_height = rail_module_bounds(pins)
1220
+ [[x, y], [x + module_width, y + module_height]]
781
1221
  elsif component.part.data.dig("render", "shape") == "module"
782
1222
  pins = component.pins.values.filter_map { |pin| [pin, @circuit.board.hole(pin.hole_id)] if pin.hole_id }
783
1223
  next [] if pins.empty?
@@ -804,9 +1244,15 @@ module Breadkit
804
1244
  [display.x, display.y] if display
805
1245
  end
806
1246
  points.concat(wire_points)
807
- points.concat(@circuit.components.values.select { |component| component.part.placement == "offboard" }
1247
+ points.concat(@visible_components.select { |component| component.part.placement == "offboard" }
808
1248
  .flat_map { |component| offboard_bounds(component) })
809
- points.concat(@circuit.components.values.flat_map do |component|
1249
+ points.concat(@visible_components.flat_map do |component|
1250
+ if rail_module?(component)
1251
+ pins = component.pins.values.filter_map { |pin| [pin, @circuit.board.hole(pin.hole_id)] if pin.hole_id }
1252
+ next [] if pins.empty?
1253
+ x, y, width, height = rail_module_bounds(pins)
1254
+ next [[x, y], [x + width, y + height]]
1255
+ end
810
1256
  next [] unless component.part.data.dig("render", "shape") == "module"
811
1257
  pins = component.pins.values.filter_map { |pin| [pin, @circuit.board.hole(pin.hole_id)] if pin.hole_id }
812
1258
  next [] if pins.empty?
@@ -821,7 +1267,7 @@ module Breadkit
821
1267
  def legend_height
822
1268
  return 0 unless @legend_enabled || !@annotations.empty?
823
1269
 
824
- 20 + (@legend_enabled ? legend_nets.length * 9 : 0) +
1270
+ 20 + (@legend_enabled ? legend_nets.length * 9 + dip_pin_map_lines.length * 7 + (dip_pin_map_lines.empty? ? 0 : 2) : 0) +
825
1271
  @annotations.each_with_index.sum { |item, index| annotation_lines("#{index + 1}. #{read(item, "message") || read(item, "rule")}").length * 7 + 2 }
826
1272
  end
827
1273
 
@@ -837,12 +1283,13 @@ module Breadkit
837
1283
  parsed = Breadkit::HoleId.parse(endpoint, board: @circuit.board)
838
1284
  if parsed.kind == :pin
839
1285
  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
1286
+ pin = component&.pin(parsed.pin)
1287
+ return unless pin
1288
+ return offboard_pin_point(component, component.pins.values.index(pin)) if component.part.placement == "offboard"
1289
+
1290
+ hole = @circuit.board.hole(pin.hole_id)
1291
+ display = display_hole(hole) if hole
1292
+ return [display.x, display.y] if display
846
1293
  end
847
1294
  hole = @circuit.board.hole(endpoint)
848
1295
  display = display_hole(hole) if hole
@@ -861,11 +1308,41 @@ module Breadkit
861
1308
  edge_route_points(wire, from, to)
862
1309
  elsif wire.route == "arc"
863
1310
  [from, [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0 + 0.8], to]
1311
+ elsif @wire_routing == "auto" && !offboard_component(wire.from) && !offboard_component(wire.to)
1312
+ @auto_routes[wire.id] ||= @auto_router.route(from, to)
864
1313
  else
865
1314
  [from, to]
866
1315
  end
867
1316
  end
868
1317
 
1318
+ def auto_obstacles
1319
+ @visible_components.filter_map do |component|
1320
+ next if component.part.placement == "offboard" || rail_module?(component)
1321
+ pins = component.pins.values.filter_map { |pin| @circuit.board.hole(pin.hole_id) }
1322
+ next if pins.empty?
1323
+
1324
+ xs, ys = pins.map { |hole| display_hole(hole).x }, pins.map { |hole| display_hole(hole).y }
1325
+ shape = component.part.data.dig("render", "shape")
1326
+ if shape == "module"
1327
+ x, y, width, height = module_bounds(component, pins.map { |hole| [nil, hole] })
1328
+ [x, y, x + width, y + height]
1329
+ elsif (size = component.part.data.dig("render", "size_mm"))
1330
+ width, height = size.map { |value| value.to_f / 2.54 }
1331
+ center_x, center_y = (xs.min + xs.max) / 2.0, (ys.min + ys.max) / 2.0
1332
+ [center_x - width / 2, center_y - height / 2, center_x + width / 2, center_y + height / 2]
1333
+ elsif shape == "dip"
1334
+ [xs.min - 0.4, (ys.min + ys.max) / 2.0 - 0.4, xs.max + 0.4, (ys.min + ys.max) / 2.0 + 0.4]
1335
+ else
1336
+ half_width, half_height = { "resistor" => [1.2, 0.4], "led_5mm" => [0.55, 0.55],
1337
+ "tact_switch" => [1.2, 1.0], "potentiometer" => [1.2, 0.8],
1338
+ "diode" => [0.7, 0.3], "rgb_led_5mm" => [0.7, 0.7],
1339
+ "to92" => [0.8, 0.6] }.fetch(shape, [0.9, 0.5])
1340
+ center_x, center_y = (xs.min + xs.max) / 2.0, (ys.min + ys.max) / 2.0
1341
+ [center_x - half_width, center_y - half_height, center_x + half_width, center_y + half_height]
1342
+ end
1343
+ end
1344
+ end
1345
+
869
1346
  def edge_route_points(wire, from, to)
870
1347
  module_from = offboard_component(wire.from)
871
1348
  module_to = offboard_component(wire.to)
@@ -897,7 +1374,12 @@ module Breadkit
897
1374
  [module_point, [module_point[0], lane], [x, lane], [x, board_point[1]], board_point]
898
1375
  else
899
1376
  y = board_point[1] + breakout
900
- [module_point, [lane, module_point[1]], [lane, y], [board_point[0], y], board_point]
1377
+ detour = landscape_terminal_detour(board_point[0], lane, y, index)
1378
+ if detour
1379
+ [module_point, [lane, module_point[1]], [lane, detour], [board_point[0], detour], [board_point[0], y], board_point]
1380
+ else
1381
+ [module_point, [lane, module_point[1]], [lane, y], [board_point[0], y], board_point]
1382
+ end
901
1383
  end
902
1384
  elsif hole&.kind == :rail && @orientation == "portrait"
903
1385
  component = module_from || module_to
@@ -924,6 +1406,19 @@ module Breadkit
924
1406
  points
925
1407
  end
926
1408
 
1409
+ def landscape_terminal_detour(from_x, to_x, y, index)
1410
+ obstacles = @visible_components.filter_map do |component|
1411
+ next unless component.part.placement == "footprint" && component.part.data.dig("render", "shape") == "module"
1412
+
1413
+ pins = component.pins.values.filter_map { |pin| [pin, @circuit.board.hole(pin.hole_id)] if pin.hole_id }
1414
+ next if pins.empty?
1415
+
1416
+ x, bottom, width, height = module_bounds(component, pins)
1417
+ bottom + height if y.between?(bottom - 0.3, bottom + height + 0.3) && [from_x, to_x].min < x + width && [from_x, to_x].max > x
1418
+ end
1419
+ obstacles.max && obstacles.max + 1.2 + index * 0.35
1420
+ end
1421
+
927
1422
  def edge_breakout_offset(wire, x)
928
1423
  @edge_breakout_offsets ||= begin
929
1424
  groups = Hash.new { |hash, key| hash[key] = [] }
@@ -1080,14 +1575,18 @@ module Breadkit
1080
1575
  def wire_color(wire)
1081
1576
  if @color_by == "net"
1082
1577
  net = @nets_by_member[wire.id] || @nets_by_hole[wire.from]
1083
- voltage = net && @circuit.potentials.values[net.name]
1578
+ voltage = net && @circuit.potentials(@state).values[net.name]
1084
1579
  return voltage.positive? ? @colors[:positive] : (voltage.negative? ? @colors[:negative_wire] : @colors[:text]) if voltage
1085
1580
  if net
1086
1581
  hash = net.name.each_byte.reduce(2_166_136_261) { |value, byte| ((value ^ byte) * 16_777_619) & 0xffffffff }
1087
- return PALETTE[hash % PALETTE.length]
1582
+ return wire_palette[hash % wire_palette.length]
1088
1583
  end
1089
1584
  end
1090
- PALETTE[@wire_indexes.fetch(wire) % PALETTE.length]
1585
+ wire_palette[@wire_indexes.fetch(wire) % wire_palette.length]
1586
+ end
1587
+
1588
+ def wire_palette
1589
+ @theme == "colorblind" ? COLORBLIND_PALETTE : PALETTE
1091
1590
  end
1092
1591
 
1093
1592
  def resistor_bands(value, bands: 4)
@@ -1128,6 +1627,10 @@ module Breadkit
1128
1627
  format("%.2f", value.to_f)
1129
1628
  end
1130
1629
 
1630
+ def coord(value)
1631
+ fmt(value).sub(/\.?0+\z/, "")
1632
+ end
1633
+
1131
1634
  def escape(value)
1132
1635
  CGI.escapeHTML(value.to_s)
1133
1636
  end