From 067dc929c42be962b585285f70eee7a4bf2d8f07 Mon Sep 17 00:00:00 2001 From: lins-code54 <288182861+lins-code54@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:44:31 -0400 Subject: [PATCH 1/2] Add Cadence (OrCAD Capture / Allegro) as an export target; remove the 3D logo by default Cadence joins Altium and KiCad on the Export page and, like them, writes library files the user imports; SeEx runs no Cadence program: - Capture symbols as one Library XML file (.xml), which Capture turns into an .olb with File > Import > Library XML - Allegro footprints as one SKILL script per footprint plus a library script (_allegro/.il); `skill load` of it in Allegro PCB Editor builds the .dra, .psm and .pad files in that folder - STEP models (_step), mapped on each footprint where EasyEDA places them - IPC-7352 land patterns for the standard SMD packages, checked against the manufacturer's lands; other packages keep the manufacturer's lands with IPC-7352 courtyard, silkscreen and place-bound rules - Append, Force, Continue on error and English metadata work as for KiCad The EasyEDA logo is now removed from downloaded 3D models by default, for every target. A model the logo cannot be cut out of safely is exported as it is and listed in the export result. The new "Remove 3D logo" option turns this off. STEP downloads fall back to modules.easyeda.com when modules.lceda.cn cannot be reached. --- README.en.md | 16 +- README.md | 14 +- src/altium/workflow.rs | 21 +- src/batch.rs | 13 +- src/cadence/allegro.rs | 1498 ++++++++++++++++++ src/cadence/capture.rs | 1418 +++++++++++++++++ src/cadence/ipc7352.rs | 2552 ++++++++++++++++++++++++++++++ src/cadence/mod.rs | 30 + src/cadence/step_place.rs | 301 ++++ src/cadence/tests.rs | 921 +++++++++++ src/cadence/workflow.rs | 787 +++++++++ src/config.rs | 5 + src/easyeda/api.rs | 67 +- src/easyeda/models.rs | 18 + src/exporter.rs | 189 ++- src/kicad/footprint_converter.rs | 111 +- src/kicad/mod.rs | 4 +- src/kicad/model_converter.rs | 1 + src/kicad/options.rs | 4 + src/kicad/symbol_converter.rs | 85 +- src/kicad/workflow.rs | 12 +- src/lceda/easyeda_fallback.rs | 2 + src/main.rs | 2 + src/metadata.rs | 1 + src/monitor.rs | 19 + src/step_logo.rs | 1245 +++++++++++++++ src/step_model.rs | 143 +- src/ui/app.rs | 4 + src/ui/pages/export.rs | 134 +- 29 files changed, 9468 insertions(+), 149 deletions(-) create mode 100644 src/cadence/allegro.rs create mode 100644 src/cadence/capture.rs create mode 100644 src/cadence/ipc7352.rs create mode 100644 src/cadence/mod.rs create mode 100644 src/cadence/step_place.rs create mode 100644 src/cadence/tests.rs create mode 100644 src/cadence/workflow.rs create mode 100644 src/step_logo.rs 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..7a599e0 --- /dev/null +++ b/src/cadence/capture.rs @@ -0,0 +1,1418 @@ +//! OrCAD Capture symbol export. +//! +//! Capture's native `.olb` format is an undocumented binary container, but Capture +//! (16.6 and later, including OrCAD X) converts a "Library XML" file into an `.olb` +//! with its `XMATIC_XML2OLB` Tcl command, which `cadence::tools` runs headlessly. 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. +//! +//! 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; +/// Horizontal gap between EasyEDA sub-parts when they are merged into one Capture part. +const UNIT_GAP: f64 = 40.0; +/// 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 mut geometry = merged_geometry(symbol, &mut warnings); + let jogs = align_to_grid(&mut geometry); + 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" + )); + } + // 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, &mut 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(part_name), + xml_escape(footprint), + xml_escape(&reference) + ); + let _ = writeln!(xml, " "); + let _ = writeln!(xml, " ", xml_escape(part_name)); + 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, " "); + let _ = writeln!(xml, " "); + + CapturePart { + name: part_name.to_string(), + xml, + warnings, + } +} + +/// 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 +} + +/// All graphics and pins of the symbol in one coordinate space (EasyEDA px, Y up). +/// +/// EasyEDA sub-parts are laid out side by side: Capture supports heterogeneous +/// packages, but their Library XML encoding is not something we can verify, so a +/// single-section part with every pin is the safe, electrically complete choice. +fn merged_geometry(symbol: &KiSymbol, warnings: &mut Vec) -> KiSymbolUnit { + let flat = 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(), + }; + if symbol.units.is_empty() { + return flat; + } + + let mut sections: Vec<&KiSymbolUnit> = Vec::new(); + if unit_has_content(&flat) { + sections.push(&flat); + } + sections.extend(symbol.units.iter().filter(|unit| unit_has_content(unit))); + if sections.len() > 1 { + warnings.push(format!( + "{} sub-parts merged side by side into one Capture section", + sections.len() + )); + } + + let mut merged = KiSymbolUnit::default(); + let mut cursor_x: Option = None; + for section in sections { + let Some((min_x, _, max_x, _)) = unit_bounds(section) else { + continue; + }; + let dx = match cursor_x { + None => 0.0, + // Keep sections on the pin grid so wires still snap. + Some(cursor) => snap(cursor + UNIT_GAP - min_x, CAPTURE_GRID as f64), + }; + cursor_x = Some(max_x + dx); + append_translated(&mut merged, section, dx); + } + merged +} + +/// 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..2d4f654 --- /dev/null +++ b/src/cadence/tests.rs @@ -0,0 +1,921 @@ +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(), 10); + assert_eq!(part.xml.matches("").count(), 2); + let rect_x: Vec = part + .xml + .match_indices("") + .map(|(index, _)| attr(&part.xml, index, "x1").parse().unwrap()) + .collect(); + assert!( + rect_x[1] - rect_x[0] >= 40 + 40, + "sections laid out side by side" + ); + assert!(part.warnings.iter().any(|w| w.contains("merged"))); +} + +#[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( From 743c1d30c6e00f381189729e29a9f5a312726439 Mon Sep 17 00:00:00 2001 From: lins-code54 <288182861+lins-code54@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:37:08 -0400 Subject: [PATCH 2/2] Export EasyEDA sub-parts as sections of a heterogeneous Capture part A symbol with several sub-parts was merged into one Capture section, and the pin layout then piled pins of different sub-parts onto the same connection points: RK3576 (six sub-parts, 698 pins) came out as one part with 133 stacked pin positions. Each sub-part is now its own section of a heterogeneous package (isHomogeneous="0"), one LibPart per section named after the package plus the section letter, each with its own drawing and pin numbers - the way Capture stores MAX628 in its own MiscPower.olb. Single symbols are unchanged. --- src/cadence/capture.rs | 166 +++++++++++++++++++++++++---------------- src/cadence/tests.rs | 51 +++++++++---- 2 files changed, 140 insertions(+), 77 deletions(-) diff --git a/src/cadence/capture.rs b/src/cadence/capture.rs index 7a599e0..b9c23b4 100644 --- a/src/cadence/capture.rs +++ b/src/cadence/capture.rs @@ -1,9 +1,8 @@ //! OrCAD Capture symbol export. //! //! Capture's native `.olb` format is an undocumented binary container, but Capture -//! (16.6 and later, including OrCAD X) converts a "Library XML" file into an `.olb` -//! with its `XMATIC_XML2OLB` Tcl command, which `cadence::tools` runs headlessly. This -//! module writes that XML. +//! (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`): @@ -14,7 +13,11 @@ //! 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. +//! * 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. @@ -30,8 +33,6 @@ use crate::kicad::symbol::{ /// Capture's standard pin grid (0.1 inch). pub const CAPTURE_GRID: i64 = 10; -/// Horizontal gap between EasyEDA sub-parts when they are merged into one Capture part. -const UNIT_GAP: f64 = 40.0; /// Default Capture colour index used by its own exports for symbol graphics. const SYMBOL_COLOR: &str = "48"; @@ -70,23 +71,90 @@ pub fn build_capture_part_with( labels: PinLabels, ) -> CapturePart { let mut warnings = Vec::new(); - let mut geometry = merged_geometry(symbol, &mut warnings); - let jogs = align_to_grid(&mut geometry); + 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 layout = CaptureLayout::new(geometry); let mut scratch = Vec::new(); - let (_, _, outline) = draw_unit(&geometry, &layout, &mut scratch); + 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, &mut warnings); + let (graphics, pins, bbox) = draw_unit(geometry, &layout, warnings); let mut pin_xml = String::new(); let mut physical = String::new(); @@ -108,16 +176,8 @@ pub fn build_capture_part_with( }; let mut xml = String::new(); - let _ = writeln!(xml, " "); - let _ = writeln!( - xml, - " ", - xml_escape(part_name), - xml_escape(footprint), - xml_escape(&reference) - ); let _ = writeln!(xml, " "); - let _ = writeln!(xml, " ", xml_escape(part_name)); + let _ = writeln!(xml, " ", xml_escape(cell)); let _ = writeln!(xml, " "); let _ = writeln!(xml, " "); // Labels go outside the pins so top/bottom pin names stay readable. @@ -181,13 +241,7 @@ pub fn build_capture_part_with( xml.push_str(&physical); let _ = writeln!(xml, " "); let _ = writeln!(xml, " "); - let _ = writeln!(xml, " "); - - CapturePart { - name: part_name.to_string(), - xml, - warnings, - } + xml } /// Wrap `` fragments into a complete Capture Library XML document. @@ -296,13 +350,12 @@ fn user_properties(symbol: &KiSymbol) -> Vec<(&'static str, String)> { props } -/// All graphics and pins of the symbol in one coordinate space (EasyEDA px, Y up). -/// -/// EasyEDA sub-parts are laid out side by side: Capture supports heterogeneous -/// packages, but their Library XML encoding is not something we can verify, so a -/// single-section part with every pin is the safe, electrically complete choice. -fn merged_geometry(symbol: &KiSymbol, warnings: &mut Vec) -> KiSymbolUnit { - let flat = KiSymbolUnit { +/// 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(), @@ -310,37 +363,22 @@ fn merged_geometry(symbol: &KiSymbol, warnings: &mut Vec) -> KiSymbolUni polylines: symbol.polylines.clone(), texts: symbol.texts.clone(), }; - if symbol.units.is_empty() { - return flat; - } - - let mut sections: Vec<&KiSymbolUnit> = Vec::new(); - if unit_has_content(&flat) { - sections.push(&flat); - } - sections.extend(symbol.units.iter().filter(|unit| unit_has_content(unit))); - if sections.len() > 1 { - warnings.push(format!( - "{} sub-parts merged side by side into one Capture section", - sections.len() - )); - } - - let mut merged = KiSymbolUnit::default(); - let mut cursor_x: Option = None; - for section in sections { - let Some((min_x, _, max_x, _)) = unit_bounds(section) else { - continue; - }; - let dx = match cursor_x { - None => 0.0, - // Keep sections on the pin grid so wires still snap. - Some(cursor) => snap(cursor + UNIT_GAP - min_x, CAPTURE_GRID as f64), - }; - cursor_x = Some(max_x + dx); - append_translated(&mut merged, section, dx); + let units: Vec<&KiSymbolUnit> = symbol + .units + .iter() + .filter(|unit| unit_has_content(unit)) + .collect(); + if units.is_empty() { + return vec![common]; } - merged + 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). diff --git a/src/cadence/tests.rs b/src/cadence/tests.rs index 2d4f654..f46814e 100644 --- a/src/cadence/tests.rs +++ b/src/cadence/tests.rs @@ -179,7 +179,7 @@ fn capture_renames_duplicate_pin_numbers() { } #[test] -fn capture_merges_sub_parts_without_overlap() { +fn sub_parts_become_sections_of_a_heterogeneous_package() { let mut symbol = sample_symbol(); let unit = KiSymbolUnit { pins: symbol.pins.clone(), @@ -197,18 +197,43 @@ fn capture_merges_sub_parts_without_overlap() { symbol.polylines.clear(); let part = build_capture_part(&symbol, "DUAL", "dual"); - assert_eq!(part.xml.matches("").count(), 10); - assert_eq!(part.xml.matches("").count(), 2); - let rect_x: Vec = part - .xml - .match_indices("") - .map(|(index, _)| attr(&part.xml, index, "x1").parse().unwrap()) - .collect(); - assert!( - rect_x[1] - rect_x[0] >= 40 + 40, - "sections laid out side by side" - ); - assert!(part.warnings.iter().any(|w| w.contains("merged"))); + let xml = &part.xml; + // As Capture stores MAX628 in MiscPower.olb: one LibPart per section, named + // after the package plus the section letter. + assert!(xml.contains("isHomogeneous=\"0\"")); + assert_eq!(xml.matches("").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]