Skip to main content

brep_app/panels/
kicad_import.rs

1//! Import a part from KiCad: its symbol, its pads and its 3D model, as ONE edit.
2//!
3//! The Symbol workbench imports a `.kicad_sym` symbol and the Pads workbench a
4//! `.kicad_mod` footprint (eCAD plan, decision 9). The host reads the files and
5//! eCAD parses them (`brep_ecad_core::kicad`). A symbol import follows KiCad's chain:
6//!
7//! 1. the symbol's `Footprint` property names `library:name`, found as
8//!    `<footprints>/<library>.pretty/<name>.kicad_mod`; a generic symbol leaves it
9//!    empty and offers `ki_fp_filters` patterns, and the user chooses among the
10//!    footprints they match;
11//! 2. the footprint's `(model …)` entry names a `.wrl` in KiCad's 3D library,
12//!    usually under `${KICAD9_3DMODEL_DIR}`;
13//! 3. BREP imports STEP, not VRML, so the model read is the `.step` of the same
14//!    name beside it.
15//!
16//! Every link that fails is a stated outcome, not an error: the part imports
17//! with what was found, and a note names the file that was looked for. KiCad's
18//! own library tables (`fp-lib-table`) are not read; a footprint library is found
19//! only under the footprints folder, by its nickname.
20//!
21//! # The part's frame
22//!
23//! The model and the ports are placed in KiCad's 3D frame of the footprint: x as
24//! the footprint's, y the footprint's NEGATED, z up from the board's top copper,
25//! millimetres. That is where KiCad's own 3D view and STEP exporter put a
26//! footprint's model ([`kicad::model_placement`]), so a pad at `(x, y)` µm in the
27//! pads block is at `(x / 1000, −y / 1000, 0)` mm in the part.
28//!
29//! # One undo
30//!
31//! The model becomes one IMPORT3D feature, each symbol pin one connection point
32//! at its pad, and the symbol and pads are written as the part's `symbol` and
33//! `pads` blocks. The features go in with ONE `add_features` (one checkpoint),
34//! and the blocks after it without a checkpoint of their own, so one Ctrl+Z takes
35//! the whole import away. A block written BEFORE the checkpoint would be inside
36//! the snapshot undo restores and survive the undo (`model_io.rs` explains the
37//! same trap for PMI).
38//!
39//! The model is imported here, not by the pipeline: KiCad turns a model by
40//! `Rz(−rz)·Ry(−ry)·Rx(−rx)`, and BREP's transform param by `Rx·Ry·Rz` with the
41//! angles as given, so KiCad's numbers cannot be handed to a transform feature.
42//! The STEP bodies are moved by KiCad's own matrix and sealed into the feature's
43//! `nativeBrep` payload, the lane a STEP-assembly part rides on, and the numbers
44//! they were placed by are kept in its `persistentData.kicadModel`.
45
46use crate::store::{ModelStore, KICAD_LIBRARY_KEY};
47use brep_render::brep_kernel as kernel;
48use brep_render::engine_state::EngineState;
49use brep_ecad_core::board::{Footprint, Model};
50use brep_ecad_core::kicad::{self, FootprintLink};
51use brep_ecad_core::Symbol;
52use serde_json::{json, Value};
53use std::path::Path;
54
55/// Which KiCad file an import starts from.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum KicadKind {
58    /// A `.kicad_sym` library: a symbol, and through it the footprint and model.
59    Symbol,
60    /// A `.kicad_mod` footprint: pads and the model.
61    Footprint,
62}
63
64impl KicadKind {
65    pub fn extension(self) -> &'static str {
66        match self {
67            KicadKind::Symbol => "kicad_sym",
68            KicadKind::Footprint => "kicad_mod",
69        }
70    }
71
72    /// The kind a file name's extension names, if any.
73    pub fn of_file(name: &str) -> Option<Self> {
74        let lower = name.to_ascii_lowercase();
75        [KicadKind::Symbol, KicadKind::Footprint]
76            .into_iter()
77            .find(|kind| lower.ends_with(&format!(".{}", kind.extension())))
78    }
79}
80
81// ============================================================================
82// Reading the user's files
83// ============================================================================
84
85/// The files an import reads: the user's KiCad library on disk. A trait so the
86/// tests can hand the chain a library without one being installed.
87pub trait KicadFiles {
88    /// Whether this build can read the user's files at all. The web build
89    /// cannot: the chain stops at the file the user uploaded.
90    fn available(&self) -> bool {
91        true
92    }
93    fn read(&self, path: &str) -> Option<Vec<u8>>;
94    fn is_file(&self, path: &str) -> bool;
95    fn is_dir(&self, path: &str) -> bool;
96    /// The names of `dir`'s entries.
97    fn list(&self, dir: &str) -> Vec<String>;
98}
99
100/// The filesystem, for the native build.
101pub struct DiskFiles;
102
103impl KicadFiles for DiskFiles {
104    fn available(&self) -> bool {
105        !cfg!(target_arch = "wasm32")
106    }
107    fn read(&self, path: &str) -> Option<Vec<u8>> {
108        std::fs::read(path).ok()
109    }
110    fn is_file(&self, path: &str) -> bool {
111        Path::new(path).is_file()
112    }
113    fn is_dir(&self, path: &str) -> bool {
114        Path::new(path).is_dir()
115    }
116    fn list(&self, dir: &str) -> Vec<String> {
117        std::fs::read_dir(dir)
118            .into_iter()
119            .flatten()
120            .flatten()
121            .map(|entry| entry.file_name().to_string_lossy().into_owned())
122            .collect()
123    }
124}
125
126fn join(dir: &str, name: &str) -> String {
127    Path::new(dir).join(name).to_string_lossy().into_owned()
128}
129
130fn parent(path: &str) -> Option<String> {
131    Path::new(path)
132        .parent()
133        .map(|dir| dir.to_string_lossy().into_owned())
134        .filter(|dir| !dir.is_empty())
135}
136
137fn stem(path: &str) -> String {
138    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
139    name.rsplit_once('.').map_or(name, |(stem, _)| stem).to_owned()
140}
141
142// ============================================================================
143// Where the library is
144// ============================================================================
145
146/// The variables KiCad names its footprint folder by, newest first.
147const FOOTPRINT_VARS: &[&str] = &[
148    "KICAD9_FOOTPRINT_DIR",
149    "KICAD8_FOOTPRINT_DIR",
150    "KICAD7_FOOTPRINT_DIR",
151    "KICAD6_FOOTPRINT_DIR",
152];
153/// The variables KiCad names its 3D-model folder by, newest first.
154/// `KISYS3DMOD` is the name before KiCad 6.
155const MODEL_VARS: &[&str] = &[
156    "KICAD9_3DMODEL_DIR",
157    "KICAD8_3DMODEL_DIR",
158    "KICAD7_3DMODEL_DIR",
159    "KICAD6_3DMODEL_DIR",
160    "KISYS3DMOD",
161];
162
163/// Where a KiCad install keeps its libraries, `(footprints, 3D models)`, for
164/// this platform. The first is also what a message names when none exists.
165fn install_defaults() -> &'static [(&'static str, &'static str)] {
166    if cfg!(target_os = "windows") {
167        &[(
168            r"C:\Program Files\KiCad\9.0\share\kicad\footprints",
169            r"C:\Program Files\KiCad\9.0\share\kicad\3dmodels",
170        )]
171    } else if cfg!(target_os = "macos") {
172        &[(
173            "/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints",
174            "/Applications/KiCad/KiCad.app/Contents/SharedSupport/3dmodels",
175        )]
176    } else {
177        &[
178            ("/usr/share/kicad/footprints", "/usr/share/kicad/3dmodels"),
179            ("/usr/local/share/kicad/footprints", "/usr/local/share/kicad/3dmodels"),
180            (
181                "/var/lib/flatpak/app/org.kicad.KiCad/current/active/files/share/kicad/footprints",
182                "/var/lib/flatpak/app/org.kicad.KiCad/current/active/files/share/kicad/3dmodels",
183            ),
184        ]
185    }
186}
187
188/// One library folder and where it came from, in words the dialog shows.
189#[derive(Clone, Debug, PartialEq, Eq)]
190pub struct Folder {
191    pub path: String,
192    pub from: String,
193}
194
195/// The user's KiCad library: the folder holding the `*.pretty` footprint
196/// libraries and the folder holding the `*.3dshapes` models.
197#[derive(Clone, Debug, PartialEq, Eq)]
198pub struct KicadLibrary {
199    pub footprints: Folder,
200    pub models: Folder,
201}
202
203impl KicadLibrary {
204    /// The folders a fresh session uses: KiCad's variable when it is set to a
205    /// folder, else the first install default that exists, else the first
206    /// install default (so a message can name where it looked).
207    pub fn seeded(env: &dyn Fn(&str) -> Option<String>, files: &dyn KicadFiles) -> Self {
208        let seed = |vars: &[&str], pick: fn(&(&'static str, &'static str)) -> &'static str| -> Folder {
209            for var in vars {
210                if let Some(path) = env(var).filter(|path| files.is_dir(path)) {
211                    return Folder { path, from: format!("from {var}") };
212                }
213            }
214            let defaults = install_defaults();
215            match defaults.iter().map(pick).find(|path| files.is_dir(path)) {
216                Some(path) => Folder { path: path.into(), from: "KiCad's install folder".into() },
217                None => Folder {
218                    path: pick(&defaults[0]).into(),
219                    from: format!("not found: {} unset, and no KiCad install here", vars.join(", ")),
220                },
221            }
222        };
223        Self {
224            footprints: seed(FOOTPRINT_VARS, |(footprints, _)| footprints),
225            models: seed(MODEL_VARS, |(_, models)| models),
226        }
227    }
228
229    /// The folders the user last chose (the [`KICAD_LIBRARY_KEY`] record), each
230    /// falling back to the seed when it is unset or no longer a folder. A stored
231    /// folder that is gone is SAID: the source names it and what is used instead.
232    pub fn load(
233        store: &dyn ModelStore,
234        env: &dyn Fn(&str) -> Option<String>,
235        files: &dyn KicadFiles,
236    ) -> Self {
237        let mut library = Self::seeded(env, files);
238        let stored: Value = store
239            .read(KICAD_LIBRARY_KEY)
240            .and_then(|text| serde_json::from_str(&text).ok())
241            .unwrap_or(Value::Null);
242        for (key, folder, what) in [
243            ("footprints", &mut library.footprints, "footprint"),
244            ("models", &mut library.models, "3D-model"),
245        ] {
246            let Some(path) = stored.get(key).and_then(Value::as_str).filter(|p| !p.is_empty()) else {
247                continue;
248            };
249            if files.is_dir(path) {
250                *folder = Folder { path: path.into(), from: "your saved folder".into() };
251            } else {
252                folder.from = format!(
253                    "your saved {what} folder {path} no longer exists, so this is {}",
254                    folder.from
255                );
256            }
257        }
258        library
259    }
260
261    /// Remember the folders for the next session.
262    pub fn save(&self, store: &dyn ModelStore) -> Result<(), String> {
263        let record = json!({ "footprints": self.footprints.path, "models": self.models.path });
264        store.write(KICAD_LIBRARY_KEY, &record.to_string())
265    }
266
267    /// Where footprint `library:name` is: `<footprints>/<library>.pretty/<name>.kicad_mod`.
268    pub fn footprint_file(&self, library: &str, name: &str) -> String {
269        join(&join(&self.footprints.path, &format!("{library}.pretty")), &format!("{name}.kicad_mod"))
270    }
271
272    /// A model path with KiCad's variables replaced: any of its 3D-model
273    /// variables is the models folder, and `KIPRJMOD` the folder the footprint
274    /// was read from. A relative path is relative to that folder too. Any other
275    /// variable is refused by name.
276    pub fn model_file(&self, path: &str, footprint_dir: Option<&str>) -> Result<String, String> {
277        let mut out = String::new();
278        let mut rest = path;
279        while let Some(start) = rest.find('$') {
280            let close = match rest[start + 1..].chars().next() {
281                Some('{') => '}',
282                Some('(') => ')',
283                _ => {
284                    out.push_str(&rest[..=start]);
285                    rest = &rest[start + 1..];
286                    continue;
287                }
288            };
289            let Some(end) = rest[start + 2..].find(close) else {
290                return Err(format!("the model path {path} has an unclosed variable"));
291            };
292            let name = &rest[start + 2..start + 2 + end];
293            let value = if MODEL_VARS.contains(&name) || is_model_var(name) {
294                self.models.path.clone()
295            } else if name == "KIPRJMOD" {
296                footprint_dir.map(str::to_owned).ok_or_else(|| {
297                    format!("the model path {path} uses ${{KIPRJMOD}}, and there is no project folder")
298                })?
299            } else {
300                return Err(format!(
301                    "the model path {path} uses ${{{name}}}, which names no KiCad 3D-model folder"
302                ));
303            };
304            out.push_str(&rest[..start]);
305            out.push_str(&value);
306            rest = &rest[start + 3 + end..];
307        }
308        out.push_str(rest);
309        if Path::new(&out).is_relative() {
310            if let Some(dir) = footprint_dir {
311                return Ok(join(dir, &out));
312            }
313        }
314        Ok(out)
315    }
316}
317
318/// `KICAD<n>_3DMODEL_DIR` for any version `n`.
319fn is_model_var(name: &str) -> bool {
320    name.strip_prefix("KICAD")
321        .and_then(|rest| rest.strip_suffix("_3DMODEL_DIR"))
322        .is_some_and(|version| !version.is_empty() && version.chars().all(|c| c.is_ascii_digit()))
323}
324
325// ============================================================================
326// Following the chain
327// ============================================================================
328
329/// A model's `.step`, read but not yet parsed. Parsing is the slow half: the
330/// real KiCad 9.0.0 SOIC-8 model (254 KB) took 591 ms in a release build, so
331/// the dialog parses on a reader thread ([`parse_step`]) and says so.
332#[derive(Clone)]
333pub struct StepSource {
334    /// The `(model …)` entry as the footprint gives it.
335    pub model: Model,
336    /// The `.step` that was read.
337    pub step_file: String,
338    pub text: String,
339}
340
341/// The 3D model a footprint names, read and parsed.
342#[derive(Clone)]
343pub struct ModelFile {
344    /// The `(model …)` entry as the footprint gives it.
345    pub model: Model,
346    /// The `.step` that was read.
347    pub step_file: String,
348    /// The file's bodies, in its own frame; placed at import.
349    pub bodies: Vec<kernel::BrepSolid>,
350    pub appearances: Vec<kernel::BodyAppearance>,
351}
352
353/// Everything one import brings, and what it could not.
354#[derive(Clone, Default)]
355pub struct KicadPart {
356    pub symbol: Option<Symbol>,
357    pub footprint: Option<Footprint>,
358    /// Where the footprint was read from, when it came from the library.
359    pub footprint_file: Option<String>,
360    pub model: Option<ModelFile>,
361    /// The model's `.step` while it waits to be parsed ([`KicadPart::parsed`]).
362    pub step: Option<StepSource>,
363    /// What the user is told: every link that failed, naming the file looked
364    /// for, and the parsers' own warnings.
365    pub notes: Vec<String>,
366}
367
368/// The symbols of a `.kicad_sym` library, named `nickname:symbol` after the
369/// file, with the importer's warnings.
370pub fn read_symbol_library(file: &str, bytes: &[u8]) -> Result<(Vec<Symbol>, Vec<String>), String> {
371    let text = std::str::from_utf8(bytes).map_err(|_| format!("{file} is not UTF-8 text"))?;
372    let report = kicad::import_library(text, &stem(file)).map_err(|error| format!("{file}: {error}"))?;
373    if report.symbols.is_empty() {
374        return Err(format!("{file} holds no symbol this importer reads"));
375    }
376    Ok((report.symbols, report.warnings))
377}
378
379/// How the footprint of a symbol import is found.
380#[derive(Clone, Debug, PartialEq, Eq)]
381pub enum FootprintChoice {
382    /// The one the symbol's `Footprint` property names.
383    Named,
384    /// One the user chose, `(library, name)`.
385    Chosen(String, String),
386    /// None: the part imports with its symbol only.
387    Skip,
388}
389
390/// A symbol import: the symbol, then its footprint as `choice` says, then the
391/// footprint's model.
392pub fn part_from_symbol(
393    symbol: Symbol,
394    choice: &FootprintChoice,
395    library: &KicadLibrary,
396    files: &dyn KicadFiles,
397) -> KicadPart {
398    let id = symbol.library_id.clone();
399    let mut part = KicadPart { symbol: Some(symbol), ..KicadPart::default() };
400    let wanted = match (choice, kicad::footprint_link(part.symbol.as_ref().unwrap())) {
401        (FootprintChoice::Chosen(lib, name), _) => Some((lib.clone(), name.clone())),
402        (FootprintChoice::Skip, _) => {
403            part.notes.push(format!("{id}: no footprint chosen, so the part imports with its symbol only"));
404            None
405        }
406        (FootprintChoice::Named, FootprintLink::Named { library: lib, name }) if lib.is_empty() => {
407            part.notes.push(format!(
408                "{id} names footprint '{name}' without a library, so it cannot be found; \
409                 the part imports with its symbol only"
410            ));
411            None
412        }
413        (FootprintChoice::Named, FootprintLink::Named { library: lib, name }) => Some((lib, name)),
414        (FootprintChoice::Named, FootprintLink::Chosen { .. }) => {
415            part.notes.push(format!("{id} names no footprint, so the part imports with its symbol only"));
416            None
417        }
418    };
419    let Some((lib, name)) = wanted else {
420        return part;
421    };
422    if !files.available() {
423        part.notes.push(format!(
424            "this build cannot read your KiCad library, so footprint {lib}:{name} is not followed"
425        ));
426        return part;
427    }
428    let file = library.footprint_file(&lib, &name);
429    let Some(bytes) = files.read(&file) else {
430        part.notes.push(format!("footprint {lib}:{name} not found: looked for {file}"));
431        return part;
432    };
433    match read_footprint(&file, &bytes) {
434        Ok((footprint, warnings)) => {
435            part.notes.extend(warnings);
436            // The symbol keeps `library:name`, KiCad's footprint id, so a netlist
437            // names a footprint Pcbnew can find (the third eCAD audit, B10).
438            if let Some(symbol) = &mut part.symbol { kicad::assign_footprint(symbol, &lib, &name); }
439            part.footprint = Some(footprint);
440            part.footprint_file = Some(file);
441            attach_model(&mut part, library, files);
442        }
443        Err(error) => part.notes.push(error),
444    }
445    part
446}
447
448/// A footprint import: the pads of `file`, then its model.
449pub fn part_from_footprint(
450    file: &str,
451    bytes: &[u8],
452    library: &KicadLibrary,
453    files: &dyn KicadFiles,
454) -> Result<KicadPart, String> {
455    let (footprint, warnings) = read_footprint(file, bytes)?;
456    let mut part = KicadPart {
457        footprint: Some(footprint),
458        footprint_file: Some(file.into()),
459        notes: warnings,
460        ..KicadPart::default()
461    };
462    attach_model(&mut part, library, files);
463    Ok(part)
464}
465
466fn read_footprint(file: &str, bytes: &[u8]) -> Result<(Footprint, Vec<String>), String> {
467    let text = std::str::from_utf8(bytes).map_err(|_| format!("{file} is not UTF-8 text"))?;
468    let (footprint, warnings) =
469        kicad::import_footprint(text).map_err(|error| format!("footprint {file}: {error}"))?;
470    Ok((footprint, warnings.into_iter().map(|w| format!("footprint {}: {w}", stem(file))).collect()))
471}
472
473/// The last two links: the footprint's `(model …)` entry, then the `.step`
474/// beside the `.wrl` it names.
475fn attach_model(part: &mut KicadPart, library: &KicadLibrary, files: &dyn KicadFiles) {
476    let footprint = part.footprint.as_ref().expect("a footprint to follow");
477    let Some(model) = footprint.model.clone() else {
478        part.notes.push(format!(
479            "footprint {} names no 3D model, so the part imports without one",
480            footprint.name
481        ));
482        return;
483    };
484    if !files.available() {
485        part.notes.push(format!(
486            "this build cannot read your KiCad library, so the 3D model {} is not read",
487            model.path
488        ));
489        return;
490    }
491    let beside = part.footprint_file.as_deref().and_then(parent);
492    let step_file = match library.model_file(&model.step_path(), beside.as_deref()) {
493        Ok(file) => file,
494        Err(error) => {
495            part.notes.push(format!("3D model not read: {error}"));
496            return;
497        }
498    };
499    let Some(bytes) = files.read(&step_file) else {
500        let named = library.model_file(&model.path, beside.as_deref()).unwrap_or_default();
501        part.notes.push(if named != step_file && files.is_file(&named) {
502            format!(
503                "3D model: the library has {named} but no {step_file} beside it, and BREP imports \
504                 STEP, not VRML; the part imports without a model"
505            )
506        } else {
507            format!("3D model not found: looked for {step_file}; the part imports without a model")
508        });
509        return;
510    };
511    match String::from_utf8(bytes) {
512        Ok(text) => part.step = Some(StepSource { model, step_file, text }),
513        Err(_) => part.notes.push(format!(
514            "3D model {step_file} is not UTF-8 text; the part imports without a model"
515        )),
516    }
517}
518
519/// Parse a model's `.step` into its bodies: the slow half of the chain, which
520/// the dialog runs on a reader thread.
521pub fn parse_step(source: &StepSource) -> Result<ModelFile, String> {
522    let (bodies, appearances) = kernel::import_step_with_appearance(&source.text).map_err(|error| {
523        format!(
524            "3D model {} did not import ({error}); the part imports without a model",
525            source.step_file
526        )
527    })?;
528    Ok(ModelFile { model: source.model.clone(), step_file: source.step_file.clone(), bodies, appearances })
529}
530
531impl KicadPart {
532    /// Take a parse's outcome: the model, or a note saying why there is none.
533    pub fn settle_model(&mut self, parsed: Result<ModelFile, String>) {
534        self.step = None;
535        match parsed {
536            Ok(model) => self.model = Some(model),
537            Err(note) => self.notes.push(note),
538        }
539    }
540
541    /// The part with its model's `.step` parsed here and now.
542    pub fn parsed(mut self) -> Self {
543        if let Some(step) = self.step.take() {
544            let outcome = parse_step(&step);
545            self.settle_model(outcome);
546        }
547        self
548    }
549}
550
551/// The footprints a generic symbol's patterns offer, `(library, name)`, from
552/// every `*.pretty` folder under the footprints folder, sorted.
553pub fn footprint_candidates(
554    filters: &[String],
555    library: &KicadLibrary,
556    files: &dyn KicadFiles,
557) -> Vec<(String, String)> {
558    let mut found = Vec::new();
559    for entry in files.list(&library.footprints.path) {
560        let Some(lib) = entry.strip_suffix(".pretty") else {
561            continue;
562        };
563        for file in files.list(&join(&library.footprints.path, &entry)) {
564            let Some(name) = file.strip_suffix(".kicad_mod") else {
565                continue;
566            };
567            if kicad::footprint_filter_matches(filters, lib, name) {
568                found.push((lib.to_owned(), name.to_owned()));
569            }
570        }
571    }
572    found.sort();
573    found
574}
575
576// ============================================================================
577// The import itself
578// ============================================================================
579
580/// What an import did, for the status line and the toast.
581#[derive(Clone, Debug, Default)]
582pub struct Imported {
583    pub summary: String,
584    pub notes: Vec<String>,
585}
586
587/// Where a pad centre is in the part: `(x / 1000, −y / 1000, 0)` mm (see the
588/// module doc for the frame).
589pub fn pad_point(footprint: &Footprint, index: usize) -> [f64; 3] {
590    let at = footprint.pads[index].at;
591    [f64::from(at.x) / 1000., -f64::from(at.y) / 1000., 0.]
592}
593
594/// A new connection point's direction: +Z, up out of the board, the way a lead
595/// or a wire leaves a pad. A point's direction is its transform's rotated +X
596/// (`feature_pipeline/ports.rs`), and `Ry(-90 deg)` turns +X onto +Z.
597const PORT_UP: [f64; 3] = [0., -90., 0.];
598/// The port group a KiCad import declares its pins' points in, and its purpose:
599/// these points ARE the part's pads, so the group is a `pcb` one.
600const IMPORT_PORT: &str = "Pins";
601const IMPORT_PURPOSE: &str = "pcb";
602
603/// Write `part` into the open part document as ONE undoable edit.
604///
605/// Refused, with nothing written, when the part already has the block this
606/// import would write: a KiCad import does not replace a symbol or pads.
607pub fn import_part(state: &mut EngineState, part: &KicadPart) -> Result<Imported, String> {
608    let parsed;
609    let part = if part.step.is_some() {
610        parsed = part.clone().parsed();
611        &parsed
612    } else {
613        part
614    };
615    if part.symbol.is_none() && part.footprint.is_none() {
616        return Err("nothing to import".into());
617    }
618    if part.symbol.is_some() && state.history.symbol_block().is_some() {
619        return Err("this part already has a symbol, and a KiCad import does not replace one".into());
620    }
621    if part.footprint.is_some() && state.history.pads_block().is_some() {
622        return Err("this part already has pads, and a KiCad import does not replace them".into());
623    }
624    let symbol_block = part.symbol.as_ref().map(serde_json::to_value).transpose().map_err(|e| e.to_string())?;
625    let pads_block = part.footprint.as_ref().map(serde_json::to_value).transpose().map_err(|e| e.to_string())?;
626
627    let mut notes = part.notes.clone();
628    let mut features = Vec::new();
629    let mut summary = Vec::new();
630    if let Some(symbol) = &part.symbol {
631        summary.push(format!("symbol {} ({})", symbol.library_id, crate::panels::count(symbol.pins.len(), "pin", "pins")));
632    }
633    if let Some(footprint) = &part.footprint {
634        summary.push(format!("{} pads", footprint.pads.len()));
635    }
636    if let Some(model) = &part.model {
637        features.push(model_feature(state, model)?);
638        summary.push(format!("3D model ({})", crate::panels::count(model.bodies.len(), "body", "bodies")));
639    }
640    let mut ports_block = None;
641    if let Some(symbol) = &part.symbol {
642        let (block, port_notes) = port_points(state, symbol, part.footprint.as_ref());
643        notes.extend(port_notes);
644        let added = block["points"].as_array().map_or(0, Vec::len);
645        if added > 0 {
646            summary.push(format!("{added} connection points"));
647            ports_block = Some(block);
648        }
649    }
650
651    // The one checkpoint: the features' when there are any, else the first
652    // block's own. Every later write falls after it.
653    let mut checkpointed = !features.is_empty();
654    if checkpointed {
655        state.add_features(&features);
656    }
657    // Before the symbol, so the symbol write's own pin follow finds each pin's
658    // point already declared and adds none.
659    if let Some(block) = ports_block {
660        let mut blocks = state
661            .history
662            .ports_block()
663            .and_then(|value| value.as_array().cloned())
664            .unwrap_or_default();
665        blocks.push(block);
666        let blocks = Some(serde_json::Value::Array(blocks));
667        if checkpointed {
668            state.history.set_ports_block_no_undo(blocks);
669        } else {
670            state.history.set_ports_block(blocks, None);
671            checkpointed = true;
672        }
673    }
674    if let Some(block) = symbol_block {
675        if checkpointed {
676            state.history.set_symbol_block_no_undo(Some(block));
677        } else {
678            state.history.set_symbol_block(Some(block), None);
679            checkpointed = true;
680        }
681    }
682    if let Some(block) = pads_block {
683        if checkpointed {
684            state.history.set_pads_block_no_undo(Some(block));
685        } else {
686            state.history.set_pads_block(Some(block), None);
687        }
688    }
689
690    // Writing the symbol ran History's pin/point binding. Each pin found the
691    // point declared for it above (a pin pairs with the point carrying its
692    // name), so it added none; what it could not bind, it says.
693    if let Some(hold) = state.history.pin_port_hold() {
694        notes.push(format!("the pins were not bound to connection points: {hold}"));
695    }
696    if let Some(report) = state.history.pin_point_report() {
697        notes.extend(report.problems.iter().map(|problem| problem.message()));
698    }
699    Ok(Imported { summary: format!("imported {}", summary.join(", ")), notes })
700}
701
702/// The IMPORT3D feature for the model: its bodies moved by KiCad's matrix and
703/// sealed, under their final names, into a `nativeBrep` payload.
704fn model_feature(state: &mut EngineState, file: &ModelFile) -> Result<Value, String> {
705    let transform = kernel::AffineTransform::new(kicad::model_placement(&file.model))
706        .map_err(|error| format!("3D model {}: {error}", file.step_file))?;
707    // A negative scale mirrors, and a mirror must reverse the faces or the
708    // solid turns inside out (`transform_brep` refuses it otherwise).
709    let mirrored = transform.determinant3() < 0.0;
710    let bodies = file
711        .bodies
712        .iter()
713        .map(|body| kernel::transform_brep(body, transform, mirrored))
714        .collect::<Result<Vec<_>, _>>()
715        .map_err(|error| format!("3D model {}: {error}", file.step_file))?;
716    let id = state.next_feature_id(&brep_render::features::feature_short_name("IMPORT3D"));
717    // The file's colours are stamped into the payload, not into the open
718    // document's metadata: the feature's run merges them back from there.
719    let payload = {
720        let _isolation = kernel::IsolatedSceneMetadata::begin();
721        kernel::native_import_payload_with_appearance(&id, &bodies, &file.appearances)
722    }
723    .map_err(|error| format!("3D model {}: {error}", file.step_file))?;
724    let model = &file.model;
725    Ok(json!({
726        "type": "IMPORT3D",
727        "inputParams": { "id": id, "nativeBrep": payload },
728        "persistentData": {
729            "kicadModel": {
730                "path": model.path,
731                "stepFile": file.step_file,
732                "offset": model.offset,
733                "scale": model.scale,
734                "rotation": model.rotation,
735            }
736        },
737    }))
738}
739
740/// One connection point per symbol pin, NAMED with the pin's label, at the
741/// FIRST pad carrying that label — as one declared port group. A pin whose
742/// name the part already declares keeps that point where it is. Returns the
743/// group and what the user is told: a pin on several pads, a pin on none.
744fn port_points(
745    state: &EngineState,
746    symbol: &Symbol,
747    footprint: Option<&Footprint>,
748) -> (Value, Vec<String>) {
749    let existing = state.history.declared_points();
750    let mut points = Vec::new();
751    let mut notes = Vec::new();
752    if footprint.is_none() && !symbol.pins.is_empty() {
753        notes.push("no pads were imported, so every new connection point sits at the part origin".into());
754    }
755    for pin in &symbol.pins {
756        let label = &pin.number;
757        if let Some(point) = existing.iter().find(|point| &point.point == label) {
758            notes.push(format!(
759                "pin '{label}' takes the part's existing connection point {}, which stays where it is",
760                point.address()
761            ));
762            continue;
763        }
764        let mut position = [0., 0., 0.];
765        if let Some(footprint) = footprint {
766            let pads: Vec<usize> =
767                (0..footprint.pads.len()).filter(|&i| &footprint.pads[i].number == label).collect();
768            match pads.as_slice() {
769                [] => notes.push(format!("pin '{label}' matches no pad, so its point sits at the part origin")),
770                [first, rest @ ..] => {
771                    position = pad_point(footprint, *first);
772                    if !rest.is_empty() {
773                        notes.push(format!(
774                            "pin '{label}' is on {} pads; its point is on the first, at ({}, {}) mm",
775                            pads.len(),
776                            position[0],
777                            position[1]
778                        ));
779                    }
780                }
781            }
782        }
783        points.push(json!({
784            "name": label,
785            "transform": { "position": position, "rotationEuler": PORT_UP }
786        }));
787    }
788    (json!({ "name": IMPORT_PORT, "purpose": IMPORT_PURPOSE, "points": points }), notes)
789}
790
791// ============================================================================
792// The dialog
793// ============================================================================
794
795/// Where a reviewed part came from, so new library folders can re-read it.
796#[derive(Clone)]
797enum Origin {
798    Symbol { symbol: Symbol, choice: FootprintChoice },
799    Footprint { file: String, bytes: Vec<u8> },
800}
801
802enum Stage {
803    /// Choosing the file; the file dialog's explorer draws this one.
804    File,
805    /// A library holding several symbols: choose one.
806    Symbol { file: String, symbols: Vec<Symbol>, warnings: Vec<String>, selected: Option<usize> },
807    /// A generic symbol: choose its footprint among those its patterns offer.
808    Footprint {
809        symbol: Symbol,
810        warnings: Vec<String>,
811        filters: Vec<String>,
812        candidates: Vec<(String, String)>,
813        selected: Option<usize>,
814    },
815    /// Everything read: the user confirms.
816    Review {
817        origin: Origin,
818        warnings: Vec<String>,
819        part: KicadPart,
820        /// The reader parsing the model's `.step`, while it runs.
821        reading: Option<Reader>,
822        /// The symbol and its file's warnings when the chain STOPPED at the
823        /// footprint folder (a generic symbol, and no folder to offer
824        /// footprints from). New folders then re-run the chain from the
825        /// symbol, which offers the footprints the review could not; reading
826        /// the review again would only repeat "no footprint folder".
827        rechoose: Option<(Symbol, Vec<String>)>,
828    },
829}
830
831/// What a click in a stage asks for, done once the stage is drawn.
832enum Step {
833    ChooseSymbol(Symbol, Vec<String>),
834    Review(Origin, Vec<String>),
835}
836
837/// A `.step` being parsed off the UI thread: the answer arrives on `answer`.
838struct Reader {
839    answer: std::sync::mpsc::Receiver<Result<ModelFile, String>>,
840    /// The file's size, for the progress line.
841    bytes: usize,
842}
843
844impl Reader {
845    /// Start parsing `source`. The native build parses on a thread of its own;
846    /// the web build never gets here (it cannot read a KiCad library, so a part
847    /// there has no `.step` to parse) and would parse in place.
848    fn start(source: StepSource) -> Self {
849        let bytes = source.text.len();
850        let (send, answer) = std::sync::mpsc::channel();
851        #[cfg(not(target_arch = "wasm32"))]
852        std::thread::spawn(move || {
853            // A dialog cancelled meanwhile has dropped the receiver; nobody waits.
854            let _ = send.send(parse_step(&source));
855        });
856        #[cfg(target_arch = "wasm32")]
857        let _ = send.send(parse_step(&source));
858        Self { answer, bytes }
859    }
860}
861
862/// What a frame of the dialog came to.
863pub enum Outcome {
864    Open,
865    Closed,
866    Imported(Imported),
867}
868
869/// The KiCad import modal's state, owned by the file dialog, which draws its
870/// file-choosing stage with its own explorer.
871pub struct KicadImport {
872    kind: KicadKind,
873    stage: Stage,
874    library: Option<KicadLibrary>,
875    footprints_buf: String,
876    models_buf: String,
877    search: String,
878    pub status: String,
879}
880
881fn process_env(var: &str) -> Option<String> {
882    std::env::var(var).ok().filter(|value| !value.is_empty())
883}
884
885impl KicadImport {
886    pub fn new() -> Self {
887        Self {
888            kind: KicadKind::Symbol,
889            stage: Stage::File,
890            library: None,
891            footprints_buf: String::new(),
892            models_buf: String::new(),
893            search: String::new(),
894            status: String::new(),
895        }
896    }
897
898    pub fn kind(&self) -> KicadKind {
899        self.kind
900    }
901
902    /// Begin an import of `kind`: the file comes next.
903    pub fn start(&mut self, kind: KicadKind, store: &dyn ModelStore) {
904        self.kind = kind;
905        self.stage = Stage::File;
906        self.search.clear();
907        self.status.clear();
908        self.set_library(KicadLibrary::load(store, &process_env, &DiskFiles));
909    }
910
911    fn set_library(&mut self, library: KicadLibrary) {
912        self.footprints_buf = library.footprints.path.clone();
913        self.models_buf = library.models.path.clone();
914        self.library = Some(library);
915    }
916
917    /// Whether the file is still to be chosen.
918    pub fn wants_file(&self) -> bool {
919        matches!(self.stage, Stage::File)
920    }
921
922    /// The chosen file arrived: parse it and go on to what it needs next.
923    /// `file` is its full path where the store has one, which is where a
924    /// footprint's relative model path starts from.
925    pub fn take_file(&mut self, file: &str, bytes: &[u8], files: &dyn KicadFiles) {
926        let Some(kind) = KicadKind::of_file(file) else {
927            self.status = format!("{file} is not a .kicad_sym or .kicad_mod file");
928            return;
929        };
930        self.kind = kind;
931        let library = self.library.clone().unwrap_or_else(|| KicadLibrary::seeded(&process_env, files));
932        self.library = Some(library.clone());
933        match kind {
934            KicadKind::Symbol => match read_symbol_library(file, bytes) {
935                Ok((mut symbols, warnings)) if symbols.len() == 1 => {
936                    self.choose_symbol(symbols.remove(0), warnings, files)
937                }
938                Ok((symbols, warnings)) => {
939                    self.search.clear();
940                    self.stage = Stage::Symbol { file: file.into(), symbols, warnings, selected: None };
941                }
942                Err(error) => self.status = format!("import failed: {error}"),
943            },
944            KicadKind::Footprint => {
945                let origin = Origin::Footprint { file: file.into(), bytes: bytes.to_vec() };
946                self.review(origin, Vec::new(), files);
947            }
948        }
949    }
950
951    /// A symbol is chosen: follow its footprint, or offer the footprints its
952    /// patterns match.
953    fn choose_symbol(&mut self, symbol: Symbol, warnings: Vec<String>, files: &dyn KicadFiles) {
954        let library = self.library.clone().expect("a library once a file is taken");
955        match kicad::footprint_link(&symbol) {
956            FootprintLink::Chosen { filters } if files.available() && files.is_dir(&library.footprints.path) => {
957                let candidates = footprint_candidates(&filters, &library, files);
958                self.search.clear();
959                self.stage = Stage::Footprint { symbol, warnings, filters, candidates, selected: None };
960            }
961            FootprintLink::Chosen { .. } => {
962                let rechoose = (symbol.clone(), warnings.clone());
963                let mut warnings = warnings;
964                warnings.push(if files.available() {
965                    format!(
966                        "no footprint folder at {}, so no footprint can be chosen",
967                        library.footprints.path
968                    )
969                } else {
970                    "this build cannot read your KiCad library, so no footprint can be chosen".into()
971                });
972                self.review(Origin::Symbol { symbol, choice: FootprintChoice::Skip }, warnings, files);
973                if let Stage::Review { rechoose: slot, .. } = &mut self.stage {
974                    *slot = Some(rechoose);
975                }
976            }
977            FootprintLink::Named { .. } => {
978                self.review(Origin::Symbol { symbol, choice: FootprintChoice::Named }, warnings, files)
979            }
980        }
981    }
982
983    fn review(&mut self, origin: Origin, warnings: Vec<String>, files: &dyn KicadFiles) {
984        let library = self.library.clone().expect("a library once a file is taken");
985        let part = match &origin {
986            Origin::Symbol { symbol, choice } => part_from_symbol(symbol.clone(), choice, &library, files),
987            Origin::Footprint { file, bytes } => match part_from_footprint(file, bytes, &library, files) {
988                Ok(part) => part,
989                Err(error) => {
990                    self.status = format!("import failed: {error}");
991                    return;
992                }
993            },
994        };
995        let mut part = part;
996        let reading = part.step.take().map(Reader::start);
997        self.stage = Stage::Review { origin, warnings, part, reading, rechoose: None };
998    }
999
1000    /// Take the reader's answer if it has come. While it has not, the dialog
1001    /// asks for another frame, so the answer shows without the pointer moving.
1002    fn poll_reader(&mut self, ctx: &egui::Context) {
1003        let Stage::Review { part, reading: Some(reader), .. } = &mut self.stage else {
1004            return;
1005        };
1006        match reader.answer.try_recv() {
1007            Ok(parsed) => part.settle_model(parsed),
1008            Err(std::sync::mpsc::TryRecvError::Empty) => {
1009                ctx.request_repaint_after(std::time::Duration::from_millis(50));
1010                return;
1011            }
1012            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
1013                part.notes.push("the 3D model's reader stopped without an answer; the part imports without a model".into())
1014            }
1015        }
1016        if let Stage::Review { reading, .. } = &mut self.stage {
1017            *reading = None;
1018        }
1019    }
1020
1021    /// Whether the model's `.step` is still being parsed.
1022    pub fn reading(&self) -> bool {
1023        matches!(self.stage, Stage::Review { reading: Some(_), .. })
1024    }
1025
1026
1027    /// Where the dialog is, for the published file state: its kind, its
1028    /// stage, and what a review would import.
1029    pub fn state_json(&self) -> Value {
1030        let stage = match &self.stage {
1031            Stage::File => "file",
1032            Stage::Symbol { .. } => "symbol",
1033            Stage::Footprint { .. } => "footprint",
1034            Stage::Review { .. } => "review",
1035        };
1036        let review = self.reviewed().map(|part| {
1037            json!({
1038                "symbol": part.symbol.as_ref().map(|s| s.library_id.clone()),
1039                "pads": part.footprint.as_ref().map(|f| f.pads.len()),
1040                "footprintFile": part.footprint_file,
1041                "stepFile": part.model.as_ref().map(|m| m.step_file.clone()),
1042                "notes": part.notes,
1043                "reading": self.reading(),
1044            })
1045        });
1046        json!({
1047            "kind": self.kind.extension(),
1048            "stage": stage,
1049            "status": self.status,
1050            "footprints": self.library.as_ref().map(|l| l.footprints.path.clone()),
1051            "models": self.library.as_ref().map(|l| l.models.path.clone()),
1052            "review": review,
1053        })
1054    }
1055
1056    /// The part the dialog would import now, once it has been read.
1057    pub fn reviewed(&self) -> Option<&KicadPart> {
1058        match &self.stage {
1059            Stage::Review { part, .. } => Some(part),
1060            _ => None,
1061        }
1062    }
1063
1064    /// Import the reviewed part; the dialog closes on success.
1065    pub fn confirm(&mut self, state: &mut EngineState) -> Result<Imported, String> {
1066        let Stage::Review { part, warnings, reading, .. } = &self.stage else {
1067            return Err("nothing has been read yet".into());
1068        };
1069        if reading.is_some() {
1070            return Err("the 3D model is still being read".into());
1071        }
1072        let mut imported = import_part(state, part)?;
1073        imported.notes.splice(0..0, warnings.iter().cloned());
1074        self.stage = Stage::File;
1075        Ok(imported)
1076    }
1077
1078    /// Draw the stages after the file: the symbol list, the footprint list,
1079    /// the review. Hit keys are `kicad:*` (see `file::HIT_KEYS`).
1080    pub fn show(
1081        &mut self,
1082        ctx: &egui::Context,
1083        state: &mut EngineState,
1084        store: &dyn ModelStore,
1085        hits: &mut std::collections::HashMap<String, egui::Rect>,
1086    ) -> Outcome {
1087        self.show_with(ctx, state, store, hits, &DiskFiles)
1088    }
1089
1090    /// [`Self::show`] reading the library through `files`.
1091    ///
1092    /// The layout is three bands. The folders, the stage's question and its
1093    /// search field sit at the top; the stage's buttons (Next, Use this
1094    /// footprint, Import) sit in the footer with Cancel; only the LIST between
1095    /// them scrolls. With Next laid out after the list, a 100-row search put it
1096    /// at y = 2540 in a 960-point window (ecad-reaudit-2026-09-23, B3).
1097    fn show_with(
1098        &mut self,
1099        ctx: &egui::Context,
1100        state: &mut EngineState,
1101        store: &dyn ModelStore,
1102        hits: &mut std::collections::HashMap<String, egui::Rect>,
1103        files: &dyn KicadFiles,
1104    ) -> Outcome {
1105        self.poll_reader(ctx);
1106        let reading = self.reading();
1107        let mut outcome = Outcome::Open;
1108        let mut step: Option<Step> = None;
1109        let mut hit = |key: &str, response: &egui::Response| {
1110            hits.insert(format!("kicad:{key}"), response.rect);
1111        };
1112        let modal = egui::Modal::new(egui::Id::new("brep-kicad-import")).show(ctx, |ui| {
1113            super::file_explorer::dialog_body(ui, |ui| {
1114                ui.heading("Import from KiCad");
1115                ui.add_space(4.0);
1116                super::file_explorer::dialog_footer(ui, "kicad", |ui| {
1117                    if !self.status.is_empty() {
1118                        ui.add_space(4.0);
1119                        ui.weak(&self.status);
1120                    }
1121                    ui.horizontal(|ui| {
1122                        let cancel = ui.button("Cancel");
1123                        hit("cancel", &cancel);
1124                        if cancel.clicked() {
1125                            outcome = Outcome::Closed;
1126                        }
1127                        step = step.take().or(self.show_buttons(ui, &mut hit));
1128                        if matches!(self.stage, Stage::Review { .. }) {
1129                            let import = ui.add_enabled(!reading, egui::Button::new("Import"));
1130                            hit("import", &import);
1131                            if import.clicked() {
1132                                match self.confirm(state) {
1133                                    Ok(imported) => outcome = Outcome::Imported(imported),
1134                                    Err(error) => self.status = format!("import refused: {error}"),
1135                                }
1136                            }
1137                        }
1138                    });
1139                });
1140                self.show_library(ui, store, files, &mut hit);
1141                ui.separator();
1142                self.show_question(ui, &mut hit);
1143                egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
1144                    step = step.take().or(self.show_list(ui, &mut hit));
1145                });
1146            })
1147        });
1148        match step {
1149            Some(Step::ChooseSymbol(symbol, warnings)) => self.choose_symbol(symbol, warnings, files),
1150            Some(Step::Review(origin, warnings)) => self.review(origin, warnings, files),
1151            None => {}
1152        }
1153        if modal.should_close() && matches!(outcome, Outcome::Open) {
1154            outcome = Outcome::Closed;
1155        }
1156        outcome
1157    }
1158
1159    /// The two library folders, where each came from, and Apply.
1160    fn show_library(
1161        &mut self,
1162        ui: &mut egui::Ui,
1163        store: &dyn ModelStore,
1164        files: &dyn KicadFiles,
1165        hit: &mut impl FnMut(&str, &egui::Response),
1166    ) {
1167        let Some(library) = self.library.clone() else {
1168            return;
1169        };
1170        // Rows, not a grid: where each folder came from can name a path, and a
1171        // grid cell is as wide as its widest content ([`path_label`]).
1172        ui.horizontal(|ui| {
1173            ui.label("Footprints");
1174            let field = ui.text_edit_singleline(&mut self.footprints_buf);
1175            hit("field:footprints", &field);
1176        });
1177        path_label(ui, &library.footprints.from);
1178        ui.horizontal(|ui| {
1179            ui.label("3D models");
1180            let field = ui.text_edit_singleline(&mut self.models_buf);
1181            hit("field:models", &field);
1182        });
1183        path_label(ui, &library.models.from);
1184        let changed = self.footprints_buf != library.footprints.path || self.models_buf != library.models.path;
1185        let apply = ui.add_enabled(changed, egui::Button::new("Use these folders"));
1186        hit("apply-folders", &apply);
1187        if apply.clicked() {
1188            self.apply_folders(store, files);
1189        }
1190    }
1191
1192    /// Take the typed folders, remember them, and read the chain again from
1193    /// them without the user starting over.
1194    fn apply_folders(&mut self, store: &dyn ModelStore, files: &dyn KicadFiles) {
1195        let Some(library) = self.library.clone() else {
1196            return;
1197        };
1198        let chosen = |path: &str, old: &Folder| {
1199            if path == old.path {
1200                old.clone()
1201            } else if files.is_dir(path) {
1202                Folder { path: path.into(), from: "your saved folder".into() }
1203            } else {
1204                Folder { path: path.into(), from: format!("{path} is not a folder") }
1205            }
1206        };
1207        let library = KicadLibrary {
1208            footprints: chosen(&self.footprints_buf, &library.footprints),
1209            models: chosen(&self.models_buf, &library.models),
1210        };
1211        self.status = match library.save(store) {
1212            Ok(()) => String::new(),
1213            Err(error) => format!("the folders were not saved: {error}"),
1214        };
1215        self.library = Some(library);
1216        // Read the chain again from the new folders: from the symbol when
1217        // the chain stopped at the footprint folder, else from the review.
1218        match &mut self.stage {
1219            Stage::Review { rechoose: Some((symbol, warnings)), .. } => {
1220                let (symbol, warnings) = (symbol.clone(), warnings.clone());
1221                self.choose_symbol(symbol, warnings, files);
1222            }
1223            Stage::Review { origin, warnings, .. } => {
1224                let (origin, warnings) = (origin.clone(), warnings.clone());
1225                self.review(origin, warnings, files);
1226            }
1227            Stage::Footprint { symbol, warnings, .. } => {
1228                let (symbol, warnings) = (symbol.clone(), warnings.clone());
1229                self.choose_symbol(symbol, warnings, files);
1230            }
1231            Stage::File | Stage::Symbol { .. } => {}
1232        }
1233    }
1234
1235    /// The stage's question and its search field: above the list, so they
1236    /// stay put while it scrolls.
1237    fn show_question(&mut self, ui: &mut egui::Ui, hit: &mut impl FnMut(&str, &egui::Response)) {
1238        match &self.stage {
1239            Stage::File | Stage::Review { .. } => return,
1240            Stage::Symbol { file, symbols, .. } => {
1241                // The file's NAME: its folder is where the user just was.
1242                let name = file.rsplit(['/', '\\']).next().unwrap_or(file);
1243                ui.label(format!("{name} holds {} symbols. Choose one.", symbols.len()))
1244                    .on_hover_text(file.as_str());
1245            }
1246            Stage::Footprint { symbol, filters, .. } => {
1247                ui.label(format!(
1248                    "{} names no footprint. It offers footprints matching {}.",
1249                    symbol.library_id,
1250                    if filters.is_empty() { "anything".to_string() } else { filters.join(" ") }
1251                ));
1252            }
1253        }
1254        let search = ui.add(
1255            egui::TextEdit::singleline(&mut self.search).hint_text("search: an exact name comes first"),
1256        );
1257        hit("search", &search);
1258    }
1259
1260    /// The stage's rows, best match first. A double click on a row chooses
1261    /// it and goes on, as the stage's button would.
1262    fn show_list(&mut self, ui: &mut egui::Ui, hit: &mut impl FnMut(&str, &egui::Response)) -> Option<Step> {
1263        let needle = self.search.clone();
1264        match &mut self.stage {
1265            Stage::File => {
1266                ui.weak("Choose a file.");
1267                None
1268            }
1269            Stage::Symbol { symbols, warnings, selected, .. } => {
1270                let mut opened = None;
1271                for index in ranked(symbols.iter().map(|s| s.library_id.as_str()), &needle) {
1272                    let symbol = &symbols[index];
1273                    let row = ui.selectable_label(
1274                        *selected == Some(index),
1275                        format!("{}  ({})", symbol.library_id, crate::panels::count(symbol.pins.len(), "pin", "pins")),
1276                    );
1277                    hit(&format!("symbol:{}", symbol.library_id), &row);
1278                    if row.clicked() {
1279                        *selected = Some(index);
1280                    }
1281                    if opens(&row) {
1282                        opened = Some(Step::ChooseSymbol(symbol.clone(), warnings.clone()));
1283                    }
1284                }
1285                opened
1286            }
1287            Stage::Footprint { symbol, warnings, candidates, selected, .. } => {
1288                let labels: Vec<String> = candidates.iter().map(|(lib, name)| format!("{lib}:{name}")).collect();
1289                let mut opened = None;
1290                for (shown, index) in ranked(labels.iter().map(String::as_str), &needle).into_iter().enumerate() {
1291                    if shown == 500 {
1292                        ui.weak("\u{2026} type to narrow the list");
1293                        break;
1294                    }
1295                    let label = &labels[index];
1296                    let row = ui.selectable_label(*selected == Some(index), label);
1297                    hit(&format!("footprint:{label}"), &row);
1298                    if row.clicked() {
1299                        *selected = Some(index);
1300                    }
1301                    if opens(&row) {
1302                        let (lib, name) = candidates[index].clone();
1303                        let choice = FootprintChoice::Chosen(lib, name);
1304                        opened = Some(Step::Review(Origin::Symbol { symbol: symbol.clone(), choice }, warnings.clone()));
1305                    }
1306                }
1307                if candidates.is_empty() {
1308                    ui.weak("No footprint in the library matches.");
1309                }
1310                opened
1311            }
1312            Stage::Review { warnings, part, reading, .. } => {
1313                show_review(ui, part, warnings, reading.as_ref().map(|reader| reader.bytes));
1314                None
1315            }
1316        }
1317    }
1318
1319    /// The stage's own buttons, in the footer beside Cancel.
1320    fn show_buttons(
1321        &mut self,
1322        ui: &mut egui::Ui,
1323        hit: &mut impl FnMut(&str, &egui::Response),
1324    ) -> Option<Step> {
1325        match &self.stage {
1326            Stage::Symbol { symbols, warnings, selected, .. } => {
1327                let next = ui.add_enabled(selected.is_some(), egui::Button::new("Next"));
1328                hit("next", &next);
1329                let symbol = selected.map(|index| symbols[index].clone())?;
1330                next.clicked().then(|| Step::ChooseSymbol(symbol, warnings.clone()))
1331            }
1332            Stage::Footprint { symbol, warnings, candidates, selected, .. } => {
1333                let use_it = ui.add_enabled(selected.is_some(), egui::Button::new("Use this footprint"));
1334                hit("use-footprint", &use_it);
1335                let skip = ui.button("No footprint");
1336                hit("no-footprint", &skip);
1337                let choice = if use_it.clicked() {
1338                    let (lib, name) = candidates[selected.unwrap()].clone();
1339                    FootprintChoice::Chosen(lib, name)
1340                } else if skip.clicked() {
1341                    FootprintChoice::Skip
1342                } else {
1343                    return None;
1344                };
1345                Some(Step::Review(Origin::Symbol { symbol: symbol.clone(), choice }, warnings.clone()))
1346            }
1347            Stage::File | Stage::Review { .. } => None,
1348        }
1349    }
1350}
1351
1352/// Whether a click on a list row opens it: a double click, or a triple one.
1353/// egui counts clicks by TIME alone, not place, so a click in the search field
1354/// a moment before makes the row's double click its triple: the audit's
1355/// double click on `Device:R` came within 0.3 s of another click and never
1356/// read as double (ecad-reaudit-2026-09-23, B3).
1357fn opens(row: &egui::Response) -> bool {
1358    row.double_clicked() || row.triple_clicked()
1359}
1360
1361/// The indices of `ids` that `needle` matches, best first: an exact name, then
1362/// a name that starts with it, then a name that holds it anywhere; the file's
1363/// order within each. An id is `library:name`, and the needle is matched
1364/// against both the whole id and the name after its `:` (a leading `:` in the
1365/// needle anchors it to the name), ignoring case. Before this ranking a search
1366/// for "R" put `Device:R` some 250 rows down, under every symbol holding an r.
1367fn ranked<'a>(ids: impl Iterator<Item = &'a str>, needle: &str) -> Vec<usize> {
1368    let needle = needle.trim().to_lowercase();
1369    let bare = needle.strip_prefix(':').unwrap_or(&needle);
1370    let mut found: Vec<(u8, usize)> = ids
1371        .enumerate()
1372        .filter_map(|(index, id)| {
1373            let id = id.to_lowercase();
1374            let name = id.rsplit_once(':').map_or(id.as_str(), |(_, name)| name);
1375            let rank = if needle.is_empty() {
1376                2
1377            } else if id == needle || name == bare {
1378                0
1379            } else if id.starts_with(&needle) || name.starts_with(bare) {
1380                1
1381            } else if id.contains(&needle) {
1382                2
1383            } else {
1384                return None;
1385            };
1386            Some((rank, index))
1387        })
1388        .collect();
1389    found.sort();
1390    found.into_iter().map(|(_, index)| index).collect()
1391}
1392
1393/// A file path in the width the dialog has: one line, cut at the end with an
1394/// ellipsis, the whole path on hover. Laid out at its natural width, a
1395/// 203-character footprint path made the review 2046 points wide, and Import
1396/// and Cancel went off a 1400-point window.
1397fn path_label(ui: &mut egui::Ui, path: &str) {
1398    ui.add(egui::Label::new(egui::RichText::new(path).weak()).truncate())
1399        .on_hover_text(path);
1400}
1401
1402/// What the import will write, and every note, before the user confirms. The
1403/// short facts sit in a grid; each file PATH gets a line of its own under its
1404/// fact, as wide as the dialog and cut to fit it ([`path_label`]), because a
1405/// grid cell is as wide as its widest content.
1406fn show_review(ui: &mut egui::Ui, part: &KicadPart, warnings: &[String], reading: Option<usize>) {
1407    let fact = |ui: &mut egui::Ui, name: &str, value: String| {
1408        ui.horizontal_wrapped(|ui| {
1409            ui.label(egui::RichText::new(name).strong());
1410            ui.label(value);
1411        });
1412    };
1413    fact(
1414        ui,
1415        "Symbol",
1416        part.symbol.as_ref().map_or("none".into(), |s| format!("{} ({})", s.library_id, crate::panels::count(s.pins.len(), "pin", "pins"))),
1417    );
1418    fact(
1419        ui,
1420        "Pads",
1421        part.footprint.as_ref().map_or("none".into(), |f| format!("{} ({})", f.name, crate::panels::count(f.pads.len(), "pad", "pads"))),
1422    );
1423    if let (Some(_), Some(file)) = (&part.footprint, &part.footprint_file) {
1424        path_label(ui, file);
1425    }
1426    match (&part.model, reading) {
1427        (_, Some(bytes)) => {
1428            ui.horizontal_wrapped(|ui| {
1429                ui.label(egui::RichText::new("3D model").strong());
1430                ui.spinner();
1431                ui.label(format!("reading the model's STEP file ({} KB)\u{2026}", bytes.div_ceil(1024)));
1432            });
1433        }
1434        (Some(m), None) => {
1435            fact(ui, "3D model", crate::panels::count(m.bodies.len(), "body", "bodies"));
1436            path_label(ui, &m.step_file);
1437            ui.weak(format!(
1438                "offset {:?} mm, rotation {:?}°, scale {:?}",
1439                m.model.offset, m.model.rotation, m.model.scale
1440            ));
1441        }
1442        (None, None) => fact(ui, "3D model", "none".into()),
1443    }
1444    if let Some(symbol) = &part.symbol {
1445        fact(
1446            ui,
1447            "Connection points",
1448            format!(
1449                "{} in the part's `Pins` group, {}",
1450                symbol.pins.len(),
1451                if part.footprint.is_some() { "each at its pin's pad" } else { "at the part origin" }
1452            ),
1453        );
1454    }
1455    let notes: Vec<&String> = warnings.iter().chain(&part.notes).collect();
1456    if !notes.is_empty() {
1457        ui.add_space(6.0);
1458        for note in notes {
1459            // A note names the file it looked for, so it WRAPS (it is a
1460            // sentence, not a path to scan), breaking inside a long path too.
1461            ui.add(
1462                egui::Label::new(
1463                    egui::RichText::new(format!("\u{2022} {note}")).color(ui.visuals().warn_fg_color),
1464                )
1465                .wrap_mode(egui::TextWrapMode::Wrap),
1466            );
1467        }
1468    }
1469}
1470