1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum KicadKind {
58 Symbol,
60 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 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
81pub trait KicadFiles {
88 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 fn list(&self, dir: &str) -> Vec<String>;
98}
99
100pub 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
142const FOOTPRINT_VARS: &[&str] = &[
148 "KICAD9_FOOTPRINT_DIR",
149 "KICAD8_FOOTPRINT_DIR",
150 "KICAD7_FOOTPRINT_DIR",
151 "KICAD6_FOOTPRINT_DIR",
152];
153const MODEL_VARS: &[&str] = &[
156 "KICAD9_3DMODEL_DIR",
157 "KICAD8_3DMODEL_DIR",
158 "KICAD7_3DMODEL_DIR",
159 "KICAD6_3DMODEL_DIR",
160 "KISYS3DMOD",
161];
162
163fn 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#[derive(Clone, Debug, PartialEq, Eq)]
190pub struct Folder {
191 pub path: String,
192 pub from: String,
193}
194
195#[derive(Clone, Debug, PartialEq, Eq)]
198pub struct KicadLibrary {
199 pub footprints: Folder,
200 pub models: Folder,
201}
202
203impl KicadLibrary {
204 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 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 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 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 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
318fn 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#[derive(Clone)]
333pub struct StepSource {
334 pub model: Model,
336 pub step_file: String,
338 pub text: String,
339}
340
341#[derive(Clone)]
343pub struct ModelFile {
344 pub model: Model,
346 pub step_file: String,
348 pub bodies: Vec<kernel::BrepSolid>,
350 pub appearances: Vec<kernel::BodyAppearance>,
351}
352
353#[derive(Clone, Default)]
355pub struct KicadPart {
356 pub symbol: Option<Symbol>,
357 pub footprint: Option<Footprint>,
358 pub footprint_file: Option<String>,
360 pub model: Option<ModelFile>,
361 pub step: Option<StepSource>,
363 pub notes: Vec<String>,
366}
367
368pub 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#[derive(Clone, Debug, PartialEq, Eq)]
381pub enum FootprintChoice {
382 Named,
384 Chosen(String, String),
386 Skip,
388}
389
390pub 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 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
448pub 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
473fn 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
519pub 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 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 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
551pub 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#[derive(Clone, Debug, Default)]
582pub struct Imported {
583 pub summary: String,
584 pub notes: Vec<String>,
585}
586
587pub 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
594const PORT_UP: [f64; 3] = [0., -90., 0.];
598const IMPORT_PORT: &str = "Pins";
601const IMPORT_PURPOSE: &str = "pcb";
602
603pub 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 {} ({} pins)", symbol.library_id, symbol.pins.len()));
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 ({} bodies)", model.bodies.len()));
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 let mut checkpointed = !features.is_empty();
654 if checkpointed {
655 state.add_features(&features);
656 }
657 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 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
702fn 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 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 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
740fn 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#[derive(Clone)]
797enum Origin {
798 Symbol { symbol: Symbol, choice: FootprintChoice },
799 Footprint { file: String, bytes: Vec<u8> },
800}
801
802enum Stage {
803 File,
805 Symbol { file: String, symbols: Vec<Symbol>, warnings: Vec<String>, selected: Option<usize> },
807 Footprint {
809 symbol: Symbol,
810 warnings: Vec<String>,
811 filters: Vec<String>,
812 candidates: Vec<(String, String)>,
813 selected: Option<usize>,
814 },
815 Review {
817 origin: Origin,
818 warnings: Vec<String>,
819 part: KicadPart,
820 reading: Option<Reader>,
822 rechoose: Option<(Symbol, Vec<String>)>,
828 },
829}
830
831enum Step {
833 ChooseSymbol(Symbol, Vec<String>),
834 Review(Origin, Vec<String>),
835}
836
837struct Reader {
839 answer: std::sync::mpsc::Receiver<Result<ModelFile, String>>,
840 bytes: usize,
842}
843
844impl Reader {
845 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 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
862pub enum Outcome {
864 Open,
865 Closed,
866 Imported(Imported),
867}
868
869pub 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 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 pub fn wants_file(&self) -> bool {
919 matches!(self.stage, Stage::File)
920 }
921
922 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 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 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 pub fn reading(&self) -> bool {
1023 matches!(self.stage, Stage::Review { reading: Some(_), .. })
1024 }
1025
1026
1027 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 pub fn reviewed(&self) -> Option<&KicadPart> {
1058 match &self.stage {
1059 Stage::Review { part, .. } => Some(part),
1060 _ => None,
1061 }
1062 }
1063
1064 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 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 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 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 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 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 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 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 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 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!("{} ({} pins)", symbol.library_id, symbol.pins.len()),
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 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
1352fn opens(row: &egui::Response) -> bool {
1358 row.double_clicked() || row.triple_clicked()
1359}
1360
1361fn 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
1393fn 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
1402fn 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!("{} ({} pins)", s.library_id, s.pins.len())),
1417 );
1418 fact(
1419 ui,
1420 "Pads",
1421 part.footprint.as_ref().map_or("none".into(), |f| format!("{} ({} pads)", f.name, f.pads.len())),
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", format!("{} bodies", m.bodies.len()));
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 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