use crate::store::{ModelStore, KICAD_LIBRARY_KEY};
use brep_render::brep_kernel as kernel;
use brep_render::engine_state::EngineState;
use brep_ecad_core::board::{Footprint, Model};
use brep_ecad_core::kicad::{self, FootprintLink};
use brep_ecad_core::Symbol;
use serde_json::{json, Value};
use std::path::Path;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KicadKind {
Symbol,
Footprint,
}
impl KicadKind {
pub fn extension(self) -> &'static str {
match self {
KicadKind::Symbol => "kicad_sym",
KicadKind::Footprint => "kicad_mod",
}
}
pub fn of_file(name: &str) -> Option<Self> {
let lower = name.to_ascii_lowercase();
[KicadKind::Symbol, KicadKind::Footprint]
.into_iter()
.find(|kind| lower.ends_with(&format!(".{}", kind.extension())))
}
}
pub trait KicadFiles {
fn available(&self) -> bool {
true
}
fn read(&self, path: &str) -> Option<Vec<u8>>;
fn is_file(&self, path: &str) -> bool;
fn is_dir(&self, path: &str) -> bool;
fn list(&self, dir: &str) -> Vec<String>;
}
pub struct DiskFiles;
impl KicadFiles for DiskFiles {
fn available(&self) -> bool {
!cfg!(target_arch = "wasm32")
}
fn read(&self, path: &str) -> Option<Vec<u8>> {
std::fs::read(path).ok()
}
fn is_file(&self, path: &str) -> bool {
Path::new(path).is_file()
}
fn is_dir(&self, path: &str) -> bool {
Path::new(path).is_dir()
}
fn list(&self, dir: &str) -> Vec<String> {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect()
}
}
fn join(dir: &str, name: &str) -> String {
Path::new(dir).join(name).to_string_lossy().into_owned()
}
fn parent(path: &str) -> Option<String> {
Path::new(path)
.parent()
.map(|dir| dir.to_string_lossy().into_owned())
.filter(|dir| !dir.is_empty())
}
fn stem(path: &str) -> String {
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
name.rsplit_once('.').map_or(name, |(stem, _)| stem).to_owned()
}
const FOOTPRINT_VARS: &[&str] = &[
"KICAD9_FOOTPRINT_DIR",
"KICAD8_FOOTPRINT_DIR",
"KICAD7_FOOTPRINT_DIR",
"KICAD6_FOOTPRINT_DIR",
];
const MODEL_VARS: &[&str] = &[
"KICAD9_3DMODEL_DIR",
"KICAD8_3DMODEL_DIR",
"KICAD7_3DMODEL_DIR",
"KICAD6_3DMODEL_DIR",
"KISYS3DMOD",
];
fn install_defaults() -> &'static [(&'static str, &'static str)] {
if cfg!(target_os = "windows") {
&[(
r"C:\Program Files\KiCad\9.0\share\kicad\footprints",
r"C:\Program Files\KiCad\9.0\share\kicad\3dmodels",
)]
} else if cfg!(target_os = "macos") {
&[(
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints",
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/3dmodels",
)]
} else {
&[
("/usr/share/kicad/footprints", "/usr/share/kicad/3dmodels"),
("/usr/local/share/kicad/footprints", "/usr/local/share/kicad/3dmodels"),
(
"/var/lib/flatpak/app/org.kicad.KiCad/current/active/files/share/kicad/footprints",
"/var/lib/flatpak/app/org.kicad.KiCad/current/active/files/share/kicad/3dmodels",
),
]
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Folder {
pub path: String,
pub from: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KicadLibrary {
pub footprints: Folder,
pub models: Folder,
}
impl KicadLibrary {
pub fn seeded(env: &dyn Fn(&str) -> Option<String>, files: &dyn KicadFiles) -> Self {
let seed = |vars: &[&str], pick: fn(&(&'static str, &'static str)) -> &'static str| -> Folder {
for var in vars {
if let Some(path) = env(var).filter(|path| files.is_dir(path)) {
return Folder { path, from: format!("from {var}") };
}
}
let defaults = install_defaults();
match defaults.iter().map(pick).find(|path| files.is_dir(path)) {
Some(path) => Folder { path: path.into(), from: "KiCad's install folder".into() },
None => Folder {
path: pick(&defaults[0]).into(),
from: format!("not found: {} unset, and no KiCad install here", vars.join(", ")),
},
}
};
Self {
footprints: seed(FOOTPRINT_VARS, |(footprints, _)| footprints),
models: seed(MODEL_VARS, |(_, models)| models),
}
}
pub fn load(
store: &dyn ModelStore,
env: &dyn Fn(&str) -> Option<String>,
files: &dyn KicadFiles,
) -> Self {
let mut library = Self::seeded(env, files);
let stored: Value = store
.read(KICAD_LIBRARY_KEY)
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or(Value::Null);
for (key, folder, what) in [
("footprints", &mut library.footprints, "footprint"),
("models", &mut library.models, "3D-model"),
] {
let Some(path) = stored.get(key).and_then(Value::as_str).filter(|p| !p.is_empty()) else {
continue;
};
if files.is_dir(path) {
*folder = Folder { path: path.into(), from: "your saved folder".into() };
} else {
folder.from = format!(
"your saved {what} folder {path} no longer exists, so this is {}",
folder.from
);
}
}
library
}
pub fn save(&self, store: &dyn ModelStore) -> Result<(), String> {
let record = json!({ "footprints": self.footprints.path, "models": self.models.path });
store.write(KICAD_LIBRARY_KEY, &record.to_string())
}
pub fn footprint_file(&self, library: &str, name: &str) -> String {
join(&join(&self.footprints.path, &format!("{library}.pretty")), &format!("{name}.kicad_mod"))
}
pub fn model_file(&self, path: &str, footprint_dir: Option<&str>) -> Result<String, String> {
let mut out = String::new();
let mut rest = path;
while let Some(start) = rest.find('$') {
let close = match rest[start + 1..].chars().next() {
Some('{') => '}',
Some('(') => ')',
_ => {
out.push_str(&rest[..=start]);
rest = &rest[start + 1..];
continue;
}
};
let Some(end) = rest[start + 2..].find(close) else {
return Err(format!("the model path {path} has an unclosed variable"));
};
let name = &rest[start + 2..start + 2 + end];
let value = if MODEL_VARS.contains(&name) || is_model_var(name) {
self.models.path.clone()
} else if name == "KIPRJMOD" {
footprint_dir.map(str::to_owned).ok_or_else(|| {
format!("the model path {path} uses ${{KIPRJMOD}}, and there is no project folder")
})?
} else {
return Err(format!(
"the model path {path} uses ${{{name}}}, which names no KiCad 3D-model folder"
));
};
out.push_str(&rest[..start]);
out.push_str(&value);
rest = &rest[start + 3 + end..];
}
out.push_str(rest);
if Path::new(&out).is_relative() {
if let Some(dir) = footprint_dir {
return Ok(join(dir, &out));
}
}
Ok(out)
}
}
fn is_model_var(name: &str) -> bool {
name.strip_prefix("KICAD")
.and_then(|rest| rest.strip_suffix("_3DMODEL_DIR"))
.is_some_and(|version| !version.is_empty() && version.chars().all(|c| c.is_ascii_digit()))
}
#[derive(Clone)]
pub struct StepSource {
pub model: Model,
pub step_file: String,
pub text: String,
}
#[derive(Clone)]
pub struct ModelFile {
pub model: Model,
pub step_file: String,
pub bodies: Vec<kernel::BrepSolid>,
pub appearances: Vec<kernel::BodyAppearance>,
}
#[derive(Clone, Default)]
pub struct KicadPart {
pub symbol: Option<Symbol>,
pub footprint: Option<Footprint>,
pub footprint_file: Option<String>,
pub model: Option<ModelFile>,
pub step: Option<StepSource>,
pub notes: Vec<String>,
}
pub fn read_symbol_library(file: &str, bytes: &[u8]) -> Result<(Vec<Symbol>, Vec<String>), String> {
let text = std::str::from_utf8(bytes).map_err(|_| format!("{file} is not UTF-8 text"))?;
let report = kicad::import_library(text, &stem(file)).map_err(|error| format!("{file}: {error}"))?;
if report.symbols.is_empty() {
return Err(format!("{file} holds no symbol this importer reads"));
}
Ok((report.symbols, report.warnings))
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FootprintChoice {
Named,
Chosen(String, String),
Skip,
}
pub fn part_from_symbol(
symbol: Symbol,
choice: &FootprintChoice,
library: &KicadLibrary,
files: &dyn KicadFiles,
) -> KicadPart {
let id = symbol.library_id.clone();
let mut part = KicadPart { symbol: Some(symbol), ..KicadPart::default() };
let wanted = match (choice, kicad::footprint_link(part.symbol.as_ref().unwrap())) {
(FootprintChoice::Chosen(lib, name), _) => Some((lib.clone(), name.clone())),
(FootprintChoice::Skip, _) => {
part.notes.push(format!("{id}: no footprint chosen, so the part imports with its symbol only"));
None
}
(FootprintChoice::Named, FootprintLink::Named { library: lib, name }) if lib.is_empty() => {
part.notes.push(format!(
"{id} names footprint '{name}' without a library, so it cannot be found; \
the part imports with its symbol only"
));
None
}
(FootprintChoice::Named, FootprintLink::Named { library: lib, name }) => Some((lib, name)),
(FootprintChoice::Named, FootprintLink::Chosen { .. }) => {
part.notes.push(format!("{id} names no footprint, so the part imports with its symbol only"));
None
}
};
let Some((lib, name)) = wanted else {
return part;
};
if !files.available() {
part.notes.push(format!(
"this build cannot read your KiCad library, so footprint {lib}:{name} is not followed"
));
return part;
}
let file = library.footprint_file(&lib, &name);
let Some(bytes) = files.read(&file) else {
part.notes.push(format!("footprint {lib}:{name} not found: looked for {file}"));
return part;
};
match read_footprint(&file, &bytes) {
Ok((footprint, warnings)) => {
part.notes.extend(warnings);
if let Some(symbol) = &mut part.symbol { kicad::assign_footprint(symbol, &lib, &name); }
part.footprint = Some(footprint);
part.footprint_file = Some(file);
attach_model(&mut part, library, files);
}
Err(error) => part.notes.push(error),
}
part
}
pub fn part_from_footprint(
file: &str,
bytes: &[u8],
library: &KicadLibrary,
files: &dyn KicadFiles,
) -> Result<KicadPart, String> {
let (footprint, warnings) = read_footprint(file, bytes)?;
let mut part = KicadPart {
footprint: Some(footprint),
footprint_file: Some(file.into()),
notes: warnings,
..KicadPart::default()
};
attach_model(&mut part, library, files);
Ok(part)
}
fn read_footprint(file: &str, bytes: &[u8]) -> Result<(Footprint, Vec<String>), String> {
let text = std::str::from_utf8(bytes).map_err(|_| format!("{file} is not UTF-8 text"))?;
let (footprint, warnings) =
kicad::import_footprint(text).map_err(|error| format!("footprint {file}: {error}"))?;
Ok((footprint, warnings.into_iter().map(|w| format!("footprint {}: {w}", stem(file))).collect()))
}
fn attach_model(part: &mut KicadPart, library: &KicadLibrary, files: &dyn KicadFiles) {
let footprint = part.footprint.as_ref().expect("a footprint to follow");
let Some(model) = footprint.model.clone() else {
part.notes.push(format!(
"footprint {} names no 3D model, so the part imports without one",
footprint.name
));
return;
};
if !files.available() {
part.notes.push(format!(
"this build cannot read your KiCad library, so the 3D model {} is not read",
model.path
));
return;
}
let beside = part.footprint_file.as_deref().and_then(parent);
let step_file = match library.model_file(&model.step_path(), beside.as_deref()) {
Ok(file) => file,
Err(error) => {
part.notes.push(format!("3D model not read: {error}"));
return;
}
};
let Some(bytes) = files.read(&step_file) else {
let named = library.model_file(&model.path, beside.as_deref()).unwrap_or_default();
part.notes.push(if named != step_file && files.is_file(&named) {
format!(
"3D model: the library has {named} but no {step_file} beside it, and BREP imports \
STEP, not VRML; the part imports without a model"
)
} else {
format!("3D model not found: looked for {step_file}; the part imports without a model")
});
return;
};
match String::from_utf8(bytes) {
Ok(text) => part.step = Some(StepSource { model, step_file, text }),
Err(_) => part.notes.push(format!(
"3D model {step_file} is not UTF-8 text; the part imports without a model"
)),
}
}
pub fn parse_step(source: &StepSource) -> Result<ModelFile, String> {
let (bodies, appearances) = kernel::import_step_with_appearance(&source.text).map_err(|error| {
format!(
"3D model {} did not import ({error}); the part imports without a model",
source.step_file
)
})?;
Ok(ModelFile { model: source.model.clone(), step_file: source.step_file.clone(), bodies, appearances })
}
impl KicadPart {
pub fn settle_model(&mut self, parsed: Result<ModelFile, String>) {
self.step = None;
match parsed {
Ok(model) => self.model = Some(model),
Err(note) => self.notes.push(note),
}
}
pub fn parsed(mut self) -> Self {
if let Some(step) = self.step.take() {
let outcome = parse_step(&step);
self.settle_model(outcome);
}
self
}
}
pub fn footprint_candidates(
filters: &[String],
library: &KicadLibrary,
files: &dyn KicadFiles,
) -> Vec<(String, String)> {
let mut found = Vec::new();
for entry in files.list(&library.footprints.path) {
let Some(lib) = entry.strip_suffix(".pretty") else {
continue;
};
for file in files.list(&join(&library.footprints.path, &entry)) {
let Some(name) = file.strip_suffix(".kicad_mod") else {
continue;
};
if kicad::footprint_filter_matches(filters, lib, name) {
found.push((lib.to_owned(), name.to_owned()));
}
}
}
found.sort();
found
}
#[derive(Clone, Debug, Default)]
pub struct Imported {
pub summary: String,
pub notes: Vec<String>,
}
pub fn pad_point(footprint: &Footprint, index: usize) -> [f64; 3] {
let at = footprint.pads[index].at;
[f64::from(at.x) / 1000., -f64::from(at.y) / 1000., 0.]
}
const PORT_UP: [f64; 3] = [0., -90., 0.];
const IMPORT_PORT: &str = "Pins";
const IMPORT_PURPOSE: &str = "pcb";
pub fn import_part(state: &mut EngineState, part: &KicadPart) -> Result<Imported, String> {
let parsed;
let part = if part.step.is_some() {
parsed = part.clone().parsed();
&parsed
} else {
part
};
if part.symbol.is_none() && part.footprint.is_none() {
return Err("nothing to import".into());
}
if part.symbol.is_some() && state.history.symbol_block().is_some() {
return Err("this part already has a symbol, and a KiCad import does not replace one".into());
}
if part.footprint.is_some() && state.history.pads_block().is_some() {
return Err("this part already has pads, and a KiCad import does not replace them".into());
}
let symbol_block = part.symbol.as_ref().map(serde_json::to_value).transpose().map_err(|e| e.to_string())?;
let pads_block = part.footprint.as_ref().map(serde_json::to_value).transpose().map_err(|e| e.to_string())?;
let mut notes = part.notes.clone();
let mut features = Vec::new();
let mut summary = Vec::new();
if let Some(symbol) = &part.symbol {
summary.push(format!("symbol {} ({})", symbol.library_id, crate::panels::count(symbol.pins.len(), "pin", "pins")));
}
if let Some(footprint) = &part.footprint {
summary.push(format!("{} pads", footprint.pads.len()));
}
if let Some(model) = &part.model {
features.push(model_feature(state, model)?);
summary.push(format!("3D model ({})", crate::panels::count(model.bodies.len(), "body", "bodies")));
}
let mut ports_block = None;
if let Some(symbol) = &part.symbol {
let (block, port_notes) = port_points(state, symbol, part.footprint.as_ref());
notes.extend(port_notes);
let added = block["points"].as_array().map_or(0, Vec::len);
if added > 0 {
summary.push(format!("{added} connection points"));
ports_block = Some(block);
}
}
let mut checkpointed = !features.is_empty();
if checkpointed {
state.add_features(&features);
}
if let Some(block) = ports_block {
let mut blocks = state
.history
.ports_block()
.and_then(|value| value.as_array().cloned())
.unwrap_or_default();
blocks.push(block);
let blocks = Some(serde_json::Value::Array(blocks));
if checkpointed {
state.history.set_ports_block_no_undo(blocks);
} else {
state.history.set_ports_block(blocks, None);
checkpointed = true;
}
}
if let Some(block) = symbol_block {
if checkpointed {
state.history.set_symbol_block_no_undo(Some(block));
} else {
state.history.set_symbol_block(Some(block), None);
checkpointed = true;
}
}
if let Some(block) = pads_block {
if checkpointed {
state.history.set_pads_block_no_undo(Some(block));
} else {
state.history.set_pads_block(Some(block), None);
}
}
if let Some(hold) = state.history.pin_port_hold() {
notes.push(format!("the pins were not bound to connection points: {hold}"));
}
if let Some(report) = state.history.pin_point_report() {
notes.extend(report.problems.iter().map(|problem| problem.message()));
}
Ok(Imported { summary: format!("imported {}", summary.join(", ")), notes })
}
fn model_feature(state: &mut EngineState, file: &ModelFile) -> Result<Value, String> {
let transform = kernel::AffineTransform::new(kicad::model_placement(&file.model))
.map_err(|error| format!("3D model {}: {error}", file.step_file))?;
let mirrored = transform.determinant3() < 0.0;
let bodies = file
.bodies
.iter()
.map(|body| kernel::transform_brep(body, transform, mirrored))
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("3D model {}: {error}", file.step_file))?;
let id = state.next_feature_id(&brep_render::features::feature_short_name("IMPORT3D"));
let payload = {
let _isolation = kernel::IsolatedSceneMetadata::begin();
kernel::native_import_payload_with_appearance(&id, &bodies, &file.appearances)
}
.map_err(|error| format!("3D model {}: {error}", file.step_file))?;
let model = &file.model;
Ok(json!({
"type": "IMPORT3D",
"inputParams": { "id": id, "nativeBrep": payload },
"persistentData": {
"kicadModel": {
"path": model.path,
"stepFile": file.step_file,
"offset": model.offset,
"scale": model.scale,
"rotation": model.rotation,
}
},
}))
}
fn port_points(
state: &EngineState,
symbol: &Symbol,
footprint: Option<&Footprint>,
) -> (Value, Vec<String>) {
let existing = state.history.declared_points();
let mut points = Vec::new();
let mut notes = Vec::new();
if footprint.is_none() && !symbol.pins.is_empty() {
notes.push("no pads were imported, so every new connection point sits at the part origin".into());
}
for pin in &symbol.pins {
let label = &pin.number;
if let Some(point) = existing.iter().find(|point| &point.point == label) {
notes.push(format!(
"pin '{label}' takes the part's existing connection point {}, which stays where it is",
point.address()
));
continue;
}
let mut position = [0., 0., 0.];
if let Some(footprint) = footprint {
let pads: Vec<usize> =
(0..footprint.pads.len()).filter(|&i| &footprint.pads[i].number == label).collect();
match pads.as_slice() {
[] => notes.push(format!("pin '{label}' matches no pad, so its point sits at the part origin")),
[first, rest @ ..] => {
position = pad_point(footprint, *first);
if !rest.is_empty() {
notes.push(format!(
"pin '{label}' is on {} pads; its point is on the first, at ({}, {}) mm",
pads.len(),
position[0],
position[1]
));
}
}
}
}
points.push(json!({
"name": label,
"transform": { "position": position, "rotationEuler": PORT_UP }
}));
}
(json!({ "name": IMPORT_PORT, "purpose": IMPORT_PURPOSE, "points": points }), notes)
}
#[derive(Clone)]
enum Origin {
Symbol { symbol: Symbol, choice: FootprintChoice },
Footprint { file: String, bytes: Vec<u8> },
}
enum Stage {
File,
Symbol { file: String, symbols: Vec<Symbol>, warnings: Vec<String>, selected: Option<usize> },
Footprint {
symbol: Symbol,
warnings: Vec<String>,
filters: Vec<String>,
candidates: Vec<(String, String)>,
selected: Option<usize>,
},
Review {
origin: Origin,
warnings: Vec<String>,
part: KicadPart,
reading: Option<Reader>,
rechoose: Option<(Symbol, Vec<String>)>,
},
}
enum Step {
ChooseSymbol(Symbol, Vec<String>),
Review(Origin, Vec<String>),
}
struct Reader {
answer: std::sync::mpsc::Receiver<Result<ModelFile, String>>,
bytes: usize,
}
impl Reader {
fn start(source: StepSource) -> Self {
let bytes = source.text.len();
let (send, answer) = std::sync::mpsc::channel();
#[cfg(not(target_arch = "wasm32"))]
std::thread::spawn(move || {
let _ = send.send(parse_step(&source));
});
#[cfg(target_arch = "wasm32")]
let _ = send.send(parse_step(&source));
Self { answer, bytes }
}
}
pub enum Outcome {
Open,
Closed,
Imported(Imported),
}
pub struct KicadImport {
kind: KicadKind,
stage: Stage,
library: Option<KicadLibrary>,
footprints_buf: String,
models_buf: String,
search: String,
pub status: String,
}
fn process_env(var: &str) -> Option<String> {
std::env::var(var).ok().filter(|value| !value.is_empty())
}
impl KicadImport {
pub fn new() -> Self {
Self {
kind: KicadKind::Symbol,
stage: Stage::File,
library: None,
footprints_buf: String::new(),
models_buf: String::new(),
search: String::new(),
status: String::new(),
}
}
pub fn kind(&self) -> KicadKind {
self.kind
}
pub fn start(&mut self, kind: KicadKind, store: &dyn ModelStore) {
self.kind = kind;
self.stage = Stage::File;
self.search.clear();
self.status.clear();
self.set_library(KicadLibrary::load(store, &process_env, &DiskFiles));
}
fn set_library(&mut self, library: KicadLibrary) {
self.footprints_buf = library.footprints.path.clone();
self.models_buf = library.models.path.clone();
self.library = Some(library);
}
pub fn wants_file(&self) -> bool {
matches!(self.stage, Stage::File)
}
pub fn take_file(&mut self, file: &str, bytes: &[u8], files: &dyn KicadFiles) {
let Some(kind) = KicadKind::of_file(file) else {
self.status = format!("{file} is not a .kicad_sym or .kicad_mod file");
return;
};
self.kind = kind;
let library = self.library.clone().unwrap_or_else(|| KicadLibrary::seeded(&process_env, files));
self.library = Some(library.clone());
match kind {
KicadKind::Symbol => match read_symbol_library(file, bytes) {
Ok((mut symbols, warnings)) if symbols.len() == 1 => {
self.choose_symbol(symbols.remove(0), warnings, files)
}
Ok((symbols, warnings)) => {
self.search.clear();
self.stage = Stage::Symbol { file: file.into(), symbols, warnings, selected: None };
}
Err(error) => self.status = format!("import failed: {error}"),
},
KicadKind::Footprint => {
let origin = Origin::Footprint { file: file.into(), bytes: bytes.to_vec() };
self.review(origin, Vec::new(), files);
}
}
}
fn choose_symbol(&mut self, symbol: Symbol, warnings: Vec<String>, files: &dyn KicadFiles) {
let library = self.library.clone().expect("a library once a file is taken");
match kicad::footprint_link(&symbol) {
FootprintLink::Chosen { filters } if files.available() && files.is_dir(&library.footprints.path) => {
let candidates = footprint_candidates(&filters, &library, files);
self.search.clear();
self.stage = Stage::Footprint { symbol, warnings, filters, candidates, selected: None };
}
FootprintLink::Chosen { .. } => {
let rechoose = (symbol.clone(), warnings.clone());
let mut warnings = warnings;
warnings.push(if files.available() {
format!(
"no footprint folder at {}, so no footprint can be chosen",
library.footprints.path
)
} else {
"this build cannot read your KiCad library, so no footprint can be chosen".into()
});
self.review(Origin::Symbol { symbol, choice: FootprintChoice::Skip }, warnings, files);
if let Stage::Review { rechoose: slot, .. } = &mut self.stage {
*slot = Some(rechoose);
}
}
FootprintLink::Named { .. } => {
self.review(Origin::Symbol { symbol, choice: FootprintChoice::Named }, warnings, files)
}
}
}
fn review(&mut self, origin: Origin, warnings: Vec<String>, files: &dyn KicadFiles) {
let library = self.library.clone().expect("a library once a file is taken");
let part = match &origin {
Origin::Symbol { symbol, choice } => part_from_symbol(symbol.clone(), choice, &library, files),
Origin::Footprint { file, bytes } => match part_from_footprint(file, bytes, &library, files) {
Ok(part) => part,
Err(error) => {
self.status = format!("import failed: {error}");
return;
}
},
};
let mut part = part;
let reading = part.step.take().map(Reader::start);
self.stage = Stage::Review { origin, warnings, part, reading, rechoose: None };
}
fn poll_reader(&mut self, ctx: &egui::Context) {
let Stage::Review { part, reading: Some(reader), .. } = &mut self.stage else {
return;
};
match reader.answer.try_recv() {
Ok(parsed) => part.settle_model(parsed),
Err(std::sync::mpsc::TryRecvError::Empty) => {
ctx.request_repaint_after(std::time::Duration::from_millis(50));
return;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
part.notes.push("the 3D model's reader stopped without an answer; the part imports without a model".into())
}
}
if let Stage::Review { reading, .. } = &mut self.stage {
*reading = None;
}
}
pub fn reading(&self) -> bool {
matches!(self.stage, Stage::Review { reading: Some(_), .. })
}
pub fn state_json(&self) -> Value {
let stage = match &self.stage {
Stage::File => "file",
Stage::Symbol { .. } => "symbol",
Stage::Footprint { .. } => "footprint",
Stage::Review { .. } => "review",
};
let review = self.reviewed().map(|part| {
json!({
"symbol": part.symbol.as_ref().map(|s| s.library_id.clone()),
"pads": part.footprint.as_ref().map(|f| f.pads.len()),
"footprintFile": part.footprint_file,
"stepFile": part.model.as_ref().map(|m| m.step_file.clone()),
"notes": part.notes,
"reading": self.reading(),
})
});
json!({
"kind": self.kind.extension(),
"stage": stage,
"status": self.status,
"footprints": self.library.as_ref().map(|l| l.footprints.path.clone()),
"models": self.library.as_ref().map(|l| l.models.path.clone()),
"review": review,
})
}
pub fn reviewed(&self) -> Option<&KicadPart> {
match &self.stage {
Stage::Review { part, .. } => Some(part),
_ => None,
}
}
pub fn confirm(&mut self, state: &mut EngineState) -> Result<Imported, String> {
let Stage::Review { part, warnings, reading, .. } = &self.stage else {
return Err("nothing has been read yet".into());
};
if reading.is_some() {
return Err("the 3D model is still being read".into());
}
let mut imported = import_part(state, part)?;
imported.notes.splice(0..0, warnings.iter().cloned());
self.stage = Stage::File;
Ok(imported)
}
pub fn show(
&mut self,
ctx: &egui::Context,
state: &mut EngineState,
store: &dyn ModelStore,
hits: &mut std::collections::HashMap<String, egui::Rect>,
) -> Outcome {
self.show_with(ctx, state, store, hits, &DiskFiles)
}
fn show_with(
&mut self,
ctx: &egui::Context,
state: &mut EngineState,
store: &dyn ModelStore,
hits: &mut std::collections::HashMap<String, egui::Rect>,
files: &dyn KicadFiles,
) -> Outcome {
self.poll_reader(ctx);
let reading = self.reading();
let mut outcome = Outcome::Open;
let mut step: Option<Step> = None;
let mut hit = |key: &str, response: &egui::Response| {
hits.insert(format!("kicad:{key}"), response.rect);
};
let modal = egui::Modal::new(egui::Id::new("brep-kicad-import")).show(ctx, |ui| {
super::file_explorer::dialog_body(ui, |ui| {
ui.heading("Import from KiCad");
ui.add_space(4.0);
super::file_explorer::dialog_footer(ui, "kicad", |ui| {
if !self.status.is_empty() {
ui.add_space(4.0);
ui.weak(&self.status);
}
ui.horizontal(|ui| {
let cancel = ui.button("Cancel");
hit("cancel", &cancel);
if cancel.clicked() {
outcome = Outcome::Closed;
}
step = step.take().or(self.show_buttons(ui, &mut hit));
if matches!(self.stage, Stage::Review { .. }) {
let import = ui.add_enabled(!reading, egui::Button::new("Import"));
hit("import", &import);
if import.clicked() {
match self.confirm(state) {
Ok(imported) => outcome = Outcome::Imported(imported),
Err(error) => self.status = format!("import refused: {error}"),
}
}
}
});
});
self.show_library(ui, store, files, &mut hit);
ui.separator();
self.show_question(ui, &mut hit);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
step = step.take().or(self.show_list(ui, &mut hit));
});
})
});
match step {
Some(Step::ChooseSymbol(symbol, warnings)) => self.choose_symbol(symbol, warnings, files),
Some(Step::Review(origin, warnings)) => self.review(origin, warnings, files),
None => {}
}
if modal.should_close() && matches!(outcome, Outcome::Open) {
outcome = Outcome::Closed;
}
outcome
}
fn show_library(
&mut self,
ui: &mut egui::Ui,
store: &dyn ModelStore,
files: &dyn KicadFiles,
hit: &mut impl FnMut(&str, &egui::Response),
) {
let Some(library) = self.library.clone() else {
return;
};
ui.horizontal(|ui| {
ui.label("Footprints");
let field = ui.text_edit_singleline(&mut self.footprints_buf);
hit("field:footprints", &field);
});
path_label(ui, &library.footprints.from);
ui.horizontal(|ui| {
ui.label("3D models");
let field = ui.text_edit_singleline(&mut self.models_buf);
hit("field:models", &field);
});
path_label(ui, &library.models.from);
let changed = self.footprints_buf != library.footprints.path || self.models_buf != library.models.path;
let apply = ui.add_enabled(changed, egui::Button::new("Use these folders"));
hit("apply-folders", &apply);
if apply.clicked() {
self.apply_folders(store, files);
}
}
fn apply_folders(&mut self, store: &dyn ModelStore, files: &dyn KicadFiles) {
let Some(library) = self.library.clone() else {
return;
};
let chosen = |path: &str, old: &Folder| {
if path == old.path {
old.clone()
} else if files.is_dir(path) {
Folder { path: path.into(), from: "your saved folder".into() }
} else {
Folder { path: path.into(), from: format!("{path} is not a folder") }
}
};
let library = KicadLibrary {
footprints: chosen(&self.footprints_buf, &library.footprints),
models: chosen(&self.models_buf, &library.models),
};
self.status = match library.save(store) {
Ok(()) => String::new(),
Err(error) => format!("the folders were not saved: {error}"),
};
self.library = Some(library);
match &mut self.stage {
Stage::Review { rechoose: Some((symbol, warnings)), .. } => {
let (symbol, warnings) = (symbol.clone(), warnings.clone());
self.choose_symbol(symbol, warnings, files);
}
Stage::Review { origin, warnings, .. } => {
let (origin, warnings) = (origin.clone(), warnings.clone());
self.review(origin, warnings, files);
}
Stage::Footprint { symbol, warnings, .. } => {
let (symbol, warnings) = (symbol.clone(), warnings.clone());
self.choose_symbol(symbol, warnings, files);
}
Stage::File | Stage::Symbol { .. } => {}
}
}
fn show_question(&mut self, ui: &mut egui::Ui, hit: &mut impl FnMut(&str, &egui::Response)) {
match &self.stage {
Stage::File | Stage::Review { .. } => return,
Stage::Symbol { file, symbols, .. } => {
let name = file.rsplit(['/', '\\']).next().unwrap_or(file);
ui.label(format!("{name} holds {} symbols. Choose one.", symbols.len()))
.on_hover_text(file.as_str());
}
Stage::Footprint { symbol, filters, .. } => {
ui.label(format!(
"{} names no footprint. It offers footprints matching {}.",
symbol.library_id,
if filters.is_empty() { "anything".to_string() } else { filters.join(" ") }
));
}
}
let search = ui.add(
egui::TextEdit::singleline(&mut self.search).hint_text("search: an exact name comes first"),
);
hit("search", &search);
}
fn show_list(&mut self, ui: &mut egui::Ui, hit: &mut impl FnMut(&str, &egui::Response)) -> Option<Step> {
let needle = self.search.clone();
match &mut self.stage {
Stage::File => {
ui.weak("Choose a file.");
None
}
Stage::Symbol { symbols, warnings, selected, .. } => {
let mut opened = None;
for index in ranked(symbols.iter().map(|s| s.library_id.as_str()), &needle) {
let symbol = &symbols[index];
let row = ui.selectable_label(
*selected == Some(index),
format!("{} ({})", symbol.library_id, crate::panels::count(symbol.pins.len(), "pin", "pins")),
);
hit(&format!("symbol:{}", symbol.library_id), &row);
if row.clicked() {
*selected = Some(index);
}
if opens(&row) {
opened = Some(Step::ChooseSymbol(symbol.clone(), warnings.clone()));
}
}
opened
}
Stage::Footprint { symbol, warnings, candidates, selected, .. } => {
let labels: Vec<String> = candidates.iter().map(|(lib, name)| format!("{lib}:{name}")).collect();
let mut opened = None;
for (shown, index) in ranked(labels.iter().map(String::as_str), &needle).into_iter().enumerate() {
if shown == 500 {
ui.weak("\u{2026} type to narrow the list");
break;
}
let label = &labels[index];
let row = ui.selectable_label(*selected == Some(index), label);
hit(&format!("footprint:{label}"), &row);
if row.clicked() {
*selected = Some(index);
}
if opens(&row) {
let (lib, name) = candidates[index].clone();
let choice = FootprintChoice::Chosen(lib, name);
opened = Some(Step::Review(Origin::Symbol { symbol: symbol.clone(), choice }, warnings.clone()));
}
}
if candidates.is_empty() {
ui.weak("No footprint in the library matches.");
}
opened
}
Stage::Review { warnings, part, reading, .. } => {
show_review(ui, part, warnings, reading.as_ref().map(|reader| reader.bytes));
None
}
}
}
fn show_buttons(
&mut self,
ui: &mut egui::Ui,
hit: &mut impl FnMut(&str, &egui::Response),
) -> Option<Step> {
match &self.stage {
Stage::Symbol { symbols, warnings, selected, .. } => {
let next = ui.add_enabled(selected.is_some(), egui::Button::new("Next"));
hit("next", &next);
let symbol = selected.map(|index| symbols[index].clone())?;
next.clicked().then(|| Step::ChooseSymbol(symbol, warnings.clone()))
}
Stage::Footprint { symbol, warnings, candidates, selected, .. } => {
let use_it = ui.add_enabled(selected.is_some(), egui::Button::new("Use this footprint"));
hit("use-footprint", &use_it);
let skip = ui.button("No footprint");
hit("no-footprint", &skip);
let choice = if use_it.clicked() {
let (lib, name) = candidates[selected.unwrap()].clone();
FootprintChoice::Chosen(lib, name)
} else if skip.clicked() {
FootprintChoice::Skip
} else {
return None;
};
Some(Step::Review(Origin::Symbol { symbol: symbol.clone(), choice }, warnings.clone()))
}
Stage::File | Stage::Review { .. } => None,
}
}
}
fn opens(row: &egui::Response) -> bool {
row.double_clicked() || row.triple_clicked()
}
fn ranked<'a>(ids: impl Iterator<Item = &'a str>, needle: &str) -> Vec<usize> {
let needle = needle.trim().to_lowercase();
let bare = needle.strip_prefix(':').unwrap_or(&needle);
let mut found: Vec<(u8, usize)> = ids
.enumerate()
.filter_map(|(index, id)| {
let id = id.to_lowercase();
let name = id.rsplit_once(':').map_or(id.as_str(), |(_, name)| name);
let rank = if needle.is_empty() {
2
} else if id == needle || name == bare {
0
} else if id.starts_with(&needle) || name.starts_with(bare) {
1
} else if id.contains(&needle) {
2
} else {
return None;
};
Some((rank, index))
})
.collect();
found.sort();
found.into_iter().map(|(_, index)| index).collect()
}
fn path_label(ui: &mut egui::Ui, path: &str) {
ui.add(egui::Label::new(egui::RichText::new(path).weak()).truncate())
.on_hover_text(path);
}
fn show_review(ui: &mut egui::Ui, part: &KicadPart, warnings: &[String], reading: Option<usize>) {
let fact = |ui: &mut egui::Ui, name: &str, value: String| {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(name).strong());
ui.label(value);
});
};
fact(
ui,
"Symbol",
part.symbol.as_ref().map_or("none".into(), |s| format!("{} ({})", s.library_id, crate::panels::count(s.pins.len(), "pin", "pins"))),
);
fact(
ui,
"Pads",
part.footprint.as_ref().map_or("none".into(), |f| format!("{} ({})", f.name, crate::panels::count(f.pads.len(), "pad", "pads"))),
);
if let (Some(_), Some(file)) = (&part.footprint, &part.footprint_file) {
path_label(ui, file);
}
match (&part.model, reading) {
(_, Some(bytes)) => {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("3D model").strong());
ui.spinner();
ui.label(format!("reading the model's STEP file ({} KB)\u{2026}", bytes.div_ceil(1024)));
});
}
(Some(m), None) => {
fact(ui, "3D model", crate::panels::count(m.bodies.len(), "body", "bodies"));
path_label(ui, &m.step_file);
ui.weak(format!(
"offset {:?} mm, rotation {:?}°, scale {:?}",
m.model.offset, m.model.rotation, m.model.scale
));
}
(None, None) => fact(ui, "3D model", "none".into()),
}
if let Some(symbol) = &part.symbol {
fact(
ui,
"Connection points",
format!(
"{} in the part's `Pins` group, {}",
symbol.pins.len(),
if part.footprint.is_some() { "each at its pin's pad" } else { "at the part origin" }
),
);
}
let notes: Vec<&String> = warnings.iter().chain(&part.notes).collect();
if !notes.is_empty() {
ui.add_space(6.0);
for note in notes {
ui.add(
egui::Label::new(
egui::RichText::new(format!("\u{2022} {note}")).color(ui.visuals().warn_fg_color),
)
.wrap_mode(egui::TextWrapMode::Wrap),
);
}
}
}