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.
@@ -1,29 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require "cgi/escape"
5
+ require "find"
4
6
 
5
7
  module Breadkit
6
8
  module Render
7
9
  class CLI
8
10
  def run(argv)
9
- options = { scale: 2.0, theme: "light", orientation: "portrait", color_by: "wire", crop: "auto", backend: "auto", quality: 90 }
11
+ original_args = argv.dup
12
+ options = { scale: 2.0, theme: "light", orientation: "portrait", color_by: "wire", crop: "auto", backend: "auto", quality: 90,
13
+ view: "breadboard", label_density: "full", wire_routing: "declared", wire_style: "raised" }
10
14
  parser = OptionParser.new do |opts|
11
15
  opts.banner = "Usage: bkrender [options] INPUT"
12
16
  opts.on("-o", "--output PATH") { |value| options[:output] = value }
13
- opts.on("-f", "--format FORMAT", %w[svg png jpeg jpg]) { |value| options[:format] = value == "jpg" ? "jpeg" : value }
17
+ opts.on("-f", "--format FORMAT", %w[svg html png jpeg jpg webp pdf apng]) { |value| options[:format] = value == "jpg" ? "jpeg" : value }
18
+ opts.on("--view NAME", %w[breadboard netlist schematic]) { |value| options[:view] = value }
14
19
  opts.on("--scale N", Float) { |value| options[:scale] = value }
15
- opts.on("--theme NAME", %w[light dark print]) { |value| options[:theme] = value }
20
+ opts.on("--theme NAME", %w[light dark print colorblind]) { |value| options[:theme] = value }
21
+ opts.on("--theme-file PATH") { |value| options[:theme_file] = value }
22
+ opts.on("--font-file PATH") { |value| options[:font_file] = value }
16
23
  opts.on("--orientation NAME", %w[portrait landscape]) { |value| options[:orientation] = value }
17
24
  opts.on("--rail-pattern PATTERN", SvgRenderer::RAIL_PATTERNS) { |value| options[:rail_pattern] = value }
18
25
  opts.on("--color-by MODE", %w[wire net]) { |value| options[:color_by] = value }
26
+ opts.on("--label-density MODE", SvgRenderer::LABEL_DENSITIES) { |value| options[:label_density] = value }
27
+ opts.on("--wire-routing MODE", %w[declared auto]) { |value| options[:wire_routing] = value }
28
+ opts.on("--wire-style MODE", %w[raised flat]) { |value| options[:wire_style] = value }
19
29
  opts.on("--show-nets") { options[:show_nets] = true }
20
30
  opts.on("--legend") { options[:legend] = true }
21
31
  opts.on("--crop MODE", %w[auto none]) { |value| options[:crop] = value }
22
32
  opts.on("--annotations FILE") { |value| options[:annotations] = value }
23
- opts.on("--backend NAME", %w[auto rsvg vips magick]) { |value| options[:backend] = value }
33
+ opts.on("--state NAME") { |value| options[:state] = value }
34
+ opts.on("--step N", Integer) { |value| options[:step] = value }
35
+ opts.on("--assembly-guide") { options[:assembly_guide] = true }
36
+ opts.on("--animate MODE", %w[steps states]) { |value| options[:animate] = value }
37
+ opts.on("--frame-delay MS", Integer) { |value| options[:frame_delay] = value }
38
+ opts.on("--layer NAME") { |value| options[:layer] = value }
39
+ opts.on("--focus REF") { |value| options[:focus] = value }
40
+ opts.on("--highlight-net NAME") { |value| options[:highlight_net] = value }
41
+ opts.on("--diff") { options[:diff] = true }
42
+ opts.on("--backend NAME", %w[auto rsvg resvg vips magick chrome]) { |value| options[:backend] = value }
24
43
  opts.on("--background COLOR") { |value| options[:background] = value }
25
44
  opts.on("--static") { options[:static] = true }
45
+ opts.on("--watch") { options[:watch] = true }
26
46
  opts.on("--quality N", Integer) { |value| options[:quality] = value }
47
+ opts.on("--print-template") { options[:print_template] = true }
48
+ opts.on("--render-timeout SECONDS", Float) { |value| options[:render_timeout] = value }
27
49
  opts.on("--force") { options[:force] = true }
28
50
  opts.on("-v", "--version") { puts "bkrender #{VERSION}"; return 0 }
29
51
  opts.on("-h", "--help") { puts opts; return 0 }
@@ -31,28 +53,88 @@ module Breadkit
31
53
  parser.parse!(argv)
32
54
  input = argv.shift
33
55
  raise ArgumentError, "input file required\n#{parser}" unless input
56
+ second_input = argv.shift if options[:diff]
57
+ raise ArgumentError, "--diff requires OLD and NEW inputs" if options[:diff] && !second_input
34
58
  raise ArgumentError, "unexpected arguments: #{argv.join(' ')}" unless argv.empty?
35
59
  format = output_format(options)
