Files
UniLoader/src/models.rs
T

605 lines
20 KiB
Rust

use crate::{
destination::{Destination, resolve_variables},
sexpr::Node,
};
use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
};
use walkdir::WalkDir;
#[derive(Debug, Default)]
pub struct ModelPlan {
pub copies: Vec<(PathBuf, Vec<u8>)>,
pub messages: Vec<String>,
}
fn supported(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "step" | "stp" | "wrl"))
}
fn digest(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn reference_parts(reference: &str) -> Vec<String> {
reference
.replace('\\', "/")
.split('/')
.filter(|s| !s.is_empty() && *s != ".")
.map(|s| s.to_ascii_lowercase())
.collect()
}
fn suffix_score(path: &Path, reference: &[String]) -> usize {
let parts = reference_parts(&path.to_string_lossy());
parts
.iter()
.rev()
.zip(reference.iter().rev())
.take_while(|(a, b)| a == b)
.count()
}
// KIPRJMOD belongs to the footprint's source project when copying a
// registered project library into another project. The target project is only
// the fallback for fresh bundle imports without discoverable source context.
fn source_project(source: &Path) -> Option<PathBuf> {
for directory in source.parent()?.ancestors() {
let Ok(entries) = std::fs::read_dir(directory) else {
continue;
};
if entries.filter_map(Result::ok).any(|entry| {
entry
.path()
.extension()
.is_some_and(|ext| ext == "kicad_pro")
&& entry.path().is_file()
}) {
return Some(directory.to_path_buf());
}
}
None
}
/// Resolve an original path first; otherwise rank recursive bundle/search-root
/// matches by trailing path components. Equal-content candidates collapse safely.
pub fn model_candidates(
reference: &str,
source_footprint: &Path,
source_root: &Path,
search_roots: &[PathBuf],
variables: &BTreeMap<String, String>,
project: Option<&Path>,
) -> Result<Vec<PathBuf>> {
let source_project = source_project(source_footprint);
let project = source_project.as_deref().or(project);
let normalized = reference.replace('\\', "/");
if let Ok(expanded) = resolve_variables(&normalized, project, variables) {
let path = PathBuf::from(&expanded);
let direct = if path.is_absolute() {
path
} else {
source_footprint.parent().unwrap_or(source_root).join(path)
};
if direct.is_file() && supported(&direct) {
return Ok(vec![direct]);
}
let bundled = source_root.join(expanded.trim_start_matches('/'));
if bundled.is_file() && supported(&bundled) {
return Ok(vec![bundled]);
}
}
let parts = reference_parts(&normalized);
let Some(filename) = parts.last() else {
return Ok(vec![]);
};
let mut roots = vec![source_root.to_path_buf()];
roots.extend_from_slice(search_roots);
let mut candidates = BTreeSet::new();
let mut best_score = 0;
for root in roots {
if !root.exists() {
continue;
}
if root.is_file() {
if supported(&root)
&& root
.file_name()
.is_some_and(|s| s.to_string_lossy().eq_ignore_ascii_case(filename))
{
let score = suffix_score(&root, &parts);
if score > best_score {
candidates.clear();
best_score = score;
}
if score == best_score {
candidates.insert(root.canonicalize()?);
}
}
continue;
}
for entry in WalkDir::new(&root).follow_links(false) {
let entry =
entry.with_context(|| format!("Search model files in {}", root.display()))?;
if !entry.file_type().is_file() || !supported(entry.path()) {
continue;
}
if !entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(filename)
{
continue;
}
let score = suffix_score(entry.path(), &parts);
if score > best_score {
candidates.clear();
best_score = score;
}
if score == best_score {
candidates.insert(entry.path().canonicalize()?);
}
}
}
let mut unique = BTreeMap::new();
for candidate in candidates {
let bytes = std::fs::read(&candidate)
.with_context(|| format!("Read model {}", candidate.display()))?;
unique.entry(digest(&bytes)).or_insert(candidate);
}
let mut paths: Vec<_> = unique.into_values().collect();
paths.sort();
Ok(paths)
}
#[cfg(test)]
pub fn repair_models(
footprint: &mut Node,
source_footprint: &Path,
source_root: &Path,
destination: &Destination,
search_roots: &[PathBuf],
variables: &BTreeMap<String, String>,
enabled: bool,
) -> Result<ModelPlan> {
repair_models_with_choices(
footprint,
source_footprint,
source_root,
destination,
search_roots,
variables,
enabled,
&BTreeMap::new(),
)
}
#[allow(clippy::too_many_arguments)]
pub fn repair_models_with_choices(
footprint: &mut Node,
source_footprint: &Path,
source_root: &Path,
destination: &Destination,
search_roots: &[PathBuf],
variables: &BTreeMap<String, String>,
enabled: bool,
choices: &BTreeMap<String, PathBuf>,
) -> Result<ModelPlan> {
let mut plan = ModelPlan::default();
repair_node(
footprint,
source_footprint,
source_root,
destination,
search_roots,
variables,
enabled,
choices,
&mut plan,
)?;
Ok(plan)
}
#[allow(clippy::too_many_arguments)]
fn repair_node(
node: &mut Node,
source_footprint: &Path,
source_root: &Path,
destination: &Destination,
search_roots: &[PathBuf],
variables: &BTreeMap<String, String>,
enabled: bool,
choices: &BTreeMap<String, PathBuf>,
plan: &mut ModelPlan,
) -> Result<()> {
let Node::List(items) = node else {
return Ok(());
};
if matches!(items.first(), Some(Node::Atom(head)) if head == "model") {
let reference = match items.get(1) {
Some(Node::Str(s) | Node::Atom(s)) => s.clone(),
_ => bail!(
"Model reference missing a filename in {}",
source_footprint.display()
),
};
if reference.starts_with("embedded://")
|| reference.starts_with("kicad-embed://")
|| reference.starts_with("${KICAD_EMBEDDED}")
{
plan.messages
.push(format!("Preserved embedded model: {reference}"));
return Ok(());
}
if !enabled {
plan.messages.push(format!("Model import disabled; external dependency remains unresolved for portability: {reference}"));
return Ok(());
}
let candidates = model_candidates(
&reference,
source_footprint,
source_root,
search_roots,
variables,
destination.project.as_deref(),
)?;
let source = if let Some(choice) = choices.get(&reference) {
let canonical = choice
.canonicalize()
.with_context(|| format!("Chosen model is unavailable: {}", choice.display()))?;
if !candidates
.iter()
.any(|candidate| candidate.canonicalize().ok().as_ref() == Some(&canonical))
{
bail!(
"pending: chosen model is no longer a matching candidate: {}",
choice.display()
);
}
canonical
} else {
match candidates.as_slice() {
[] => {
plan.messages.push(format!("Missing model; dependency unresolved, original reference preserved: {reference}"));
return Ok(());
}
[one] => one.clone(),
many => bail!(
"pending: ambiguous model {reference}; choose one of: {}",
many.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(" | ")
),
}
};
let bytes =
std::fs::read(&source).with_context(|| format!("Read model {}", source.display()))?;
// A footprint already in this destination must not acquire another hash
// suffix each time its models are reviewed or the component is reimported.
if source.parent().and_then(|p| p.canonicalize().ok())
== destination.models_dir.canonicalize().ok()
&& destination.models_dir.is_dir()
{
items[1] = Node::Str(destination.model_uri(&source));
plan.messages
.push(format!("Reused model: {}", source.display()));
return Ok(());
}
let hash = digest(&bytes);
let stem = source.file_stem().unwrap_or_default().to_string_lossy();
let safe_stem: String = stem
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let extension = source
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_ascii_lowercase();
let mut target =
destination
.models_dir
.join(format!("{safe_stem}_{}.{}", &hash[..12], extension));
if target.exists()
&& std::fs::read(&target)
.with_context(|| format!("Read existing model {}", target.display()))?
!= bytes
{
target = destination
.models_dir
.join(format!("{safe_stem}_{hash}.{extension}"));
if target.exists() && std::fs::read(&target)? != bytes {
bail!("Model storage collision at {}", target.display());
}
}
let uri = destination.model_uri(&target);
let reused = target.is_file();
if !reused && !plan.copies.iter().any(|(path, _)| path == &target) {
plan.copies.push((target.clone(), bytes));
}
items[1] = Node::Str(uri.clone());
plan.messages.push(format!(
"{} model: {} -> {}{}",
if reused { "Reused" } else { "Copied" },
source.display(),
uri,
if reference != source.to_string_lossy() {
" (reference repaired)"
} else {
""
}
));
return Ok(());
}
for child in items {
repair_node(
child,
source_footprint,
source_root,
destination,
search_roots,
variables,
enabled,
choices,
plan,
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sexpr::parse;
fn scratch() -> PathBuf {
let path = std::env::temp_dir().join(format!(
"uniloader-models-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn repairs_windows_paths_preserving_transforms() {
let root = scratch();
std::fs::create_dir_all(root.join("bundle/3d")).unwrap();
std::fs::write(root.join("bundle/3d/part.STEP"), b"model").unwrap();
std::fs::write(root.join("board.kicad_pro"), b"{}").unwrap();
let dest = Destination::new(
&root.join("global"),
Some(&root.join("board.kicad_pro")),
&root.join("config"),
)
.unwrap();
let mut fp = parse(r#"(footprint "x" (model "C:\\old\\3d\\part.STEP" (offset (xyz 1 2 3)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 90))))"#).unwrap();
let plan = repair_models(
&mut fp,
&root.join("bundle/x.kicad_mod"),
&root.join("bundle"),
&dest,
&[],
&BTreeMap::new(),
true,
)
.unwrap();
assert_eq!(plan.copies.len(), 1);
let rendered = fp.to_string();
assert!(rendered.contains("${KIPRJMOD}/libraries/models/part_"));
assert!(rendered.contains("(xyz 0 0 90)"));
assert!(rendered.contains("(xyz 1 2 3)"));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn collapses_identical_candidates_but_requires_choice_for_different_content() {
let root = scratch();
for dir in ["a", "b"] {
std::fs::create_dir(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("part.step"), b"same").unwrap();
}
let candidates = model_candidates(
"C:/obsolete/part.step",
&root.join("x.kicad_mod"),
&root,
&[],
&BTreeMap::new(),
None,
)
.unwrap();
assert_eq!(candidates.len(), 1);
std::fs::write(root.join("b/part.step"), b"different").unwrap();
assert_eq!(
model_candidates(
"C:/obsolete/part.step",
&root.join("x.kicad_mod"),
&root,
&[],
&BTreeMap::new(),
None
)
.unwrap()
.len(),
2
);
let dest = Destination::new(&root.join("global"), None, &root.join("config")).unwrap();
let mut fp = parse(r#"(footprint "x" (model "C:/obsolete/part.step"))"#).unwrap();
assert!(
repair_models(
&mut fp,
&root.join("x.kicad_mod"),
&root,
&dest,
&[],
&BTreeMap::new(),
true
)
.unwrap_err()
.to_string()
.starts_with("pending:")
);
let choices = BTreeMap::from([("C:/obsolete/part.step".into(), root.join("b/part.step"))]);
let plan = repair_models_with_choices(
&mut fp,
&root.join("x.kicad_mod"),
&root,
&dest,
&[],
&BTreeMap::new(),
true,
&choices,
)
.unwrap();
assert_eq!(plan.copies[0].1, b"different");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn external_project_model_uses_source_context_and_becomes_portable() {
let root = scratch();
let source = root.join("source");
let target = root.join("target");
for project in [&source, &target] {
std::fs::create_dir_all(project.join("libraries/models")).unwrap();
std::fs::create_dir_all(project.join("libraries/Parts.pretty")).unwrap();
std::fs::write(project.join("board.kicad_pro"), b"{}").unwrap();
}
std::fs::write(source.join("libraries/models/chip.step"), b"SOURCE MODEL").unwrap();
// Same URI in another project is not the same dependency.
std::fs::write(
target.join("libraries/models/chip.step"),
b"WRONG TARGET MODEL",
)
.unwrap();
let dest = Destination::new(
&root.join("global"),
Some(&target.join("board.kicad_pro")),
&root.join("config"),
)
.unwrap();
let mut fp = parse(r#"(footprint "chip" (model "${KIPRJMOD}/libraries/models/chip.step" (offset (xyz 1 2 3))))"#).unwrap();
let source_fp = source.join("libraries/Parts.pretty/chip.kicad_mod");
let plan = repair_models(
&mut fp,
&source_fp,
source_fp.parent().unwrap(),
&dest,
&[],
&BTreeMap::new(),
true,
)
.unwrap();
assert_eq!(plan.copies.len(), 1);
assert_eq!(plan.copies[0].1, b"SOURCE MODEL");
assert!(plan.copies[0].0.starts_with(&target));
assert!(
fp.to_string()
.contains("${KIPRJMOD}/libraries/models/chip_")
);
assert!(fp.to_string().contains("(xyz 1 2 3)"));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn conflicting_model_storage_keeps_existing_bytes() {
let root = scratch();
std::fs::write(root.join("chip.step"), b"NEW MODEL").unwrap();
let dest = Destination::new(&root.join("global"), None, &root.join("config")).unwrap();
std::fs::create_dir_all(&dest.models_dir).unwrap();
let hash = digest(b"NEW MODEL");
let collision = dest.models_dir.join(format!("chip_{}.step", &hash[..12]));
std::fs::write(&collision, b"EXISTING USER MODEL").unwrap();
let mut fp = parse(r#"(footprint "chip" (model "chip.step"))"#).unwrap();
let plan = repair_models(
&mut fp,
&root.join("chip.kicad_mod"),
&root,
&dest,
&[],
&BTreeMap::new(),
true,
)
.unwrap();
assert_ne!(plan.copies[0].0, collision);
assert_eq!(std::fs::read(&collision).unwrap(), b"EXISTING USER MODEL");
assert_eq!(plan.copies[0].1, b"NEW MODEL");
// A second collision refuses to plan an overwrite.
std::fs::write(&plan.copies[0].0, b"ANOTHER EXISTING MODEL").unwrap();
let mut original = parse(r#"(footprint "chip" (model "chip.step"))"#).unwrap();
assert!(
repair_models(
&mut original,
&root.join("chip.kicad_mod"),
&root,
&dest,
&[],
&BTreeMap::new(),
true
)
.is_err()
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn embedded_references_are_preserved_without_warnings() {
let root = scratch();
let dest = Destination::new(&root.join("global"), None, &root.join("config")).unwrap();
for reference in [
"embedded://chip.step",
"kicad-embed://chip.step",
"${KICAD_EMBEDDED}/chip.step",
] {
let mut fp = Node::list(vec![
Node::symbol("footprint"),
Node::string("chip"),
Node::list(vec![Node::symbol("model"), Node::string(reference)]),
]);
let before = fp.to_string();
let plan = repair_models(
&mut fp,
&root.join("chip.kicad_mod"),
&root,
&dest,
&[],
&BTreeMap::new(),
true,
)
.unwrap();
assert_eq!(fp.to_string(), before);
assert!(plan.copies.is_empty());
assert!(plan.messages[0].contains("Preserved embedded"));
}
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn missing_models_remain_visible() {
let root = scratch();
let dest = Destination::new(&root.join("global"), None, &root.join("config")).unwrap();
let mut fp = parse(r#"(footprint "x" (model "missing.wrl"))"#).unwrap();
let before = fp.to_string();
let plan = repair_models(
&mut fp,
&root.join("x.kicad_mod"),
&root,
&dest,
&[],
&BTreeMap::new(),
true,
)
.unwrap();
assert_eq!(fp.to_string(), before);
assert!(plan.copies.is_empty());
assert!(plan.messages[0].contains("Missing model"));
std::fs::remove_dir_all(root).unwrap();
}
}