Skip to main content

pedalkernel/
kicad.rs

1//! KiCad netlist export from the parsed `.pedal` AST.
2//!
3//! Generates a KiCad-compatible netlist file (S-expression format) so
4//! the same `.pedal` file can drive both tone-prototyping via WDF and
5//! PCB layout via KiCad.
6
7use crate::compiler::component::Component;
8use crate::compiler::components::*;
9use crate::dsl::*;
10use std::fmt::Write;
11
12/// Human-readable value string for a component.
13///
14/// Covers every component kind (including switched R/C/L, tubes, BBDs,
15/// photocouplers, …) with a `type_tag()` fallback. Shared with the BOM
16/// exporter ([`crate::bom`]) so the downcast ladder lives in one place.
17pub(crate) fn value_str(kind: &dyn Component) -> String {
18    // Switched components (check before generic resistance/capacitance/inductance)
19    if let Some(rs) = kind.as_any().downcast_ref::<ResistorSwitched>() {
20        let values = &rs.values;
21        if values.is_empty() {
22            return "R_switched".into();
23        }
24        return format!(
25            "R_switched_{}-{}",
26            format_eng(
27                *values
28                    .iter()
29                    .min_by(|a, b| a.partial_cmp(b).unwrap())
30                    .unwrap(),
31                "Ω"
32            ),
33            format_eng(
34                *values
35                    .iter()
36                    .max_by(|a, b| a.partial_cmp(b).unwrap())
37                    .unwrap(),
38                "Ω"
39            )
40        );
41    }
42    if let Some(cs) = kind.as_any().downcast_ref::<CapSwitched>() {
43        let values = &cs.values;
44        if values.is_empty() {
45            return "C_switched".into();
46        }
47        return format!(
48            "C_switched_{}-{}",
49            format_eng(
50                *values
51                    .iter()
52                    .min_by(|a, b| a.partial_cmp(b).unwrap())
53                    .unwrap(),
54                "F"
55            ),
56            format_eng(
57                *values
58                    .iter()
59                    .max_by(|a, b| a.partial_cmp(b).unwrap())
60                    .unwrap(),
61                "F"
62            )
63        );
64    }
65    if let Some(ls) = kind.as_any().downcast_ref::<InductorSwitched>() {
66        let values = &ls.values;
67        if values.is_empty() {
68            return "L_switched".into();
69        }
70        return format!(
71            "L_switched_{}-{}",
72            format_eng(
73                *values
74                    .iter()
75                    .min_by(|a, b| a.partial_cmp(b).unwrap())
76                    .unwrap(),
77                "H"
78            ),
79            format_eng(
80                *values
81                    .iter()
82                    .max_by(|a, b| a.partial_cmp(b).unwrap())
83                    .unwrap(),
84                "H"
85            )
86        );
87    }
88
89    // Tempco (check before generic resistance)
90    if let Some(tc) = kind.as_any().downcast_ref::<Tempco>() {
91        return format!("{}_Tempco_{:.0}ppm", format_eng(tc.resistance, "Ω"), tc.ppm);
92    }
93
94    // Diodes (check before generic diode_type to handle Zener and DiodePair)
95    if let Some(z) = kind.as_any().downcast_ref::<Zener>() {
96        return format!("{}V_Zener", z.breakdown_voltage);
97    }
98    if let Some(dt) = kind.diode_type() {
99        if kind.as_any().downcast_ref::<DiodePair>().is_some() {
100            return format!("{dt:?}_pair");
101        }
102        return format!("{dt:?}");
103    }
104
105    // Passive components with scalar values
106    if let Some(r) = kind.resistance() {
107        return format_eng(r, "Ω");
108    }
109    if let Some(c) = kind.capacitance() {
110        return format_eng(c, "F");
111    }
112    if let Some(l) = kind.inductance() {
113        return format_eng(l, "H");
114    }
115
116    // BJTs
117    if kind.is_bjt() {
118        if let Some(name) = kind.model_name() {
119            return name.to_string();
120        }
121    }
122
123    // Op-amp
124    if let Some(ot) = kind.op_amp_type() {
125        return match ot {
126            OpAmpType::Generic => "OpAmp".into(),
127            OpAmpType::Tl071 => "TL071".into(),
128            OpAmpType::Tl072 => "TL072".into(),
129            OpAmpType::Tl082 => "TL082".into(),
130            OpAmpType::Jrc4558 => "JRC4558D".into(),
131            OpAmpType::Rc4558 => "RC4558".into(),
132            OpAmpType::Lm308 => "LM308N".into(),
133            OpAmpType::Lm741 => "LM741".into(),
134            OpAmpType::Ne5532 => "NE5532".into(),
135            OpAmpType::Ca3080 => "CA3080".into(),
136            OpAmpType::Op07 => "OP07".into(),
137        };
138    }
139
140    // JFETs
141    if let Some(jfet) = kind.as_any().downcast_ref::<NJfet>() {
142        return format!("N-JFET_{}", jfet.model);
143    }
144    if let Some(jfet) = kind.as_any().downcast_ref::<PJfet>() {
145        return format!("P-JFET_{}", jfet.model);
146    }
147
148    // Photocoupler
149    if let Some(pc) = kind.as_any().downcast_ref::<PhotocouplerComp>() {
150        return format!("Vactrol_{:?}", pc.coupler_type);
151    }
152
153    // LFO
154    if let Some(lfo) = kind.as_any().downcast_ref::<Lfo>() {
155        return format!(
156            "LFO_{:?}_R{}_C{}",
157            lfo.waveform,
158            format_eng(lfo.timing_r, ""),
159            format_eng(lfo.timing_c, "")
160        );
161    }
162
163    // Tubes
164    if let Some(t) = kind.as_any().downcast_ref::<Triode>() {
165        return format!("Triode_{:?}", t.model);
166    }
167    if let Some(vm) = kind.as_any().downcast_ref::<VariMu>() {
168        return format!("VariMu_{}", vm.model);
169    }
170    if let Some(p) = kind.as_any().downcast_ref::<Pentode>() {
171        return format!("Pentode_{:?}", p.model);
172    }
173
174    // Envelope follower
175    if let Some(ef) = kind.as_any().downcast_ref::<EnvelopeFollower>() {
176        return format!(
177            "ENV_atk{}/{}_rel{}/{}_sens{}",
178            format_eng(ef.attack_r, ""),
179            format_eng(ef.attack_c, ""),
180            format_eng(ef.release_r, ""),
181            format_eng(ef.release_c, ""),
182            format_eng(ef.sensitivity_r, ""),
183        );
184    }
185
186    // MOSFETs
187    if let Some(m) = kind.as_any().downcast_ref::<Nmos>() {
188        return format!("N-MOS_{:?}", m.mosfet_type);
189    }
190    if let Some(m) = kind.as_any().downcast_ref::<Pmos>() {
191        return format!("P-MOS_{:?}", m.mosfet_type);
192    }
193
194    // BBD
195    if let Some(b) = kind.as_any().downcast_ref::<Bbd>() {
196        return format!("BBD_{:?}", b.bbd_type);
197    }
198
199    // Delay line
200    if let Some(dl) = kind.as_any().downcast_ref::<DelayLineComp>() {
201        return format!(
202            "DelayLine_{}-{}_{:?}_{:?}",
203            format_eng(dl.min_delay, "s"),
204            format_eng(dl.max_delay, "s"),
205            dl.interpolation,
206            dl.medium
207        );
208    }
209
210    // Tap
211    if let Some(tap) = kind.as_any().downcast_ref::<Tap>() {
212        return format!("Tap_{}_{:.1}x", tap.parent_id, tap.ratio);
213    }
214
215    // Neon
216    if let Some(n) = kind.as_any().downcast_ref::<Neon>() {
217        return match n.neon_type {
218            NeonType::Ne2 => "NE-2".into(),
219            NeonType::Ne51 => "NE-51".into(),
220            NeonType::Ne83 => "NE-83".into(),
221        };
222    }
223
224    // Synth ICs
225    if let Some(v) = kind.as_any().downcast_ref::<Vco>() {
226        return format!("VCO_{:?}", v.vco_type);
227    }
228    if let Some(v) = kind.as_any().downcast_ref::<Vcf>() {
229        return format!("VCF_{:?}", v.vcf_type);
230    }
231    if let Some(v) = kind.as_any().downcast_ref::<Vca>() {
232        return format!("VCA_{:?}", v.vca_type);
233    }
234    if let Some(c) = kind.as_any().downcast_ref::<Comparator>() {
235        return format!("Comparator_{:?}", c.comp_type);
236    }
237    if let Some(s) = kind.as_any().downcast_ref::<AnalogSwitch>() {
238        return format!("Switch_{:?}", s.switch_type);
239    }
240
241    // Matched transistors
242    if let Some(m) = kind.as_any().downcast_ref::<MatchedNpn>() {
243        return format!("Matched_NPN_{:?}", m.matched_type);
244    }
245    if let Some(m) = kind.as_any().downcast_ref::<MatchedPnp>() {
246        return format!("Matched_PNP_{:?}", m.matched_type);
247    }
248
249    // Transformer
250    if let Some(cfg) = kind.transformer_config() {
251        let pri = match cfg.primary_type {
252            crate::dsl::WindingType::Standard => "",
253            crate::dsl::WindingType::CenterTap => "CT",
254            crate::dsl::WindingType::PushPull => "PP",
255        };
256        let sec = match cfg.secondary_type {
257            crate::dsl::WindingType::Standard => "",
258            crate::dsl::WindingType::CenterTap => "CT",
259            crate::dsl::WindingType::PushPull => "PP",
260        };
261        return format!(
262            "XFMR_{:.1}:1_{}{}_{}H",
263            cfg.turns_ratio,
264            pri,
265            sec,
266            format_eng(cfg.primary_inductance, "")
267        );
268    }
269
270    // Rotary switch
271    if let Some(sw) = kind.as_any().downcast_ref::<RotarySwitch>() {
272        return format!("Rotary_{}pos", sw.linked_ids.len());
273    }
274
275    // Switch
276    if let Some(sw) = kind.as_any().downcast_ref::<Switch>() {
277        return format!("Switch_{}pos", sw.positions);
278    }
279
280    // Trigger input
281    if kind.as_any().downcast_ref::<TriggerInputComp>().is_some() {
282        return "TriggerInput".into();
283    }
284
285    // Fallback
286    kind.type_tag().to_string()
287}
288
289/// Format a value with engineering suffix for display.
290pub fn format_eng(val: f64, unit: &str) -> String {
291    if val >= 1e6 {
292        format!("{:.1}M{unit}", val / 1e6)
293    } else if val >= 1e3 {
294        format!("{:.1}k{unit}", val / 1e3)
295    } else if val >= 1.0 {
296        format!("{:.1}{unit}", val)
297    } else if val >= 1e-3 {
298        format!("{:.1}m{unit}", val * 1e3)
299    } else if val >= 1e-6 {
300        format!("{:.1}u{unit}", val * 1e6)
301    } else if val >= 1e-9 {
302        format!("{:.1}n{unit}", val * 1e9)
303    } else {
304        format!("{:.1}p{unit}", val * 1e12)
305    }
306}
307
308/// Build a net map from the DSL net definitions.
309/// Each unique junction point gets a net number.
310fn build_net_map(nets: &[NetDef]) -> Vec<(usize, String, Vec<Pin>)> {
311    // Collect all connected groups.  Each NetDef says "from connects to all of to".
312    // We merge nets that share a pin.
313    let mut groups: Vec<Vec<Pin>> = Vec::new();
314
315    for net in nets {
316        let mut all_pins: Vec<Pin> = vec![net.from.clone()];
317        all_pins.extend(net.to.iter().cloned());
318
319        // Find existing groups that share any of these pins
320        let mut merge_indices: Vec<usize> = Vec::new();
321        for (i, group) in groups.iter().enumerate() {
322            if all_pins.iter().any(|p| group.contains(p)) {
323                merge_indices.push(i);
324            }
325        }
326
327        if merge_indices.is_empty() {
328            groups.push(all_pins);
329        } else {
330            // Merge all matching groups + new pins into the first matching group
331            merge_indices.sort();
332            let target = merge_indices[0];
333            for &idx in merge_indices.iter().skip(1).rev() {
334                let g = groups.remove(idx);
335                groups[target].extend(g);
336            }
337            for p in all_pins {
338                if !groups[target].contains(&p) {
339                    groups[target].push(p);
340                }
341            }
342        }
343    }
344
345    // Assign net numbers (0 = unconnected, 1+ = nets)
346    groups
347        .into_iter()
348        .enumerate()
349        .map(|(i, pins)| {
350            let net_num = i + 1;
351            // Name the net after the first reserved node or first pin
352            let name = pins
353                .iter()
354                .find_map(|p| {
355                    if let Pin::Reserved(n) = p {
356                        Some(n.clone())
357                    } else {
358                        None
359                    }
360                })
361                .unwrap_or_else(|| format!("Net{net_num}"));
362            (net_num, name, pins)
363        })
364        .collect()
365}
366
367fn pin_to_string(pin: &Pin) -> String {
368    match pin {
369        Pin::Reserved(n) => n.clone(),
370        Pin::ComponentPin { component, pin } => format!("{component}.{pin}"),
371        Pin::Fork {
372            switch,
373            destinations,
374        } => {
375            let dests: Vec<_> = destinations.iter().map(pin_to_string).collect();
376            format!("fork({}, [{}])", switch, dests.join(", "))
377        }
378        Pin::SubcircuitPort { subcircuit, port } => format!("{subcircuit}.{port}"),
379    }
380}
381
382/// Export a parsed pedal definition to KiCad netlist format (S-expression).
383pub fn export_kicad_netlist(pedal: &PedalDef) -> String {
384    let mut out = String::with_capacity(4096);
385
386    writeln!(out, "(export (version D)").unwrap();
387    writeln!(out, "  (design").unwrap();
388    writeln!(out, "    (source \"{}.pedal\")", pedal.name).unwrap();
389    writeln!(out, "    (tool \"PedalKernel DSL\"))").unwrap();
390
391    // Components section
392    writeln!(out, "  (components").unwrap();
393    for comp in &pedal.components {
394        if let Some(lfo) = comp.kind.as_any().downcast_ref::<Lfo>() {
395            // LFO components expand into timing R and C for the physical circuit
396            let waveform = &lfo.waveform;
397            // Timing resistor
398            writeln!(
399                out,
400                "    (comp (ref R_{id}) (value \"{val}\") (libsource (lib \"Device:R\")) (field (name \"Note\") \"LFO {wf:?} timing\"))",
401                id = comp.id, val = format_eng(lfo.timing_r, "Ω"), wf = waveform
402            )
403            .unwrap();
404            // Timing capacitor
405            writeln!(
406                out,
407                "    (comp (ref C_{id}) (value \"{val}\") (libsource (lib \"Device:C\")) (field (name \"Note\") \"LFO {wf:?} timing\"))",
408                id = comp.id, val = format_eng(lfo.timing_c, "F"), wf = waveform
409            )
410            .unwrap();
411        } else if let Some(ef) = comp.kind.as_any().downcast_ref::<EnvelopeFollower>() {
412            // Envelope follower expands into 5 physical components
413            // Attack timing resistor
414            writeln!(
415                out,
416                "    (comp (ref R_{id}_ATK) (value \"{val}\") (libsource (lib \"Device:R\")) (field (name \"Note\") \"Envelope attack timing\"))",
417                id = comp.id, val = format_eng(ef.attack_r, "Ω")
418            )
419            .unwrap();
420            // Attack timing capacitor
421            writeln!(
422                out,
423                "    (comp (ref C_{id}_ATK) (value \"{val}\") (libsource (lib \"Device:C\")) (field (name \"Note\") \"Envelope attack timing\"))",
424                id = comp.id, val = format_eng(ef.attack_c, "F")
425            )
426            .unwrap();
427            // Release timing resistor
428            writeln!(
429                out,
430                "    (comp (ref R_{id}_REL) (value \"{val}\") (libsource (lib \"Device:R\")) (field (name \"Note\") \"Envelope release timing\"))",
431                id = comp.id, val = format_eng(ef.release_r, "Ω")
432            )
433            .unwrap();
434            // Release timing capacitor
435            writeln!(
436                out,
437                "    (comp (ref C_{id}_REL) (value \"{val}\") (libsource (lib \"Device:C\")) (field (name \"Note\") \"Envelope release timing\"))",
438                id = comp.id, val = format_eng(ef.release_c, "F")
439            )
440            .unwrap();
441            // Sensitivity resistor
442            writeln!(
443                out,
444                "    (comp (ref R_{id}_SENS) (value \"{val}\") (libsource (lib \"Device:R\")) (field (name \"Note\") \"Envelope sensitivity\"))",
445                id = comp.id, val = format_eng(ef.sensitivity_r, "Ω")
446            )
447            .unwrap();
448            // Rectifier diode (part of the physical envelope detector)
449            writeln!(
450                out,
451                "    (comp (ref D_{id}) (value \"1N4148\") (libsource (lib \"Device:D\")) (field (name \"Note\") \"Envelope rectifier\"))",
452                id = comp.id
453            )
454            .unwrap();
455        } else {
456            let (lib_ref, _prefix) = comp.kind.footprint_ref();
457            let val = value_str(&*comp.kind);
458            writeln!(
459                out,
460                "    (comp (ref {id}) (value \"{val}\") (libsource (lib \"{lib_ref}\")))",
461                id = comp.id
462            )
463            .unwrap();
464        }
465    }
466    writeln!(out, "  )").unwrap();
467
468    // Nets section
469    let net_map = build_net_map(&pedal.nets);
470    writeln!(out, "  (nets").unwrap();
471    for (num, name, pins) in &net_map {
472        write!(out, "    (net (code {num}) (name \"{name}\")").unwrap();
473        for pin in pins {
474            write!(out, " (node (ref \"{}\"))", pin_to_string(pin)).unwrap();
475        }
476        writeln!(out, ")").unwrap();
477    }
478    writeln!(out, "  )").unwrap();
479
480    writeln!(out, ")").unwrap();
481    out
482}
483
484// ---------------------------------------------------------------------------
485// Tests
486// ---------------------------------------------------------------------------
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn format_eng_values() {
494        assert_eq!(format_eng(4700.0, "Ω"), "4.7kΩ");
495        assert_eq!(format_eng(220e-9, "F"), "220.0nF");
496        assert_eq!(format_eng(0.1, "H"), "100.0mH");
497        assert_eq!(format_eng(1e6, "Ω"), "1.0MΩ");
498    }
499
500    #[test]
501    fn export_tube_screamer() {
502        let pedal = PedalDef {
503            name: "Tube Screamer".into(),
504            subtitle: None,
505            components: vec![
506                ComponentDef {
507                    id: "R1".into(),
508                    kind: Box::new(Resistor { value: 4700.0 }),
509                },
510                ComponentDef {
511                    id: "C1".into(),
512                    kind: Box::new(Capacitor {
513                        config: CapConfig::new(220e-9),
514                    }),
515                },
516                ComponentDef {
517                    id: "D1".into(),
518                    kind: Box::new(DiodePair {
519                        diode_type: DiodeType::Silicon,
520                    }),
521                },
522            ],
523            nets: vec![
524                NetDef {
525                    from: Pin::Reserved("in".into()),
526                    to: vec![Pin::ComponentPin {
527                        component: "C1".into(),
528                        pin: "a".into(),
529                    }],
530                },
531                NetDef {
532                    from: Pin::ComponentPin {
533                        component: "C1".into(),
534                        pin: "b".into(),
535                    },
536                    to: vec![
537                        Pin::ComponentPin {
538                            component: "R1".into(),
539                            pin: "a".into(),
540                        },
541                        Pin::ComponentPin {
542                            component: "D1".into(),
543                            pin: "a".into(),
544                        },
545                    ],
546                },
547            ],
548            controls: vec![],
549            supplies: vec![],
550            trims: vec![],
551            monitors: vec![],
552            sidechains: vec![],
553            mirrors: hashbrown::HashMap::new(),
554            calibrate: false,
555            subcircuits: vec![],
556            ports: vec![],
557            init_hints: vec![],
558            uses: vec![],
559        };
560
561        let netlist = export_kicad_netlist(&pedal);
562        assert!(netlist.contains("(export (version D)"));
563        assert!(netlist.contains("4.7kΩ"));
564        assert!(netlist.contains("220.0nF"));
565        assert!(netlist.contains("(net (code"));
566    }
567
568    #[test]
569    fn net_merging() {
570        // Two nets that share a pin should merge
571        let nets = vec![
572            NetDef {
573                from: Pin::Reserved("in".into()),
574                to: vec![Pin::ComponentPin {
575                    component: "C1".into(),
576                    pin: "a".into(),
577                }],
578            },
579            NetDef {
580                from: Pin::ComponentPin {
581                    component: "C1".into(),
582                    pin: "a".into(),
583                },
584                to: vec![Pin::ComponentPin {
585                    component: "R1".into(),
586                    pin: "a".into(),
587                }],
588            },
589        ];
590        let map = build_net_map(&nets);
591        // Should be a single net group
592        assert_eq!(map.len(), 1);
593        assert_eq!(map[0].2.len(), 3); // in, C1.a, R1.a
594    }
595}