60
+ if options[:assembly_guide]
61
+ raise ArgumentError, "--assembly-guide requires HTML output" unless format == "html"
62
+ raise ArgumentError, "--assembly-guide requires breadboard view" unless options[:view] == "breadboard"
63
+ if options.values_at(:diff, :step, :state, :layer, :focus, :highlight_net, :annotations, :print_template).any?
64
+ raise ArgumentError, "--assembly-guide cannot select a diff, step, state, layer, focus, annotations, or print template"
65
+ end
66
+ end
67
+ if options[:theme_file]
68
+ custom = Theme.load(options[:theme_file])
69
+ options[:theme], options[:theme_colors] = custom.values_at(:base, :colors)
70
+ end
71
+ options[:font_css] = Theme.font(options[:font_file]) if options[:font_file]
72
+ if options.values_at(:theme_file, :font_file).any? && options[:view] != "breadboard"
73
+ raise ArgumentError, "custom themes and embedded fonts require breadboard view"
74
+ end
75
+ raise ArgumentError, "colorblind theme requires breadboard view" if options[:theme] == "colorblind" && options[:view] != "breadboard"
76
+ raise ArgumentError, "--animate and --frame-delay require APNG output" if format != "apng" && options.values_at(:animate, :frame_delay).any?
77
+ raise ArgumentError, "APNG cannot select a single --state or --step" if format == "apng" && options.values_at(:state, :step).any?
78
+ validate_electrical_view_options(options, format) unless options[:view] == "breadboard"
79
+ validate_step_options(options, format) if options[:step]
80
+ if options[:print_template]
81
+ raise ArgumentError, "--print-template requires PDF output" unless format == "pdf"
82
+ options.merge!(theme: "print", crop: "none", scale: 1.0, static: true)
83
+ end
36
84
  raise ArgumentError, "--background is only supported for JPEG" if options[:background] && format != "jpeg"
37
- raise ArgumentError, "cannot write binary image data to a terminal; use -o PATH" if format != "svg" && !options[:output] && $stdout.tty?
85
+ raise ArgumentError, "cannot write binary image data to a terminal; use -o PATH" if !%w[svg html].include?(format) && !options[:output] && $stdout.tty?
86
+ if options[:watch]
87
+ raise ArgumentError, "--watch requires -o PATH" unless options[:output]
88
+ original_args.delete_at(original_args.index("--watch"))
89
+ return watch(input, second_input, options[:annotations], options[:output], original_args, format)
90
+ end
91
+ return render_diff(input, second_input, options, format) if options[:diff]
38
92
  circuit = Breadkit.load(input)
93
+ circuit.diagnostics.each do |item|
94
+ location = [item.location&.path, item.location&.line].compact.join(":")
95
+ warn [location, item.severity || "error", item.message].reject { |value| value.nil? || value.empty? }.join(": ")
96
+ end
39
97
  errors = circuit.diagnostics.reject { |item| %w[warning info].include?(item.severity) }
40
- if !errors.empty? && !options[:force]
41
- errors.each do |item|
42
- location = [item.location&.path, item.location&.line].compact.join(":")
43
- warn [location, item.message].compact.reject(&:empty?).join(": ")
44
- end
45
- return 1
46
- end
47
- svg = SvgRenderer.new.render(circuit, crop: options[:crop], theme: options[:theme], orientation: options[:orientation],
48
- show_nets: options[:show_nets], legend: options[:legend], color_by: options[:color_by],
49
- annotations: read_annotations(options[:annotations], input), rail_pattern: options[:rail_pattern],
50
- interactive_layers: format == "svg" && !options[:static])
51
- output = if format == "svg"
52
- svg
98
+ return 1 if !errors.empty? && !options[:force]
99
+ all_steps = circuit.respond_to?(:steps) ? circuit.steps : []
100
+ step = select_step(all_steps, options[:step]) if options[:step]
101
+ circuit = circuit_for_step(circuit, options[:step]) if step
102
+ state = StateSelection.resolve(circuit, options[:state]) if options[:state]
103
+ raise ArgumentError, "unknown circuit state: #{options[:state]}" if options[:state] && !state
104
+ render_options = if %w[netlist schematic].include?(options[:view])
105
+ { theme: options[:theme], state: state }
106
+ else
107
+ { crop: step ? "none" : options[:crop], theme: options[:theme], orientation: options[:orientation],
108
+ show_nets: options[:show_nets], legend: options[:legend], color_by: options[:color_by],
109
+ annotations: read_annotations(options[:annotations], input), rail_pattern: options[:rail_pattern],
110
+ interactive_layers: !step && %w[svg html].include?(format) && !options[:static] && !options[:layer],
111
+ state: state, active_layer: options[:layer], focus: options[:focus],
112
+ highlight_net: options[:highlight_net], label_density: options[:label_density],
113
+ theme_colors: options[:theme_colors] || {}, font_css: options[:font_css],
114
+ wire_routing: options[:wire_routing], wire_style: options[:wire_style] }
115
+ end
116
+ renderer = { "breadboard" => SvgRenderer, "netlist" => NetlistRenderer,
117
+ "schematic" => SchematicRenderer }.fetch(options[:view])
118
+ if options[:assembly_guide]
119
+ output = assembly_guide(circuit, render_options, options[:theme])
120
+ options[:output] ? File.binwrite(options[:output], output) : $stdout.write(output)
121
+ return 0
122
+ end
123
+ if format == "apng"
124
+ output = render_animation(circuit, options, render_options)
125
+ options[:output] ? File.binwrite(options[:output], output) : $stdout.write(output)
126
+ return 0
127
+ end
128
+ svg = renderer.new.render(circuit, **render_options)
129
+ svg = add_step_banner(svg, options[:step], step, all_steps, options[:theme]) if step
130
+ svg = print_dimensions(svg) if options[:print_template]
131
+ output = case format
132
+ when "svg" then svg
133
+ when "html" then html_viewer(svg, theme: options[:theme], circuit: circuit, render_options: render_options)
53
134
  else
54
135
  Rasterizer.new.rasterize(svg, format: format, scale: options[:scale],
55
- background: options[:background] || "white", quality: options[:quality], backend: options[:backend])
136
+ background: options[:background] || "white", quality: options[:quality], backend: options[:backend],
137
+ timeout: options[:render_timeout] || 60)
56
138
  end
