diff --git a/README.en.md b/README.en.md index 0e11cf2..893547c 100644 --- a/README.en.md +++ b/README.en.md @@ -5,7 +5,7 @@

SeEx (Seek and Export)

- Batch export LCSC components in formats supported by Altium Designer (AD) and KiCad. + Batch export LCSC components in formats supported by Altium Designer (AD), KiCad and Cadence (OrCAD Capture / Allegro).

English | 简体中文

@@ -38,6 +38,14 @@ The project history, in chronological order: - [x] Support batch export. - [x] Export multiple components into one merged `schlib` and `pcblib` library. +### Cadence (OrCAD Capture / Allegro) + +- [x] Export `Capture` schematic symbols as one `Library XML` file, which Capture turns into an `.olb` with `File > Import > Library XML`. +- [x] Export `Allegro` footprints as one `SKILL` script per footprint plus a library script: running `skill load("_allegro/.il")` in the Allegro PCB Editor command window builds the `.dra`, `.psm` and `.pad` files in that folder. +- [x] Export `3D` models (`step`), mapped on the footprints where EasyEDA places them. +- [x] Standard packages (chip resistors and capacitors, `SOT`, `SOD`, `SOIC`, `QFN`, ...) get `IPC-7352` lands, checked against the manufacturer's; other packages keep the manufacturer's lands with `IPC-7352` silkscreen, assembly and place-bound rules. +- [x] `seex` runs no Cadence program itself; the import is done in Cadence. + ### Shared Features - [x] Export metadata in Chinese or English. @@ -45,6 +53,7 @@ The project history, in chronological order: - [x] Add new components to existing libraries exported by `seex`. - [x] Customize schematic stroke and fill colors. - [x] Export `3D` models independently. +- [x] Remove the EasyEDA logo (watermark) from `3D` models by default and rebuild them; a model the logo cannot be removed from safely is exported as it is and named in the export result. ## Supported EDA Versions @@ -53,13 +62,14 @@ The project history, in chronological order: - [x] `KiCad 9.0` to `Latest` - [x] `Altium Designer 23.x` to `Latest` +- [x] `Cadence SPB 26.1` (OrCAD X Capture / Allegro X); the Allegro footprint scripts need a release that runs `SKILL` (`Allegro PCB Designer` or `OrCAD PCB Designer Professional`) ## What Is Planned? -- [ ] Support more `EDA` tools, such as `allegro` and `pads`. +- [ ] Support more `EDA` tools, such as `pads`. - [ ] Continue improving the current workflow and fix more hidden `bugs`. - [ ] Keep improving interactions and usability, refine the icons, and try new design languages. -- [ ] Explore a broadly applicable way to remove or hide parts of `3D` models, such as specific markings. +- [ ] Explore a broadly applicable way to remove or hide parts of `3D` models, such as specific markings (the EasyEDA logo is already removed by default). ## Who Would SeEx Like to Contribute? diff --git a/README.md b/README.md index 90f6fb1..664df29 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

SeEx(Seek and Export)

- 批量导出立创商城元件为Altium Designer(AD)、KiCad可用的格式。 + 批量导出立创商城元件为Altium Designer(AD)、KiCad、Cadence(OrCAD Capture / Allegro)可用的格式。

English | 简体中文

@@ -32,24 +32,32 @@ - [x] 可以单独导出器件的`schlib`, `pcblib`(`pcblib`已经内嵌了`step`模型)。 - [x] 也可以批量导出。 - [x] 支持导出多个器件到同一个`schlib`, `pcblib`,即合并导出。 +### Cadence(OrCAD Capture / Allegro)部分 +- [x] 导出`Capture`原理图符号:一个`Library XML`文件,在`Capture`中通过`File > Import > Library XML`转换为`.olb`。 +- [x] 导出`Allegro`封装:每个封装一个`SKILL`脚本,外加一个库脚本;在`Allegro PCB Editor`的命令窗口执行`skill load("<库>_allegro/<库>.il")`,即在同一目录生成`.dra`、`.psm`和`.pad`。 +- [x] 导出`3D`模型(`step`),封装中已按立创EDA的位置映射好模型。 +- [x] 标准封装(贴片阻容、`SOT`、`SOD`、`SOIC`、`QFN`等)按`IPC-7352`计算焊盘并核对厂家焊盘;其余封装保留厂家焊盘,按`IPC-7352`规则生成丝印、装配层和占位区。 +- [x] `seex`本身不调用任何`Cadence`程序,导入由用户在`Cadence`中完成。 ### 共同部分 - [x] 均支持选择中英文元数据导出。 - [x] 均支持多线程下载。 - [x] 均支持向已现存的经由`seex`导出的库里添加新的器件/元件。 - [x] 均支持自定义原理图的描边和填充颜色。 - [x] 均支持单独导出`3D`模型。 +- [x] 默认去除`3D`模型上的立创EDA标志(水印),重建模型;无法安全去除时保留原模型,并在导出结果中列出这些元件。 ## SeEx支持的`EDA`版本??? > [!NOTE] > 这些版本仅为目前所知,不排除`seex`支持更多的版本。如果您无法在更低版本的`EDA`软件中打开`seex`导出的库,可以在`issue`里提出,会考虑适配。 - [x] `KiCad 9.0` ~ `Latest` - [x] `Altium Designer 23.x` ~ `Latest` +- [x] `Cadence SPB 26.1`(OrCAD X Capture / Allegro X);`Allegro`封装脚本需要支持`SKILL`的版本(`Allegro PCB Designer`或`OrCAD PCB Designer Professional`) ## SeEx未来想做啥??? -- [ ] 支持更多的`EDA`工具,类似`allegro`, `pads`等。 +- [ ] 支持更多的`EDA`工具,类似`pads`等。 - [ ] 继续完善现在的工作流,修复更多隐藏的`bug`。 - [ ] 持续优化交互功能,提升用户使用体验,优化图标,尝试新的设计语言。 -- [ ] 探索一种普遍适用的方案,用于除去/隐去`3D`模型的一部分组件,类似特定标识符等等。 +- [ ] 探索一种普遍适用的方案,用于除去/隐去`3D`模型的一部分组件(立创EDA标志已默认去除),类似特定标识符等等。 ## SeEx期待的贡献者??? - [x] 具有软件设计经验,能持续维护软件。 diff --git a/src/altium/workflow.rs b/src/altium/workflow.rs index 68f5acd..476cc5e 100644 --- a/src/altium/workflow.rs +++ b/src/altium/workflow.rs @@ -17,6 +17,7 @@ use crate::error::{AppError, Result}; use crate::lceda::{LcedaClient, SearchItem}; use crate::lcsc::{LcscClient, LcscProduct}; use crate::metadata::ResolvedMetadata; +use crate::step_model::ModelCleanup; use crate::util::{nested_string, sanitize_filename, split_obj_and_mtl}; #[derive(Debug, Serialize)] @@ -38,6 +39,7 @@ pub async fn download_step( item: &SearchItem, out_dir: &Path, force: bool, + models: &ModelCleanup, ) -> Result { fs::create_dir_all(out_dir)?; let out_file = out_dir.join(item.choose_step_filename()); @@ -47,6 +49,10 @@ pub async fn download_step( let model_uuid = client.get_model_uuid(item).await?; let content = client.download_step_bytes(&model_uuid).await?; + let label = item + .lcsc_id() + .unwrap_or_else(|| item.display_name().to_string()); + let content = models.clean(&label, &content); // The STEP is stored in LCEDA's own frame, which does not always rest on the board plane. fs::write(&out_file, crate::step_model::place_step_on_board(&content))?; Ok(out_file) @@ -184,6 +190,7 @@ pub(crate) async fn load_placed_step_for_pcblib( client: &LcedaClient, item: &SearchItem, model_candidates: &[String], + models: &ModelCleanup, ) -> Option { let mut candidates = model_candidates.to_vec(); if item.model_uuid.is_some() { @@ -223,6 +230,10 @@ pub(crate) async fn load_placed_step_for_pcblib( } }; let _ = fs::remove_file(&downloaded); + let label = item + .lcsc_id() + .unwrap_or_else(|| item.display_name().to_string()); + let bytes = models.clean(&label, &bytes); let placed = scratch_step_path(); let written = fs::File::create(&placed) @@ -250,9 +261,10 @@ pub(crate) async fn build_pcblib_library_from_detail( item: &SearchItem, footprint_data: &Value, component_name: &str, + models: &ModelCleanup, ) -> Result { let candidates = step_model_candidates(item, footprint_data); - let step_data = load_placed_step_for_pcblib(client, item, &candidates).await; + let step_data = load_placed_step_for_pcblib(client, item, &candidates, models).await; build_pcblib_from_payload_with_model(footprint_data, component_name, step_data) } @@ -306,12 +318,13 @@ pub async fn build_pcblib_library_for_item( client: &LcedaClient, item: &SearchItem, footprint_name: &str, + models: &ModelCleanup, ) -> Result { let footprint_uuid = item .footprint_uuid() .ok_or(AppError::MissingSymbolOrFootprint)?; let footprint_data = client.component_detail(&footprint_uuid).await?; - build_pcblib_library_from_detail(client, item, &footprint_data, footprint_name).await + build_pcblib_library_from_detail(client, item, &footprint_data, footprint_name, models).await } pub async fn build_schlib_component_for_item( @@ -358,6 +371,7 @@ pub async fn export_pcblib( item: &SearchItem, out_dir: &Path, force: bool, + models: &ModelCleanup, ) -> Result { fs::create_dir_all(out_dir)?; let footprint_uuid = item @@ -371,7 +385,8 @@ pub async fn export_pcblib( } let library = - build_pcblib_library_from_detail(client, item, &footprint_data, &footprint_name).await?; + build_pcblib_library_from_detail(client, item, &footprint_data, &footprint_name, models) + .await?; write_pcblib(&library, &out_file)?; Ok(out_file) } diff --git a/src/batch.rs b/src/batch.rs index 4c20111..bf2fb5a 100644 --- a/src/batch.rs +++ b/src/batch.rs @@ -25,6 +25,7 @@ use crate::error::{AppError, Result}; use crate::lceda::{LcedaClient, SearchItem}; use crate::lcsc::{LcscClient, LcscProduct}; use crate::monitor::ExportProgressCallback; +use crate::step_model::ModelCleanup; use crate::util::sanitize_filename; #[derive(Clone)] @@ -44,6 +45,8 @@ pub struct BatchOptions { pub schematic_stroke_color: [u8; 3], /// `None` exports the symbol body with no fill at all. pub schematic_fill_color: Option<[u8; 3]>, + /// What happens to downloaded 3D models before they are embedded (logo removal). + pub models: Arc, pub progress: Option, } @@ -871,6 +874,7 @@ async fn export_merged_components_parallel( merged_pcblib_file, options.schematic_stroke_color, options.schematic_fill_color, + &options.models, parallel, options.append, summary, @@ -1018,6 +1022,7 @@ async fn build_merged_components_parallel( merged_pcblib_file: &str, schematic_stroke_color: [u8; 3], schematic_fill_color: Option<[u8; 3]>, + models: &Arc, parallel: usize, as_records: bool, summary: &mut BatchSummary, @@ -1032,6 +1037,7 @@ async fn build_merged_components_parallel( let client = client.clone(); let semaphore = semaphore.clone(); let merged_pcblib_file = merged_pcblib_file.clone(); + let models = Arc::clone(models); join_set.spawn(async move { let _permit = semaphore .acquire_owned() @@ -1045,6 +1051,7 @@ async fn build_merged_components_parallel( merged_pcblib_file.as_str(), schematic_stroke_color, schematic_fill_color, + &models, ) .await; // An append merge only needs the streams, and reducing the part here keeps @@ -1095,6 +1102,7 @@ async fn build_named_merged_component( merged_pcblib_file: &str, schematic_stroke_color: [u8; 3], schematic_fill_color: Option<[u8; 3]>, + models: &ModelCleanup, ) -> Result { let schlib_record = if targets.schlib { let symbol_data = named @@ -1122,7 +1130,7 @@ async fn build_named_merged_component( // only needed to build the footprint, and a download takes seconds while the // batch has one in flight per parallel part. let step_data = - load_placed_step_for_pcblib(client, &named.item, &named.step_candidates).await; + load_placed_step_for_pcblib(client, &named.item, &named.step_candidates, models).await; let footprint_data = named .footprint_data .as_ref() @@ -1342,7 +1350,7 @@ async fn export_component( if targets.pcblib { let pcblib_dir = options.output.join("pcblib"); - export_pcblib(client, &item, &pcblib_dir, options.force).await?; + export_pcblib(client, &item, &pcblib_dir, options.force, &options.models).await?; } Ok(ExportedComponent { display_name }) @@ -1485,6 +1493,7 @@ mod tests { force: false, schematic_stroke_color: [11, 56, 104], schematic_fill_color: Some([255, 255, 224]), + models: Default::default(), progress: None, }; diff --git a/src/cadence/allegro.rs b/src/cadence/allegro.rs new file mode 100644 index 0000000..5a6d9ce --- /dev/null +++ b/src/cadence/allegro.rs @@ -0,0 +1,1498 @@ +//! Allegro / OrCAD PCB Editor footprint export. +//! +//! Allegro package symbols (`.dra` / `.psm`) and padstacks (`.pad`) are binary and +//! undocumented, and only Allegro itself writes them. SeEx therefore writes one +//! self-contained SKILL script per footprint, plus one library script that builds +//! them all: loaded in Allegro PCB Editor (`skill load("…/.il")`), it opens a +//! new package symbol drawing for each footprint, runs its script and saves the +//! `.dra`, `.psm` and `.pad` files next to the scripts. Each footprint script can +//! also be loaded on its own into an empty package symbol drawing. +//! +//! A script creates the padstacks, places the pins, draws the silkscreen, the place +//! bound and the reference designator labels, and maps the STEP model. Every +//! Allegro call that has varied between releases runs under `errset` with a +//! fallback, so a single unsupported call degrades one feature instead of aborting. +//! SKILL needs Allegro PCB Designer or OrCAD PCB Designer Professional; the base +//! OrCAD PCB Designer tier does not run it. + +use std::collections::HashMap; +use std::fmt::Write as _; + +use crate::kicad::footprint::{KiArc, KiCircle, KiFootprint, KiLine, KiPad, PadShape, PadType}; + +/// EasyEDA footprint unit: 10 mil. +const PX_TO_MM: f64 = 0.254; +/// Solder mask opening added to every copper pad (total, i.e. 0.05 mm per side). +const MASK_EXPANSION_MM: f64 = 0.1; +/// Anti-pad / thermal relief of a plated hole on plane layers: pad + 0.5 mm +/// (0.25 mm clearance per side), the usual Allegro default. +const ANTI_EXPANSION_MM: f64 = 0.5; +/// Clearance between the outermost geometry and the generated place bound. +const PLACE_BOUND_CLEARANCE_MM: f64 = 0.25; +/// Allegro still truncates/limits legacy names; stay inside the conservative limits. +const MAX_SYMBOL_NAME: usize = 31; +const MAX_PADSTACK_NAME: usize = 20; + +const SILK_TOP: &str = "PACKAGE GEOMETRY/SILKSCREEN_TOP"; +/// The silkscreen reference designator sits this far above the place bound (its +/// baseline; Allegro anchors text at the baseline), centred over the part. +const REFDES_GAP_MM: f64 = 0.3; + +// Line widths and text sizes follow the KiCad Library Conventions F5.1 / F5.2, +// which put the IPC drawing layers into numbers: +/// Silkscreen lines: 0.12 mm nominal (F5.1). +pub const SILK_LINE_MM: f64 = 0.12; +/// Assembly (fabrication) lines: 0.10 mm (F5.2). +pub const ASSEMBLY_LINE_MM: f64 = 0.10; +/// Silkscreen reference designator: 1.0 mm high, 0.15 mm strokes (F5.1) on +/// parts wide enough for it; on small parts it shrinks with the part, down to +/// 0.6 mm, so the label is about as wide as the part. +pub const SILK_TEXT_MM: f64 = 1.0; +pub const SILK_TEXT_MIN_MM: f64 = 0.6; +/// Silkscreen strokes never go below this, whatever the text height. +pub const SILK_STROKE_MIN_MM: f64 = 0.10; + +/// Height of the silkscreen reference designator `label` over a part whose +/// place bound is `bound`: the label (at least three characters) spans the +/// part's width, between 0.6 and 1.0 mm high. +pub fn silk_refdes_height(bound: Option<(f64, f64, f64, f64)>, label: &str) -> f64 { + let Some((x1, _, x2, _)) = bound else { + return SILK_TEXT_MM; + }; + let chars = label.chars().count().max(3) as f64; + ((x2 - x1) / (chars * CHAR_ADVANCE)).clamp(SILK_TEXT_MIN_MM, SILK_TEXT_MM) +} + +/// Stroke width for silkscreen text `height` mm high: 15 %, at least 0.10 mm. +pub fn silk_stroke(height: f64) -> f64 { + (height * 0.15).max(SILK_STROKE_MIN_MM) +} +/// Assembly reference designator: 0.5-1.0 mm high, scaled so about four +/// characters fit the body; strokes 15 % of the height (F5.2). +pub const ASSEMBLY_TEXT_MIN_MM: f64 = 0.5; +pub const ASSEMBLY_TEXT_MAX_MM: f64 = 1.0; +/// Text block used for the silkscreen reference designator. +const SILK_TEXT_BLOCK: u32 = 3; +/// Text block used for the assembly reference designator. +const ASSEMBLY_TEXT_BLOCK: u32 = 2; +/// Advance of one character as a fraction of the text height. +const CHAR_ADVANCE: f64 = 0.9; + +/// Height, rotation and position of the assembly reference designator `label` +/// for a body `(x1, y1, x2, y2)`: centred on the component along its long +/// axis, scaled so the label (at least three characters) fits, 0.5-1.0 mm. +pub fn assembly_refdes_layout( + body: Option<(f64, f64, f64, f64)>, + label: &str, +) -> (f64, f64, f64, f64) { + let chars = label.chars().count().max(3) as f64; + let Some((x1, y1, x2, y2)) = body else { + return (ASSEMBLY_TEXT_MAX_MM, 0.0, 0.0, -ASSEMBLY_TEXT_MAX_MM / 2.0); + }; + let (w, h) = (x2 - x1, y2 - y1); + let (major, minor, rotation) = if h > w { (h, w, 90.0) } else { (w, h, 0.0) }; + let (cx, cy) = ((x1 + x2) / 2.0, (y1 + y2) / 2.0); + let height = (major / (chars * CHAR_ADVANCE)) + .min(minor * 0.9) + .clamp(ASSEMBLY_TEXT_MIN_MM, ASSEMBLY_TEXT_MAX_MM); + // Centred on the component: the baseline half a text height below (or + // beside) the centre. + if rotation == 0.0 { + (height, rotation, cx, cy - height / 2.0) + } else { + (height, rotation, cx + height / 2.0, cy) + } +} +const SILK_BOTTOM: &str = "PACKAGE GEOMETRY/SILKSCREEN_BOTTOM"; +const ASSY_TOP: &str = "PACKAGE GEOMETRY/ASSEMBLY_TOP"; +const ASSY_BOTTOM: &str = "PACKAGE GEOMETRY/ASSEMBLY_BOTTOM"; +const PLACE_BOUND_TOP: &str = "PACKAGE GEOMETRY/PLACE_BOUND_TOP"; + +/// A generated Allegro footprint script. +#[derive(Debug, Clone)] +pub struct AllegroFootprint { + /// Complete, self-contained SKILL script. + pub script: String, + /// Non-fatal conversion notes. + pub warnings: Vec, +} + +/// What a script draws, kept to place the reference designators (mm, Y up). +#[derive(Debug, Clone, Default)] +struct Drawing { + /// Pad centres and the half size of the larger pad side. + pads: Vec<(f64, f64, f64)>, + /// Lines and arcs as polylines: layer and points. + lines: Vec<(String, Vec<(f64, f64)>)>, + /// Circles: layer, centre and radius. + circles: Vec<(String, (f64, f64), f64)>, +} + +/// Allegro-safe lower-case identifier (`[a-z0-9_-]`), truncated to `max_len`. +pub fn allegro_identifier(name: &str, max_len: usize) -> String { + let mut out = String::with_capacity(name.len()); + let mut last_underscore = false; + for ch in name.chars() { + let mapped = if ch.is_ascii_alphanumeric() || ch == '-' { + ch.to_ascii_lowercase() + } else { + '_' + }; + if mapped == '_' { + if last_underscore { + continue; + } + last_underscore = true; + } else { + last_underscore = false; + } + out.push(mapped); + } + let mut out = out.trim_matches('_').to_string(); + if out.is_empty() { + out = "seex_fp".to_string(); + } + if out.len() > max_len { + out.truncate(max_len); + out = out.trim_end_matches(['_', '-']).to_string(); + } + out +} + +/// Package symbol name for an LCSC component: the LCSC id first so truncation never +/// makes two parts collide. +pub fn footprint_name_for(lcsc_id: &str, title: &str) -> String { + allegro_identifier(&format!("{}_{}", lcsc_id, title), MAX_SYMBOL_NAME) +} + +/// Rotation (degrees) and offset (mm) of a STEP model on its footprint, in +/// Allegro's axes (Y up). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct StepTransform { + pub rotation: [f64; 3], + pub offset: [f64; 3], +} + +/// A STEP model to map onto the footprint. +pub struct StepModel<'a> { + /// File name; Allegro stores only this and finds the file through STEPPATH. + pub file: &'a str, + /// Folder holding the file. + pub dir: &'a std::path::Path, + pub transform: StepTransform, +} + +/// Build the SKILL script for `footprint`, mapping `step` as its primary 3D model. +pub fn build_allegro_footprint_with( + footprint: &KiFootprint, + name: &str, + refdes_prefix: &str, + step: Option, +) -> AllegroFootprint { + let mut builder = Builder::default(); + builder.pins_and_padstacks(footprint); + builder.graphics(footprint); + let place_bound = place_bound(footprint); + // The component frame is the silkscreen: the assembly layer keeps only the + // reference designator, centred on the body the assembly outline gave. + let body = assembly_box(&builder.drawing); + builder.drop_assembly_graphics(); + + let refdes = format!("{}*", refdes_label(refdes_prefix)); + let mut script = String::new(); + let _ = writeln!( + script, + ";; {name}.il - Allegro package symbol generated by SeEx (https://github.com/ref42/seex)\n\ + ;;\n\ + ;; The library script in this folder builds every footprint at once. To build\n\ + ;; only this one:\n\ + ;; 1. Allegro PCB Editor: File > New, Drawing Type \"Package symbol\", name \"{name}\".\n\ + ;; 2. Command window: skill load(\"/{name}.il\")\n\ + ;; 3. File > Save writes {name}.dra; File > Create Symbol writes {name}.psm.\n\ + ;; Padstacks are written as .pad files to the current working directory; keep\n\ + ;; them on your PADPATH together with the .psm on your PSMPATH.\n" + ); + script.push_str(HELPERS); + let _ = writeln!( + script, + "\nwhen( seex_begin(\"{name}\" {})", + f(drawing_half_extent(footprint)) + ); + for line in &builder.padstack_calls { + let _ = writeln!(script, " {line}"); + } + for line in &builder.body { + let _ = writeln!(script, " {line}"); + } + let silk_height = silk_refdes_height(place_bound, &refdes); + let _ = writeln!( + script, + " seex_text_block({SILK_TEXT_BLOCK} {} {} {})", + f(silk_height), + f(silk_height), + f(silk_stroke(silk_height)) + ); + let body = body.or_else(|| pad_box(&builder.drawing)); + let (assembly_height, assembly_rotation, assembly_x, assembly_y) = + assembly_refdes_layout(body, &refdes); + let _ = writeln!( + script, + " seex_text_block({ASSEMBLY_TEXT_BLOCK} {} {} {})", + f(assembly_height), + f(assembly_height), + f(assembly_height * 0.15) + ); + if let Some((x1, y1, x2, y2)) = place_bound { + let _ = writeln!( + script, + " seex_place_bound(\"{PLACE_BOUND_TOP}\" {} {} {} {})", + f(x1), + f(y1), + f(x2), + f(y2) + ); + let label_y = y2 + REFDES_GAP_MM; + let _ = writeln!( + script, + " seex_text(\"REF DES/SILKSCREEN_TOP\" \"{refdes}\" {} {} {SILK_TEXT_BLOCK} 0.0)", + f((x1 + x2) / 2.0), + f(label_y) + ); + } else { + let _ = writeln!( + script, + " seex_text(\"REF DES/SILKSCREEN_TOP\" \"{refdes}\" 0.0 1.0 {SILK_TEXT_BLOCK} 0.0)" + ); + } + let _ = writeln!( + script, + " seex_text(\"REF DES/ASSEMBLY_TOP\" \"{refdes}\" {} {} {ASSEMBLY_TEXT_BLOCK} {})", + f(assembly_x), + f(assembly_y), + f(assembly_rotation) + ); + if let Some(step) = step { + let [rx, ry, rz] = step.transform.rotation; + let [ox, oy, oz] = step.transform.offset; + let _ = writeln!( + script, + " seex_step(\"{}\" \"{}\" {} {} {} {} {} {})", + skill_string(step.file), + skill_string(&step.dir.to_string_lossy().replace('\\', "/")), + f(rx), + f(ry), + f(rz), + f(ox), + f(oy), + f(oz) + ); + } + let _ = writeln!(script, " seex_end(\"{name}\")\n)"); + + AllegroFootprint { + script, + warnings: builder.warnings, + } +} + +/// Extent of the copper, for parts without a body outline. +fn pad_box(drawing: &Drawing) -> Option<(f64, f64, f64, f64)> { + let points: Vec<(f64, f64)> = drawing + .pads + .iter() + .flat_map(|&(x, y, half)| [(x - half, y - half), (x + half, y + half)]) + .collect(); + if points.is_empty() { + return None; + } + let (x1, y1, x2, y2) = bounds(&points); + (x2 - x1 > 1e-6 && y2 - y1 > 1e-6).then_some((x1, y1, x2, y2)) +} + +/// Extent of the assembly-top outline, i.e. the component body. +fn assembly_box(drawing: &Drawing) -> Option<(f64, f64, f64, f64)> { + let points: Vec<(f64, f64)> = drawing + .lines + .iter() + .filter(|(layer, _)| layer == ASSY_TOP) + .flat_map(|(_, points)| points.iter().copied()) + .chain( + drawing + .circles + .iter() + .filter(|(layer, ..)| layer == ASSY_TOP) + .flat_map(|(_, (x, y), r)| [(x - r, y - r), (x + r, y + r)]), + ) + .collect(); + if points.is_empty() { + return None; + } + let (x1, y1, x2, y2) = bounds(&points); + (x2 - x1 > 1e-6 && y2 - y1 > 1e-6).then_some((x1, y1, x2, y2)) +} + +fn refdes_label(prefix: &str) -> String { + let prefix: String = prefix + .trim() + .trim_end_matches('?') + .chars() + .filter(|ch| ch.is_ascii_alphabetic()) + .collect(); + if prefix.is_empty() { + "U".to_string() + } else { + prefix + } +} + +#[derive(Default)] +struct Builder { + padstack_calls: Vec, + padstack_names: Vec, + body: Vec, + warnings: Vec, + drawing: Drawing, +} + +impl Builder { + /// Removes the assembly outline (lines, arcs, circles) from the drawing and + /// the script. + fn drop_assembly_graphics(&mut self) { + let assembly = |layer: &str| layer == ASSY_TOP || layer == ASSY_BOTTOM; + self.drawing.lines.retain(|(layer, ..)| !assembly(layer)); + self.drawing.circles.retain(|(layer, ..)| !assembly(layer)); + self.body.retain(|call| { + !call.contains(&format!("\"{ASSY_TOP}\"")) + && !call.contains(&format!("\"{ASSY_BOTTOM}\"")) + }); + } + + fn pins_and_padstacks(&mut self, footprint: &KiFootprint) { + let mut used_numbers: HashMap = HashMap::new(); + let mut skipped_vias = 0usize; + + for pad in &footprint.pads { + let number = pad.number.trim().to_string(); + let is_mechanical = pad.pad_type == PadType::NpThroughHole; + if number.is_empty() && !is_mechanical { + // Unnumbered plated holes are EasyEDA vias (usually thermal vias in an + // exposed pad). As Allegro pins they would be unconnected mechanical + // pins inside a copper pad, so they are left out on purpose. + skipped_vias += 1; + continue; + } + + let (x, y) = to_mm(pad.pos_x, pad.pos_y); + let pin_number = if is_mechanical { + None + } else { + let count = used_numbers.entry(number.clone()).or_insert(0); + *count += 1; + if *count == 1 { + Some(number.clone()) + } else { + let renamed = format!("{}_{}", number, count); + self.warnings.push(format!( + "pad number {number} is used more than once; extra pad renamed {renamed} \ + (Allegro pin numbers must be unique)" + )); + Some(renamed) + } + }; + + let Some(stack) = Padstack::for_pad(pad, &mut self.warnings) else { + continue; + }; + self.declare_padstack(&stack); + + let rotation = if stack.custom_polygon.is_some() { + 0.0 + } else { + normalize_degrees(pad.rotation) + }; + let number_arg = match &pin_number { + Some(number) => format!("\"{}\"", skill_escape(number)), + None => "nil".to_string(), + }; + self.drawing + .pads + .push((x, y, stack.width.max(stack.height) / 2.0)); + self.body.push(format!( + "seex_pin(\"{}\" {} {} {} {})", + stack.name, + number_arg, + f(x), + f(y), + f(rotation) + )); + + if let Some(points) = &stack.custom_polygon { + let absolute: Vec<(f64, f64)> = + points.iter().map(|(px, py)| (x + px, y + py)).collect(); + let (copper, mask, paste) = if stack.bottom { + ( + "ETCH/BOTTOM", + "PACKAGE GEOMETRY/SOLDERMASK_BOTTOM", + "PACKAGE GEOMETRY/PASTEMASK_BOTTOM", + ) + } else { + ( + "ETCH/TOP", + "PACKAGE GEOMETRY/SOLDERMASK_TOP", + "PACKAGE GEOMETRY/PASTEMASK_TOP", + ) + }; + for layer in [copper, mask, paste] { + self.body.push(format!( + "seex_shape(\"{layer}\" {})", + points_list(&absolute) + )); + } + } + } + + if skipped_vias > 0 { + self.warnings.push(format!( + "{skipped_vias} unnumbered via(s) skipped; add thermal vias to the pad in Allegro if needed" + )); + } + } + + fn declare_padstack(&mut self, stack: &Padstack) { + if self.padstack_names.contains(&stack.name) { + return; + } + self.padstack_names.push(stack.name.clone()); + self.padstack_calls.push(stack.skill()); + self.padstack_calls.push(stack.check_call()); + } + + fn graphics(&mut self, footprint: &KiFootprint) { + let mut skipped_layers: Vec = Vec::new(); + let mut note_skip = |layer: &str| { + if !skipped_layers.iter().any(|known| known == layer) { + skipped_layers.push(layer.to_string()); + } + }; + + for line in &footprint.lines { + match graphic_layer(&line.layer) { + Some(layer) => self.line(layer, line), + None if is_courtyard(&line.layer) => {} + None => note_skip(&line.layer), + } + } + for circle in &footprint.circles { + match graphic_layer(&circle.layer) { + Some(layer) => self.circle(layer, circle), + None if is_courtyard(&circle.layer) => {} + None => note_skip(&circle.layer), + } + } + for arc in &footprint.arcs { + match graphic_layer(&arc.layer) { + Some(layer) => self.arc(layer, arc), + None if is_courtyard(&arc.layer) => {} + None => note_skip(&arc.layer), + } + } + + if !skipped_layers.is_empty() { + self.warnings.push(format!( + "graphics on layers without an Allegro equivalent were skipped: {}", + skipped_layers.join(", ") + )); + } + if !footprint.texts.is_empty() { + self.warnings.push(format!( + "{} footprint text item(s) not converted (Allegro text blocks are design specific)", + footprint.texts.len() + )); + } + } + + fn line(&mut self, layer: &str, line: &KiLine) { + let a = to_mm(line.start_x, line.start_y); + let b = to_mm(line.end_x, line.end_y); + if distance(a, b) < 1e-6 { + return; + } + self.drawing.lines.push((layer.to_string(), vec![a, b])); + self.body.push(format!( + "seex_line(\"{layer}\" {} {})", + f(line_width(layer, line.width)), + points_list(&[a, b]) + )); + } + + fn circle(&mut self, layer: &str, circle: &KiCircle) { + let center = to_mm(circle.center_x, circle.center_y); + let edge = to_mm(circle.end_x, circle.end_y); + let radius = distance(center, edge); + if radius < 1e-6 { + return; + } + self.drawing + .circles + .push((layer.to_string(), center, radius)); + self.body.push(format!( + "seex_circle(\"{layer}\" {} {} {} {} {})", + f(line_width_mm(layer, circle.width)), + f(center.0), + f(center.1), + f(radius), + if circle.fill { "t" } else { "nil" } + )); + } + + fn arc(&mut self, layer: &str, arc: &KiArc) { + let start = to_mm(arc.start_x, arc.start_y); + let mid = to_mm(arc.mid_x, arc.mid_y); + let end = to_mm(arc.end_x, arc.end_y); + let width = line_width(layer, arc.width); + let Some((center, _)) = circumcircle(start, mid, end) else { + self.drawing + .lines + .push((layer.to_string(), vec![start, mid, end])); + self.body.push(format!( + "seex_line(\"{layer}\" {} {})", + f(width), + points_list(&[start, mid, end]) + )); + return; + }; + // Y points up here, so a negative cross product means clockwise travel. + let cross = (mid.0 - start.0) * (end.1 - mid.1) - (mid.1 - start.1) * (end.0 - mid.0); + let clockwise = cross < 0.0; + let fallback = arc_polyline(start, end, center, clockwise); + self.drawing + .lines + .push((layer.to_string(), fallback.clone())); + self.body.push(format!( + "seex_arc(\"{layer}\" {} {} {} {} {} {} {} {} {})", + f(width), + f(start.0), + f(start.1), + f(end.0), + f(end.1), + f(center.0), + f(center.1), + if clockwise { "t" } else { "nil" }, + points_list(&fallback) + )); + } +} + +/// Padstack definition derived from one EasyEDA pad. +struct Padstack { + name: String, + /// SKILL figure symbol and size (mm) of the copper pad. + figure: &'static str, + width: f64, + height: f64, + kind: PadstackKind, + bottom: bool, + /// Pad-relative outline (mm, Y up) for custom polygon pads. + custom_polygon: Option>, +} + +enum PadstackKind { + Smd, + Plated { drill_x: f64, drill_y: f64 }, + NonPlated { drill: f64 }, +} + +impl Padstack { + fn for_pad(pad: &KiPad, warnings: &mut Vec) -> Option { + let bottom = pad.layers.iter().any(|layer| layer.starts_with("B.")) + && !pad.layers.iter().any(|layer| layer.starts_with("F.")); + let mut width = px(pad.size_x).max(0.01); + let mut height = px(pad.size_y).max(0.01); + + let kind = match pad.pad_type { + PadType::NpThroughHole => { + let drill = pad + .drill + .as_ref() + .map(|drill| px(drill.diameter)) + .unwrap_or(width); + PadstackKind::NonPlated { drill } + } + PadType::ThroughHole => { + let Some(drill) = &pad.drill else { + warnings.push(format!( + "through-hole pad {} has no drill; skipped", + pad.number + )); + return None; + }; + let drill_x = px(drill.diameter); + let drill_y = drill.width.map(px).unwrap_or(drill_x); + PadstackKind::Plated { drill_x, drill_y } + } + PadType::Smd | PadType::Connect => PadstackKind::Smd, + }; + + let mut custom_polygon = None; + let mut figure = match pad.shape { + PadShape::Circle => "CIRCLE", + PadShape::Oval => "OBLONG", + _ => "RECTANGLE", + }; + if figure == "CIRCLE" { + let diameter = width.max(height); + width = diameter; + height = diameter; + } + if figure == "OBLONG" && (width - height).abs() < 1e-6 { + figure = "CIRCLE"; + } + // Allegro rejects a RECTANGLE with equal sides ("Illegal figure size"). + if figure == "RECTANGLE" && (width - height).abs() < 1e-6 { + figure = "SQUARE"; + } + + if pad.shape == PadShape::Custom { + let points = pad + .polygon + .as_deref() + .map(parse_kicad_polygon) + .unwrap_or_default(); + if points.len() >= 3 { + let (min_x, min_y, max_x, max_y) = bounds(&points); + // A small round pad anchors the pin; the real outline is drawn as + // copper / mask / paste shapes around it. + let anchor = ((max_x - min_x).min(max_y - min_y) * 0.5).clamp(0.1, 1.0); + width = anchor; + height = anchor; + figure = "CIRCLE"; + warnings.push(format!( + "pad {} is a custom polygon: emitted as a {:.2} mm anchor pad plus copper/mask/paste shapes", + pad.number, anchor + )); + custom_polygon = Some(points); + } else { + warnings.push(format!( + "pad {} is a custom polygon without usable points; using its bounding size", + pad.number + )); + } + } + + let name = padstack_name(figure, width, height, &kind, bottom); + Some(Self { + name, + figure, + width, + height, + kind, + bottom, + custom_polygon, + }) + } + + /// Layers (as Allegro reports them) and pad types this padstack must have. + fn required_layers(&self) -> Vec<(&'static str, &'static str)> { + match &self.kind { + PadstackKind::Smd if self.bottom => vec![ + ("ETCH/BOTTOM", "REGULAR"), + ("PIN/SOLDERMASK_BOTTOM", "REGULAR"), + ("PIN/PASTEMASK_BOTTOM", "REGULAR"), + ], + PadstackKind::Smd => vec![ + ("ETCH/TOP", "REGULAR"), + ("PIN/SOLDERMASK_TOP", "REGULAR"), + ("PIN/PASTEMASK_TOP", "REGULAR"), + ], + PadstackKind::Plated { .. } => vec![ + ("ETCH/TOP", "REGULAR"), + ("ETCH/TOP", "ANTI"), + ("ETCH/TOP", "THERMAL"), + ("ETCH/BOTTOM", "REGULAR"), + ("ETCH/BOTTOM", "ANTI"), + ("ETCH/BOTTOM", "THERMAL"), + ("PIN/SOLDERMASK_TOP", "REGULAR"), + ("PIN/SOLDERMASK_BOTTOM", "REGULAR"), + ], + PadstackKind::NonPlated { .. } => vec![ + ("PIN/SOLDERMASK_TOP", "REGULAR"), + ("PIN/SOLDERMASK_BOTTOM", "REGULAR"), + ], + } + } + + /// `seex_check_padstack(name required drilled)`: reads the padstack back and + /// reports any required layer it lacks. + fn check_call(&self) -> String { + let required: Vec = self + .required_layers() + .iter() + .map(|(layer, kind)| format!("list(\"{layer}\" \"{kind}\")")) + .collect(); + let drilled = if matches!(self.kind, PadstackKind::Smd) { + "nil" + } else { + "t" + }; + format!( + "seex_check_padstack(\"{}\" list({}) {drilled})", + self.name, + required.join(" ") + ) + } + + /// `seex_padstack(name drill pads fallbackDrill fallbackPads)` call. + fn skill(&self) -> String { + let fig = format!("'{}", self.figure); + let fallback_fig = if self.figure == "OBLONG" { + "'RECTANGLE".to_string() + } else { + fig.clone() + }; + let (w, h) = (self.width, self.height); + let (mw, mh) = (w + MASK_EXPANSION_MM, h + MASK_EXPANSION_MM); + match &self.kind { + PadstackKind::Smd => { + let (copper, mask, paste) = if self.bottom { + ("BOTTOM", "SOLDERMASK_BOTTOM", "PASTEMASK_BOTTOM") + } else { + ("TOP", "SOLDERMASK_TOP", "PASTEMASK_TOP") + }; + let pads = |fig: &str| { + format!( + "list({} {} {})", + pad_call(copper, fig, w, h), + pad_call(mask, fig, mw, mh), + pad_call(paste, fig, w, h) + ) + }; + format!( + "seex_padstack(\"{}\" nil {} nil {})", + self.name, + pads(&fig), + pads(&fallback_fig) + ) + } + PadstackKind::Plated { drill_x, drill_y } => { + let (aw, ah) = (w + ANTI_EXPANSION_MM, h + ANTI_EXPANSION_MM); + let all_layers = |fig: &str, internal: bool| { + let mut etch = vec!["TOP"]; + if internal { + etch.push("DEFAULT_INTERNAL"); + } + etch.push("BOTTOM"); + let mut calls = Vec::new(); + for layer in etch { + calls.push(pad_call(layer, fig, w, h)); + calls.push(typed_pad_call(layer, "ANTI", fig, aw, ah)); + calls.push(typed_pad_call(layer, "THERMAL", fig, aw, ah)); + } + calls.push(pad_call("SOLDERMASK_TOP", fig, mw, mh)); + calls.push(pad_call("SOLDERMASK_BOTTOM", fig, mw, mh)); + format!("list({})", calls.join(" ")) + }; + let is_slot = (drill_x - drill_y).abs() > 1e-6; + let drill = if is_slot { + format!("seex_slot({} {} t)", f(*drill_x), f(*drill_y)) + } else { + format!("seex_drill({} t)", f(*drill_x)) + }; + // Fallback: round drill (smallest slot dimension) and no internal pad. + let fallback_drill = format!("seex_drill({} t)", f(drill_x.min(*drill_y))); + format!( + "seex_padstack(\"{}\" {} {} {} {})", + self.name, + drill, + all_layers(&fig, true), + fallback_drill, + all_layers(&fallback_fig, false) + ) + } + PadstackKind::NonPlated { drill } => { + let mask = drill + MASK_EXPANSION_MM; + let pads = format!( + "list({} {})", + pad_call("SOLDERMASK_TOP", "'CIRCLE", mask, mask), + pad_call("SOLDERMASK_BOTTOM", "'CIRCLE", mask, mask) + ); + format!( + "seex_padstack(\"{}\" seex_drill({} nil) {pads} seex_drill({} nil) {pads})", + self.name, + f(*drill), + f(*drill) + ) + } + } + } +} + +fn pad_call(layer: &str, figure: &str, width: f64, height: f64) -> String { + format!("seex_pad(\"{layer}\" {figure} {} {})", f(width), f(height)) +} + +fn typed_pad_call(layer: &str, kind: &str, figure: &str, width: f64, height: f64) -> String { + format!( + "seex_padt(\"{layer}\" '{kind} {figure} {} {})", + f(width), + f(height) + ) +} + +/// Deterministic, geometry-based padstack name so identical pads share one `.pad`. +fn padstack_name( + figure: &str, + width: f64, + height: f64, + kind: &PadstackKind, + bottom: bool, +) -> String { + let fig = match figure { + "CIRCLE" => "c", + "OBLONG" => "o", + "SQUARE" => "q", + _ => "r", + }; + let size = if fig == "c" || fig == "q" { + hundredths(width) + } else { + format!("{}x{}", hundredths(width), hundredths(height)) + }; + let name = match kind { + PadstackKind::Smd => format!("sx_{}{fig}{size}", if bottom { "b" } else { "s" }), + PadstackKind::Plated { drill_x, drill_y } => { + let drill = if (drill_x - drill_y).abs() > 1e-6 { + format!("{}x{}", hundredths(*drill_x), hundredths(*drill_y)) + } else { + hundredths(*drill_x) + }; + format!("sx_t{fig}{size}d{drill}") + } + PadstackKind::NonPlated { drill } => format!("sx_h{}", hundredths(*drill)), + }; + if name.len() <= MAX_PADSTACK_NAME { + name + } else { + // Keep the type prefix readable and make the rest a stable short hash. + format!("{}{:08x}", &name[..5], fnv1a(&name)) + } +} + +fn hundredths(mm: f64) -> String { + format!("{}", (mm * 100.0).round() as i64) +} + +fn fnv1a(text: &str) -> u32 { + let mut hash: u32 = 0x811c_9dc5; + for byte in text.bytes() { + hash ^= u32::from(byte); + hash = hash.wrapping_mul(0x0100_0193); + } + hash +} + +/// Points of a KiCad custom pad primitive: `(xy 0.12 -0.34)` pairs, pad-relative mm +/// with Y down. Returned with Y up. +fn parse_kicad_polygon(text: &str) -> Vec<(f64, f64)> { + let mut points = Vec::new(); + let mut rest = text; + while let Some(index) = rest.find("(xy ") { + rest = &rest[index + 4..]; + let Some(end) = rest.find(')') else { + break; + }; + let mut numbers = rest[..end].split_whitespace().map(str::parse::); + if let (Some(Ok(x)), Some(Ok(y))) = (numbers.next(), numbers.next()) { + points.push((x, -y)); + } + rest = &rest[end..]; + } + points +} + +/// Half size (mm) of a square drawing area centred on the origin that holds the +/// whole footprint with room for labels. New Allegro drawings start at (0, 0), so +/// without this every negative coordinate is "outside of the extents". +fn drawing_half_extent(footprint: &KiFootprint) -> f64 { + let mut max_abs: f64 = 0.0; + let mut add = |x: f64, y: f64| { + let (x, y) = to_mm(x, y); + max_abs = max_abs.max(x.abs()).max(y.abs()); + }; + for pad in &footprint.pads { + let half = pad.size_x.max(pad.size_y); + add(pad.pos_x.abs() + half, pad.pos_y.abs() + half); + } + for line in &footprint.lines { + add(line.start_x, line.start_y); + add(line.end_x, line.end_y); + } + for circle in &footprint.circles { + let r = ((circle.end_x - circle.center_x).powi(2) + + (circle.end_y - circle.center_y).powi(2)) + .sqrt(); + add(circle.center_x.abs() + r, circle.center_y.abs() + r); + } + for arc in &footprint.arcs { + add(arc.start_x, arc.start_y); + add(arc.mid_x, arc.mid_y); + add(arc.end_x, arc.end_y); + } + (max_abs * 2.0 + 10.0).ceil() +} + +fn place_bound(footprint: &KiFootprint) -> Option<(f64, f64, f64, f64)> { + let mut courtyard = Vec::new(); + for line in footprint + .lines + .iter() + .filter(|line| is_courtyard(&line.layer)) + { + courtyard.push(to_mm(line.start_x, line.start_y)); + courtyard.push(to_mm(line.end_x, line.end_y)); + } + if courtyard.len() >= 2 { + let (x1, y1, x2, y2) = bounds(&courtyard); + if x2 - x1 > 1e-6 && y2 - y1 > 1e-6 { + return Some((x1, y1, x2, y2)); + } + } + + // No usable courtyard: wrap the pads. + let mut points = Vec::new(); + for pad in &footprint.pads { + let (x, y) = to_mm(pad.pos_x, pad.pos_y); + let half = px(pad.size_x.max(pad.size_y)) / 2.0; + points.push((x - half, y - half)); + points.push((x + half, y + half)); + } + if points.is_empty() { + return None; + } + let (x1, y1, x2, y2) = bounds(&points); + Some(( + x1 - PLACE_BOUND_CLEARANCE_MM, + y1 - PLACE_BOUND_CLEARANCE_MM, + x2 + PLACE_BOUND_CLEARANCE_MM, + y2 + PLACE_BOUND_CLEARANCE_MM, + )) +} + +fn is_courtyard(layer: &str) -> bool { + matches!(layer, "F.CrtYd" | "B.CrtYd") +} + +fn graphic_layer(layer: &str) -> Option<&'static str> { + match layer { + "F.SilkS" => Some(SILK_TOP), + "B.SilkS" => Some(SILK_BOTTOM), + "F.Fab" | "Dwgs.User" => Some(ASSY_TOP), + "B.Fab" => Some(ASSY_BOTTOM), + _ => None, + } +} + +/// Line widths in the shared model are not consistently scaled (some sources are px, +/// some already mm); accept plausible values and fall back to library defaults. +fn line_width(layer: &str, width_px: f64) -> f64 { + sane_width(layer, px(width_px)) +} + +fn line_width_mm(layer: &str, width: f64) -> f64 { + // Circle widths are stored in mm by the converter. + sane_width(layer, width) +} + +/// Silkscreen and assembly lines are drawn at the convention widths (KLC F5.1, +/// F5.2) whatever the source used; other layers keep plausible source widths. +fn sane_width(layer: &str, width_mm: f64) -> f64 { + if layer == SILK_TOP || layer == SILK_BOTTOM { + SILK_LINE_MM + } else if layer == ASSY_TOP || layer == ASSY_BOTTOM { + ASSEMBLY_LINE_MM + } else if (0.05..=1.0).contains(&width_mm) { + width_mm + } else { + 0.1 + } +} + +fn to_mm(x_px: f64, y_px: f64) -> (f64, f64) { + // Footprint model: Y down. Allegro: Y up. + (px(x_px), -px(y_px)) +} + +fn px(value: f64) -> f64 { + value * PX_TO_MM +} + +fn distance(a: (f64, f64), b: (f64, f64)) -> f64 { + ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() +} + +fn bounds(points: &[(f64, f64)]) -> (f64, f64, f64, f64) { + let mut out = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for &(x, y) in points { + out.0 = out.0.min(x); + out.1 = out.1.min(y); + out.2 = out.2.max(x); + out.3 = out.3.max(y); + } + out +} + +fn circumcircle(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> Option<((f64, f64), f64)> { + let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1)); + if d.abs() < 1e-12 { + return None; + } + let a2 = a.0 * a.0 + a.1 * a.1; + let b2 = b.0 * b.0 + b.1 * b.1; + let c2 = c.0 * c.0 + c.1 * c.1; + let center = ( + (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d, + (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d, + ); + Some((center, distance(center, a))) +} + +fn arc_polyline( + start: (f64, f64), + end: (f64, f64), + center: (f64, f64), + clockwise: bool, +) -> Vec<(f64, f64)> { + let radius = distance(center, start); + let a0 = (start.1 - center.1).atan2(start.0 - center.0); + let a1 = (end.1 - center.1).atan2(end.0 - center.0); + let tau = std::f64::consts::TAU; + let sweep = if clockwise { + -(a0 - a1).rem_euclid(tau) + } else { + (a1 - a0).rem_euclid(tau) + }; + let segments = ((sweep.abs() / tau) * 32.0).ceil().max(2.0) as usize; + (0..=segments) + .map(|index| { + let angle = a0 + sweep * index as f64 / segments as f64; + ( + center.0 + radius * angle.cos(), + center.1 + radius * angle.sin(), + ) + }) + .collect() +} + +fn normalize_degrees(angle: f64) -> f64 { + let normalized = angle.rem_euclid(360.0); + if (normalized - 360.0).abs() < 1e-9 { + 0.0 + } else { + normalized + } +} + +fn points_list(points: &[(f64, f64)]) -> String { + let inner: Vec = points + .iter() + .map(|(x, y)| format!("list({} {})", f(*x), f(*y))) + .collect(); + format!("list({})", inner.join(" ")) +} + +/// SKILL float literal (always with a decimal point, 4 decimals). +fn f(value: f64) -> String { + let value = if value.abs() < 5e-5 { 0.0 } else { value }; + format!("{value:.4}") +} + +fn skill_escape(text: &str) -> String { + text.replace('\\', "\\\\").replace('"', "\\\"") +} + +/// Shared SKILL helpers, embedded in every footprint script so each file is +/// self-contained. All coordinates passed to them are millimetres, Y up. +const HELPERS: &str = r#" +;; ---------------------------------------------------------------- SeEx helpers +seex_scale = 1.0 +seex_errors = 0 + +procedure( seex_units_scale() + let( (scale units) + scale = car(errset(axlMKSConvert(1.0 "MM") nil)) + unless( numberp(scale) + units = car(errset(car(axlDBGetDesignUnits()) nil)) + scale = cond( + ( !stringp(units) 1.0 ) + ( rexMatchp("^mil" units) 1.0 / 0.0254 ) + ( rexMatchp("^inch" units) 1.0 / 25.4 ) + ( rexMatchp("^cent" units) 0.1 ) + ( rexMatchp("^micro" units) 1000.0 ) + ( t 1.0 ) + ) + ) + scale + ) +) + +procedure( seex_u(mm) float(mm) * seex_scale ) + +procedure( seex_xy(x y) list(seex_u(x) seex_u(y)) ) + +procedure( seex_pts(points) + mapcar(lambda((p) seex_xy(car(p) cadr(p))) points) +) + +procedure( seex_begin(name half) + let( (dtype) + seex_errors = 0 + seex_notes = nil + ;; axlDesignType(nil) is "SYMBOL" for .dra drawings and "LAYOUT" for boards. + dtype = car(errset(axlDesignType(nil) nil)) + if( stringp(dtype) && upperCase(dtype) != "SYMBOL" then + printf("SeEx: the open design is not a symbol drawing. Use File > New > Package symbol first.\n") + seex_errors = 1 + nil + else + ;; Prefer metric with 4 decimals; harmless if the call is not available. + errset(axlDBChangeDesignUnits("millimeters" 4) nil) + seex_scale = seex_units_scale() + ;; New drawings span (0 0)..(w h); centre the extents on the symbol origin. + unless( car(errset(axlDBChangeDesignExtents( + list(seex_xy(-half -half) seex_xy(half half))) nil)) + printf("SeEx: WARNING could not set drawing extents to +/-%g mm\n" half) + ) + printf("SeEx: building package symbol %s (1 mm = %g design units)\n" name seex_scale) + t + ) + ) +) + +procedure( seex_pad(layer figure w h) + make_axlPadStackPad(?layer layer ?type 'REGULAR ?figure figure + ?figureSize list(seex_u(w) seex_u(h))) +) + +procedure( seex_padt(layer type figure w h) + make_axlPadStackPad(?layer layer ?type type ?figure figure + ?figureSize list(seex_u(w) seex_u(h))) +) + +;; Review notes passed back to SeEx (written after the status line). +seex_notes = nil +procedure( seex_note(text) + seex_notes = cons(text seex_notes) + printf("SeEx: %s\n" text) +) + +;; Read a padstack back and check that every required (layer type) pad is there. +procedure( seex_has_pad(ps layer type) + let( (found) + found = nil + foreach( pad ps->pads + when( pad->layer == layer && pad->type == type && pad->figureName != "NULL" + found = t + ) + ) + found + ) +) + +procedure( seex_check_padstack(name required drilled) + let( (ps missing) + ps = car(errset(axlLoadPadstack(name) nil)) + if( !ps then + seex_note(sprintf(nil "padstack %s could not be read back for checking" name)) + else + missing = nil + foreach( req required + unless( seex_has_pad(ps car(req) cadr(req)) + missing = cons(sprintf(nil "%s %s" car(req) cadr(req)) missing) + ) + ) + when( drilled && !(numberp(ps->drillDiameter) && ps->drillDiameter > 0) + missing = cons("drill" missing) + ) + when( missing + seex_note(sprintf(nil "padstack %s is missing %s" name buildString(reverse(missing) ", "))) + ) + ) + !missing + ) +) + +procedure( seex_drill(d plated) + make_axlPadStackDrill(?drillDiameter seex_u(d) + ?plating if(plated 'PLATED 'NON_PLATED) + ?figure 'CIRCLE + ?figureSize list(seex_u(d) seex_u(d))) +) + +procedure( seex_slot(w h plated) + let( (drill) + drill = car(errset(make_axlPadStackDrill(?drillDiameter seex_u(min(w h)) + ?holeType 'OVAL_SLOT + ?slotSize list(seex_u(w) seex_u(h)) + ?plating if(plated 'PLATED 'NON_PLATED) + ?figure 'OBLONG + ?figureSize list(seex_u(w) seex_u(h))) nil)) + unless( drill + printf("SeEx: WARNING slot %gx%g mm not supported here; using a round drill - edit the padstack\n" w h) + seex_errors = seex_errors + 1 + drill = seex_drill(min(w h) plated) + ) + drill + ) +) + +procedure( seex_try_padstack(name drill pads) + let( (res) + ;; 4th argument t skips the hole-vs-pad checks (needed for NPTH mask-only stacks). + res = errset(axlDBCreatePadStack(name drill pads t) nil) + when( res && car(res) + errset(axlPadstackToDisk(name) nil) + ) + res && car(res) + ) +) + +procedure( seex_padstack(name drill pads fbDrill fbPads) + cond( + ( seex_try_padstack(name drill pads) t ) + ( seex_try_padstack(name fbDrill fbPads) + seex_note(sprintf(nil "padstack %s created with simplified layers/figure (no internal pad or a round drill); review it" name)) + t ) + ( car(errset(axlLoadPadstack(name) nil)) + seex_note(sprintf(nil "padstack %s could not be created; the existing one on PADPATH is used" name)) + t ) + ( t + printf("SeEx: ERROR could not create padstack %s\n" name) + seex_errors = seex_errors + 1 + nil ) + ) +) + +procedure( seex_pin(padstack number x y rot) + let( (orient ptext res) + ptext = nil + when( number + orient = make_axlTextOrientation(?textBlock "1" ?justify "center" ?rotation 0.0) + ptext = make_axlPinText(?offset list(0.0 0.0) ?text orient) + ptext->number = number + ) + res = errset(axlDBCreatePin(padstack seex_xy(x y) ptext float(rot)) t) + unless( res && car(res) + printf("SeEx: ERROR could not place pin %L with padstack %s\n" number padstack) + seex_errors = seex_errors + 1 + ) + ) +) + +procedure( seex_line(layer width points) + errset(axlDBCreateLine(seex_pts(points) seex_u(width) layer) t) +) + +procedure( seex_circle(layer width cx cy r filled) + if( filled then + ;; A ring as wide as the radius renders as a solid disc. + errset(axlDBCreateCircle(list(seex_xy(cx cy) seex_u(r / 2.0)) seex_u(r) layer) t) + else + errset(axlDBCreateCircle(list(seex_xy(cx cy) seex_u(r)) seex_u(width) layer) t) + ) +) + +procedure( seex_arc(layer width sx sy ex ey cx cy clockwise fallback) + let( (path res) + res = errset( + progn( + path = axlPathStart(list(seex_xy(sx sy)) seex_u(width)) + path = axlPathArcCenter(path seex_u(width) seex_xy(ex ey) clockwise seex_xy(cx cy)) + axlDBCreatePath(path layer) + ) nil) + unless( res && car(res) + seex_line(layer width fallback) + ) + ) +) + +procedure( seex_polygon_path(points) + let( (path) + path = axlPathStart(list(seex_xy(car(car(points)) cadr(car(points)))) 0.0) + foreach( p cdr(points) + path = axlPathLine(path 0.0 seex_xy(car(p) cadr(p))) + ) + axlPathLine(path 0.0 seex_xy(car(car(points)) cadr(car(points)))) + ) +) + +procedure( seex_shape(layer points) + let( (res) + res = errset(axlDBCreateShape(seex_polygon_path(points) t layer) nil) + unless( res && car(res) + printf("SeEx: could not create a filled shape on %s; drawing its outline\n" layer) + seex_line(layer 0.0 append(points list(car(points)))) + ) + ) +) + +procedure( seex_place_bound(layer x1 y1 x2 y2) + seex_shape(layer list(list(x1 y1) list(x2 y1) list(x2 y2) list(x1 y2))) +) + +procedure( seex_text(layer label x y block rot) + let( (orient) + orient = make_axlTextOrientation(?textBlock sprintf(nil "%d" block) ?justify "center" + ?rotation float(rot)) + errset(axlDBCreateText(label seex_xy(x y) orient layer) t) + ) +) + +;; Size a text block of this drawing (mm): character width and height and the +;; stroke ("photo") width. Boards use their own text block table. +procedure( seex_text_block(block w h stroke) + let( (param) + param = car(errset(axlGetParam(sprintf(nil "paramTextBlock:%d" block)) nil)) + if( param then + errset(param->width = seex_u(w) nil) + errset(param->height = seex_u(h) nil) + errset(param->photoWidth = seex_u(stroke) nil) + errset(param->charSpace = seex_u(h * 0.2) nil) + errset(param->lineSpace = seex_u(h * 1.6) nil) + unless( car(errset(axlSetParam(param) nil)) + printf("SeEx: could not size text block %d\n" block) + ) + else + printf("SeEx: text block %d not found\n" block) + ) + ) +) + +;; Map a STEP model (found through STEPPATH) as the symbol's primary 3D model, +;; rotated (degrees) and then shifted (design units). The model's folder is put +;; in front of STEPPATH for this session so the mapping finds it. +procedure( seex_step(file dir rx ry rz ox oy oz) + let( (path ok) + path = car(errset(axlGetVariable("steppath") nil)) + when( listp(path) + path = buildString(path " ") + ) + unless( stringp(path) && member(dir parseString(path " ")) + errset(axlSetVariable("steppath" if(stringp(path) && path != "" + strcat(dir " " path) dir)) t) + ) + ok = car(errset(axlStepSet('primary nil file + list('rotation_x rx 'rotation_y ry 'rotation_z rz + 'offset_x ox 'offset_y oy 'offset_z oz)) t)) + unless( ok + printf("SeEx: could not map STEP model %s\n" file) + ) + ok + ) +) + +procedure( seex_end(name) + errset(axlVisibleUpdate(t) nil) + if( seex_errors == 0 then + if( boundp('seex_library_build) && seex_library_build then + printf("SeEx: %s drawn\n" name) + else + printf("SeEx: %s done. File > Save to write %s.dra, File > Create Symbol for %s.psm.\n" name name name) + ) + else + printf("SeEx: %s finished with %d problem(s); see messages above before saving.\n" name seex_errors) + ) + t +) + +;; Save the drawing and compile the .psm; "OK" or the reason it failed. +procedure( seex_finish(name) + let( (saved compiled) + saved = car(errset(axlSaveDesign(?design name ?noMru t ?noConfirm t) t)) + compiled = car(errset(axlCompileSymbol(?symbol name ?silent t ?silentOnOverwrite t) t)) + cond( + ( !saved "save failed" ) + ( !compiled "create symbol failed" ) + ( seex_errors > 0 sprintf(nil "%d problem(s) while building" seex_errors) ) + ( t "OK" ) + ) + ) +) +;; ------------------------------------------------------------ end of helpers +"#; + +/// The library script: loaded in Allegro PCB Editor, it builds every footprint +/// script in `dir` into `.dra`, `.psm` and `.pad` files in the same folder. +/// +/// Each footprint gets a new package symbol drawing (`axlOpenDesign` creates one +/// for a `.dra` name that does not exist yet; an older build is replaced). The +/// folder is the one the export wrote to; when the library has been moved, the +/// current working directory is used instead. +pub fn library_script(library: &str, dir: &std::path::Path, footprints: &[String]) -> String { + let dir = skill_string(&dir.to_string_lossy().replace('\\', "/")); + let names: Vec = footprints + .iter() + .map(|name| format!("\"{}\"", skill_string(name))) + .collect(); + let mut script = String::new(); + let _ = writeln!( + script, + ";; {library}.il - builds the Allegro footprints of the SeEx library \"{library}\"\n\ + ;;\n\ + ;; In Allegro PCB Editor (with any design open), type in the command window:\n\ + ;; skill load(\"{dir}/{library}.il\")\n\ + ;; Every footprint script in this folder becomes .dra, .psm and its\n\ + ;; .pad padstacks here. Then add this folder to PSMPATH and PADPATH, and the\n\ + ;; 3D model folder to STEPPATH (Setup > User Preferences > Paths > Library).\n\ + ;; Save your open design first: Allegro opens a new drawing for each footprint.\n" + ); + let _ = writeln!( + script, + "procedure( seex_build_library(dir names) + let( (home done failed status design) + unless( isDir(dir) + dir = getWorkingDir() + ) + home = getWorkingDir() + changeWorkingDir(dir) + seex_library_build = t + done = 0 + failed = nil + foreach( name names + design = strcat(dir \"/\" name \".dra\") + when( isFile(design) + deleteFile(design) + ) + status = cond( + ( !isFile(strcat(dir \"/\" name \".il\")) \"script missing\" ) + ( !car(errset(axlOpenDesign(?design design ?mode \"wf\") t)) \"could not open a new drawing\" ) + ( !car(errset(load(strcat(dir \"/\" name \".il\")) t)) \"script failed\" ) + ( t seex_finish(name) ) + ) + if( status == \"OK\" then + done = done + 1 + else + failed = cons(sprintf(nil \"%s (%s)\" name status) failed) + ) + ) + seex_library_build = nil + changeWorkingDir(home) + printf(\"SeEx: built %d of %d footprints in %s\\n\" done length(names) dir) + foreach( entry reverse(failed) + printf(\"SeEx: FAILED %s\\n\" entry) + ) + done + ) +) +" + ); + let _ = writeln!( + script, + "seex_build_library(\"{dir}\" list({}))", + names.join(" ") + ); + script +} + +/// Text for a SKILL string literal. +fn skill_string(text: &str) -> String { + text.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/src/cadence/capture.rs b/src/cadence/capture.rs new file mode 100644 index 0000000..b9c23b4 --- /dev/null +++ b/src/cadence/capture.rs @@ -0,0 +1,1456 @@ +//! OrCAD Capture symbol export. +//! +//! Capture's native `.olb` format is an undocumented binary container, but Capture +//! (16.6 and later, including OrCAD X) turns a "Library XML" file into an `.olb` +//! (`File > Import > Library XML`). This module writes that XML. +//! +//! The element layout, attribute names and enumerations below mirror files exported +//! by Capture 17.2 itself (`File > Export > Library XML`): +//! +//! * coordinates are integer Capture units of 0.01 inch with Y pointing down — one +//! EasyEDA pixel (10 mil) is exactly one Capture unit; +//! * pins are `SymbolPinScalar` elements whose `hotptX/Y` is the wire connection point +//! and `startX/Y` the end touching the body; pin numbers live in `PhysicalPart`; +//! * pin types: 0 input, 1 bidirectional, 2 output, 3 open collector, 4 passive, +//! 5 tri-state, 6 open emitter, 7 power; +//! * fill styles: 0 solid, 1 none; +//! * a symbol with EasyEDA sub-parts becomes a heterogeneous package +//! (`isHomogeneous="0"`) with one `LibPart` per section, named after the package +//! plus the section letter (`RK3576_C42388007A`, ...B), as Capture stores +//! MAX628 in its own `MiscPower.olb`. +//! +//! Emitted elements: `Rect`, `Line`, `Polygon`, `Ellipse`, `Arc` and `CommentText` (all +//! in Capture's `olb.xsd`); arcs smaller than a Capture unit become line segments. + +use std::cell::Cell; +use std::collections::HashMap; +use std::fmt::Write as _; + +use crate::kicad::symbol::{ + KiArc, KiCircle, KiPin, KiPolyline, KiRectangle, KiSymbol, KiSymbolUnit, KiText, PinStyle, + PinType, +}; + +/// Capture's standard pin grid (0.1 inch). +pub const CAPTURE_GRID: i64 = 10; +/// Default Capture colour index used by its own exports for symbol graphics. +const SYMBOL_COLOR: &str = "48"; + +/// One Capture part, ready to be written into a Library XML file. +#[derive(Debug, Clone)] +pub struct CapturePart { + /// Package / cell name inside the `.olb`. + pub name: String, + /// `` XML fragment for this part. + pub xml: String, + /// Non-fatal conversion notes (off-grid pins, renamed duplicate pins, ...). + pub warnings: Vec, +} + +/// Build the Capture part for `symbol`, linked to the Allegro footprint `footprint`. +/// Whether the part shows pin names / pin numbers (Capture's part-level switches). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PinLabels { + pub names: bool, + pub numbers: bool, +} + +impl Default for PinLabels { + fn default() -> Self { + Self { + names: true, + numbers: true, + } + } +} + +pub fn build_capture_part_with( + symbol: &KiSymbol, + part_name: &str, + footprint: &str, + labels: PinLabels, +) -> CapturePart { + let mut warnings = Vec::new(); + let sections = sections(symbol); + // One section is an ordinary part; EasyEDA sub-parts become the sections of a + // heterogeneous Capture package (U1A, U1B, ...), each drawn on its own, the + // way Capture's own libraries store them: one `LibPart` per section, named + // after the package plus the section letter, each with its own pin numbers. + let heterogeneous = sections.len() > 1; + let mut jogs = 0; + let mut lib_parts = String::new(); + for (index, mut geometry) in sections.into_iter().enumerate() { + jogs += align_to_grid(&mut geometry); + let cell = if heterogeneous { + format!("{part_name}{}", section_letter(index)) + } else { + part_name.to_string() + }; + lib_parts.push_str(&lib_part_xml( + symbol, + &geometry, + &cell, + part_name, + labels, + &mut warnings, + )); + } + if jogs > 0 { + warnings.push(format!( + "{jogs} pin(s) sat between grid lines; moved onto the 0.1\" grid with a short jog to the body" + )); + } + + let mut xml = String::new(); + let _ = writeln!(xml, " "); + let _ = writeln!( + xml, + " ", + if heterogeneous { 0 } else { 1 }, + xml_escape(part_name), + xml_escape(footprint), + xml_escape(&refdes_prefix(&symbol.reference)) + ); + xml.push_str(&lib_parts); + let _ = writeln!(xml, " "); + + CapturePart { + name: part_name.to_string(), + xml, + warnings, + } +} + +/// Section letters as Capture numbers them: A..Z, then AA, AB, ... +fn section_letter(index: usize) -> String { + let mut index = index; + let mut letters = Vec::new(); + loop { + letters.push(b'A' + (index % 26) as u8); + if index < 26 { + break; + } + index = index / 26 - 1; + } + letters.reverse(); + String::from_utf8(letters).unwrap_or_default() +} + +/// One ``: the drawing and pin numbers of one section. +fn lib_part_xml( + symbol: &KiSymbol, + geometry: &KiSymbolUnit, + cell: &str, + part_name: &str, + labels: PinLabels, + warnings: &mut Vec, +) -> String { + // Capture's part editor takes the body outline's top-left corner as the part + // origin (every part Cadence ships has its outline at 0,0): with the origin + // anywhere else, a pin moved in the editor is redrawn from the origin. Lay the + // part out once to find the outline, then again shifted onto the origin. + let mut layout = CaptureLayout::new(geometry); + let mut scratch = Vec::new(); + let (_, _, outline) = draw_unit(geometry, &layout, &mut scratch); + layout.dx -= outline.x1 as f64; + layout.dy -= outline.y1 as f64; + let (graphics, pins, bbox) = draw_unit(geometry, &layout, warnings); + + let mut pin_xml = String::new(); + let mut physical = String::new(); + for (position, pin) in pins.iter().enumerate() { + write_pin(&mut pin_xml, pin, position); + let _ = writeln!( + physical, + " \n \n ", + xml_escape(&pin.number), + position + ); + } + + let reference = refdes_prefix(&symbol.reference); + let value = if symbol.value.trim().is_empty() { + part_name.to_string() + } else { + symbol.value.clone() + }; + + let mut xml = String::new(); + let _ = writeln!(xml, " "); + let _ = writeln!(xml, " ", xml_escape(cell)); + let _ = writeln!(xml, " "); + let _ = writeln!(xml, " "); + // Labels go outside the pins so top/bottom pin names stay readable. + let mut extent = bbox; + for pin in &pins { + extent.add(pin.hot.0, pin.hot.1); + } + write_display_prop( + &mut xml, + "Part Reference", + bbox.x1, + extent.y1 - CAPTURE_GRID, + ); + write_display_prop(&mut xml, "Value", bbox.x1, extent.y2 + CAPTURE_GRID); + for (name, value) in user_properties(symbol) { + let _ = writeln!( + xml, + " \n \n ", + xml_escape(name), + xml_escape(&value) + ); + } + let _ = writeln!( + xml, + " \n \n " + ); + let _ = writeln!( + xml, + " \n \n ", + bbox.x1, bbox.x2, bbox.y1, bbox.y2 + ); + write_flag(&mut xml, 8, "IsPinNumbersVisible", labels.numbers); + write_flag(&mut xml, 8, "IsPinNamesRotated", true); + write_flag(&mut xml, 8, "IsPinNamesVisible", labels.names); + let _ = writeln!( + xml, + " \n \n " + ); + let _ = writeln!( + xml, + " \n \n " + ); + let _ = writeln!( + xml, + " \n \n " + ); + let _ = writeln!( + xml, + " \n \n ", + xml_escape(&value) + ); + let _ = writeln!( + xml, + " \n \n ", + xml_escape(&reference) + ); + xml.push_str(&graphics); + xml.push_str(&pin_xml); + let _ = writeln!(xml, " "); + let _ = writeln!(xml, " \n "); + xml.push_str(&physical); + let _ = writeln!(xml, " "); + let _ = writeln!(xml, " "); + xml +} + +/// Wrap `` fragments into a complete Capture Library XML document. +pub fn render_library_xml(olb_name: &str, packages: &[String]) -> String { + let mut xml = String::new(); + xml.push_str("\n"); + xml.push_str("\n\n"); + let _ = writeln!(xml, " \n", xml_escape(olb_name)); + xml.push_str(&default_values_xml()); + for package in packages { + xml.push('\n'); + xml.push_str(package); + } + xml.push_str("\n\n"); + xml +} + +/// Every `` of a Capture Library XML document SeEx wrote, as (part name, +/// the block as it stands in the file), in document order. An append keeps these +/// and writes them back with the new parts. +pub fn package_blocks(xml: &str) -> Vec<(String, String)> { + const OPEN: &str = ""; + const CLOSE: &str = ""; + let mut blocks = Vec::new(); + let mut rest = xml; + while let Some(start) = rest.find(OPEN) { + let Some(len) = rest[start..].find(CLOSE) else { + break; + }; + let block = &rest[start..start + len + CLOSE.len()]; + rest = &rest[start + len + CLOSE.len()..]; + let name = block + .find(" name=\"") + .map(|at| &block[at + 7..]) + .and_then(|value| value.find('"').map(|end| &value[..end])); + if let Some(name) = name { + blocks.push((xml_unescape(name), format!(" {block}\n"))); + } + } + blocks +} + +fn xml_unescape(text: &str) -> String { + text.replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +/// Capture-safe ASCII identifier: letters, digits, `_` and `-` only. +pub fn capture_identifier(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut last_underscore = false; + for ch in name.chars() { + let mapped = if ch.is_ascii_alphanumeric() || ch == '-' { + ch + } else { + '_' + }; + if mapped == '_' { + if last_underscore { + continue; + } + last_underscore = true; + } else { + last_underscore = false; + } + out.push(mapped); + } + let trimmed = out.trim_matches('_').to_string(); + if trimmed.is_empty() { + "PART".to_string() + } else { + trimmed + } +} + +fn refdes_prefix(reference: &str) -> String { + let prefix: String = reference + .trim() + .trim_end_matches('?') + .chars() + .filter(|ch| ch.is_ascii_alphabetic()) + .collect(); + if prefix.is_empty() { + "U".to_string() + } else { + prefix + } +} + +fn user_properties(symbol: &KiSymbol) -> Vec<(&'static str, String)> { + let mut props = Vec::new(); + let mut push = |name: &'static str, value: &str| { + let value = value.trim(); + if !value.is_empty() { + props.push((name, value.to_string())); + } + }; + push("Manufacturer", &symbol.manufacturer); + push("Description", &symbol.description); + push("Datasheet", &symbol.datasheet); + push("LCSC Part", &symbol.lcsc_id); + push("JLC Part", &symbol.jlc_id); + props +} + +/// The sections of the Capture part, each in its own coordinate space (EasyEDA +/// px, Y up): the whole symbol, or one per EasyEDA sub-part. Graphics and pins +/// the symbol has outside its sub-parts belong to every section, as KiCad's +/// unit 0 does. +fn sections(symbol: &KiSymbol) -> Vec { + let common = KiSymbolUnit { + pins: symbol.pins.clone(), + rectangles: symbol.rectangles.clone(), + circles: symbol.circles.clone(), + arcs: symbol.arcs.clone(), + polylines: symbol.polylines.clone(), + texts: symbol.texts.clone(), + }; + let units: Vec<&KiSymbolUnit> = symbol + .units + .iter() + .filter(|unit| unit_has_content(unit)) + .collect(); + if units.is_empty() { + return vec![common]; + } + units + .into_iter() + .map(|unit| { + let mut section = unit.clone(); + append_translated(&mut section, &common, 0.0); + section + }) + .collect() +} + +/// Direction from a pin's connection point towards the body (EasyEDA px, Y up). +fn pin_direction(pin: &KiPin) -> (f64, f64) { + let angle = (((180 + pin.rotation) % 360 + 360) % 360) as f64; + let (sin, cos) = angle.to_radians().sin_cos(); + (cos.round(), sin.round()) +} + +/// True when `point` lies on the outline of the box `b` (x0, y0, x1, y1). +fn on_box_edge(point: (f64, f64), b: [f64; 4]) -> bool { + const TOL: f64 = 0.5; + let (x, y) = point; + let within_x = x >= b[0] - TOL && x <= b[2] + TOL; + let within_y = y >= b[1] - TOL && y <= b[3] + TOL; + ((x - b[0]).abs() < TOL || (x - b[2]).abs() < TOL) && within_y + || ((y - b[1]).abs() < TOL || (y - b[3]).abs() < TOL) && within_x +} + +/// The sub-grid offset most pins share, as (x, y) in (-grid/2, grid/2]. +fn common_offset(geometry: &KiSymbolUnit, grid: f64) -> (f64, f64) { + let offset = |v: f64| { + let r = v - (v / grid).floor() * grid; // [0, grid) + let r = if r > grid / 2.0 + 1e-6 { r - grid } else { r }; + (r * 100.0).round() / 100.0 + }; + let mut counts: HashMap<(i64, i64), usize> = HashMap::new(); + for pin in &geometry.pins { + let key = ( + (offset(pin.pos_x) * 100.0) as i64, + (offset(pin.pos_y) * 100.0) as i64, + ); + *counts.entry(key).or_default() += 1; + } + let best = counts + .into_iter() + .max_by_key(|&((x, y), n)| (n, -(x.abs() + y.abs()))); + best.map(|((x, y), _)| (x as f64 / 100.0, y as f64 / 100.0)) + .unwrap_or((0.0, 0.0)) +} + +fn translate_unit(unit: &mut KiSymbolUnit, dx: f64, dy: f64) { + for pin in &mut unit.pins { + pin.pos_x += dx; + pin.pos_y += dy; + } + for rect in &mut unit.rectangles { + rect.x1 += dx; + rect.x2 += dx; + rect.y1 += dy; + rect.y2 += dy; + } + for circle in &mut unit.circles { + circle.cx += dx; + circle.cy += dy; + } + for arc in &mut unit.arcs { + arc.start_x += dx; + arc.mid_x += dx; + arc.end_x += dx; + arc.start_y += dy; + arc.mid_y += dy; + arc.end_y += dy; + } + for polyline in &mut unit.polylines { + for point in &mut polyline.points { + point.0 += dx; + point.1 += dy; + } + } + for text in &mut unit.texts { + text.x += dx; + text.y += dy; + } +} + +/// Puts every pin's connection point on Capture's 0.1" grid, where wires snap. +/// +/// The edges of body rectangles that pins attach to move outward to the grid (other +/// edges keep their drawn position) and the pins slide along the edge to the nearest +/// grid point, keeping a whole-grid length. A pin +/// drawn without a body rectangle is lengthened to the next grid line; when it sits +/// between grid lines it moves onto one and a short jog joins it to the drawing. +/// Returns the number of jogs added. +fn align_to_grid(geometry: &mut KiSymbolUnit) -> usize { + let grid = CAPTURE_GRID as f64; + // Most EasyEDA symbols that are off the grid are off as a whole (drawn on a + // 0.05" grid): move the whole drawing so the pins land on the grid, keeping + // every pin where it meets the body and every line where it was. + let (shift_x, shift_y) = common_offset(geometry, grid); + if shift_x != 0.0 || shift_y != 0.0 { + translate_unit(geometry, -shift_x, -shift_y); + } + let down = |v: f64| ((v / grid) + 1e-6).floor() * grid; + let up = |v: f64| ((v / grid) - 1e-6).ceil() * grid; + let starts: Vec<(f64, f64)> = geometry + .pins + .iter() + .map(|pin| { + let (dx, dy) = pin_direction(pin); + let len = pin.length.abs(); + (pin.pos_x + len * dx, pin.pos_y + len * dy) + }) + .collect(); + + // Bodies: rectangles with pins on their edges, before and after snapping. + // Rectangle index, drawn box, snapped box, and which edges carry pins (x1, y1, + // x2, y2 order). + let mut bodies: Vec<(usize, [f64; 4], [f64; 4], [bool; 4])> = Vec::new(); + for (index, rect) in geometry.rectangles.iter_mut().enumerate() { + let old = [ + rect.x1.min(rect.x2), + rect.y1.min(rect.y2), + rect.x1.max(rect.x2), + rect.y1.max(rect.y2), + ]; + if !starts.iter().any(|&s| on_box_edge(s, old)) { + continue; + } + // Only edges that carry pins move to the grid; the others keep the drawn + // shape (a varistor's flat box stays flat), stretched just enough to + // reach every pin row after the pins slide onto the grid. + const TOL: f64 = 0.5; + let mut new = old; + let mut pinned = [false; 4]; + let (mut row_lo, mut row_hi) = (f64::MAX, f64::MIN); + let (mut column_lo, mut column_hi) = (f64::MAX, f64::MIN); + for (pin, &start) in geometry.pins.iter().zip(&starts) { + if !on_box_edge(start, old) { + continue; + } + let (dx, dy) = pin_direction(pin); + if dx != 0.0 && dy == 0.0 { + if (start.0 - old[0]).abs() < TOL { + new[0] = down(old[0]); + pinned[0] = true; + } else if (start.0 - old[2]).abs() < TOL { + new[2] = up(old[2]); + pinned[2] = true; + } + let row = snap(start.1, grid); + row_lo = row_lo.min(row); + row_hi = row_hi.max(row); + } else if dy != 0.0 && dx == 0.0 { + if (start.1 - old[1]).abs() < TOL { + new[1] = down(old[1]); + pinned[1] = true; + } else if (start.1 - old[3]).abs() < TOL { + new[3] = up(old[3]); + pinned[3] = true; + } + let column = snap(start.0, grid); + column_lo = column_lo.min(column); + column_hi = column_hi.max(column); + } + } + new[0] = new[0].min(column_lo); + new[2] = new[2].max(column_hi); + new[1] = new[1].min(row_lo); + new[3] = new[3].max(row_hi); + rect.x1 = new[0]; + rect.y1 = new[1]; + rect.x2 = new[2]; + rect.y2 = new[3]; + bodies.push((index, old, new, pinned)); + } + + let mut used: std::collections::HashSet<(i64, i64)> = std::collections::HashSet::new(); + let mut stretched: Vec<(usize, (f64, f64))> = Vec::new(); + let mut jogs = Vec::new(); + for (pin, &start) in geometry.pins.iter_mut().zip(&starts) { + let (dx, dy) = pin_direction(pin); + if (dx == 0.0) == (dy == 0.0) { + continue; + } + let horizontal = dx != 0.0; + let body = bodies + .iter() + .find(|(_, old, _, _)| on_box_edge(start, *old)); + let (mut hot, mut new_start) = match body { + Some((_, _, b, _)) => { + let s = if horizontal { + (if dx > 0.0 { b[0] } else { b[2] }, snap(start.1, grid)) + } else { + (snap(start.0, grid), if dy > 0.0 { b[1] } else { b[3] }) + }; + let len = (pin.length.abs() / grid).round().max(1.0) * grid; + ((s.0 - dx * len, s.1 - dy * len), s) + } + None => { + let (hx, hy) = (pin.pos_x, pin.pos_y); + // Outward is against the direction towards the body. + let hot = if horizontal { + (if dx > 0.0 { down(hx) } else { up(hx) }, snap(hy, grid)) + } else { + (snap(hx, grid), if dy > 0.0 { down(hy) } else { up(hy) }) + }; + let s = if horizontal { + (start.0, hot.1) + } else { + (hot.0, start.1) + }; + let mut hot = hot; + // Keep at least half a grid of visible pin. + if ((hot.0 - s.0).abs() + (hot.1 - s.1).abs()) < grid / 2.0 { + hot = (hot.0 - dx * grid, hot.1 - dy * grid); + } + (hot, s) + } + }; + let key = |p: (f64, f64)| (p.0.round() as i64, p.1.round() as i64); + let along = |p: (f64, f64), d: f64| { + if horizontal { + (p.0, p.1 + d) + } else { + (p.0 + d, p.1) + } + }; + let mut grow_body = false; + if used.contains(&key(hot)) { + match body { + // Pins EasyEDA stacked on one spot (shield, GND) spread along their + // edge: inside the edge first, then past an end whose side edge has + // no pins (the body grows there). With no room they stay stacked. + Some((_, _, b, pinned)) => { + let (lo, hi) = if horizontal { + (b[1], b[3]) + } else { + (b[0], b[2]) + }; + let (lo_free, hi_free) = if horizontal { + (!pinned[1], !pinned[3]) + } else { + (!pinned[0], !pinned[2]) + }; + let position = |p: (f64, f64)| if horizontal { p.1 } else { p.0 }; + let mut chosen = None; + 'search: for pass in 0..2 { + for step in 1..=16 { + for sign in [1.0, -1.0] { + let d = sign * step as f64 * grid; + let (h, s) = (along(hot, d), along(new_start, d)); + let v = position(s); + let fits = if pass == 0 { + v > lo + 1e-6 && v < hi - 1e-6 + } else { + (v >= lo - 1e-6 && v <= hi + 1e-6) + || (v > hi && hi_free) + || (v < lo && lo_free) + }; + if fits && !used.contains(&key(h)) { + chosen = Some((h, s, v < lo || v > hi)); + break 'search; + } + } + } + } + if let Some((h, s, outside)) = chosen { + hot = h; + new_start = s; + grow_body = outside; + } + } + None => { + let mut tries = 0; + while used.contains(&key(hot)) && tries < 8 { + hot = along(hot, grid); + new_start = along(new_start, grid); + tries += 1; + } + } + } + } + used.insert(key(hot)); + if body.is_none() + && ((new_start.0 - start.0).abs() > 0.01 || (new_start.1 - start.1).abs() > 0.01) + { + jogs.push((start, new_start)); + } + pin.pos_x = hot.0; + pin.pos_y = hot.1; + pin.length = ((hot.0 - new_start.0).abs() + (hot.1 - new_start.1).abs()).max(1.0); + if let (Some((index, _, _, _)), true) = (body, grow_body) { + stretched.push((*index, new_start)); + } + } + for (index, point) in stretched { + let rect = &mut geometry.rectangles[index]; + let (x1, x2) = (rect.x1.min(rect.x2), rect.x1.max(rect.x2)); + let (y1, y2) = (rect.y1.min(rect.y2), rect.y1.max(rect.y2)); + let grow = |lo: f64, hi: f64, v: f64| (lo.min(v), hi.max(v)); + let ((x1, x2), (y1, y2)) = (grow(x1, x2, point.0), grow(y1, y2, point.1)); + rect.x1 = x1; + rect.x2 = x2; + rect.y1 = y1; + rect.y2 = y2; + } + let count = jogs.len(); + for (a, b) in jogs { + geometry.polylines.push(KiPolyline { + points: vec![a, b], + stroke_width: 0.0, + fill: false, + }); + } + count +} + +fn unit_has_content(unit: &KiSymbolUnit) -> bool { + !unit.pins.is_empty() + || !unit.rectangles.is_empty() + || !unit.circles.is_empty() + || !unit.arcs.is_empty() + || !unit.polylines.is_empty() + || !unit.texts.is_empty() +} + +fn unit_bounds(unit: &KiSymbolUnit) -> Option<(f64, f64, f64, f64)> { + let mut bounds = Bounds::default(); + for pin in &unit.pins { + bounds.add(pin.pos_x, pin.pos_y); + } + for rect in &unit.rectangles { + bounds.add(rect.x1, rect.y1); + bounds.add(rect.x2, rect.y2); + } + for circle in &unit.circles { + bounds.add(circle.cx - circle.radius, circle.cy - circle.radius); + bounds.add(circle.cx + circle.radius, circle.cy + circle.radius); + } + for arc in &unit.arcs { + bounds.add(arc.start_x, arc.start_y); + bounds.add(arc.mid_x, arc.mid_y); + bounds.add(arc.end_x, arc.end_y); + } + for polyline in &unit.polylines { + for &(x, y) in &polyline.points { + bounds.add(x, y); + } + } + bounds.get() +} + +fn append_translated(target: &mut KiSymbolUnit, source: &KiSymbolUnit, dx: f64) { + for pin in &source.pins { + let mut pin = pin.clone(); + pin.pos_x += dx; + target.pins.push(pin); + } + for rect in &source.rectangles { + let mut rect = rect.clone(); + rect.x1 += dx; + rect.x2 += dx; + target.rectangles.push(rect); + } + for circle in &source.circles { + let mut circle = circle.clone(); + circle.cx += dx; + target.circles.push(circle); + } + for arc in &source.arcs { + let mut arc = arc.clone(); + arc.start_x += dx; + arc.mid_x += dx; + arc.end_x += dx; + target.arcs.push(arc); + } + for polyline in &source.polylines { + let mut polyline = polyline.clone(); + for point in &mut polyline.points { + point.0 += dx; + } + target.polylines.push(polyline); + } + for text in &source.texts { + let mut text = text.clone(); + text.x += dx; + target.texts.push(text); + } +} + +/// Graphics XML, pins and body outline of one laid-out unit. +fn draw_unit( + geometry: &KiSymbolUnit, + layout: &CaptureLayout, + warnings: &mut Vec, +) -> (String, Vec, IntBox) { + layout.graphics.set(IntBox::empty()); + let mut graphics = String::new(); + for rect in &geometry.rectangles { + write_rect(&mut graphics, layout, rect); + } + for circle in &geometry.circles { + write_circle(&mut graphics, layout, circle); + } + for arc in &geometry.arcs { + write_arc(&mut graphics, layout, arc); + } + for polyline in &geometry.polylines { + write_polyline(&mut graphics, layout, polyline); + } + for text in &geometry.texts { + write_comment_text(&mut graphics, layout, text); + } + let mut pins = capture_pins(&geometry.pins, layout, warnings); + let bbox = layout.body_bbox(&mut pins, &mut graphics); + (graphics, pins, bbox) +} + +/// Maps EasyEDA px (Y up) to integer Capture units (Y down). +/// +/// The translation puts the first pin's connection point exactly on the 0.1" grid +/// and the body near the origin, like symbols drawn in Capture itself. +struct CaptureLayout { + dx: f64, + dy: f64, + /// Bounding box of everything drawn so far (excluding pins). + graphics: Cell, +} + +impl CaptureLayout { + fn new(geometry: &KiSymbolUnit) -> Self { + let grid = CAPTURE_GRID as f64; + let (min_x, min_y) = unit_bounds(geometry) + .map(|(min_x, _, _, max_y)| (min_x, -max_y)) + .unwrap_or((0.0, 0.0)); + let (pin_x, pin_y) = geometry + .pins + .first() + .map(|pin| (pin.pos_x, -pin.pos_y)) + .unwrap_or((0.0, 0.0)); + // Shift the body towards the origin in whole grid steps, then add the + // sub-grid correction that lands the reference pin on the grid. + let dx = -snap(min_x, grid) + (snap(pin_x, grid) - pin_x); + let dy = -snap(min_y, grid) + (snap(pin_y, grid) - pin_y); + Self { + dx, + dy, + graphics: Cell::new(IntBox::empty()), + } + } + + /// Capture coordinates before rounding to whole units. + fn point_f(&self, x: f64, y_up: f64) -> (f64, f64) { + (x + self.dx, -y_up + self.dy) + } + + fn point(&self, x: f64, y_up: f64) -> (i64, i64) { + ( + (x + self.dx).round() as i64, + (-y_up + self.dy).round() as i64, + ) + } + + /// The part body outline (`SymbolBBox`), laid out like Capture's own parts: on + /// the 0.1" grid, around every graphic and pin start, with each pin starting on + /// its edge. Capture's part editor keeps graphics inside this outline and snaps + /// moves to the grid, so a tight, off-grid outline makes lines jump or refuse to + /// move. A pin whose body end lay inside the outline is shortened to the edge and + /// a lead line drawn from the edge to where it used to end; a pin whose tip would + /// sit on or inside the outline is lengthened to one grid step outside it. + fn body_bbox(&self, pins: &mut [CapturePin], graphics: &mut String) -> IntBox { + let grid = CAPTURE_GRID; + let mut bbox = self.graphics.get(); + for pin in pins.iter() { + bbox.add(pin.start.0, pin.start.1); + } + if bbox.is_empty() { + bbox.add(0, 0); + bbox.add(grid, grid); + } + let down = |v: i64| v.div_euclid(grid) * grid; + let up = |v: i64| (v + grid - 1).div_euclid(grid) * grid; + let mut body = IntBox { + x1: down(bbox.x1), + y1: down(bbox.y1), + x2: up(bbox.x2), + y2: up(bbox.y2), + }; + if body.x1 == body.x2 { + body.x2 += grid; + } + if body.y1 == body.y2 { + body.y2 += grid; + } + for pin in pins.iter_mut() { + let ((hx, hy), (sx, sy)) = (pin.hot, pin.start); + // The edge the pin leaves from, and its tip at least one grid step + // outside it: a tip on or inside the outline would make a zero-length + // pin, which Capture draws as a bare marker. + let (edge, hot) = if hy == sy && hx < sx { + ((body.x1, sy), (hx.min(body.x1 - grid), hy)) + } else if hy == sy && hx > sx { + ((body.x2, sy), (hx.max(body.x2 + grid), hy)) + } else if hx == sx && hy < sy { + ((sx, body.y1), (hx, hy.min(body.y1 - grid))) + } else if hx == sx && hy > sy { + ((sx, body.y2), (hx, hy.max(body.y2 + grid))) + } else { + continue; + }; + if edge != pin.start { + write_line(graphics, self, edge, pin.start); + pin.start = edge; + } + pin.hot = hot; + } + body + } + + fn record(&self, x: i64, y: i64) { + let mut bbox = self.graphics.get(); + bbox.add(x, y); + self.graphics.set(bbox); + } +} + +#[derive(Debug, Clone, Copy)] +struct IntBox { + x1: i64, + y1: i64, + x2: i64, + y2: i64, +} + +impl IntBox { + const fn empty() -> Self { + Self { + x1: i64::MAX, + y1: i64::MAX, + x2: i64::MIN, + y2: i64::MIN, + } + } + + fn is_empty(&self) -> bool { + self.x1 > self.x2 + } + + fn add(&mut self, x: i64, y: i64) { + self.x1 = self.x1.min(x); + self.y1 = self.y1.min(y); + self.x2 = self.x2.max(x); + self.y2 = self.y2.max(y); + } +} + +#[derive(Debug, Clone)] +struct CapturePin { + name: String, + number: String, + hot: (i64, i64), + start: (i64, i64), + pin_type: u8, + dot: bool, + clock: bool, +} + +fn capture_pins( + pins: &[KiPin], + layout: &CaptureLayout, + warnings: &mut Vec, +) -> Vec { + let mut seen: HashMap = HashMap::new(); + let mut off_grid = Vec::new(); + let mut out = Vec::with_capacity(pins.len()); + + for pin in pins { + let length = if pin.length.abs() < 1.0 { + warnings.push(format!( + "pin {} had zero length; using {} units", + pin.number, CAPTURE_GRID + )); + CAPTURE_GRID as f64 + } else { + pin.length.abs() + }; + // KiCad orientation: direction from the connection point towards the body. + let angle = (((180 + pin.rotation) % 360 + 360) % 360) as f64; + let (sin, cos) = angle.to_radians().sin_cos(); + let hot = layout.point(pin.pos_x, pin.pos_y); + let start = layout.point(pin.pos_x + length * cos, pin.pos_y + length * sin); + if hot.0 % CAPTURE_GRID != 0 || hot.1 % CAPTURE_GRID != 0 { + off_grid.push(pin.number.clone()); + } + + let base_number = if pin.number.trim().is_empty() { + (out.len() + 1).to_string() + } else { + pin.number.trim().to_string() + }; + let count = seen.entry(base_number.clone()).or_insert(0); + *count += 1; + let number = if *count == 1 { + base_number + } else { + let renamed = format!("{}_{}", base_number, count); + warnings.push(format!( + "duplicate pin number {} renamed to {}", + base_number, renamed + )); + renamed + }; + let name = if pin.name.trim().is_empty() { + number.clone() + } else { + pin.name.trim().to_string() + }; + + out.push(CapturePin { + name, + number, + hot, + start, + pin_type: capture_pin_type(pin.pin_type), + dot: matches!( + pin.style, + PinStyle::Inverted + | PinStyle::InvertedClock + | PinStyle::InputLow + | PinStyle::OutputLow + ), + clock: matches!( + pin.style, + PinStyle::Clock + | PinStyle::InvertedClock + | PinStyle::ClockLow + | PinStyle::EdgeClockHigh + ), + }); + } + + if !off_grid.is_empty() { + warnings.push(format!( + "pins not on the 0.1\" grid (wires may not snap): {}", + off_grid.join(", ") + )); + } + out +} + +fn capture_pin_type(pin_type: PinType) -> u8 { + match pin_type { + PinType::Input => 0, + PinType::Bidirectional => 1, + PinType::Output => 2, + PinType::OpenCollector => 3, + PinType::Passive | PinType::Unspecified | PinType::NoConnect => 4, + PinType::TriState => 5, + PinType::OpenEmitter => 6, + PinType::PowerIn | PinType::PowerOut => 7, + } +} + +fn write_pin(out: &mut String, pin: &CapturePin, position: usize) { + let _ = writeln!(out, " "); + let _ = writeln!( + out, + " ", + pin.hot.0, + pin.hot.1, + xml_escape(&pin.name), + position, + pin.start.0, + pin.start.1, + pin.pin_type + ); + write_flag(out, 10, "IsLong", true); + write_flag(out, 10, "IsClock", pin.clock); + write_flag(out, 10, "IsDot", pin.dot); + write_flag(out, 10, "IsLeftPointing", false); + write_flag(out, 10, "IsRightPointing", false); + write_flag(out, 10, "IsNetStyle", false); + write_flag(out, 10, "IsNoConnect", false); + write_flag(out, 10, "IsGlobal", false); + write_flag(out, 10, "IsNumberVisible", true); + let _ = writeln!(out, " "); +} + +fn write_flag(out: &mut String, indent: usize, name: &str, value: bool) { + let pad = " ".repeat(indent); + let _ = writeln!( + out, + "{pad}<{name}>\n{pad} \n{pad}", + u8::from(value) + ); +} + +fn write_display_prop(out: &mut String, name: &str, x: i64, y: i64) { + let _ = writeln!( + out, + " \n \n \n \n \n \n \n \n \n \n \n " + ); +} + +fn write_rect(out: &mut String, layout: &CaptureLayout, rect: &KiRectangle) { + let (ax, ay) = layout.point(rect.x1, rect.y1); + let (bx, by) = layout.point(rect.x2, rect.y2); + let (x1, x2) = (ax.min(bx), ax.max(bx)); + let (y1, y2) = (ay.min(by), ay.max(by)); + if x1 == x2 || y1 == y2 { + write_line(out, layout, (x1, y1), (x2, y2)); + return; + } + layout.record(x1, y1); + layout.record(x2, y2); + // Body rectangles are left unfilled so pin names stay readable (Capture default). + let _ = writeln!( + out, + " \n \n " + ); +} + +fn write_line(out: &mut String, layout: &CaptureLayout, a: (i64, i64), b: (i64, i64)) { + if a == b { + return; + } + layout.record(a.0, a.1); + layout.record(b.0, b.1); + let _ = writeln!( + out, + " \n \n ", + a.0, b.0, a.1, b.1 + ); +} + +fn write_polygon(out: &mut String, layout: &CaptureLayout, points: &[(i64, i64)], filled: bool) { + let mut points: Vec<(i64, i64)> = dedup_points(points); + if points.len() < 3 { + if points.len() == 2 { + write_line(out, layout, points[0], points[1]); + } + return; + } + // Capture closes polygons by repeating the first point. + if points.first() != points.last() { + points.push(points[0]); + } + let _ = writeln!( + out, + " \n ", + if filled { 0 } else { 1 } + ); + for (x, y) in points { + layout.record(x, y); + let _ = writeln!( + out, + " \n \n " + ); + } + let _ = writeln!(out, " "); +} + +fn write_polyline_points(out: &mut String, layout: &CaptureLayout, points: &[(i64, i64)]) { + let points = dedup_points(points); + for pair in points.windows(2) { + write_line(out, layout, pair[0], pair[1]); + } +} + +fn write_circle(out: &mut String, layout: &CaptureLayout, circle: &KiCircle) { + // Native Capture ellipse (bounding box), as in olb.xsd; fillStyle 0 = solid, 1 = none. + // Round the corner, then add the rounded diameter, so a circle keeps its size + // (rounding both corners can shrink a small one by a whole unit). + let diameter = (2.0 * circle.radius).round().max(1.0) as i64; + let (x1, y1) = layout.point(circle.cx - circle.radius, circle.cy + circle.radius); + let (x2, y2) = (x1 + diameter, y1 + diameter); + // Keep tiny markers (e.g. pin-1 dots) visible after rounding to 0.01". + let (x2, y2) = (x2.max(x1 + 1), y2.max(y1 + 1)); + layout.record(x1, y1); + layout.record(x2, y2); + let _ = writeln!( + out, + " \n \n ", + if circle.fill { 0 } else { 1 } + ); +} + +fn write_arc(out: &mut String, layout: &CaptureLayout, arc: &KiArc) { + let start = (arc.start_x, arc.start_y); + let mid = (arc.mid_x, arc.mid_y); + let end = (arc.end_x, arc.end_y); + let Some((cx, cy, radius)) = circumcircle(start, mid, end) else { + let points = [start, mid, end].map(|(x, y)| layout.point(x, y)); + write_polyline_points(out, layout, &points); + return; + }; + + let a0 = (start.1 - cy).atan2(start.0 - cx); + let am = (mid.1 - cy).atan2(mid.0 - cx); + let a1 = (end.1 - cy).atan2(end.0 - cx); + let ccw = normalize_angle(am - a0) < normalize_angle(a1 - a0); + let sweep = if ccw { + normalize_angle(a1 - a0) + } else { + -normalize_angle(a0 - a1) + }; + let segments = ((segments_for_radius(radius) as f64) * sweep.abs() / std::f64::consts::TAU) + .ceil() + .max(2.0) as usize; + let points: Vec<(i64, i64)> = (0..=segments) + .map(|index| { + let angle = a0 + sweep * index as f64 / segments as f64; + layout.point(cx + radius * angle.cos(), cy + radius * angle.sin()) + }) + .collect(); + // Arcs too small for whole Capture units stay line segments. + if radius < 1.5 || sweep.abs() < 1e-3 { + write_polyline_points(out, layout, &points); + return; + } + for &(x, y) in &points { + layout.record(x, y); + } + // Native Capture arc: the circle's bounding box plus start and end points; Capture + // draws counter-clockwise on screen (decreasing angle with Y down) from start to + // end, as its own Discrete.olb inductor does. Sweeps near a full turn are split. + let pieces = if sweep.abs() > 1.5 * std::f64::consts::PI { + 2 + } else { + 1 + }; + // The bounding box is in whole units, so pick the representable circle that + // follows the drawn arc best (rounding the box corners would squash and shift + // small arcs such as inductor bumps). + let samples: Vec<(f64, f64)> = (0..=8) + .map(|i| { + let angle = a0 + sweep * i as f64 / 8.0; + layout.point_f(cx + radius * angle.cos(), cy + radius * angle.sin()) + }) + .collect(); + // The written end points are whole units and other lines meet them there: the + // circle should pass through them. + let ends = [layout.point(start.0, start.1), layout.point(end.0, end.1)] + .map(|(x, y)| (x as f64, y as f64)); + let (fcx, fcy) = layout.point_f(cx, cy); + let mut best = (f64::MAX, (fcx, fcy, radius)); + for half in [0.0, 0.5] { + let near = |v: f64| (v - half).round() + half; + for i in -2..=2 { + for j in -2..=2 { + for k in -2..=2 { + let (ccx, ccy, r) = ( + near(fcx) + i as f64, + near(fcy) + j as f64, + near(radius) + k as f64, + ); + if r < 1.0 { + continue; + } + let miss = |&(x, y): &(f64, f64)| ((x - ccx).hypot(y - ccy) - r).powi(2); + let err: f64 = samples.iter().map(miss).sum::() + + 4.0 * ends.iter().map(miss).sum::(); + if err < best.0 { + best = (err, (ccx, ccy, r)); + } + } + } + } + } + let (bcx, bcy, br) = best.1; + let (x1, y1) = ((bcx - br).round() as i64, (bcy - br).round() as i64); + let (x2, y2) = ((bcx + br).round() as i64, (bcy + br).round() as i64); + for piece in 0..pieces { + let from = a0 + sweep * piece as f64 / pieces as f64; + let to = a0 + sweep * (piece + 1) as f64 / pieces as f64; + let at = |angle: f64| layout.point(cx + radius * angle.cos(), cy + radius * angle.sin()); + // Counter-clockwise in EasyEDA's Y-up frame is counter-clockwise on screen. + let (first, last) = if sweep > 0.0 { + (at(from), at(to)) + } else { + (at(to), at(from)) + }; + let _ = writeln!( + out, + " \n \n ", + last.0, last.1, first.0, first.1 + ); + } +} + +fn write_polyline(out: &mut String, layout: &CaptureLayout, polyline: &KiPolyline) { + let points: Vec<(i64, i64)> = polyline + .points + .iter() + .map(|&(x, y)| layout.point(x, y)) + .collect(); + // A filled shape is closed whether or not its last point repeats the first + // (EasyEDA's MOSFET arrows and diode triangles don't). + if polyline.fill && dedup_points(&points).len() >= 3 { + write_polygon(out, layout, &points, true); + } else { + write_polyline_points(out, layout, &points); + } +} + +fn write_comment_text(out: &mut String, layout: &CaptureLayout, text: &KiText) { + let content = text.text.trim(); + if content.is_empty() { + return; + } + // EasyEDA anchors text at its baseline; Capture at the top-left of the text box. + let (x, baseline) = layout.point(text.x, text.y); + let height = CAPTURE_GRID; + let y = baseline - height; + let width = (content.chars().count() as i64 * 5).max(5); + layout.record(x, y); + layout.record(x + width, baseline); + let _ = writeln!( + out, + " \n \n \n \n \n ", + xml_escape(content), + x + width + ); +} + +fn dedup_points(points: &[(i64, i64)]) -> Vec<(i64, i64)> { + let mut out: Vec<(i64, i64)> = Vec::with_capacity(points.len()); + for &point in points { + if out.last() != Some(&point) { + out.push(point); + } + } + out +} + +fn segments_for_radius(radius: f64) -> usize { + // Capture units are coarse (0.01"); more segments than this only adds rounding noise. + (radius.abs() * 2.0).clamp(8.0, 36.0) as usize +} + +fn circumcircle(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> Option<(f64, f64, f64)> { + let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1)); + if d.abs() < 1e-9 { + return None; + } + let a2 = a.0 * a.0 + a.1 * a.1; + let b2 = b.0 * b.0 + b.1 * b.1; + let c2 = c.0 * c.0 + c.1 * c.1; + let ux = (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d; + let uy = (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d; + let radius = ((a.0 - ux).powi(2) + (a.1 - uy).powi(2)).sqrt(); + Some((ux, uy, radius)) +} + +fn normalize_angle(angle: f64) -> f64 { + angle.rem_euclid(std::f64::consts::TAU) +} + +/// Nearest multiple of `grid`, halves rounding up, so pins half a grid off all move +/// the same way and keep their spacing. +fn snap(value: f64, grid: f64) -> f64 { + (value / grid + 0.5 + 1e-9).floor() * grid +} + +#[derive(Default)] +struct Bounds { + min_x: f64, + min_y: f64, + max_x: f64, + max_y: f64, + any: bool, +} + +impl Bounds { + fn add(&mut self, x: f64, y: f64) { + if self.any { + self.min_x = self.min_x.min(x); + self.min_y = self.min_y.min(y); + self.max_x = self.max_x.max(x); + self.max_y = self.max_y.max(y); + } else { + *self = Bounds { + min_x: x, + min_y: y, + max_x: x, + max_y: y, + any: true, + }; + } + } + + fn get(&self) -> Option<(f64, f64, f64, f64)> { + self.any + .then_some((self.min_x, self.min_y, self.max_x, self.max_y)) + } +} + +pub(crate) fn xml_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + // Control characters are not allowed in XML 1.0 attributes. + c if (c as u32) < 0x20 => out.push(' '), + c => out.push(c), + } + } + out +} + +/// `DefaultValues` block copied from Capture 17.2's own Library XML exports. +fn default_values_xml() -> String { + let mut xml = String::from(" \n \n"); + for index in 0..24 { + let (height, width) = if matches!(index, 3 | 4 | 17..=23) { + ("8", "0") + } else { + ("-9", if index == 13 { "5" } else { "4" }) + }; + let name = if index == 13 { "Courier New" } else { "Arial" }; + let _ = writeln!( + xml, + " \n \n " + ); + } + xml.push_str(" \n \n \n"); + xml.push_str(" \n \n \n"); + xml.push_str(" \n \n \n"); + let fields = [ + "1ST PART FIELD", + "2ND PART FIELD", + "3RD PART FIELD", + "4TH PART FIELD", + "5TH PART FIELD", + "6TH PART FIELD", + "7TH PART FIELD", + "PCB Footprint", + ]; + for (index, field) in fields.iter().enumerate() { + let _ = writeln!( + xml, + " \n \n ", + index + 1 + ); + } + xml.push_str(" \n"); + xml +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn library_xml_round_trips_its_packages() { + let parts = [ + " \n \n \n" + .to_string(), + " \n \n \n" + .to_string(), + ]; + let xml = render_library_xml("Parts.olb", &parts); + let blocks = package_blocks(&xml); + assert_eq!( + blocks + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(), + ["R_A&B_C1", "U_C2"] + ); + let again: Vec = blocks.into_iter().map(|(_, block)| block).collect(); + assert_eq!(render_library_xml("Parts.olb", &again), xml); + } + + #[test] + fn identifiers_are_plain_ascii() { + assert_eq!(capture_identifier("10kΩ 0402 (1%)"), "10k_0402_1"); + assert_eq!(capture_identifier("***"), "PART"); + } +} diff --git a/src/cadence/ipc7352.rs b/src/cadence/ipc7352.rs new file mode 100644 index 0000000..2f588ab --- /dev/null +++ b/src/cadence/ipc7352.rs @@ -0,0 +1,2552 @@ +//! IPC-7352 land patterns, density level N (nominal), for the standard SMD families. +//! +//! IPC-7352 (2023) replaced IPC-7352. For surface-mount parts it keeps IPC-7352's +//! land equations, zero orientations and naming convention, so the land is +//! calculated from the component's dimensions (minimum/maximum lead span `L`, +//! terminal length `T` and width `W`): +//! +//! ```text +//! Zmax = Lmin + 2 Jt + sqrt(CL² + F² + P²) +//! Gmin = Smax - 2 Jh - sqrt(CS² + F² + P²) (S toe-to-heel, RMS tolerance) +//! Xmax = Wmin + 2 Js + sqrt(CW² + F² + P²) +//! ``` +//! +//! Z and X are rounded up and G down to the round-off (0.005 mm for chips narrower +//! than 0.5 mm, as IPC-7352 asks); the courtyard is the lands and body plus the +//! courtyard excess, on a 0.05 mm grid. The pattern is built in zero orientation +//! (pin 1 upper left, chips and diodes with pin 1 / the cathode on the left), its +//! origin at the package centre, and named with the convention +//! (`RESC1005X40N`, `SOT95P280X145-5N`, ...). +//! +//! EasyEDA's own land pattern is used to check the result: its pads must line up +//! with the calculated ones after a quarter turn, which also carries EasyEDA's pin +//! numbers over. Anything that does not fit is reported and keeps EasyEDA's lands. + +use crate::kicad::{FootprintKiCircle as KiCircle, KiFootprint, KiLine, KiPad, PadShape, PadType}; + +/// Board fabrication tolerance F and placement tolerance P (mm). +pub const FAB_TOLERANCE: f64 = 0.05; +pub const PLACEMENT_TOLERANCE: f64 = 0.025; +/// Silkscreen line width and minimum gap to copper (mm). +const SILK_WIDTH: f64 = 0.12; +const SILK_TO_COPPER: f64 = 0.20; +const ASSEMBLY_WIDTH: f64 = 0.10; +const COURTYARD_WIDTH: f64 = 0.05; +const COURTYARD_GRID: f64 = 0.05; +/// Minimum copper gap between an exposed pad and the terminal lands. +const EP_TO_PAD: f64 = 0.20; +/// EasyEDA pads must land within this distance of the calculated ones. +const MATCH_TOLERANCE: f64 = 0.6; +const PX: f64 = 0.254; + +/// Solder-joint goals and courtyard of one IPC-7352 table, density level N. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Goals { + pub toe: f64, + pub heel: f64, + pub side: f64, + pub courtyard: f64, + pub round_off: f64, + pub table: &'static str, +} + +/// Rectangular and square-end chips, 1608 (0603) and larger. +pub const CHIP: Goals = Goals { + toe: 0.35, + heel: 0.0, + side: 0.0, + courtyard: 0.25, + round_off: 0.05, + table: "chip >= 1608", +}; +/// Chips narrower than 0.5 mm (0201, 01005): IPC-7352 rounds their lands to +/// 0.005 mm. +pub const CHIP_TINY: Goals = Goals { + round_off: 0.005, + table: "chip, width < 0.5", + ..CHIP_SMALL +}; +/// Chips smaller than 1608 (0603). +pub const CHIP_SMALL: Goals = Goals { + toe: 0.20, + heel: 0.0, + side: 0.0, + courtyard: 0.15, + round_off: 0.01, + table: "chip < 1608", +}; +/// Gull-wing leads, pitch above 0.625 mm. +pub const GULL_WING: Goals = Goals { + toe: 0.35, + heel: 0.35, + side: 0.03, + courtyard: 0.25, + round_off: 0.05, + table: "gull-wing, pitch > 0.625", +}; +/// Gull-wing leads, pitch 0.625 mm and below. +pub const GULL_WING_FINE: Goals = Goals { + toe: 0.35, + heel: 0.35, + side: -0.02, + courtyard: 0.25, + round_off: 0.05, + table: "gull-wing, pitch <= 0.625", +}; +/// Molded bodies with inward L-bend terminals (DO-214 SMA/SMB/SMC). +pub const MOLDED: Goals = Goals { + toe: 0.15, + heel: 0.50, + side: -0.05, + courtyard: 0.25, + round_off: 0.05, + table: "molded body, inward L-bend", +}; +/// Flat leads (SODFL / SOTFL: SOD-523, SOD-923). IPC-7352 gives no separate +/// table; the toe is the nominal lead thickness rounded up (0.20 mm, as PCB +/// Libraries' calculator does) and heel and side are the chip goals (Table 3-5). +/// The QFN goals used before (side -0.04 mm) made 0.30 mm wide lands for a +/// 0.30 mm lead, narrower than NXP's and onsemi's SOD-523 footprints. +pub const FLAT_LEAD: Goals = Goals { + toe: 0.20, + heel: 0.0, + side: 0.0, + courtyard: 0.25, + round_off: 0.05, + table: "flat lead", +}; +/// Flat no-lead terminations (QFN, SON). +pub const NO_LEAD: Goals = Goals { + toe: 0.30, + heel: 0.0, + side: -0.04, + courtyard: 0.25, + round_off: 0.05, + table: "flat no-lead", +}; + +/// A dimension with its tolerance (mm). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Dim { + pub min: f64, + pub max: f64, +} + +impl Dim { + pub const fn new(min: f64, max: f64) -> Self { + Self { min, max } + } + + fn around(nominal: f64, tolerance: f64) -> Self { + Self::new(nominal - tolerance, nominal + tolerance) + } + + pub fn nominal(&self) -> f64 { + (self.min + self.max) / 2.0 + } + + fn tolerance(&self) -> f64 { + self.max - self.min + } +} + +/// Where the terminals are, in IPC-7352 zero orientation. +#[derive(Debug, Clone, PartialEq)] +pub enum Layout { + /// Two terminals on the X axis, pin 1 on the left. + TwoTerminal, + /// Columns on the left and right: Y of each left pin (top first) and right pin + /// (bottom first), counter-clockwise numbering. + Dual { left: Vec, right: Vec }, + /// `per_side` pins on each of the four sides, pin 1 at the top of the left side. + Quad { per_side: usize }, +} + +/// Everything the calculation needs about a package. +#[derive(Debug, Clone, PartialEq)] +pub struct Package { + /// IPC-7352 family prefix (`RESC`, `SOT`, `SOIC`, `QFN`, ...). + pub prefix: &'static str, + pub layout: Layout, + pub pitch: f64, + /// Toe-to-toe span across the lands (chips and no-lead: body length). + pub span: Dim, + pub terminal: Dim, + pub width: Dim, + /// Width of the tab lead (SOT-223), which takes the right-hand column. + pub tab: Option, + /// Nominal body size along X and Y in zero orientation. + pub body: (f64, f64), + pub height: f64, + pub exposed_pad: Option<(f64, f64)>, + pub goals: Goals, + /// The two terminals are polarised (cathode = pin 1). + pub polarized: bool, + /// Dimensions came from typical JEDEC/EIA values rather than the part. + pub source: &'static str, +} + +impl Package { + pub fn pins(&self) -> usize { + match &self.layout { + Layout::TwoTerminal => 2, + Layout::Dual { left, right } => left.len() + right.len(), + Layout::Quad { per_side } => per_side * 4, + } + } + + /// IPC-7352 land pattern name, density level N. + pub fn ipc_name(&self) -> String { + let h = hundredths(self.height); + let n = self.pins() + usize::from(self.exposed_pad.is_some()); + match self.prefix { + "SOD" | "SODFL" | "DIOM" | "RESC" | "CAPC" | "INDC" | "LEDC" | "DIOC" | "FUSC" => { + let (a, b) = if matches!(self.prefix, "SOD" | "SODFL" | "DIOM") { + (self.span.nominal(), self.body.1) + } else { + (self.body.0, self.body.1) + }; + format!("{}{}{}X{h}N", self.prefix, tenths(a), tenths(b)) + } + "QFP" => format!( + "QFP{}P{}X{}X{h}-{n}N", + hundredths(self.pitch), + hundredths(self.span.nominal()), + hundredths(self.span.nominal()) + ), + "QFN" | "SON" => format!( + "{}{}P{}X{}X{h}-{n}N", + self.prefix, + hundredths(self.pitch), + hundredths(self.body.0), + hundredths(self.body.1) + ), + _ => format!( + "{}{}P{}X{h}-{n}N", + self.prefix, + hundredths(self.pitch), + hundredths(self.span.nominal()) + ), + } + } +} + +/// `1.6` -> `16`, `0.8` -> `08`, `1.25` -> `12` (two digits of 0.1 mm). +fn tenths(mm: f64) -> String { + format!("{:02}", ((mm * 10.0) + 1e-6).floor() as i64) +} + +/// `0.95` -> `95`, `2.80` -> `280`. +fn hundredths(mm: f64) -> String { + format!("{}", (mm * 100.0).round() as i64) +} + +/// Land dimensions of one row of terminals (mm). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Lands { + pub z: f64, + pub g: f64, + pub x: f64, +} + +impl Lands { + /// Land length along the span. + pub fn length(&self) -> f64 { + (self.z - self.g) / 2.0 + } + + /// Land centre distance from the package centre. + pub fn centre(&self) -> f64 { + (self.z + self.g) / 4.0 + } +} + +/// IPC-7352 land equations. +pub fn lands(span: Dim, terminal: Dim, width: Dim, goals: &Goals) -> Lands { + let ft = FAB_TOLERANCE.powi(2) + PLACEMENT_TOLERANCE.powi(2); + let z = span.min + 2.0 * goals.toe + (span.tolerance().powi(2) + ft).sqrt(); + // Toe-to-heel distance with an RMS tolerance. + let s_min = span.min - 2.0 * terminal.max; + let s_max = span.max - 2.0 * terminal.min; + let s_tol = s_max - s_min; + let s_tol_rms = (span.tolerance().powi(2) + 2.0 * terminal.tolerance().powi(2)).sqrt(); + let s_max_rms = s_max - (s_tol - s_tol_rms) / 2.0; + let g = s_max_rms - 2.0 * goals.heel - (s_tol_rms.powi(2) + ft).sqrt(); + let x = width.min + 2.0 * goals.side + (width.tolerance().powi(2) + ft).sqrt(); + let r = goals.round_off; + Lands { + z: round_up(z, r), + g: round_down(g, r).max(0.0), + x: round_up(x, r), + } +} + +fn round_up(v: f64, step: f64) -> f64 { + ((v / step) - 1e-9).ceil() * step +} + +fn round_down(v: f64, step: f64) -> f64 { + ((v / step) + 1e-9).floor() * step +} + +fn round2(v: f64) -> f64 { + (v * 100.0).round() / 100.0 + 0.0 +} + +// --------------------------------------------------------------------------- +// Packages + +/// EIA chip sizes: code, body length and width with tolerance, terminal length. +const CHIP_SIZES: [(&str, f64, f64, f64, f64, Dim); 9] = [ + ("0201", 0.60, 0.03, 0.30, 0.03, Dim::new(0.10, 0.20)), + ("0402", 1.00, 0.05, 0.50, 0.05, Dim::new(0.10, 0.30)), + ("0603", 1.60, 0.10, 0.80, 0.10, Dim::new(0.10, 0.50)), + ("0805", 2.00, 0.10, 1.25, 0.10, Dim::new(0.20, 0.75)), + ("1206", 3.20, 0.15, 1.60, 0.15, Dim::new(0.25, 0.75)), + ("1210", 3.20, 0.15, 2.50, 0.15, Dim::new(0.25, 0.75)), + ("1812", 4.50, 0.30, 3.20, 0.20, Dim::new(0.25, 0.85)), + ("2010", 5.00, 0.10, 2.50, 0.15, Dim::new(0.35, 0.85)), + ("2512", 6.35, 0.10, 3.20, 0.15, Dim::new(0.35, 0.85)), +]; + +/// Maximum height by chip kind and size (resistor, capacitor, other). +fn chip_height(prefix: &str, code: &str) -> f64 { + let (r, c, other) = match code { + "0201" => (0.26, 0.33, 0.33), + "0402" => (0.40, 0.55, 0.55), + "0603" => (0.55, 0.90, 0.80), + "0805" => (0.70, 1.45, 1.00), + "1206" => (0.70, 1.80, 1.20), + "1210" => (0.70, 2.70, 1.20), + "1812" => (0.70, 2.70, 2.00), + _ => (0.70, 2.70, 1.50), + }; + match prefix { + "RESC" | "FUSC" => r, + "CAPC" => c, + _ => other, + } +} + +/// IPC chip prefix for an LCSC reference prefix. +fn chip_prefix(reference: &str, package_prefix: &str) -> &'static str { + match reference { + "R" | "RN" => "RESC", + "C" => "CAPC", + "L" | "FB" => "INDC", + "LED" => "LEDC", + "D" => "DIOC", + "F" => "FUSC", + _ => match package_prefix { + "R" => "RESC", + "C" => "CAPC", + "L" | "FB" => "INDC", + "LED" => "LEDC", + "D" => "DIOC", + "F" => "FUSC", + _ => "RESC", + }, + } +} + +fn chip_code(l: f64, w: f64) -> Option<&'static str> { + CHIP_SIZES + .iter() + .find(|s| (l - s.1).abs() <= 0.12 && (w - s.3).abs() <= 0.12) + .map(|s| s.0) +} + +fn chip(code: &str, prefix: &'static str) -> Option { + let &(_, l, lt, w, wt, t) = CHIP_SIZES.iter().find(|s| s.0 == code)?; + let small = l < 1.5; + Some(Package { + prefix, + layout: Layout::TwoTerminal, + pitch: 0.0, + span: Dim::around(l, lt), + terminal: t, + width: Dim::around(w, wt), + body: (l, w), + height: chip_height(prefix, code), + tab: None, + exposed_pad: None, + goals: if w < 0.5 { + CHIP_TINY + } else if small { + CHIP_SMALL + } else { + CHIP + }, + polarized: matches!(prefix, "LEDC" | "DIOC"), + source: "EIA chip dimensions", + }) +} + +/// Parameters EasyEDA puts in its package names: `_L2.9-W1.6-P0.95-LS2.8`. +#[derive(Debug, Default, Clone, Copy)] +struct NameDims { + l: Option, + w: Option, + p: Option, + ls: Option, + h: Option, + ep: Option<(f64, f64)>, + has_ep: bool, +} + +fn name_dims(rest: &str) -> NameDims { + let mut d = NameDims::default(); + for token in rest.split(['-', '_']) { + let num = |s: &str| s.parse::().ok(); + if let Some(v) = token.strip_prefix("LS").and_then(num) { + d.ls.get_or_insert(v); + } else if let Some(v) = token.strip_prefix("EP") { + d.has_ep = true; + let mut parts = v.split('X').filter_map(num); + if let Some(a) = parts.next() { + d.ep = Some((a, parts.next().unwrap_or(a))); + } + } else if let Some(v) = token.strip_prefix('L').and_then(num) { + d.l.get_or_insert(v); + } else if let Some(v) = token.strip_prefix('W').and_then(num) { + d.w.get_or_insert(v); + } else if let Some(v) = token.strip_prefix('P').and_then(num) { + d.p.get_or_insert(v); + } else if let Some(v) = token.strip_prefix('H').and_then(num) { + d.h.get_or_insert(v); + } + } + d +} + +/// Gull-wing lead width by pitch (JEDEC). +fn gull_lead_width(pitch: f64) -> Dim { + if pitch >= 1.2 { + Dim::new(0.31, 0.51) + } else if pitch >= 0.75 { + Dim::new(0.30, 0.45) + } else if pitch >= 0.6 { + Dim::new(0.22, 0.38) + } else if pitch >= 0.45 { + Dim::new(0.17, 0.27) + } else { + Dim::new(0.13, 0.23) + } +} + +/// No-lead terminal width by pitch (JEDEC MO-220 / MO-229). +fn no_lead_width(pitch: f64) -> Dim { + if pitch >= 0.75 { + Dim::new(0.28, 0.38) + } else if pitch >= 0.6 { + Dim::new(0.25, 0.35) + } else if pitch >= 0.45 { + Dim::new(0.18, 0.30) + } else { + Dim::new(0.15, 0.25) + } +} + +fn gull_goals(pitch: f64) -> Goals { + if pitch > 0.625 { + GULL_WING + } else { + GULL_WING_FINE + } +} + +/// Y positions of `n` pins at `pitch`, top first, centred on 0. +fn column(n: usize, pitch: f64) -> Vec { + (0..n) + .map(|i| round2(((n as f64 - 1.0) / 2.0 - i as f64) * pitch)) + .collect() +} + +fn dual(n: usize, pitch: f64) -> Layout { + let left = column(n / 2, pitch); + let mut right = left.clone(); + right.reverse(); + Layout::Dual { left, right } +} + +/// Recognise the package from EasyEDA's package name. `reference` is the part's +/// reference prefix (R, C, LED, ...), which picks the chip kind. +pub fn package_from_name(package: &str, reference: &str) -> Result { + let mut p = parse_package(package, reference)?; + // Bidirectional TVS diodes (EasyEDA's "-BI") have no cathode: no cathode + // mark on the footprint. + let bidirectional = package + .to_ascii_uppercase() + .split(['-', '_']) + .any(|token| token == "BI"); + if bidirectional && p.polarized { + p.polarized = false; + } + Ok(p) +} + +fn parse_package(package: &str, reference: &str) -> Result { + let upper = package.trim().to_ascii_uppercase(); + if upper.is_empty() { + return Err("EasyEDA names no package".into()); + } + let (base, rest) = upper.split_once('_').unwrap_or((upper.as_str(), "")); + let dims = name_dims(rest); + let head = base.split('-').next().unwrap_or(base); + + // Chips: R0402, C0603, LED0603-RD, or LED-SMD_L1.6-W0.8. + let letters: String = head + .chars() + .take_while(|c| c.is_ascii_alphabetic()) + .collect(); + let digits = &head[letters.len()..]; + if !letters.is_empty() + && letters.len() <= 3 + && digits.len() == 4 + && digits.chars().all(|c| c.is_ascii_digit()) + { + if let Some(p) = chip(digits, chip_prefix(reference, &letters)) { + return Ok(p); + } + return Err(format!("chip size {digits} is not in the EIA table")); + } + if base.ends_with("-SMD") { + if let (Some(l), Some(w)) = (dims.l, dims.w) { + if let Some(code) = chip_code(l, w) { + let letters = base.trim_end_matches("-SMD"); + if let Some(p) = chip(code, chip_prefix(reference, letters)) { + return Ok(p); + } + } + } + } + + let mut parts = base.split('-'); + let family = parts.next().unwrap_or_default(); + let size = parts.next().unwrap_or_default(); + let count: Option = parts.next().and_then(|v| v.parse().ok()); + let pins_in_name: Option = size.parse().ok(); + + match family { + "SOT" | "SC" => sot(size, count, &dims), + "SOD" => sod(size), + "SMA" | "SMB" | "SMC" | "DO" => do214(family, size, reference), + "SO" | "SOIC" | "SOP" | "TSSOP" | "MSOP" | "SSOP" | "TSOP" | "HTSSOP" | "ESOP" | "HSOP" + | "VSSOP" | "QSOP" => { + let n = pins_in_name.ok_or("no pin count")?; + let pitch = dims.p.ok_or("no pitch in the package name")?; + let span_nom = dims.ls.ok_or("no lead span in the package name")?; + let (mut body_l, mut body_w) = ( + dims.l.ok_or("no body length")?, + dims.w.ok_or("no body width")?, + ); + if n % 2 != 0 { + return Err(format!("{n} pins cannot sit in two rows")); + } + let exposed_pad = match ( + dims.ep, + dims.has_ep || family.starts_with('H') || family == "ESOP", + ) { + (Some(ep), _) => Some(ep), + (None, true) => return Err("exposed pad size missing from the package name".into()), + (None, false) => None, + }; + let soic = (pitch - 1.27).abs() < 0.01 && family != "SSOP"; + // One footprint per JEDEC package: EasyEDA's body sizes vary a little + // from part to part (4.9 x 3.9, 5.0 x 4.0 for the same SOIC-8). + let span_nom = if soic { + if body_w < 5.0 { + body_w = 3.90; + body_l = match n { + 8 => 4.90, + 14 => 8.65, + 16 => 9.90, + _ => body_l, + }; + 6.00 + } else { + body_w = 7.50; + body_l = match n { + 16 => 10.30, + 18 => 11.55, + 20 => 12.80, + 24 => 15.40, + 28 => 17.90, + _ => body_l, + }; + 10.30 + } + } else { + span_nom + }; + let height = dims.h.unwrap_or(match family { + "TSSOP" | "TSOP" => 1.20, + "MSOP" => 1.10, + "SSOP" => 2.00, + _ if body_w > 5.0 => 2.65, + _ => 1.75, + }); + Ok(Package { + prefix: if soic { "SOIC" } else { "SOP" }, + layout: dual(n, pitch), + pitch, + span: Dim::around(span_nom, 0.20), + terminal: if soic { + Dim::new(0.40, 1.27) + } else { + Dim::new(0.45, 0.75) + }, + width: if matches!(family, "TSSOP" | "TSOP") { + Dim::new(0.19, 0.30) + } else { + gull_lead_width(pitch) + }, + body: (body_w, body_l), + height, + tab: None, + exposed_pad, + goals: gull_goals(pitch), + polarized: false, + source: "package name with JEDEC lead tolerances", + }) + } + "QFP" | "LQFP" | "TQFP" => { + let n = pins_in_name.ok_or("no pin count")?; + let pitch = dims.p.ok_or("no pitch in the package name")?; + let span_nom = dims.ls.ok_or("no lead span in the package name")?; + let (l, w) = ( + dims.l.ok_or("no body length")?, + dims.w.ok_or("no body width")?, + ); + if (l - w).abs() > 0.01 || n % 4 != 0 { + return Err("rectangular QFPs are not calculated yet".into()); + } + Ok(Package { + prefix: "QFP", + layout: Layout::Quad { per_side: n / 4 }, + pitch, + span: Dim::around(span_nom, 0.20), + terminal: Dim::new(0.45, 0.75), + width: gull_lead_width(pitch), + body: (w, l), + height: dims.h.unwrap_or(match family { + "TQFP" => 1.20, + "LQFP" => 1.60, + _ => 2.45, + }), + tab: None, + exposed_pad: None, + goals: gull_goals(pitch), + polarized: false, + source: "package name with JEDEC lead tolerances", + }) + } + "QFN" | "VQFN" | "WQFN" | "UQFN" | "TQFN" | "XQFN" | "HVQFN" | "LQFN" | "UFQFPN" + | "VFQFPN" | "WFQFN" | "DFN" | "SON" | "WSON" | "VSON" | "UDFN" | "WDFN" | "TDFN" + | "VDFN" | "XDFN" => { + let n = pins_in_name.ok_or("no pin count")?; + let pitch = dims.p.ok_or("no pitch in the package name")?; + let (l, w) = ( + dims.l.ok_or("no body length")?, + dims.w.ok_or("no body width")?, + ); + // UFQFPN / VFQFPN are ST's names for quad flat no-lead packages. + let quad = family.ends_with("QFN") || family.ends_with("QFPN"); + let height = dims.h.unwrap_or(match family.chars().next() { + Some('W') => 0.80, + Some('U') => 0.60, + Some('X') => 0.40, + _ => 1.00, + }); + let ep = match (dims.ep, dims.has_ep) { + (Some(ep), _) => Some(ep), + (None, true) => return Err("exposed pad size missing from the package name".into()), + (None, false) => None, + }; + if quad { + if (l - w).abs() > 0.01 || n % 4 != 0 { + return Err("rectangular QFNs are not calculated yet".into()); + } + Ok(Package { + prefix: "QFN", + layout: Layout::Quad { per_side: n / 4 }, + pitch, + span: Dim::around(l, 0.10), + terminal: Dim::new(0.30, 0.50), + width: no_lead_width(pitch), + body: (w, l), + height, + tab: None, + exposed_pad: ep, + goals: NO_LEAD, + polarized: false, + source: "package name with JEDEC MO-220 tolerances", + }) + } else { + if n % 2 != 0 { + return Err(format!("{n} pins cannot sit in two rows")); + } + // Terminals along the longer side; the span is the shorter side. + let (along, across) = if l >= w { (l, w) } else { (w, l) }; + Ok(Package { + prefix: "SON", + layout: dual(n, pitch), + pitch, + span: Dim::around(across, 0.10), + terminal: Dim::new(0.30, 0.50), + width: no_lead_width(pitch), + body: (across, along), + height, + tab: None, + exposed_pad: ep, + goals: NO_LEAD, + polarized: false, + source: "package name with JEDEC MO-229 tolerances", + }) + } + } + _ => Err(format!( + "{family} packages are not in the IPC-7352 calculator" + )), + } +} + +/// SOT-23 (3/5/6 pins) and SC-70 / SOT-323 / SOT-353 / SOT-363 (JEDEC TO-236, +/// MO-178, MO-203). +fn sot(size: &str, count: Option, dims: &NameDims) -> Result { + if size == "223" && matches!(count, None | Some(3) | Some(4)) { + // JEDEC TO-261AA: three leads and the tab opposite. + return Ok(Package { + prefix: "SOT", + layout: Layout::Dual { + left: vec![2.30, 0.0, -2.30], + right: vec![0.0], + }, + pitch: 2.30, + span: Dim::new(6.70, 7.30), + terminal: Dim::new(0.75, 1.10), + width: Dim::new(0.66, 0.84), + tab: Some(Dim::new(2.90, 3.18)), + body: (3.50, 6.50), + height: 1.80, + exposed_pad: None, + goals: GULL_WING, + polarized: false, + source: "JEDEC TO-261AA dimensions", + }); + } + let (pins, small) = match (size, count) { + ("23", None) | ("23", Some(3)) => (3, false), + ("23", Some(n)) if n == 5 || n == 6 => (n, false), + ("323", None) | ("70", Some(3)) => (3, true), + ("353", None) | ("70", Some(5)) => (5, true), + ("363", None) | ("70", Some(6)) => (6, true), + _ => return Err(format!("SOT-{size} is not in the IPC-7352 calculator")), + }; + let pitch = if small { 0.65 } else { 0.95 }; + let p = pitch; + let layout = match pins { + 3 => Layout::Dual { + left: vec![p, -p], + right: vec![0.0], + }, + 5 => Layout::Dual { + left: vec![p, 0.0, -p], + right: vec![-p, p], + }, + _ => Layout::Dual { + left: vec![p, 0.0, -p], + right: vec![-p, 0.0, p], + }, + }; + // Three-lead SOT-23 comes narrow (JEDEC TO-236, 1.3 mm body, 2.4 mm span) and + // wide (MO-178, the SOT-23-5 body: 1.6 mm, 2.8 mm span); the package name says + // which. + let wide = dims.ls.is_some_and(|ls| ls >= 2.7) || dims.w.is_some_and(|w| w >= 1.5); + let (span, terminal, width, body, height) = match (small, pins) { + (false, 3) if !wide => ( + Dim::new(2.10, 2.64), + Dim::new(0.30, 0.55), + Dim::new(0.30, 0.50), + (1.30, 2.92), + 1.12, + ), + (false, _) => ( + Dim::new(2.60, 3.00), + Dim::new(0.30, 0.60), + Dim::new(0.30, 0.50), + (1.60, 2.90), + 1.45, + ), + (true, _) => ( + Dim::new(2.00, 2.20), + Dim::new(0.26, 0.46), + Dim::new(0.15, 0.30), + (1.25, 2.00), + 1.10, + ), + }; + Ok(Package { + prefix: "SOT", + layout, + pitch, + span, + terminal, + width, + body, + height, + tab: None, + exposed_pad: None, + goals: gull_goals(pitch), + polarized: false, + source: "JEDEC TO-236 / MO-178 / MO-203 dimensions", + }) +} + +/// DO-214 molded diodes: SMA (DO-214AC), SMB (DO-214AA), SMC (DO-214AB). +fn do214(family: &str, size: &str, reference: &str) -> Result { + let kind = match (family, size) { + ("SMA", _) | ("DO", "214AC") => "SMA", + ("SMB", _) | ("DO", "214AA") => "SMB", + ("SMC", _) | ("DO", "214AB") => "SMC", + _ => { + return Err(format!("{family}-{size} is not in the IPC-7352 calculator")); + } + }; + let _ = reference; + let (span, width, body, height) = match kind { + "SMA" => ( + Dim::new(4.90, 5.50), + Dim::new(1.27, 1.63), + (4.30, 2.60), + 2.44, + ), + "SMB" => ( + Dim::new(5.10, 5.70), + Dim::new(1.96, 2.21), + (4.30, 3.60), + 2.44, + ), + _ => ( + Dim::new(7.70, 8.10), + Dim::new(2.90, 3.20), + (6.85, 5.90), + 2.62, + ), + }; + Ok(Package { + prefix: "DIOM", + layout: Layout::TwoTerminal, + pitch: 0.0, + span, + terminal: Dim::new(0.76, 1.52), + width, + tab: None, + body, + height, + exposed_pad: None, + goals: MOLDED, + polarized: true, + source: "JEDEC DO-214 dimensions", + }) +} + +/// SOD-123, SOD-323 (gull-wing) and SOD-523, SOD-923 (flat lead). +fn sod(size: &str) -> Result { + let (span, terminal, width, body, height, flat) = match size { + "123" => ( + Dim::new(3.55, 3.85), + Dim::new(0.25, 0.55), + Dim::new(0.45, 0.65), + (2.70, 1.60), + 1.35, + false, + ), + "323" => ( + Dim::new(2.30, 2.70), + Dim::new(0.20, 0.40), + Dim::new(0.25, 0.40), + (1.70, 1.25), + 1.10, + false, + ), + // onsemi case 502 / NXP SOD523: lead length 0.15-0.25 mm. + "523" => ( + Dim::new(1.50, 1.70), + Dim::new(0.15, 0.25), + Dim::new(0.25, 0.35), + (1.20, 0.80), + 0.70, + true, + ), + "923" => ( + Dim::new(0.95, 1.05), + Dim::new(0.15, 0.25), + Dim::new(0.15, 0.25), + (0.80, 0.60), + 0.45, + true, + ), + _ => return Err(format!("SOD-{size} is not in the IPC-7352 calculator")), + }; + Ok(Package { + prefix: if flat { "SODFL" } else { "SOD" }, + layout: Layout::TwoTerminal, + pitch: 0.0, + span, + terminal, + width, + body, + height, + tab: None, + exposed_pad: None, + goals: if flat { FLAT_LEAD } else { GULL_WING }, + polarized: true, + source: "JEDEC/JEITA SOD dimensions", + }) +} + +// --------------------------------------------------------------------------- +// Geometry + +/// One calculated land (mm, Y up, zero orientation). +#[derive(Debug, Clone, PartialEq)] +pub struct Land { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +impl Land { + fn left(&self) -> f64 { + self.x - self.w / 2.0 + } + fn right(&self) -> f64 { + self.x + self.w / 2.0 + } + fn bottom(&self) -> f64 { + self.y - self.h / 2.0 + } + fn top(&self) -> f64 { + self.y + self.h / 2.0 + } +} + +/// The lands in standard numbering order (pin 1 first, exposed pad last). +pub fn layout_lands(package: &Package) -> (Vec, Lands) { + let mut row = lands( + package.span, + package.terminal, + package.width, + &package.goals, + ); + // Neighbouring lands keep a copper gap. + if package.pitch > 0.0 { + let max_x = round_down(package.pitch - 0.1, 0.01); + row.x = row.x.min(max_x); + } + // Pull the heel back from an exposed pad. + if let Some((ex, ey)) = package.exposed_pad { + let min_g = ex.max(ey) + 2.0 * EP_TO_PAD; + if row.g < min_g { + row.g = round_up(min_g, 0.01); + } + } + let (len, c) = (round2(row.length()), round2(row.centre())); + let tab_x = package + .tab + .map(|tab| lands(package.span, package.terminal, tab, &package.goals).x) + .unwrap_or(row.x); + let mut out = Vec::new(); + match &package.layout { + Layout::TwoTerminal => { + out.push(Land { + x: -c, + y: 0.0, + w: len, + h: row.x, + }); + out.push(Land { + x: c, + y: 0.0, + w: len, + h: row.x, + }); + } + Layout::Dual { left, right } => { + for &y in left { + out.push(Land { + x: -c, + y, + w: len, + h: row.x, + }); + } + for &y in right { + out.push(Land { + x: c, + y, + w: len, + h: tab_x, + }); + } + } + Layout::Quad { per_side } => { + let col = column(*per_side, package.pitch); + for &y in &col { + out.push(Land { + x: -c, + y, + w: len, + h: row.x, + }); + } + for &y in &col { + out.push(Land { + x: -y, + y: -c, + w: row.x, + h: len, + }); + } + for &y in &col { + out.push(Land { + x: c, + y: -y, + w: len, + h: row.x, + }); + } + for &y in &col { + out.push(Land { + x: y, + y: c, + w: row.x, + h: len, + }); + } + } + } + if let Some((ex, ey)) = package.exposed_pad { + out.push(Land { + x: 0.0, + y: 0.0, + w: ex, + h: ey, + }); + } + (out, row) +} + +/// A finished IPC-7352 land pattern. +#[derive(Debug, Clone)] +pub struct LandPattern { + pub name: String, + /// Footprint in the converter's units (EasyEDA px, Y down), origin at the + /// package centre. + pub footprint: KiFootprint, + /// Turn (degrees, counter-clockwise) and shift (mm) from EasyEDA's footprint to + /// this one: `ipc = R(turn) * easyeda + shift`. + pub turn: f64, + pub shift: (f64, f64), + pub notes: Vec, +} + +/// Calculate the IPC-7352 land pattern for `package_name` and line it up with +/// EasyEDA's footprint `easyeda` to carry its pin numbers over. `first_pin` is the +/// pad that goes where IPC puts pin 1 (the cathode of diodes and LEDs). +pub fn land_pattern( + package_name: &str, + reference: &str, + easyeda: &KiFootprint, + first_pin: &str, +) -> Result { + let package = package_from_name(package_name, reference)?; + let (lands, row) = layout_lands(&package); + if lands.iter().any(|l| l.w <= 0.0 || l.h <= 0.0) { + return Err("the calculated lands have no size".into()); + } + if row.g <= 0.0 { + return Err("the calculated lands overlap".into()); + } + + let theirs = easyeda_pads(easyeda)?; + let ours: Vec<(f64, f64)> = lands.iter().map(|l| (l.x, l.y)).collect(); + let alignment = align(&ours, &theirs, first_pin)?; + let mut notes = Vec::new(); + if !alignment.pin1_ipc { + notes.push(format!( + "IPC-7352: EasyEDA's pin {first_pin} is not at an IPC pin-1 position for this package; numbering kept from EasyEDA" + )); + } + + notes.extend(check_fit( + package_name, + &package, + &lands, + easyeda, + &alignment, + )?); + + let name = package.ipc_name(); + // Silkscreen: the simplified IPC-7352 drawing of the body (see + // `build_footprint`), not EasyEDA's, whose outlines are drawn round its own + // lands and come out up to twice the size of the part. + let footprint = build_footprint(&name, &package, &lands, &alignment.numbers); + Ok(LandPattern { + name, + footprint, + turn: alignment.turn, + shift: alignment.shift, + notes, + }) +} + +/// How far the calculated pattern may stray from the part EasyEDA describes. +/// A pad centre may sit this far (mm) from the manufacturer's... +const FIT_CENTRE: f64 = 0.35; +/// ...or this fraction of the manufacturer's land length, whichever is larger. +const FIT_CENTRE_RATIO: f64 = 0.45; +/// Lands must overlap the manufacturer's by at least this fraction of the smaller. +const FIT_OVERLAP: f64 = 0.35; +/// A land may not be narrower (across its pin) than this fraction of the +/// manufacturer's: narrower means the calculation assumed a thinner lead. +const FIT_WIDTH: f64 = 0.7; + +/// Check that the calculated pattern is the part's real package: the package +/// name's own dimensions (body, lead span) must agree with the dimensions the +/// calculation used, and every land must sit on the manufacturer's land for the +/// same pin. A part whose package differs from the JEDEC/EIA one assumed (a +/// non-standard lead span, a wider body, a different pad layout) is refused, and +/// the manufacturer's lands are used instead. +fn check_fit( + package_name: &str, + package: &Package, + lands: &[Land], + easyeda: &KiFootprint, + alignment: &Alignment, +) -> Result, String> { + let upper = package_name.trim().to_ascii_uppercase(); + let rest = upper.split_once('_').map(|(_, r)| r).unwrap_or(""); + let dims = name_dims(rest); + if let Some(ls) = dims.ls { + let (lo, hi) = (package.span.min - 0.15, package.span.max + 0.15); + if ls < lo || ls > hi { + return Err(format!( + "the package name gives a {ls:.2} mm lead span, outside the {:.2}-{:.2} mm of the JEDEC/EIA outline the calculation uses", + package.span.min, package.span.max + )); + } + } + if let (Some(l), Some(w)) = (dims.l, dims.w) { + let named = (l.min(w), l.max(w)); + let used = ( + package.body.0.min(package.body.1), + package.body.0.max(package.body.1), + ); + if (named.0 - used.0).abs() > 0.3 || (named.1 - used.1).abs() > 0.3 { + return Err(format!( + "the package name gives a {:.2} x {:.2} mm body, the JEDEC/EIA outline {:.2} x {:.2} mm", + named.1, named.0, used.1, used.0 + )); + } + } + + // Manufacturer's lands in the calculated pattern's frame. + let q = (alignment.turn / 90.0).round() as usize % 4; + let mut theirs: Vec<(String, [f64; 4])> = Vec::new(); + for pad in &easyeda.pads { + let number = pad.number.trim(); + if number.is_empty() || matches!(pad.pad_type, PadType::NpThroughHole) { + continue; + } + let (w, h) = (pad.size_x * PX, pad.size_y * PX); + let (sin, cos) = pad.rotation.to_radians().sin_cos(); + let (hw, hh) = ( + (w * cos.abs() + h * sin.abs()) / 2.0, + (w * sin.abs() + h * cos.abs()) / 2.0, + ); + let (x, y) = turn_point(q, pad.pos_x * PX, -pad.pos_y * PX); + let (x, y) = (x + alignment.shift.0, y + alignment.shift.1); + let (hw, hh) = if q % 2 == 1 { (hh, hw) } else { (hw, hh) }; + let rect = [x - hw, y - hh, x + hw, y + hh]; + match theirs.iter_mut().find(|t| t.0 == number) { + Some(t) => { + t.1 = [ + t.1[0].min(rect[0]), + t.1[1].min(rect[1]), + t.1[2].max(rect[2]), + t.1[3].max(rect[3]), + ]; + } + None => theirs.push((number.to_string(), rect)), + } + } + let mut worst: Option<(f64, String)> = None; + for (land, number) in lands.iter().zip(&alignment.numbers) { + let Some((_, r)) = theirs.iter().find(|t| &t.0 == number) else { + continue; + }; + let ours = [land.left(), land.bottom(), land.right(), land.top()]; + let centre = |b: &[f64; 4]| ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); + let (a, b) = (centre(&ours), centre(r)); + let distance = (a.0 - b.0).hypot(a.1 - b.1); + let area = |b: &[f64; 4]| ((b[2] - b[0]) * (b[3] - b[1])).max(1e-9); + let inter = ((ours[2].min(r[2]) - ours[0].max(r[0])).max(0.0)) + * ((ours[3].min(r[3]) - ours[1].max(r[1])).max(0.0)); + let overlap = inter / area(&ours).min(area(r)); + let their_length = (r[2] - r[0]).max(r[3] - r[1]); + let allowed = FIT_CENTRE.max(FIT_CENTRE_RATIO * their_length); + // Across the pin: perpendicular to the direction from the package centre + // (none for a centre pad). + let centre_pad = land.x.abs() < 1e-6 && land.y.abs() < 1e-6; + let left_or_right = match package.layout { + Layout::Quad { .. } => land.x.abs() >= land.y.abs(), + _ => true, + }; + let across = if centre_pad { + None + } else if left_or_right { + Some((land.h, r[3] - r[1])) + } else { + Some((land.w, r[2] - r[0])) + }; + // Where IPC-7352 asks for a side fillet (side goal >= 0), and for every + // two-terminal part but the molded-body ones, the land is at least as wide + // as the widest lead. + let full_width = package.goals.side >= 0.0 + || (matches!(package.layout, Layout::TwoTerminal) && package.goals != MOLDED); + if let (Some((ours, _)), true) = (across, full_width) { + if ours + 1e-9 < package.width.max { + return Err(format!( + "pad {number} would be {ours:.2} mm wide, narrower than the {:.2} mm lead", + package.width.max + )); + } + } + let problem = if distance > allowed { + Some(format!( + "pad {number} sits {distance:.2} mm from the manufacturer's land (allowed {allowed:.2} mm)" + )) + } else if overlap < FIT_OVERLAP { + Some(format!( + "pad {number} overlaps the manufacturer's land by only {:.0}%", + overlap * 100.0 + )) + } else if let Some((ours, theirs)) = across.filter(|(o, t)| *o < FIT_WIDTH * t) { + Some(format!( + "pad {number} is {ours:.2} mm wide, the manufacturer's {theirs:.2} mm" + )) + } else { + None + }; + if let Some(problem) = problem { + return Err(format!( + "{problem}: the part's package does not match the JEDEC/EIA outline the calculation assumes" + )); + } + let score = distance / allowed; + if worst.as_ref().is_none_or(|w| score > w.0) { + let width = across + .map(|(o, t)| format!(", width {o:.2} mm vs {t:.2} mm")) + .unwrap_or_default(); + worst = Some(( + score, + format!("{distance:.2} mm, {:.0}% overlap{width}", overlap * 100.0), + )); + } + } + Ok(worst + .map(|(_, text)| { + vec![format!( + "IPC-7352 lands checked against the manufacturer's: largest pad offset {text}" + )] + }) + .unwrap_or_default()) +} + +/// EasyEDA's numbered SMD pads (mm, Y up); pads sharing a number become one. +fn easyeda_pads(footprint: &KiFootprint) -> Result, String> { + let mut groups: Vec<(String, f64, f64, f64, f64)> = Vec::new(); + for pad in &footprint.pads { + match pad.pad_type { + PadType::NpThroughHole => continue, + PadType::ThroughHole => { + if pad.number.trim().is_empty() { + continue; + } + return Err("the part has through-hole pins (IPC-7251, not calculated)".into()); + } + _ => {} + } + let number = pad.number.trim(); + if number.is_empty() { + continue; + } + let (x, y) = (pad.pos_x * PX, -pad.pos_y * PX); + match groups.iter_mut().find(|g| g.0 == number) { + Some(g) => { + g.1 = g.1.min(x); + g.2 = g.2.min(y); + g.3 = g.3.max(x); + g.4 = g.4.max(y); + } + None => groups.push((number.to_string(), x, y, x, y)), + } + } + Ok(groups + .into_iter() + .map(|(n, x0, y0, x1, y1)| (n, (x0 + x1) / 2.0, (y0 + y1) / 2.0)) + .collect()) +} + +struct Alignment { + numbers: Vec, + turn: f64, + shift: (f64, f64), + pin1_ipc: bool, +} + +fn bbox_centre(points: impl Iterator) -> (f64, f64) { + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for (x, y) in points { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + ((x0 + x1) / 2.0, (y0 + y1) / 2.0) +} + +fn turn_point(q: usize, x: f64, y: f64) -> (f64, f64) { + match q % 4 { + 0 => (x, y), + 1 => (-y, x), + 2 => (-x, -y), + _ => (y, -x), + } +} + +/// IPC-7352 polarity marking on the silkscreen: a bar beside the cathode of a +/// polarized two-terminal part, a 0.3 mm dot beside pin 1 of anything else +/// with more pins. +fn polarity_mark(package: &Package, lands: &[Land]) -> (Vec, Vec) { + let to_px = |mm: f64| mm / PX; + let (mut lines, mut circles) = (Vec::new(), Vec::new()); + let Some(first) = lands.first() else { + return (lines, circles); + }; + let gap = SILK_TO_COPPER + SILK_WIDTH / 2.0; + if package.layout == Layout::TwoTerminal { + if package.polarized { + let x = first.left() - gap; + lines.push(KiLine { + start_x: to_px(x), + start_y: to_px(-first.bottom()), + end_x: to_px(x), + end_y: to_px(-first.top()), + width: to_px(SILK_WIDTH), + layer: "F.SilkS".into(), + }); + } + } else { + let r = 0.15; + let (cx, cy) = (first.left() - SILK_TO_COPPER - r, first.y); + circles.push(KiCircle { + center_x: to_px(cx), + center_y: to_px(-cy), + end_x: to_px(cx + r), + end_y: to_px(-cy), + // The converter stores circle widths in mm. + width: SILK_WIDTH, + layer: "F.SilkS".into(), + fill: true, + }); + } + (lines, circles) +} + +/// Find the quarter turn that puts EasyEDA's pads on the calculated ones. +fn align( + ours: &[(f64, f64)], + theirs: &[(String, f64, f64)], + first: &str, +) -> Result { + if ours.len() != theirs.len() { + return Err(format!( + "EasyEDA's footprint has {} numbered pads, the IPC-7352 pattern {}", + theirs.len(), + ours.len() + )); + } + let c_ours = bbox_centre(ours.iter().copied()); + let c_theirs = bbox_centre(theirs.iter().map(|p| (p.1, p.2))); + let mut fits: Vec = Vec::new(); + for q in 0..4 { + let (tcx, tcy) = turn_point(q, c_theirs.0, c_theirs.1); + let shift = (c_ours.0 - tcx, c_ours.1 - tcy); + let mut numbers = vec![String::new(); ours.len()]; + let mut ok = true; + for (number, x, y) in theirs { + let (rx, ry) = turn_point(q, *x, *y); + let (px, py) = (rx + shift.0, ry + shift.1); + let nearest = ours + .iter() + .enumerate() + .map(|(i, o)| (i, ((o.0 - px).powi(2) + (o.1 - py).powi(2)).sqrt())) + .min_by(|a, b| a.1.total_cmp(&b.1)); + match nearest { + Some((i, d)) if d <= MATCH_TOLERANCE && numbers[i].is_empty() => { + numbers[i] = number.clone(); + } + _ => { + ok = false; + break; + } + } + } + if ok { + fits.push(Alignment { + pin1_ipc: numbers.first().is_some_and(|n| n == first), + numbers, + turn: q as f64 * 90.0, + shift: (round4(shift.0), round4(shift.1)), + }); + } + } + if fits.is_empty() { + return Err( + "EasyEDA's pads do not line up with the IPC-7352 pattern (different package?)".into(), + ); + } + let best = fits.iter().position(|f| f.pin1_ipc).unwrap_or(0); + Ok(fits.swap_remove(best)) +} + +fn round4(v: f64) -> f64 { + (v * 10_000.0).round() / 10_000.0 + 0.0 +} + +/// Lands, silkscreen, assembly outline and courtyard as a converter footprint. +fn build_footprint( + name: &str, + package: &Package, + lands: &[Land], + numbers: &[String], +) -> KiFootprint { + let to_px = |mm: f64| mm / PX; + let mut fp = KiFootprint { + name: name.to_string(), + pads: Vec::new(), + tracks: Vec::new(), + circles: Vec::new(), + arcs: Vec::new(), + texts: Vec::new(), + lines: Vec::new(), + model_3d: None, + }; + for (land, number) in lands.iter().zip(numbers) { + fp.pads.push(KiPad { + number: number.clone(), + pad_type: PadType::Smd, + shape: PadShape::Rect, + pos_x: to_px(land.x), + pos_y: to_px(-land.y), + size_x: to_px(land.w), + size_y: to_px(land.h), + rotation: 0.0, + layers: vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()], + drill: None, + polygon: None, + }); + } + let mut lines: Vec = Vec::new(); + let mut line = |layer: &str, width: f64, a: (f64, f64), b: (f64, f64)| { + lines.push(KiLine { + start_x: to_px(a.0), + start_y: to_px(-a.1), + end_x: to_px(b.0), + end_y: to_px(-b.1), + width: to_px(width), + layer: layer.to_string(), + }); + }; + + let (bx, by) = (package.body.0 / 2.0, package.body.1 / 2.0); + let two_terminal = package.layout == Layout::TwoTerminal; + + // Assembly: the body, pin 1 corner chamfered; a cathode band on diodes. + if two_terminal { + rect_lines(&mut line, "F.Fab", ASSEMBLY_WIDTH, -bx, -by, bx, by); + if package.polarized { + let band = -bx + package.body.0 * 0.2; + line("F.Fab", ASSEMBLY_WIDTH, (band, -by), (band, by)); + } + } else { + let chamfer = (package.body.0.min(package.body.1) * 0.25).min(1.0); + let corners = [ + (-bx + chamfer, by), + (bx, by), + (bx, -by), + (-bx, -by), + (-bx, by - chamfer), + (-bx + chamfer, by), + ]; + for pair in corners.windows(2) { + line("F.Fab", ASSEMBLY_WIDTH, pair[0], pair[1]); + } + } + + // Silkscreen: the body outline, kept clear of the copper. + let keep_out: Vec<(f64, f64, f64, f64)> = lands + .iter() + .map(|l| { + let m = SILK_TO_COPPER + SILK_WIDTH / 2.0; + (l.left() - m, l.bottom() - m, l.right() + m, l.top() + m) + }) + .collect(); + let sx = bx + SILK_WIDTH / 2.0; + let sy = by + SILK_WIDTH / 2.0; + let outline = [ + ((-sx, sy), (sx, sy)), + ((sx, sy), (sx, -sy)), + ((sx, -sy), (-sx, -sy)), + ((-sx, -sy), (-sx, sy)), + ]; + let mut outline_drawn = false; + for (a, b) in outline { + for (p, q) in clip_segment(a, b, &keep_out) { + line("F.SilkS", SILK_WIDTH, p, q); + outline_drawn = true; + } + } + // Small chips (0402, 0603, ...) leave no room for the body outline between + // the lands. The part still gets a mark on the board: the body's two long + // sides, as long as the body, moved out just far enough to clear the copper. + let (lx0, mut ly0, lx1, mut ly1) = (-bx, -sy, bx, sy); + if !outline_drawn { + for l in lands { + ly0 = ly0.min(l.bottom() - SILK_TO_COPPER - SILK_WIDTH / 2.0); + ly1 = ly1.max(l.top() + SILK_TO_COPPER + SILK_WIDTH / 2.0); + } + line("F.SilkS", SILK_WIDTH, (lx0, ly1), (lx1, ly1)); + line("F.SilkS", SILK_WIDTH, (lx0, ly0), (lx1, ly0)); + } + // Pin 1 mark: a bar beside a diode's cathode, a dot beside pin 1 otherwise. + let (mark_lines, mark_circles) = polarity_mark(package, lands); + for l in &mark_lines { + let (a, b) = ( + (l.start_x * PX, -l.start_y * PX), + (l.end_x * PX, -l.end_y * PX), + ); + line("F.SilkS", SILK_WIDTH, a, b); + } + fp.circles.extend(mark_circles); + + // Courtyard: lands and body plus the courtyard excess, on the courtyard grid, + // and never inside the silkscreen drawn outside the lands. + let (mut x0, mut y0, mut x1, mut y1) = (-bx, -by, bx, by); + for l in lands { + x0 = x0.min(l.left()); + y0 = y0.min(l.bottom()); + x1 = x1.max(l.right()); + y1 = y1.max(l.top()); + } + let e = package.goals.courtyard; + let (x0, y0) = ( + round_down(x0 - e, COURTYARD_GRID), + round_down(y0 - e, COURTYARD_GRID), + ); + let (x1, y1) = ( + round_up(x1 + e, COURTYARD_GRID), + round_up(y1 + e, COURTYARD_GRID), + ); + let silk = SILK_WIDTH / 2.0; + let (x0, y0) = ( + x0.min(round_down(lx0 - silk, COURTYARD_GRID)), + y0.min(round_down(ly0 - silk, COURTYARD_GRID)), + ); + let (x1, y1) = ( + x1.max(round_up(lx1 + silk, COURTYARD_GRID)), + y1.max(round_up(ly1 + silk, COURTYARD_GRID)), + ); + rect_lines(&mut line, "F.CrtYd", COURTYARD_WIDTH, x0, y0, x1, y1); + fp.lines = lines; + fp +} + +fn rect_lines( + line: &mut impl FnMut(&str, f64, (f64, f64), (f64, f64)), + layer: &str, + width: f64, + x0: f64, + y0: f64, + x1: f64, + y1: f64, +) { + line(layer, width, (x0, y1), (x1, y1)); + line(layer, width, (x1, y1), (x1, y0)); + line(layer, width, (x1, y0), (x0, y0)); + line(layer, width, (x0, y0), (x0, y1)); +} + +/// Parts of the axis-aligned segment `a`-`b` outside every keep-out box, dropping +/// pieces shorter than 0.2 mm. +fn clip_segment( + a: (f64, f64), + b: (f64, f64), + keep_out: &[(f64, f64, f64, f64)], +) -> Vec<((f64, f64), (f64, f64))> { + let horizontal = (a.1 - b.1).abs() < 1e-9; + let (fixed, mut lo, mut hi) = if horizontal { + (a.1, a.0.min(b.0), a.0.max(b.0)) + } else { + (a.0, a.1.min(b.1), a.1.max(b.1)) + }; + if lo > hi { + std::mem::swap(&mut lo, &mut hi); + } + let mut pieces = vec![(lo, hi)]; + for &(x0, y0, x1, y1) in keep_out { + let (f0, f1, c0, c1) = if horizontal { + (y0, y1, x0, x1) + } else { + (x0, x1, y0, y1) + }; + if fixed <= f0 || fixed >= f1 { + continue; + } + pieces = pieces + .into_iter() + .flat_map(|(s, e)| { + let mut out = Vec::new(); + if c0 > s { + out.push((s, e.min(c0))); + } + if c1 < e { + out.push((s.max(c1), e)); + } + out + }) + .filter(|(s, e)| e > s) + .collect(); + } + pieces + .into_iter() + .filter(|(s, e)| e - s >= 0.2) + .map(|(s, e)| { + if horizontal { + ((s, fixed), (e, fixed)) + } else { + ((fixed, s), (fixed, e)) + } + }) + .collect() +} + +/// IPC-7352 rules for a land pattern seex cannot calculate (connectors, +/// through-hole and other non-standard parts): the manufacturer's lands from +/// EasyEDA stay, and the rest is brought in line: a courtyard of lands and body +/// plus 0.25 mm on the 0.05 mm grid, 0.12 mm silkscreen kept 0.2 mm off the +/// copper, an assembly outline, and plated holes with at least a 0.25 mm annular +/// ring. Returns the footprint and what was changed. +pub fn conform(easyeda: &KiFootprint) -> (KiFootprint, Vec) { + let mut fp = easyeda.clone(); + let mut changes = Vec::new(); + let mm = |px: f64| px * PX; + let to_px = |v: f64| v / PX; + + // Plated holes: at least a 0.25 mm annular ring, if the neighbours allow it. + const RING: f64 = 0.25; + let boxes: Vec<(f64, f64, f64, f64)> = fp.pads.iter().map(pad_box).collect(); + let mut widened = 0; + for i in 0..fp.pads.len() { + let pad = &fp.pads[i]; + if pad.pad_type != PadType::ThroughHole { + continue; + } + let Some(drill) = &pad.drill else { continue }; + // The ring is checked along each axis of the pad, so a slot in an oval + // pad keeps the oval: only the side that is too thin grows. + let hole_x = mm(drill.diameter); + let hole_y = mm(drill.width.unwrap_or(drill.diameter)); + let need_x = round_up(hole_x + 2.0 * RING, 0.05); + let need_y = round_up(hole_y + 2.0 * RING, 0.05); + let (w, h) = (mm(pad.size_x), mm(pad.size_y)); + let grow_x = (need_x - w).max(0.0); + let grow_y = (need_y - h).max(0.0); + if grow_x < 1e-6 && grow_y < 1e-6 { + continue; + } + // Pad rotated by 90 or 270 degrees: its x side lies along the board's y. + let quarter = ((pad.rotation.rem_euclid(180.0)) - 90.0).abs() < 45.0; + let (gx, gy) = if quarter { + (grow_y, grow_x) + } else { + (grow_x, grow_y) + }; + let (x0, y0, x1, y1) = boxes[i]; + let grown = (x0 - gx / 2.0, y0 - gy / 2.0, x1 + gx / 2.0, y1 + gy / 2.0); + let clear = boxes + .iter() + .enumerate() + .all(|(j, b)| j == i || gap_boxes(grown, *b) >= 0.2); + if !clear { + continue; + } + let pad = &mut fp.pads[i]; + pad.size_x = to_px(w + grow_x); + pad.size_y = to_px(h + grow_y); + widened += 1; + } + if widened > 0 { + changes.push(format!( + "{widened} plated pad(s) widened to a {RING:.2} mm annular ring" + )); + } + + // Silkscreen: 0.12 mm, clear of the copper. + let keep_out: Vec<(f64, f64, f64, f64)> = fp + .pads + .iter() + .map(|p| { + let (x0, y0, x1, y1) = pad_box(p); + let m = SILK_TO_COPPER + SILK_WIDTH / 2.0; + (x0 - m, y0 - m, x1 + m, y1 + m) + }) + .collect(); + let mut lines = Vec::new(); + let mut clipped = 0; + let mut outline: Vec<(f64, f64)> = Vec::new(); + for line in fp.lines.drain(..) { + let a = (mm(line.start_x), -mm(line.start_y)); + let b = (mm(line.end_x), -mm(line.end_y)); + match line.layer.as_str() { + "F.CrtYd" | "B.CrtYd" => {} + "F.SilkS" | "B.SilkS" => { + outline.extend([a, b]); + let pieces = clip_any(a, b, &keep_out); + if pieces.len() != 1 || pieces[0] != (a, b) { + clipped += 1; + } + for (p, q) in pieces { + lines.push(KiLine { + start_x: to_px(p.0), + start_y: to_px(-p.1), + end_x: to_px(q.0), + end_y: to_px(-q.1), + width: to_px(SILK_WIDTH), + layer: line.layer.clone(), + }); + } + } + _ => { + if line.layer.ends_with(".Fab") { + outline.extend([a, b]); + } + lines.push(line); + } + } + } + // Silkscreen arcs become clipped segments too. + for arc in std::mem::take(&mut fp.arcs) { + if !arc.layer.ends_with("SilkS") { + fp.arcs.push(arc); + continue; + } + let points = arc_points(&arc); + for pair in points.windows(2) { + let (a, b) = (pair[0], pair[1]); + outline.extend([a, b]); + for (p, q) in clip_any(a, b, &keep_out) { + lines.push(KiLine { + start_x: to_px(p.0), + start_y: to_px(-p.1), + end_x: to_px(q.0), + end_y: to_px(-q.1), + width: to_px(SILK_WIDTH), + layer: arc.layer.clone(), + }); + } + } + clipped += 1; + } + // Silkscreen circles: kept where they clear the copper. A circle drawn round + // a pad (a test point's ring) that the pad reaches into grows just enough to + // clear it, so the mark stays the manufacturer's. + let copper: Vec<(f64, f64, f64, f64)> = fp.pads.iter().map(pad_box).collect(); + let mut circles = Vec::new(); + for mut c in std::mem::take(&mut fp.circles) { + if !c.layer.ends_with("SilkS") { + circles.push(c); + continue; + } + let (cx, cy) = (mm(c.center_x), -mm(c.center_y)); + let r = ((mm(c.end_x) - mm(c.center_x)).powi(2) + (mm(c.end_y) - mm(c.center_y)).powi(2)) + .sqrt(); + // Circle widths are stored in mm by the converter. + let half = if c.fill { 0.0 } else { SILK_WIDTH / 2.0 }; + let clear = |r: f64| { + copper.iter().all(|b| { + let near = (cx - cx.clamp(b.0, b.2)).hypot(cy - cy.clamp(b.1, b.3)); + let far = (cx - b.0) + .abs() + .max((cx - b.2).abs()) + .hypot((cy - b.1).abs().max((cy - b.3).abs())); + near >= r + half + SILK_TO_COPPER - 1e-9 || r - half >= far + SILK_TO_COPPER - 1e-9 + }) + }; + if clear(r) { + if !c.fill { + c.width = SILK_WIDTH; + } + circles.push(c); + continue; + } + clipped += 1; + // Rings round the pads under their centre. + let around: Vec<&(f64, f64, f64, f64)> = boxes + .iter() + .filter(|b| cx > b.0 && cx < b.2 && cy > b.1 && cy < b.3) + .collect(); + if c.fill || around.is_empty() { + continue; + } + let needed = around + .iter() + .map(|b| { + (cx - b.0) + .abs() + .max((cx - b.2).abs()) + .hypot((cy - b.1).abs().max((cy - b.3).abs())) + }) + .fold(0.0, f64::max) + + SILK_TO_COPPER + + half; + if needed > r * 2.0 || !clear(needed) { + continue; + } + c.end_x = to_px(cx + needed); + c.end_y = to_px(-cy); + c.width = SILK_WIDTH; + circles.push(c); + } + fp.circles = circles; + if clipped > 0 { + changes.push(format!( + "silkscreen set to {SILK_WIDTH:.2} mm and kept {SILK_TO_COPPER:.2} mm off the copper" + )); + } + + // Body outline: EasyEDA's assembly or silkscreen drawing; else the lands. + let mut bx = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for &(x, y) in &outline { + bx = (bx.0.min(x), bx.1.min(y), bx.2.max(x), bx.3.max(y)); + } + let has_assembly = lines.iter().any(|l| l.layer == "F.Fab"); + if !has_assembly && bx.0 < bx.2 { + let corners = [ + (bx.0, bx.3), + (bx.2, bx.3), + (bx.2, bx.1), + (bx.0, bx.1), + (bx.0, bx.3), + ]; + for pair in corners.windows(2) { + lines.push(KiLine { + start_x: to_px(pair[0].0), + start_y: to_px(-pair[0].1), + end_x: to_px(pair[1].0), + end_y: to_px(-pair[1].1), + width: to_px(ASSEMBLY_WIDTH), + layer: "F.Fab".into(), + }); + } + changes.push("assembly outline added".into()); + } + + // A footprint without any silkscreen (test points, pads-only parts) gets an + // outline around its lands and body, clear of the copper, so the part is + // marked on the board. + let has_silk = lines.iter().any(|l| l.layer.ends_with("SilkS")) + || fp.circles.iter().any(|c| c.layer.ends_with("SilkS")); + let mut cy = bx; + if !has_silk && !keep_out.is_empty() { + let mut sb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for b in keep_out.iter().copied() { + sb = (sb.0.min(b.0), sb.1.min(b.1), sb.2.max(b.2), sb.3.max(b.3)); + } + if bx.0 < bx.2 { + let m = SILK_WIDTH / 2.0; + sb = ( + sb.0.min(bx.0 - m), + sb.1.min(bx.1 - m), + sb.2.max(bx.2 + m), + sb.3.max(bx.3 + m), + ); + } + // Just outside the keep-out boxes, which count their own edges as inside. + let sb = (sb.0 - 1e-6, sb.1 - 1e-6, sb.2 + 1e-6, sb.3 + 1e-6); + let corners = [ + (sb.0, sb.3), + (sb.2, sb.3), + (sb.2, sb.1), + (sb.0, sb.1), + (sb.0, sb.3), + ]; + for pair in corners.windows(2) { + for (p, q) in clip_any(pair[0], pair[1], &keep_out) { + lines.push(KiLine { + start_x: to_px(p.0), + start_y: to_px(-p.1), + end_x: to_px(q.0), + end_y: to_px(-q.1), + width: to_px(SILK_WIDTH), + layer: "F.SilkS".into(), + }); + } + } + let m = SILK_WIDTH / 2.0; + cy = (sb.0 - m, sb.1 - m, sb.2 + m, sb.3 + m); + if bx.0 < bx.2 { + cy = ( + cy.0.min(bx.0), + cy.1.min(bx.1), + cy.2.max(bx.2), + cy.3.max(bx.3), + ); + } + changes.push("silkscreen outline added around the lands".into()); + } + + // Courtyard: lands and body plus the courtyard excess, on the 0.05 mm grid, + // and round the silkscreen circles. + for b in &boxes { + cy = (cy.0.min(b.0), cy.1.min(b.1), cy.2.max(b.2), cy.3.max(b.3)); + } + for c in fp.circles.iter().filter(|c| c.layer.ends_with("SilkS")) { + let (x, y) = (mm(c.center_x), -mm(c.center_y)); + let r = (mm(c.end_x) - mm(c.center_x)).hypot(mm(c.end_y) - mm(c.center_y)) + c.width / 2.0; + cy = ( + cy.0.min(x - r), + cy.1.min(y - r), + cy.2.max(x + r), + cy.3.max(y + r), + ); + } + if cy.0 < cy.2 { + let e = GULL_WING.courtyard; + let (x0, y0) = ( + round_down(cy.0 - e, COURTYARD_GRID), + round_down(cy.1 - e, COURTYARD_GRID), + ); + let (x1, y1) = ( + round_up(cy.2 + e, COURTYARD_GRID), + round_up(cy.3 + e, COURTYARD_GRID), + ); + let corners = [(x0, y1), (x1, y1), (x1, y0), (x0, y0), (x0, y1)]; + for pair in corners.windows(2) { + lines.push(KiLine { + start_x: to_px(pair[0].0), + start_y: to_px(-pair[0].1), + end_x: to_px(pair[1].0), + end_y: to_px(-pair[1].1), + width: to_px(COURTYARD_WIDTH), + layer: "F.CrtYd".into(), + }); + } + changes.push("courtyard: lands and body + 0.25 mm on the 0.05 mm grid".into()); + } + fp.lines = lines; + (fp, changes) +} + +/// Copper extent of a pad (mm, Y up), any rotation. +fn pad_box(pad: &KiPad) -> (f64, f64, f64, f64) { + let (x, y) = (pad.pos_x * PX, -pad.pos_y * PX); + let (w, h) = (pad.size_x * PX / 2.0, pad.size_y * PX / 2.0); + let (s, c) = pad.rotation.to_radians().sin_cos(); + let (ex, ey) = ((w * c).abs() + (h * s).abs(), (w * s).abs() + (h * c).abs()); + let mut b = (x - ex, y - ey, x + ex, y + ey); + if let Some(points) = pad.polygon.as_deref() { + for (px, py) in parse_points(points) { + b = ( + b.0.min(x + px), + b.1.min(y + py), + b.2.max(x + px), + b.3.max(y + py), + ); + } + } + b +} + +/// Pad-relative polygon points (mm, Y up) from KiCad's `(xy x y)` list. +fn parse_points(text: &str) -> Vec<(f64, f64)> { + let mut out = Vec::new(); + for chunk in text.split("(xy").skip(1) { + let nums: Vec = chunk + .split(|c: char| c.is_whitespace() || c == ')') + .filter_map(|t| t.parse().ok()) + .collect(); + if let [x, y, ..] = nums.as_slice() { + out.push((*x, -*y)); + } + } + out +} + +fn gap_boxes(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)) -> f64 { + let dx = (a.0 - b.2).max(b.0 - a.2).max(0.0); + let dy = (a.1 - b.3).max(b.1 - a.3).max(0.0); + (dx * dx + dy * dy).sqrt() +} + +/// Points along a footprint arc (mm, Y up). +fn arc_points(arc: &crate::kicad::FootprintKiArc) -> Vec<(f64, f64)> { + let p = |x: f64, y: f64| (x * PX, -y * PX); + let (a, m, b) = ( + p(arc.start_x, arc.start_y), + p(arc.mid_x, arc.mid_y), + p(arc.end_x, arc.end_y), + ); + let d = 2.0 * (a.0 * (m.1 - b.1) + m.0 * (b.1 - a.1) + b.0 * (a.1 - m.1)); + if d.abs() < 1e-12 { + return vec![a, m, b]; + } + let sq = |q: (f64, f64)| q.0 * q.0 + q.1 * q.1; + let ux = (sq(a) * (m.1 - b.1) + sq(m) * (b.1 - a.1) + sq(b) * (a.1 - m.1)) / d; + let uy = (sq(a) * (b.0 - m.0) + sq(m) * (a.0 - b.0) + sq(b) * (m.0 - a.0)) / d; + let r = ((a.0 - ux).powi(2) + (a.1 - uy).powi(2)).sqrt(); + let ang = |q: (f64, f64)| (q.1 - uy).atan2(q.0 - ux); + let (a0, am, a1) = (ang(a), ang(m), ang(b)); + let tau = std::f64::consts::TAU; + let ccw = (am - a0).rem_euclid(tau) < (a1 - a0).rem_euclid(tau); + let sweep = if ccw { + (a1 - a0).rem_euclid(tau) + } else { + -(a0 - a1).rem_euclid(tau) + }; + (0..=16) + .map(|i| { + let t = a0 + sweep * i as f64 / 16.0; + (ux + r * t.cos(), uy + r * t.sin()) + }) + .collect() +} + +/// Parts of the segment `a`-`b` outside every box (any direction), dropping +/// pieces shorter than 0.1 mm. +fn clip_any( + a: (f64, f64), + b: (f64, f64), + keep_out: &[(f64, f64, f64, f64)], +) -> Vec<((f64, f64), (f64, f64))> { + let mut pieces = vec![(0.0f64, 1.0f64)]; + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + for &(x0, y0, x1, y1) in keep_out { + // Liang-Barsky: the parameter interval of the segment inside the box. + let mut t0: f64 = 0.0; + let mut t1: f64 = 1.0; + let mut inside = true; + for (p, q) in [ + (-dx, a.0 - x0), + (dx, x1 - a.0), + (-dy, a.1 - y0), + (dy, y1 - a.1), + ] { + if p.abs() < 1e-12 { + if q < 0.0 { + inside = false; + break; + } + } else { + let r = q / p; + if p < 0.0 { + t0 = t0.max(r); + } else { + t1 = t1.min(r); + } + } + } + if !inside || t0 >= t1 { + continue; + } + pieces = pieces + .into_iter() + .flat_map(|(s, e)| { + let mut out = Vec::new(); + if t0 > s { + out.push((s, e.min(t0))); + } + if t1 < e { + out.push((s.max(t1), e)); + } + out + }) + .filter(|(s, e)| e > s) + .collect(); + } + let len = (dx * dx + dy * dy).sqrt(); + pieces + .into_iter() + .filter(|(s, e)| (e - s) * len >= 0.1) + .map(|(s, e)| ((a.0 + dx * s, a.1 + dy * s), (a.0 + dx * e, a.1 + dy * e))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pad(number: &str, x_mm: f64, y_mm: f64) -> KiPad { + KiPad { + number: number.into(), + pad_type: PadType::Smd, + shape: PadShape::Rect, + pos_x: x_mm / PX, + pos_y: -y_mm / PX, + size_x: 0.5 / PX, + size_y: 0.5 / PX, + rotation: 0.0, + layers: vec!["F.Cu".into()], + drill: None, + polygon: None, + } + } + + fn footprint(pads: Vec) -> KiFootprint { + KiFootprint { + name: "t".into(), + pads, + tracks: vec![], + circles: vec![], + arcs: vec![], + texts: vec![], + lines: vec![], + model_3d: None, + } + } + + #[test] + fn names_follow_the_ipc_convention() { + let name = |pkg: &str, r: &str| package_from_name(pkg, r).unwrap().ipc_name(); + assert_eq!(name("R0402", "R"), "RESC1005X40N"); + assert_eq!(name("C0603", "C"), "CAPC1608X90N"); + assert_eq!(name("LED0603-RD", "LED"), "LEDC1608X80N"); + assert_eq!(name("LED-SMD_L1.6-W0.8-R-RD", "LED"), "LEDC1608X80N"); + assert_eq!( + name("SOT-23-5_L2.9-W1.6-P0.95-LS2.8-BR", "U"), + "SOT95P280X145-5N" + ); + // Narrow (TO-236) and wide (MO-178) three-lead SOT-23. + assert_eq!( + name("SOT-23-3_L2.9-W1.3-P1.90-LS2.4-BR", "Q"), + "SOT95P237X112-3N" + ); + assert_eq!( + name("SOT-23-3_L2.9-W1.6-P1.90-LS2.8-BR", "U"), + "SOT95P280X145-3N" + ); + assert_eq!( + name("SOT-23-3_L2.9-W1.3-P1.90-LS2.4-BR", "Q"), + "SOT95P237X112-3N" + ); + assert_eq!( + name("SOT-323_L2.0-W1.3-P1.30-LS2.1-BR", "Q"), + "SOT65P210X110-3N" + ); + assert_eq!(name("SOD-323_L1.8-W1.3-LS2.5-RD", "D"), "SOD2512X110N"); + assert_eq!(name("SOD-523_L1.2-W0.8-LS1.6-RD", "D"), "SODFL1608X70N"); + // SOD-523 flat leads: Z 2.15 as in NXP's footprint, lands 0.55 x 0.40. + let p = package_from_name("SOD-523_L1.2-W0.8-LS1.6-RD", "D").unwrap(); + let l = lands(p.span, p.terminal, p.width, &p.goals); + assert_eq!((round2(l.z), round2(l.g), round2(l.x)), (2.15, 1.05, 0.40)); + assert_eq!( + name("SOIC-8_L4.9-W3.9-P1.27-LS6.0-BL", "U"), + "SOIC127P600X175-8N" + ); + assert_eq!( + name("TSSOP-20_L6.5-W4.4-P0.65-LS6.4-BL", "U"), + "SOP65P640X120-20N" + ); + assert_eq!( + name("SOT-223-3_L6.5-W3.4-P2.30-LS7.0-BR", "U"), + "SOT230P700X180-4N" + ); + // EasyEDA's SOIC-8 variants share one pattern. + assert_eq!( + name("SOP-8_L5.0-W4.0-P1.27-LS6.0-BL", "U"), + "SOIC127P600X175-8N" + ); + assert_eq!( + name("SO-8_L4.9-W3.9-P1.27-LS6.0-BL", "U"), + "SOIC127P600X175-8N" + ); + assert_eq!( + package_from_name("SOP-8_L5.0-W4.0-P1.27-LS6.0-BL", "U").unwrap(), + package_from_name("SOIC-8_L4.9-W3.9-P1.27-LS6.1-BL", "U").unwrap() + ); + assert_eq!(name("SMA_L4.3-W2.6-LS5.2-RD", "D"), "DIOM5226X244N"); + assert_eq!(name("SMC_L6.9-W5.9-LS7.9-BI", "D"), "DIOM7959X262N"); + assert_eq!( + name("ESOP-8_L4.9-W3.9-P1.27-LS6.0-BL-EP3.3X2.4", "U"), + "SOIC127P600X175-9N" + ); + assert_eq!( + name("LQFP-48_L7.0-W7.0-P0.50-LS9.0-BL", "U"), + "QFP50P900X900X160-48N" + ); + assert_eq!( + name("QFN-56_L7.0-W7.0-P0.40-BL-EP4.0", "U"), + "QFN40P700X700X100-57N" + ); + // ST's UFQFPN is a QFN. + assert_eq!( + name("UFQFPN-28_L4.0-W4.0-P0.50-BL", "U"), + "QFN50P400X400X60-28N" + ); + assert!(package_from_name("USB-C-SMD_TYPE-C-31-M-12", "USBC").is_err()); + } + + #[test] + fn chip_lands_follow_the_equations() { + let p = package_from_name("R0603", "R").unwrap(); + let row = lands(p.span, p.terminal, p.width, &p.goals); + // Zmax = 1.50 + 0.70 + sqrt(0.2^2 + 0.05^2 + 0.025^2) = 2.408 -> 2.45 + assert!((row.z - 2.45).abs() < 1e-9, "{row:?}"); + // Xmax = 0.70 + sqrt(0.2^2 + 0.05^2 + 0.025^2) = 0.908 -> 0.95 + assert!((row.x - 0.95).abs() < 1e-9, "{row:?}"); + assert!(row.g > 0.0 && row.g < 1.0, "{row:?}"); + let small = package_from_name("R0402", "R").unwrap(); + assert_eq!(small.goals, CHIP_SMALL); + } + + #[test] + fn sot23_5_is_turned_to_pin_one_upper_left() { + // EasyEDA draws SOT-23-5 with pins 1-3 along the bottom. + let easy = footprint(vec![ + pad("1", -0.95, -1.15), + pad("2", 0.0, -1.15), + pad("3", 0.95, -1.15), + pad("4", 0.95, 1.15), + pad("5", -0.95, 1.15), + ]); + let lp = land_pattern("SOT-23-5_L2.9-W1.6-P0.95-LS2.8-BR", "U", &easy, "1").unwrap(); + assert_eq!(lp.name, "SOT95P280X145-5N"); + let first = &lp.footprint.pads[0]; + assert_eq!(first.number, "1"); + assert!( + first.pos_x < 0.0 && first.pos_y < 0.0, + "pin 1 upper left (Y down)" + ); + let numbers: Vec<&str> = lp + .footprint + .pads + .iter() + .map(|p| p.number.as_str()) + .collect(); + assert_eq!(numbers, ["1", "2", "3", "4", "5"]); + assert!( + lp.notes + .iter() + .all(|n| n.starts_with("IPC-7352 lands checked")), + "{:?}", + lp.notes + ); + // EasyEDA's pin 1 (bottom left) turns to the upper left: a quarter turn clockwise. + assert_eq!(lp.turn, 270.0); + assert!(lp.footprint.lines.iter().any(|l| l.layer == "F.CrtYd")); + } + + #[test] + fn led_cathode_goes_left() { + // Pin 2 is the cathode and EasyEDA has it on the left already. + let easy = footprint(vec![pad("1", 0.8, 0.0), pad("2", -0.8, 0.0)]); + let lp = land_pattern("LED0603-RD", "LED", &easy, "2").unwrap(); + assert_eq!(lp.footprint.pads[0].number, "2"); + assert!(lp.footprint.pads[0].pos_x < 0.0); + assert_eq!(lp.turn, 0.0); + } + + #[test] + fn small_chips_are_marked_on_the_silkscreen() { + // 0402 and 0603 leave no room between the lands: a line above and one + // below the lands, clear of the copper and inside the courtyard. + for (package, x, half_body) in [("R0402", 0.48, 0.5), ("C0603", 0.78, 0.8)] { + let easy = footprint(vec![pad("1", -x, 0.0), pad("2", x, 0.0)]); + let reference = &package[..1]; + let lp = land_pattern(package, reference, &easy, "1").unwrap(); + let silk: Vec<&KiLine> = lp + .footprint + .lines + .iter() + .filter(|l| l.layer == "F.SilkS") + .collect(); + assert_eq!(silk.len(), 2, "{package}"); + // As long as the body, not the land pattern. + for l in &silk { + assert!((l.start_x * PX).abs() <= half_body + 1e-9, "{package}"); + assert!((l.end_x * PX).abs() <= half_body + 1e-9, "{package}"); + } + let lands: Vec<(f64, f64, f64, f64)> = lp.footprint.pads.iter().map(pad_box).collect(); + let top = lands.iter().map(|b| b.3).fold(f64::MIN, f64::max); + for l in &silk { + let y = (-l.start_y * PX).abs(); + assert!( + y - SILK_WIDTH / 2.0 >= top + SILK_TO_COPPER - 1e-9, + "{package}: {y}" + ); + } + let courtyard = lp + .footprint + .lines + .iter() + .filter(|l| l.layer == "F.CrtYd") + .map(|l| (-l.start_y * PX).abs()) + .fold(f64::MIN, f64::max); + let silk_top = (-silk[0].start_y * PX).abs() + SILK_WIDTH / 2.0; + assert!(courtyard >= silk_top - 1e-9, "{package}"); + } + } + + #[test] + fn test_point_ring_grows_round_its_pad() { + // C2826547: a 2.2 mm square pad with a silkscreen ring inside its corners. + let mut lug = pad("1", 0.0, 0.0); + lug.size_x = 2.2 / PX; + lug.size_y = 2.2 / PX; + let mut easy = footprint(vec![lug]); + easy.circles.push(KiCircle { + center_x: 0.0, + center_y: 0.0, + end_x: 1.3 / PX, + end_y: 0.0, + width: 0.15, + layer: "F.SilkS".into(), + fill: false, + }); + let (fp, _) = conform(&easy); + let rings: Vec<&KiCircle> = fp.circles.iter().filter(|c| c.layer == "F.SilkS").collect(); + assert_eq!(rings.len(), 1); + let r = rings[0].end_x * PX; + assert!( + (r - (1.1f64.hypot(1.1) + SILK_TO_COPPER + SILK_WIDTH / 2.0)).abs() < 1e-6, + "{r}" + ); + // The ring is the mark: no outline is added round the pad. + assert!(!fp.lines.iter().any(|l| l.layer == "F.SilkS")); + } + + #[test] + fn pads_only_footprints_get_a_silkscreen_outline() { + let mut lug = pad("1", 0.0, 0.0); + lug.size_x = 3.0 / PX; + lug.size_y = 1.8 / PX; + let (fp, changes) = conform(&footprint(vec![lug])); + assert!( + changes.iter().any(|c| c.contains("silkscreen outline")), + "{changes:?}" + ); + let silk: Vec<&KiLine> = fp.lines.iter().filter(|l| l.layer == "F.SilkS").collect(); + assert_eq!(silk.len(), 4); + // 1.5 mm half width + 0.20 mm clearance + half the 0.12 mm line. + let x = silk + .iter() + .map(|l| (l.start_x * PX).abs()) + .fold(f64::MIN, f64::max); + assert!((x - 1.76).abs() < 1e-3, "{x}"); + } + + #[test] + fn qfn_exposed_pad_keeps_its_number_and_clearance() { + let p = package_from_name("QFN-16_L3.0-W3.0-P0.50-BL-EP1.7", "U").unwrap(); + let (lands, row) = layout_lands(&p); + assert_eq!(lands.len(), 17); + assert!(row.g / 2.0 >= 1.7 / 2.0 + EP_TO_PAD - 1e-9); + let mut pads: Vec = lands[..16] + .iter() + .enumerate() + .map(|(i, l)| { + let mut p = pad(&(i + 1).to_string(), l.x, l.y); + p.size_x = l.w / PX; + p.size_y = l.h / PX; + p + }) + .collect(); + // EasyEDA's exposed pad split into four paste windows with one number. + for (dx, dy) in [(-0.4, -0.4), (0.4, -0.4), (-0.4, 0.4), (0.4, 0.4)] { + pads.push(pad("17", dx, dy)); + } + let lp = land_pattern( + "QFN-16_L3.0-W3.0-P0.50-BL-EP1.7", + "U", + &footprint(pads), + "1", + ) + .unwrap(); + assert_eq!(lp.footprint.pads.len(), 17); + assert_eq!(lp.footprint.pads[16].number, "17"); + assert_eq!(lp.name, "QFN50P300X300X100-17N"); + } + + #[test] + fn two_terminal_lands_cover_the_lead() { + // Every two-terminal family but the molded bodies gets lands at least as + // wide as the widest lead (the SOD-523 lands once came out narrower). + for (name, r) in [ + ("R0201", "R"), + ("R0402", "R"), + ("R0603", "R"), + ("R0805", "R"), + ("R1206", "R"), + ("C0402", "C"), + ("C0603", "C"), + ("LED0603-RD", "LED"), + ("SOD-123", "D"), + ("SOD-323", "D"), + ("SOD-523", "D"), + ("SOD-923", "D"), + ] { + let p = package_from_name(name, r).unwrap(); + let row = lands(p.span, p.terminal, p.width, &p.goals); + assert!( + row.x + 1e-9 >= p.width.max, + "{name}: {row:?} for lead {:?}", + p.width + ); + } + } + + #[test] + fn parts_off_the_jedec_outline_are_refused() { + // A SOD-523 named with a 2.2 mm lead span is not the JEDEC part. + let easy = footprint(vec![pad("1", -0.8, 0.0), pad("2", 0.8, 0.0)]); + let err = land_pattern("SOD-523_L1.2-W0.8-LS2.2-RD", "D", &easy, "1").unwrap_err(); + assert!(err.contains("lead span"), "{err}"); + // Manufacturer lands far outside the calculated ones: a different package. + let wide = footprint(vec![pad("1", -1.3, 0.0), pad("2", 1.3, 0.0)]); + let err = land_pattern("SOD-523_L1.2-W0.8-LS1.6-RD", "D", &wide, "1").unwrap_err(); + assert!(err.contains("manufacturer's land"), "{err}"); + // Manufacturer's lands much wider than the calculated ones: the part's leads + // are wider than the outline assumes. + let mut fat = footprint(vec![pad("1", -0.8, 0.0), pad("2", 0.8, 0.0)]); + for p in &mut fat.pads { + p.size_y = 0.9 / PX; + } + let err = land_pattern("SOD-523_L1.2-W0.8-LS1.6-RD", "D", &fat, "1").unwrap_err(); + assert!(err.contains("wide"), "{err}"); + // The goals used before for SOD-523 (QFN, side -0.04 mm) gave lands narrower + // than the lead: refused. + let mut p = package_from_name("SOD-523_L1.2-W0.8-LS1.6-RD", "D").unwrap(); + p.goals = NO_LEAD; + let row = lands(p.span, p.terminal, p.width, &p.goals); + assert!(row.x < p.width.max, "{row:?}"); + // The real part: lands on the manufacturer's. + let lp = land_pattern("SOD-523_L1.2-W0.8-LS1.6-RD", "D", &easy, "1").unwrap(); + assert_eq!(lp.name, "SODFL1608X70N"); + } + + #[test] + fn mismatched_footprints_are_refused() { + let easy = footprint(vec![ + pad("1", -1.0, 0.0), + pad("2", 1.0, 0.0), + pad("3", 0.0, 1.0), + ]); + assert!(land_pattern("R0603", "R", &easy, "1").is_err()); + } + + #[test] + fn conform_adds_courtyard_and_clears_silkscreen() { + let mut through = pad("1", -2.54, 0.0); + through.pad_type = PadType::ThroughHole; + through.size_x = 1.0 / PX; + through.size_y = 1.0 / PX; + through.drill = Some(crate::kicad::Drill { + diameter: 0.8 / PX, + width: None, + offset_x: 0.0, + offset_y: 0.0, + }); + let mut easy = footprint(vec![through, pad("2", 2.54, 0.0)]); + easy.lines.push(KiLine { + start_x: -4.0 / PX, + start_y: 0.0, + end_x: 4.0 / PX, + end_y: 0.0, + width: 0.25 / PX, + layer: "F.SilkS".into(), + }); + let (fp, changes) = conform(&easy); + assert!(changes.iter().any(|c| c.contains("annular")), "{changes:?}"); + assert!((fp.pads[0].size_x * PX - 1.30).abs() < 1e-9); + let silk: Vec<&KiLine> = fp.lines.iter().filter(|l| l.layer == "F.SilkS").collect(); + assert_eq!(silk.len(), 3, "split around both pads"); + assert!( + silk.iter() + .all(|l| (l.width * PX - SILK_WIDTH).abs() < 1e-9) + ); + assert_eq!(fp.lines.iter().filter(|l| l.layer == "F.CrtYd").count(), 4); + assert_eq!(fp.lines.iter().filter(|l| l.layer == "F.Fab").count(), 4); + // Diagonal silkscreen is clipped too. + assert_eq!( + clip_any((-1.0, -1.0), (1.0, 1.0), &[(-0.5, -0.5, 0.5, 0.5)]).len(), + 2 + ); + } + + #[test] + fn slotted_oval_pads_stay_oval() { + // USB-C shell lug: 1.0 x 2.1 mm oval pad, 0.6 x 1.7 mm slot. + let lug = |rotation: f64| { + let mut p = pad("1", 0.0, 0.0); + p.pad_type = PadType::ThroughHole; + p.shape = PadShape::Oval; + p.size_x = 1.0 / PX; + p.size_y = 2.1 / PX; + p.rotation = rotation; + p.drill = Some(crate::kicad::Drill { + diameter: 0.6 / PX, + width: Some(1.7 / PX), + offset_x: 0.0, + offset_y: 0.0, + }); + p + }; + for rotation in [0.0, 90.0] { + let (fp, changes) = conform(&footprint(vec![lug(rotation)])); + assert!(changes.iter().any(|c| c.contains("annular")), "{changes:?}"); + assert!( + (fp.pads[0].size_x * PX - 1.10).abs() < 1e-9, + "{}", + fp.pads[0].size_x * PX + ); + assert!( + (fp.pads[0].size_y * PX - 2.20).abs() < 1e-9, + "{}", + fp.pads[0].size_y * PX + ); + assert_eq!(fp.pads[0].shape, PadShape::Oval); + } + // A slot that already has its ring is left alone. + let mut ok = lug(0.0); + ok.size_x = 1.2 / PX; + ok.size_y = 2.3 / PX; + let (fp, _) = conform(&footprint(vec![ok])); + assert!( + (fp.pads[0].size_x * PX - 1.2).abs() < 1e-9 + && (fp.pads[0].size_y * PX - 2.3).abs() < 1e-9 + ); + } + + #[test] + fn silkscreen_stays_off_the_copper() { + let keep = [(-1.0, -0.5, -0.4, 0.5), (0.4, -0.5, 1.0, 0.5)]; + let pieces = clip_segment((-1.5, 0.3), (1.5, 0.3), &keep); + assert_eq!(pieces.len(), 3); + assert!(pieces.iter().all(|((a, _), (b, _))| b - a >= 0.2)); + assert!(clip_segment((-0.9, 0.0), (-0.5, 0.0), &keep).is_empty()); + } +} diff --git a/src/cadence/mod.rs b/src/cadence/mod.rs new file mode 100644 index 0000000..6fca886 --- /dev/null +++ b/src/cadence/mod.rs @@ -0,0 +1,30 @@ +//! Cadence export: OrCAD Capture symbols and Allegro footprints, written as files the +//! user imports into Cadence, the way the KiCad and Altium targets write their +//! libraries. +//! +//! Capture's `.olb` and Allegro's `.dra` / `.psm` / `.pad` are undocumented binary +//! formats that only Cadence's own programs write, so SeEx writes the formats Cadence +//! reads in their place: +//! +//! * symbols as one Capture **Library XML** file, which Capture turns into an `.olb` +//! with `File > Import > Library XML` (see [`capture`]); +//! * footprints as Allegro **SKILL** scripts, one per footprint plus a library script +//! that builds them all into `.dra`, `.psm` and `.pad` files (see [`allegro`]); +//! * 3D models as STEP files, which the footprints map. +//! +//! Lands follow IPC-7352 where SeEx can calculate them (see [`ipc7352`]); other +//! footprints keep the manufacturer's lands with IPC-7352 courtyard and silkscreen +//! rules. + +pub mod allegro; +pub mod capture; +pub mod ipc7352; +pub mod step_place; +pub mod workflow; + +#[cfg(test)] +mod tests; + +pub use workflow::{ + CadenceBatchOptions, CadenceBatchSummary, CadenceExportOptions, export_cadence_batch, +}; diff --git a/src/cadence/step_place.rs b/src/cadence/step_place.rs new file mode 100644 index 0000000..6932d03 --- /dev/null +++ b/src/cadence/step_place.rs @@ -0,0 +1,301 @@ +//! Placing EasyEDA STEP models on Allegro footprints. +//! +//! EasyEDA keeps a model in its own coordinates and records where it sits on the +//! footprint: the model's centre (`c_origin`), its height above the board (`z`) and a +//! rotation (`c_rotation`). EasyEDA centres the model's outline on `c_origin` and +//! rests its bottom on the board, so the Allegro mapping is: rotate by `c_rotation`, +//! then shift the rotated centre onto `c_origin` and the bottom onto `z`. + +use crate::cadence::allegro::StepTransform; +use crate::easyeda::{ComponentData, ModelPlacement}; +use crate::kicad::KiFootprint; + +/// EasyEDA canvas unit (10 mil) in millimetres. +const PX_MM: f64 = 0.254; + +/// Bounding box (min, max) of a STEP model's vertices, in the file's units (mm for +/// EasyEDA models). Falls back to all points when the file has no vertex list. +pub fn step_bounds(data: &[u8]) -> Option<([f64; 3], [f64; 3])> { + let text = String::from_utf8_lossy(data); + let mut points: std::collections::HashMap<&str, [f64; 3]> = std::collections::HashMap::new(); + let mut vertices: Vec<&str> = Vec::new(); + for statement in text.split(';') { + let statement = statement.trim(); + let Some(rest) = statement.strip_prefix('#') else { + continue; + }; + let Some((id, body)) = rest.split_once('=') else { + continue; + }; + let id = id.trim(); + let body = body.trim(); + if let Some(args) = body.strip_prefix("CARTESIAN_POINT") { + if let Some(point) = parse_point(args) { + points.insert(id, point); + } + } else if let Some(args) = body.strip_prefix("VERTEX_POINT") { + if let Some(reference) = args.rsplit('#').next() { + let reference = reference.trim_end_matches(|c: char| c == ')' || c.is_whitespace()); + vertices.push(reference); + } + } + } + let selected: Vec<[f64; 3]> = if vertices.is_empty() { + points.values().copied().collect() + } else { + vertices + .iter() + .filter_map(|id| points.get(id).copied()) + .collect() + }; + if selected.is_empty() { + return None; + } + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for point in selected { + for axis in 0..3 { + min[axis] = min[axis].min(point[axis]); + max[axis] = max[axis].max(point[axis]); + } + } + Some((min, max)) +} + +fn parse_point(args: &str) -> Option<[f64; 3]> { + let start = args.rfind('(')?; + let end = args[start..].find(')')? + start; + let values: Vec = args[start + 1..end] + .split(',') + .filter_map(|value| value.trim().parse::().ok()) + .collect(); + match values.as_slice() { + [x, y, z] => Some([*x, *y, *z]), + [x, y] => Some([*x, *y, 0.0]), + _ => None, + } +} + +/// Allegro STEP mapping for an EasyEDA model on the footprint whose origin is +/// `package_origin` (EasyEDA canvas units). +pub fn allegro_transform( + placement: Option<&ModelPlacement>, + package_origin: (f64, f64), + bounds: Option<([f64; 3], [f64; 3])>, +) -> StepTransform { + let (target_x, target_y, bottom, rotation) = match placement { + Some(p) => ( + (p.origin_x - package_origin.0) * PX_MM, + // EasyEDA's y axis points down, Allegro's up. + -(p.origin_y - package_origin.1) * PX_MM, + p.z * PX_MM, + p.rotation.map(normalize_degrees), + ), + None => (0.0, 0.0, 0.0, [0.0; 3]), + }; + let offset = match bounds { + // A model tilted about x or y: keep EasyEDA's numbers, the bounding box of + // the unrotated model says nothing useful about the result. + Some((min, max)) if rotation[0] == 0.0 && rotation[1] == 0.0 => { + let (cx, cy) = ((min[0] + max[0]) / 2.0, (min[1] + max[1]) / 2.0); + let (sin, cos) = rotation[2].to_radians().sin_cos(); + let (rx, ry) = (cx * cos - cy * sin, cx * sin + cy * cos); + [target_x - rx, target_y - ry, bottom - min[2]] + } + _ => [target_x, target_y, bottom], + }; + StepTransform { + rotation, + offset: offset.map(|v| round4(v) + 0.0), + } +} + +/// Where EasyEDA puts the model centre on the footprint (mm, Allegro axes). +pub fn model_centre_mm(placement: &ModelPlacement, package_origin: (f64, f64)) -> (f64, f64) { + ( + (placement.origin_x - package_origin.0) * PX_MM, + -(placement.origin_y - package_origin.1) * PX_MM, + ) +} + +fn normalize_degrees(angle: f64) -> f64 { + let a = angle.rem_euclid(360.0); + if (a - 360.0).abs() < 1e-9 { + 0.0 + } else { + round4(a) + } +} + +fn round4(value: f64) -> f64 { + (value * 10_000.0).round() / 10_000.0 +} + +/// Allegro mapping of EasyEDA's model on the exported footprint: EasyEDA's +/// placement on its own footprint, turned and shifted with the footprint when it +/// was redrawn as an IPC-7352 land pattern (`ipc`: turn in degrees and shift in mm, +/// `ipc = R(turn) * easyeda + shift`). `data` is the model the export writes. +pub fn model_on_footprint( + component: &ComponentData, + easyeda_footprint: &KiFootprint, + ipc: Option<(f64, (f64, f64))>, + data: Option<&[u8]>, + notes: &mut Vec, +) -> StepTransform { + let Some((turn, (sx, sy))) = ipc else { + return easyeda_model_transform(component, easyeda_footprint, data, notes); + }; + // The calculated pattern is centred on the package, so the model's outline is + // centred on the origin; EasyEDA only decides how it is turned and its height. + // EasyEDA's notes about where it put the model do not apply here. + let mut easyeda_notes = Vec::new(); + let transform = easyeda_model_transform(component, easyeda_footprint, data, &mut easyeda_notes); + if transform.rotation[0] == 0.0 && transform.rotation[1] == 0.0 { + if let Some((min, max)) = data.and_then(step_bounds) { + let rz = (transform.rotation[2] + turn).rem_euclid(360.0); + let (cx, cy) = ((min[0] + max[0]) / 2.0, (min[1] + max[1]) / 2.0); + let (sin, cos) = rz.to_radians().sin_cos(); + return StepTransform { + rotation: [0.0, 0.0, rz], + offset: [ + round4(-(cx * cos - cy * sin)) + 0.0, + round4(-(cx * sin + cy * cos)) + 0.0, + transform.offset[2], + ], + }; + } + } + notes.extend(easyeda_notes); + if transform.rotation[0] != 0.0 || transform.rotation[1] != 0.0 { + notes.push("3D: EasyEDA tilts this model; check it in the 3D view".to_string()); + } + let (sin, cos) = turn.to_radians().sin_cos(); + let [ox, oy, oz] = transform.offset; + StepTransform { + rotation: [ + transform.rotation[0], + transform.rotation[1], + (transform.rotation[2] + turn).rem_euclid(360.0), + ], + offset: [ + round4(ox * cos - oy * sin + sx) + 0.0, + round4(ox * sin + oy * cos + sy) + 0.0, + oz, + ], + } +} + +/// Allegro mapping for EasyEDA's own model on EasyEDA's footprint. +fn easyeda_model_transform( + component: &ComponentData, + footprint: &KiFootprint, + data: Option<&[u8]>, + notes: &mut Vec, +) -> StepTransform { + let bounds = data.and_then(step_bounds); + if bounds.is_none() { + notes.push("3D: could not read the model outline; placed at the origin".to_string()); + } + let package_origin = (component.package_bbox_x, component.package_bbox_y); + let mut placement = component.model_3d.as_ref().and_then(|m| m.placement); + // EasyEDA data sometimes places a model beside its pads. A two-pad part is + // symmetric about its pads, so centre it there; otherwise keep EasyEDA's + // placement and say so. + if let (Some(p), Some((x0, y0, x1, y1))) = (placement.as_mut(), pad_extent_mm(footprint)) { + let pads = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let centre = model_centre_mm(p, package_origin); + let off = ((centre.0 - pads.0).powi(2) + (centre.1 - pads.1).powi(2)).sqrt(); + // Farther from the pads than the pads reach (plus a body's overhang): the + // placement is plainly wrong (seen 1.1 m off), so centre the model. + let reach = (x1 - x0).hypot(y1 - y0) / 2.0 + 3.0; + if off > reach || off > 0.25 && footprint.pads.len() == 2 { + p.origin_x = package_origin.0 + pads.0 / PX_MM; + p.origin_y = package_origin.1 - pads.1 / PX_MM; + notes.push(format!( + "3D: EasyEDA puts the model {off:.2} mm off its pads; centred it on the pads" + )); + } else if off > 0.25 && footprint.pads.len() > 2 { + notes.push(format!( + "3D: EasyEDA puts the model {off:.2} mm from the pad centre; check it in the 3D view" + )); + } + } + allegro_transform(placement.as_ref(), package_origin, bounds) +} + +/// (min x, min y, max x, max y) of the pad centres in mm, Allegro axes (Y up). +fn pad_extent_mm(footprint: &KiFootprint) -> Option<(f64, f64, f64, f64)> { + let mut extent: Option<(f64, f64, f64, f64)> = None; + for pad in &footprint.pads { + let (x, y) = (pad.pos_x * PX_MM, -pad.pos_y * PX_MM); + extent = Some(match extent { + None => (x, y, x, y), + Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)), + }); + } + extent +} + +#[cfg(test)] +mod tests { + use super::*; + + const STEP: &str = "ISO-10303-21;\nDATA;\n#1=CARTESIAN_POINT('',(-1.01,-0.41,-0.71));\n#2=CARTESIAN_POINT('',(0.59,0.39,0.));\n#3=CARTESIAN_POINT('Axis',(50.,50.,50.));\n#4=VERTEX_POINT('',#1);\n#5=VERTEX_POINT('',#2);\nENDSEC;\n"; + + #[test] + fn bounds_use_vertices_only() { + let (min, max) = step_bounds(STEP.as_bytes()).unwrap(); + assert_eq!(min, [-1.01, -0.41, -0.71]); + assert_eq!(max, [0.59, 0.39, 0.0]); + } + + #[test] + fn rotated_off_centre_model_lands_on_its_origin() { + // ESD5Z5.0 (C475685): model centre x = -0.21, rotated 180 degrees, origin on + // the package origin, bottom 0.71 below the model origin. + let placement = ModelPlacement { + origin_x: 4000.0, + origin_y: 3000.0, + z: 0.0, + rotation: [0.0, 0.0, 180.0], + }; + let t = allegro_transform( + Some(&placement), + (4000.0, 3000.0), + step_bounds(STEP.as_bytes()), + ); + assert_eq!(t.rotation, [0.0, 0.0, 180.0]); + assert_eq!(t.offset, [-0.21, -0.01, 0.71]); + } + + #[test] + fn origin_offset_is_converted_and_y_flipped() { + // CL10A475 (C19666): c_origin 3998.0435,3000.5 on a package at 4000,3000. + let placement = ModelPlacement { + origin_x: 3998.0435, + origin_y: 3000.5, + z: 0.0, + rotation: [0.0; 3], + }; + let bounds = Some(([-0.8, -0.4, -0.4], [0.8, 0.4, 0.4])); + let t = allegro_transform(Some(&placement), (4000.0, 3000.0), bounds); + assert_eq!(t.offset, [-0.497, -0.127, 0.4]); + assert_eq!(t.rotation, [0.0; 3]); + assert_eq!( + allegro_transform(None, (0.0, 0.0), None), + StepTransform::default() + ); + } + + #[test] + fn negative_angles_are_normalised() { + let placement = ModelPlacement { + origin_x: 0.0, + origin_y: 0.0, + z: 0.0, + rotation: [0.0, 0.0, -90.0], + }; + let t = allegro_transform(Some(&placement), (0.0, 0.0), None); + assert_eq!(t.rotation, [0.0, 0.0, 270.0]); + } +} diff --git a/src/cadence/tests.rs b/src/cadence/tests.rs new file mode 100644 index 0000000..f46814e --- /dev/null +++ b/src/cadence/tests.rs @@ -0,0 +1,946 @@ +use crate::cadence::allegro::{ + AllegroFootprint, StepModel, StepTransform, allegro_identifier, build_allegro_footprint_with, + footprint_name_for, library_script, +}; +use crate::cadence::capture::{ + CAPTURE_GRID, CapturePart, PinLabels, build_capture_part_with, capture_identifier, + render_library_xml, +}; +use crate::kicad::footprint::{ + Drill, KiArc as FpArc, KiCircle as FpCircle, KiFootprint, KiLine, KiPad, PadShape, PadType, +}; +use crate::kicad::symbol::{ + KiCircle, KiPin, KiPolyline, KiRectangle, KiSymbol, KiSymbolUnit, PinStyle, PinType, +}; + +fn build_allegro_footprint(footprint: &KiFootprint, name: &str, refdes: &str) -> AllegroFootprint { + build_allegro_footprint_with(footprint, name, refdes, None) +} + +fn build_capture_part(symbol: &KiSymbol, name: &str, footprint: &str) -> CapturePart { + build_capture_part_with(symbol, name, footprint, PinLabels::default()) +} + +/// Padstacks a footprint script creates, in order. +fn padstacks(script: &str) -> Vec { + script + .match_indices("seex_padstack(\"") + .map(|(at, call)| { + let rest = &script[at + call.len()..]; + rest[..rest.find('"').unwrap()].to_string() + }) + .collect() +} + +fn pin(number: &str, name: &str, x: f64, y: f64, rotation: i32, pin_type: PinType) -> KiPin { + KiPin { + number: number.to_string(), + name: name.to_string(), + pin_type, + style: PinStyle::Line, + pos_x: x, + pos_y: y, + rotation, + length: 10.0, + } +} + +/// Op-amp style symbol in the shared model (EasyEDA px, Y up), body 40x40. +pub(crate) fn sample_symbol() -> KiSymbol { + KiSymbol { + name: "LM321_C7950".to_string(), + reference: "U?".to_string(), + value: "LM321 & \"rail\"".to_string(), + description: "Single op-amp".to_string(), + footprint: String::new(), + datasheet: "https://example.test/ds?a=1&b=2".to_string(), + manufacturer: "TI".to_string(), + lcsc_id: "C7950".to_string(), + jlc_id: String::new(), + units: Vec::new(), + pins: vec![ + // Left side pins point right (KiCad orientation 0 => EasyEDA rotation 180). + pin("3", "+IN", -10.0, 10.0, 180, PinType::Input), + pin("4", "-IN", -10.0, -10.0, 180, PinType::Input), + // Right side pin. + pin("1", "OUT", 50.0, 0.0, 0, PinType::Output), + // Top / bottom power pins (KiCad orientation 270 / 90 => EasyEDA 90 / 270). + pin("5", "V+", 20.0, 30.0, 90, PinType::PowerIn), + pin("2", "V-", 20.0, -30.0, 270, PinType::PowerIn), + ], + rectangles: vec![KiRectangle { + x1: 0.0, + y1: 20.0, + x2: 40.0, + y2: -20.0, + stroke_width: 1.0, + fill: true, + }], + circles: vec![KiCircle { + cx: 35.0, + cy: 0.0, + radius: 2.0, + stroke_width: 1.0, + fill: false, + }], + arcs: Vec::new(), + polylines: vec![KiPolyline { + points: vec![(5.0, 15.0), (30.0, 0.0), (5.0, -15.0), (5.0, 15.0)], + stroke_width: 1.0, + fill: true, + }], + texts: Vec::new(), + } +} + +fn attr(xml: &str, element_start: usize, name: &str) -> String { + let rest = &xml[element_start..]; + let key = format!(" {name}=\""); + let start = rest.find(&key).expect("attribute present") + key.len(); + let end = rest[start..].find('"').unwrap() + start; + rest[start..end].to_string() +} + +#[test] +fn capture_pins_land_on_grid_and_point_away_from_body() { + let part = build_capture_part(&sample_symbol(), "LM321_C7950", "c7950_lm321"); + let xml = &part.xml; + let mut count = 0; + for (index, _) in xml.match_indices("") { + count += 1; + let hx: i64 = attr(xml, index, "hotptX").parse().unwrap(); + let hy: i64 = attr(xml, index, "hotptY").parse().unwrap(); + let sx: i64 = attr(xml, index, "startX").parse().unwrap(); + let sy: i64 = attr(xml, index, "startY").parse().unwrap(); + assert_eq!(hx % CAPTURE_GRID, 0, "hotptX on grid"); + assert_eq!(hy % CAPTURE_GRID, 0, "hotptY on grid"); + assert_eq!((hx - sx).abs() + (hy - sy).abs(), 10, "pin length kept"); + match attr(xml, index, "name").as_str() { + "+IN" | "-IN" => assert!(sx > hx, "left pins extend right into the body"), + "OUT" => assert!(sx < hx, "right pin extends left into the body"), + // Capture Y grows downwards: V+ is on top so its body end is below it. + "V+" => assert!(sy > hy), + "V-" => assert!(sy < hy), + other => panic!("unexpected pin {other}"), + } + } + assert_eq!(count, 5); + assert!(part.warnings.is_empty(), "{:?}", part.warnings); +} + +#[test] +fn capture_part_has_capture_structure_and_metadata() { + let part = build_capture_part(&sample_symbol(), "LM321_C7950", "c7950_lm321"); + let xml = &part.xml; + assert!(xml.contains("refdesPrefix=\"U\"")); + assert!(xml.contains("pcbFootprint=\"c7950_lm321\"")); + assert!(xml.contains("")); + assert!(xml.contains("val=\"https://example.test/ds?a=1&b=2\"")); + assert!(xml.contains("")); + // Pin types: input 0, output 2, power 7. + assert!(xml.contains("name=\"+IN\" position=\"0\"")); + assert!(xml.contains("type=\"7\"")); + assert!(xml.contains("type=\"2\"")); + // Physical pin numbers map positions to package pins. + assert!(xml.contains("")); + assert!(xml.contains("")); + // Body rectangle unfilled, triangle polygon filled. + assert!(xml.contains("\n \n ", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + ]; + let positions: Vec = order.iter().map(|tag| xml.find(tag).unwrap()).collect(); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1])); +} + +#[test] +fn capture_renames_duplicate_pin_numbers() { + let mut symbol = sample_symbol(); + symbol.pins[1].number = "3".to_string(); + let part = build_capture_part(&symbol, "X", "x"); + assert!(part.xml.contains("").count(), 2); + assert!(xml.contains("")); + assert!(xml.contains("")); + assert_eq!(xml.matches("").count(), 2); + assert_eq!(xml.matches("").count(), 10); + // Each section is drawn on its own, at the origin, with its own pin numbers. + let sections: Vec<&str> = xml.split("").skip(1).collect(); + for section in §ions { + assert_eq!(section.matches("").count(), 1); + assert_capture_body_rules(&format!("{section}")); + } + assert!(sections[0].contains("number=\"1\"")); + assert!(sections[1].contains("number=\"10\"")); + assert!(!sections[1].contains("number=\"1\" ")); + // Pins never share a connection point. + for section in §ions { + let mut hot: Vec<(i64, i64)> = pin_points(section) + .into_iter() + .map(|(_, x, y, _, _)| (x, y)) + .collect(); + let count = hot.len(); + hot.sort(); + hot.dedup(); + assert_eq!(hot.len(), count); + } +} + +#[test] +fn single_symbols_stay_homogeneous() { + let part = build_capture_part(&sample_symbol(), "LM321_C7950", "c7950_lm321"); + assert!(part.xml.contains("isHomogeneous=\"1\"")); + assert_eq!(part.xml.matches("").count(), 1); + assert!(part.xml.contains("")); +} + +#[test] +fn library_xml_wraps_packages() { + let part = build_capture_part(&sample_symbol(), "LM321_C7950", "c7950_lm321"); + let xml = render_library_xml("SeEx.olb", &[part.xml.clone(), part.xml]); + assert!(xml.starts_with("").count(), 2); + assert_eq!(xml.matches("").count(), 24); + assert!(xml.trim_end().ends_with("")); +} + +#[test] +fn identifiers_are_tool_safe() { + assert_eq!( + capture_identifier("STM32F103C8T6 (LQFP-48)"), + "STM32F103C8T6_LQFP-48" + ); + assert_eq!(capture_identifier("中文"), "PART"); + assert_eq!(allegro_identifier("SOP-8_L4.9", 31), "sop-8_l4_9"); + let long = footprint_name_for("C2040", "RP2040 very long package title that keeps going"); + assert!(long.len() <= 31); + assert!(long.starts_with("c2040_")); +} + +fn smd_pad(number: &str, x: f64, y: f64, w: f64, h: f64, rotation: f64) -> KiPad { + KiPad { + number: number.to_string(), + pad_type: PadType::Smd, + shape: PadShape::Rect, + pos_x: x, + pos_y: y, + size_x: w, + size_y: h, + rotation, + layers: vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()], + drill: None, + polygon: None, + } +} + +/// SOIC-like footprint with every pad flavour the converter can emit (EasyEDA px, Y down). +pub(crate) fn sample_footprint() -> KiFootprint { + let mut pads = vec![ + smd_pad("1", -10.0, -15.0, 6.0, 2.4, 0.0), + smd_pad("2", -10.0, 15.0, 6.0, 2.4, 0.0), + smd_pad("2", 10.0, 15.0, 6.0, 2.4, 90.0), + ]; + pads.push(KiPad { + number: "3".into(), + pad_type: PadType::ThroughHole, + shape: PadShape::Oval, + pos_x: 20.0, + pos_y: 0.0, + size_x: 8.0, + size_y: 5.0, + rotation: 0.0, + layers: vec!["*.Cu".into(), "*.Mask".into()], + drill: Some(Drill { + diameter: 5.0, + width: Some(3.0), + offset_x: 0.0, + offset_y: 0.0, + }), + polygon: None, + }); + pads.push(KiPad { + number: "4".into(), + pad_type: PadType::ThroughHole, + shape: PadShape::Circle, + pos_x: 30.0, + pos_y: 0.0, + size_x: 6.0, + size_y: 6.0, + rotation: 0.0, + layers: vec!["*.Cu".into(), "*.Mask".into()], + drill: Some(Drill { + diameter: 4.0, + width: None, + offset_x: 0.0, + offset_y: 0.0, + }), + polygon: None, + }); + pads.push(KiPad { + number: "EP".into(), + pad_type: PadType::Smd, + shape: PadShape::Custom, + pos_x: 0.0, + pos_y: 0.0, + size_x: 0.01, + size_y: 0.01, + rotation: 0.0, + layers: vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()], + drill: None, + polygon: Some( + "\n\t\t(primitives \n\t\t\t(gr_poly \n\t\t\t\t(pts (xy -1.00 -0.50) (xy 1.00 -0.50) (xy 1.00 0.50) (xy -1.00 0.50)\n\t\t\t\t) \n\t\t\t\t(width 0.1) \n\t\t\t)\n\t\t)\n\t" + .into(), + ), + }); + // Via (unnumbered plated) and a mounting hole (NPTH). + pads.push(KiPad { + number: String::new(), + pad_type: PadType::ThroughHole, + shape: PadShape::Circle, + pos_x: 0.0, + pos_y: 0.0, + size_x: 2.4, + size_y: 2.4, + rotation: 0.0, + layers: vec!["*.Cu".into(), "*.Mask".into()], + drill: Some(Drill { + diameter: 1.2, + width: None, + offset_x: 0.0, + offset_y: 0.0, + }), + polygon: None, + }); + pads.push(KiPad { + number: String::new(), + pad_type: PadType::NpThroughHole, + shape: PadShape::Circle, + pos_x: -30.0, + pos_y: 0.0, + size_x: 12.0, + size_y: 12.0, + rotation: 0.0, + layers: vec!["*.Cu".into(), "*.Mask".into()], + drill: Some(Drill { + diameter: 12.0, + width: None, + offset_x: 0.0, + offset_y: 0.0, + }), + polygon: None, + }); + + let line = |x1: f64, y1: f64, x2: f64, y2: f64, layer: &str| KiLine { + start_x: x1, + start_y: y1, + end_x: x2, + end_y: y2, + width: 0.6, + layer: layer.into(), + }; + KiFootprint { + name: "test".into(), + pads, + tracks: Vec::new(), + circles: vec![FpCircle { + center_x: -14.0, + center_y: -20.0, + end_x: -13.0, + end_y: -20.0, + width: 0.2, + layer: "F.SilkS".into(), + fill: true, + }], + arcs: vec![FpArc { + start_x: -5.0, + start_y: -20.0, + mid_x: 0.0, + mid_y: -15.0, + end_x: 5.0, + end_y: -20.0, + width: 0.6, + layer: "F.SilkS".into(), + }], + texts: Vec::new(), + lines: vec![ + line(-20.0, -20.0, 20.0, -20.0, "F.SilkS"), + line(-20.0, 20.0, 20.0, 20.0, "F.Fab"), + line(-40.0, -25.0, 40.0, -25.0, "F.CrtYd"), + line(40.0, -25.0, 40.0, 25.0, "F.CrtYd"), + line(40.0, 25.0, -40.0, 25.0, "F.CrtYd"), + line(-40.0, 25.0, -40.0, -25.0, "F.CrtYd"), + line(0.0, 0.0, 5.0, 5.0, "Cmts.User"), + ], + model_3d: None, + } +} + +#[test] + +fn allegro_script_creates_shared_padstacks_and_pins() { + let fp = build_allegro_footprint(&sample_footprint(), "c1_test", "U?"); + let script = &fp.script; + + // 6 x 2.4 px = 1.524 x 0.6096 mm -> one shared SMD padstack for pads 1 and both 2s. + assert!( + padstacks(&fp.script).contains(&"sx_sr152x61".to_string()), + "{:?}", + padstacks(&fp.script) + ); + assert_eq!(script.matches("seex_padstack(\"sx_sr152x61\"").count(), 1); + // Duplicate pad number renamed, rotation preserved. + assert!(script.contains("seex_pin(\"sx_sr152x61\" \"2_2\" 2.5400 -3.8100 90.0000)")); + // Y is flipped: EasyEDA y = -15 px (up on screen) -> +3.81 mm in Allegro. + assert!(script.contains("seex_pin(\"sx_sr152x61\" \"1\" -2.5400 3.8100 0.0000)")); + // Oblong pad with an oval slot, plated. + assert!(script.contains("seex_slot(1.2700 0.7620 t)")); + assert!(script.contains("'OBLONG")); + // Round plated pad keeps the DEFAULT_INTERNAL layer with a fallback without it. + assert!(script.contains("seex_padstack(\"sx_tc152d102\" seex_drill(1.0160 t)")); + // Mechanical hole: non-plated, no pin number. + assert!(script.contains("seex_padstack(\"sx_h305\" seex_drill(3.0480 nil)")); + assert!(script.contains("seex_pin(\"sx_h305\" nil -7.6200 0.0000 0.0000)")); + // Custom polygon pad: anchor pad plus three shapes. + assert!(script.contains("seex_shape(\"ETCH/TOP\"")); + assert!(script.contains("seex_shape(\"PACKAGE GEOMETRY/PASTEMASK_TOP\"")); + // Courtyard becomes the place bound (80 x 50 px = 20.32 x 12.7 mm). + assert!(script.contains( + "seex_place_bound(\"PACKAGE GEOMETRY/PLACE_BOUND_TOP\" -10.1600 -6.3500 10.1600 6.3500)" + )); + assert!(script.contains("\"REF DES/SILKSCREEN_TOP\" \"U*\"")); + // Silkscreen / assembly graphics, arc, filled dot. + assert!(script.contains("seex_line(\"PACKAGE GEOMETRY/SILKSCREEN_TOP\"")); + // The component frame is the silkscreen: no assembly outline. + assert!(!script.contains("seex_line(\"PACKAGE GEOMETRY/ASSEMBLY_TOP\"")); + assert!(script.contains("seex_text(\"REF DES/ASSEMBLY_TOP\"")); + assert!(script.contains("seex_arc(\"PACKAGE GEOMETRY/SILKSCREEN_TOP\"")); + assert!(script.contains("seex_circle(\"PACKAGE GEOMETRY/SILKSCREEN_TOP\"")); + + let joined = fp.warnings.join("\n"); + assert!(joined.contains("1 unnumbered via"), "{joined}"); + assert!(joined.contains("2_2")); + assert!(joined.contains("custom polygon")); + assert!(joined.contains("Cmts.User")); +} + +#[test] +fn allegro_script_parentheses_balance() { + let fp = build_allegro_footprint(&sample_footprint(), "c1_test", "R"); + let mut depth = 0i64; + let mut in_string = false; + let mut in_comment = false; + let mut escaped = false; + for ch in fp.script.chars() { + if in_comment { + in_comment = ch != '\n'; + continue; + } + if in_string { + match (escaped, ch) { + (true, _) => escaped = false, + (false, '\\') => escaped = true, + (false, '"') => in_string = false, + _ => {} + } + continue; + } + match ch { + ';' => in_comment = true, + '"' => in_string = true, + '(' => depth += 1, + ')' => { + depth -= 1; + assert!(depth >= 0, "unbalanced ')'"); + } + _ => {} + } + } + assert_eq!(depth, 0); + assert!(!in_string); +} + +#[test] +fn allegro_names_long_padstacks_with_hash() { + let mut footprint = sample_footprint(); + footprint.pads = vec![KiPad { + number: "1".into(), + pad_type: PadType::ThroughHole, + shape: PadShape::Oval, + pos_x: 0.0, + pos_y: 0.0, + size_x: 123.4, + size_y: 98.7, + rotation: 0.0, + layers: vec!["*.Cu".into()], + drill: Some(Drill { + diameter: 77.7, + width: Some(55.5), + offset_x: 0.0, + offset_y: 0.0, + }), + polygon: None, + }]; + let fp = build_allegro_footprint(&footprint, "big", "J"); + let stacks = padstacks(&fp.script); + assert_eq!(stacks.len(), 1); + assert!(stacks[0].len() <= 20, "{}", stacks[0]); + assert!(stacks[0].starts_with("sx_to")); +} + +#[test] +fn square_smd_pads_use_allegro_square_figure() { + let mut footprint = sample_footprint(); + footprint.pads.truncate(1); + footprint.pads[0].size_x = 3.15; + footprint.pads[0].size_y = 3.15; + let fp = build_allegro_footprint(&footprint, "c2286_led", "LED?"); + assert!( + padstacks(&fp.script).contains(&"sx_sq80".to_string()), + "{:?}", + padstacks(&fp.script) + ); + assert!(fp.script.contains("'SQUARE")); + assert!( + !fp.script + .contains("seex_pad(\"TOP\" 'RECTANGLE 0.8001 0.8001)") + ); +} + +#[test] +fn allegro_script_maps_step_model() { + let fp = build_allegro_footprint_with( + &sample_footprint(), + "c1_test", + "U", + Some(StepModel { + file: "c1_test.step", + dir: std::path::Path::new("E:\\Libs\\3D\\Package_SO"), + transform: StepTransform { + rotation: [0.0, 0.0, 90.0], + offset: [0.0, -0.127, 0.7], + }, + }), + ); + assert!( + fp.script.contains( + " seex_step(\"c1_test.step\" \"E:/Libs/3D/Package_SO\" 0.0000 0.0000 90.0000 0.0000 -0.1270 0.7000)\n seex_end(\"c1_test\")" + ), + "{}", + fp.script + ); + assert!( + !build_allegro_footprint(&sample_footprint(), "c1_test", "U") + .script + .contains(" seex_step(") + ); +} + +#[test] +fn padstacks_carry_every_required_layer_and_are_checked() { + let footprint = sample_footprint(); + let fp = build_allegro_footprint(&footprint, "test_tht", "J"); + // Plated holes: regular, anti-pad and thermal relief on outer and internal layers. + for layer in ["TOP", "DEFAULT_INTERNAL", "BOTTOM"] { + for kind in ["ANTI", "THERMAL"] { + assert!( + fp.script + .contains(&format!("seex_padt(\"{layer}\" '{kind} ")), + "{layer} {kind} missing:\n{}", + fp.script + ); + } + } + assert!(fp.script.contains("seex_pad(\"SOLDERMASK_BOTTOM\" ")); + // Every padstack is read back and checked. + for stack in &padstacks(&fp.script) { + assert!( + fp.script + .contains(&format!("seex_check_padstack(\"{stack}\" ")), + "{stack} not checked" + ); + } + assert!(fp.script.contains("list(\"ETCH/TOP\" \"THERMAL\")")); + + let mut smd = sample_footprint(); + smd.pads.truncate(1); + smd.pads[0].pad_type = PadType::Smd; + smd.pads[0].drill = None; + smd.pads[0].layers = vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()]; + let fp = build_allegro_footprint(&smd, "test_smd", "R"); + assert!(fp.script.contains("seex_pad(\"PASTEMASK_TOP\" ")); + assert!(fp.script.contains( + "list(list(\"ETCH/TOP\" \"REGULAR\") list(\"PIN/SOLDERMASK_TOP\" \"REGULAR\") list(\"PIN/PASTEMASK_TOP\" \"REGULAR\")) nil)" + )); +} + +fn pin_points(xml: &str) -> Vec<(String, i64, i64, i64, i64)> { + xml.match_indices("") + .map(|(index, _)| { + ( + attr(xml, index, "name"), + attr(xml, index, "hotptX").parse().unwrap(), + attr(xml, index, "hotptY").parse().unwrap(), + attr(xml, index, "startX").parse().unwrap(), + attr(xml, index, "startY").parse().unwrap(), + ) + }) + .collect() +} + +#[test] +fn off_grid_symbols_are_snapped_to_the_grid() { + // EasyEDA symbol drawn on a 0.05" grid: the body and the right pins sit half a + // grid off, and a free-standing pin (no body) is off in both directions. + let mut symbol = sample_symbol(); + symbol.rectangles[0].x2 = 45.0; + symbol.pins[2].pos_x = 55.0; // OUT, on the moved right edge + symbol.pins[2].pos_y = 5.0; + symbol + .pins + .push(pin("6", "NC", 72.5, -15.0, 0, PinType::NoConnect)); + let part = build_capture_part(&symbol, "T_C1", "t"); + for (name, hx, hy, sx, sy) in pin_points(&part.xml) { + assert_eq!(hx % CAPTURE_GRID, 0, "{name} hotptX {hx}"); + assert_eq!(hy % CAPTURE_GRID, 0, "{name} hotptY {hy}"); + assert!( + (hx - sx).abs() + (hy - sy).abs() >= 5, + "{name} keeps a visible pin" + ); + } + // The body grew to the grid. + let rect = part.xml.find("").unwrap(); + for key in ["x1", "x2", "y1", "y2"] { + let v: i64 = attr(&part.xml, rect, key).parse().unwrap(); + assert_eq!(v % CAPTURE_GRID, 0, "body {key} {v}"); + } +} + +/// Capture's own parts (all of Discrete.olb) keep the body outline on the 0.1" +/// grid with its top-left corner at the part origin, start every pin on it and draw +/// everything inside it; the part editor relies on that to move graphics and pins. +fn assert_capture_body_rules(xml: &str) { + let problems = capture_body_problems(xml); + assert!(problems.is_empty(), "{problems:?}"); +} + +fn capture_body_problems(xml: &str) -> Vec { + let mut problems = Vec::new(); + let Some(bbox) = xml.find("") else { + return vec!["no SymbolBBox".to_string()]; + }; + let get = |key: &str| attr(xml, bbox, key).parse::().unwrap(); + let (x1, x2, y1, y2) = (get("x1"), get("x2"), get("y1"), get("y2")); + if [x1, x2, y1, y2].iter().any(|v| v % CAPTURE_GRID != 0) || x1 >= x2 || y1 >= y2 { + problems.push(format!("outline {x1} {x2} {y1} {y2} off grid")); + } + if (x1, y1) != (0, 0) { + problems.push(format!( + "outline starts at ({x1}, {y1}), not at the part origin" + )); + } + for (name, hx, hy, sx, sy) in pin_points(xml) { + if (hx - sx).abs() + (hy - sy).abs() < CAPTURE_GRID { + problems.push(format!( + "pin {name} is shorter than 0.1\" ({hx}, {hy}) to ({sx}, {sy})" + )); + } + let on_edge = ((sx == x1 || sx == x2) && (y1..=y2).contains(&sy)) + || ((sy == y1 || sy == y2) && (x1..=x2).contains(&sx)); + if !on_edge { + problems.push(format!( + "pin {name} starts at ({sx}, {sy}), off the outline {x1} {x2} {y1} {y2}" + )); + } + } + let mut at = 0; + while let Some(found) = xml[at..].find("") { + let index = at + found; + for (kx, ky) in [("x1", "y1"), ("x2", "y2")] { + let x: i64 = attr(xml, index, kx).parse().unwrap(); + let y: i64 = attr(xml, index, ky).parse().unwrap(); + if !((x1..=x2).contains(&x) && (y1..=y2).contains(&y)) { + problems.push(format!( + "line point ({x}, {y}) outside the outline {x1} {x2} {y1} {y2}" + )); + } + } + at = index + 6; + } + problems +} + +#[test] +fn filled_shapes_are_closed_polygons() { + // EasyEDA's MOSFET arrow: three points, filled, last point not repeated. + let mut symbol = sample_symbol(); + symbol.polylines = vec![KiPolyline { + points: vec![(0.0, 0.0), (-6.0, 2.0), (-6.0, -2.0)], + stroke_width: 1.0, + fill: true, + }]; + let part = build_capture_part(&symbol, "T_Q2", "t"); + let polygon = part + .xml + .find("") + .expect("filled triangle is a polygon"); + assert_eq!(attr(&part.xml, polygon, "fillStyle"), "0", "solid fill"); + let end = polygon + part.xml[polygon..].find("").unwrap(); + assert_eq!( + part.xml[polygon..end].matches("").count(), + 4, + "closed: 3 corners + first again" + ); +} + +#[test] +fn drawn_symbols_get_a_capture_style_outline() { + // Schottky diode as EasyEDA draws it: bar and triangle between 0.05" grid lines, + // pins ending on the drawing rather than on a body box. + let mut symbol = sample_symbol(); + symbol.rectangles.clear(); + symbol.circles.clear(); + symbol.pins = vec![ + pin("1", "K", -20.0, 0.0, 180, PinType::Passive), + pin("2", "A", 20.0, 0.0, 0, PinType::Passive), + ]; + symbol.pins[0].length = 15.0; + symbol.pins[1].length = 15.0; + symbol.polylines = vec![ + KiPolyline { + points: vec![(-5.0, 6.0), (-5.0, -6.0)], + stroke_width: 1.0, + fill: false, + }, + KiPolyline { + points: vec![(5.0, 6.0), (-5.0, 0.0), (5.0, -6.0), (5.0, 6.0)], + stroke_width: 1.0, + fill: true, + }, + ]; + let part = build_capture_part(&symbol, "T_D1", "t"); + assert_capture_body_rules(&part.xml); + // The pins still reach the drawing: lead lines close the gap to the bar/triangle. + assert!(part.xml.matches("").count() >= 3, "{}", part.xml); + + // MOSFET gate: a 0.05" pin whose tip falls on the outline once it is on the grid + // must not become a zero-length pin. + let mut mosfet = symbol.clone(); + mosfet.pins[0].pos_x = -10.0; + mosfet.pins[0].length = 5.0; + let part = build_capture_part(&mosfet, "T_Q1", "t"); + assert_capture_body_rules(&part.xml); + + // Box symbols already follow the rules and keep their pins. + let part = build_capture_part(&sample_symbol(), "LM321_C7950", "c7950_lm321"); + assert_capture_body_rules(&part.xml); +} + +#[test] +fn bodies_keep_their_shape_on_edges_without_pins() { + // Varistor: a flat 0.2" x 0.08" box with pins left and right and a diagonal + // that crosses it. Only the pin edges may move to the grid. + let mut symbol = sample_symbol(); + symbol.pins = vec![ + pin("1", "1", -20.0, 0.0, 180, PinType::Passive), + pin("2", "2", 30.0, 0.0, 0, PinType::Passive), + ]; + symbol.rectangles[0] = KiRectangle { + x1: -10.0, + y1: 4.0, + x2: 20.0, + y2: -4.0, + stroke_width: 1.0, + fill: false, + }; + symbol.circles.clear(); + symbol.polylines = vec![KiPolyline { + points: vec![(-10.0, -8.0), (-5.0, -8.0), (15.0, 8.0)], + stroke_width: 1.0, + fill: false, + }]; + let part = build_capture_part(&symbol, "T_RV1", "t"); + let rect = part.xml.find("").unwrap(); + let get = |key: &str| attr(&part.xml, rect, key).parse::().unwrap(); + assert_eq!( + (get("y2") - get("y1")).abs(), + 8, + "the box stays 0.08\" tall" + ); + assert_eq!((get("x2") - get("x1")).abs(), 30); + for (name, hx, hy, _, _) in pin_points(&part.xml) { + assert_eq!(hx % CAPTURE_GRID, 0, "{name}"); + assert_eq!(hy % CAPTURE_GRID, 0, "{name}"); + } +} + +#[test] + +fn half_grid_symbols_move_as_a_whole() { + // A two-pin header drawn on the 0.05" grid: both pins sit half a grid off. + // The whole drawing moves, so the pins stay 0.1" apart and on the body. + let mut symbol = sample_symbol(); + symbol.pins = vec![ + pin("1", "1", -20.0, 5.0, 180, PinType::Passive), + pin("2", "2", -20.0, -5.0, 180, PinType::Passive), + ]; + symbol.rectangles[0] = KiRectangle { + x1: -10.0, + y1: 25.0, + x2: 20.0, + y2: -15.0, + stroke_width: 1.0, + fill: false, + }; + symbol.polylines.clear(); + let part = build_capture_part(&symbol, "T_J1", "t"); + let pins = pin_points(&part.xml); + let ys: Vec = pins.iter().map(|p| p.2).collect(); + assert_eq!((ys[0] - ys[1]).abs(), 10, "{pins:?}"); + assert!( + part.warnings.iter().all(|w| !w.contains("jog")), + "{:?}", + part.warnings + ); + let rect = part.xml.find("").unwrap(); + let get = |key: &str| attr(&part.xml, rect, key).parse::().unwrap(); + assert_eq!((get("y2") - get("y1")).abs(), 40); +} + +#[test] +fn stacked_pins_stay_on_the_body() { + // Three shield pins EasyEDA put on one spot under the body: they are spread + // along the bottom edge and the body widens to keep them attached. + let mut symbol = sample_symbol(); + symbol.pins = vec![ + pin("1", "VBUS", -10.0, 10.0, 180, PinType::Passive), + pin("6", "SH", 10.0, -10.0, 270, PinType::Passive), + pin("7", "SH", 10.0, -10.0, 270, PinType::Passive), + pin("8", "SH", 10.0, -10.0, 270, PinType::Passive), + ]; + symbol.rectangles[0] = KiRectangle { + x1: 0.0, + y1: 20.0, + x2: 20.0, + y2: 0.0, + stroke_width: 1.0, + fill: false, + }; + symbol.polylines.clear(); + symbol.circles.clear(); + let part = build_capture_part(&symbol, "T_J2", "t"); + let rect = part.xml.find("").unwrap(); + let get = |key: &str| attr(&part.xml, rect, key).parse::().unwrap(); + let (x1, x2) = (get("x1").min(get("x2")), get("x1").max(get("x2"))); + let bottom = get("y1").max(get("y2")); + let mut xs = Vec::new(); + for (name, hx, _, sx, sy) in pin_points(&part.xml) { + if name == "SH" { + assert_eq!(sy, bottom, "attached to the bottom edge"); + assert!(sx >= x1 && sx <= x2, "{sx} within {x1}..{x2}"); + xs.push(hx); + } + } + xs.sort(); + xs.dedup(); + assert_eq!(xs.len(), 3, "spread apart"); +} + +#[test] +fn assembly_refdes_follows_the_fabrication_conventions() { + use crate::cadence::allegro::assembly_refdes_layout as layout; + // Always centred on the component: baseline half a text height below. + // 0402 (1.0 x 0.5 mm): the smallest size. + let (h, rot, x, y) = layout(Some((-0.5, -0.25, 0.5, 0.25)), "R*"); + assert_eq!((h, rot), (0.5, 0.0)); + assert!(x.abs() < 1e-9 && (y + 0.25).abs() < 1e-9); + // 0603 (1.6 x 0.8 mm): "R*" at 0.59 mm; "LED*" at 0.5 mm, still centred. + let (h, _, _, y) = layout(Some((-0.8, -0.4, 0.8, 0.4)), "R*"); + assert!((h - 1.6 / 2.7).abs() < 1e-9); + assert!((y + h / 2.0).abs() < 1e-9); + let (h, _, x, y) = layout(Some((1.0, 2.0, 2.6, 2.8)), "LED*"); + assert_eq!(h, 0.5); + assert!((x - 1.8).abs() < 1e-9 && (y - 2.15).abs() < 1e-9); + // SOIC-8 standing (3.9 x 4.9 mm): along the long axis, full size. + let (h, rot, x, _) = layout(Some((-1.95, -2.45, 1.95, 2.45)), "U*"); + assert_eq!((h, rot), (1.0, 90.0)); + assert!((x - 0.5).abs() < 1e-9); + // SOT-23 lying (2.9 x 1.3 mm): "Q*" at 1.0 mm. + let (h, rot, ..) = layout(Some((-1.45, -0.65, 1.45, 0.65)), "Q*"); + assert!((h - 1.0).abs() < 1e-9 && rot == 0.0); +} + +#[test] +fn silkscreen_refdes_fits_the_part() { + use crate::cadence::allegro::{silk_refdes_height as height, silk_stroke}; + // 0402 (place bound about 1.4 mm wide): the smallest size. + assert_eq!(height(Some((-0.7, -0.5, 0.7, 0.5)), "R*"), 0.6); + // 0603 LED (2.7 mm): "LED*" spans the part. + assert!((height(Some((-1.35, -0.75, 1.35, 0.75)), "LED*") - 2.7 / 3.6).abs() < 1e-9); + // SOT-23 and larger: the full 1.0 mm. + assert_eq!(height(Some((-1.7, -1.6, 1.7, 1.6)), "Q*"), 1.0); + assert_eq!(height(None, "U*"), 1.0); + assert!((silk_stroke(1.0) - 0.15).abs() < 1e-9); + assert!((silk_stroke(0.6) - 0.10).abs() < 1e-9); +} + +#[test] +fn library_script_builds_every_footprint_in_its_folder() { + let script = library_script( + "Parts", + std::path::Path::new("E:\\Libs\\Parts_allegro"), + &["resc1005x40n".to_string(), "sot95p280x145-5n".to_string()], + ); + assert!(script.contains("skill load(\"E:/Libs/Parts_allegro/Parts.il\")")); + assert!(script.contains( + "seex_build_library(\"E:/Libs/Parts_allegro\" list(\"resc1005x40n\" \"sot95p280x145-5n\"))" + )); + assert!(script.contains("axlOpenDesign(?design design ?mode \"wf\")")); + let code: String = script + .lines() + .filter(|line| !line.trim_start().starts_with(';')) + .collect::>() + .join("\n"); + assert_eq!(code.matches('(').count(), code.matches(')').count()); +} diff --git a/src/cadence/workflow.rs b/src/cadence/workflow.rs new file mode 100644 index 0000000..cddd37b --- /dev/null +++ b/src/cadence/workflow.rs @@ -0,0 +1,787 @@ +//! Batch export of LCSC components to Cadence import files. +//! +//! ```text +//! /.xml Capture Library XML: File > Import > Library XML +//! /_allegro/ one SKILL script per footprint, and .il, +//! which builds them all into .dra / .psm / .pad files +//! /_step/ STEP models, mapped by the footprint scripts +//! ``` +//! +//! Like the KiCad export, a run refuses to write into a library that is already there +//! unless it is asked to append. Appending keeps what the library holds and adds the +//! new parts; `force` replaces parts, footprints and models that are already there. +//! Parts that share an IPC-7352 land pattern share one footprint and its 3D model. +//! The models are written as EasyEDA serves them (without the logo, by default) and +//! each footprint maps its model where EasyEDA places it. + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::sync::Semaphore; +use tokio::task::JoinSet; + +use crate::cadence::allegro::{ + StepModel, allegro_identifier, build_allegro_footprint_with, footprint_name_for, library_script, +}; +use crate::cadence::capture::{ + PinLabels, build_capture_part_with, capture_identifier, package_blocks, render_library_xml, +}; +use crate::cadence::ipc7352; +use crate::cadence::step_place::model_on_footprint; +use crate::easyeda::{ComponentData, EasyedaApi}; +use crate::error::{AppError, Result}; +use crate::kicad::workflow::{apply_requested_metadata, validate_lcsc_id}; +use crate::kicad::{KiFootprint, KiPin, KiSymbol, build_ki_footprint, build_ki_symbol}; +use crate::lcsc::LcscClient; +use crate::monitor::ExportProgressCallback; +use crate::step_model::ModelCleanup; + +/// How long one component may take before the batch gives up on it (as for KiCad). +const COMPONENT_TIMEOUT: Duration = Duration::from_secs(180); +/// Allegro keeps package symbol names to 31 characters. +const MAX_FOOTPRINT_NAME: usize = 31; + +#[derive(Debug, Clone, Default)] +pub struct CadenceExportOptions { + pub symbol: bool, + pub footprint: bool, + pub model_3d: bool, + pub full: bool, + pub force: bool, +} + +impl CadenceExportOptions { + pub fn targets_symbol(&self) -> bool { + self.symbol || self.full + } + + pub fn targets_footprint(&self) -> bool { + self.footprint || self.full + } + + pub fn targets_model_3d(&self) -> bool { + self.model_3d || self.full + } +} + +#[derive(Clone)] +pub struct CadenceBatchOptions { + pub ids: Vec, + pub output: PathBuf, + pub library_name: String, + pub export: CadenceExportOptions, + pub english_metadata: bool, + pub parallel: usize, + pub continue_on_error: bool, + /// Add to the library already in the output directory instead of refusing it. + pub append: bool, + /// What happens to downloaded 3D models before they are written (logo removal). + pub models: Arc, + pub progress: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct CadenceBatchSummary { + pub total: usize, + pub skipped: usize, + pub success: usize, + pub failed: usize, + pub failed_ids: Vec, + pub failure_details: Vec, + pub generated_files: Vec, + /// Conversion notes worth reading (lands kept from the manufacturer, renamed pins...). + pub notes: Vec, + pub symbols_written: usize, + pub footprints_written: usize, + /// Footprints calculated as IPC-7352 land patterns. + pub footprints_ipc: usize, + pub models_written: usize, + pub models_skipped: usize, + pub models_missing: usize, +} + +/// Where one library's files go. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CadenceLayout { + /// Library name as Capture accepts it. + pub name: String, + /// Capture Library XML file. + pub xml: PathBuf, + /// Allegro footprint scripts, and the footprints they build. + pub allegro_dir: PathBuf, + /// STEP models. + pub model_dir: PathBuf, +} + +impl CadenceLayout { + pub fn new(output: &Path, library_name: &str) -> Self { + let name = capture_identifier(library_name); + Self { + xml: output.join(format!("{name}.xml")), + allegro_dir: output.join(format!("{name}_allegro")), + model_dir: output.join(format!("{name}_step")), + name, + } + } + + /// The library script that builds every footprint. + pub fn library_script(&self) -> PathBuf { + self.allegro_dir.join(format!("{}.il", self.name)) + } + + fn footprint_script(&self, footprint: &str) -> PathBuf { + self.allegro_dir.join(format!("{footprint}.il")) + } + + fn model_file(&self, footprint: &str) -> PathBuf { + self.model_dir.join(format!("{footprint}.step")) + } + + /// Footprint scripts in the library folder, sorted (the library script excluded). + fn footprint_scripts(&self) -> Vec { + let library = self.name.to_ascii_lowercase(); + let mut names: Vec = fs::read_dir(&self.allegro_dir) + .into_iter() + .flatten() + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| has_extension(path, "il")) + .filter_map(|path| Some(path.file_stem()?.to_str()?.to_string())) + .filter(|stem| stem.to_ascii_lowercase() != library) + .collect(); + names.sort(); + names + } +} + +fn has_extension(path: &Path, ext: &str) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case(ext)) +} + +/// What one component contributed. +struct ExportedPart { + lcsc_id: String, + title: String, + /// `` fragment and part name, when the symbol is written. + symbol: Option<(String, String)>, + footprint_written: bool, + footprint_ipc: bool, + model: Option, + notes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ModelOutcome { + Written, + Skipped, + Missing, +} + +/// State the parallel parts share. +struct Shared { + api: EasyedaApi, + lcsc: Option, + layout: CadenceLayout, + export: CadenceExportOptions, + models: Arc, + /// Capture parts the library already has (upper case), kept unless `force`. + existing_parts: HashSet, + /// Footprints claimed by a part of this run: the first part to use one writes it. + claimed: Mutex>, +} + +pub async fn export_cadence_batch(options: CadenceBatchOptions) -> Result { + validate_options(&options)?; + let layout = CadenceLayout::new(&options.output, &options.library_name); + reject_existing_library(&options, &layout)?; + + let existing_xml = if options.export.targets_symbol() { + fs::read_to_string(&layout.xml).unwrap_or_default() + } else { + String::new() + }; + let existing_blocks = package_blocks(&existing_xml); + let existing_parts = existing_blocks + .iter() + .map(|(name, _)| name.to_ascii_uppercase()) + .collect(); + + let mut ids = Vec::new(); + let mut seen = HashSet::new(); + for id in &options.ids { + let id = id.trim().to_ascii_uppercase(); + if !id.is_empty() && seen.insert(id.clone()) { + ids.push(id); + } + } + + let mut summary = CadenceBatchSummary { + total: ids.len(), + ..CadenceBatchSummary::default() + }; + if let Some(progress) = &options.progress { + progress(0, summary.total, &crate::progress::starting(ids.len())); + } + + let shared = Arc::new(Shared { + api: EasyedaApi::new(), + lcsc: options.english_metadata.then(LcscClient::new), + layout: layout.clone(), + export: options.export.clone(), + models: Arc::clone(&options.models), + existing_parts, + claimed: Mutex::new(HashSet::new()), + }); + let semaphore = Arc::new(Semaphore::new(options.parallel.max(1))); + let done = Arc::new(AtomicUsize::new(0)); + let mut join_set = JoinSet::new(); + for id in ids { + let shared = Arc::clone(&shared); + let semaphore = Arc::clone(&semaphore); + let progress = options.progress.clone(); + let done = Arc::clone(&done); + let total = summary.total; + join_set.spawn(async move { + let _permit = semaphore + .acquire_owned() + .await + .expect("Cadence batch semaphore should remain open"); + if let Some(progress) = &progress { + progress( + done.load(Ordering::Relaxed), + total, + &crate::progress::fetching(&id), + ); + } + let result = + match tokio::time::timeout(COMPONENT_TIMEOUT, export_part(&shared, &id)).await { + Ok(result) => result, + Err(_) => Err(AppError::Other(format!( + "{id}: gave up after {}s", + COMPONENT_TIMEOUT.as_secs() + ))), + }; + done.fetch_add(1, Ordering::Relaxed); + (id, result) + }); + } + + let mut parts = Vec::new(); + let mut first_error = None; + while let Some(joined) = join_set.join_next().await { + let (id, result) = match joined { + Ok(joined) => joined, + Err(err) => ( + "?".to_string(), + Err(AppError::Other(format!( + "Cadence batch task join failed: {err}" + ))), + ), + }; + let message = match result { + Ok(part) => { + summary.success += 1; + let message = crate::progress::exported(&part.lcsc_id, Some(&part.title)); + parts.push(part); + message + } + Err(err) => { + summary.failed += 1; + summary.failed_ids.push(id.clone()); + summary.failure_details.push(format!("{id}: {err}")); + if first_error.is_none() { + first_error = Some(err); + } + crate::progress::failed(&id) + } + }; + if let Some(progress) = &options.progress { + progress(summary.success + summary.failed, summary.total, &message); + } + } + parts.sort_by(|a, b| a.lcsc_id.cmp(&b.lcsc_id)); + + for part in &parts { + let wrote_anything = part.symbol.is_some() + || part.footprint_written + || part.model == Some(ModelOutcome::Written); + if !wrote_anything { + summary.skipped += 1; + } + if part.footprint_written { + summary.footprints_written += 1; + if part.footprint_ipc { + summary.footprints_ipc += 1; + } + } + match part.model { + Some(ModelOutcome::Written) => summary.models_written += 1, + Some(ModelOutcome::Skipped) => summary.models_skipped += 1, + Some(ModelOutcome::Missing) => summary.models_missing += 1, + None => {} + } + summary.notes.extend( + part.notes + .iter() + .map(|note| format!("{}: {note}", part.lcsc_id)), + ); + } + + // The symbols of this run join the ones the library keeps, in one document. + let new_parts: Vec<(String, String)> = parts + .iter() + .filter_map(|part| part.symbol.clone()) + .collect(); + if !new_parts.is_empty() { + summary.symbols_written = new_parts.len(); + let replaced: HashSet = new_parts + .iter() + .map(|(name, _)| name.to_ascii_uppercase()) + .collect(); + let mut packages: Vec = existing_blocks + .into_iter() + .filter(|(name, _)| !replaced.contains(&name.to_ascii_uppercase())) + .map(|(_, block)| block) + .collect(); + packages.extend(new_parts.into_iter().map(|(_, xml)| xml)); + if let Some(dir) = layout.xml.parent() { + fs::create_dir_all(dir)?; + } + fs::write( + &layout.xml, + render_library_xml(&format!("{}.olb", layout.name), &packages), + )?; + } + if options.export.targets_footprint() { + let footprints = layout.footprint_scripts(); + if !footprints.is_empty() { + fs::write( + layout.library_script(), + library_script(&layout.name, &layout.allegro_dir, &footprints), + )?; + } + } + summary.generated_files = generated_files(&layout, &options.export); + + let stopped_with = |summary: &CadenceBatchSummary, first_error: Option| { + let reason = first_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "Cadence export failed".to_string()); + if summary.failed_ids.is_empty() { + AppError::Other(reason) + } else { + AppError::Other(format!( + "{reason}. Failed IDs: {}", + summary.failed_ids.join(", ") + )) + } + }; + if summary.failed > 0 && !options.continue_on_error { + return Err(stopped_with(&summary, first_error)); + } + if summary.success == 0 { + return Err(stopped_with(&summary, first_error)); + } + Ok(summary) +} + +fn validate_options(options: &CadenceBatchOptions) -> Result<()> { + if options.ids.is_empty() { + return Err(AppError::Other( + "no valid LCSC IDs found for Cadence export".to_string(), + )); + } + if options.parallel == 0 { + return Err(AppError::Other("--parallel must be at least 1".to_string())); + } + let export = &options.export; + if !(export.targets_symbol() || export.targets_footprint() || export.targets_model_3d()) { + return Err(AppError::Other( + "choose what to export: symbol, footprint, 3D or full".to_string(), + )); + } + Ok(()) +} + +/// Refuses to write into a library that is already there unless the run appends, +/// the rule the KiCad and Altium exports follow. +fn reject_existing_library(options: &CadenceBatchOptions, layout: &CadenceLayout) -> Result<()> { + if options.append { + return Ok(()); + } + let export = &options.export; + let mut existing = Vec::new(); + if export.targets_symbol() && layout.xml.exists() { + existing.push(layout.xml.display().to_string()); + } + if export.targets_footprint() && !layout.footprint_scripts().is_empty() { + existing.push(layout.allegro_dir.display().to_string()); + } + let models_only = export.model_3d && !export.full && !export.footprint; + if models_only && holds_extension(&layout.model_dir, "step") { + existing.push(layout.model_dir.display().to_string()); + } + if existing.is_empty() { + Ok(()) + } else { + Err(AppError::Other(format!( + "the Cadence library already exists ({}); turn on Append to add to it", + existing.join(", ") + ))) + } +} + +fn holds_extension(dir: &Path, ext: &str) -> bool { + fs::read_dir(dir).is_ok_and(|entries| { + entries + .filter_map(|entry| entry.ok()) + .any(|entry| has_extension(&entry.path(), ext)) + }) +} + +fn generated_files(layout: &CadenceLayout, export: &CadenceExportOptions) -> Vec { + let mut files = Vec::new(); + if export.targets_symbol() && layout.xml.exists() { + files.push(layout.xml.clone()); + } + if export.targets_footprint() && layout.library_script().exists() { + files.push(layout.library_script()); + } + if export.targets_model_3d() && holds_extension(&layout.model_dir, "step") { + files.push(layout.model_dir.clone()); + } + files +} + +async fn export_part(shared: &Shared, lcsc_id: &str) -> Result { + validate_lcsc_id(lcsc_id)?; + let mut component = shared.api.get_component_data(lcsc_id).await?; + apply_requested_metadata(&mut component, shared.lcsc.as_ref()).await; + let export = &shared.export; + let layout = &shared.layout; + let title = strip_lcsc_suffix(&component.title, lcsc_id); + let part_name = capture_identifier(&format!("{title}_{lcsc_id}")); + let mut notes = Vec::new(); + + // The symbol's pins say which pad is the cathode; the footprint name is set + // once the footprint is known. + let mut ki_symbol = build_ki_symbol(&component, &part_name, String::new()); + let first = first_pin(ki_symbol.as_ref().ok(), &component.designator); + let chosen = footprint_for(&component, lcsc_id, &title, &first, &mut notes)?; + let footprint_name = chosen.name.clone(); + + let mut symbol = None; + if export.targets_symbol() + && (export.force + || !shared + .existing_parts + .contains(&part_name.to_ascii_uppercase())) + { + let ki_symbol = ki_symbol + .as_mut() + .map_err(|err| AppError::Other(err.to_string()))?; + ki_symbol.footprint = footprint_name.clone(); + let part = build_capture_part_with( + ki_symbol, + &part_name, + &footprint_name, + pin_labels(&component), + ); + notes.extend(part.warnings.iter().map(|w| format!("symbol: {w}"))); + symbol = Some((part.name, part.xml)); + } + + // The first part of the run that uses a footprint writes it and its model. + let first_user = shared + .claimed + .lock() + .map(|mut claimed| claimed.insert(footprint_name.clone())) + .unwrap_or(true); + let model_file = layout.model_file(&footprint_name); + let mut model = None; + if export.targets_model_3d() { + model = Some(match &component.model_3d { + None => ModelOutcome::Missing, + Some(_) if !first_user => ModelOutcome::Skipped, + Some(_) if model_file.exists() && !export.force => ModelOutcome::Skipped, + Some(info) => { + let step = shared.api.download_3d_step(&info.uuid).await?; + // Written as EasyEDA has it: the footprint maps it with EasyEDA's own + // placement (see `step_place`). + let step = shared.models.clean(lcsc_id, &step); + fs::create_dir_all(&layout.model_dir)?; + fs::write(&model_file, &step)?; + ModelOutcome::Written + } + }); + } + + let script = layout.footprint_script(&footprint_name); + let footprint_written = export.targets_footprint() + && first_user + && (export.force || !script.exists() || model == Some(ModelOutcome::Written)); + if footprint_written { + let model_dir = layout.model_dir.clone(); + let model_name = format!("{footprint_name}.step"); + let step = if model_file.exists() && component.model_3d.is_some() { + let data = fs::read(&model_file).ok(); + Some(StepModel { + file: &model_name, + dir: &model_dir, + transform: model_on_footprint( + &component, + &chosen.easyeda, + chosen.ipc, + data.as_deref(), + &mut notes, + ), + }) + } else { + None + }; + let refdes = designator_prefix(&component.designator).unwrap_or_else(|| "U".to_string()); + let allegro = + build_allegro_footprint_with(&chosen.footprint, &footprint_name, &refdes, step); + notes.extend(allegro.warnings.iter().map(|w| format!("footprint: {w}"))); + fs::create_dir_all(&layout.allegro_dir)?; + fs::write(&script, allegro.script)?; + } + + Ok(ExportedPart { + lcsc_id: lcsc_id.to_string(), + title, + symbol, + footprint_written, + footprint_ipc: chosen.ipc.is_some(), + model, + notes, + }) +} + +/// The footprint chosen for a part. +struct ChosenFootprint { + name: String, + footprint: KiFootprint, + /// EasyEDA's own footprint, which its model placement refers to. + easyeda: KiFootprint, + /// For a calculated land pattern: its turn (degrees) and shift (mm) from + /// EasyEDA's footprint. + ipc: Option<(f64, (f64, f64))>, +} + +/// The footprint for a part: the IPC-7352 land pattern when SeEx can calculate it +/// for the package, otherwise EasyEDA's lands with IPC-7352 courtyard and +/// silkscreen rules. +fn footprint_for( + component: &ComponentData, + lcsc_id: &str, + title: &str, + first: &str, + notes: &mut Vec, +) -> Result { + let own_name = footprint_name_for(lcsc_id, title); + let easyeda = build_ki_footprint(component, &own_name)?; + let prefix = designator_prefix(&component.designator).unwrap_or_default(); + match ipc7352::land_pattern(&component.package, &prefix, &easyeda, first) { + Ok(pattern) => { + notes.extend(pattern.notes.iter().cloned()); + Ok(ChosenFootprint { + name: allegro_identifier(&pattern.name, MAX_FOOTPRINT_NAME), + footprint: pattern.footprint, + easyeda, + ipc: Some((pattern.turn, pattern.shift)), + }) + } + Err(reason) => { + notes.push(format!( + "IPC-7352 lands not calculated ({reason}); the manufacturer's lands are kept with IPC-7352 courtyard and silkscreen rules" + )); + let (footprint, _) = ipc7352::conform(&easyeda); + Ok(ChosenFootprint { + name: own_name, + footprint, + easyeda, + ipc: None, + }) + } + } +} + +/// The pin IPC puts first: the cathode of a diode or LED (the pin named K or +/// cathode), otherwise pin 1. +fn first_pin(symbol: Option<&KiSymbol>, designator: &str) -> String { + let polarized = matches!(designator_prefix(designator).as_deref(), Some("D" | "LED")); + let pins: Vec<&KiPin> = symbol + .map(|symbol| { + symbol + .pins + .iter() + .chain(symbol.units.iter().flat_map(|unit| unit.pins.iter())) + .collect() + }) + .unwrap_or_default(); + if polarized { + let cathode = pins.iter().find(|pin| { + let name = pin.name.trim().to_ascii_uppercase(); + name == "K" || name == "CATHODE" || name == "KA" || name == "C" && pins.len() == 2 + }); + if let Some(pin) = cathode { + return pin.number.clone(); + } + } + "1".to_string() +} + +/// `BAT54WS_C466657` -> `BAT54WS` for part C466657. +fn strip_lcsc_suffix(title: &str, lcsc_id: &str) -> String { + let title = title.trim(); + for sep in ['_', '-', ' '] { + let suffix = format!("{sep}{lcsc_id}"); + if title.len() > suffix.len() + && title + .to_ascii_uppercase() + .ends_with(&suffix.to_ascii_uppercase()) + { + return title[..title.len() - suffix.len()].to_string(); + } + } + title.to_string() +} + +/// Reference prefix from EasyEDA's designator (`R?` -> `R`, `U1` -> `U`). +fn designator_prefix(designator: &str) -> Option { + let prefix: String = designator + .trim() + .trim_end_matches(|c: char| c == '?' || c.is_ascii_digit()) + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + (!prefix.is_empty()).then_some(prefix) +} + +/// Show pin names / numbers only if EasyEDA shows them on at least one pin +/// (resistors, capacitors and the like hide both). +fn pin_labels(component: &ComponentData) -> PinLabels { + let lines = component.data_str.iter().chain( + component + .symbol_parts + .iter() + .flat_map(|part| part.data_str.iter()), + ); + let shown = |segment: Option<&str>| { + segment + .and_then(|segment| segment.split('~').next()) + .is_some_and(|flag| !matches!(flag.trim(), "" | "0" | "none")) + }; + let mut any_pin = false; + let mut labels = PinLabels { + names: false, + numbers: false, + }; + for line in lines.filter(|line| line.starts_with("P~")) { + any_pin = true; + let segments: Vec<&str> = line.split("^^").collect(); + labels.names |= shown(segments.get(3).copied()); + labels.numbers |= shown(segments.get(4).copied()); + } + if any_pin { + labels + } else { + PinLabels::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(tag: &str) -> PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("seex_cadence_{tag}_{stamp}")); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn options(output: &Path, append: bool) -> CadenceBatchOptions { + CadenceBatchOptions { + ids: vec!["C25744".to_string()], + output: output.to_path_buf(), + library_name: "Parts".to_string(), + export: CadenceExportOptions { + full: true, + ..CadenceExportOptions::default() + }, + english_metadata: false, + parallel: 1, + continue_on_error: false, + append, + models: Default::default(), + progress: None, + } + } + + #[test] + fn the_library_files_sit_side_by_side() { + let layout = CadenceLayout::new(Path::new("out"), "My Parts"); + assert_eq!(layout.name, "My_Parts"); + assert_eq!(layout.xml, Path::new("out").join("My_Parts.xml")); + assert_eq!( + layout.allegro_dir, + Path::new("out").join("My_Parts_allegro") + ); + assert_eq!(layout.model_dir, Path::new("out").join("My_Parts_step")); + assert_eq!( + layout.library_script(), + Path::new("out") + .join("My_Parts_allegro") + .join("My_Parts.il") + ); + } + + #[test] + fn an_existing_library_is_only_written_into_when_appending() { + let output = temp_dir("append"); + let layout = CadenceLayout::new(&output, "Parts"); + fs::write(&layout.xml, "").unwrap(); + let refused = reject_existing_library(&options(&output, false), &layout); + assert!(refused.unwrap_err().to_string().contains("Append")); + assert!(reject_existing_library(&options(&output, true), &layout).is_ok()); + let _ = fs::remove_dir_all(&output); + } + + #[test] + fn the_library_script_skips_itself() { + let output = temp_dir("scripts"); + let layout = CadenceLayout::new(&output, "Parts"); + fs::create_dir_all(&layout.allegro_dir).unwrap(); + for name in ["sot95p280x145-5n", "Parts", "resc1005x40n"] { + fs::write(layout.allegro_dir.join(format!("{name}.il")), "").unwrap(); + } + fs::write(layout.allegro_dir.join("notes.txt"), "").unwrap(); + assert_eq!( + layout.footprint_scripts(), + ["resc1005x40n", "sot95p280x145-5n"] + ); + let _ = fs::remove_dir_all(&output); + } + + #[test] + fn names_lose_the_lcsc_suffix_and_designators_their_number() { + assert_eq!(strip_lcsc_suffix("BAT54WS_C466657", "C466657"), "BAT54WS"); + assert_eq!( + strip_lcsc_suffix("0402WGF1002TCE", "C25744"), + "0402WGF1002TCE" + ); + assert_eq!(designator_prefix("R?").as_deref(), Some("R")); + assert_eq!(designator_prefix("U12").as_deref(), Some("U")); + assert_eq!(designator_prefix("?"), None); + } +} diff --git a/src/config.rs b/src/config.rs index 86633d7..dd1a5b0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -58,6 +58,7 @@ pub struct ExportConfig { pub output_path: String, pub mode: String, pub kicad_mode: String, + pub cadence_mode: String, pub merge: bool, pub append: bool, pub library_name: String, @@ -70,6 +71,8 @@ pub struct ExportConfig { /// `None` is the "no colour" choice: the symbol body is exported without a /// fill rather than painted in some default. pub schematic_fill_color: Option<[u8; 3]>, + /// Rebuild downloaded 3D models without the EasyEDA logo; on unless turned off. + pub remove_model_logo: bool, } impl Default for ExportConfig { @@ -79,6 +82,7 @@ impl Default for ExportConfig { output_path: "export".to_string(), mode: "full".to_string(), kicad_mode: "full".to_string(), + cadence_mode: "full".to_string(), merge: false, append: false, library_name: "SeExMerged".to_string(), @@ -89,6 +93,7 @@ impl Default for ExportConfig { // Preserve the existing Altium symbol colors as the initial choices. schematic_stroke_color: [11, 56, 104], schematic_fill_color: Some([255, 255, 224]), + remove_model_logo: true, } } } diff --git a/src/easyeda/api.rs b/src/easyeda/api.rs index e4ced8d..a896310 100644 --- a/src/easyeda/api.rs +++ b/src/easyeda/api.rs @@ -13,13 +13,18 @@ use tokio::sync::{Mutex, OnceCell}; const MAX_REQUEST_ATTEMPTS: u32 = 4; const RETRY_BASE_DELAY_MS: u64 = 250; const STEP_DOWNLOAD_ATTEMPTS: u32 = 2; -const STEP_DOWNLOAD_TOTAL_TIMEOUT_SECS: u64 = 60; +/// Detailed models reach 10 MB; through the easyeda.com mirror one can take well +/// over a minute. +const STEP_DOWNLOAD_TOTAL_TIMEOUT_SECS: u64 = 180; const STEP_DOWNLOAD_STALL_TIMEOUT_SECS: u64 = 20; const UPSTREAM_BLOCK_RETRY_DELAY_MS: u64 = 10_000; const UPSTREAM_BLOCK_COOLDOWN_MS: u64 = 45_000; const COMPONENT_REQUEST_INTERVAL_MS: u64 = 150; const MODEL_REQUEST_INTERVAL_MS: u64 = 150; const MODEL_DOWNLOAD_BASE_URL: &str = "https://modules.lceda.cn/qAxj6KHrDKw4blvCG8QJPs7Y"; +/// The same models on EasyEDA's international host, used when lceda.cn cannot be +/// reached from the user's network (seen as a failed TLS handshake). +const MODEL_DOWNLOAD_MIRROR_URL: &str = "https://modules.easyeda.com/qAxj6KHrDKw4blvCG8QJPs7Y"; /// How many downloaded models the cache keeps. /// /// The models are the largest thing a run holds: a 25-part batch of them was @@ -403,6 +408,7 @@ impl EasyedaApi { manufacturer, datasheet, jlc_id, + package, }) } @@ -438,6 +444,7 @@ impl EasyedaApi { uuid, title, rotation_z, + placement: model_placement(attrs), }); } } @@ -448,6 +455,7 @@ impl EasyedaApi { pub async fn download_3d_step(&self, uuid: &str) -> Result>> { let url = format!("{MODEL_DOWNLOAD_BASE_URL}/{uuid}"); + let mirror = format!("{MODEL_DOWNLOAD_MIRROR_URL}/{uuid}"); let cell = { let mut cache = self.step_cache.lock().await; cache @@ -458,10 +466,21 @@ impl EasyedaApi { let result = cell .get_or_init(|| async { - self.download_with_retry(&url, "STEP", uuid, ModelSink::Memory) + let bytes = match self + .download_with_retry(&url, "STEP", uuid, ModelSink::Memory) .await - .map(|bytes| Arc::new(bytes.expect("a memory download returns its bytes"))) - .map_err(|e| e.to_string()) + { + Ok(bytes) => Ok(bytes), + Err(primary) => { + log::warn!( + "STEP download from lceda.cn failed ({primary}); trying easyeda.com" + ); + self.download_with_retry(&mirror, "STEP", uuid, ModelSink::Memory) + .await + .map_err(|mirror_err| format!("{primary}; mirror: {mirror_err}")) + } + }; + bytes.map(|bytes| Arc::new(bytes.expect("a memory download returns its bytes"))) }) .await .clone(); @@ -484,8 +503,15 @@ impl EasyedaApi { /// hold one model in memory per parallel part while they are in flight. pub async fn download_3d_step_to_file(&self, uuid: &str, path: &Path) -> Result<()> { let url = format!("{MODEL_DOWNLOAD_BASE_URL}/{uuid}"); - self.download_with_retry(&url, "STEP", uuid, ModelSink::File(path)) - .await?; + if let Err(primary) = self + .download_with_retry(&url, "STEP", uuid, ModelSink::File(path)) + .await + { + log::warn!("STEP download from lceda.cn failed ({primary}); trying easyeda.com"); + let mirror = format!("{MODEL_DOWNLOAD_MIRROR_URL}/{uuid}"); + self.download_with_retry(&mirror, "STEP", uuid, ModelSink::File(path)) + .await?; + } Ok(()) } @@ -896,3 +922,32 @@ mod tests { ); } } + +/// `c_origin` / `z` / `c_rotation` of an EasyEDA `outline3D` node. +fn model_placement(attrs: &serde_json::Value) -> Option { + let numbers = |key: &str| -> Vec { + let text = match attrs.get(key) { + Some(serde_json::Value::String(text)) => text.clone(), + Some(serde_json::Value::Number(number)) => number.to_string(), + _ => String::new(), + }; + text.split(',') + .filter_map(|part| part.trim().parse::().ok()) + .collect() + }; + let origin = numbers("c_origin"); + if origin.len() < 2 { + return None; + } + let rotation = numbers("c_rotation"); + Some(crate::easyeda::models::ModelPlacement { + origin_x: origin[0], + origin_y: origin[1], + z: numbers("z").first().copied().unwrap_or(0.0), + rotation: [ + rotation.first().copied().unwrap_or(0.0), + rotation.get(1).copied().unwrap_or(0.0), + rotation.get(2).copied().unwrap_or(0.0), + ], + }) +} diff --git a/src/easyeda/models.rs b/src/easyeda/models.rs index bcb3965..4a84cb0 100644 --- a/src/easyeda/models.rs +++ b/src/easyeda/models.rs @@ -18,6 +18,9 @@ pub struct ComponentData { pub manufacturer: String, pub datasheet: String, pub jlc_id: String, + /// EasyEDA package name (`c_para.package`), e.g. `SOT-23-5_L2.9-W1.6-P0.95-LS2.8-BR`. + #[serde(default)] + pub package: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -36,6 +39,21 @@ pub struct Model3dInfo { /// Consumers convert it into their own frame with `crate::util::model_rotation_z`. #[serde(default)] pub rotation_z: f64, + /// Where EasyEDA places the model on the footprint (the `outline3D` node). + #[serde(default)] + pub placement: Option, +} + +/// EasyEDA 3D model placement, in EasyEDA canvas units (1 px = 10 mil, Y down). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct ModelPlacement { + /// Model centre (`c_origin`). + pub origin_x: f64, + pub origin_y: f64, + /// Height of the model bottom above the board (`z`). + pub z: f64, + /// Rotation about x, y and z in degrees (`c_rotation`). + pub rotation: [f64; 3], } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/exporter.rs b/src/exporter.rs index 0be2ab6..1cbafe4 100644 --- a/src/exporter.rs +++ b/src/exporter.rs @@ -1,11 +1,15 @@ use crate::altium::workflow::download_step; use crate::batch::{BatchOptions, BatchSummary, export_batch}; +use crate::cadence::{ + CadenceBatchOptions, CadenceBatchSummary, CadenceExportOptions, export_cadence_batch, +}; use crate::cli::KicadVersion; use crate::kicad::{ KicadBatchOptions, KicadBatchSummary, KicadExportOptions, KicadLibraryOptions, export_kicad_batch, }; use crate::lceda::LcedaClient; +use crate::step_model::ModelCleanup; use futures_util::stream::{self, StreamExt}; use std::fs; use std::io::{BufWriter, Write}; @@ -32,6 +36,8 @@ pub struct ExportRequest { pub force: bool, pub schematic_stroke_color: [u8; 3], pub schematic_fill_color: Option<[u8; 3]>, + /// Rebuild downloaded 3D models without the EasyEDA logo (on by default). + pub remove_model_logo: bool, } pub fn spawn_export(state: Arc>, req: ExportRequest) { @@ -47,19 +53,20 @@ pub fn spawn_export(state: Arc>, req: ExportRequest) { std::thread::spawn(move || { let started_at = Instant::now(); - let input_path = if is_kicad_target(&req) { - None - } else { + let models = Arc::new(ModelCleanup::new(req.remove_model_logo)); + let input_path = if is_altium_target(&req) { Some(create_temp_input_path()) + } else { + None }; let result: Result = (|| { if let Some(path) = &input_path { write_ids_file(path, &req.ids)?; } set_progress(&state, running_message(&req), 0, req.ids.len()); - if !is_kicad_target(&req) { + if is_altium_target(&req) { if normalize_altium_mode(&req.mode) == "3d" { - return run_altium_3d_api(&req, &state, started_at); + return run_altium_3d_api(&req, &state, started_at, &models); } let input_path = input_path .as_ref() @@ -69,6 +76,7 @@ pub fn spawn_export(state: Arc>, req: ExportRequest) { input_path, started_at, progress_callback(state.clone()), + &models, ); } @@ -77,9 +85,20 @@ pub fn spawn_export(state: Arc>, req: ExportRequest) { .build() .map_err(|err| err.to_string())?; runtime.block_on(async { + if is_cadence_target(&req) { + let summary = export_cadence_batch(build_cadence_batch_options( + &req, + Some(progress_callback(state.clone())), + &models, + )) + .await + .map_err(|e| e.to_string())?; + return Ok(format_cadence_summary(&req, &summary, started_at.elapsed())); + } let summary = export_kicad_batch(build_kicad_batch_options( &req, Some(progress_callback(state.clone())), + &models, )) .await .map_err(|e| e.to_string())?; @@ -91,10 +110,16 @@ pub fn spawn_export(state: Arc>, req: ExportRequest) { let _ = fs::remove_file(path); } - let message = match result { + let mut message = match result { Ok(message) => message, Err(err) => format_error(&req, &err, started_at.elapsed()), }; + if exports_models(&req) { + for line in models.summary_lines() { + message.push('\n'); + message.push_str(&line); + } + } if let Ok(mut s) = state.lock() { s.export_running = false; @@ -124,6 +149,8 @@ fn running_message(req: &ExportRequest) -> String { let count = req.ids.len(); if is_kicad_target(req) { format!("Running export KiCad batch for {} items...", count) + } else if is_cadence_target(req) { + format!("Running Cadence export for {} items...", count) } else if normalize_altium_mode(&req.mode) == "3d" { format!("Downloading Altium 3D models for {} items...", count) } else if req.merge && req.append { @@ -160,6 +187,7 @@ fn run_altium_api( input_path: &Path, started_at: Instant, progress: ExportProgressCallback, + models: &Arc, ) -> Result { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -169,7 +197,7 @@ fn run_altium_api( let client = LcedaClient::new(); let summary = export_batch( &client, - build_batch_options(req, &input_path.to_path_buf(), Some(progress)), + build_batch_options(req, &input_path.to_path_buf(), Some(progress), models), ) .await .map_err(|e| e.to_string())?; @@ -181,6 +209,7 @@ fn run_altium_3d_api( req: &ExportRequest, state: &Arc>, started_at: Instant, + models: &Arc, ) -> Result { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -195,10 +224,11 @@ fn run_altium_3d_api( let mut jobs = stream::iter(req.ids.clone().into_iter().map(|id| { let client = client.clone(); let output = output.clone(); + let models = Arc::clone(models); async move { let result = async { let item = client.select_item(&id, 1).await?; - download_step(&client, &item, &output, force).await + download_step(&client, &item, &output, force, &models).await } .await; (id, result) @@ -247,6 +277,7 @@ fn build_batch_options( req: &ExportRequest, input_path: &PathBuf, progress: Option, + models: &Arc, ) -> BatchOptions { let mode = normalize_altium_mode(&req.mode); let append = req.append && req.merge; @@ -265,6 +296,7 @@ fn build_batch_options( force: req.force, schematic_stroke_color: req.schematic_stroke_color, schematic_fill_color: req.schematic_fill_color, + models: Arc::clone(models), progress, } } @@ -272,6 +304,7 @@ fn build_batch_options( fn build_kicad_batch_options( req: &ExportRequest, progress: Option, + models: &Arc, ) -> KicadBatchOptions { let mode = normalize_kicad_mode(&req.mode); KicadBatchOptions { @@ -290,11 +323,38 @@ fn build_kicad_batch_options( full: mode == "full", force: req.force, kicad_version: KicadVersion::V6, + models: Arc::clone(models), + }, + english_metadata: req.lcsc_english, + parallel: req.parallel.max(1), + continue_on_error: req.continue_on_error, + append: req.append, + progress, + } +} + +fn build_cadence_batch_options( + req: &ExportRequest, + progress: Option, + models: &Arc, +) -> CadenceBatchOptions { + let mode = normalize_cadence_mode(&req.mode); + CadenceBatchOptions { + ids: req.ids.clone(), + output: PathBuf::from(normalize_output_path(&req.output_path)), + library_name: resolve_library_name(req), + export: CadenceExportOptions { + symbol: mode == "symbol", + footprint: mode == "footprint", + model_3d: mode == "3d", + full: mode == "full", + force: req.force, }, english_metadata: req.lcsc_english, parallel: req.parallel.max(1), continue_on_error: req.continue_on_error, append: req.append, + models: Arc::clone(models), progress, } } @@ -314,9 +374,29 @@ fn is_kicad_target(req: &ExportRequest) -> bool { normalize_target(&req.target) == "kicad" } +fn is_cadence_target(req: &ExportRequest) -> bool { + normalize_target(&req.target) == "cadence" +} + +fn is_altium_target(req: &ExportRequest) -> bool { + normalize_target(&req.target) == "altium" +} + +/// Whether the run downloads 3D models, so its logo removal is worth reporting. +fn exports_models(req: &ExportRequest) -> bool { + if is_altium_target(req) { + matches!(normalize_altium_mode(&req.mode), "full" | "pcblib" | "3d") + } else if is_cadence_target(req) { + matches!(normalize_cadence_mode(&req.mode), "full" | "3d") + } else { + matches!(normalize_kicad_mode(&req.mode), "full" | "3d") + } +} + fn normalize_target(target: &str) -> &str { match target.trim().to_ascii_lowercase().as_str() { "kicad" => "kicad", + "cadence" => "cadence", _ => "altium", } } @@ -339,6 +419,15 @@ fn normalize_kicad_mode(mode: &str) -> &str { } } +fn normalize_cadence_mode(mode: &str) -> &str { + match mode.trim().to_ascii_lowercase().as_str() { + "symbol" => "symbol", + "footprint" => "footprint", + "3d" => "3d", + _ => "full", + } +} + fn normalize_output_path(path: &str) -> String { let trimmed = path.trim(); if trimmed.is_empty() { @@ -349,7 +438,7 @@ fn normalize_output_path(path: &str) -> String { } fn effective_library_name(req: &ExportRequest) -> Option { - if !is_kicad_target(req) && !req.merge { + if is_altium_target(req) && !req.merge { return None; } @@ -480,6 +569,68 @@ fn format_kicad_summary( lines.join("\n") } +fn format_cadence_summary( + req: &ExportRequest, + summary: &CadenceBatchSummary, + elapsed: Duration, +) -> String { + let mut lines = vec![ + "Status: Success".to_string(), + "Target: Cadence".to_string(), + format!("Elapsed: {}", format_duration(elapsed)), + format!( + "export Cadence batch complete. Total: {} | Skipped: {} | Success: {} | Failed: {}", + summary.total, summary.skipped, summary.success, summary.failed, + ), + format!( + "Targets: {} | Parallel: {}", + mode_label(req), + req.parallel.max(1), + ), + format!( + "Continue on error: {} | LCSC English: {} | Force: {}", + yes_no(req.continue_on_error), + yes_no(req.lcsc_english), + yes_no(req.force), + ), + format!("Library name: {}", resolve_library_name(req)), + format!( + "Capture symbols: {} | Allegro footprints: {} ({} IPC-7352) | 3D models: written {} | skipped {} | missing {}", + summary.symbols_written, + summary.footprints_written, + summary.footprints_ipc, + summary.models_written, + summary.models_skipped, + summary.models_missing, + ), + ]; + + if !summary.failed_ids.is_empty() { + lines.push(format!("Failed IDs: {}", summary.failed_ids.join(", "))); + } + if !summary.failure_details.is_empty() { + lines.push("Failure details:".to_string()); + for detail in &summary.failure_details { + lines.push(format!("- {}", detail)); + } + } + for path in &summary.generated_files { + lines.push(format!("Generated: {}", path.display())); + } + lines.push("Capture: File > Import > Library XML turns the .xml into an .olb".to_string()); + lines.push( + "Allegro: in PCB Editor run skill load(\"_allegro/.il\") to build the .dra/.psm/.pad files" + .to_string(), + ); + if !summary.notes.is_empty() { + lines.push("Notes:".to_string()); + for note in &summary.notes { + lines.push(format!("- {}", note)); + } + } + lines.join("\n") +} + fn format_duration(duration: Duration) -> String { let total_ms = duration.as_millis(); if total_ms < 60_000 { @@ -500,6 +651,8 @@ fn format_duration(duration: Duration) -> String { fn format_error(req: &ExportRequest, error: &str, elapsed: Duration) -> String { let target = if is_kicad_target(req) { "KiCad" + } else if is_cadence_target(req) { + "Cadence" } else { "Altium" }; @@ -527,7 +680,14 @@ fn format_error(req: &ExportRequest, error: &str, elapsed: Duration) -> String { } fn mode_label(req: &ExportRequest) -> &'static str { - if is_kicad_target(req) { + if is_cadence_target(req) { + match normalize_cadence_mode(&req.mode) { + "symbol" => "Capture", + "footprint" => "Allegro", + "3d" => "3D", + _ => "Full", + } + } else if is_kicad_target(req) { match normalize_kicad_mode(&req.mode) { "symbol" => "Symbol", "footprint" => "Footprint", @@ -553,7 +713,14 @@ fn merge_label(req: &ExportRequest) -> &'static str { } fn target_folders(req: &ExportRequest) -> Vec<&'static str> { - if is_kicad_target(req) { + if is_cadence_target(req) { + match normalize_cadence_mode(&req.mode) { + "symbol" => vec!["*.xml"], + "footprint" => vec!["*_allegro"], + "3d" => vec!["*_step"], + _ => vec!["*.xml", "*_allegro", "*_step"], + } + } else if is_kicad_target(req) { match normalize_kicad_mode(&req.mode) { "symbol" => vec!["*.kicad_sym"], "footprint" => vec!["*.pretty"], diff --git a/src/kicad/footprint_converter.rs b/src/kicad/footprint_converter.rs index 7df7b95..afe1578 100644 --- a/src/kicad/footprint_converter.rs +++ b/src/kicad/footprint_converter.rs @@ -1,3 +1,4 @@ +use crate::cli::KicadVersion; use crate::easyeda::svg_parser::{SvgCommand, parse_svg_path}; use crate::easyeda::{ComponentData, FootprintImporter}; use crate::error::Result; @@ -14,15 +15,71 @@ pub fn convert_footprint( lib_manager: &LibraryManager, lcsc_id: &str, ) -> Result<()> { - let ee_footprint = FootprintImporter::parse(&component_data.package_detail)?; - let converter = Converter::new(args.kicad_version); - // Use LCSC ID as unique identifier to prevent name collisions let footprint_name = format!("{}_{}", sanitize_name(&component_data.title), lcsc_id); + let mut ki_footprint = build_ki_footprint(component_data, &footprint_name)?; + + // Add 3D model reference if available + if let Some(model_info) = &component_data.model_3d + && args.targets_model_3d() + { + // Use LCSC ID as unique identifier to prevent name collisions + let model_name = format!("{}_{}", sanitize_name(&model_info.title), lcsc_id); + + // Prefer STEP format as it's more widely supported + let model_lib_name = lib_manager.model_lib_name(); + let model_dir_name = lib_manager.model_dir_name(); + let model_path = format!( + "${{{}}}/{}/{}.step", + model_lib_name, model_dir_name, model_name + ); + + ki_footprint.model_3d = Some(kicad::Ki3dModel { + path: model_path, + // The model's own coordinates already place it: its rotation is the only thing the + // exported footprint adds, and the STEP itself is lifted onto the board plane when it + // is written (see `crate::step_model`). + offset: (0.0, 0.0, 0.0), + scale: (1.0, 1.0, 1.0), + rotate: ( + 0.0, + 0.0, + crate::util::model_rotation_z(model_info.rotation_z), + ), + }); + } + + // Export footprint + let exporter = kicad::FootprintExporter::new(); + let footprint_data = exporter.export(&ki_footprint)?; + let (_, status) = + lib_manager.write_footprint_if_needed(&ki_footprint.name, &footprint_data, args.force)?; + + match status { + FileWriteStatus::Written => log::debug!("Wrote footprint: {}", ki_footprint.name), + FileWriteStatus::Skipped => { + log::debug!("Skipped existing footprint: {}", ki_footprint.name) + } + } + + Ok(()) +} + +/// Convert EasyEDA footprint data into the EDA-neutral `KiFootprint` model. +/// +/// Coordinates stay in EasyEDA units (1 px = 10 mil) with Y pointing down, relative +/// to the package bbox origin. A courtyard is generated when the source has none. +/// No 3D model is attached; callers decide how models are referenced. +pub fn build_ki_footprint( + component_data: &ComponentData, + footprint_name: &str, +) -> Result { + let ee_footprint = FootprintImporter::parse(&component_data.package_detail)?; + let converter = Converter::new(KicadVersion::V6); // Convert EasyEDA footprint to KiCad footprint let mut ki_footprint = kicad::KiFootprint { - name: footprint_name, + name: footprint_name.to_string(), pads: Vec::new(), tracks: Vec::new(), circles: Vec::new(), @@ -426,50 +483,7 @@ pub fn convert_footprint( add_generated_courtyard_if_missing(&mut ki_footprint, &converter); - // Add 3D model reference if available - if let Some(model_info) = &component_data.model_3d - && args.targets_model_3d() - { - // Use LCSC ID as unique identifier to prevent name collisions - let model_name = format!("{}_{}", sanitize_name(&model_info.title), lcsc_id); - - // Prefer STEP format as it's more widely supported - let model_lib_name = lib_manager.model_lib_name(); - let model_dir_name = lib_manager.model_dir_name(); - let model_path = format!( - "${{{}}}/{}/{}.step", - model_lib_name, model_dir_name, model_name - ); - - ki_footprint.model_3d = Some(kicad::Ki3dModel { - path: model_path, - // The model's own coordinates already place it: its rotation is the only thing the - // exported footprint adds, and the STEP itself is lifted onto the board plane when it - // is written (see `crate::step_model`). - offset: (0.0, 0.0, 0.0), - scale: (1.0, 1.0, 1.0), - rotate: ( - 0.0, - 0.0, - crate::util::model_rotation_z(model_info.rotation_z), - ), - }); - } - - // Export footprint - let exporter = kicad::FootprintExporter::new(); - let footprint_data = exporter.export(&ki_footprint)?; - let (_, status) = - lib_manager.write_footprint_if_needed(&ki_footprint.name, &footprint_data, args.force)?; - - match status { - FileWriteStatus::Written => log::debug!("Wrote footprint: {}", ki_footprint.name), - FileWriteStatus::Skipped => { - log::debug!("Skipped existing footprint: {}", ki_footprint.name) - } - } - - Ok(()) + Ok(ki_footprint) } fn is_footprint_graphic_layer(layer_id: i32) -> bool { @@ -668,7 +682,6 @@ impl Bounds { #[cfg(test)] mod tests { use super::*; - use crate::cli::KicadVersion; #[test] fn generates_front_courtyard_when_missing() { diff --git a/src/kicad/mod.rs b/src/kicad/mod.rs index 5614b26..120df12 100644 --- a/src/kicad/mod.rs +++ b/src/kicad/mod.rs @@ -16,7 +16,7 @@ pub use footprint::{ Drill, Ki3dModel, KiArc as FootprintKiArc, KiCircle as FootprintKiCircle, KiFootprint, KiLine, KiPad, KiText, KiTrack, PadShape, PadType, }; -pub use footprint_converter::convert_footprint; +pub use footprint_converter::{build_ki_footprint, convert_footprint}; pub use footprint_exporter::FootprintExporter; pub use layers::*; pub use library::{FileWriteStatus, LibraryManager, SymbolWriteStatus}; @@ -27,7 +27,7 @@ pub use symbol::KiText as SymbolKiText; pub use symbol::{ KiCircle, KiPin, KiPolyline, KiRectangle, KiSymbol, KiSymbolUnit, PinStyle, PinType, }; -pub use symbol_converter::convert_symbol; +pub use symbol_converter::{build_ki_symbol, convert_symbol}; pub use symbol_exporter::SymbolExporter; pub use workflow::{ KicadBatchOptions, KicadBatchSummary, KicadExportedComponent, KicadSingleOptions, diff --git a/src/kicad/model_converter.rs b/src/kicad/model_converter.rs index b0991f8..be03f18 100644 --- a/src/kicad/model_converter.rs +++ b/src/kicad/model_converter.rs @@ -87,6 +87,7 @@ pub async fn convert_3d_model( match result { Ok(step_data) => { + let step_data = args.models.clean(lcsc_id, &step_data); lib_manager.write_step_model(&model_name, &step_data)?; log::debug!("Wrote STEP model: {}", model_name); Ok(ModelExportStatus::Written) diff --git a/src/kicad/options.rs b/src/kicad/options.rs index 4e6f8ba..9072b17 100644 --- a/src/kicad/options.rs +++ b/src/kicad/options.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; +use std::sync::Arc; use crate::cli::KicadVersion; +use crate::step_model::ModelCleanup; #[derive(Debug, Clone)] pub struct KicadExportOptions { @@ -10,6 +12,8 @@ pub struct KicadExportOptions { pub full: bool, pub force: bool, pub kicad_version: KicadVersion, + /// What happens to downloaded 3D models before they are written (logo removal). + pub models: Arc, } impl KicadExportOptions { diff --git a/src/kicad/symbol_converter.rs b/src/kicad/symbol_converter.rs index 75e5854..06acc1e 100644 --- a/src/kicad/symbol_converter.rs +++ b/src/kicad/symbol_converter.rs @@ -13,7 +13,53 @@ pub fn convert_symbol( // Use LCSC ID as unique identifier to prevent name collisions let component_name = format!("{}_{}", sanitize_name(&component_data.title), lcsc_id); let footprint_name = component_name.clone(); + let ki_symbol = build_ki_symbol( + component_data, + &component_name, + format!("{}:{}", lib_manager.footprint_lib_name(), footprint_name), + )?; + + if ki_symbol.units.is_empty() { + log::debug!("Converted symbol as a single KiCad unit"); + } else { + log::debug!( + "Converted symbol as {} KiCad units from EasyEDA subparts", + ki_symbol.units.len() + ); + } + + // Export symbol + let exporter = kicad::SymbolExporter::new(args.kicad_version); + let symbol_data = exporter.export(&ki_symbol)?; + + let lib_path = lib_manager.get_symbol_lib_path(); + + // Use thread-safe add_or_update method + let status = lib_manager.add_or_update_component( + &lib_path, + &ki_symbol.name, + &symbol_data, + args.force, + )?; + match status { + SymbolWriteStatus::Added => log::debug!("Added symbol: {}", ki_symbol.name), + SymbolWriteStatus::Updated => log::debug!("Updated symbol: {}", ki_symbol.name), + SymbolWriteStatus::Skipped => log::debug!("Skipped existing symbol: {}", ki_symbol.name), + } + + Ok(()) +} + +/// Convert EasyEDA symbol data into the EDA-neutral `KiSymbol` model. +/// +/// Coordinates stay in EasyEDA units (1 px = 10 mil) with Y pointing up, exactly as +/// the KiCad exporter expects. Other back ends (e.g. Cadence) reuse this model. +pub fn build_ki_symbol( + component_data: &ComponentData, + component_name: &str, + footprint_ref: String, +) -> Result { let common_unit = SymbolImporter::parse(&component_data.data_str).and_then(|symbol| { convert_symbol_unit(&symbol, component_data.bbox_x, component_data.bbox_y) })?; @@ -40,12 +86,12 @@ pub fn convert_symbol( (reference.unwrap_or_else(|| "U".to_string()), units) }; - let ki_symbol = kicad::KiSymbol { - name: component_name.clone(), + Ok(kicad::KiSymbol { + name: component_name.to_string(), reference, value: component_data.title.clone(), description: component_data.description.clone(), - footprint: format!("{}:{}", lib_manager.footprint_lib_name(), footprint_name), + footprint: footprint_ref, datasheet: component_data.datasheet.clone(), manufacturer: component_data.manufacturer.clone(), lcsc_id: component_data.lcsc_id.clone(), @@ -57,38 +103,7 @@ pub fn convert_symbol( arcs: common_unit.geometry.arcs, polylines: common_unit.geometry.polylines, texts: common_unit.geometry.texts, - }; - - if ki_symbol.units.is_empty() { - log::debug!("Converted symbol as a single KiCad unit"); - } else { - log::debug!( - "Converted symbol as {} KiCad units from EasyEDA subparts", - ki_symbol.units.len() - ); - } - - // Export symbol - let exporter = kicad::SymbolExporter::new(args.kicad_version); - let symbol_data = exporter.export(&ki_symbol)?; - - let lib_path = lib_manager.get_symbol_lib_path(); - - // Use thread-safe add_or_update method - let status = lib_manager.add_or_update_component( - &lib_path, - &ki_symbol.name, - &symbol_data, - args.force, - )?; - - match status { - SymbolWriteStatus::Added => log::debug!("Added symbol: {}", ki_symbol.name), - SymbolWriteStatus::Updated => log::debug!("Updated symbol: {}", ki_symbol.name), - SymbolWriteStatus::Skipped => log::debug!("Skipped existing symbol: {}", ki_symbol.name), - } - - Ok(()) + }) } struct ConvertedSymbolUnit { diff --git a/src/kicad/workflow.rs b/src/kicad/workflow.rs index f79c3dc..8e3f72a 100644 --- a/src/kicad/workflow.rs +++ b/src/kicad/workflow.rs @@ -431,7 +431,10 @@ async fn export_one_kicad_component( }) } -async fn apply_requested_metadata(component_data: &mut ComponentData, client: Option<&LcscClient>) { +pub(crate) async fn apply_requested_metadata( + component_data: &mut ComponentData, + client: Option<&LcscClient>, +) { let product = if let Some(client) = client { client .product_detail_optional(&component_data.lcsc_id) @@ -554,7 +557,7 @@ fn validate_library_options(options: &KicadLibraryOptions) -> Result<()> { Ok(()) } -fn validate_lcsc_id(id: &str) -> Result<()> { +pub(crate) fn validate_lcsc_id(id: &str) -> Result<()> { let trimmed = id.trim(); let Some(digits) = trimmed .strip_prefix('C') @@ -607,6 +610,7 @@ mod tests { full: false, force: false, kicad_version: KicadVersion::V6, + models: Default::default(), }; let err = validate_targets(&export).unwrap_err().to_string(); @@ -661,6 +665,7 @@ mod tests { full: true, force: false, kicad_version: KicadVersion::V6, + models: Default::default(), }; let path = kicad_checkpoint_path("lib", Path::new("out"), &export) @@ -702,6 +707,7 @@ mod tests { full: false, force: false, kicad_version: KicadVersion::V6, + models: Default::default(), }, english_metadata: false, parallel: 1, @@ -762,6 +768,7 @@ mod tests { full: false, force: false, kicad_version: KicadVersion::V6, + models: Default::default(), }, english_metadata: false, parallel: 1, @@ -810,6 +817,7 @@ mod tests { manufacturer: "CN Manufacturer".to_string(), datasheet: "https://source.test/ds".to_string(), jlc_id: String::new(), + package: String::new(), }; let metadata = ResolvedMetadata::from_component_data(&component_data, None); diff --git a/src/lceda/easyeda_fallback.rs b/src/lceda/easyeda_fallback.rs index 8a9aeb7..223bba2 100644 --- a/src/lceda/easyeda_fallback.rs +++ b/src/lceda/easyeda_fallback.rs @@ -682,10 +682,12 @@ mod tests { uuid: "model-uuid".to_string(), title: "BODY-H1.0".to_string(), rotation_z: 0.0, + placement: None, }), manufacturer: "SEEX".to_string(), datasheet: String::new(), jlc_id: String::new(), + package: String::new(), } } } diff --git a/src/main.rs b/src/main.rs index 838487a..bab83e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod altium; mod batch; mod bom; +mod cadence; mod cli; mod config; mod easyeda; @@ -20,6 +21,7 @@ mod lcsc; mod metadata; mod monitor; mod progress; +mod step_logo; mod step_model; mod ui; mod util; diff --git a/src/metadata.rs b/src/metadata.rs index 892dedf..9aed2d4 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -523,6 +523,7 @@ mod tests { manufacturer: "CN Manufacturer".to_string(), datasheet: "https://source.test/ds".to_string(), jlc_id: String::new(), + package: String::new(), } } } diff --git a/src/monitor.rs b/src/monitor.rs index 477e819..deaabc2 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -53,6 +53,7 @@ pub struct MonitorState { pub export_progress: Option, pub export_mode: String, pub export_kicad_mode: String, + pub export_cadence_mode: String, pub export_merge: bool, pub export_append: bool, pub export_library_name: String, @@ -60,6 +61,8 @@ pub struct MonitorState { pub export_continue_on_error: bool, pub export_lcsc_english: bool, pub export_force: bool, + /// Rebuild downloaded 3D models without the EasyEDA logo. + pub export_remove_model_logo: bool, pub matched_save_path: String, pub bom_language: String, pub bom_running: bool, @@ -83,6 +86,7 @@ impl MonitorState { export_progress: None, export_mode: "full".to_string(), export_kicad_mode: "full".to_string(), + export_cadence_mode: "full".to_string(), export_merge: false, export_append: false, export_library_name: "SeExMerged".to_string(), @@ -90,6 +94,7 @@ impl MonitorState { export_continue_on_error: false, export_lcsc_english: false, export_force: false, + export_remove_model_logo: true, matched_save_path: default_save_path("matched.csv"), bom_language: "chinese".to_string(), bom_running: false, @@ -262,6 +267,7 @@ impl MonitorState { pub fn set_export_target(&mut self, target: String) { self.export_target = match target.trim().to_ascii_lowercase().as_str() { "kicad" => "kicad".to_string(), + "cadence" => "cadence".to_string(), _ => "altium".to_string(), }; } @@ -284,6 +290,19 @@ impl MonitorState { }; } + pub fn set_export_cadence_mode(&mut self, mode: String) { + self.export_cadence_mode = match mode.trim().to_ascii_lowercase().as_str() { + "symbol" => "symbol".to_string(), + "footprint" => "footprint".to_string(), + "3d" => "3d".to_string(), + _ => "full".to_string(), + }; + } + + pub fn set_export_remove_model_logo(&mut self, remove: bool) { + self.export_remove_model_logo = remove; + } + pub fn set_export_merge(&mut self, merge: bool) { self.export_merge = merge; if !merge { diff --git a/src/step_logo.rs b/src/step_logo.rs new file mode 100644 index 0000000..dc8f3a1 --- /dev/null +++ b/src/step_logo.rs @@ -0,0 +1,1245 @@ +//! Removes the LCEDA / EasyEDA logo that EasyEDA embeds in its STEP models. +//! +//! The logo is an icon plus the words "LCEDA" and "EasyEDA", usually on one side +//! of the package, and on many models a second, mirrored "LCEDA" underneath. It +//! comes in two forms: +//! +//! * thin solids (about 1 µm) lying on the package (SOT-23 family); +//! * a relief or engraving (0.001–0.1 mm) merged into a body face. Each letter +//! is attached to that face through an inner loop of its boundary (passives, +//! diodes, LEDs). The face is flat, or the round side of a cylindrical body +//! (buzzers, round capacitors). +//! +//! Only the logo is touched: part markings, pin-1 marks and cathode bands look +//! similar but are kept. A piece belongs to the logo when it is the icon (always +//! the same shape), or one of the logo letters right next to the icon, or part +//! of a five-letter "LCEDA" row. Letters are recognised by their number of side +//! faces and how many of those are curved, which does not depend on the scale. +//! +//! Thin solids are dropped. For merged letters, their faces are deleted along +//! with the inner loop of the face they sit on, which leaves that face whole. +//! The result is checked: every shell must still be closed and nothing may +//! point at a deleted entity. If anything is off, the model is kept as it was and +//! [`strip_logo`] says so, so the export can tell the user the logo is still there. + +use std::collections::{HashMap, HashSet}; + +/// Deepest relief / engraving that can belong to the logo (mm). Round parts +/// such as buzzers have it cut 0.1 mm deep. +const MAX_RELIEF_MM: f64 = 0.12; +/// Thickest solid that can be a logo piece (mm). +const MAX_THIN_SOLID_MM: f64 = 0.005; +/// Two pieces lie in the same plane when their offsets differ by less (mm). +const PLANE_TOLERANCE_MM: f64 = 0.02; +/// Largest flood fill accepted as a relief feature (faces). +const MAX_FEATURE_FACES: usize = 400; + +/// (side faces, curved side faces) of the icon. +const ICON: (usize, usize) = (35, 28); +/// Letters of "LCEDA" and "EasyEDA" next to the icon. +const LOGO_LETTERS: [(usize, usize); 8] = [ + (6, 0), + (12, 0), + (14, 2), + (14, 8), + (14, 12), + (15, 5), + (21, 19), + (22, 14), +]; +/// Letters of the separate "LCEDA" row (another font). +const ROW_LETTERS: [(usize, usize); 6] = [(6, 0), (10, 4), (10, 8), (11, 1), (12, 0), (14, 2)]; +/// Shapes every "LCEDA" row contains (L, C, E, D). +const ROW_REQUIRED: [(usize, usize); 4] = [(6, 0), (10, 8), (12, 0), (10, 4)]; + +/// What [`strip_logo`] took out. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct LogoRemoval { + /// Logo pieces (icon, letters). + pub pieces: usize, + /// Faces deleted, thin solids included. + pub faces: usize, +} + +/// What logo removal did to one model. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogoOutcome { + /// No logo was found; the model is used as it is. + NotFound, + /// The logo was removed and the model rebuilt without it. + Removed(LogoRemoval), + /// A logo was found but could not be cut out without breaking the model, so + /// the original model (logo included) is used. + Kept, + /// The model could not be read, so it could not be checked for a logo; the + /// original model is used. + Unreadable, +} + +impl LogoOutcome { + /// The exported model may still carry the logo. + pub fn logo_remains(self) -> bool { + matches!(self, Self::Kept | Self::Unreadable) + } +} + +/// The model without the EasyEDA logo, and what was done. The new model is only +/// returned for [`LogoOutcome::Removed`]; in every other case the caller keeps +/// the original bytes. +pub fn strip_logo(data: &[u8]) -> (Option>, LogoOutcome) { + match remove_logo(data) { + Ok((model, removal)) => (Some(model), LogoOutcome::Removed(removal)), + Err(outcome) => (None, outcome), + } +} + +/// The model without the EasyEDA logo; otherwise why it was left as it was. +fn remove_logo(data: &[u8]) -> Result<(Vec, LogoRemoval), LogoOutcome> { + let text: String = data.iter().map(|&byte| byte as char).collect(); + let mut model = Model::parse(&text).ok_or(LogoOutcome::Unreadable)?; + let geometry = Geometry::new(&model); + let pieces = geometry.pieces(); + let mut selected = select_logo(&pieces); + // Some models draw the icon across the edge of the face the words sit on, so + // it is not a separate relief: take the words, and colour the icon like the + // face under it instead of cutting it out. + let mut icon_faces: HashSet = HashSet::new(); + if selected.is_empty() { + selected = select_words(&pieces); + if selected.is_empty() { + return Err(LogoOutcome::NotFound); + } + icon_faces = geometry.faces_beside(&pieces, &selected); + } + let base_face = selected + .iter() + .find_map(|&i| pieces[i].loops.first().map(|&(face, _)| face)); + let mut dead: HashSet = HashSet::new(); + let mut loops: Vec<(u64, u64)> = Vec::new(); + let mut removal = LogoRemoval::default(); + for &index in &selected { + let piece = &pieces[index]; + removal.pieces += 1; + removal.faces += piece.faces.len(); + dead.extend(piece.faces.iter().copied()); + if let Some(solid) = piece.solid { + dead.insert(solid); + dead.extend(model.refs(solid)); + } + loops.extend(piece.loops.iter().copied()); + } + // Letters that straddle the seam of a split face (round bodies) are part of + // its outline rather than holes in it, so they cannot be cut out: give what + // is left inside each logo's area the colour of the face under it. + let mut groups: Vec> = Vec::new(); + for &index in &selected { + match groups + .iter_mut() + .find(|g| coplanar(&pieces[g[0]], &pieces[index])) + { + Some(group) => group.push(index), + None => groups.push(vec![index]), + } + } + let mut leftovers: Vec<(HashSet, u64)> = Vec::new(); + for group in groups { + let first = &pieces[group[0]]; + let Some(&(base, _)) = first.loops.first() else { + continue; + }; + let (mut lo, mut hi) = (first.lo, first.hi); + for &i in &group { + for a in 0..3 { + lo[a] = lo[a].min(pieces[i].lo[a]); + hi[a] = hi[a].max(pieces[i].hi[a]); + } + } + let margin = 0.02; + let bases: HashSet = loops.iter().map(|&(face, _)| face).collect(); + let mut leftover: HashSet = HashSet::new(); + for entity in &model.entities { + let face = entity.id; + if entity.kind != "ADVANCED_FACE" + || dead.contains(&face) + || bases.contains(&face) + || icon_faces.contains(&face) + { + continue; + } + let one: HashSet = [face].into_iter().collect(); + let points = geometry.face_points(&one); + let within = !points.is_empty() + && points.iter().all(|p| { + (0..3).all(|a| p[a] >= lo[a] - margin && p[a] <= hi[a] + margin) + && match first.cylinder { + Some(c) => c.depth(*p) <= MAX_RELIEF_MM, + None => (dot(*p, first.normal) - first.offset).abs() <= MAX_RELIEF_MM, + } + }); + if within { + leftover.insert(face); + } + } + leftovers.push((leftover, base)); + } + if let Some(base) = base_face { + let recoloured = recolour(&mut model, &icon_faces, base); + if recoloured > 0 { + removal.pieces += 1; + removal.faces += recoloured; + } + } + for (leftover, base) in leftovers { + removal.faces += recolour(&mut model, &leftover, base); + } + + for (face, bound) in loops { + if dead.contains(&face) { + continue; + } + let body = model.body(face).ok_or(LogoOutcome::Kept)?.to_string(); + let edited = remove_ref(&body, bound).ok_or(LogoOutcome::Kept)?; + model.set_body(face, edited); + } + model.delete_cascading(dead); + model.collect_garbage(); + if !model.is_consistent() { + return Err(LogoOutcome::Kept); + } + let out: Vec = model.write().chars().map(|c| c as u8).collect(); + Ok((out, removal)) +} + +// --------------------------------------------------------------------------- +// STEP text + +struct Entity { + id: u64, + /// Statement as read, without the closing `;`. + raw: String, + /// Everything after `=`. + body: String, + kind: String, + refs: Vec, + edited: bool, +} + +struct Model { + head: String, + tail: String, + entities: Vec, + index: HashMap, + roots: Vec, + live: Vec, +} + +impl Model { + fn parse(text: &str) -> Option { + let data_at = text.find("DATA;")? + "DATA;".len(); + let head = text[..data_at].to_string(); + let mut entities = Vec::new(); + let mut tail = None; + let mut start = data_at; + let mut in_string = false; + let bytes = text.as_bytes(); + let mut i = data_at; + while i < bytes.len() { + match bytes[i] { + b'\'' => in_string = !in_string, + b';' if !in_string => { + let statement = text[start..i].trim(); + if let Some(rest) = statement.strip_prefix('#') { + let (id, body) = rest.split_once('=')?; + let id: u64 = id.trim().parse().ok()?; + let body = body.trim().to_string(); + entities.push(Entity { + id, + raw: statement.to_string(), + kind: entity_kind(&body), + refs: refs_of(&body), + body, + edited: false, + }); + } else if statement == "ENDSEC" { + tail = Some(text[start..].trim_start().to_string()); + break; + } else if !statement.is_empty() { + return None; + } + start = i + 1; + } + _ => {} + } + i += 1; + } + let tail = tail?; + let index: HashMap = entities + .iter() + .enumerate() + .map(|(i, e)| (e.id, i)) + .collect(); + let mut used: HashSet = HashSet::new(); + for entity in &entities { + used.extend(entity.refs.iter().copied()); + } + let roots = entities + .iter() + .map(|e| e.id) + .filter(|id| !used.contains(id)) + .collect(); + let live = vec![true; entities.len()]; + Some(Self { + head, + tail, + entities, + index, + roots, + live, + }) + } + + fn get(&self, id: u64) -> Option<&Entity> { + let &i = self.index.get(&id)?; + self.live[i].then(|| &self.entities[i]) + } + + fn kind(&self, id: u64) -> &str { + self.get(id).map(|e| e.kind.as_str()).unwrap_or("") + } + + fn refs(&self, id: u64) -> Vec { + self.get(id).map(|e| e.refs.clone()).unwrap_or_default() + } + + fn body(&self, id: u64) -> Option<&str> { + self.get(id).map(|e| e.body.as_str()) + } + + fn set_body(&mut self, id: u64, body: String) { + if let Some(&i) = self.index.get(&id) { + let entity = &mut self.entities[i]; + entity.refs = refs_of(&body); + entity.body = body; + entity.edited = true; + } + } + + /// Deletes `dead`; entities that point at a deleted one either lose it from a + /// list of references or are deleted too. + fn delete_cascading(&mut self, dead: HashSet) { + let mut users: HashMap> = HashMap::new(); + for entity in &self.entities { + for &r in &entity.refs { + users.entry(r).or_default().push(entity.id); + } + } + let mut gone: HashSet = HashSet::new(); + let mut queue: Vec = dead.into_iter().collect(); + while let Some(id) = queue.pop() { + if !gone.insert(id) { + continue; + } + for user in users.get(&id).cloned().unwrap_or_default() { + if gone.contains(&user) || !self.refs(user).contains(&id) { + continue; + } + let body = self.body(user).unwrap_or_default().to_string(); + match remove_ref(&body, id) { + Some(edited) => self.set_body(user, edited), + None => queue.push(user), + } + } + } + for id in gone { + if let Some(&i) = self.index.get(&id) { + self.live[i] = false; + } + } + } + + /// Keeps only what the file's top-level entities still reach. + fn collect_garbage(&mut self) { + let mut reached = vec![false; self.entities.len()]; + let mut stack: Vec = self.roots.clone(); + while let Some(id) = stack.pop() { + let Some(&i) = self.index.get(&id) else { + continue; + }; + if !self.live[i] || reached[i] { + continue; + } + reached[i] = true; + stack.extend(self.entities[i].refs.iter().copied()); + } + self.live = reached; + } + + /// Every closed shell still closed (each edge used by exactly two of its + /// faces), no reference to a missing entity, at least one solid left. + fn is_consistent(&self) -> bool { + let mut solids = 0; + for (i, entity) in self.entities.iter().enumerate() { + if !self.live[i] { + continue; + } + if entity.refs.iter().any(|&r| self.get(r).is_none()) { + return false; + } + match entity.kind.as_str() { + "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" => solids += 1, + "CLOSED_SHELL" => { + let mut uses: HashMap = HashMap::new(); + for &face in &entity.refs { + for edge in face_edges(self, face) { + *uses.entry(edge).or_default() += 1; + } + } + if uses.values().any(|&n| n != 2) { + return false; + } + } + _ => {} + } + } + solids > 0 + } + + fn write(&self) -> String { + let mut out = String::with_capacity(self.head.len() + self.entities.len() * 64); + out.push_str(&self.head); + out.push('\n'); + for (i, entity) in self.entities.iter().enumerate() { + if !self.live[i] { + continue; + } + if entity.edited { + out.push_str(&format!("#{} = {}", entity.id, entity.body)); + } else { + out.push_str(&entity.raw); + } + out.push_str(" ;\n"); + } + out.push_str(&self.tail); + out + } +} + +/// `body` with every quoted string emptied, so brackets and `#` inside names +/// do not count. +fn without_strings(body: &str) -> String { + let mut out = String::with_capacity(body.len()); + let mut in_string = false; + for c in body.chars() { + if c == '\'' { + in_string = !in_string; + out.push(c); + } else if !in_string { + out.push(c); + } + } + out +} + +fn entity_kind(body: &str) -> String { + without_strings(body) + .trim_start_matches(|c: char| c == '(' || c.is_whitespace()) + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect() +} + +fn refs_of(body: &str) -> Vec { + let clean = without_strings(body); + let mut refs = Vec::new(); + let bytes = clean.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'#' { + let start = i + 1; + let mut end = start; + while end < bytes.len() && bytes[end].is_ascii_digit() { + end += 1; + } + if let Ok(id) = clean[start..end].parse() { + refs.push(id); + } + i = end; + } else { + i += 1; + } + } + refs +} + +/// Drops `#target` from the plain reference list it sits in. `None` when it is +/// not in such a list, or is the list's only member. +fn remove_ref(body: &str, target: u64) -> Option { + let token = format!("#{target}"); + let bytes = body.as_bytes(); + let mut in_string = false; + let mut at = None; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\'' => in_string = !in_string, + b'#' if !in_string && body[i..].starts_with(&token) => { + let end = i + token.len(); + if end == bytes.len() || !bytes[end].is_ascii_digit() { + at = Some(i); + break; + } + } + _ => {} + } + i += 1; + } + let at = at?; + let list_char = |c: u8| c == b'#' || c == b',' || c.is_ascii_digit() || c.is_ascii_whitespace(); + let mut open = at; + loop { + if open == 0 { + return None; + } + open -= 1; + match bytes[open] { + b'(' => break, + c if list_char(c) => {} + _ => return None, + } + } + let mut close = at; + loop { + close += 1; + match bytes.get(close)? { + b')' => break, + &c if list_char(c) => {} + _ => return None, + } + } + let items: Vec<&str> = body[open + 1..close].split(',').map(str::trim).collect(); + if items.len() < 2 { + return None; + } + let kept: Vec<&str> = items.into_iter().filter(|item| *item != token).collect(); + Some(format!( + "{} {} {}", + &body[..=open], + kept.join(", "), + &body[close..] + )) +} + +fn numbers(body: &str) -> Vec { + let clean = without_strings(body); + let Some(open) = clean.rfind('(') else { + return Vec::new(); + }; + let close = clean[open..] + .find(')') + .map(|c| open + c) + .unwrap_or(clean.len()); + clean[open + 1..close] + .split(',') + .filter_map(|v| v.trim().parse::().ok()) + .collect() +} + +// --------------------------------------------------------------------------- +// Topology + +/// Bounds of an `ADVANCED_FACE`: every reference but the last (the surface). +fn face_bounds(model: &Model, face: u64) -> Vec { + let refs = model.refs(face); + refs[..refs.len().saturating_sub(1)].to_vec() +} + +fn face_surface(model: &Model, face: u64) -> Option { + model.refs(face).last().copied() +} + +fn bound_edges(model: &Model, bound: u64) -> Vec { + let Some(&edge_loop) = model.refs(bound).first() else { + return Vec::new(); + }; + if model.kind(edge_loop) != "EDGE_LOOP" { + return Vec::new(); + } + model + .refs(edge_loop) + .into_iter() + .filter_map(|oriented| model.refs(oriented).last().copied()) + .collect() +} + +fn face_edges(model: &Model, face: u64) -> Vec { + face_bounds(model, face) + .into_iter() + .flat_map(|bound| bound_edges(model, bound)) + .collect() +} + +type Point = [f64; 3]; + +struct Geometry<'a> { + model: &'a Model, + edge_faces: HashMap>, +} + +/// A logo candidate: one relief feature or one thin solid. +#[derive(Clone, Copy, Debug)] +struct Cylinder { + origin: Point, + axis: Point, + radius: f64, +} + +impl Cylinder { + /// Distance of `p` from the axis. + fn distance(&self, p: Point) -> f64 { + let v = sub(p, self.origin); + let along = dot(v, self.axis); + let r = sub( + v, + [ + self.axis[0] * along, + self.axis[1] * along, + self.axis[2] * along, + ], + ); + dot(r, r).sqrt() + } + + fn depth(&self, p: Point) -> f64 { + (self.distance(p) - self.radius).abs() + } + + /// Same axis line (the radius may differ). + fn coaxial(&self, other: &Cylinder) -> bool { + dot(self.axis, other.axis).abs() > 0.999 + && self.distance(other.origin) <= PLANE_TOLERANCE_MM + } + + /// Outward unit direction at `p`. + fn radial(&self, p: Point) -> Option { + let v = sub(p, self.origin); + let along = dot(v, self.axis); + unit(sub( + v, + [ + self.axis[0] * along, + self.axis[1] * along, + self.axis[2] * along, + ], + )) + } +} + +struct Piece { + faces: HashSet, + /// (face, inner bound) pairs to drop when the piece goes. + loops: Vec<(u64, u64)>, + solid: Option, + /// Unit normal of the base plane; for a piece on a cylinder, the radial + /// direction at the piece. + normal: Point, + /// Base plane offset along `normal`; the radius for a piece on a cylinder. + offset: f64, + /// The cylinder the piece is cut into, when it is not on a flat face. + cylinder: Option, + /// Height of the relief / depth of the engraving (thickness of a thin solid). + depth: f64, + lo: Point, + hi: Point, + shape: (usize, usize), +} + +impl<'a> Geometry<'a> { + fn new(model: &'a Model) -> Self { + let mut edge_faces: HashMap> = HashMap::new(); + for entity in model.entities.iter() { + if entity.kind == "ADVANCED_FACE" { + for edge in face_edges(model, entity.id) { + edge_faces.entry(edge).or_default().push(entity.id); + } + } + } + Self { model, edge_faces } + } + + fn point(&self, id: u64) -> Option { + match numbers(self.model.body(id)?).as_slice() { + [x, y, z, ..] => Some([*x, *y, *z]), + _ => None, + } + } + + fn face_points(&self, faces: &HashSet) -> Vec { + let mut points = Vec::new(); + for &face in faces { + for edge in face_edges(self.model, face) { + for vertex in self.model.refs(edge).into_iter().take(2) { + if let Some(p) = self.model.refs(vertex).first().and_then(|&p| self.point(p)) { + points.push(p); + } + } + } + } + points + } + + /// Origin and unit normal of a planar face. + fn plane(&self, face: u64) -> Option<(Point, Point)> { + let surface = face_surface(self.model, face)?; + if self.model.kind(surface) != "PLANE" { + return None; + } + let placement = *self.model.refs(surface).first()?; + let refs = self.model.refs(placement); + let origin = self.point(*refs.first()?)?; + let normal = unit(self.point(*refs.get(1)?)?)?; + Some((origin, normal)) + } + + /// Axis and radius of a cylindrical face. + fn cylinder(&self, face: u64) -> Option { + let surface = face_surface(self.model, face)?; + if self.model.kind(surface) != "CYLINDRICAL_SURFACE" { + return None; + } + let radius = *numbers(self.model.body(surface)?).last()?; + let placement = *self.model.refs(surface).first()?; + let refs = self.model.refs(placement); + let origin = self.point(*refs.first()?)?; + let axis = unit(self.point(*refs.get(1)?)?)?; + Some(Cylinder { + origin, + axis, + radius, + }) + } + + fn pieces(&self) -> Vec { + let model = self.model; + let mut pieces = Vec::new(); + let mut solid_faces: HashSet = HashSet::new(); + + // Thin solids. + let solids: Vec = model + .entities + .iter() + .filter(|e| e.kind == "MANIFOLD_SOLID_BREP") + .map(|e| e.id) + .collect(); + if solids.len() > 1 { + for &solid in &solids { + let Some(&shell) = model.refs(solid).first() else { + continue; + }; + let faces: HashSet = model.refs(shell).into_iter().collect(); + let Some((lo, hi)) = bounds(&self.face_points(&faces)) else { + continue; + }; + let extent = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]; + let axis = (0..3) + .min_by(|&a, &b| extent[a].total_cmp(&extent[b])) + .unwrap_or(2); + if extent[axis] > MAX_THIN_SOLID_MM { + continue; + } + let mut normal = [0.0; 3]; + normal[axis] = 1.0; + let shape = self.shape(&faces, normal); + solid_faces.extend(faces.iter().copied()); + pieces.push(Piece { + faces, + loops: Vec::new(), + solid: Some(solid), + normal, + offset: (lo[axis] + hi[axis]) / 2.0, + cylinder: None, + depth: extent[axis], + lo, + hi, + shape, + }); + } + } + + // Relief / engraving behind inner loops of flat faces. + let mut features: Vec = Vec::new(); + for entity in model.entities.iter() { + if entity.kind != "ADVANCED_FACE" || solid_faces.contains(&entity.id) { + continue; + } + let face = entity.id; + let inner: Vec = face_bounds(model, face) + .into_iter() + .filter(|&b| model.kind(b) == "FACE_BOUND") + .collect(); + if inner.is_empty() { + continue; + } + let plane = self.plane(face); + let cylinder = if plane.is_none() { + self.cylinder(face) + } else { + None + }; + if plane.is_none() && cylinder.is_none() { + continue; + } + for bound in inner { + let Some(faces) = self.flood(face, bound) else { + continue; + }; + let points = self.face_points(&faces); + let depth_of = |p: &Point| match (plane, cylinder) { + (Some((origin, normal)), _) => dot(sub(*p, origin), normal).abs(), + (None, Some(c)) => c.depth(*p), + _ => f64::MAX, + }; + let depth = points.iter().map(depth_of).fold(0.0, f64::max); + if depth > MAX_RELIEF_MM { + continue; + } + let Some((lo, hi)) = bounds(&points) else { + continue; + }; + if let Some(same) = features.iter_mut().find(|p| p.faces == faces) { + same.loops.push((face, bound)); + continue; + } + let (normal, offset) = match (plane, cylinder) { + (Some((origin, normal)), _) => (normal, dot(origin, normal)), + (None, Some(c)) => { + let centre = [ + (lo[0] + hi[0]) / 2.0, + (lo[1] + hi[1]) / 2.0, + (lo[2] + hi[2]) / 2.0, + ]; + let Some(radial) = c.radial(centre) else { + continue; + }; + (radial, c.radius) + } + _ => continue, + }; + let shape = self.shape_on(&faces, normal, cylinder); + features.push(Piece { + faces, + loops: vec![(face, bound)], + solid: None, + normal, + offset, + cylinder, + depth, + lo, + hi, + shape, + }); + } + } + // A letter's island (inside an "A" or "D") is reached again from the + // letter's own floor; only the whole letter counts as a piece. A deeper + // recess that merely holds shallower letters (C9002) does not swallow them. + let holds = |g: &Piece, f: &Piece| { + g.faces.len() > f.faces.len() + && f.faces.is_subset(&g.faces) + && (g.depth - f.depth).abs() <= 0.005 + }; + let outer: Vec = features + .iter() + .map(|f| !features.iter().any(|g| holds(g, f))) + .collect(); + let mut nested_loops: Vec<(usize, Vec<(u64, u64)>)> = Vec::new(); + for (i, f) in features.iter().enumerate() { + if !outer[i] { + if let Some(j) = features.iter().position(|g| holds(g, f)) { + nested_loops.push((j, f.loops.clone())); + } + } + } + for (j, loops) in nested_loops { + features[j].loops.extend(loops); + } + pieces.extend( + features + .into_iter() + .zip(outer) + .filter(|(_, o)| *o) + .map(|(f, _)| f), + ); + pieces + } + + /// Small faces in the plane of the words `words` (within relief depth) close + /// to them: the logo icon when it is not a separate relief. + fn faces_beside(&self, pieces: &[Piece], words: &[usize]) -> HashSet { + let first = &pieces[words[0]]; + let (normal, offset, cylinder) = (first.normal, first.offset, first.cylinder); + let (mut lo, mut hi) = (first.lo, first.hi); + let mut heights: Vec = Vec::new(); + let mut taken: HashSet = HashSet::new(); + for &i in words { + let p = &pieces[i]; + for a in 0..3 { + lo[a] = lo[a].min(p.lo[a]); + hi[a] = hi[a].max(p.hi[a]); + } + heights.push((0..3).map(|a| p.hi[a] - p.lo[a]).fold(0.0, f64::max)); + taken.extend(p.faces.iter().copied()); + } + heights.sort_by(f64::total_cmp); + let reach = 6.0 * heights[heights.len() / 2]; + for a in 0..3 { + lo[a] -= reach; + hi[a] += reach; + } + let mut found = HashSet::new(); + for entity in &self.model.entities { + if entity.kind != "ADVANCED_FACE" || taken.contains(&entity.id) { + continue; + } + let one: HashSet = [entity.id].into_iter().collect(); + let points = self.face_points(&one); + if !points.is_empty() + && points.iter().all(|p| { + (0..3).all(|a| p[a] >= lo[a] && p[a] <= hi[a]) + && match cylinder { + Some(c) => c.depth(*p) <= MAX_RELIEF_MM, + None => (dot(*p, normal) - offset).abs() <= MAX_RELIEF_MM, + } + }) + { + found.insert(entity.id); + } + } + found + } + + /// Faces reached from the inner loop `bound` of `face` without crossing it. + fn flood(&self, face: u64, bound: u64) -> Option> { + let mut found: HashSet = HashSet::new(); + let mut stack: Vec = Vec::new(); + for edge in bound_edges(self.model, bound) { + stack.extend(self.edge_faces.get(&edge).into_iter().flatten().copied()); + } + while let Some(next) = stack.pop() { + if next == face || !found.insert(next) { + continue; + } + if found.len() > MAX_FEATURE_FACES { + return None; + } + for edge in face_edges(self.model, next) { + stack.extend(self.edge_faces.get(&edge).into_iter().flatten().copied()); + } + } + (!found.is_empty()).then_some(found) + } + + /// (side faces, curved side faces): faces not parallel to the base plane. + fn shape(&self, faces: &HashSet, normal: Point) -> (usize, usize) { + self.shape_on(faces, normal, None) + } + + /// [`Self::shape`] for a piece on a flat face or, with `cylinder`, cut into a + /// round one: there the floor is coaxial with it rather than parallel. + fn shape_on( + &self, + faces: &HashSet, + normal: Point, + cylinder: Option, + ) -> (usize, usize) { + let mut sides = 0; + let mut curved = 0; + for &face in faces { + if let Some(base) = cylinder { + if self.cylinder(face).is_some_and(|c| c.coaxial(&base)) { + continue; + } + } + if let Some((_, n)) = self.plane(face) { + if (dot(n, normal).abs() - 1.0).abs() < 1e-3 { + continue; + } + } + sides += 1; + let kind = face_surface(self.model, face) + .map(|s| self.model.kind(s)) + .unwrap_or(""); + if kind.starts_with("B_SPLINE") { + curved += 1; + } + } + (sides, curved) + } +} + +/// Gives `faces` the colour of `like`: their STYLED_ITEMs take its styles. +/// Returns how many faces changed. +fn recolour(model: &mut Model, faces: &HashSet, like: u64) -> usize { + let mut styled: HashMap> = HashMap::new(); + for entity in &model.entities { + if entity.kind == "STYLED_ITEM" { + if let Some(&item) = entity.refs.last() { + styled.entry(item).or_default().push(entity.id); + } + } + } + let Some(template) = styled + .get(&like) + .and_then(|items| items.first()) + .and_then(|&id| model.body(id)) + .map(str::to_string) + else { + return 0; + }; + let tag = format!("#{like}"); + let Some(at) = template.rfind(&tag) else { + return 0; + }; + let mut changed = 0; + for &face in faces { + let Some(items) = styled.get(&face) else { + continue; + }; + for &item in items { + let body = format!("{}#{face}{}", &template[..at], &template[at + tag.len()..]); + model.set_body(item, body); + } + changed += 1; + } + changed +} + +/// The "LCEDA" and "EasyEDA" words without their icon: twelve coplanar letters +/// next to each other that include every letter shape of the logo. +fn select_words(pieces: &[Piece]) -> Vec { + let letters: Vec = (0..pieces.len()) + .filter(|&i| pieces[i].solid.is_none() && LOGO_LETTERS.contains(&pieces[i].shape)) + .collect(); + let mut seen: HashSet = HashSet::new(); + for &start in &letters { + if seen.contains(&start) { + continue; + } + let mut group = vec![start]; + let mut k = 0; + while k < group.len() { + let current = &pieces[group[k]]; + let height = (0..3) + .map(|a| current.hi[a] - current.lo[a]) + .fold(0.0, f64::max); + for &other in &letters { + if !group.contains(&other) + && coplanar(&pieces[other], current) + && gap(&pieces[other], current) <= 0.8 * height + { + group.push(other); + } + } + k += 1; + } + seen.extend(group.iter().copied()); + let shapes: Vec<(usize, usize)> = group.iter().map(|&i| pieces[i].shape).collect(); + if group.len() == 12 && LOGO_LETTERS.iter().all(|s| shapes.contains(s)) { + group.sort_unstable(); + return group; + } + } + Vec::new() +} + +/// Indices of the pieces that make up the logo. +fn select_logo(pieces: &[Piece]) -> Vec { + let mut chosen: HashSet = HashSet::new(); + // Icon and the words next to it. + for (icon, piece) in pieces.iter().enumerate() { + if !shape_like(piece, ICON) || chosen.contains(&icon) { + continue; + } + let size = (0..3) + .map(|a| piece.hi[a] - piece.lo[a]) + .fold(0.0, f64::max); + let reach = if piece.cylinder.is_some() { 0.5 } else { 0.35 } * size; + let mut group = vec![icon]; + let (mut lo, mut hi) = (piece.lo, piece.hi); + loop { + let next = pieces.iter().enumerate().position(|(i, p)| { + !group.contains(&i) + && coplanar(p, piece) + && (inside(p, lo, hi) + || ((LOGO_LETTERS.iter().any(|&s| shape_like(p, s)) + || shape_like(p, ICON)) + && group.iter().any(|&g| gap(p, &pieces[g]) <= reach))) + }); + let Some(next) = next else { break }; + group.push(next); + for a in 0..3 { + lo[a] = lo[a].min(pieces[next].lo[a]); + hi[a] = hi[a].max(pieces[next].hi[a]); + } + } + // Icon plus at least the five letters of one word. + if group.len() >= 6 { + chosen.extend(group); + } + } + // Separate "LCEDA" rows. + let rest: Vec = (0..pieces.len()) + .filter(|i| !chosen.contains(i) && ROW_LETTERS.contains(&pieces[*i].shape)) + .collect(); + let mut seen: HashSet = HashSet::new(); + for &start in &rest { + if seen.contains(&start) { + continue; + } + let mut group = vec![start]; + let mut k = 0; + while k < group.len() { + let current = &pieces[group[k]]; + let height = (0..3) + .map(|a| current.hi[a] - current.lo[a]) + .fold(0.0, f64::max); + for &other in &rest { + if !group.contains(&other) + && coplanar(&pieces[other], current) + && gap(&pieces[other], current) <= 0.6 * height + { + group.push(other); + } + } + k += 1; + } + seen.extend(group.iter().copied()); + let shapes: Vec<(usize, usize)> = group.iter().map(|&i| pieces[i].shape).collect(); + if group.len() == 5 && ROW_REQUIRED.iter().all(|s| shapes.contains(s)) { + chosen.extend(group); + } + } + let mut chosen: Vec = chosen.into_iter().collect(); + chosen.sort_unstable(); + chosen +} + +/// Whether `piece` has the logo shape `shape` (side faces, curved side faces). +/// Cut into a cylinder, outlines split into a few more faces than on a flat face. +fn shape_like(piece: &Piece, shape: (usize, usize)) -> bool { + if piece.cylinder.is_none() { + return piece.shape == shape; + } + piece.shape.0.abs_diff(shape.0) <= 2 && piece.shape.1.abs_diff(shape.1) <= 3 +} + +fn coplanar(a: &Piece, b: &Piece) -> bool { + match (a.cylinder, b.cylinder) { + (Some(ca), Some(cb)) => { + return ca.coaxial(&cb) && (ca.radius - cb.radius).abs() <= PLANE_TOLERANCE_MM; + } + (None, None) => {} + _ => return false, + } + let d = dot(a.normal, b.normal); + d.abs() > 0.999 && (a.offset - d.signum() * b.offset).abs() <= PLANE_TOLERANCE_MM +} + +/// Distance between two bounding boxes (0 when they touch or overlap). +fn gap(a: &Piece, b: &Piece) -> f64 { + (0..3) + .map(|i| (a.lo[i] - b.hi[i]).max(b.lo[i] - a.hi[i]).max(0.0)) + .fold(0.0, f64::max) +} + +fn inside(p: &Piece, lo: Point, hi: Point) -> bool { + (0..3).all(|a| p.lo[a] >= lo[a] - 1e-3 && p.hi[a] <= hi[a] + 1e-3) +} + +fn bounds(points: &[Point]) -> Option<(Point, Point)> { + let first = *points.first()?; + let (mut lo, mut hi) = (first, first); + for p in points { + for a in 0..3 { + lo[a] = lo[a].min(p[a]); + hi[a] = hi[a].max(p[a]); + } + } + Some((lo, hi)) +} + +fn dot(a: Point, b: Point) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn sub(a: Point, b: Point) -> Point { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn unit(v: Point) -> Option { + let length = dot(v, v).sqrt(); + (length > 1e-12).then(|| [v[0] / length, v[1] / length, v[2] / length]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn removes_reference_from_list_only() { + assert_eq!( + remove_ref("CLOSED_SHELL ( 'NONE', ( #1, #22, #3 ) )", 22).as_deref(), + Some("CLOSED_SHELL ( 'NONE', ( #1, #3 ) )") + ); + assert_eq!(remove_ref("STYLED_ITEM ( 'color', ( #5 ), #22 )", 22), None); + assert_eq!(remove_ref("X ( ( #22 ) )", 22), None); + assert_eq!( + remove_ref("X ( ( #220, #22 ) )", 22).as_deref(), + Some("X ( ( #220 ) )") + ); + assert_eq!( + remove_ref("X ( '#22', ( #1, #22 ) )", 22).as_deref(), + Some("X ( '#22', ( #1 ) )") + ); + } + + #[test] + fn parses_kind_refs_and_numbers() { + let body = "CARTESIAN_POINT ( 'p(1)', ( 0.5, -1.E-05, 2. ) )"; + assert_eq!(entity_kind(body), "CARTESIAN_POINT"); + assert_eq!(numbers(body), vec![0.5, -1e-5, 2.0]); + assert_eq!( + refs_of("EDGE_CURVE ( '#9', #1, #23, #4, .T. )"), + vec![1, 23, 4] + ); + assert_eq!( + entity_kind("( NAMED_UNIT ( * ) SI_UNIT ( $, .METRE. ) )"), + "NAMED_UNIT" + ); + } + + /// A unit cube with a 3-letter "logo" would be too contrived to build by + /// hand; models without a logo must come back untouched. + #[test] + fn model_without_logo_is_left_alone() { + let step = "ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n#1 = CARTESIAN_POINT ( 'NONE', ( 0., 0., 0. ) ) ;\nENDSEC;\nEND-ISO-10303-21;\n"; + assert_eq!(strip_logo(step.as_bytes()), (None, LogoOutcome::NotFound)); + } + + #[test] + fn recolour_copies_the_base_face_style() { + let step = "ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n\ + #1 = ADVANCED_FACE ( 'NONE', ( ), #9, .T. ) ;\n\ + #2 = ADVANCED_FACE ( 'NONE', ( ), #9, .T. ) ;\n\ + #3 = STYLED_ITEM ( 'NONE', ( #7 ), #1 ) ;\n\ + #4 = STYLED_ITEM ( 'NONE', ( #8 ), #2 ) ;\n\ + #7 = PRESENTATION_STYLE_ASSIGNMENT ( ( ) ) ;\n\ + #8 = PRESENTATION_STYLE_ASSIGNMENT ( ( ) ) ;\n\ + #9 = PLANE ( 'NONE', $ ) ;\n\ + ENDSEC;\nEND-ISO-10303-21;\n"; + let mut model = Model::parse(step).unwrap(); + let faces: HashSet = [2].into_iter().collect(); + assert_eq!(recolour(&mut model, &faces, 1), 1); + assert_eq!(model.body(4), Some("STYLED_ITEM ( 'NONE', ( #7 ), #2 )")); + assert_eq!(model.refs(4), vec![7, 2]); + } +} diff --git a/src/step_model.rs b/src/step_model.rs index 21a9ba8..ff5cb92 100644 --- a/src/step_model.rs +++ b/src/step_model.rs @@ -26,10 +26,19 @@ //! an appendage 2.5 mm past the end of the body; none of them may decide the placement. //! //! Call [`place_step_on_board`] before writing or embedding a downloaded STEP model. +//! +//! Before that, every target hands the model to a [`ModelCleanup`], which takes out the logo +//! EasyEDA puts on many of its models (see `crate::step_logo`) and keeps count of what it did, +//! so the export can say which models still carry the logo. use std::borrow::Cow; +use std::fmt; use std::io::{self, Write}; use std::ops::Range; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::step_logo::{LogoOutcome, strip_logo}; /// Share of the lowest vertices ignored when reading the underside of a part. const UNDERSIDE_TRIM: f64 = 0.02; @@ -283,9 +292,141 @@ fn find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option { .map(|offset| from + offset) } +/// What happens to the downloaded models of one export before they are written or embedded. +/// +/// EasyEDA puts its logo on many of its STEP models. Removing it is on by default for every +/// target: the model is rebuilt without the logo. When that cannot be done without breaking the +/// model, the original model is exported and its part is remembered, so the run can tell the user +/// exactly which models still show the logo. +pub struct ModelCleanup { + remove_logo: bool, + removed: AtomicUsize, + with_logo: Mutex>, +} + +impl Default for ModelCleanup { + fn default() -> Self { + Self::new(true) + } +} + +impl fmt::Debug for ModelCleanup { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ModelCleanup") + .field("remove_logo", &self.remove_logo) + .field("removed", &self.removed()) + .field("with_logo", &self.parts_with_logo()) + .finish() + } +} + +impl ModelCleanup { + pub fn new(remove_logo: bool) -> Self { + Self { + remove_logo, + removed: AtomicUsize::new(0), + with_logo: Mutex::new(Vec::new()), + } + } + + /// The model of `part` as it should be exported: without the logo when that is asked for and + /// can be done, otherwise unchanged. + pub fn clean<'a>(&self, part: &str, step_data: &'a [u8]) -> Cow<'a, [u8]> { + if !self.remove_logo { + return Cow::Borrowed(step_data); + } + match strip_logo(step_data) { + (Some(model), LogoOutcome::Removed(removal)) => { + log::debug!( + "{part}: removed the 3D model logo ({} pieces, {} faces)", + removal.pieces, + removal.faces + ); + self.removed.fetch_add(1, Ordering::Relaxed); + Cow::Owned(model) + } + (_, outcome) => { + if outcome.logo_remains() { + log::warn!("{part}: the 3D model logo could not be removed ({outcome:?})"); + if let Ok(mut parts) = self.with_logo.lock() + && !parts.iter().any(|known| known == part) + { + parts.push(part.to_string()); + } + } + Cow::Borrowed(step_data) + } + } + } + + /// Models rebuilt without the logo so far. + pub fn removed(&self) -> usize { + self.removed.load(Ordering::Relaxed) + } + + /// Parts whose exported model still carries the logo, sorted. + pub fn parts_with_logo(&self) -> Vec { + let mut parts = self + .with_logo + .lock() + .map(|parts| parts.clone()) + .unwrap_or_default(); + parts.sort(); + parts + } + + /// Lines for the export summary. + pub fn summary_lines(&self) -> Vec { + if !self.remove_logo { + return vec!["3D logo removal: OFF (models keep the EasyEDA logo)".to_string()]; + } + let with_logo = self.parts_with_logo(); + let mut lines = vec![format!( + "3D logo removal: ON | removed {} | kept with logo {}", + self.removed(), + with_logo.len() + )]; + if !with_logo.is_empty() { + lines.push(format!("{LOGO_KEPT_PREFIX} {}", with_logo.join(", "))); + } + lines + } +} + +/// Starts the summary line that names the parts whose model still carries the logo. +pub const LOGO_KEPT_PREFIX: &str = "Models with logo:"; + #[cfg(test)] mod tests { - use super::{parse_model_points, place_step_on_board, underside_lift_mm}; + use super::{ + LOGO_KEPT_PREFIX, ModelCleanup, parse_model_points, place_step_on_board, underside_lift_mm, + }; + use std::borrow::Cow; + + #[test] + fn logo_removal_reports_the_models_that_keep_their_logo() { + let plain = step_with(&[[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]); + let unreadable = b"not a STEP file".to_vec(); + + let models = ModelCleanup::default(); + assert!(matches!(models.clean("C1", &plain), Cow::Borrowed(_))); + assert!(matches!(models.clean("C2", &unreadable), Cow::Borrowed(_))); + assert!(matches!(models.clean("C2", &unreadable), Cow::Borrowed(_))); + assert_eq!(models.removed(), 0); + assert_eq!(models.parts_with_logo(), ["C2"]); + assert_eq!( + models.summary_lines(), + [ + "3D logo removal: ON | removed 0 | kept with logo 1".to_string(), + format!("{LOGO_KEPT_PREFIX} C2"), + ] + ); + + let off = ModelCleanup::new(false); + assert!(matches!(off.clean("C2", &unreadable), Cow::Borrowed(_))); + assert!(off.parts_with_logo().is_empty()); + assert!(off.summary_lines()[0].contains("OFF")); + } fn step_with(points: &[[f64; 3]]) -> Vec { let mut text = String::from("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n"); diff --git a/src/ui/app.rs b/src/ui/app.rs index 901e17c..02ba48d 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1263,6 +1263,7 @@ pub fn apply_config_to_state(config: &AppConfig, state: &mut MonitorState) { state.set_export_target(config.export.target.clone()); state.set_export_mode(config.export.mode.clone()); state.set_export_kicad_mode(config.export.kicad_mode.clone()); + state.set_export_cadence_mode(config.export.cadence_mode.clone()); state.set_export_merge(config.export.merge); state.set_export_append(config.export.append); state.set_export_library_name(config.export.library_name.clone()); @@ -1270,6 +1271,7 @@ pub fn apply_config_to_state(config: &AppConfig, state: &mut MonitorState) { state.set_export_continue_on_error(config.export.continue_on_error); state.set_export_lcsc_english(config.export.lcsc_english); state.set_export_force(config.export.force); + state.set_export_remove_model_logo(config.export.remove_model_logo); state.set_matched_save_path(config.monitor.matched_save_path.clone()); state.set_bom_language(config.monitor.bom_language.clone()); } @@ -1290,6 +1292,7 @@ pub fn snapshot_config( output_path: state.export_output_path.clone(), mode: state.export_mode.clone(), kicad_mode: state.export_kicad_mode.clone(), + cadence_mode: state.export_cadence_mode.clone(), merge: state.export_merge, append: state.export_append, library_name: state.export_library_name.clone(), @@ -1299,6 +1302,7 @@ pub fn snapshot_config( force: state.export_force, schematic_stroke_color, schematic_fill_color, + remove_model_logo: state.export_remove_model_logo, }, monitor: MonitorConfig { matched_save_path: state.matched_save_path.clone(), diff --git a/src/ui/pages/export.rs b/src/ui/pages/export.rs index efe9418..4f75987 100644 --- a/src/ui/pages/export.rs +++ b/src/ui/pages/export.rs @@ -82,6 +82,18 @@ fn failure_reason(result: &str) -> Option { .map(ToOwned::to_owned) } +/// The parts whose exported 3D model still carries the EasyEDA logo. +fn models_with_logo(result: &str) -> Option { + result + .lines() + .find_map(|line| { + line.trim() + .strip_prefix(crate::step_model::LOGO_KEPT_PREFIX) + }) + .map(|parts| parts.trim().to_string()) + .filter(|parts| !parts.is_empty()) +} + impl SeexUi { pub(crate) fn export_page( &mut self, @@ -245,8 +257,8 @@ impl SeexUi { .text_size(px(BODY)) .text_color(theme::muted()) .child(locale::t( - "Altium and KiCad export", - "导出 Altium 和 KiCad 库", + "Altium, KiCad and Cadence export", + "导出 Altium、KiCad 和 Cadence 库", )), ); @@ -322,6 +334,23 @@ impl SeexUi { .child(reason), ); } + if let Some(parts) = result.as_deref().and_then(models_with_logo) { + let message = match locale::lang() { + Lang::En => format!( + "The EasyEDA logo could not be removed from these 3D models; the original models were exported: {parts}" + ), + Lang::Zh => { + format!("以下元件的 3D 模型无法去除 EasyEDA 标志,已导出原始模型:{parts}") + } + }; + body = body.child( + div() + .w_full() + .text_size(px(BODY)) + .text_color(theme::danger()) + .child(message), + ); + } components::section(body).into_any_element() } @@ -337,10 +366,10 @@ impl SeexUi { ids: state.get_unique_ids(), target: state.export_target.clone(), output_path: state.export_output_path.clone(), - mode: if state.export_target == "kicad" { - state.export_kicad_mode.clone() - } else { - state.export_mode.clone() + mode: match state.export_target.as_str() { + "kicad" => state.export_kicad_mode.clone(), + "cadence" => state.export_cadence_mode.clone(), + _ => state.export_mode.clone(), }, merge: state.export_merge, append: state.export_append, @@ -351,25 +380,30 @@ impl SeexUi { force: state.export_force, schematic_stroke_color: self.schematic_stroke_color, schematic_fill_color: self.schematic_fill_color, + remove_model_logo: state.export_remove_model_logo, } }; crate::exporter::spawn_export(std::sync::Arc::clone(&self.state), request); } fn export_settings_panel(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { - let (target, mode, kicad_mode, merge, append, continue_on_error, force, english) = self + let (target, mode, merge, append, continue_on_error, force, english, remove_logo) = self .state .lock() .map(|state| { ( state.export_target.clone(), - state.export_mode.clone(), - state.export_kicad_mode.clone(), + match state.export_target.as_str() { + "kicad" => state.export_kicad_mode.clone(), + "cadence" => state.export_cadence_mode.clone(), + _ => state.export_mode.clone(), + }, state.export_merge, state.export_append, state.export_continue_on_error, state.export_force, state.export_lcsc_english, + state.export_remove_model_logo, ) }) .unwrap_or_default(); @@ -399,7 +433,7 @@ impl SeexUi { .child(components::selectable( "target-altium", "Altium", - target != "kicad", + target != "kicad" && target != "cadence", cx.listener(|this, _event: &ClickEvent, _window, cx| { if let Ok(mut state) = this.state.lock() { state.set_export_target("altium".to_string()); @@ -421,6 +455,19 @@ impl SeexUi { this.mark_config_dirty(); cx.notify(); }), + )) + .child(components::selectable( + "target-cadence", + "Cadence", + target == "cadence", + cx.listener(|this, _event: &ClickEvent, _window, cx| { + if let Ok(mut state) = this.state.lock() { + state.set_export_target("cadence".to_string()); + } + this.close_color_pickers(cx); + this.mark_config_dirty(); + cx.notify(); + }), )); let mut scope_row = @@ -434,38 +481,38 @@ impl SeexUi { "导出范围:", ))); - let modes: [(&'static str, &'static str); 4] = if target == "kicad" { - [ + let modes: [(&'static str, &'static str); 4] = match target.as_str() { + "kicad" => [ ("full", locale::t("Full", "完整")), ("symbol", locale::t("Symbol", "符号")), ("footprint", locale::t("Footprint", "封装")), ("3d", "3D"), - ] - } else { - [ + ], + "cadence" => [ + ("full", locale::t("Full", "完整")), + ("symbol", "Capture"), + ("footprint", "Allegro"), + ("3d", "3D"), + ], + _ => [ ("full", locale::t("Full", "完整")), ("schlib", "SchLib"), ("pcblib", "PcbLib"), ("3d", "3D"), - ] - }; - let current_mode = if target == "kicad" { - kicad_mode.clone() - } else { - mode.clone() + ], }; for (value, label) in modes { let target = target.clone(); scope_row = scope_row.child(components::selectable( format!("scope-{value}"), label, - current_mode == value, + mode == value, cx.listener(move |this, _event: &ClickEvent, _window, cx| { if let Ok(mut state) = this.state.lock() { - if target == "kicad" { - state.set_export_kicad_mode(value.to_string()); - } else { - state.set_export_mode(value.to_string()); + match target.as_str() { + "kicad" => state.set_export_kicad_mode(value.to_string()), + "cadence" => state.set_export_cadence_mode(value.to_string()), + _ => state.set_export_mode(value.to_string()), } } this.mark_config_dirty(); @@ -482,11 +529,13 @@ impl SeexUi { .flex_1() .gap(px(12.0)); - // Merge belongs to Altium alone: a KiCad export always writes one named - // symbol library and one footprint directory, so there is nothing to - // merge. Append applies to both, with the same meaning in each. - if mode != "3d" { - if target != "kicad" { + // Merge belongs to Altium alone: a KiCad or Cadence export always writes + // one named library, so there is nothing to merge. Append applies to + // all three, with the same meaning in each. Altium's 3D scope only + // downloads models, so neither applies there. + let altium = target != "kicad" && target != "cadence"; + if !(altium && mode == "3d") { + if altium { toggles = toggles.child(components::hint( components::selectable( "toggle-merge", @@ -597,6 +646,27 @@ impl SeexUi { ), HintArea::ExportToggles, cx, + )) + .child(components::hint( + components::selectable( + "toggle-remove-logo", + locale::t("Remove 3D logo", "去除 3D 标志"), + remove_logo, + cx.listener(|this, _event: &ClickEvent, _window, cx| { + if let Ok(mut state) = this.state.lock() { + let next = !state.export_remove_model_logo; + state.set_export_remove_model_logo(next); + } + this.mark_config_dirty(); + cx.notify(); + }), + ), + locale::t( + "Remove 3D logo: rebuild 3D models without the EasyEDA logo; a model it cannot be removed from is exported as it is and listed", + "去除 3D 标志:重建 3D 模型并去除 EasyEDA 标志;无法去除的模型按原样导出并列出", + ), + HintArea::ExportToggles, + cx, )); let mut body = div() @@ -610,7 +680,7 @@ impl SeexUi { )) .child(target_row); - if target != "kicad" { + if target != "kicad" && target != "cadence" { // The fill can be "no colour", so its palette leads with the // candidate for that; the stroke always has a colour. let no_fill = components::none_color_candidate(