57
139
  options[:output] ? File.binwrite(options[:output], output) : $stdout.write(output)
58
140
  0
@@ -66,14 +148,319 @@ module Breadkit
66
148
 
67
149
  private
68
150
 
151
+ def assembly_guide(circuit, render_options, theme)
152
+ steps = circuit.respond_to?(:steps) ? circuit.steps : []
153
+ raise ArgumentError, "circuit has no assembly steps" if steps.empty?
154
+
155
+ physical_wires = circuit.wires.reject { |wire| wire.electrical == false }
156
+ items = circuit.components.values.group_by { |component| [component.part.id, bom_value(component)] }
157
+ rows = items.sort_by { |(part, value), _| [part, value.to_s] }.map do |(part, value), components|
158
+ cells = [components.length, part, value, components.map(&:ref).sort.join(", ")]
159
+ "<tr>#{cells.map { |cell| "<td>#{CGI.escapeHTML(cell.to_s)}</td>" }.join}</tr>"
160
+ end
161
+ rows << "<tr><td>#{physical_wires.length}</td><td>Jumper wire</td><td></td><td></td></tr>"
162
+ panels = steps.each_with_index.map do |step, index|
163
+ number = index + 1
164
+ stage = circuit_for_step(circuit, number, physical_only: true)
165
+ svg = SvgRenderer.new.render(stage, **render_options.merge(crop: "none", interactive_layers: false))
166
+ .sub(/\A<\?xml[^>]*\?>\s*/, "")
167
+ heading = CGI.escapeHTML(step[:title] || "Assembly step")
168
+ added_parts = circuit.components.values.select { |component| component.step == number }.map(&:ref).sort
169
+ added_wires = physical_wires.select { |wire| wire.step == number }.map { |wire| "#{wire.from} → #{wire.to}" }
170
+ changes = []
171
+ changes << "Place #{added_parts.join(', ')}" unless added_parts.empty?
172
+ changes.concat(added_wires.map { |wire| "Connect #{wire}" })
173
+ instructions = changes.map { |change| "<li>#{CGI.escapeHTML(change)}</li>" }.join
174
+ %(<section class="step"><h2>Step #{number} of #{steps.length}: #{heading}</h2><ul>#{instructions}</ul><div class="diagram">#{svg}</div></section>)
175
+ end
176
+ dark = theme == "dark"
177
+ background, surface, foreground, muted = dark ? %w[#111a16 #1d2822 #ecf5ef #b7c9bd] : %w[#f5f7f4 #ffffff #1c3026 #536a5b]
178
+ title = CGI.escapeHTML(circuit.title || "Breadkit assembly guide")
179
+ <<~HTML
180
+ <!doctype html>
181
+ <html lang="en">
182
+ <head>
183
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
184
+ <title>#{title}</title>
185
+ <style>
186
+ *{box-sizing:border-box}body{margin:0;background:#{background};color:#{foreground};font:16px/1.5 system-ui,sans-serif}
187
+ main{max-width:1120px;margin:auto;padding:32px 20px 64px}h1{font-size:clamp(28px,4vw,44px);line-height:1.1;margin:0 0 10px}
188
+ .intro{color:#{muted};margin:0 0 32px}h2{font-size:22px;line-height:1.25;margin:0 0 16px}
189
+ section{background:#{surface};border:1px solid #{muted};border-radius:14px;padding:24px;margin:0 0 24px}
190
+ table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:10px 12px;border-bottom:1px solid #{muted}}
191
+ th{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#{muted}}tr:last-child td{border-bottom:0}
192
+ ul{margin:0 0 20px;padding-left:22px}.diagram{overflow:auto}.diagram svg{display:block;width:min(100%,560px);height:auto;margin:auto}
193
+ @media(max-width:600px){main{padding:20px 12px}section{padding:16px}th,td{padding:8px 5px;font-size:13px}}
194
+ @media print{body{background:white;color:black}main{max-width:none;padding:0}section{break-inside:avoid;border-color:#999}.step{break-before:page}}
195
+ </style>
196
+ </head>
197
+ <body><main><h1>#{title}</h1><p class="intro">#{steps.length} assembly steps · #{circuit.components.length} parts · #{physical_wires.length} jumper #{physical_wires.length == 1 ? 'wire' : 'wires'}</p>
198
+ <section><h2>Bill of materials</h2><table id="bom"><thead><tr><th>Qty</th><th>Part</th><th>Value</th><th>Refs</th></tr></thead><tbody>#{rows.join}</tbody></table></section>
199
+ #{panels.join("\n")}
200
+ </main></body></html>
201
+ HTML
202
+ end
203
+
204
+ def bom_value(component)
205
+ return nil unless component.value
206
+
207
+ Breadkit::Value.new(component.value, category: component.part.data.dig("render", "shape")).to_s
208
+ rescue ArgumentError
209
+ component.value.to_s
210
+ end
211
+
212
+ def render_animation(circuit, options, render_options)
213
+ raise ArgumentError, "APNG is available only in breadboard view" unless options[:view] == "breadboard"
214
+
215
+ steps = circuit.respond_to?(:steps) ? circuit.steps : []
216
+ mode = options[:animate] || (steps.empty? ? "states" : "steps")
217
+ if mode == "steps" && options.values_at(:annotations, :focus, :highlight_net, :layer).any?
218
+ raise ArgumentError, "APNG assembly steps cannot use annotations, focus, highlighted nets, or a layer filter"
219
+ end
220
+ frames = if mode == "steps"
221
+ steps.each_index.map do |index|
222
+ number = index + 1
223
+ svg = SvgRenderer.new.render(circuit_for_step(circuit, number), **render_options.merge(crop: "none", interactive_layers: false))
224
+ add_step_banner(svg, number, steps[index], steps, options[:theme])
225
+ end
226
+ else
227
+ circuit.states("all", budget: 256).map do |state|
228
+ SvgRenderer.new.render(circuit, **render_options.merge(crop: "none", state: state, interactive_layers: false))
229
+ end
230
+ end
231
+ raise ArgumentError, "APNG requires at least two #{mode}" unless frames.length >= 2
232
+
233
+ rasterizer = Rasterizer.new
234
+ images = frames.map do |svg|
235
+ rasterizer.rasterize(svg, format: "png", scale: options[:scale], backend: options[:backend],
236
+ timeout: options[:render_timeout] || 60)
237
+ end
238
+ ApngEncoder.new.encode(images, delay_ms: options[:frame_delay] || 800)
239
+ end
240
+
241
+ def watch(input, second_input, annotations, output, args, format)
242
+ root = File.dirname(File.expand_path(input))
243
+ paths = [input, second_input, annotations].compact.map { |path| File.expand_path(path) }
244
+ snapshot = watch_snapshot(root, paths, output)
245
+ warn "bkrender: watching #{root}; press Ctrl-C to stop"
246
+ loop do
247
+ if run(args.dup).zero?
248
+ write_live_reload(output) if format == "html"
249
+ warn "bkrender: rendered #{output}"
250
+ end
251
+ loop do
252
+ sleep 0.5
253
+ updated = watch_snapshot(root, paths, output)
254
+ next if updated == snapshot
255
+
256
+ snapshot = updated
257
+ break
258
+ end
259
+ end
260
+ rescue Interrupt
261
+ 0
262
+ end
263
+
264
+ def write_live_reload(output)
265
+ revision = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s
266
+ sidecar = "#{output}.reload.js"
267
+ filename = JSON.generate(File.basename(sidecar)).gsub("<", "\\u003c")
268
+ script = <<~HTML
269
+ <script>
270
+ (() => {
271
+ const revision = #{JSON.generate(revision)};
272
+ const file = #{filename};
273
+ window.__breadkitWatch = incoming => { if (incoming !== revision) location.reload(); };
274
+ setInterval(() => {
275
+ const tag = document.createElement('script');
276
+ tag.src = encodeURIComponent(file) + '?t=' + Date.now();
277
+ tag.onload = tag.onerror = () => tag.remove();
278
+ document.head.append(tag);
279
+ }, 1000);
280
+ })();
281
+ </script>
282
+ HTML
283
+ html = File.binread(output)
284
+ raise Error, "cannot add live reload to HTML output" unless html.match?(%r{</body>}i)
285
+
286
+ File.binwrite(output, html.sub(%r{</body>}i) { "#{script}</body>" })
287
+ File.binwrite(sidecar, "window.__breadkitWatch(#{JSON.generate(revision)});\n")
288
+ end
289
+
290
+ def watch_snapshot(root, paths, output)
291
+ # ponytail: watch nearby circuit and part files; add dependency tracking if broad projects make scanning costly.
292
+ files = paths.dup
293
+ Find.find(root) do |path|
294
+ if File.directory?(path)
295
+ Find.prune if path != root && (File.basename(path).start_with?(".") || File.basename(path) == "node_modules")
296
+ elsif path.end_with?(".bk.rb", ".yml", ".yaml", ".toml", ".json")
297
+ files << path
298
+ end
299
+ end
300
+ files.map { |path| File.expand_path(path) }.uniq.sort.reject { |path| path == File.expand_path(output) }.to_h do |path|
301
+ stat = File.stat(path)
302
+ [path, [stat.mtime.to_r, stat.size]]
303
+ rescue Errno::ENOENT
304
+ [path, nil]
305
+ end
306
+ end
307
+
308
+ def validate_step_options(options, format)
309
+ raise ArgumentError, "--step must be a positive integer" unless options[:step].positive?
310
+ raise ArgumentError, "--step is unavailable in #{options[:view]} view" unless options[:view] == "breadboard"
311
+ raise ArgumentError, "--step requires SVG or image output" if format == "html"
312
+ raise ArgumentError, "--step is unavailable with --diff" if options[:diff]
313
+ raise ArgumentError, "--step is unavailable with --print-template" if options[:print_template]
314
+ raise ArgumentError, "--step is unavailable with --annotations" if options[:annotations]
315
+ end
316
+
317
+ def select_step(steps, number)
318
+ raise ArgumentError, "circuit has no assembly steps" if steps.empty?
319
+
320
+ steps[number - 1] || raise(ArgumentError, "unknown assembly step: #{number}")
321
+ end
322
+
323
+ def circuit_for_step(circuit, number, physical_only: false)
324
+ visible = ->(item) { !item.step || item.step <= number }
325
+ components = circuit.components.select { |_ref, component| visible.call(component) }
326
+ wires = circuit.wires.select { |wire| visible.call(wire) && (!physical_only || wire.electrical != false) }
327
+ supplies = circuit.supplies.select(&visible)
328
+ labels = circuit.labels.select(&visible)
329
+ future_refs = (circuit.components.keys - components.keys) + (circuit.supplies.map(&:name) - supplies.map(&:name))
330
+ (wires.flat_map { |wire| [wire.from, wire.to] } + labels.map(&:at)).each do |endpoint|
331
+ ref = endpoint.split(".", 2).first if endpoint.include?(".")
332
+ raise ArgumentError, "step #{number} references #{endpoint} before #{ref} is placed" if future_refs.include?(ref)
333
+ end
334
+ Breadkit::Circuit.new(title: circuit.title, board: circuit.board, components: components, wires: wires,
335
+ supplies: supplies, labels: labels, expectations: [], lint_disables: [], diagnostics: [],
336
+ steps: circuit.steps.take(number), source_root: circuit.source_root)
337
+ end
338
+
339
+ def add_step_banner(svg, number, step, all_steps, theme)
340
+ inner = svg.sub(/\A<\?xml[^>]*\?>\s*/, "")
341
+ match = inner.match(/\A<svg\b[^>]*\bwidth="([\d.]+)" height="([\d.]+)"/)
342
+ raise Error, "cannot add an assembly step title to this SVG" unless match
343
+
344
+ width, height = match.captures.map(&:to_f)
345
+ max_chars = [(width - 24).div(7), 12].max
346
+ titles = all_steps.map { |item| wrap_step_title(item[:title] || "Assembly step", max_chars) }
347
+ banner_height = 50 + titles.map(&:length).max * 18
348
+ heading = "Step #{number} of #{all_steps.length}: #{step[:title] || 'Assembly step'}"
349
+ background, foreground = theme == "dark" ? %w[#0d1420 #f2f6fc] : %w[#f4f7fb #172338]
350
+ inner = inner.sub("<svg ", %(<svg id="step-board" x="0" y="#{banner_height}" ))
351
+ title_lines = [%(<text x="20" y="27" fill="#{foreground}" font-size="15" font-weight="700">Step #{number} of #{all_steps.length}</text>)]
352
+ titles[number - 1].each_with_index do |line, index|
353
+ title_lines << %(<text x="20" y="#{52 + index * 18}" fill="#{foreground}" font-size="12">#{CGI.escapeHTML(line)}</text>)
354
+ end
355
+ %(<?xml version="1.0" encoding="UTF-8"?>\n) +
356
+ %(<svg xmlns="http://www.w3.org/2000/svg" width="#{format('%.2f', width)}" height="#{format('%.2f', height + banner_height)}" viewBox="0 0 #{format('%.2f', width)} #{format('%.2f', height + banner_height)}" role="img" aria-label="#{CGI.escapeHTML(heading)}" data-step="#{number}">\n) +
357
+ %(<title>#{CGI.escapeHTML(heading)}</title><rect width="#{format('%.2f', width)}" height="#{banner_height}" fill="#{background}"/>\n) +
358
+ %(<g id="assembly-step-title" font-family="Helvetica, Arial, sans-serif">#{title_lines.join}</g>\n) +
359
+ %(#{inner}\n</svg>)
360
+ end
361
+
362
+ def wrap_step_title(title, max_chars)
363
+ words = title.split.flat_map { |word| word.scan(/.{1,#{max_chars}}/) }
364
+ words.each_with_object([""]) do |word, lines|
365
+ if lines.last.empty? || lines.last.length + word.length + 1 <= max_chars
366
+ lines[-1] = [lines.last, word].reject(&:empty?).join(" ")
367
+ else
368
+ lines << word
369
+ end
370
+ end
371
+ end
372
+
373
+ def validate_electrical_view_options(options, format)
374
+ view = options[:view]
375
+ raise ArgumentError, "HTML is unavailable in #{view} view" if format == "html"
376
+
377
+ unavailable = { diff: options[:diff], print_template: options[:print_template], rail_pattern: options[:rail_pattern],
378
+ annotations: options[:annotations], layer: options[:layer], focus: options[:focus],
379
+ highlight_net: options[:highlight_net], show_nets: options[:show_nets], legend: options[:legend],
380
+ static: options[:static], crop: options[:crop] != "auto", orientation: options[:orientation] != "portrait",
381
+ color_by: options[:color_by] != "wire", label_density: options[:label_density] != "full",
382
+ wire_routing: options[:wire_routing] != "declared", wire_style: options[:wire_style] != "raised" }
383
+ option = unavailable.find { |_name, used| used }&.first
384
+ raise ArgumentError, "--#{option.to_s.tr('_', '-')} is unavailable in #{view} view" if option
385
+ end
386
+
387
+ def print_dimensions(svg)
388
+ svg.sub(/(<svg\b[^>]*?\bwidth=")([\d.]+)(" height=")([\d.]+)(")/) do
389
+ "#{$1}#{format('%.2f', $2.to_f * 0.254)}mm#{$3}#{format('%.2f', $4.to_f * 0.254)}mm#{$5}"
390
+ end
391
+ end
392
+
69
393
  def output_format(options)
70
394
  extension = File.extname(options[:output].to_s).downcase
71
- from_path = { ".svg" => "svg", ".png" => "png", ".jpg" => "jpeg", ".jpeg" => "jpeg" }[extension]
72
- raise ArgumentError, "unsupported output extension: #{extension}" if options[:output] && !from_path
395
+ from_path = { ".svg" => "svg", ".html" => "html", ".png" => "png", ".jpg" => "jpeg", ".jpeg" => "jpeg",
396
+ ".webp" => "webp", ".pdf" => "pdf", ".apng" => "apng" }[extension]
397
+ raise ArgumentError, "unsupported output extension: #{extension}" if options[:output] && !from_path && !options[:format]
73
398
  if options[:format] && from_path && options[:format] != from_path
74
399
  raise ArgumentError, "--format conflicts with output extension"
75
400
  end
76
- options[:format] || from_path || "svg"
401
+ options[:format] || from_path || (options[:diff] ? "html" : "svg")
402
+ end
403
+
404
+ def render_diff(before_path, after_path, options, format)
405
+ raise ArgumentError, "--diff requires HTML output" unless format == "html"
406
+ raise ArgumentError, "--diff does not support annotations, state, layer, or focus selection" if options.values_at(:annotations, :state, :layer, :focus, :highlight_net).any?
407
+
408
+ before, after = [before_path, after_path].map { |path| Breadkit.load(path) }
409
+ [before, after].zip([before_path, after_path]).each do |circuit, path|
410
+ circuit.diagnostics.each { |item| warn "#{path}: #{item.severity || 'error'}: #{item.message}" }
411
+ end
412
+ return 1 if !options[:force] && [before, after].any? { |circuit| circuit.diagnostics.any? { |item| !%w[warning info].include?(item.severity) } }
413
+
414
+ removed = unmatched_wires(before, after).to_h { |id| [id, "removed"] }
415
+ added = unmatched_wires(after, before).to_h { |id| [id, "added"] }
416
+ render_options = { crop: options[:crop], theme: options[:theme], orientation: options[:orientation],
417
+ show_nets: options[:show_nets], legend: options[:legend], color_by: options[:color_by],
418
+ rail_pattern: options[:rail_pattern], interactive_layers: !options[:static],
419
+ label_density: options[:label_density], theme_colors: options[:theme_colors] || {},
420
+ font_css: options[:font_css], wire_routing: options[:wire_routing],
421
+ wire_style: options[:wire_style] }
422
+ old_svg = SvgRenderer.new.render(before, **render_options, diff_wires: removed)
423
+ new_svg = SvgRenderer.new.render(after, **render_options, diff_wires: added)
424
+ html = diff_viewer(old_svg, new_svg, theme: options[:theme])
425
+ options[:output] ? File.binwrite(options[:output], html) : $stdout.write(html)
426
+ 0
427
+ end
428
+
429
+ def unmatched_wires(first, second)
430
+ remaining = second.wires.map { |wire| wire_signature(wire) }.tally
431
+ first.wires.filter_map do |wire|
432
+ signature = wire_signature(wire)
433
+ if remaining.fetch(signature, 0).positive?
434
+ remaining[signature] -= 1
435
+ nil
436
+ else
437
+ wire.id
438
+ end
439
+ end
440
+ end
441
+
442
+ def wire_signature(wire)
443
+ [*([wire.from, wire.to].sort), wire.color, wire.route, wire.layer, wire.electrical, wire.dashed]
444
+ end
445
+
446
+ def diff_viewer(old_svg, new_svg, theme:)
447
+ background = theme == "dark" ? "#151d19" : "#f1f4f1"
448
+ foreground = theme == "dark" ? "#ecf3ee" : "#203029"
449
+ before = CGI.escapeHTML(html_viewer(old_svg, theme: theme))
450
+ after = CGI.escapeHTML(html_viewer(new_svg, theme: theme))
451
+ <<~HTML
452
+ <!doctype html>
453
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
454
+ <title>Breadkit wiring diff</title><style>
455
+ *{box-sizing:border-box}body{margin:0;background:#{background};color:#{foreground};font:14px system-ui,sans-serif}
456
+ main{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;padding:8px}section{min-width:0}
457
+ h2{margin:0 0 8px;font-size:14px;font-weight:600}iframe{width:100%;height:calc(100vh - 48px);border:1px solid currentColor;border-radius:8px}
458
+ @media(max-width:700px){main{grid-template-columns:1fr}iframe{height:70vh}}
459
+ </style></head><body><main>
460
+ <section><h2>Before · removed wires in red</h2><iframe title="Before" sandbox="allow-scripts" srcdoc="#{before}"></iframe></section>
461
+ <section><h2>After · added wires in green</h2><iframe title="After" sandbox="allow-scripts" srcdoc="#{after}"></iframe></section>
462
+ </main></body></html>
463
+ HTML
77
464
  end
78
465
 
79
466
  def read_annotations(path, input)
@@ -85,6 +472,144 @@ module Breadkit
85
472
  match ||= files.first if files.length == 1
86
473
  Array(match && match["offenses"])
87
474
  end
475
+
476
+ def html_viewer(svg, theme:, circuit: nil, render_options: nil)
477
+ background = theme == "dark" ? "#151d19" : "#f1f4f1"
478
+ foreground = theme == "dark" ? "#ecf3ee" : "#203029"
479
+ states = circuit&.states("all", budget: 256) || []
480
+ selected = render_options&.dig(:state)&.name.to_s
481
+ diagram = viewer_diagrams(svg, states, selected) { |state| SvgRenderer.new.render(circuit, **render_options.merge(state: state)) }
482
+ schematic = if circuit
483
+ initial = SchematicRenderer.new.render(circuit, theme: theme, state: render_options[:state])
484
+ viewer_diagrams(initial, states, selected) { |state| SchematicRenderer.new.render(circuit, theme: theme, state: state) }
485
+ end
486
+ state_control = ""
487
+ if states.length > 1
488
+ options = states.map do |state|
489
+ name = state.name.to_s
490
+ "<option value=\"#{CGI.escapeHTML(name)}\"#{' selected' if name == selected}>#{CGI.escapeHTML(name.empty? ? 'Open' : name)}</option>"
491
+ end.join
492
+ state_control = "<label for=\"state\">State</label><select id=\"state\" aria-label=\"Switch state\">#{options}</select>"
493
+ end
494
+ schematic_panel = if schematic
495
+ <<~PANEL
496
+ <section class="diagram-panel" aria-labelledby="schematic-heading">
497
+ <div class="panel-header"><h2 id="schematic-heading">Schematic</h2><div class="zoom-controls">
498
+ <button type="button" data-pane="schematic" data-action="in" aria-label="Zoom in schematic">+</button>
499
+ <button type="button" data-pane="schematic" data-action="out" aria-label="Zoom out schematic">−</button>
500
+ <button type="button" data-pane="schematic" data-action="fit" aria-label="Fit schematic">Fit</button>
501
+ </div></div>
502
+ <div id="schematic-viewport" class="viewport" tabindex="0" aria-label="Schematic diagram. Arrow keys pan; plus and minus zoom."><div id="schematic-scene" class="scene">#{schematic}</div></div>
503
+ </section>
504
+ PANEL
505
+ end
506
+ <<~HTML
507
+ <!doctype html>
508
+ <html lang="en">
509
+ <head>
510
+ <meta charset="utf-8">
511
+ <meta name="viewport" content="width=device-width, initial-scale=1">
512
+ <title>Breadkit diagram</title>
513
+ <style>
514
+ *{box-sizing:border-box}body{margin:0;height:100dvh;display:flex;flex-direction:column;background:#{background};color:#{foreground};font:14px system-ui,sans-serif}
515
+ #diagrams{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;padding:8px;flex:1;min-height:0}
516
+ #diagrams.single{grid-template-columns:1fr}.diagram-panel{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid currentColor;border-radius:8px;overflow:hidden}
517
+ .panel-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 8px;border-bottom:1px solid currentColor}
518
+ h2{margin:0;font-size:14px;font-weight:600}.zoom-controls{display:flex;gap:2px}
519
+ .viewport{position:relative;flex:1;min-height:0;overflow:hidden;touch-action:none;cursor:grab}.viewport.dragging{cursor:grabbing}.viewport:focus-visible{outline:2px solid currentColor;outline-offset:-2px}
520
+ .scene{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}.scene svg{display:block;max-width:none}
521
+ .scene svg[data-viewer-state]:not([data-active]){display:none}
522
+ .net-muted{opacity:.18!important}
523
+ #toolbar{display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid currentColor;background:#{background}}
524
+ button{color:inherit;background:transparent;border:0;border-radius:4px;min-width:36px;height:32px;font:inherit;cursor:pointer}button:hover,button:focus-visible{outline:2px solid currentColor}
525
+ select{color:inherit;background:#{background};border:1px solid currentColor;border-radius:4px;font:inherit;max-width:220px;padding:4px}
526
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
527
+ [data-switch]{cursor:pointer}[data-switch]:focus-visible{filter:drop-shadow(0 0 3px currentColor)}
528
+ @media(max-width:850px){body{height:auto;min-height:100dvh}#diagrams.dual{grid-template-columns:1fr;flex:none}.dual .diagram-panel{height:68vh}}
529
+ </style>
530
+ </head>
531
+ <body>
532
+ <div id="toolbar" role="toolbar" aria-label="Diagram controls">
533
+ #{state_control}
534
+ <label for="net">Net</label><select id="net" aria-label="Highlight net"><option value="">All nets</option></select>
535
+ </div>
536
+ <main id="diagrams" class="#{schematic ? 'dual' : 'single'}">
537
+ <section class="diagram-panel" aria-labelledby="breadboard-heading">
538
+ <div class="panel-header"><h2 id="breadboard-heading">Breadboard</h2><div class="zoom-controls">
539
+ <button type="button" data-pane="board" data-action="in" aria-label="Zoom in breadboard">+</button>
540
+ <button type="button" data-pane="board" data-action="out" aria-label="Zoom out breadboard">−</button>
541
+ <button type="button" data-pane="board" data-action="fit" aria-label="Fit breadboard">Fit</button>
542
+ </div></div>
543
+ <div id="viewport" class="viewport" tabindex="0" aria-label="Interactive breadboard diagram. Arrow keys pan; plus and minus zoom."><div id="scene" class="scene">#{diagram}</div></div>
544
+ </section>
545
+ #{schematic_panel}
546
+ </main>
547
+ <span id="viewer-status" class="sr-only" role="status"></span>
548
+ <script>
549
+ const stateSelect=document.getElementById('state'),netSelect=document.getElementById('net'),status=document.getElementById('viewer-status');
550
+ let selectedNet='',hoverNet='';
551
+ const panes={};
552
+ const makePane=(key,viewportId,sceneId)=>{
553
+ const viewport=document.getElementById(viewportId),scene=document.getElementById(sceneId);
554
+ let svg=scene.querySelector('[data-active]')||scene.querySelector('svg'),scale=1,x=0,y=0,dragging=false,lastX=0,lastY=0;
555
+ const paint=()=>{scene.style.transform=`translate(${x}px,${y}px) scale(${scale})`};
556
+ const fit=()=>{const w=Number(svg.getAttribute('width')),h=Number(svg.getAttribute('height'));
557
+ scene.style.width=`${w}px`;scene.style.height=`${h}px`;
558
+ scale=Math.min((viewport.clientWidth-32)/w,(viewport.clientHeight-32)/h);x=(viewport.clientWidth-w*scale)/2;y=(viewport.clientHeight-h*scale)/2;paint()};
559
+ const zoom=(factor,cx,cy)=>{const next=Math.max(.1,Math.min(12,scale*factor));x=cx-(cx-x)*next/scale;y=cy-(cy-y)*next/scale;scale=next;paint()};
560
+ const showState=name=>{const next=[...scene.querySelectorAll('[data-viewer-state]')].find(node=>node.getAttribute('data-viewer-state')===name);
561
+ if(!next)return;svg.removeAttribute('data-active');svg=next;svg.setAttribute('data-active','');fit()};
562
+ viewport.addEventListener('wheel',event=>{event.preventDefault();const box=viewport.getBoundingClientRect();zoom(event.deltaY<0?1.15:1/1.15,event.clientX-box.left,event.clientY-box.top)},{passive:false});
563
+ viewport.addEventListener('pointerdown',event=>{if(event.target.closest('[data-layer-button],[data-switch]'))return;dragging=true;lastX=event.clientX;lastY=event.clientY;viewport.classList.add('dragging');viewport.setPointerCapture(event.pointerId)});
564
+ viewport.addEventListener('pointermove',event=>{if(dragging){x+=event.clientX-lastX;y+=event.clientY-lastY;lastX=event.clientX;lastY=event.clientY;paint();return}
565
+ const net=event.target.closest('[data-net]')?.getAttribute('data-net')||'';if(net===hoverNet)return;hoverNet=net;paintNet()});
566
+ const endDrag=()=>{dragging=false;viewport.classList.remove('dragging')};viewport.addEventListener('pointerup',endDrag);viewport.addEventListener('pointercancel',endDrag);
567
+ viewport.addEventListener('pointerleave',()=>{if(dragging)return;hoverNet='';paintNet()});
568
+ viewport.addEventListener('keydown',event=>{if(event.defaultPrevented)return;
569
+ if(event.key==='+'||event.key==='=')zoom(1.25,viewport.clientWidth/2,viewport.clientHeight/2);
570
+ else if(event.key==='-')zoom(.8,viewport.clientWidth/2,viewport.clientHeight/2);
571
+ else if(event.key==='0')fit();
572
+ else if(event.key==='ArrowLeft')x+=24;else if(event.key==='ArrowRight')x-=24;
573
+ else if(event.key==='ArrowUp')y+=24;else if(event.key==='ArrowDown')y-=24;else return;
574
+ paint();event.preventDefault()});
575
+ panes[key]={scene,active:()=>svg,fit,zoom,showState};
576
+ };
577
+ makePane('board','viewport','scene');
578
+ if(document.getElementById('schematic-viewport'))makePane('schematic','schematic-viewport','schematic-scene');
579
+ const paintNet=()=>{const net=hoverNet||selectedNet;
580
+ Object.values(panes).forEach(pane=>pane.active().querySelectorAll('[data-net]').forEach(node=>node.classList.toggle('net-muted',!!net&&node.getAttribute('data-net')!==net)))};
581
+ const populateNets=()=>{const names=[...new Set(Object.values(panes).flatMap(pane=>[...pane.active().querySelectorAll('[data-net]')].map(node=>node.getAttribute('data-net'))))].sort((a,b)=>a.localeCompare(b,undefined,{numeric:true}));
582
+ netSelect.replaceChildren(new Option('All nets',''),...names.map(name=>new Option(name,name)));selectedNet='';netSelect.value='';paintNet()};
583
+ const selectState=name=>{Object.values(panes).forEach(pane=>pane.showState(name));stateSelect.value=name;hoverNet='';populateNets();
584
+ status.textContent=`${name||'Open'} switch state selected`};
585
+ if(stateSelect){const board=panes.board.scene;
586
+ board.querySelectorAll('[data-switch]').forEach(node=>{node.setAttribute('tabindex','0');node.setAttribute('role','button');node.setAttribute('aria-label',`Toggle ${node.getAttribute('data-switch')}`)});
587
+ const switchRefs=[...new Set([...panes.board.active().querySelectorAll('[data-switch]')].map(node=>node.getAttribute('data-switch')))];
588
+ const toggleSwitch=ref=>{const active=new Set(stateSelect.value.split(',').filter(Boolean));active.has(ref)?active.delete(ref):active.add(ref);
589
+ selectState(switchRefs.filter(name=>active.has(name)).join(','));[...panes.board.active().querySelectorAll('[data-switch]')].find(node=>node.getAttribute('data-switch')===ref)?.focus()};
590
+ stateSelect.addEventListener('change',()=>selectState(stateSelect.value));
591
+ board.addEventListener('click',event=>{const button=event.target.closest('[data-switch]');if(button)toggleSwitch(button.getAttribute('data-switch'))});
592
+ board.addEventListener('keydown',event=>{const button=event.target.closest('[data-switch]');if(!button||(event.key!=='Enter'&&event.key!==' '))return;
593
+ event.preventDefault();toggleSwitch(button.getAttribute('data-switch'))})}
594
+ netSelect.addEventListener('change',()=>{selectedNet=netSelect.value;paintNet();status.textContent=selectedNet?`${selectedNet} highlighted in both diagrams`:'All nets shown'});
595
+ document.querySelectorAll('[data-action]').forEach(button=>button.addEventListener('click',()=>{const pane=panes[button.getAttribute('data-pane')],action=button.getAttribute('data-action');
596
+ if(action==='fit')pane.fit();else pane.zoom(action==='in'?1.25:.8,pane.scene.parentElement.clientWidth/2,pane.scene.parentElement.clientHeight/2)}));
597
+ window.addEventListener('resize',()=>Object.values(panes).forEach(pane=>pane.fit()));populateNets();Object.values(panes).forEach(pane=>pane.fit());
598
+ </script>
599
+ </body>
600
+ </html>
601
+ HTML
602
+ end
603
+
604
+ def viewer_diagrams(original, states, selected)
605
+ return original.sub(/\A<\?xml[^>]*\?>\s*/, "") if states.length < 2
606
+
607
+ states.map do |state|
608
+ state_svg = state.name.to_s == selected ? original : yield(state)
609
+ state_svg.sub(/\A<\?xml[^>]*\?>\s*/, "")
610
+ .sub("<svg ", "<svg data-viewer-state=\"#{CGI.escapeHTML(state.name.to_s)}\"#{' data-active' if state.name.to_s == selected} ")
611
+ end.join
612
+ end
88
613
  end
89
614
  end
90
615
  end