diff --git a/README.en.md b/README.en.md
index 0e11cf2..893547c 100644
--- a/README.en.md
+++ b/README.en.md
@@ -5,7 +5,7 @@
SeEx (Seek and Export)
- Batch export LCSC components in formats supported by Altium Designer (AD) and KiCad.
+ Batch export LCSC components in formats supported by Altium Designer (AD), KiCad and Cadence (OrCAD Capture / Allegro).
English | 简体中文
@@ -38,6 +38,14 @@ The project history, in chronological order:
- [x] Support batch export.
- [x] Export multiple components into one merged `schlib` and `pcblib` library.
+### Cadence (OrCAD Capture / Allegro)
+
+- [x] Export `Capture` schematic symbols as one `Library XML` file, which Capture turns into an `.olb` with `File > Import > Library XML`.
+- [x] Export `Allegro` footprints as one `SKILL` script per footprint plus a library script: running `skill load("_allegro/.il")` in the Allegro PCB Editor command window builds the `.dra`, `.psm` and `.pad` files in that folder.
+- [x] Export `3D` models (`step`), mapped on the footprints where EasyEDA places them.
+- [x] Standard packages (chip resistors and capacitors, `SOT`, `SOD`, `SOIC`, `QFN`, ...) get `IPC-7352` lands, checked against the manufacturer's; other packages keep the manufacturer's lands with `IPC-7352` silkscreen, assembly and place-bound rules.
+- [x] `seex` runs no Cadence program itself; the import is done in Cadence.
+
### Shared Features
- [x] Export metadata in Chinese or English.
@@ -45,6 +53,7 @@ The project history, in chronological order:
- [x] Add new components to existing libraries exported by `seex`.
- [x] Customize schematic stroke and fill colors.
- [x] Export `3D` models independently.
+- [x] Remove the EasyEDA logo (watermark) from `3D` models by default and rebuild them; a model the logo cannot be removed from safely is exported as it is and named in the export result.
## Supported EDA Versions
@@ -53,13 +62,14 @@ The project history, in chronological order:
- [x] `KiCad 9.0` to `Latest`
- [x] `Altium Designer 23.x` to `Latest`
+- [x] `Cadence SPB 26.1` (OrCAD X Capture / Allegro X); the Allegro footprint scripts need a release that runs `SKILL` (`Allegro PCB Designer` or `OrCAD PCB Designer Professional`)
## What Is Planned?
-- [ ] Support more `EDA` tools, such as `allegro` and `pads`.
+- [ ] Support more `EDA` tools, such as `pads`.
- [ ] Continue improving the current workflow and fix more hidden `bugs`.
- [ ] Keep improving interactions and usability, refine the icons, and try new design languages.
-- [ ] Explore a broadly applicable way to remove or hide parts of `3D` models, such as specific markings.
+- [ ] Explore a broadly applicable way to remove or hide parts of `3D` models, such as specific markings (the EasyEDA logo is already removed by default).
## Who Would SeEx Like to Contribute?
diff --git a/README.md b/README.md
index 90f6fb1..664df29 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
SeEx(Seek and Export)
- 批量导出立创商城元件为Altium Designer(AD)、KiCad可用的格式。
+ 批量导出立创商城元件为Altium Designer(AD)、KiCad、Cadence(OrCAD Capture / Allegro)可用的格式。
English | 简体中文
@@ -32,24 +32,32 @@
- [x] 可以单独导出器件的`schlib`, `pcblib`(`pcblib`已经内嵌了`step`模型)。
- [x] 也可以批量导出。
- [x] 支持导出多个器件到同一个`schlib`, `pcblib`,即合并导出。
+### Cadence(OrCAD Capture / Allegro)部分
+- [x] 导出`Capture`原理图符号:一个`Library XML`文件,在`Capture`中通过`File > Import > Library XML`转换为`.olb`。
+- [x] 导出`Allegro`封装:每个封装一个`SKILL`脚本,外加一个库脚本;在`Allegro PCB Editor`的命令窗口执行`skill load("<库>_allegro/<库>.il")`,即在同一目录生成`.dra`、`.psm`和`.pad`。
+- [x] 导出`3D`模型(`step`),封装中已按立创EDA的位置映射好模型。
+- [x] 标准封装(贴片阻容、`SOT`、`SOD`、`SOIC`、`QFN`等)按`IPC-7352`计算焊盘并核对厂家焊盘;其余封装保留厂家焊盘,按`IPC-7352`规则生成丝印、装配层和占位区。
+- [x] `seex`本身不调用任何`Cadence`程序,导入由用户在`Cadence`中完成。
### 共同部分
- [x] 均支持选择中英文元数据导出。
- [x] 均支持多线程下载。
- [x] 均支持向已现存的经由`seex`导出的库里添加新的器件/元件。
- [x] 均支持自定义原理图的描边和填充颜色。
- [x] 均支持单独导出`3D`模型。
+- [x] 默认去除`3D`模型上的立创EDA标志(水印),重建模型;无法安全去除时保留原模型,并在导出结果中列出这些元件。
## SeEx支持的`EDA`版本???
> [!NOTE]
> 这些版本仅为目前所知,不排除`seex`支持更多的版本。如果您无法在更低版本的`EDA`软件中打开`seex`导出的库,可以在`issue`里提出,会考虑适配。
- [x] `KiCad 9.0` ~ `Latest`
- [x] `Altium Designer 23.x` ~ `Latest`
+- [x] `Cadence SPB 26.1`(OrCAD X Capture / Allegro X);`Allegro`封装脚本需要支持`SKILL`的版本(`Allegro PCB Designer`或`OrCAD PCB Designer Professional`)
## SeEx未来想做啥???
-- [ ] 支持更多的`EDA`工具,类似`allegro`, `pads`等。
+- [ ] 支持更多的`EDA`工具,类似`pads`等。
- [ ] 继续完善现在的工作流,修复更多隐藏的`bug`。
- [ ] 持续优化交互功能,提升用户使用体验,优化图标,尝试新的设计语言。
-- [ ] 探索一种普遍适用的方案,用于除去/隐去`3D`模型的一部分组件,类似特定标识符等等。
+- [ ] 探索一种普遍适用的方案,用于除去/隐去`3D`模型的一部分组件(立创EDA标志已默认去除),类似特定标识符等等。
## SeEx期待的贡献者???
- [x] 具有软件设计经验,能持续维护软件。
diff --git a/src/altium/workflow.rs b/src/altium/workflow.rs
index 68f5acd..476cc5e 100644
--- a/src/altium/workflow.rs
+++ b/src/altium/workflow.rs
@@ -17,6 +17,7 @@ use crate::error::{AppError, Result};
use crate::lceda::{LcedaClient, SearchItem};
use crate::lcsc::{LcscClient, LcscProduct};
use crate::metadata::ResolvedMetadata;
+use crate::step_model::ModelCleanup;
use crate::util::{nested_string, sanitize_filename, split_obj_and_mtl};
#[derive(Debug, Serialize)]
@@ -38,6 +39,7 @@ pub async fn download_step(
item: &SearchItem,
out_dir: &Path,
force: bool,
+ models: &ModelCleanup,
) -> Result {
fs::create_dir_all(out_dir)?;
let out_file = out_dir.join(item.choose_step_filename());
@@ -47,6 +49,10 @@ pub async fn download_step(
let model_uuid = client.get_model_uuid(item).await?;
let content = client.download_step_bytes(&model_uuid).await?;
+ let label = item
+ .lcsc_id()
+ .unwrap_or_else(|| item.display_name().to_string());
+ let content = models.clean(&label, &content);
// The STEP is stored in LCEDA's own frame, which does not always rest on the board plane.
fs::write(&out_file, crate::step_model::place_step_on_board(&content))?;
Ok(out_file)
@@ -184,6 +190,7 @@ pub(crate) async fn load_placed_step_for_pcblib(
client: &LcedaClient,
item: &SearchItem,
model_candidates: &[String],
+ models: &ModelCleanup,
) -> Option {
let mut candidates = model_candidates.to_vec();
if item.model_uuid.is_some() {
@@ -223,6 +230,10 @@ pub(crate) async fn load_placed_step_for_pcblib(
}
};
let _ = fs::remove_file(&downloaded);
+ let label = item
+ .lcsc_id()
+ .unwrap_or_else(|| item.display_name().to_string());
+ let bytes = models.clean(&label, &bytes);
let placed = scratch_step_path();
let written = fs::File::create(&placed)
@@ -250,9 +261,10 @@ pub(crate) async fn build_pcblib_library_from_detail(
item: &SearchItem,
footprint_data: &Value,
component_name: &str,
+ models: &ModelCleanup,
) -> Result {
let candidates = step_model_candidates(item, footprint_data);
- let step_data = load_placed_step_for_pcblib(client, item, &candidates).await;
+ let step_data = load_placed_step_for_pcblib(client, item, &candidates, models).await;
build_pcblib_from_payload_with_model(footprint_data, component_name, step_data)
}
@@ -306,12 +318,13 @@ pub async fn build_pcblib_library_for_item(
client: &LcedaClient,
item: &SearchItem,
footprint_name: &str,
+ models: &ModelCleanup,
) -> Result {
let footprint_uuid = item
.footprint_uuid()
.ok_or(AppError::MissingSymbolOrFootprint)?;
let footprint_data = client.component_detail(&footprint_uuid).await?;
- build_pcblib_library_from_detail(client, item, &footprint_data, footprint_name).await
+ build_pcblib_library_from_detail(client, item, &footprint_data, footprint_name, models).await
}
pub async fn build_schlib_component_for_item(
@@ -358,6 +371,7 @@ pub async fn export_pcblib(
item: &SearchItem,
out_dir: &Path,
force: bool,
+ models: &ModelCleanup,
) -> Result {
fs::create_dir_all(out_dir)?;
let footprint_uuid = item
@@ -371,7 +385,8 @@ pub async fn export_pcblib(
}
let library =
- build_pcblib_library_from_detail(client, item, &footprint_data, &footprint_name).await?;
+ build_pcblib_library_from_detail(client, item, &footprint_data, &footprint_name, models)
+ .await?;
write_pcblib(&library, &out_file)?;
Ok(out_file)
}
diff --git a/src/batch.rs b/src/batch.rs
index 4c20111..bf2fb5a 100644
--- a/src/batch.rs
+++ b/src/batch.rs
@@ -25,6 +25,7 @@ use crate::error::{AppError, Result};
use crate::lceda::{LcedaClient, SearchItem};
use crate::lcsc::{LcscClient, LcscProduct};
use crate::monitor::ExportProgressCallback;
+use crate::step_model::ModelCleanup;
use crate::util::sanitize_filename;
#[derive(Clone)]
@@ -44,6 +45,8 @@ pub struct BatchOptions {
pub schematic_stroke_color: [u8; 3],
/// `None` exports the symbol body with no fill at all.
pub schematic_fill_color: Option<[u8; 3]>,
+ /// What happens to downloaded 3D models before they are embedded (logo removal).
+ pub models: Arc,
pub progress: Option,
}
@@ -871,6 +874,7 @@ async fn export_merged_components_parallel(
merged_pcblib_file,
options.schematic_stroke_color,
options.schematic_fill_color,
+ &options.models,
parallel,
options.append,
summary,
@@ -1018,6 +1022,7 @@ async fn build_merged_components_parallel(
merged_pcblib_file: &str,
schematic_stroke_color: [u8; 3],
schematic_fill_color: Option<[u8; 3]>,
+ models: &Arc,
parallel: usize,
as_records: bool,
summary: &mut BatchSummary,
@@ -1032,6 +1037,7 @@ async fn build_merged_components_parallel(
let client = client.clone();
let semaphore = semaphore.clone();
let merged_pcblib_file = merged_pcblib_file.clone();
+ let models = Arc::clone(models);
join_set.spawn(async move {
let _permit = semaphore
.acquire_owned()
@@ -1045,6 +1051,7 @@ async fn build_merged_components_parallel(
merged_pcblib_file.as_str(),
schematic_stroke_color,
schematic_fill_color,
+ &models,
)
.await;
// An append merge only needs the streams, and reducing the part here keeps
@@ -1095,6 +1102,7 @@ async fn build_named_merged_component(
merged_pcblib_file: &str,
schematic_stroke_color: [u8; 3],
schematic_fill_color: Option<[u8; 3]>,
+ models: &ModelCleanup,
) -> Result {
let schlib_record = if targets.schlib {
let symbol_data = named
@@ -1122,7 +1130,7 @@ async fn build_named_merged_component(
// only needed to build the footprint, and a download takes seconds while the
// batch has one in flight per parallel part.
let step_data =
- load_placed_step_for_pcblib(client, &named.item, &named.step_candidates).await;
+ load_placed_step_for_pcblib(client, &named.item, &named.step_candidates, models).await;
let footprint_data = named
.footprint_data
.as_ref()
@@ -1342,7 +1350,7 @@ async fn export_component(
if targets.pcblib {
let pcblib_dir = options.output.join("pcblib");
- export_pcblib(client, &item, &pcblib_dir, options.force).await?;
+ export_pcblib(client, &item, &pcblib_dir, options.force, &options.models).await?;
}
Ok(ExportedComponent { display_name })
@@ -1485,6 +1493,7 @@ mod tests {
force: false,
schematic_stroke_color: [11, 56, 104],
schematic_fill_color: Some([255, 255, 224]),
+ models: Default::default(),
progress: None,
};
diff --git a/src/cadence/allegro.rs b/src/cadence/allegro.rs
new file mode 100644
index 0000000..5a6d9ce
--- /dev/null
+++ b/src/cadence/allegro.rs
@@ -0,0 +1,1498 @@
+//! Allegro / OrCAD PCB Editor footprint export.
+//!
+//! Allegro package symbols (`.dra` / `.psm`) and padstacks (`.pad`) are binary and
+//! undocumented, and only Allegro itself writes them. SeEx therefore writes one
+//! self-contained SKILL script per footprint, plus one library script that builds
+//! them all: loaded in Allegro PCB Editor (`skill load("…/.il")`), it opens a
+//! new package symbol drawing for each footprint, runs its script and saves the
+//! `.dra`, `.psm` and `.pad` files next to the scripts. Each footprint script can
+//! also be loaded on its own into an empty package symbol drawing.
+//!
+//! A script creates the padstacks, places the pins, draws the silkscreen, the place
+//! bound and the reference designator labels, and maps the STEP model. Every
+//! Allegro call that has varied between releases runs under `errset` with a
+//! fallback, so a single unsupported call degrades one feature instead of aborting.
+//! SKILL needs Allegro PCB Designer or OrCAD PCB Designer Professional; the base
+//! OrCAD PCB Designer tier does not run it.
+
+use std::collections::HashMap;
+use std::fmt::Write as _;
+
+use crate::kicad::footprint::{KiArc, KiCircle, KiFootprint, KiLine, KiPad, PadShape, PadType};
+
+/// EasyEDA footprint unit: 10 mil.
+const PX_TO_MM: f64 = 0.254;
+/// Solder mask opening added to every copper pad (total, i.e. 0.05 mm per side).
+const MASK_EXPANSION_MM: f64 = 0.1;
+/// Anti-pad / thermal relief of a plated hole on plane layers: pad + 0.5 mm
+/// (0.25 mm clearance per side), the usual Allegro default.
+const ANTI_EXPANSION_MM: f64 = 0.5;
+/// Clearance between the outermost geometry and the generated place bound.
+const PLACE_BOUND_CLEARANCE_MM: f64 = 0.25;
+/// Allegro still truncates/limits legacy names; stay inside the conservative limits.
+const MAX_SYMBOL_NAME: usize = 31;
+const MAX_PADSTACK_NAME: usize = 20;
+
+const SILK_TOP: &str = "PACKAGE GEOMETRY/SILKSCREEN_TOP";
+/// The silkscreen reference designator sits this far above the place bound (its
+/// baseline; Allegro anchors text at the baseline), centred over the part.
+const REFDES_GAP_MM: f64 = 0.3;
+
+// Line widths and text sizes follow the KiCad Library Conventions F5.1 / F5.2,
+// which put the IPC drawing layers into numbers:
+/// Silkscreen lines: 0.12 mm nominal (F5.1).
+pub const SILK_LINE_MM: f64 = 0.12;
+/// Assembly (fabrication) lines: 0.10 mm (F5.2).
+pub const ASSEMBLY_LINE_MM: f64 = 0.10;
+/// Silkscreen reference designator: 1.0 mm high, 0.15 mm strokes (F5.1) on
+/// parts wide enough for it; on small parts it shrinks with the part, down to
+/// 0.6 mm, so the label is about as wide as the part.
+pub const SILK_TEXT_MM: f64 = 1.0;
+pub const SILK_TEXT_MIN_MM: f64 = 0.6;
+/// Silkscreen strokes never go below this, whatever the text height.
+pub const SILK_STROKE_MIN_MM: f64 = 0.10;
+
+/// Height of the silkscreen reference designator `label` over a part whose
+/// place bound is `bound`: the label (at least three characters) spans the
+/// part's width, between 0.6 and 1.0 mm high.
+pub fn silk_refdes_height(bound: Option<(f64, f64, f64, f64)>, label: &str) -> f64 {
+ let Some((x1, _, x2, _)) = bound else {
+ return SILK_TEXT_MM;
+ };
+ let chars = label.chars().count().max(3) as f64;
+ ((x2 - x1) / (chars * CHAR_ADVANCE)).clamp(SILK_TEXT_MIN_MM, SILK_TEXT_MM)
+}
+
+/// Stroke width for silkscreen text `height` mm high: 15 %, at least 0.10 mm.
+pub fn silk_stroke(height: f64) -> f64 {
+ (height * 0.15).max(SILK_STROKE_MIN_MM)
+}
+/// Assembly reference designator: 0.5-1.0 mm high, scaled so about four
+/// characters fit the body; strokes 15 % of the height (F5.2).
+pub const ASSEMBLY_TEXT_MIN_MM: f64 = 0.5;
+pub const ASSEMBLY_TEXT_MAX_MM: f64 = 1.0;
+/// Text block used for the silkscreen reference designator.
+const SILK_TEXT_BLOCK: u32 = 3;
+/// Text block used for the assembly reference designator.
+const ASSEMBLY_TEXT_BLOCK: u32 = 2;
+/// Advance of one character as a fraction of the text height.
+const CHAR_ADVANCE: f64 = 0.9;
+
+/// Height, rotation and position of the assembly reference designator `label`
+/// for a body `(x1, y1, x2, y2)`: centred on the component along its long
+/// axis, scaled so the label (at least three characters) fits, 0.5-1.0 mm.
+pub fn assembly_refdes_layout(
+ body: Option<(f64, f64, f64, f64)>,
+ label: &str,
+) -> (f64, f64, f64, f64) {
+ let chars = label.chars().count().max(3) as f64;
+ let Some((x1, y1, x2, y2)) = body else {
+ return (ASSEMBLY_TEXT_MAX_MM, 0.0, 0.0, -ASSEMBLY_TEXT_MAX_MM / 2.0);
+ };
+ let (w, h) = (x2 - x1, y2 - y1);
+ let (major, minor, rotation) = if h > w { (h, w, 90.0) } else { (w, h, 0.0) };
+ let (cx, cy) = ((x1 + x2) / 2.0, (y1 + y2) / 2.0);
+ let height = (major / (chars * CHAR_ADVANCE))
+ .min(minor * 0.9)
+ .clamp(ASSEMBLY_TEXT_MIN_MM, ASSEMBLY_TEXT_MAX_MM);
+ // Centred on the component: the baseline half a text height below (or
+ // beside) the centre.
+ if rotation == 0.0 {
+ (height, rotation, cx, cy - height / 2.0)
+ } else {
+ (height, rotation, cx + height / 2.0, cy)
+ }
+}
+const SILK_BOTTOM: &str = "PACKAGE GEOMETRY/SILKSCREEN_BOTTOM";
+const ASSY_TOP: &str = "PACKAGE GEOMETRY/ASSEMBLY_TOP";
+const ASSY_BOTTOM: &str = "PACKAGE GEOMETRY/ASSEMBLY_BOTTOM";
+const PLACE_BOUND_TOP: &str = "PACKAGE GEOMETRY/PLACE_BOUND_TOP";
+
+/// A generated Allegro footprint script.
+#[derive(Debug, Clone)]
+pub struct AllegroFootprint {
+ /// Complete, self-contained SKILL script.
+ pub script: String,
+ /// Non-fatal conversion notes.
+ pub warnings: Vec,
+}
+
+/// What a script draws, kept to place the reference designators (mm, Y up).
+#[derive(Debug, Clone, Default)]
+struct Drawing {
+ /// Pad centres and the half size of the larger pad side.
+ pads: Vec<(f64, f64, f64)>,
+ /// Lines and arcs as polylines: layer and points.
+ lines: Vec<(String, Vec<(f64, f64)>)>,
+ /// Circles: layer, centre and radius.
+ circles: Vec<(String, (f64, f64), f64)>,
+}
+
+/// Allegro-safe lower-case identifier (`[a-z0-9_-]`), truncated to `max_len`.
+pub fn allegro_identifier(name: &str, max_len: usize) -> String {
+ let mut out = String::with_capacity(name.len());
+ let mut last_underscore = false;
+ for ch in name.chars() {
+ let mapped = if ch.is_ascii_alphanumeric() || ch == '-' {
+ ch.to_ascii_lowercase()
+ } else {
+ '_'
+ };
+ if mapped == '_' {
+ if last_underscore {
+ continue;
+ }
+ last_underscore = true;
+ } else {
+ last_underscore = false;
+ }
+ out.push(mapped);
+ }
+ let mut out = out.trim_matches('_').to_string();
+ if out.is_empty() {
+ out = "seex_fp".to_string();
+ }
+ if out.len() > max_len {
+ out.truncate(max_len);
+ out = out.trim_end_matches(['_', '-']).to_string();
+ }
+ out
+}
+
+/// Package symbol name for an LCSC component: the LCSC id first so truncation never
+/// makes two parts collide.
+pub fn footprint_name_for(lcsc_id: &str, title: &str) -> String {
+ allegro_identifier(&format!("{}_{}", lcsc_id, title), MAX_SYMBOL_NAME)
+}
+
+/// Rotation (degrees) and offset (mm) of a STEP model on its footprint, in
+/// Allegro's axes (Y up).
+#[derive(Debug, Clone, Copy, PartialEq, Default)]
+pub struct StepTransform {
+ pub rotation: [f64; 3],
+ pub offset: [f64; 3],
+}
+
+/// A STEP model to map onto the footprint.
+pub struct StepModel<'a> {
+ /// File name; Allegro stores only this and finds the file through STEPPATH.
+ pub file: &'a str,
+ /// Folder holding the file.
+ pub dir: &'a std::path::Path,
+ pub transform: StepTransform,
+}
+
+/// Build the SKILL script for `footprint`, mapping `step` as its primary 3D model.
+pub fn build_allegro_footprint_with(
+ footprint: &KiFootprint,
+ name: &str,
+ refdes_prefix: &str,
+ step: Option,
+) -> AllegroFootprint {
+ let mut builder = Builder::default();
+ builder.pins_and_padstacks(footprint);
+ builder.graphics(footprint);
+ let place_bound = place_bound(footprint);
+ // The component frame is the silkscreen: the assembly layer keeps only the
+ // reference designator, centred on the body the assembly outline gave.
+ let body = assembly_box(&builder.drawing);
+ builder.drop_assembly_graphics();
+
+ let refdes = format!("{}*", refdes_label(refdes_prefix));
+ let mut script = String::new();
+ let _ = writeln!(
+ script,
+ ";; {name}.il - Allegro package symbol generated by SeEx (https://github.com/ref42/seex)\n\
+ ;;\n\
+ ;; The library script in this folder builds every footprint at once. To build\n\
+ ;; only this one:\n\
+ ;; 1. Allegro PCB Editor: File > New, Drawing Type \"Package symbol\", name \"{name}\".\n\
+ ;; 2. Command window: skill load(\"/{name}.il\")\n\
+ ;; 3. File > Save writes {name}.dra; File > Create Symbol writes {name}.psm.\n\
+ ;; Padstacks are written as .pad files to the current working directory; keep\n\
+ ;; them on your PADPATH together with the .psm on your PSMPATH.\n"
+ );
+ script.push_str(HELPERS);
+ let _ = writeln!(
+ script,
+ "\nwhen( seex_begin(\"{name}\" {})",
+ f(drawing_half_extent(footprint))
+ );
+ for line in &builder.padstack_calls {
+ let _ = writeln!(script, " {line}");
+ }
+ for line in &builder.body {
+ let _ = writeln!(script, " {line}");
+ }
+ let silk_height = silk_refdes_height(place_bound, &refdes);
+ let _ = writeln!(
+ script,
+ " seex_text_block({SILK_TEXT_BLOCK} {} {} {})",
+ f(silk_height),
+ f(silk_height),
+ f(silk_stroke(silk_height))
+ );
+ let body = body.or_else(|| pad_box(&builder.drawing));
+ let (assembly_height, assembly_rotation, assembly_x, assembly_y) =
+ assembly_refdes_layout(body, &refdes);
+ let _ = writeln!(
+ script,
+ " seex_text_block({ASSEMBLY_TEXT_BLOCK} {} {} {})",
+ f(assembly_height),
+ f(assembly_height),
+ f(assembly_height * 0.15)
+ );
+ if let Some((x1, y1, x2, y2)) = place_bound {
+ let _ = writeln!(
+ script,
+ " seex_place_bound(\"{PLACE_BOUND_TOP}\" {} {} {} {})",
+ f(x1),
+ f(y1),
+ f(x2),
+ f(y2)
+ );
+ let label_y = y2 + REFDES_GAP_MM;
+ let _ = writeln!(
+ script,
+ " seex_text(\"REF DES/SILKSCREEN_TOP\" \"{refdes}\" {} {} {SILK_TEXT_BLOCK} 0.0)",
+ f((x1 + x2) / 2.0),
+ f(label_y)
+ );
+ } else {
+ let _ = writeln!(
+ script,
+ " seex_text(\"REF DES/SILKSCREEN_TOP\" \"{refdes}\" 0.0 1.0 {SILK_TEXT_BLOCK} 0.0)"
+ );
+ }
+ let _ = writeln!(
+ script,
+ " seex_text(\"REF DES/ASSEMBLY_TOP\" \"{refdes}\" {} {} {ASSEMBLY_TEXT_BLOCK} {})",
+ f(assembly_x),
+ f(assembly_y),
+ f(assembly_rotation)
+ );
+ if let Some(step) = step {
+ let [rx, ry, rz] = step.transform.rotation;
+ let [ox, oy, oz] = step.transform.offset;
+ let _ = writeln!(
+ script,
+ " seex_step(\"{}\" \"{}\" {} {} {} {} {} {})",
+ skill_string(step.file),
+ skill_string(&step.dir.to_string_lossy().replace('\\', "/")),
+ f(rx),
+ f(ry),
+ f(rz),
+ f(ox),
+ f(oy),
+ f(oz)
+ );
+ }
+ let _ = writeln!(script, " seex_end(\"{name}\")\n)");
+
+ AllegroFootprint {
+ script,
+ warnings: builder.warnings,
+ }
+}
+
+/// Extent of the copper, for parts without a body outline.
+fn pad_box(drawing: &Drawing) -> Option<(f64, f64, f64, f64)> {
+ let points: Vec<(f64, f64)> = drawing
+ .pads
+ .iter()
+ .flat_map(|&(x, y, half)| [(x - half, y - half), (x + half, y + half)])
+ .collect();
+ if points.is_empty() {
+ return None;
+ }
+ let (x1, y1, x2, y2) = bounds(&points);
+ (x2 - x1 > 1e-6 && y2 - y1 > 1e-6).then_some((x1, y1, x2, y2))
+}
+
+/// Extent of the assembly-top outline, i.e. the component body.
+fn assembly_box(drawing: &Drawing) -> Option<(f64, f64, f64, f64)> {
+ let points: Vec<(f64, f64)> = drawing
+ .lines
+ .iter()
+ .filter(|(layer, _)| layer == ASSY_TOP)
+ .flat_map(|(_, points)| points.iter().copied())
+ .chain(
+ drawing
+ .circles
+ .iter()
+ .filter(|(layer, ..)| layer == ASSY_TOP)
+ .flat_map(|(_, (x, y), r)| [(x - r, y - r), (x + r, y + r)]),
+ )
+ .collect();
+ if points.is_empty() {
+ return None;
+ }
+ let (x1, y1, x2, y2) = bounds(&points);
+ (x2 - x1 > 1e-6 && y2 - y1 > 1e-6).then_some((x1, y1, x2, y2))
+}
+
+fn refdes_label(prefix: &str) -> String {
+ let prefix: String = prefix
+ .trim()
+ .trim_end_matches('?')
+ .chars()
+ .filter(|ch| ch.is_ascii_alphabetic())
+ .collect();
+ if prefix.is_empty() {
+ "U".to_string()
+ } else {
+ prefix
+ }
+}
+
+#[derive(Default)]
+struct Builder {
+ padstack_calls: Vec,
+ padstack_names: Vec,
+ body: Vec,
+ warnings: Vec,
+ drawing: Drawing,
+}
+
+impl Builder {
+ /// Removes the assembly outline (lines, arcs, circles) from the drawing and
+ /// the script.
+ fn drop_assembly_graphics(&mut self) {
+ let assembly = |layer: &str| layer == ASSY_TOP || layer == ASSY_BOTTOM;
+ self.drawing.lines.retain(|(layer, ..)| !assembly(layer));
+ self.drawing.circles.retain(|(layer, ..)| !assembly(layer));
+ self.body.retain(|call| {
+ !call.contains(&format!("\"{ASSY_TOP}\""))
+ && !call.contains(&format!("\"{ASSY_BOTTOM}\""))
+ });
+ }
+
+ fn pins_and_padstacks(&mut self, footprint: &KiFootprint) {
+ let mut used_numbers: HashMap = HashMap::new();
+ let mut skipped_vias = 0usize;
+
+ for pad in &footprint.pads {
+ let number = pad.number.trim().to_string();
+ let is_mechanical = pad.pad_type == PadType::NpThroughHole;
+ if number.is_empty() && !is_mechanical {
+ // Unnumbered plated holes are EasyEDA vias (usually thermal vias in an
+ // exposed pad). As Allegro pins they would be unconnected mechanical
+ // pins inside a copper pad, so they are left out on purpose.
+ skipped_vias += 1;
+ continue;
+ }
+
+ let (x, y) = to_mm(pad.pos_x, pad.pos_y);
+ let pin_number = if is_mechanical {
+ None
+ } else {
+ let count = used_numbers.entry(number.clone()).or_insert(0);
+ *count += 1;
+ if *count == 1 {
+ Some(number.clone())
+ } else {
+ let renamed = format!("{}_{}", number, count);
+ self.warnings.push(format!(
+ "pad number {number} is used more than once; extra pad renamed {renamed} \
+ (Allegro pin numbers must be unique)"
+ ));
+ Some(renamed)
+ }
+ };
+
+ let Some(stack) = Padstack::for_pad(pad, &mut self.warnings) else {
+ continue;
+ };
+ self.declare_padstack(&stack);
+
+ let rotation = if stack.custom_polygon.is_some() {
+ 0.0
+ } else {
+ normalize_degrees(pad.rotation)
+ };
+ let number_arg = match &pin_number {
+ Some(number) => format!("\"{}\"", skill_escape(number)),
+ None => "nil".to_string(),
+ };
+ self.drawing
+ .pads
+ .push((x, y, stack.width.max(stack.height) / 2.0));
+ self.body.push(format!(
+ "seex_pin(\"{}\" {} {} {} {})",
+ stack.name,
+ number_arg,
+ f(x),
+ f(y),
+ f(rotation)
+ ));
+
+ if let Some(points) = &stack.custom_polygon {
+ let absolute: Vec<(f64, f64)> =
+ points.iter().map(|(px, py)| (x + px, y + py)).collect();
+ let (copper, mask, paste) = if stack.bottom {
+ (
+ "ETCH/BOTTOM",
+ "PACKAGE GEOMETRY/SOLDERMASK_BOTTOM",
+ "PACKAGE GEOMETRY/PASTEMASK_BOTTOM",
+ )
+ } else {
+ (
+ "ETCH/TOP",
+ "PACKAGE GEOMETRY/SOLDERMASK_TOP",
+ "PACKAGE GEOMETRY/PASTEMASK_TOP",
+ )
+ };
+ for layer in [copper, mask, paste] {
+ self.body.push(format!(
+ "seex_shape(\"{layer}\" {})",
+ points_list(&absolute)
+ ));
+ }
+ }
+ }
+
+ if skipped_vias > 0 {
+ self.warnings.push(format!(
+ "{skipped_vias} unnumbered via(s) skipped; add thermal vias to the pad in Allegro if needed"
+ ));
+ }
+ }
+
+ fn declare_padstack(&mut self, stack: &Padstack) {
+ if self.padstack_names.contains(&stack.name) {
+ return;
+ }
+ self.padstack_names.push(stack.name.clone());
+ self.padstack_calls.push(stack.skill());
+ self.padstack_calls.push(stack.check_call());
+ }
+
+ fn graphics(&mut self, footprint: &KiFootprint) {
+ let mut skipped_layers: Vec = Vec::new();
+ let mut note_skip = |layer: &str| {
+ if !skipped_layers.iter().any(|known| known == layer) {
+ skipped_layers.push(layer.to_string());
+ }
+ };
+
+ for line in &footprint.lines {
+ match graphic_layer(&line.layer) {
+ Some(layer) => self.line(layer, line),
+ None if is_courtyard(&line.layer) => {}
+ None => note_skip(&line.layer),
+ }
+ }
+ for circle in &footprint.circles {
+ match graphic_layer(&circle.layer) {
+ Some(layer) => self.circle(layer, circle),
+ None if is_courtyard(&circle.layer) => {}
+ None => note_skip(&circle.layer),
+ }
+ }
+ for arc in &footprint.arcs {
+ match graphic_layer(&arc.layer) {
+ Some(layer) => self.arc(layer, arc),
+ None if is_courtyard(&arc.layer) => {}
+ None => note_skip(&arc.layer),
+ }
+ }
+
+ if !skipped_layers.is_empty() {
+ self.warnings.push(format!(
+ "graphics on layers without an Allegro equivalent were skipped: {}",
+ skipped_layers.join(", ")
+ ));
+ }
+ if !footprint.texts.is_empty() {
+ self.warnings.push(format!(
+ "{} footprint text item(s) not converted (Allegro text blocks are design specific)",
+ footprint.texts.len()
+ ));
+ }
+ }
+
+ fn line(&mut self, layer: &str, line: &KiLine) {
+ let a = to_mm(line.start_x, line.start_y);
+ let b = to_mm(line.end_x, line.end_y);
+ if distance(a, b) < 1e-6 {
+ return;
+ }
+ self.drawing.lines.push((layer.to_string(), vec![a, b]));
+ self.body.push(format!(
+ "seex_line(\"{layer}\" {} {})",
+ f(line_width(layer, line.width)),
+ points_list(&[a, b])
+ ));
+ }
+
+ fn circle(&mut self, layer: &str, circle: &KiCircle) {
+ let center = to_mm(circle.center_x, circle.center_y);
+ let edge = to_mm(circle.end_x, circle.end_y);
+ let radius = distance(center, edge);
+ if radius < 1e-6 {
+ return;
+ }
+ self.drawing
+ .circles
+ .push((layer.to_string(), center, radius));
+ self.body.push(format!(
+ "seex_circle(\"{layer}\" {} {} {} {} {})",
+ f(line_width_mm(layer, circle.width)),
+ f(center.0),
+ f(center.1),
+ f(radius),
+ if circle.fill { "t" } else { "nil" }
+ ));
+ }
+
+ fn arc(&mut self, layer: &str, arc: &KiArc) {
+ let start = to_mm(arc.start_x, arc.start_y);
+ let mid = to_mm(arc.mid_x, arc.mid_y);
+ let end = to_mm(arc.end_x, arc.end_y);
+ let width = line_width(layer, arc.width);
+ let Some((center, _)) = circumcircle(start, mid, end) else {
+ self.drawing
+ .lines
+ .push((layer.to_string(), vec![start, mid, end]));
+ self.body.push(format!(
+ "seex_line(\"{layer}\" {} {})",
+ f(width),
+ points_list(&[start, mid, end])
+ ));
+ return;
+ };
+ // Y points up here, so a negative cross product means clockwise travel.
+ let cross = (mid.0 - start.0) * (end.1 - mid.1) - (mid.1 - start.1) * (end.0 - mid.0);
+ let clockwise = cross < 0.0;
+ let fallback = arc_polyline(start, end, center, clockwise);
+ self.drawing
+ .lines
+ .push((layer.to_string(), fallback.clone()));
+ self.body.push(format!(
+ "seex_arc(\"{layer}\" {} {} {} {} {} {} {} {} {})",
+ f(width),
+ f(start.0),
+ f(start.1),
+ f(end.0),
+ f(end.1),
+ f(center.0),
+ f(center.1),
+ if clockwise { "t" } else { "nil" },
+ points_list(&fallback)
+ ));
+ }
+}
+
+/// Padstack definition derived from one EasyEDA pad.
+struct Padstack {
+ name: String,
+ /// SKILL figure symbol and size (mm) of the copper pad.
+ figure: &'static str,
+ width: f64,
+ height: f64,
+ kind: PadstackKind,
+ bottom: bool,
+ /// Pad-relative outline (mm, Y up) for custom polygon pads.
+ custom_polygon: Option>,
+}
+
+enum PadstackKind {
+ Smd,
+ Plated { drill_x: f64, drill_y: f64 },
+ NonPlated { drill: f64 },
+}
+
+impl Padstack {
+ fn for_pad(pad: &KiPad, warnings: &mut Vec) -> Option {
+ let bottom = pad.layers.iter().any(|layer| layer.starts_with("B."))
+ && !pad.layers.iter().any(|layer| layer.starts_with("F."));
+ let mut width = px(pad.size_x).max(0.01);
+ let mut height = px(pad.size_y).max(0.01);
+
+ let kind = match pad.pad_type {
+ PadType::NpThroughHole => {
+ let drill = pad
+ .drill
+ .as_ref()
+ .map(|drill| px(drill.diameter))
+ .unwrap_or(width);
+ PadstackKind::NonPlated { drill }
+ }
+ PadType::ThroughHole => {
+ let Some(drill) = &pad.drill else {
+ warnings.push(format!(
+ "through-hole pad {} has no drill; skipped",
+ pad.number
+ ));
+ return None;
+ };
+ let drill_x = px(drill.diameter);
+ let drill_y = drill.width.map(px).unwrap_or(drill_x);
+ PadstackKind::Plated { drill_x, drill_y }
+ }
+ PadType::Smd | PadType::Connect => PadstackKind::Smd,
+ };
+
+ let mut custom_polygon = None;
+ let mut figure = match pad.shape {
+ PadShape::Circle => "CIRCLE",
+ PadShape::Oval => "OBLONG",
+ _ => "RECTANGLE",
+ };
+ if figure == "CIRCLE" {
+ let diameter = width.max(height);
+ width = diameter;
+ height = diameter;
+ }
+ if figure == "OBLONG" && (width - height).abs() < 1e-6 {
+ figure = "CIRCLE";
+ }
+ // Allegro rejects a RECTANGLE with equal sides ("Illegal figure size").
+ if figure == "RECTANGLE" && (width - height).abs() < 1e-6 {
+ figure = "SQUARE";
+ }
+
+ if pad.shape == PadShape::Custom {
+ let points = pad
+ .polygon
+ .as_deref()
+ .map(parse_kicad_polygon)
+ .unwrap_or_default();
+ if points.len() >= 3 {
+ let (min_x, min_y, max_x, max_y) = bounds(&points);
+ // A small round pad anchors the pin; the real outline is drawn as
+ // copper / mask / paste shapes around it.
+ let anchor = ((max_x - min_x).min(max_y - min_y) * 0.5).clamp(0.1, 1.0);
+ width = anchor;
+ height = anchor;
+ figure = "CIRCLE";
+ warnings.push(format!(
+ "pad {} is a custom polygon: emitted as a {:.2} mm anchor pad plus copper/mask/paste shapes",
+ pad.number, anchor
+ ));
+ custom_polygon = Some(points);
+ } else {
+ warnings.push(format!(
+ "pad {} is a custom polygon without usable points; using its bounding size",
+ pad.number
+ ));
+ }
+ }
+
+ let name = padstack_name(figure, width, height, &kind, bottom);
+ Some(Self {
+ name,
+ figure,
+ width,
+ height,
+ kind,
+ bottom,
+ custom_polygon,
+ })
+ }
+
+ /// Layers (as Allegro reports them) and pad types this padstack must have.
+ fn required_layers(&self) -> Vec<(&'static str, &'static str)> {
+ match &self.kind {
+ PadstackKind::Smd if self.bottom => vec![
+ ("ETCH/BOTTOM", "REGULAR"),
+ ("PIN/SOLDERMASK_BOTTOM", "REGULAR"),
+ ("PIN/PASTEMASK_BOTTOM", "REGULAR"),
+ ],
+ PadstackKind::Smd => vec![
+ ("ETCH/TOP", "REGULAR"),
+ ("PIN/SOLDERMASK_TOP", "REGULAR"),
+ ("PIN/PASTEMASK_TOP", "REGULAR"),
+ ],
+ PadstackKind::Plated { .. } => vec![
+ ("ETCH/TOP", "REGULAR"),
+ ("ETCH/TOP", "ANTI"),
+ ("ETCH/TOP", "THERMAL"),
+ ("ETCH/BOTTOM", "REGULAR"),
+ ("ETCH/BOTTOM", "ANTI"),
+ ("ETCH/BOTTOM", "THERMAL"),
+ ("PIN/SOLDERMASK_TOP", "REGULAR"),
+ ("PIN/SOLDERMASK_BOTTOM", "REGULAR"),
+ ],
+ PadstackKind::NonPlated { .. } => vec![
+ ("PIN/SOLDERMASK_TOP", "REGULAR"),
+ ("PIN/SOLDERMASK_BOTTOM", "REGULAR"),
+ ],
+ }
+ }
+
+ /// `seex_check_padstack(name required drilled)`: reads the padstack back and
+ /// reports any required layer it lacks.
+ fn check_call(&self) -> String {
+ let required: Vec = self
+ .required_layers()
+ .iter()
+ .map(|(layer, kind)| format!("list(\"{layer}\" \"{kind}\")"))
+ .collect();
+ let drilled = if matches!(self.kind, PadstackKind::Smd) {
+ "nil"
+ } else {
+ "t"
+ };
+ format!(
+ "seex_check_padstack(\"{}\" list({}) {drilled})",
+ self.name,
+ required.join(" ")
+ )
+ }
+
+ /// `seex_padstack(name drill pads fallbackDrill fallbackPads)` call.
+ fn skill(&self) -> String {
+ let fig = format!("'{}", self.figure);
+ let fallback_fig = if self.figure == "OBLONG" {
+ "'RECTANGLE".to_string()
+ } else {
+ fig.clone()
+ };
+ let (w, h) = (self.width, self.height);
+ let (mw, mh) = (w + MASK_EXPANSION_MM, h + MASK_EXPANSION_MM);
+ match &self.kind {
+ PadstackKind::Smd => {
+ let (copper, mask, paste) = if self.bottom {
+ ("BOTTOM", "SOLDERMASK_BOTTOM", "PASTEMASK_BOTTOM")
+ } else {
+ ("TOP", "SOLDERMASK_TOP", "PASTEMASK_TOP")
+ };
+ let pads = |fig: &str| {
+ format!(
+ "list({} {} {})",
+ pad_call(copper, fig, w, h),
+ pad_call(mask, fig, mw, mh),
+ pad_call(paste, fig, w, h)
+ )
+ };
+ format!(
+ "seex_padstack(\"{}\" nil {} nil {})",
+ self.name,
+ pads(&fig),
+ pads(&fallback_fig)
+ )
+ }
+ PadstackKind::Plated { drill_x, drill_y } => {
+ let (aw, ah) = (w + ANTI_EXPANSION_MM, h + ANTI_EXPANSION_MM);
+ let all_layers = |fig: &str, internal: bool| {
+ let mut etch = vec!["TOP"];
+ if internal {
+ etch.push("DEFAULT_INTERNAL");
+ }
+ etch.push("BOTTOM");
+ let mut calls = Vec::new();
+ for layer in etch {
+ calls.push(pad_call(layer, fig, w, h));
+ calls.push(typed_pad_call(layer, "ANTI", fig, aw, ah));
+ calls.push(typed_pad_call(layer, "THERMAL", fig, aw, ah));
+ }
+ calls.push(pad_call("SOLDERMASK_TOP", fig, mw, mh));
+ calls.push(pad_call("SOLDERMASK_BOTTOM", fig, mw, mh));
+ format!("list({})", calls.join(" "))
+ };
+ let is_slot = (drill_x - drill_y).abs() > 1e-6;
+ let drill = if is_slot {
+ format!("seex_slot({} {} t)", f(*drill_x), f(*drill_y))
+ } else {
+ format!("seex_drill({} t)", f(*drill_x))
+ };
+ // Fallback: round drill (smallest slot dimension) and no internal pad.
+ let fallback_drill = format!("seex_drill({} t)", f(drill_x.min(*drill_y)));
+ format!(
+ "seex_padstack(\"{}\" {} {} {} {})",
+ self.name,
+ drill,
+ all_layers(&fig, true),
+ fallback_drill,
+ all_layers(&fallback_fig, false)
+ )
+ }
+ PadstackKind::NonPlated { drill } => {
+ let mask = drill + MASK_EXPANSION_MM;
+ let pads = format!(
+ "list({} {})",
+ pad_call("SOLDERMASK_TOP", "'CIRCLE", mask, mask),
+ pad_call("SOLDERMASK_BOTTOM", "'CIRCLE", mask, mask)
+ );
+ format!(
+ "seex_padstack(\"{}\" seex_drill({} nil) {pads} seex_drill({} nil) {pads})",
+ self.name,
+ f(*drill),
+ f(*drill)
+ )
+ }
+ }
+ }
+}
+
+fn pad_call(layer: &str, figure: &str, width: f64, height: f64) -> String {
+ format!("seex_pad(\"{layer}\" {figure} {} {})", f(width), f(height))
+}
+
+fn typed_pad_call(layer: &str, kind: &str, figure: &str, width: f64, height: f64) -> String {
+ format!(
+ "seex_padt(\"{layer}\" '{kind} {figure} {} {})",
+ f(width),
+ f(height)
+ )
+}
+
+/// Deterministic, geometry-based padstack name so identical pads share one `.pad`.
+fn padstack_name(
+ figure: &str,
+ width: f64,
+ height: f64,
+ kind: &PadstackKind,
+ bottom: bool,
+) -> String {
+ let fig = match figure {
+ "CIRCLE" => "c",
+ "OBLONG" => "o",
+ "SQUARE" => "q",
+ _ => "r",
+ };
+ let size = if fig == "c" || fig == "q" {
+ hundredths(width)
+ } else {
+ format!("{}x{}", hundredths(width), hundredths(height))
+ };
+ let name = match kind {
+ PadstackKind::Smd => format!("sx_{}{fig}{size}", if bottom { "b" } else { "s" }),
+ PadstackKind::Plated { drill_x, drill_y } => {
+ let drill = if (drill_x - drill_y).abs() > 1e-6 {
+ format!("{}x{}", hundredths(*drill_x), hundredths(*drill_y))
+ } else {
+ hundredths(*drill_x)
+ };
+ format!("sx_t{fig}{size}d{drill}")
+ }
+ PadstackKind::NonPlated { drill } => format!("sx_h{}", hundredths(*drill)),
+ };
+ if name.len() <= MAX_PADSTACK_NAME {
+ name
+ } else {
+ // Keep the type prefix readable and make the rest a stable short hash.
+ format!("{}{:08x}", &name[..5], fnv1a(&name))
+ }
+}
+
+fn hundredths(mm: f64) -> String {
+ format!("{}", (mm * 100.0).round() as i64)
+}
+
+fn fnv1a(text: &str) -> u32 {
+ let mut hash: u32 = 0x811c_9dc5;
+ for byte in text.bytes() {
+ hash ^= u32::from(byte);
+ hash = hash.wrapping_mul(0x0100_0193);
+ }
+ hash
+}
+
+/// Points of a KiCad custom pad primitive: `(xy 0.12 -0.34)` pairs, pad-relative mm
+/// with Y down. Returned with Y up.
+fn parse_kicad_polygon(text: &str) -> Vec<(f64, f64)> {
+ let mut points = Vec::new();
+ let mut rest = text;
+ while let Some(index) = rest.find("(xy ") {
+ rest = &rest[index + 4..];
+ let Some(end) = rest.find(')') else {
+ break;
+ };
+ let mut numbers = rest[..end].split_whitespace().map(str::parse::);
+ if let (Some(Ok(x)), Some(Ok(y))) = (numbers.next(), numbers.next()) {
+ points.push((x, -y));
+ }
+ rest = &rest[end..];
+ }
+ points
+}
+
+/// Half size (mm) of a square drawing area centred on the origin that holds the
+/// whole footprint with room for labels. New Allegro drawings start at (0, 0), so
+/// without this every negative coordinate is "outside of the extents".
+fn drawing_half_extent(footprint: &KiFootprint) -> f64 {
+ let mut max_abs: f64 = 0.0;
+ let mut add = |x: f64, y: f64| {
+ let (x, y) = to_mm(x, y);
+ max_abs = max_abs.max(x.abs()).max(y.abs());
+ };
+ for pad in &footprint.pads {
+ let half = pad.size_x.max(pad.size_y);
+ add(pad.pos_x.abs() + half, pad.pos_y.abs() + half);
+ }
+ for line in &footprint.lines {
+ add(line.start_x, line.start_y);
+ add(line.end_x, line.end_y);
+ }
+ for circle in &footprint.circles {
+ let r = ((circle.end_x - circle.center_x).powi(2)
+ + (circle.end_y - circle.center_y).powi(2))
+ .sqrt();
+ add(circle.center_x.abs() + r, circle.center_y.abs() + r);
+ }
+ for arc in &footprint.arcs {
+ add(arc.start_x, arc.start_y);
+ add(arc.mid_x, arc.mid_y);
+ add(arc.end_x, arc.end_y);
+ }
+ (max_abs * 2.0 + 10.0).ceil()
+}
+
+fn place_bound(footprint: &KiFootprint) -> Option<(f64, f64, f64, f64)> {
+ let mut courtyard = Vec::new();
+ for line in footprint
+ .lines
+ .iter()
+ .filter(|line| is_courtyard(&line.layer))
+ {
+ courtyard.push(to_mm(line.start_x, line.start_y));
+ courtyard.push(to_mm(line.end_x, line.end_y));
+ }
+ if courtyard.len() >= 2 {
+ let (x1, y1, x2, y2) = bounds(&courtyard);
+ if x2 - x1 > 1e-6 && y2 - y1 > 1e-6 {
+ return Some((x1, y1, x2, y2));
+ }
+ }
+
+ // No usable courtyard: wrap the pads.
+ let mut points = Vec::new();
+ for pad in &footprint.pads {
+ let (x, y) = to_mm(pad.pos_x, pad.pos_y);
+ let half = px(pad.size_x.max(pad.size_y)) / 2.0;
+ points.push((x - half, y - half));
+ points.push((x + half, y + half));
+ }
+ if points.is_empty() {
+ return None;
+ }
+ let (x1, y1, x2, y2) = bounds(&points);
+ Some((
+ x1 - PLACE_BOUND_CLEARANCE_MM,
+ y1 - PLACE_BOUND_CLEARANCE_MM,
+ x2 + PLACE_BOUND_CLEARANCE_MM,
+ y2 + PLACE_BOUND_CLEARANCE_MM,
+ ))
+}
+
+fn is_courtyard(layer: &str) -> bool {
+ matches!(layer, "F.CrtYd" | "B.CrtYd")
+}
+
+fn graphic_layer(layer: &str) -> Option<&'static str> {
+ match layer {
+ "F.SilkS" => Some(SILK_TOP),
+ "B.SilkS" => Some(SILK_BOTTOM),
+ "F.Fab" | "Dwgs.User" => Some(ASSY_TOP),
+ "B.Fab" => Some(ASSY_BOTTOM),
+ _ => None,
+ }
+}
+
+/// Line widths in the shared model are not consistently scaled (some sources are px,
+/// some already mm); accept plausible values and fall back to library defaults.
+fn line_width(layer: &str, width_px: f64) -> f64 {
+ sane_width(layer, px(width_px))
+}
+
+fn line_width_mm(layer: &str, width: f64) -> f64 {
+ // Circle widths are stored in mm by the converter.
+ sane_width(layer, width)
+}
+
+/// Silkscreen and assembly lines are drawn at the convention widths (KLC F5.1,
+/// F5.2) whatever the source used; other layers keep plausible source widths.
+fn sane_width(layer: &str, width_mm: f64) -> f64 {
+ if layer == SILK_TOP || layer == SILK_BOTTOM {
+ SILK_LINE_MM
+ } else if layer == ASSY_TOP || layer == ASSY_BOTTOM {
+ ASSEMBLY_LINE_MM
+ } else if (0.05..=1.0).contains(&width_mm) {
+ width_mm
+ } else {
+ 0.1
+ }
+}
+
+fn to_mm(x_px: f64, y_px: f64) -> (f64, f64) {
+ // Footprint model: Y down. Allegro: Y up.
+ (px(x_px), -px(y_px))
+}
+
+fn px(value: f64) -> f64 {
+ value * PX_TO_MM
+}
+
+fn distance(a: (f64, f64), b: (f64, f64)) -> f64 {
+ ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
+}
+
+fn bounds(points: &[(f64, f64)]) -> (f64, f64, f64, f64) {
+ let mut out = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
+ for &(x, y) in points {
+ out.0 = out.0.min(x);
+ out.1 = out.1.min(y);
+ out.2 = out.2.max(x);
+ out.3 = out.3.max(y);
+ }
+ out
+}
+
+fn circumcircle(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> Option<((f64, f64), f64)> {
+ let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1));
+ if d.abs() < 1e-12 {
+ return None;
+ }
+ let a2 = a.0 * a.0 + a.1 * a.1;
+ let b2 = b.0 * b.0 + b.1 * b.1;
+ let c2 = c.0 * c.0 + c.1 * c.1;
+ let center = (
+ (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d,
+ (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d,
+ );
+ Some((center, distance(center, a)))
+}
+
+fn arc_polyline(
+ start: (f64, f64),
+ end: (f64, f64),
+ center: (f64, f64),
+ clockwise: bool,
+) -> Vec<(f64, f64)> {
+ let radius = distance(center, start);
+ let a0 = (start.1 - center.1).atan2(start.0 - center.0);
+ let a1 = (end.1 - center.1).atan2(end.0 - center.0);
+ let tau = std::f64::consts::TAU;
+ let sweep = if clockwise {
+ -(a0 - a1).rem_euclid(tau)
+ } else {
+ (a1 - a0).rem_euclid(tau)
+ };
+ let segments = ((sweep.abs() / tau) * 32.0).ceil().max(2.0) as usize;
+ (0..=segments)
+ .map(|index| {
+ let angle = a0 + sweep * index as f64 / segments as f64;
+ (
+ center.0 + radius * angle.cos(),
+ center.1 + radius * angle.sin(),
+ )
+ })
+ .collect()
+}
+
+fn normalize_degrees(angle: f64) -> f64 {
+ let normalized = angle.rem_euclid(360.0);
+ if (normalized - 360.0).abs() < 1e-9 {
+ 0.0
+ } else {
+ normalized
+ }
+}
+
+fn points_list(points: &[(f64, f64)]) -> String {
+ let inner: Vec = points
+ .iter()
+ .map(|(x, y)| format!("list({} {})", f(*x), f(*y)))
+ .collect();
+ format!("list({})", inner.join(" "))
+}
+
+/// SKILL float literal (always with a decimal point, 4 decimals).
+fn f(value: f64) -> String {
+ let value = if value.abs() < 5e-5 { 0.0 } else { value };
+ format!("{value:.4}")
+}
+
+fn skill_escape(text: &str) -> String {
+ text.replace('\\', "\\\\").replace('"', "\\\"")
+}
+
+/// Shared SKILL helpers, embedded in every footprint script so each file is
+/// self-contained. All coordinates passed to them are millimetres, Y up.
+const HELPERS: &str = r#"
+;; ---------------------------------------------------------------- SeEx helpers
+seex_scale = 1.0
+seex_errors = 0
+
+procedure( seex_units_scale()
+ let( (scale units)
+ scale = car(errset(axlMKSConvert(1.0 "MM") nil))
+ unless( numberp(scale)
+ units = car(errset(car(axlDBGetDesignUnits()) nil))
+ scale = cond(
+ ( !stringp(units) 1.0 )
+ ( rexMatchp("^mil" units) 1.0 / 0.0254 )
+ ( rexMatchp("^inch" units) 1.0 / 25.4 )
+ ( rexMatchp("^cent" units) 0.1 )
+ ( rexMatchp("^micro" units) 1000.0 )
+ ( t 1.0 )
+ )
+ )
+ scale
+ )
+)
+
+procedure( seex_u(mm) float(mm) * seex_scale )
+
+procedure( seex_xy(x y) list(seex_u(x) seex_u(y)) )
+
+procedure( seex_pts(points)
+ mapcar(lambda((p) seex_xy(car(p) cadr(p))) points)
+)
+
+procedure( seex_begin(name half)
+ let( (dtype)
+ seex_errors = 0
+ seex_notes = nil
+ ;; axlDesignType(nil) is "SYMBOL" for .dra drawings and "LAYOUT" for boards.
+ dtype = car(errset(axlDesignType(nil) nil))
+ if( stringp(dtype) && upperCase(dtype) != "SYMBOL" then
+ printf("SeEx: the open design is not a symbol drawing. Use File > New > Package symbol first.\n")
+ seex_errors = 1
+ nil
+ else
+ ;; Prefer metric with 4 decimals; harmless if the call is not available.
+ errset(axlDBChangeDesignUnits("millimeters" 4) nil)
+ seex_scale = seex_units_scale()
+ ;; New drawings span (0 0)..(w h); centre the extents on the symbol origin.
+ unless( car(errset(axlDBChangeDesignExtents(
+ list(seex_xy(-half -half) seex_xy(half half))) nil))
+ printf("SeEx: WARNING could not set drawing extents to +/-%g mm\n" half)
+ )
+ printf("SeEx: building package symbol %s (1 mm = %g design units)\n" name seex_scale)
+ t
+ )
+ )
+)
+
+procedure( seex_pad(layer figure w h)
+ make_axlPadStackPad(?layer layer ?type 'REGULAR ?figure figure
+ ?figureSize list(seex_u(w) seex_u(h)))
+)
+
+procedure( seex_padt(layer type figure w h)
+ make_axlPadStackPad(?layer layer ?type type ?figure figure
+ ?figureSize list(seex_u(w) seex_u(h)))
+)
+
+;; Review notes passed back to SeEx (written after the status line).
+seex_notes = nil
+procedure( seex_note(text)
+ seex_notes = cons(text seex_notes)
+ printf("SeEx: %s\n" text)
+)
+
+;; Read a padstack back and check that every required (layer type) pad is there.
+procedure( seex_has_pad(ps layer type)
+ let( (found)
+ found = nil
+ foreach( pad ps->pads
+ when( pad->layer == layer && pad->type == type && pad->figureName != "NULL"
+ found = t
+ )
+ )
+ found
+ )
+)
+
+procedure( seex_check_padstack(name required drilled)
+ let( (ps missing)
+ ps = car(errset(axlLoadPadstack(name) nil))
+ if( !ps then
+ seex_note(sprintf(nil "padstack %s could not be read back for checking" name))
+ else
+ missing = nil
+ foreach( req required
+ unless( seex_has_pad(ps car(req) cadr(req))
+ missing = cons(sprintf(nil "%s %s" car(req) cadr(req)) missing)
+ )
+ )
+ when( drilled && !(numberp(ps->drillDiameter) && ps->drillDiameter > 0)
+ missing = cons("drill" missing)
+ )
+ when( missing
+ seex_note(sprintf(nil "padstack %s is missing %s" name buildString(reverse(missing) ", ")))
+ )
+ )
+ !missing
+ )
+)
+
+procedure( seex_drill(d plated)
+ make_axlPadStackDrill(?drillDiameter seex_u(d)
+ ?plating if(plated 'PLATED 'NON_PLATED)
+ ?figure 'CIRCLE
+ ?figureSize list(seex_u(d) seex_u(d)))
+)
+
+procedure( seex_slot(w h plated)
+ let( (drill)
+ drill = car(errset(make_axlPadStackDrill(?drillDiameter seex_u(min(w h))
+ ?holeType 'OVAL_SLOT
+ ?slotSize list(seex_u(w) seex_u(h))
+ ?plating if(plated 'PLATED 'NON_PLATED)
+ ?figure 'OBLONG
+ ?figureSize list(seex_u(w) seex_u(h))) nil))
+ unless( drill
+ printf("SeEx: WARNING slot %gx%g mm not supported here; using a round drill - edit the padstack\n" w h)
+ seex_errors = seex_errors + 1
+ drill = seex_drill(min(w h) plated)
+ )
+ drill
+ )
+)
+
+procedure( seex_try_padstack(name drill pads)
+ let( (res)
+ ;; 4th argument t skips the hole-vs-pad checks (needed for NPTH mask-only stacks).
+ res = errset(axlDBCreatePadStack(name drill pads t) nil)
+ when( res && car(res)
+ errset(axlPadstackToDisk(name) nil)
+ )
+ res && car(res)
+ )
+)
+
+procedure( seex_padstack(name drill pads fbDrill fbPads)
+ cond(
+ ( seex_try_padstack(name drill pads) t )
+ ( seex_try_padstack(name fbDrill fbPads)
+ seex_note(sprintf(nil "padstack %s created with simplified layers/figure (no internal pad or a round drill); review it" name))
+ t )
+ ( car(errset(axlLoadPadstack(name) nil))
+ seex_note(sprintf(nil "padstack %s could not be created; the existing one on PADPATH is used" name))
+ t )
+ ( t
+ printf("SeEx: ERROR could not create padstack %s\n" name)
+ seex_errors = seex_errors + 1
+ nil )
+ )
+)
+
+procedure( seex_pin(padstack number x y rot)
+ let( (orient ptext res)
+ ptext = nil
+ when( number
+ orient = make_axlTextOrientation(?textBlock "1" ?justify "center" ?rotation 0.0)
+ ptext = make_axlPinText(?offset list(0.0 0.0) ?text orient)
+ ptext->number = number
+ )
+ res = errset(axlDBCreatePin(padstack seex_xy(x y) ptext float(rot)) t)
+ unless( res && car(res)
+ printf("SeEx: ERROR could not place pin %L with padstack %s\n" number padstack)
+ seex_errors = seex_errors + 1
+ )
+ )
+)
+
+procedure( seex_line(layer width points)
+ errset(axlDBCreateLine(seex_pts(points) seex_u(width) layer) t)
+)
+
+procedure( seex_circle(layer width cx cy r filled)
+ if( filled then
+ ;; A ring as wide as the radius renders as a solid disc.
+ errset(axlDBCreateCircle(list(seex_xy(cx cy) seex_u(r / 2.0)) seex_u(r) layer) t)
+ else
+ errset(axlDBCreateCircle(list(seex_xy(cx cy) seex_u(r)) seex_u(width) layer) t)
+ )
+)
+
+procedure( seex_arc(layer width sx sy ex ey cx cy clockwise fallback)
+ let( (path res)
+ res = errset(
+ progn(
+ path = axlPathStart(list(seex_xy(sx sy)) seex_u(width))
+ path = axlPathArcCenter(path seex_u(width) seex_xy(ex ey) clockwise seex_xy(cx cy))
+ axlDBCreatePath(path layer)
+ ) nil)
+ unless( res && car(res)
+ seex_line(layer width fallback)
+ )
+ )
+)
+
+procedure( seex_polygon_path(points)
+ let( (path)
+ path = axlPathStart(list(seex_xy(car(car(points)) cadr(car(points)))) 0.0)
+ foreach( p cdr(points)
+ path = axlPathLine(path 0.0 seex_xy(car(p) cadr(p)))
+ )
+ axlPathLine(path 0.0 seex_xy(car(car(points)) cadr(car(points))))
+ )
+)
+
+procedure( seex_shape(layer points)
+ let( (res)
+ res = errset(axlDBCreateShape(seex_polygon_path(points) t layer) nil)
+ unless( res && car(res)
+ printf("SeEx: could not create a filled shape on %s; drawing its outline\n" layer)
+ seex_line(layer 0.0 append(points list(car(points))))
+ )
+ )
+)
+
+procedure( seex_place_bound(layer x1 y1 x2 y2)
+ seex_shape(layer list(list(x1 y1) list(x2 y1) list(x2 y2) list(x1 y2)))
+)
+
+procedure( seex_text(layer label x y block rot)
+ let( (orient)
+ orient = make_axlTextOrientation(?textBlock sprintf(nil "%d" block) ?justify "center"
+ ?rotation float(rot))
+ errset(axlDBCreateText(label seex_xy(x y) orient layer) t)
+ )
+)
+
+;; Size a text block of this drawing (mm): character width and height and the
+;; stroke ("photo") width. Boards use their own text block table.
+procedure( seex_text_block(block w h stroke)
+ let( (param)
+ param = car(errset(axlGetParam(sprintf(nil "paramTextBlock:%d" block)) nil))
+ if( param then
+ errset(param->width = seex_u(w) nil)
+ errset(param->height = seex_u(h) nil)
+ errset(param->photoWidth = seex_u(stroke) nil)
+ errset(param->charSpace = seex_u(h * 0.2) nil)
+ errset(param->lineSpace = seex_u(h * 1.6) nil)
+ unless( car(errset(axlSetParam(param) nil))
+ printf("SeEx: could not size text block %d\n" block)
+ )
+ else
+ printf("SeEx: text block %d not found\n" block)
+ )
+ )
+)
+
+;; Map a STEP model (found through STEPPATH) as the symbol's primary 3D model,
+;; rotated (degrees) and then shifted (design units). The model's folder is put
+;; in front of STEPPATH for this session so the mapping finds it.
+procedure( seex_step(file dir rx ry rz ox oy oz)
+ let( (path ok)
+ path = car(errset(axlGetVariable("steppath") nil))
+ when( listp(path)
+ path = buildString(path " ")
+ )
+ unless( stringp(path) && member(dir parseString(path " "))
+ errset(axlSetVariable("steppath" if(stringp(path) && path != ""
+ strcat(dir " " path) dir)) t)
+ )
+ ok = car(errset(axlStepSet('primary nil file
+ list('rotation_x rx 'rotation_y ry 'rotation_z rz
+ 'offset_x ox 'offset_y oy 'offset_z oz)) t))
+ unless( ok
+ printf("SeEx: could not map STEP model %s\n" file)
+ )
+ ok
+ )
+)
+
+procedure( seex_end(name)
+ errset(axlVisibleUpdate(t) nil)
+ if( seex_errors == 0 then
+ if( boundp('seex_library_build) && seex_library_build then
+ printf("SeEx: %s drawn\n" name)
+ else
+ printf("SeEx: %s done. File > Save to write %s.dra, File > Create Symbol for %s.psm.\n" name name name)
+ )
+ else
+ printf("SeEx: %s finished with %d problem(s); see messages above before saving.\n" name seex_errors)
+ )
+ t
+)
+
+;; Save the drawing and compile the .psm; "OK" or the reason it failed.
+procedure( seex_finish(name)
+ let( (saved compiled)
+ saved = car(errset(axlSaveDesign(?design name ?noMru t ?noConfirm t) t))
+ compiled = car(errset(axlCompileSymbol(?symbol name ?silent t ?silentOnOverwrite t) t))
+ cond(
+ ( !saved "save failed" )
+ ( !compiled "create symbol failed" )
+ ( seex_errors > 0 sprintf(nil "%d problem(s) while building" seex_errors) )
+ ( t "OK" )
+ )
+ )
+)
+;; ------------------------------------------------------------ end of helpers
+"#;
+
+/// The library script: loaded in Allegro PCB Editor, it builds every footprint
+/// script in `dir` into `.dra`, `.psm` and `.pad` files in the same folder.
+///
+/// Each footprint gets a new package symbol drawing (`axlOpenDesign` creates one
+/// for a `.dra` name that does not exist yet; an older build is replaced). The
+/// folder is the one the export wrote to; when the library has been moved, the
+/// current working directory is used instead.
+pub fn library_script(library: &str, dir: &std::path::Path, footprints: &[String]) -> String {
+ let dir = skill_string(&dir.to_string_lossy().replace('\\', "/"));
+ let names: Vec = footprints
+ .iter()
+ .map(|name| format!("\"{}\"", skill_string(name)))
+ .collect();
+ let mut script = String::new();
+ let _ = writeln!(
+ script,
+ ";; {library}.il - builds the Allegro footprints of the SeEx library \"{library}\"\n\
+ ;;\n\
+ ;; In Allegro PCB Editor (with any design open), type in the command window:\n\
+ ;; skill load(\"{dir}/{library}.il\")\n\
+ ;; Every footprint script in this folder becomes .dra, .psm and its\n\
+ ;; .pad padstacks here. Then add this folder to PSMPATH and PADPATH, and the\n\
+ ;; 3D model folder to STEPPATH (Setup > User Preferences > Paths > Library).\n\
+ ;; Save your open design first: Allegro opens a new drawing for each footprint.\n"
+ );
+ let _ = writeln!(
+ script,
+ "procedure( seex_build_library(dir names)
+ let( (home done failed status design)
+ unless( isDir(dir)
+ dir = getWorkingDir()
+ )
+ home = getWorkingDir()
+ changeWorkingDir(dir)
+ seex_library_build = t
+ done = 0
+ failed = nil
+ foreach( name names
+ design = strcat(dir \"/\" name \".dra\")
+ when( isFile(design)
+ deleteFile(design)
+ )
+ status = cond(
+ ( !isFile(strcat(dir \"/\" name \".il\")) \"script missing\" )
+ ( !car(errset(axlOpenDesign(?design design ?mode \"wf\") t)) \"could not open a new drawing\" )
+ ( !car(errset(load(strcat(dir \"/\" name \".il\")) t)) \"script failed\" )
+ ( t seex_finish(name) )
+ )
+ if( status == \"OK\" then
+ done = done + 1
+ else
+ failed = cons(sprintf(nil \"%s (%s)\" name status) failed)
+ )
+ )
+ seex_library_build = nil
+ changeWorkingDir(home)
+ printf(\"SeEx: built %d of %d footprints in %s\\n\" done length(names) dir)
+ foreach( entry reverse(failed)
+ printf(\"SeEx: FAILED %s\\n\" entry)
+ )
+ done
+ )
+)
+"
+ );
+ let _ = writeln!(
+ script,
+ "seex_build_library(\"{dir}\" list({}))",
+ names.join(" ")
+ );
+ script
+}
+
+/// Text for a SKILL string literal.
+fn skill_string(text: &str) -> String {
+ text.replace('\\', "\\\\").replace('"', "\\\"")
+}
diff --git a/src/cadence/capture.rs b/src/cadence/capture.rs
new file mode 100644
index 0000000..b9c23b4
--- /dev/null
+++ b/src/cadence/capture.rs
@@ -0,0 +1,1456 @@
+//! OrCAD Capture symbol export.
+//!
+//! Capture's native `.olb` format is an undocumented binary container, but Capture
+//! (16.6 and later, including OrCAD X) turns a "Library XML" file into an `.olb`
+//! (`File > Import > Library XML`). This module writes that XML.
+//!
+//! The element layout, attribute names and enumerations below mirror files exported
+//! by Capture 17.2 itself (`File > Export > Library XML`):
+//!
+//! * coordinates are integer Capture units of 0.01 inch with Y pointing down — one
+//! EasyEDA pixel (10 mil) is exactly one Capture unit;
+//! * pins are `SymbolPinScalar` elements whose `hotptX/Y` is the wire connection point
+//! and `startX/Y` the end touching the body; pin numbers live in `PhysicalPart`;
+//! * pin types: 0 input, 1 bidirectional, 2 output, 3 open collector, 4 passive,
+//! 5 tri-state, 6 open emitter, 7 power;
+//! * fill styles: 0 solid, 1 none;
+//! * a symbol with EasyEDA sub-parts becomes a heterogeneous package
+//! (`isHomogeneous="0"`) with one `LibPart` per section, named after the package
+//! plus the section letter (`RK3576_C42388007A`, ...B), as Capture stores
+//! MAX628 in its own `MiscPower.olb`.
+//!
+//! Emitted elements: `Rect`, `Line`, `Polygon`, `Ellipse`, `Arc` and `CommentText` (all
+//! in Capture's `olb.xsd`); arcs smaller than a Capture unit become line segments.
+
+use std::cell::Cell;
+use std::collections::HashMap;
+use std::fmt::Write as _;
+
+use crate::kicad::symbol::{
+ KiArc, KiCircle, KiPin, KiPolyline, KiRectangle, KiSymbol, KiSymbolUnit, KiText, PinStyle,
+ PinType,
+};
+
+/// Capture's standard pin grid (0.1 inch).
+pub const CAPTURE_GRID: i64 = 10;
+/// Default Capture colour index used by its own exports for symbol graphics.
+const SYMBOL_COLOR: &str = "48";
+
+/// One Capture part, ready to be written into a Library XML file.
+#[derive(Debug, Clone)]
+pub struct CapturePart {
+ /// Package / cell name inside the `.olb`.
+ pub name: String,
+ /// `` XML fragment for this part.
+ pub xml: String,
+ /// Non-fatal conversion notes (off-grid pins, renamed duplicate pins, ...).
+ pub warnings: Vec,
+}
+
+/// Build the Capture part for `symbol`, linked to the Allegro footprint `footprint`.
+/// Whether the part shows pin names / pin numbers (Capture's part-level switches).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PinLabels {
+ pub names: bool,
+ pub numbers: bool,
+}
+
+impl Default for PinLabels {
+ fn default() -> Self {
+ Self {
+ names: true,
+ numbers: true,
+ }
+ }
+}
+
+pub fn build_capture_part_with(
+ symbol: &KiSymbol,
+ part_name: &str,
+ footprint: &str,
+ labels: PinLabels,
+) -> CapturePart {
+ let mut warnings = Vec::new();
+ let sections = sections(symbol);
+ // One section is an ordinary part; EasyEDA sub-parts become the sections of a
+ // heterogeneous Capture package (U1A, U1B, ...), each drawn on its own, the
+ // way Capture's own libraries store them: one `LibPart` per section, named
+ // after the package plus the section letter, each with its own pin numbers.
+ let heterogeneous = sections.len() > 1;
+ let mut jogs = 0;
+ let mut lib_parts = String::new();
+ for (index, mut geometry) in sections.into_iter().enumerate() {
+ jogs += align_to_grid(&mut geometry);
+ let cell = if heterogeneous {
+ format!("{part_name}{}", section_letter(index))
+ } else {
+ part_name.to_string()
+ };
+ lib_parts.push_str(&lib_part_xml(
+ symbol,
+ &geometry,
+ &cell,
+ part_name,
+ labels,
+ &mut warnings,
+ ));
+ }
+ if jogs > 0 {
+ warnings.push(format!(
+ "{jogs} pin(s) sat between grid lines; moved onto the 0.1\" grid with a short jog to the body"
+ ));
+ }
+
+ let mut xml = String::new();
+ let _ = writeln!(xml, " ");
+ let _ = writeln!(
+ xml,
+ " ",
+ if heterogeneous { 0 } else { 1 },
+ xml_escape(part_name),
+ xml_escape(footprint),
+ xml_escape(&refdes_prefix(&symbol.reference))
+ );
+ xml.push_str(&lib_parts);
+ let _ = writeln!(xml, " ");
+
+ CapturePart {
+ name: part_name.to_string(),
+ xml,
+ warnings,
+ }
+}
+
+/// Section letters as Capture numbers them: A..Z, then AA, AB, ...
+fn section_letter(index: usize) -> String {
+ let mut index = index;
+ let mut letters = Vec::new();
+ loop {
+ letters.push(b'A' + (index % 26) as u8);
+ if index < 26 {
+ break;
+ }
+ index = index / 26 - 1;
+ }
+ letters.reverse();
+ String::from_utf8(letters).unwrap_or_default()
+}
+
+/// One ``: the drawing and pin numbers of one section.
+fn lib_part_xml(
+ symbol: &KiSymbol,
+ geometry: &KiSymbolUnit,
+ cell: &str,
+ part_name: &str,
+ labels: PinLabels,
+ warnings: &mut Vec,
+) -> String {
+ // Capture's part editor takes the body outline's top-left corner as the part
+ // origin (every part Cadence ships has its outline at 0,0): with the origin
+ // anywhere else, a pin moved in the editor is redrawn from the origin. Lay the
+ // part out once to find the outline, then again shifted onto the origin.
+ let mut layout = CaptureLayout::new(geometry);
+ let mut scratch = Vec::new();
+ let (_, _, outline) = draw_unit(geometry, &layout, &mut scratch);
+ layout.dx -= outline.x1 as f64;
+ layout.dy -= outline.y1 as f64;
+ let (graphics, pins, bbox) = draw_unit(geometry, &layout, warnings);
+
+ let mut pin_xml = String::new();
+ let mut physical = String::new();
+ for (position, pin) in pins.iter().enumerate() {
+ write_pin(&mut pin_xml, pin, position);
+ let _ = writeln!(
+ physical,
+ " \n \n ",
+ xml_escape(&pin.number),
+ position
+ );
+ }
+
+ let reference = refdes_prefix(&symbol.reference);
+ let value = if symbol.value.trim().is_empty() {
+ part_name.to_string()
+ } else {
+ symbol.value.clone()
+ };
+
+ let mut xml = String::new();
+ let _ = writeln!(xml, " ");
+ let _ = writeln!(xml, " ", xml_escape(cell));
+ let _ = writeln!(xml, " ");
+ let _ = writeln!(xml, " ");
+ // Labels go outside the pins so top/bottom pin names stay readable.
+ let mut extent = bbox;
+ for pin in &pins {
+ extent.add(pin.hot.0, pin.hot.1);
+ }
+ write_display_prop(
+ &mut xml,
+ "Part Reference",
+ bbox.x1,
+ extent.y1 - CAPTURE_GRID,
+ );
+ write_display_prop(&mut xml, "Value", bbox.x1, extent.y2 + CAPTURE_GRID);
+ for (name, value) in user_properties(symbol) {
+ let _ = writeln!(
+ xml,
+ " \n \n ",
+ xml_escape(name),
+ xml_escape(&value)
+ );
+ }
+ let _ = writeln!(
+ xml,
+ " \n \n "
+ );
+ let _ = writeln!(
+ xml,
+ " \n \n ",
+ bbox.x1, bbox.x2, bbox.y1, bbox.y2
+ );
+ write_flag(&mut xml, 8, "IsPinNumbersVisible", labels.numbers);
+ write_flag(&mut xml, 8, "IsPinNamesRotated", true);
+ write_flag(&mut xml, 8, "IsPinNamesVisible", labels.names);
+ let _ = writeln!(
+ xml,
+ " \n \n "
+ );
+ let _ = writeln!(
+ xml,
+ " \n \n "
+ );
+ let _ = writeln!(
+ xml,
+ " \n \n "
+ );
+ let _ = writeln!(
+ xml,
+ " \n \n ",
+ xml_escape(&value)
+ );
+ let _ = writeln!(
+ xml,
+ " \n \n ",
+ xml_escape(&reference)
+ );
+ xml.push_str(&graphics);
+ xml.push_str(&pin_xml);
+ let _ = writeln!(xml, " ");
+ let _ = writeln!(xml, " \n ");
+ xml.push_str(&physical);
+ let _ = writeln!(xml, " ");
+ let _ = writeln!(xml, " ");
+ xml
+}
+
+/// Wrap `` fragments into a complete Capture Library XML document.
+pub fn render_library_xml(olb_name: &str, packages: &[String]) -> String {
+ let mut xml = String::new();
+ xml.push_str("\n");
+ xml.push_str("\n\n");
+ let _ = writeln!(xml, " \n", xml_escape(olb_name));
+ xml.push_str(&default_values_xml());
+ for package in packages {
+ xml.push('\n');
+ xml.push_str(package);
+ }
+ xml.push_str("\n\n");
+ xml
+}
+
+/// Every `` of a Capture Library XML document SeEx wrote, as (part name,
+/// the block as it stands in the file), in document order. An append keeps these
+/// and writes them back with the new parts.
+pub fn package_blocks(xml: &str) -> Vec<(String, String)> {
+ const OPEN: &str = "";
+ const CLOSE: &str = "";
+ let mut blocks = Vec::new();
+ let mut rest = xml;
+ while let Some(start) = rest.find(OPEN) {
+ let Some(len) = rest[start..].find(CLOSE) else {
+ break;
+ };
+ let block = &rest[start..start + len + CLOSE.len()];
+ rest = &rest[start + len + CLOSE.len()..];
+ let name = block
+ .find(" name=\"")
+ .map(|at| &block[at + 7..])
+ .and_then(|value| value.find('"').map(|end| &value[..end]));
+ if let Some(name) = name {
+ blocks.push((xml_unescape(name), format!(" {block}\n")));
+ }
+ }
+ blocks
+}
+
+fn xml_unescape(text: &str) -> String {
+ text.replace(""", "\"")
+ .replace("'", "'")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("&", "&")
+}
+
+/// Capture-safe ASCII identifier: letters, digits, `_` and `-` only.
+pub fn capture_identifier(name: &str) -> String {
+ let mut out = String::with_capacity(name.len());
+ let mut last_underscore = false;
+ for ch in name.chars() {
+ let mapped = if ch.is_ascii_alphanumeric() || ch == '-' {
+ ch
+ } else {
+ '_'
+ };
+ if mapped == '_' {
+ if last_underscore {
+ continue;
+ }
+ last_underscore = true;
+ } else {
+ last_underscore = false;
+ }
+ out.push(mapped);
+ }
+ let trimmed = out.trim_matches('_').to_string();
+ if trimmed.is_empty() {
+ "PART".to_string()
+ } else {
+ trimmed
+ }
+}
+
+fn refdes_prefix(reference: &str) -> String {
+ let prefix: String = reference
+ .trim()
+ .trim_end_matches('?')
+ .chars()
+ .filter(|ch| ch.is_ascii_alphabetic())
+ .collect();
+ if prefix.is_empty() {
+ "U".to_string()
+ } else {
+ prefix
+ }
+}
+
+fn user_properties(symbol: &KiSymbol) -> Vec<(&'static str, String)> {
+ let mut props = Vec::new();
+ let mut push = |name: &'static str, value: &str| {
+ let value = value.trim();
+ if !value.is_empty() {
+ props.push((name, value.to_string()));
+ }
+ };
+ push("Manufacturer", &symbol.manufacturer);
+ push("Description", &symbol.description);
+ push("Datasheet", &symbol.datasheet);
+ push("LCSC Part", &symbol.lcsc_id);
+ push("JLC Part", &symbol.jlc_id);
+ props
+}
+
+/// The sections of the Capture part, each in its own coordinate space (EasyEDA
+/// px, Y up): the whole symbol, or one per EasyEDA sub-part. Graphics and pins
+/// the symbol has outside its sub-parts belong to every section, as KiCad's
+/// unit 0 does.
+fn sections(symbol: &KiSymbol) -> Vec {
+ let common = KiSymbolUnit {
+ pins: symbol.pins.clone(),
+ rectangles: symbol.rectangles.clone(),
+ circles: symbol.circles.clone(),
+ arcs: symbol.arcs.clone(),
+ polylines: symbol.polylines.clone(),
+ texts: symbol.texts.clone(),
+ };
+ let units: Vec<&KiSymbolUnit> = symbol
+ .units
+ .iter()
+ .filter(|unit| unit_has_content(unit))
+ .collect();
+ if units.is_empty() {
+ return vec![common];
+ }
+ units
+ .into_iter()
+ .map(|unit| {
+ let mut section = unit.clone();
+ append_translated(&mut section, &common, 0.0);
+ section
+ })
+ .collect()
+}
+
+/// Direction from a pin's connection point towards the body (EasyEDA px, Y up).
+fn pin_direction(pin: &KiPin) -> (f64, f64) {
+ let angle = (((180 + pin.rotation) % 360 + 360) % 360) as f64;
+ let (sin, cos) = angle.to_radians().sin_cos();
+ (cos.round(), sin.round())
+}
+
+/// True when `point` lies on the outline of the box `b` (x0, y0, x1, y1).
+fn on_box_edge(point: (f64, f64), b: [f64; 4]) -> bool {
+ const TOL: f64 = 0.5;
+ let (x, y) = point;
+ let within_x = x >= b[0] - TOL && x <= b[2] + TOL;
+ let within_y = y >= b[1] - TOL && y <= b[3] + TOL;
+ ((x - b[0]).abs() < TOL || (x - b[2]).abs() < TOL) && within_y
+ || ((y - b[1]).abs() < TOL || (y - b[3]).abs() < TOL) && within_x
+}
+
+/// The sub-grid offset most pins share, as (x, y) in (-grid/2, grid/2].
+fn common_offset(geometry: &KiSymbolUnit, grid: f64) -> (f64, f64) {
+ let offset = |v: f64| {
+ let r = v - (v / grid).floor() * grid; // [0, grid)
+ let r = if r > grid / 2.0 + 1e-6 { r - grid } else { r };
+ (r * 100.0).round() / 100.0
+ };
+ let mut counts: HashMap<(i64, i64), usize> = HashMap::new();
+ for pin in &geometry.pins {
+ let key = (
+ (offset(pin.pos_x) * 100.0) as i64,
+ (offset(pin.pos_y) * 100.0) as i64,
+ );
+ *counts.entry(key).or_default() += 1;
+ }
+ let best = counts
+ .into_iter()
+ .max_by_key(|&((x, y), n)| (n, -(x.abs() + y.abs())));
+ best.map(|((x, y), _)| (x as f64 / 100.0, y as f64 / 100.0))
+ .unwrap_or((0.0, 0.0))
+}
+
+fn translate_unit(unit: &mut KiSymbolUnit, dx: f64, dy: f64) {
+ for pin in &mut unit.pins {
+ pin.pos_x += dx;
+ pin.pos_y += dy;
+ }
+ for rect in &mut unit.rectangles {
+ rect.x1 += dx;
+ rect.x2 += dx;
+ rect.y1 += dy;
+ rect.y2 += dy;
+ }
+ for circle in &mut unit.circles {
+ circle.cx += dx;
+ circle.cy += dy;
+ }
+ for arc in &mut unit.arcs {
+ arc.start_x += dx;
+ arc.mid_x += dx;
+ arc.end_x += dx;
+ arc.start_y += dy;
+ arc.mid_y += dy;
+ arc.end_y += dy;
+ }
+ for polyline in &mut unit.polylines {
+ for point in &mut polyline.points {
+ point.0 += dx;
+ point.1 += dy;
+ }
+ }
+ for text in &mut unit.texts {
+ text.x += dx;
+ text.y += dy;
+ }
+}
+
+/// Puts every pin's connection point on Capture's 0.1" grid, where wires snap.
+///
+/// The edges of body rectangles that pins attach to move outward to the grid (other
+/// edges keep their drawn position) and the pins slide along the edge to the nearest
+/// grid point, keeping a whole-grid length. A pin
+/// drawn without a body rectangle is lengthened to the next grid line; when it sits
+/// between grid lines it moves onto one and a short jog joins it to the drawing.
+/// Returns the number of jogs added.
+fn align_to_grid(geometry: &mut KiSymbolUnit) -> usize {
+ let grid = CAPTURE_GRID as f64;
+ // Most EasyEDA symbols that are off the grid are off as a whole (drawn on a
+ // 0.05" grid): move the whole drawing so the pins land on the grid, keeping
+ // every pin where it meets the body and every line where it was.
+ let (shift_x, shift_y) = common_offset(geometry, grid);
+ if shift_x != 0.0 || shift_y != 0.0 {
+ translate_unit(geometry, -shift_x, -shift_y);
+ }
+ let down = |v: f64| ((v / grid) + 1e-6).floor() * grid;
+ let up = |v: f64| ((v / grid) - 1e-6).ceil() * grid;
+ let starts: Vec<(f64, f64)> = geometry
+ .pins
+ .iter()
+ .map(|pin| {
+ let (dx, dy) = pin_direction(pin);
+ let len = pin.length.abs();
+ (pin.pos_x + len * dx, pin.pos_y + len * dy)
+ })
+ .collect();
+
+ // Bodies: rectangles with pins on their edges, before and after snapping.
+ // Rectangle index, drawn box, snapped box, and which edges carry pins (x1, y1,
+ // x2, y2 order).
+ let mut bodies: Vec<(usize, [f64; 4], [f64; 4], [bool; 4])> = Vec::new();
+ for (index, rect) in geometry.rectangles.iter_mut().enumerate() {
+ let old = [
+ rect.x1.min(rect.x2),
+ rect.y1.min(rect.y2),
+ rect.x1.max(rect.x2),
+ rect.y1.max(rect.y2),
+ ];
+ if !starts.iter().any(|&s| on_box_edge(s, old)) {
+ continue;
+ }
+ // Only edges that carry pins move to the grid; the others keep the drawn
+ // shape (a varistor's flat box stays flat), stretched just enough to
+ // reach every pin row after the pins slide onto the grid.
+ const TOL: f64 = 0.5;
+ let mut new = old;
+ let mut pinned = [false; 4];
+ let (mut row_lo, mut row_hi) = (f64::MAX, f64::MIN);
+ let (mut column_lo, mut column_hi) = (f64::MAX, f64::MIN);
+ for (pin, &start) in geometry.pins.iter().zip(&starts) {
+ if !on_box_edge(start, old) {
+ continue;
+ }
+ let (dx, dy) = pin_direction(pin);
+ if dx != 0.0 && dy == 0.0 {
+ if (start.0 - old[0]).abs() < TOL {
+ new[0] = down(old[0]);
+ pinned[0] = true;
+ } else if (start.0 - old[2]).abs() < TOL {
+ new[2] = up(old[2]);
+ pinned[2] = true;
+ }
+ let row = snap(start.1, grid);
+ row_lo = row_lo.min(row);
+ row_hi = row_hi.max(row);
+ } else if dy != 0.0 && dx == 0.0 {
+ if (start.1 - old[1]).abs() < TOL {
+ new[1] = down(old[1]);
+ pinned[1] = true;
+ } else if (start.1 - old[3]).abs() < TOL {
+ new[3] = up(old[3]);
+ pinned[3] = true;
+ }
+ let column = snap(start.0, grid);
+ column_lo = column_lo.min(column);
+ column_hi = column_hi.max(column);
+ }
+ }
+ new[0] = new[0].min(column_lo);
+ new[2] = new[2].max(column_hi);
+ new[1] = new[1].min(row_lo);
+ new[3] = new[3].max(row_hi);
+ rect.x1 = new[0];
+ rect.y1 = new[1];
+ rect.x2 = new[2];
+ rect.y2 = new[3];
+ bodies.push((index, old, new, pinned));
+ }
+
+ let mut used: std::collections::HashSet<(i64, i64)> = std::collections::HashSet::new();
+ let mut stretched: Vec<(usize, (f64, f64))> = Vec::new();
+ let mut jogs = Vec::new();
+ for (pin, &start) in geometry.pins.iter_mut().zip(&starts) {
+ let (dx, dy) = pin_direction(pin);
+ if (dx == 0.0) == (dy == 0.0) {
+ continue;
+ }
+ let horizontal = dx != 0.0;
+ let body = bodies
+ .iter()
+ .find(|(_, old, _, _)| on_box_edge(start, *old));
+ let (mut hot, mut new_start) = match body {
+ Some((_, _, b, _)) => {
+ let s = if horizontal {
+ (if dx > 0.0 { b[0] } else { b[2] }, snap(start.1, grid))
+ } else {
+ (snap(start.0, grid), if dy > 0.0 { b[1] } else { b[3] })
+ };
+ let len = (pin.length.abs() / grid).round().max(1.0) * grid;
+ ((s.0 - dx * len, s.1 - dy * len), s)
+ }
+ None => {
+ let (hx, hy) = (pin.pos_x, pin.pos_y);
+ // Outward is against the direction towards the body.
+ let hot = if horizontal {
+ (if dx > 0.0 { down(hx) } else { up(hx) }, snap(hy, grid))
+ } else {
+ (snap(hx, grid), if dy > 0.0 { down(hy) } else { up(hy) })
+ };
+ let s = if horizontal {
+ (start.0, hot.1)
+ } else {
+ (hot.0, start.1)
+ };
+ let mut hot = hot;
+ // Keep at least half a grid of visible pin.
+ if ((hot.0 - s.0).abs() + (hot.1 - s.1).abs()) < grid / 2.0 {
+ hot = (hot.0 - dx * grid, hot.1 - dy * grid);
+ }
+ (hot, s)
+ }
+ };
+ let key = |p: (f64, f64)| (p.0.round() as i64, p.1.round() as i64);
+ let along = |p: (f64, f64), d: f64| {
+ if horizontal {
+ (p.0, p.1 + d)
+ } else {
+ (p.0 + d, p.1)
+ }
+ };
+ let mut grow_body = false;
+ if used.contains(&key(hot)) {
+ match body {
+ // Pins EasyEDA stacked on one spot (shield, GND) spread along their
+ // edge: inside the edge first, then past an end whose side edge has
+ // no pins (the body grows there). With no room they stay stacked.
+ Some((_, _, b, pinned)) => {
+ let (lo, hi) = if horizontal {
+ (b[1], b[3])
+ } else {
+ (b[0], b[2])
+ };
+ let (lo_free, hi_free) = if horizontal {
+ (!pinned[1], !pinned[3])
+ } else {
+ (!pinned[0], !pinned[2])
+ };
+ let position = |p: (f64, f64)| if horizontal { p.1 } else { p.0 };
+ let mut chosen = None;
+ 'search: for pass in 0..2 {
+ for step in 1..=16 {
+ for sign in [1.0, -1.0] {
+ let d = sign * step as f64 * grid;
+ let (h, s) = (along(hot, d), along(new_start, d));
+ let v = position(s);
+ let fits = if pass == 0 {
+ v > lo + 1e-6 && v < hi - 1e-6
+ } else {
+ (v >= lo - 1e-6 && v <= hi + 1e-6)
+ || (v > hi && hi_free)
+ || (v < lo && lo_free)
+ };
+ if fits && !used.contains(&key(h)) {
+ chosen = Some((h, s, v < lo || v > hi));
+ break 'search;
+ }
+ }
+ }
+ }
+ if let Some((h, s, outside)) = chosen {
+ hot = h;
+ new_start = s;
+ grow_body = outside;
+ }
+ }
+ None => {
+ let mut tries = 0;
+ while used.contains(&key(hot)) && tries < 8 {
+ hot = along(hot, grid);
+ new_start = along(new_start, grid);
+ tries += 1;
+ }
+ }
+ }
+ }
+ used.insert(key(hot));
+ if body.is_none()
+ && ((new_start.0 - start.0).abs() > 0.01 || (new_start.1 - start.1).abs() > 0.01)
+ {
+ jogs.push((start, new_start));
+ }
+ pin.pos_x = hot.0;
+ pin.pos_y = hot.1;
+ pin.length = ((hot.0 - new_start.0).abs() + (hot.1 - new_start.1).abs()).max(1.0);
+ if let (Some((index, _, _, _)), true) = (body, grow_body) {
+ stretched.push((*index, new_start));
+ }
+ }
+ for (index, point) in stretched {
+ let rect = &mut geometry.rectangles[index];
+ let (x1, x2) = (rect.x1.min(rect.x2), rect.x1.max(rect.x2));
+ let (y1, y2) = (rect.y1.min(rect.y2), rect.y1.max(rect.y2));
+ let grow = |lo: f64, hi: f64, v: f64| (lo.min(v), hi.max(v));
+ let ((x1, x2), (y1, y2)) = (grow(x1, x2, point.0), grow(y1, y2, point.1));
+ rect.x1 = x1;
+ rect.x2 = x2;
+ rect.y1 = y1;
+ rect.y2 = y2;
+ }
+ let count = jogs.len();
+ for (a, b) in jogs {
+ geometry.polylines.push(KiPolyline {
+ points: vec![a, b],
+ stroke_width: 0.0,
+ fill: false,
+ });
+ }
+ count
+}
+
+fn unit_has_content(unit: &KiSymbolUnit) -> bool {
+ !unit.pins.is_empty()
+ || !unit.rectangles.is_empty()
+ || !unit.circles.is_empty()
+ || !unit.arcs.is_empty()
+ || !unit.polylines.is_empty()
+ || !unit.texts.is_empty()
+}
+
+fn unit_bounds(unit: &KiSymbolUnit) -> Option<(f64, f64, f64, f64)> {
+ let mut bounds = Bounds::default();
+ for pin in &unit.pins {
+ bounds.add(pin.pos_x, pin.pos_y);
+ }
+ for rect in &unit.rectangles {
+ bounds.add(rect.x1, rect.y1);
+ bounds.add(rect.x2, rect.y2);
+ }
+ for circle in &unit.circles {
+ bounds.add(circle.cx - circle.radius, circle.cy - circle.radius);
+ bounds.add(circle.cx + circle.radius, circle.cy + circle.radius);
+ }
+ for arc in &unit.arcs {
+ bounds.add(arc.start_x, arc.start_y);
+ bounds.add(arc.mid_x, arc.mid_y);
+ bounds.add(arc.end_x, arc.end_y);
+ }
+ for polyline in &unit.polylines {
+ for &(x, y) in &polyline.points {
+ bounds.add(x, y);
+ }
+ }
+ bounds.get()
+}
+
+fn append_translated(target: &mut KiSymbolUnit, source: &KiSymbolUnit, dx: f64) {
+ for pin in &source.pins {
+ let mut pin = pin.clone();
+ pin.pos_x += dx;
+ target.pins.push(pin);
+ }
+ for rect in &source.rectangles {
+ let mut rect = rect.clone();
+ rect.x1 += dx;
+ rect.x2 += dx;
+ target.rectangles.push(rect);
+ }
+ for circle in &source.circles {
+ let mut circle = circle.clone();
+ circle.cx += dx;
+ target.circles.push(circle);
+ }
+ for arc in &source.arcs {
+ let mut arc = arc.clone();
+ arc.start_x += dx;
+ arc.mid_x += dx;
+ arc.end_x += dx;
+ target.arcs.push(arc);
+ }
+ for polyline in &source.polylines {
+ let mut polyline = polyline.clone();
+ for point in &mut polyline.points {
+ point.0 += dx;
+ }
+ target.polylines.push(polyline);
+ }
+ for text in &source.texts {
+ let mut text = text.clone();
+ text.x += dx;
+ target.texts.push(text);
+ }
+}
+
+/// Graphics XML, pins and body outline of one laid-out unit.
+fn draw_unit(
+ geometry: &KiSymbolUnit,
+ layout: &CaptureLayout,
+ warnings: &mut Vec,
+) -> (String, Vec, IntBox) {
+ layout.graphics.set(IntBox::empty());
+ let mut graphics = String::new();
+ for rect in &geometry.rectangles {
+ write_rect(&mut graphics, layout, rect);
+ }
+ for circle in &geometry.circles {
+ write_circle(&mut graphics, layout, circle);
+ }
+ for arc in &geometry.arcs {
+ write_arc(&mut graphics, layout, arc);
+ }
+ for polyline in &geometry.polylines {
+ write_polyline(&mut graphics, layout, polyline);
+ }
+ for text in &geometry.texts {
+ write_comment_text(&mut graphics, layout, text);
+ }
+ let mut pins = capture_pins(&geometry.pins, layout, warnings);
+ let bbox = layout.body_bbox(&mut pins, &mut graphics);
+ (graphics, pins, bbox)
+}
+
+/// Maps EasyEDA px (Y up) to integer Capture units (Y down).
+///
+/// The translation puts the first pin's connection point exactly on the 0.1" grid
+/// and the body near the origin, like symbols drawn in Capture itself.
+struct CaptureLayout {
+ dx: f64,
+ dy: f64,
+ /// Bounding box of everything drawn so far (excluding pins).
+ graphics: Cell,
+}
+
+impl CaptureLayout {
+ fn new(geometry: &KiSymbolUnit) -> Self {
+ let grid = CAPTURE_GRID as f64;
+ let (min_x, min_y) = unit_bounds(geometry)
+ .map(|(min_x, _, _, max_y)| (min_x, -max_y))
+ .unwrap_or((0.0, 0.0));
+ let (pin_x, pin_y) = geometry
+ .pins
+ .first()
+ .map(|pin| (pin.pos_x, -pin.pos_y))
+ .unwrap_or((0.0, 0.0));
+ // Shift the body towards the origin in whole grid steps, then add the
+ // sub-grid correction that lands the reference pin on the grid.
+ let dx = -snap(min_x, grid) + (snap(pin_x, grid) - pin_x);
+ let dy = -snap(min_y, grid) + (snap(pin_y, grid) - pin_y);
+ Self {
+ dx,
+ dy,
+ graphics: Cell::new(IntBox::empty()),
+ }
+ }
+
+ /// Capture coordinates before rounding to whole units.
+ fn point_f(&self, x: f64, y_up: f64) -> (f64, f64) {
+ (x + self.dx, -y_up + self.dy)
+ }
+
+ fn point(&self, x: f64, y_up: f64) -> (i64, i64) {
+ (
+ (x + self.dx).round() as i64,
+ (-y_up + self.dy).round() as i64,
+ )
+ }
+
+ /// The part body outline (`SymbolBBox`), laid out like Capture's own parts: on
+ /// the 0.1" grid, around every graphic and pin start, with each pin starting on
+ /// its edge. Capture's part editor keeps graphics inside this outline and snaps
+ /// moves to the grid, so a tight, off-grid outline makes lines jump or refuse to
+ /// move. A pin whose body end lay inside the outline is shortened to the edge and
+ /// a lead line drawn from the edge to where it used to end; a pin whose tip would
+ /// sit on or inside the outline is lengthened to one grid step outside it.
+ fn body_bbox(&self, pins: &mut [CapturePin], graphics: &mut String) -> IntBox {
+ let grid = CAPTURE_GRID;
+ let mut bbox = self.graphics.get();
+ for pin in pins.iter() {
+ bbox.add(pin.start.0, pin.start.1);
+ }
+ if bbox.is_empty() {
+ bbox.add(0, 0);
+ bbox.add(grid, grid);
+ }
+ let down = |v: i64| v.div_euclid(grid) * grid;
+ let up = |v: i64| (v + grid - 1).div_euclid(grid) * grid;
+ let mut body = IntBox {
+ x1: down(bbox.x1),
+ y1: down(bbox.y1),
+ x2: up(bbox.x2),
+ y2: up(bbox.y2),
+ };
+ if body.x1 == body.x2 {
+ body.x2 += grid;
+ }
+ if body.y1 == body.y2 {
+ body.y2 += grid;
+ }
+ for pin in pins.iter_mut() {
+ let ((hx, hy), (sx, sy)) = (pin.hot, pin.start);
+ // The edge the pin leaves from, and its tip at least one grid step
+ // outside it: a tip on or inside the outline would make a zero-length
+ // pin, which Capture draws as a bare marker.
+ let (edge, hot) = if hy == sy && hx < sx {
+ ((body.x1, sy), (hx.min(body.x1 - grid), hy))
+ } else if hy == sy && hx > sx {
+ ((body.x2, sy), (hx.max(body.x2 + grid), hy))
+ } else if hx == sx && hy < sy {
+ ((sx, body.y1), (hx, hy.min(body.y1 - grid)))
+ } else if hx == sx && hy > sy {
+ ((sx, body.y2), (hx, hy.max(body.y2 + grid)))
+ } else {
+ continue;
+ };
+ if edge != pin.start {
+ write_line(graphics, self, edge, pin.start);
+ pin.start = edge;
+ }
+ pin.hot = hot;
+ }
+ body
+ }
+
+ fn record(&self, x: i64, y: i64) {
+ let mut bbox = self.graphics.get();
+ bbox.add(x, y);
+ self.graphics.set(bbox);
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+struct IntBox {
+ x1: i64,
+ y1: i64,
+ x2: i64,
+ y2: i64,
+}
+
+impl IntBox {
+ const fn empty() -> Self {
+ Self {
+ x1: i64::MAX,
+ y1: i64::MAX,
+ x2: i64::MIN,
+ y2: i64::MIN,
+ }
+ }
+
+ fn is_empty(&self) -> bool {
+ self.x1 > self.x2
+ }
+
+ fn add(&mut self, x: i64, y: i64) {
+ self.x1 = self.x1.min(x);
+ self.y1 = self.y1.min(y);
+ self.x2 = self.x2.max(x);
+ self.y2 = self.y2.max(y);
+ }
+}
+
+#[derive(Debug, Clone)]
+struct CapturePin {
+ name: String,
+ number: String,
+ hot: (i64, i64),
+ start: (i64, i64),
+ pin_type: u8,
+ dot: bool,
+ clock: bool,
+}
+
+fn capture_pins(
+ pins: &[KiPin],
+ layout: &CaptureLayout,
+ warnings: &mut Vec,
+) -> Vec {
+ let mut seen: HashMap = HashMap::new();
+ let mut off_grid = Vec::new();
+ let mut out = Vec::with_capacity(pins.len());
+
+ for pin in pins {
+ let length = if pin.length.abs() < 1.0 {
+ warnings.push(format!(
+ "pin {} had zero length; using {} units",
+ pin.number, CAPTURE_GRID
+ ));
+ CAPTURE_GRID as f64
+ } else {
+ pin.length.abs()
+ };
+ // KiCad orientation: direction from the connection point towards the body.
+ let angle = (((180 + pin.rotation) % 360 + 360) % 360) as f64;
+ let (sin, cos) = angle.to_radians().sin_cos();
+ let hot = layout.point(pin.pos_x, pin.pos_y);
+ let start = layout.point(pin.pos_x + length * cos, pin.pos_y + length * sin);
+ if hot.0 % CAPTURE_GRID != 0 || hot.1 % CAPTURE_GRID != 0 {
+ off_grid.push(pin.number.clone());
+ }
+
+ let base_number = if pin.number.trim().is_empty() {
+ (out.len() + 1).to_string()
+ } else {
+ pin.number.trim().to_string()
+ };
+ let count = seen.entry(base_number.clone()).or_insert(0);
+ *count += 1;
+ let number = if *count == 1 {
+ base_number
+ } else {
+ let renamed = format!("{}_{}", base_number, count);
+ warnings.push(format!(
+ "duplicate pin number {} renamed to {}",
+ base_number, renamed
+ ));
+ renamed
+ };
+ let name = if pin.name.trim().is_empty() {
+ number.clone()
+ } else {
+ pin.name.trim().to_string()
+ };
+
+ out.push(CapturePin {
+ name,
+ number,
+ hot,
+ start,
+ pin_type: capture_pin_type(pin.pin_type),
+ dot: matches!(
+ pin.style,
+ PinStyle::Inverted
+ | PinStyle::InvertedClock
+ | PinStyle::InputLow
+ | PinStyle::OutputLow
+ ),
+ clock: matches!(
+ pin.style,
+ PinStyle::Clock
+ | PinStyle::InvertedClock
+ | PinStyle::ClockLow
+ | PinStyle::EdgeClockHigh
+ ),
+ });
+ }
+
+ if !off_grid.is_empty() {
+ warnings.push(format!(
+ "pins not on the 0.1\" grid (wires may not snap): {}",
+ off_grid.join(", ")
+ ));
+ }
+ out
+}
+
+fn capture_pin_type(pin_type: PinType) -> u8 {
+ match pin_type {
+ PinType::Input => 0,
+ PinType::Bidirectional => 1,
+ PinType::Output => 2,
+ PinType::OpenCollector => 3,
+ PinType::Passive | PinType::Unspecified | PinType::NoConnect => 4,
+ PinType::TriState => 5,
+ PinType::OpenEmitter => 6,
+ PinType::PowerIn | PinType::PowerOut => 7,
+ }
+}
+
+fn write_pin(out: &mut String, pin: &CapturePin, position: usize) {
+ let _ = writeln!(out, " ");
+ let _ = writeln!(
+ out,
+ " ",
+ pin.hot.0,
+ pin.hot.1,
+ xml_escape(&pin.name),
+ position,
+ pin.start.0,
+ pin.start.1,
+ pin.pin_type
+ );
+ write_flag(out, 10, "IsLong", true);
+ write_flag(out, 10, "IsClock", pin.clock);
+ write_flag(out, 10, "IsDot", pin.dot);
+ write_flag(out, 10, "IsLeftPointing", false);
+ write_flag(out, 10, "IsRightPointing", false);
+ write_flag(out, 10, "IsNetStyle", false);
+ write_flag(out, 10, "IsNoConnect", false);
+ write_flag(out, 10, "IsGlobal", false);
+ write_flag(out, 10, "IsNumberVisible", true);
+ let _ = writeln!(out, " ");
+}
+
+fn write_flag(out: &mut String, indent: usize, name: &str, value: bool) {
+ let pad = " ".repeat(indent);
+ let _ = writeln!(
+ out,
+ "{pad}<{name}>\n{pad} \n{pad}{name}>",
+ u8::from(value)
+ );
+}
+
+fn write_display_prop(out: &mut String, name: &str, x: i64, y: i64) {
+ let _ = writeln!(
+ out,
+ " \n \n \n \n \n \n \n \n \n \n \n "
+ );
+}
+
+fn write_rect(out: &mut String, layout: &CaptureLayout, rect: &KiRectangle) {
+ let (ax, ay) = layout.point(rect.x1, rect.y1);
+ let (bx, by) = layout.point(rect.x2, rect.y2);
+ let (x1, x2) = (ax.min(bx), ax.max(bx));
+ let (y1, y2) = (ay.min(by), ay.max(by));
+ if x1 == x2 || y1 == y2 {
+ write_line(out, layout, (x1, y1), (x2, y2));
+ return;
+ }
+ layout.record(x1, y1);
+ layout.record(x2, y2);
+ // Body rectangles are left unfilled so pin names stay readable (Capture default).
+ let _ = writeln!(
+ out,
+ " \n \n "
+ );
+}
+
+fn write_line(out: &mut String, layout: &CaptureLayout, a: (i64, i64), b: (i64, i64)) {
+ if a == b {
+ return;
+ }
+ layout.record(a.0, a.1);
+ layout.record(b.0, b.1);
+ let _ = writeln!(
+ out,
+ " \n \n ",
+ a.0, b.0, a.1, b.1
+ );
+}
+
+fn write_polygon(out: &mut String, layout: &CaptureLayout, points: &[(i64, i64)], filled: bool) {
+ let mut points: Vec<(i64, i64)> = dedup_points(points);
+ if points.len() < 3 {
+ if points.len() == 2 {
+ write_line(out, layout, points[0], points[1]);
+ }
+ return;
+ }
+ // Capture closes polygons by repeating the first point.
+ if points.first() != points.last() {
+ points.push(points[0]);
+ }
+ let _ = writeln!(
+ out,
+ " \n ",
+ if filled { 0 } else { 1 }
+ );
+ for (x, y) in points {
+ layout.record(x, y);
+ let _ = writeln!(
+ out,
+ " \n \n "
+ );
+ }
+ let _ = writeln!(out, " ");
+}
+
+fn write_polyline_points(out: &mut String, layout: &CaptureLayout, points: &[(i64, i64)]) {
+ let points = dedup_points(points);
+ for pair in points.windows(2) {
+ write_line(out, layout, pair[0], pair[1]);
+ }
+}
+
+fn write_circle(out: &mut String, layout: &CaptureLayout, circle: &KiCircle) {
+ // Native Capture ellipse (bounding box), as in olb.xsd; fillStyle 0 = solid, 1 = none.
+ // Round the corner, then add the rounded diameter, so a circle keeps its size
+ // (rounding both corners can shrink a small one by a whole unit).
+ let diameter = (2.0 * circle.radius).round().max(1.0) as i64;
+ let (x1, y1) = layout.point(circle.cx - circle.radius, circle.cy + circle.radius);
+ let (x2, y2) = (x1 + diameter, y1 + diameter);
+ // Keep tiny markers (e.g. pin-1 dots) visible after rounding to 0.01".
+ let (x2, y2) = (x2.max(x1 + 1), y2.max(y1 + 1));
+ layout.record(x1, y1);
+ layout.record(x2, y2);
+ let _ = writeln!(
+ out,
+ " \n \n ",
+ if circle.fill { 0 } else { 1 }
+ );
+}
+
+fn write_arc(out: &mut String, layout: &CaptureLayout, arc: &KiArc) {
+ let start = (arc.start_x, arc.start_y);
+ let mid = (arc.mid_x, arc.mid_y);
+ let end = (arc.end_x, arc.end_y);
+ let Some((cx, cy, radius)) = circumcircle(start, mid, end) else {
+ let points = [start, mid, end].map(|(x, y)| layout.point(x, y));
+ write_polyline_points(out, layout, &points);
+ return;
+ };
+
+ let a0 = (start.1 - cy).atan2(start.0 - cx);
+ let am = (mid.1 - cy).atan2(mid.0 - cx);
+ let a1 = (end.1 - cy).atan2(end.0 - cx);
+ let ccw = normalize_angle(am - a0) < normalize_angle(a1 - a0);
+ let sweep = if ccw {
+ normalize_angle(a1 - a0)
+ } else {
+ -normalize_angle(a0 - a1)
+ };
+ let segments = ((segments_for_radius(radius) as f64) * sweep.abs() / std::f64::consts::TAU)
+ .ceil()
+ .max(2.0) as usize;
+ let points: Vec<(i64, i64)> = (0..=segments)
+ .map(|index| {
+ let angle = a0 + sweep * index as f64 / segments as f64;
+ layout.point(cx + radius * angle.cos(), cy + radius * angle.sin())
+ })
+ .collect();
+ // Arcs too small for whole Capture units stay line segments.
+ if radius < 1.5 || sweep.abs() < 1e-3 {
+ write_polyline_points(out, layout, &points);
+ return;
+ }
+ for &(x, y) in &points {
+ layout.record(x, y);
+ }
+ // Native Capture arc: the circle's bounding box plus start and end points; Capture
+ // draws counter-clockwise on screen (decreasing angle with Y down) from start to
+ // end, as its own Discrete.olb inductor does. Sweeps near a full turn are split.
+ let pieces = if sweep.abs() > 1.5 * std::f64::consts::PI {
+ 2
+ } else {
+ 1
+ };
+ // The bounding box is in whole units, so pick the representable circle that
+ // follows the drawn arc best (rounding the box corners would squash and shift
+ // small arcs such as inductor bumps).
+ let samples: Vec<(f64, f64)> = (0..=8)
+ .map(|i| {
+ let angle = a0 + sweep * i as f64 / 8.0;
+ layout.point_f(cx + radius * angle.cos(), cy + radius * angle.sin())
+ })
+ .collect();
+ // The written end points are whole units and other lines meet them there: the
+ // circle should pass through them.
+ let ends = [layout.point(start.0, start.1), layout.point(end.0, end.1)]
+ .map(|(x, y)| (x as f64, y as f64));
+ let (fcx, fcy) = layout.point_f(cx, cy);
+ let mut best = (f64::MAX, (fcx, fcy, radius));
+ for half in [0.0, 0.5] {
+ let near = |v: f64| (v - half).round() + half;
+ for i in -2..=2 {
+ for j in -2..=2 {
+ for k in -2..=2 {
+ let (ccx, ccy, r) = (
+ near(fcx) + i as f64,
+ near(fcy) + j as f64,
+ near(radius) + k as f64,
+ );
+ if r < 1.0 {
+ continue;
+ }
+ let miss = |&(x, y): &(f64, f64)| ((x - ccx).hypot(y - ccy) - r).powi(2);
+ let err: f64 = samples.iter().map(miss).sum::()
+ + 4.0 * ends.iter().map(miss).sum::();
+ if err < best.0 {
+ best = (err, (ccx, ccy, r));
+ }
+ }
+ }
+ }
+ }
+ let (bcx, bcy, br) = best.1;
+ let (x1, y1) = ((bcx - br).round() as i64, (bcy - br).round() as i64);
+ let (x2, y2) = ((bcx + br).round() as i64, (bcy + br).round() as i64);
+ for piece in 0..pieces {
+ let from = a0 + sweep * piece as f64 / pieces as f64;
+ let to = a0 + sweep * (piece + 1) as f64 / pieces as f64;
+ let at = |angle: f64| layout.point(cx + radius * angle.cos(), cy + radius * angle.sin());
+ // Counter-clockwise in EasyEDA's Y-up frame is counter-clockwise on screen.
+ let (first, last) = if sweep > 0.0 {
+ (at(from), at(to))
+ } else {
+ (at(to), at(from))
+ };
+ let _ = writeln!(
+ out,
+ " \n \n ",
+ last.0, last.1, first.0, first.1
+ );
+ }
+}
+
+fn write_polyline(out: &mut String, layout: &CaptureLayout, polyline: &KiPolyline) {
+ let points: Vec<(i64, i64)> = polyline
+ .points
+ .iter()
+ .map(|&(x, y)| layout.point(x, y))
+ .collect();
+ // A filled shape is closed whether or not its last point repeats the first
+ // (EasyEDA's MOSFET arrows and diode triangles don't).
+ if polyline.fill && dedup_points(&points).len() >= 3 {
+ write_polygon(out, layout, &points, true);
+ } else {
+ write_polyline_points(out, layout, &points);
+ }
+}
+
+fn write_comment_text(out: &mut String, layout: &CaptureLayout, text: &KiText) {
+ let content = text.text.trim();
+ if content.is_empty() {
+ return;
+ }
+ // EasyEDA anchors text at its baseline; Capture at the top-left of the text box.
+ let (x, baseline) = layout.point(text.x, text.y);
+ let height = CAPTURE_GRID;
+ let y = baseline - height;
+ let width = (content.chars().count() as i64 * 5).max(5);
+ layout.record(x, y);
+ layout.record(x + width, baseline);
+ let _ = writeln!(
+ out,
+ " \n \n \n \n \n ",
+ xml_escape(content),
+ x + width
+ );
+}
+
+fn dedup_points(points: &[(i64, i64)]) -> Vec<(i64, i64)> {
+ let mut out: Vec<(i64, i64)> = Vec::with_capacity(points.len());
+ for &point in points {
+ if out.last() != Some(&point) {
+ out.push(point);
+ }
+ }
+ out
+}
+
+fn segments_for_radius(radius: f64) -> usize {
+ // Capture units are coarse (0.01"); more segments than this only adds rounding noise.
+ (radius.abs() * 2.0).clamp(8.0, 36.0) as usize
+}
+
+fn circumcircle(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> Option<(f64, f64, f64)> {
+ let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1));
+ if d.abs() < 1e-9 {
+ return None;
+ }
+ let a2 = a.0 * a.0 + a.1 * a.1;
+ let b2 = b.0 * b.0 + b.1 * b.1;
+ let c2 = c.0 * c.0 + c.1 * c.1;
+ let ux = (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d;
+ let uy = (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d;
+ let radius = ((a.0 - ux).powi(2) + (a.1 - uy).powi(2)).sqrt();
+ Some((ux, uy, radius))
+}
+
+fn normalize_angle(angle: f64) -> f64 {
+ angle.rem_euclid(std::f64::consts::TAU)
+}
+
+/// Nearest multiple of `grid`, halves rounding up, so pins half a grid off all move
+/// the same way and keep their spacing.
+fn snap(value: f64, grid: f64) -> f64 {
+ (value / grid + 0.5 + 1e-9).floor() * grid
+}
+
+#[derive(Default)]
+struct Bounds {
+ min_x: f64,
+ min_y: f64,
+ max_x: f64,
+ max_y: f64,
+ any: bool,
+}
+
+impl Bounds {
+ fn add(&mut self, x: f64, y: f64) {
+ if self.any {
+ self.min_x = self.min_x.min(x);
+ self.min_y = self.min_y.min(y);
+ self.max_x = self.max_x.max(x);
+ self.max_y = self.max_y.max(y);
+ } else {
+ *self = Bounds {
+ min_x: x,
+ min_y: y,
+ max_x: x,
+ max_y: y,
+ any: true,
+ };
+ }
+ }
+
+ fn get(&self) -> Option<(f64, f64, f64, f64)> {
+ self.any
+ .then_some((self.min_x, self.min_y, self.max_x, self.max_y))
+ }
+}
+
+pub(crate) fn xml_escape(value: &str) -> String {
+ let mut out = String::with_capacity(value.len());
+ for ch in value.chars() {
+ match ch {
+ '&' => out.push_str("&"),
+ '<' => out.push_str("<"),
+ '>' => out.push_str(">"),
+ '"' => out.push_str("""),
+ '\'' => out.push_str("'"),
+ // Control characters are not allowed in XML 1.0 attributes.
+ c if (c as u32) < 0x20 => out.push(' '),
+ c => out.push(c),
+ }
+ }
+ out
+}
+
+/// `DefaultValues` block copied from Capture 17.2's own Library XML exports.
+fn default_values_xml() -> String {
+ let mut xml = String::from(" \n \n");
+ for index in 0..24 {
+ let (height, width) = if matches!(index, 3 | 4 | 17..=23) {
+ ("8", "0")
+ } else {
+ ("-9", if index == 13 { "5" } else { "4" })
+ };
+ let name = if index == 13 { "Courier New" } else { "Arial" };
+ let _ = writeln!(
+ xml,
+ " \n \n "
+ );
+ }
+ xml.push_str(" \n \n \n");
+ xml.push_str(" \n \n \n");
+ xml.push_str(" \n \n \n");
+ let fields = [
+ "1ST PART FIELD",
+ "2ND PART FIELD",
+ "3RD PART FIELD",
+ "4TH PART FIELD",
+ "5TH PART FIELD",
+ "6TH PART FIELD",
+ "7TH PART FIELD",
+ "PCB Footprint",
+ ];
+ for (index, field) in fields.iter().enumerate() {
+ let _ = writeln!(
+ xml,
+ " \n \n ",
+ index + 1
+ );
+ }
+ xml.push_str(" \n");
+ xml
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn library_xml_round_trips_its_packages() {
+ let parts = [
+ " \n \n \n"
+ .to_string(),
+ " \n \n \n"
+ .to_string(),
+ ];
+ let xml = render_library_xml("Parts.olb", &parts);
+ let blocks = package_blocks(&xml);
+ assert_eq!(
+ blocks
+ .iter()
+ .map(|(name, _)| name.as_str())
+ .collect::>(),
+ ["R_A&B_C1", "U_C2"]
+ );
+ let again: Vec = blocks.into_iter().map(|(_, block)| block).collect();
+ assert_eq!(render_library_xml("Parts.olb", &again), xml);
+ }
+
+ #[test]
+ fn identifiers_are_plain_ascii() {
+ assert_eq!(capture_identifier("10kΩ 0402 (1%)"), "10k_0402_1");
+ assert_eq!(capture_identifier("***"), "PART");
+ }
+}
diff --git a/src/cadence/ipc7352.rs b/src/cadence/ipc7352.rs
new file mode 100644
index 0000000..2f588ab
--- /dev/null
+++ b/src/cadence/ipc7352.rs
@@ -0,0 +1,2552 @@
+//! IPC-7352 land patterns, density level N (nominal), for the standard SMD families.
+//!
+//! IPC-7352 (2023) replaced IPC-7352. For surface-mount parts it keeps IPC-7352's
+//! land equations, zero orientations and naming convention, so the land is
+//! calculated from the component's dimensions (minimum/maximum lead span `L`,
+//! terminal length `T` and width `W`):
+//!
+//! ```text
+//! Zmax = Lmin + 2 Jt + sqrt(CL² + F² + P²)
+//! Gmin = Smax - 2 Jh - sqrt(CS² + F² + P²) (S toe-to-heel, RMS tolerance)
+//! Xmax = Wmin + 2 Js + sqrt(CW² + F² + P²)
+//! ```
+//!
+//! Z and X are rounded up and G down to the round-off (0.005 mm for chips narrower
+//! than 0.5 mm, as IPC-7352 asks); the courtyard is the lands and body plus the
+//! courtyard excess, on a 0.05 mm grid. The pattern is built in zero orientation
+//! (pin 1 upper left, chips and diodes with pin 1 / the cathode on the left), its
+//! origin at the package centre, and named with the convention
+//! (`RESC1005X40N`, `SOT95P280X145-5N`, ...).
+//!
+//! EasyEDA's own land pattern is used to check the result: its pads must line up
+//! with the calculated ones after a quarter turn, which also carries EasyEDA's pin
+//! numbers over. Anything that does not fit is reported and keeps EasyEDA's lands.
+
+use crate::kicad::{FootprintKiCircle as KiCircle, KiFootprint, KiLine, KiPad, PadShape, PadType};
+
+/// Board fabrication tolerance F and placement tolerance P (mm).
+pub const FAB_TOLERANCE: f64 = 0.05;
+pub const PLACEMENT_TOLERANCE: f64 = 0.025;
+/// Silkscreen line width and minimum gap to copper (mm).
+const SILK_WIDTH: f64 = 0.12;
+const SILK_TO_COPPER: f64 = 0.20;
+const ASSEMBLY_WIDTH: f64 = 0.10;
+const COURTYARD_WIDTH: f64 = 0.05;
+const COURTYARD_GRID: f64 = 0.05;
+/// Minimum copper gap between an exposed pad and the terminal lands.
+const EP_TO_PAD: f64 = 0.20;
+/// EasyEDA pads must land within this distance of the calculated ones.
+const MATCH_TOLERANCE: f64 = 0.6;
+const PX: f64 = 0.254;
+
+/// Solder-joint goals and courtyard of one IPC-7352 table, density level N.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Goals {
+ pub toe: f64,
+ pub heel: f64,
+ pub side: f64,
+ pub courtyard: f64,
+ pub round_off: f64,
+ pub table: &'static str,
+}
+
+/// Rectangular and square-end chips, 1608 (0603) and larger.
+pub const CHIP: Goals = Goals {
+ toe: 0.35,
+ heel: 0.0,
+ side: 0.0,
+ courtyard: 0.25,
+ round_off: 0.05,
+ table: "chip >= 1608",
+};
+/// Chips narrower than 0.5 mm (0201, 01005): IPC-7352 rounds their lands to
+/// 0.005 mm.
+pub const CHIP_TINY: Goals = Goals {
+ round_off: 0.005,
+ table: "chip, width < 0.5",
+ ..CHIP_SMALL
+};
+/// Chips smaller than 1608 (0603).
+pub const CHIP_SMALL: Goals = Goals {
+ toe: 0.20,
+ heel: 0.0,
+ side: 0.0,
+ courtyard: 0.15,
+ round_off: 0.01,
+ table: "chip < 1608",
+};
+/// Gull-wing leads, pitch above 0.625 mm.
+pub const GULL_WING: Goals = Goals {
+ toe: 0.35,
+ heel: 0.35,
+ side: 0.03,
+ courtyard: 0.25,
+ round_off: 0.05,
+ table: "gull-wing, pitch > 0.625",
+};
+/// Gull-wing leads, pitch 0.625 mm and below.
+pub const GULL_WING_FINE: Goals = Goals {
+ toe: 0.35,
+ heel: 0.35,
+ side: -0.02,
+ courtyard: 0.25,
+ round_off: 0.05,
+ table: "gull-wing, pitch <= 0.625",
+};
+/// Molded bodies with inward L-bend terminals (DO-214 SMA/SMB/SMC).
+pub const MOLDED: Goals = Goals {
+ toe: 0.15,
+ heel: 0.50,
+ side: -0.05,
+ courtyard: 0.25,
+ round_off: 0.05,
+ table: "molded body, inward L-bend",
+};
+/// Flat leads (SODFL / SOTFL: SOD-523, SOD-923). IPC-7352 gives no separate
+/// table; the toe is the nominal lead thickness rounded up (0.20 mm, as PCB
+/// Libraries' calculator does) and heel and side are the chip goals (Table 3-5).
+/// The QFN goals used before (side -0.04 mm) made 0.30 mm wide lands for a
+/// 0.30 mm lead, narrower than NXP's and onsemi's SOD-523 footprints.
+pub const FLAT_LEAD: Goals = Goals {
+ toe: 0.20,
+ heel: 0.0,
+ side: 0.0,
+ courtyard: 0.25,
+ round_off: 0.05,
+ table: "flat lead",
+};
+/// Flat no-lead terminations (QFN, SON).
+pub const NO_LEAD: Goals = Goals {
+ toe: 0.30,
+ heel: 0.0,
+ side: -0.04,
+ courtyard: 0.25,
+ round_off: 0.05,
+ table: "flat no-lead",
+};
+
+/// A dimension with its tolerance (mm).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Dim {
+ pub min: f64,
+ pub max: f64,
+}
+
+impl Dim {
+ pub const fn new(min: f64, max: f64) -> Self {
+ Self { min, max }
+ }
+
+ fn around(nominal: f64, tolerance: f64) -> Self {
+ Self::new(nominal - tolerance, nominal + tolerance)
+ }
+
+ pub fn nominal(&self) -> f64 {
+ (self.min + self.max) / 2.0
+ }
+
+ fn tolerance(&self) -> f64 {
+ self.max - self.min
+ }
+}
+
+/// Where the terminals are, in IPC-7352 zero orientation.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Layout {
+ /// Two terminals on the X axis, pin 1 on the left.
+ TwoTerminal,
+ /// Columns on the left and right: Y of each left pin (top first) and right pin
+ /// (bottom first), counter-clockwise numbering.
+ Dual { left: Vec, right: Vec },
+ /// `per_side` pins on each of the four sides, pin 1 at the top of the left side.
+ Quad { per_side: usize },
+}
+
+/// Everything the calculation needs about a package.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Package {
+ /// IPC-7352 family prefix (`RESC`, `SOT`, `SOIC`, `QFN`, ...).
+ pub prefix: &'static str,
+ pub layout: Layout,
+ pub pitch: f64,
+ /// Toe-to-toe span across the lands (chips and no-lead: body length).
+ pub span: Dim,
+ pub terminal: Dim,
+ pub width: Dim,
+ /// Width of the tab lead (SOT-223), which takes the right-hand column.
+ pub tab: Option,
+ /// Nominal body size along X and Y in zero orientation.
+ pub body: (f64, f64),
+ pub height: f64,
+ pub exposed_pad: Option<(f64, f64)>,
+ pub goals: Goals,
+ /// The two terminals are polarised (cathode = pin 1).
+ pub polarized: bool,
+ /// Dimensions came from typical JEDEC/EIA values rather than the part.
+ pub source: &'static str,
+}
+
+impl Package {
+ pub fn pins(&self) -> usize {
+ match &self.layout {
+ Layout::TwoTerminal => 2,
+ Layout::Dual { left, right } => left.len() + right.len(),
+ Layout::Quad { per_side } => per_side * 4,
+ }
+ }
+
+ /// IPC-7352 land pattern name, density level N.
+ pub fn ipc_name(&self) -> String {
+ let h = hundredths(self.height);
+ let n = self.pins() + usize::from(self.exposed_pad.is_some());
+ match self.prefix {
+ "SOD" | "SODFL" | "DIOM" | "RESC" | "CAPC" | "INDC" | "LEDC" | "DIOC" | "FUSC" => {
+ let (a, b) = if matches!(self.prefix, "SOD" | "SODFL" | "DIOM") {
+ (self.span.nominal(), self.body.1)
+ } else {
+ (self.body.0, self.body.1)
+ };
+ format!("{}{}{}X{h}N", self.prefix, tenths(a), tenths(b))
+ }
+ "QFP" => format!(
+ "QFP{}P{}X{}X{h}-{n}N",
+ hundredths(self.pitch),
+ hundredths(self.span.nominal()),
+ hundredths(self.span.nominal())
+ ),
+ "QFN" | "SON" => format!(
+ "{}{}P{}X{}X{h}-{n}N",
+ self.prefix,
+ hundredths(self.pitch),
+ hundredths(self.body.0),
+ hundredths(self.body.1)
+ ),
+ _ => format!(
+ "{}{}P{}X{h}-{n}N",
+ self.prefix,
+ hundredths(self.pitch),
+ hundredths(self.span.nominal())
+ ),
+ }
+ }
+}
+
+/// `1.6` -> `16`, `0.8` -> `08`, `1.25` -> `12` (two digits of 0.1 mm).
+fn tenths(mm: f64) -> String {
+ format!("{:02}", ((mm * 10.0) + 1e-6).floor() as i64)
+}
+
+/// `0.95` -> `95`, `2.80` -> `280`.
+fn hundredths(mm: f64) -> String {
+ format!("{}", (mm * 100.0).round() as i64)
+}
+
+/// Land dimensions of one row of terminals (mm).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Lands {
+ pub z: f64,
+ pub g: f64,
+ pub x: f64,
+}
+
+impl Lands {
+ /// Land length along the span.
+ pub fn length(&self) -> f64 {
+ (self.z - self.g) / 2.0
+ }
+
+ /// Land centre distance from the package centre.
+ pub fn centre(&self) -> f64 {
+ (self.z + self.g) / 4.0
+ }
+}
+
+/// IPC-7352 land equations.
+pub fn lands(span: Dim, terminal: Dim, width: Dim, goals: &Goals) -> Lands {
+ let ft = FAB_TOLERANCE.powi(2) + PLACEMENT_TOLERANCE.powi(2);
+ let z = span.min + 2.0 * goals.toe + (span.tolerance().powi(2) + ft).sqrt();
+ // Toe-to-heel distance with an RMS tolerance.
+ let s_min = span.min - 2.0 * terminal.max;
+ let s_max = span.max - 2.0 * terminal.min;
+ let s_tol = s_max - s_min;
+ let s_tol_rms = (span.tolerance().powi(2) + 2.0 * terminal.tolerance().powi(2)).sqrt();
+ let s_max_rms = s_max - (s_tol - s_tol_rms) / 2.0;
+ let g = s_max_rms - 2.0 * goals.heel - (s_tol_rms.powi(2) + ft).sqrt();
+ let x = width.min + 2.0 * goals.side + (width.tolerance().powi(2) + ft).sqrt();
+ let r = goals.round_off;
+ Lands {
+ z: round_up(z, r),
+ g: round_down(g, r).max(0.0),
+ x: round_up(x, r),
+ }
+}
+
+fn round_up(v: f64, step: f64) -> f64 {
+ ((v / step) - 1e-9).ceil() * step
+}
+
+fn round_down(v: f64, step: f64) -> f64 {
+ ((v / step) + 1e-9).floor() * step
+}
+
+fn round2(v: f64) -> f64 {
+ (v * 100.0).round() / 100.0 + 0.0
+}
+
+// ---------------------------------------------------------------------------
+// Packages
+
+/// EIA chip sizes: code, body length and width with tolerance, terminal length.
+const CHIP_SIZES: [(&str, f64, f64, f64, f64, Dim); 9] = [
+ ("0201", 0.60, 0.03, 0.30, 0.03, Dim::new(0.10, 0.20)),
+ ("0402", 1.00, 0.05, 0.50, 0.05, Dim::new(0.10, 0.30)),
+ ("0603", 1.60, 0.10, 0.80, 0.10, Dim::new(0.10, 0.50)),
+ ("0805", 2.00, 0.10, 1.25, 0.10, Dim::new(0.20, 0.75)),
+ ("1206", 3.20, 0.15, 1.60, 0.15, Dim::new(0.25, 0.75)),
+ ("1210", 3.20, 0.15, 2.50, 0.15, Dim::new(0.25, 0.75)),
+ ("1812", 4.50, 0.30, 3.20, 0.20, Dim::new(0.25, 0.85)),
+ ("2010", 5.00, 0.10, 2.50, 0.15, Dim::new(0.35, 0.85)),
+ ("2512", 6.35, 0.10, 3.20, 0.15, Dim::new(0.35, 0.85)),
+];
+
+/// Maximum height by chip kind and size (resistor, capacitor, other).
+fn chip_height(prefix: &str, code: &str) -> f64 {
+ let (r, c, other) = match code {
+ "0201" => (0.26, 0.33, 0.33),
+ "0402" => (0.40, 0.55, 0.55),
+ "0603" => (0.55, 0.90, 0.80),
+ "0805" => (0.70, 1.45, 1.00),
+ "1206" => (0.70, 1.80, 1.20),
+ "1210" => (0.70, 2.70, 1.20),
+ "1812" => (0.70, 2.70, 2.00),
+ _ => (0.70, 2.70, 1.50),
+ };
+ match prefix {
+ "RESC" | "FUSC" => r,
+ "CAPC" => c,
+ _ => other,
+ }
+}
+
+/// IPC chip prefix for an LCSC reference prefix.
+fn chip_prefix(reference: &str, package_prefix: &str) -> &'static str {
+ match reference {
+ "R" | "RN" => "RESC",
+ "C" => "CAPC",
+ "L" | "FB" => "INDC",
+ "LED" => "LEDC",
+ "D" => "DIOC",
+ "F" => "FUSC",
+ _ => match package_prefix {
+ "R" => "RESC",
+ "C" => "CAPC",
+ "L" | "FB" => "INDC",
+ "LED" => "LEDC",
+ "D" => "DIOC",
+ "F" => "FUSC",
+ _ => "RESC",
+ },
+ }
+}
+
+fn chip_code(l: f64, w: f64) -> Option<&'static str> {
+ CHIP_SIZES
+ .iter()
+ .find(|s| (l - s.1).abs() <= 0.12 && (w - s.3).abs() <= 0.12)
+ .map(|s| s.0)
+}
+
+fn chip(code: &str, prefix: &'static str) -> Option {
+ let &(_, l, lt, w, wt, t) = CHIP_SIZES.iter().find(|s| s.0 == code)?;
+ let small = l < 1.5;
+ Some(Package {
+ prefix,
+ layout: Layout::TwoTerminal,
+ pitch: 0.0,
+ span: Dim::around(l, lt),
+ terminal: t,
+ width: Dim::around(w, wt),
+ body: (l, w),
+ height: chip_height(prefix, code),
+ tab: None,
+ exposed_pad: None,
+ goals: if w < 0.5 {
+ CHIP_TINY
+ } else if small {
+ CHIP_SMALL
+ } else {
+ CHIP
+ },
+ polarized: matches!(prefix, "LEDC" | "DIOC"),
+ source: "EIA chip dimensions",
+ })
+}
+
+/// Parameters EasyEDA puts in its package names: `_L2.9-W1.6-P0.95-LS2.8`.
+#[derive(Debug, Default, Clone, Copy)]
+struct NameDims {
+ l: Option,
+ w: Option,
+ p: Option,
+ ls: Option,
+ h: Option,
+ ep: Option<(f64, f64)>,
+ has_ep: bool,
+}
+
+fn name_dims(rest: &str) -> NameDims {
+ let mut d = NameDims::default();
+ for token in rest.split(['-', '_']) {
+ let num = |s: &str| s.parse::().ok();
+ if let Some(v) = token.strip_prefix("LS").and_then(num) {
+ d.ls.get_or_insert(v);
+ } else if let Some(v) = token.strip_prefix("EP") {
+ d.has_ep = true;
+ let mut parts = v.split('X').filter_map(num);
+ if let Some(a) = parts.next() {
+ d.ep = Some((a, parts.next().unwrap_or(a)));
+ }
+ } else if let Some(v) = token.strip_prefix('L').and_then(num) {
+ d.l.get_or_insert(v);
+ } else if let Some(v) = token.strip_prefix('W').and_then(num) {
+ d.w.get_or_insert(v);
+ } else if let Some(v) = token.strip_prefix('P').and_then(num) {
+ d.p.get_or_insert(v);
+ } else if let Some(v) = token.strip_prefix('H').and_then(num) {
+ d.h.get_or_insert(v);
+ }
+ }
+ d
+}
+
+/// Gull-wing lead width by pitch (JEDEC).
+fn gull_lead_width(pitch: f64) -> Dim {
+ if pitch >= 1.2 {
+ Dim::new(0.31, 0.51)
+ } else if pitch >= 0.75 {
+ Dim::new(0.30, 0.45)
+ } else if pitch >= 0.6 {
+ Dim::new(0.22, 0.38)
+ } else if pitch >= 0.45 {
+ Dim::new(0.17, 0.27)
+ } else {
+ Dim::new(0.13, 0.23)
+ }
+}
+
+/// No-lead terminal width by pitch (JEDEC MO-220 / MO-229).
+fn no_lead_width(pitch: f64) -> Dim {
+ if pitch >= 0.75 {
+ Dim::new(0.28, 0.38)
+ } else if pitch >= 0.6 {
+ Dim::new(0.25, 0.35)
+ } else if pitch >= 0.45 {
+ Dim::new(0.18, 0.30)
+ } else {
+ Dim::new(0.15, 0.25)
+ }
+}
+
+fn gull_goals(pitch: f64) -> Goals {
+ if pitch > 0.625 {
+ GULL_WING
+ } else {
+ GULL_WING_FINE
+ }
+}
+
+/// Y positions of `n` pins at `pitch`, top first, centred on 0.
+fn column(n: usize, pitch: f64) -> Vec {
+ (0..n)
+ .map(|i| round2(((n as f64 - 1.0) / 2.0 - i as f64) * pitch))
+ .collect()
+}
+
+fn dual(n: usize, pitch: f64) -> Layout {
+ let left = column(n / 2, pitch);
+ let mut right = left.clone();
+ right.reverse();
+ Layout::Dual { left, right }
+}
+
+/// Recognise the package from EasyEDA's package name. `reference` is the part's
+/// reference prefix (R, C, LED, ...), which picks the chip kind.
+pub fn package_from_name(package: &str, reference: &str) -> Result {
+ let mut p = parse_package(package, reference)?;
+ // Bidirectional TVS diodes (EasyEDA's "-BI") have no cathode: no cathode
+ // mark on the footprint.
+ let bidirectional = package
+ .to_ascii_uppercase()
+ .split(['-', '_'])
+ .any(|token| token == "BI");
+ if bidirectional && p.polarized {
+ p.polarized = false;
+ }
+ Ok(p)
+}
+
+fn parse_package(package: &str, reference: &str) -> Result {
+ let upper = package.trim().to_ascii_uppercase();
+ if upper.is_empty() {
+ return Err("EasyEDA names no package".into());
+ }
+ let (base, rest) = upper.split_once('_').unwrap_or((upper.as_str(), ""));
+ let dims = name_dims(rest);
+ let head = base.split('-').next().unwrap_or(base);
+
+ // Chips: R0402, C0603, LED0603-RD, or LED-SMD_L1.6-W0.8.
+ let letters: String = head
+ .chars()
+ .take_while(|c| c.is_ascii_alphabetic())
+ .collect();
+ let digits = &head[letters.len()..];
+ if !letters.is_empty()
+ && letters.len() <= 3
+ && digits.len() == 4
+ && digits.chars().all(|c| c.is_ascii_digit())
+ {
+ if let Some(p) = chip(digits, chip_prefix(reference, &letters)) {
+ return Ok(p);
+ }
+ return Err(format!("chip size {digits} is not in the EIA table"));
+ }
+ if base.ends_with("-SMD") {
+ if let (Some(l), Some(w)) = (dims.l, dims.w) {
+ if let Some(code) = chip_code(l, w) {
+ let letters = base.trim_end_matches("-SMD");
+ if let Some(p) = chip(code, chip_prefix(reference, letters)) {
+ return Ok(p);
+ }
+ }
+ }
+ }
+
+ let mut parts = base.split('-');
+ let family = parts.next().unwrap_or_default();
+ let size = parts.next().unwrap_or_default();
+ let count: Option = parts.next().and_then(|v| v.parse().ok());
+ let pins_in_name: Option = size.parse().ok();
+
+ match family {
+ "SOT" | "SC" => sot(size, count, &dims),
+ "SOD" => sod(size),
+ "SMA" | "SMB" | "SMC" | "DO" => do214(family, size, reference),
+ "SO" | "SOIC" | "SOP" | "TSSOP" | "MSOP" | "SSOP" | "TSOP" | "HTSSOP" | "ESOP" | "HSOP"
+ | "VSSOP" | "QSOP" => {
+ let n = pins_in_name.ok_or("no pin count")?;
+ let pitch = dims.p.ok_or("no pitch in the package name")?;
+ let span_nom = dims.ls.ok_or("no lead span in the package name")?;
+ let (mut body_l, mut body_w) = (
+ dims.l.ok_or("no body length")?,
+ dims.w.ok_or("no body width")?,
+ );
+ if n % 2 != 0 {
+ return Err(format!("{n} pins cannot sit in two rows"));
+ }
+ let exposed_pad = match (
+ dims.ep,
+ dims.has_ep || family.starts_with('H') || family == "ESOP",
+ ) {
+ (Some(ep), _) => Some(ep),
+ (None, true) => return Err("exposed pad size missing from the package name".into()),
+ (None, false) => None,
+ };
+ let soic = (pitch - 1.27).abs() < 0.01 && family != "SSOP";
+ // One footprint per JEDEC package: EasyEDA's body sizes vary a little
+ // from part to part (4.9 x 3.9, 5.0 x 4.0 for the same SOIC-8).
+ let span_nom = if soic {
+ if body_w < 5.0 {
+ body_w = 3.90;
+ body_l = match n {
+ 8 => 4.90,
+ 14 => 8.65,
+ 16 => 9.90,
+ _ => body_l,
+ };
+ 6.00
+ } else {
+ body_w = 7.50;
+ body_l = match n {
+ 16 => 10.30,
+ 18 => 11.55,
+ 20 => 12.80,
+ 24 => 15.40,
+ 28 => 17.90,
+ _ => body_l,
+ };
+ 10.30
+ }
+ } else {
+ span_nom
+ };
+ let height = dims.h.unwrap_or(match family {
+ "TSSOP" | "TSOP" => 1.20,
+ "MSOP" => 1.10,
+ "SSOP" => 2.00,
+ _ if body_w > 5.0 => 2.65,
+ _ => 1.75,
+ });
+ Ok(Package {
+ prefix: if soic { "SOIC" } else { "SOP" },
+ layout: dual(n, pitch),
+ pitch,
+ span: Dim::around(span_nom, 0.20),
+ terminal: if soic {
+ Dim::new(0.40, 1.27)
+ } else {
+ Dim::new(0.45, 0.75)
+ },
+ width: if matches!(family, "TSSOP" | "TSOP") {
+ Dim::new(0.19, 0.30)
+ } else {
+ gull_lead_width(pitch)
+ },
+ body: (body_w, body_l),
+ height,
+ tab: None,
+ exposed_pad,
+ goals: gull_goals(pitch),
+ polarized: false,
+ source: "package name with JEDEC lead tolerances",
+ })
+ }
+ "QFP" | "LQFP" | "TQFP" => {
+ let n = pins_in_name.ok_or("no pin count")?;
+ let pitch = dims.p.ok_or("no pitch in the package name")?;
+ let span_nom = dims.ls.ok_or("no lead span in the package name")?;
+ let (l, w) = (
+ dims.l.ok_or("no body length")?,
+ dims.w.ok_or("no body width")?,
+ );
+ if (l - w).abs() > 0.01 || n % 4 != 0 {
+ return Err("rectangular QFPs are not calculated yet".into());
+ }
+ Ok(Package {
+ prefix: "QFP",
+ layout: Layout::Quad { per_side: n / 4 },
+ pitch,
+ span: Dim::around(span_nom, 0.20),
+ terminal: Dim::new(0.45, 0.75),
+ width: gull_lead_width(pitch),
+ body: (w, l),
+ height: dims.h.unwrap_or(match family {
+ "TQFP" => 1.20,
+ "LQFP" => 1.60,
+ _ => 2.45,
+ }),
+ tab: None,
+ exposed_pad: None,
+ goals: gull_goals(pitch),
+ polarized: false,
+ source: "package name with JEDEC lead tolerances",
+ })
+ }
+ "QFN" | "VQFN" | "WQFN" | "UQFN" | "TQFN" | "XQFN" | "HVQFN" | "LQFN" | "UFQFPN"
+ | "VFQFPN" | "WFQFN" | "DFN" | "SON" | "WSON" | "VSON" | "UDFN" | "WDFN" | "TDFN"
+ | "VDFN" | "XDFN" => {
+ let n = pins_in_name.ok_or("no pin count")?;
+ let pitch = dims.p.ok_or("no pitch in the package name")?;
+ let (l, w) = (
+ dims.l.ok_or("no body length")?,
+ dims.w.ok_or("no body width")?,
+ );
+ // UFQFPN / VFQFPN are ST's names for quad flat no-lead packages.
+ let quad = family.ends_with("QFN") || family.ends_with("QFPN");
+ let height = dims.h.unwrap_or(match family.chars().next() {
+ Some('W') => 0.80,
+ Some('U') => 0.60,
+ Some('X') => 0.40,
+ _ => 1.00,
+ });
+ let ep = match (dims.ep, dims.has_ep) {
+ (Some(ep), _) => Some(ep),
+ (None, true) => return Err("exposed pad size missing from the package name".into()),
+ (None, false) => None,
+ };
+ if quad {
+ if (l - w).abs() > 0.01 || n % 4 != 0 {
+ return Err("rectangular QFNs are not calculated yet".into());
+ }
+ Ok(Package {
+ prefix: "QFN",
+ layout: Layout::Quad { per_side: n / 4 },
+ pitch,
+ span: Dim::around(l, 0.10),
+ terminal: Dim::new(0.30, 0.50),
+ width: no_lead_width(pitch),
+ body: (w, l),
+ height,
+ tab: None,
+ exposed_pad: ep,
+ goals: NO_LEAD,
+ polarized: false,
+ source: "package name with JEDEC MO-220 tolerances",
+ })
+ } else {
+ if n % 2 != 0 {
+ return Err(format!("{n} pins cannot sit in two rows"));
+ }
+ // Terminals along the longer side; the span is the shorter side.
+ let (along, across) = if l >= w { (l, w) } else { (w, l) };
+ Ok(Package {
+ prefix: "SON",
+ layout: dual(n, pitch),
+ pitch,
+ span: Dim::around(across, 0.10),
+ terminal: Dim::new(0.30, 0.50),
+ width: no_lead_width(pitch),
+ body: (across, along),
+ height,
+ tab: None,
+ exposed_pad: ep,
+ goals: NO_LEAD,
+ polarized: false,
+ source: "package name with JEDEC MO-229 tolerances",
+ })
+ }
+ }
+ _ => Err(format!(
+ "{family} packages are not in the IPC-7352 calculator"
+ )),
+ }
+}
+
+/// SOT-23 (3/5/6 pins) and SC-70 / SOT-323 / SOT-353 / SOT-363 (JEDEC TO-236,
+/// MO-178, MO-203).
+fn sot(size: &str, count: Option, dims: &NameDims) -> Result {
+ if size == "223" && matches!(count, None | Some(3) | Some(4)) {
+ // JEDEC TO-261AA: three leads and the tab opposite.
+ return Ok(Package {
+ prefix: "SOT",
+ layout: Layout::Dual {
+ left: vec![2.30, 0.0, -2.30],
+ right: vec![0.0],
+ },
+ pitch: 2.30,
+ span: Dim::new(6.70, 7.30),
+ terminal: Dim::new(0.75, 1.10),
+ width: Dim::new(0.66, 0.84),
+ tab: Some(Dim::new(2.90, 3.18)),
+ body: (3.50, 6.50),
+ height: 1.80,
+ exposed_pad: None,
+ goals: GULL_WING,
+ polarized: false,
+ source: "JEDEC TO-261AA dimensions",
+ });
+ }
+ let (pins, small) = match (size, count) {
+ ("23", None) | ("23", Some(3)) => (3, false),
+ ("23", Some(n)) if n == 5 || n == 6 => (n, false),
+ ("323", None) | ("70", Some(3)) => (3, true),
+ ("353", None) | ("70", Some(5)) => (5, true),
+ ("363", None) | ("70", Some(6)) => (6, true),
+ _ => return Err(format!("SOT-{size} is not in the IPC-7352 calculator")),
+ };
+ let pitch = if small { 0.65 } else { 0.95 };
+ let p = pitch;
+ let layout = match pins {
+ 3 => Layout::Dual {
+ left: vec![p, -p],
+ right: vec![0.0],
+ },
+ 5 => Layout::Dual {
+ left: vec![p, 0.0, -p],
+ right: vec![-p, p],
+ },
+ _ => Layout::Dual {
+ left: vec![p, 0.0, -p],
+ right: vec![-p, 0.0, p],
+ },
+ };
+ // Three-lead SOT-23 comes narrow (JEDEC TO-236, 1.3 mm body, 2.4 mm span) and
+ // wide (MO-178, the SOT-23-5 body: 1.6 mm, 2.8 mm span); the package name says
+ // which.
+ let wide = dims.ls.is_some_and(|ls| ls >= 2.7) || dims.w.is_some_and(|w| w >= 1.5);
+ let (span, terminal, width, body, height) = match (small, pins) {
+ (false, 3) if !wide => (
+ Dim::new(2.10, 2.64),
+ Dim::new(0.30, 0.55),
+ Dim::new(0.30, 0.50),
+ (1.30, 2.92),
+ 1.12,
+ ),
+ (false, _) => (
+ Dim::new(2.60, 3.00),
+ Dim::new(0.30, 0.60),
+ Dim::new(0.30, 0.50),
+ (1.60, 2.90),
+ 1.45,
+ ),
+ (true, _) => (
+ Dim::new(2.00, 2.20),
+ Dim::new(0.26, 0.46),
+ Dim::new(0.15, 0.30),
+ (1.25, 2.00),
+ 1.10,
+ ),
+ };
+ Ok(Package {
+ prefix: "SOT",
+ layout,
+ pitch,
+ span,
+ terminal,
+ width,
+ body,
+ height,
+ tab: None,
+ exposed_pad: None,
+ goals: gull_goals(pitch),
+ polarized: false,
+ source: "JEDEC TO-236 / MO-178 / MO-203 dimensions",
+ })
+}
+
+/// DO-214 molded diodes: SMA (DO-214AC), SMB (DO-214AA), SMC (DO-214AB).
+fn do214(family: &str, size: &str, reference: &str) -> Result {
+ let kind = match (family, size) {
+ ("SMA", _) | ("DO", "214AC") => "SMA",
+ ("SMB", _) | ("DO", "214AA") => "SMB",
+ ("SMC", _) | ("DO", "214AB") => "SMC",
+ _ => {
+ return Err(format!("{family}-{size} is not in the IPC-7352 calculator"));
+ }
+ };
+ let _ = reference;
+ let (span, width, body, height) = match kind {
+ "SMA" => (
+ Dim::new(4.90, 5.50),
+ Dim::new(1.27, 1.63),
+ (4.30, 2.60),
+ 2.44,
+ ),
+ "SMB" => (
+ Dim::new(5.10, 5.70),
+ Dim::new(1.96, 2.21),
+ (4.30, 3.60),
+ 2.44,
+ ),
+ _ => (
+ Dim::new(7.70, 8.10),
+ Dim::new(2.90, 3.20),
+ (6.85, 5.90),
+ 2.62,
+ ),
+ };
+ Ok(Package {
+ prefix: "DIOM",
+ layout: Layout::TwoTerminal,
+ pitch: 0.0,
+ span,
+ terminal: Dim::new(0.76, 1.52),
+ width,
+ tab: None,
+ body,
+ height,
+ exposed_pad: None,
+ goals: MOLDED,
+ polarized: true,
+ source: "JEDEC DO-214 dimensions",
+ })
+}
+
+/// SOD-123, SOD-323 (gull-wing) and SOD-523, SOD-923 (flat lead).
+fn sod(size: &str) -> Result {
+ let (span, terminal, width, body, height, flat) = match size {
+ "123" => (
+ Dim::new(3.55, 3.85),
+ Dim::new(0.25, 0.55),
+ Dim::new(0.45, 0.65),
+ (2.70, 1.60),
+ 1.35,
+ false,
+ ),
+ "323" => (
+ Dim::new(2.30, 2.70),
+ Dim::new(0.20, 0.40),
+ Dim::new(0.25, 0.40),
+ (1.70, 1.25),
+ 1.10,
+ false,
+ ),
+ // onsemi case 502 / NXP SOD523: lead length 0.15-0.25 mm.
+ "523" => (
+ Dim::new(1.50, 1.70),
+ Dim::new(0.15, 0.25),
+ Dim::new(0.25, 0.35),
+ (1.20, 0.80),
+ 0.70,
+ true,
+ ),
+ "923" => (
+ Dim::new(0.95, 1.05),
+ Dim::new(0.15, 0.25),
+ Dim::new(0.15, 0.25),
+ (0.80, 0.60),
+ 0.45,
+ true,
+ ),
+ _ => return Err(format!("SOD-{size} is not in the IPC-7352 calculator")),
+ };
+ Ok(Package {
+ prefix: if flat { "SODFL" } else { "SOD" },
+ layout: Layout::TwoTerminal,
+ pitch: 0.0,
+ span,
+ terminal,
+ width,
+ body,
+ height,
+ tab: None,
+ exposed_pad: None,
+ goals: if flat { FLAT_LEAD } else { GULL_WING },
+ polarized: true,
+ source: "JEDEC/JEITA SOD dimensions",
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Geometry
+
+/// One calculated land (mm, Y up, zero orientation).
+#[derive(Debug, Clone, PartialEq)]
+pub struct Land {
+ pub x: f64,
+ pub y: f64,
+ pub w: f64,
+ pub h: f64,
+}
+
+impl Land {
+ fn left(&self) -> f64 {
+ self.x - self.w / 2.0
+ }
+ fn right(&self) -> f64 {
+ self.x + self.w / 2.0
+ }
+ fn bottom(&self) -> f64 {
+ self.y - self.h / 2.0
+ }
+ fn top(&self) -> f64 {
+ self.y + self.h / 2.0
+ }
+}
+
+/// The lands in standard numbering order (pin 1 first, exposed pad last).
+pub fn layout_lands(package: &Package) -> (Vec, Lands) {
+ let mut row = lands(
+ package.span,
+ package.terminal,
+ package.width,
+ &package.goals,
+ );
+ // Neighbouring lands keep a copper gap.
+ if package.pitch > 0.0 {
+ let max_x = round_down(package.pitch - 0.1, 0.01);
+ row.x = row.x.min(max_x);
+ }
+ // Pull the heel back from an exposed pad.
+ if let Some((ex, ey)) = package.exposed_pad {
+ let min_g = ex.max(ey) + 2.0 * EP_TO_PAD;
+ if row.g < min_g {
+ row.g = round_up(min_g, 0.01);
+ }
+ }
+ let (len, c) = (round2(row.length()), round2(row.centre()));
+ let tab_x = package
+ .tab
+ .map(|tab| lands(package.span, package.terminal, tab, &package.goals).x)
+ .unwrap_or(row.x);
+ let mut out = Vec::new();
+ match &package.layout {
+ Layout::TwoTerminal => {
+ out.push(Land {
+ x: -c,
+ y: 0.0,
+ w: len,
+ h: row.x,
+ });
+ out.push(Land {
+ x: c,
+ y: 0.0,
+ w: len,
+ h: row.x,
+ });
+ }
+ Layout::Dual { left, right } => {
+ for &y in left {
+ out.push(Land {
+ x: -c,
+ y,
+ w: len,
+ h: row.x,
+ });
+ }
+ for &y in right {
+ out.push(Land {
+ x: c,
+ y,
+ w: len,
+ h: tab_x,
+ });
+ }
+ }
+ Layout::Quad { per_side } => {
+ let col = column(*per_side, package.pitch);
+ for &y in &col {
+ out.push(Land {
+ x: -c,
+ y,
+ w: len,
+ h: row.x,
+ });
+ }
+ for &y in &col {
+ out.push(Land {
+ x: -y,
+ y: -c,
+ w: row.x,
+ h: len,
+ });
+ }
+ for &y in &col {
+ out.push(Land {
+ x: c,
+ y: -y,
+ w: len,
+ h: row.x,
+ });
+ }
+ for &y in &col {
+ out.push(Land {
+ x: y,
+ y: c,
+ w: row.x,
+ h: len,
+ });
+ }
+ }
+ }
+ if let Some((ex, ey)) = package.exposed_pad {
+ out.push(Land {
+ x: 0.0,
+ y: 0.0,
+ w: ex,
+ h: ey,
+ });
+ }
+ (out, row)
+}
+
+/// A finished IPC-7352 land pattern.
+#[derive(Debug, Clone)]
+pub struct LandPattern {
+ pub name: String,
+ /// Footprint in the converter's units (EasyEDA px, Y down), origin at the
+ /// package centre.
+ pub footprint: KiFootprint,
+ /// Turn (degrees, counter-clockwise) and shift (mm) from EasyEDA's footprint to
+ /// this one: `ipc = R(turn) * easyeda + shift`.
+ pub turn: f64,
+ pub shift: (f64, f64),
+ pub notes: Vec,
+}
+
+/// Calculate the IPC-7352 land pattern for `package_name` and line it up with
+/// EasyEDA's footprint `easyeda` to carry its pin numbers over. `first_pin` is the
+/// pad that goes where IPC puts pin 1 (the cathode of diodes and LEDs).
+pub fn land_pattern(
+ package_name: &str,
+ reference: &str,
+ easyeda: &KiFootprint,
+ first_pin: &str,
+) -> Result {
+ let package = package_from_name(package_name, reference)?;
+ let (lands, row) = layout_lands(&package);
+ if lands.iter().any(|l| l.w <= 0.0 || l.h <= 0.0) {
+ return Err("the calculated lands have no size".into());
+ }
+ if row.g <= 0.0 {
+ return Err("the calculated lands overlap".into());
+ }
+
+ let theirs = easyeda_pads(easyeda)?;
+ let ours: Vec<(f64, f64)> = lands.iter().map(|l| (l.x, l.y)).collect();
+ let alignment = align(&ours, &theirs, first_pin)?;
+ let mut notes = Vec::new();
+ if !alignment.pin1_ipc {
+ notes.push(format!(
+ "IPC-7352: EasyEDA's pin {first_pin} is not at an IPC pin-1 position for this package; numbering kept from EasyEDA"
+ ));
+ }
+
+ notes.extend(check_fit(
+ package_name,
+ &package,
+ &lands,
+ easyeda,
+ &alignment,
+ )?);
+
+ let name = package.ipc_name();
+ // Silkscreen: the simplified IPC-7352 drawing of the body (see
+ // `build_footprint`), not EasyEDA's, whose outlines are drawn round its own
+ // lands and come out up to twice the size of the part.
+ let footprint = build_footprint(&name, &package, &lands, &alignment.numbers);
+ Ok(LandPattern {
+ name,
+ footprint,
+ turn: alignment.turn,
+ shift: alignment.shift,
+ notes,
+ })
+}
+
+/// How far the calculated pattern may stray from the part EasyEDA describes.
+/// A pad centre may sit this far (mm) from the manufacturer's...
+const FIT_CENTRE: f64 = 0.35;
+/// ...or this fraction of the manufacturer's land length, whichever is larger.
+const FIT_CENTRE_RATIO: f64 = 0.45;
+/// Lands must overlap the manufacturer's by at least this fraction of the smaller.
+const FIT_OVERLAP: f64 = 0.35;
+/// A land may not be narrower (across its pin) than this fraction of the
+/// manufacturer's: narrower means the calculation assumed a thinner lead.
+const FIT_WIDTH: f64 = 0.7;
+
+/// Check that the calculated pattern is the part's real package: the package
+/// name's own dimensions (body, lead span) must agree with the dimensions the
+/// calculation used, and every land must sit on the manufacturer's land for the
+/// same pin. A part whose package differs from the JEDEC/EIA one assumed (a
+/// non-standard lead span, a wider body, a different pad layout) is refused, and
+/// the manufacturer's lands are used instead.
+fn check_fit(
+ package_name: &str,
+ package: &Package,
+ lands: &[Land],
+ easyeda: &KiFootprint,
+ alignment: &Alignment,
+) -> Result, String> {
+ let upper = package_name.trim().to_ascii_uppercase();
+ let rest = upper.split_once('_').map(|(_, r)| r).unwrap_or("");
+ let dims = name_dims(rest);
+ if let Some(ls) = dims.ls {
+ let (lo, hi) = (package.span.min - 0.15, package.span.max + 0.15);
+ if ls < lo || ls > hi {
+ return Err(format!(
+ "the package name gives a {ls:.2} mm lead span, outside the {:.2}-{:.2} mm of the JEDEC/EIA outline the calculation uses",
+ package.span.min, package.span.max
+ ));
+ }
+ }
+ if let (Some(l), Some(w)) = (dims.l, dims.w) {
+ let named = (l.min(w), l.max(w));
+ let used = (
+ package.body.0.min(package.body.1),
+ package.body.0.max(package.body.1),
+ );
+ if (named.0 - used.0).abs() > 0.3 || (named.1 - used.1).abs() > 0.3 {
+ return Err(format!(
+ "the package name gives a {:.2} x {:.2} mm body, the JEDEC/EIA outline {:.2} x {:.2} mm",
+ named.1, named.0, used.1, used.0
+ ));
+ }
+ }
+
+ // Manufacturer's lands in the calculated pattern's frame.
+ let q = (alignment.turn / 90.0).round() as usize % 4;
+ let mut theirs: Vec<(String, [f64; 4])> = Vec::new();
+ for pad in &easyeda.pads {
+ let number = pad.number.trim();
+ if number.is_empty() || matches!(pad.pad_type, PadType::NpThroughHole) {
+ continue;
+ }
+ let (w, h) = (pad.size_x * PX, pad.size_y * PX);
+ let (sin, cos) = pad.rotation.to_radians().sin_cos();
+ let (hw, hh) = (
+ (w * cos.abs() + h * sin.abs()) / 2.0,
+ (w * sin.abs() + h * cos.abs()) / 2.0,
+ );
+ let (x, y) = turn_point(q, pad.pos_x * PX, -pad.pos_y * PX);
+ let (x, y) = (x + alignment.shift.0, y + alignment.shift.1);
+ let (hw, hh) = if q % 2 == 1 { (hh, hw) } else { (hw, hh) };
+ let rect = [x - hw, y - hh, x + hw, y + hh];
+ match theirs.iter_mut().find(|t| t.0 == number) {
+ Some(t) => {
+ t.1 = [
+ t.1[0].min(rect[0]),
+ t.1[1].min(rect[1]),
+ t.1[2].max(rect[2]),
+ t.1[3].max(rect[3]),
+ ];
+ }
+ None => theirs.push((number.to_string(), rect)),
+ }
+ }
+ let mut worst: Option<(f64, String)> = None;
+ for (land, number) in lands.iter().zip(&alignment.numbers) {
+ let Some((_, r)) = theirs.iter().find(|t| &t.0 == number) else {
+ continue;
+ };
+ let ours = [land.left(), land.bottom(), land.right(), land.top()];
+ let centre = |b: &[f64; 4]| ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
+ let (a, b) = (centre(&ours), centre(r));
+ let distance = (a.0 - b.0).hypot(a.1 - b.1);
+ let area = |b: &[f64; 4]| ((b[2] - b[0]) * (b[3] - b[1])).max(1e-9);
+ let inter = ((ours[2].min(r[2]) - ours[0].max(r[0])).max(0.0))
+ * ((ours[3].min(r[3]) - ours[1].max(r[1])).max(0.0));
+ let overlap = inter / area(&ours).min(area(r));
+ let their_length = (r[2] - r[0]).max(r[3] - r[1]);
+ let allowed = FIT_CENTRE.max(FIT_CENTRE_RATIO * their_length);
+ // Across the pin: perpendicular to the direction from the package centre
+ // (none for a centre pad).
+ let centre_pad = land.x.abs() < 1e-6 && land.y.abs() < 1e-6;
+ let left_or_right = match package.layout {
+ Layout::Quad { .. } => land.x.abs() >= land.y.abs(),
+ _ => true,
+ };
+ let across = if centre_pad {
+ None
+ } else if left_or_right {
+ Some((land.h, r[3] - r[1]))
+ } else {
+ Some((land.w, r[2] - r[0]))
+ };
+ // Where IPC-7352 asks for a side fillet (side goal >= 0), and for every
+ // two-terminal part but the molded-body ones, the land is at least as wide
+ // as the widest lead.
+ let full_width = package.goals.side >= 0.0
+ || (matches!(package.layout, Layout::TwoTerminal) && package.goals != MOLDED);
+ if let (Some((ours, _)), true) = (across, full_width) {
+ if ours + 1e-9 < package.width.max {
+ return Err(format!(
+ "pad {number} would be {ours:.2} mm wide, narrower than the {:.2} mm lead",
+ package.width.max
+ ));
+ }
+ }
+ let problem = if distance > allowed {
+ Some(format!(
+ "pad {number} sits {distance:.2} mm from the manufacturer's land (allowed {allowed:.2} mm)"
+ ))
+ } else if overlap < FIT_OVERLAP {
+ Some(format!(
+ "pad {number} overlaps the manufacturer's land by only {:.0}%",
+ overlap * 100.0
+ ))
+ } else if let Some((ours, theirs)) = across.filter(|(o, t)| *o < FIT_WIDTH * t) {
+ Some(format!(
+ "pad {number} is {ours:.2} mm wide, the manufacturer's {theirs:.2} mm"
+ ))
+ } else {
+ None
+ };
+ if let Some(problem) = problem {
+ return Err(format!(
+ "{problem}: the part's package does not match the JEDEC/EIA outline the calculation assumes"
+ ));
+ }
+ let score = distance / allowed;
+ if worst.as_ref().is_none_or(|w| score > w.0) {
+ let width = across
+ .map(|(o, t)| format!(", width {o:.2} mm vs {t:.2} mm"))
+ .unwrap_or_default();
+ worst = Some((
+ score,
+ format!("{distance:.2} mm, {:.0}% overlap{width}", overlap * 100.0),
+ ));
+ }
+ }
+ Ok(worst
+ .map(|(_, text)| {
+ vec![format!(
+ "IPC-7352 lands checked against the manufacturer's: largest pad offset {text}"
+ )]
+ })
+ .unwrap_or_default())
+}
+
+/// EasyEDA's numbered SMD pads (mm, Y up); pads sharing a number become one.
+fn easyeda_pads(footprint: &KiFootprint) -> Result, String> {
+ let mut groups: Vec<(String, f64, f64, f64, f64)> = Vec::new();
+ for pad in &footprint.pads {
+ match pad.pad_type {
+ PadType::NpThroughHole => continue,
+ PadType::ThroughHole => {
+ if pad.number.trim().is_empty() {
+ continue;
+ }
+ return Err("the part has through-hole pins (IPC-7251, not calculated)".into());
+ }
+ _ => {}
+ }
+ let number = pad.number.trim();
+ if number.is_empty() {
+ continue;
+ }
+ let (x, y) = (pad.pos_x * PX, -pad.pos_y * PX);
+ match groups.iter_mut().find(|g| g.0 == number) {
+ Some(g) => {
+ g.1 = g.1.min(x);
+ g.2 = g.2.min(y);
+ g.3 = g.3.max(x);
+ g.4 = g.4.max(y);
+ }
+ None => groups.push((number.to_string(), x, y, x, y)),
+ }
+ }
+ Ok(groups
+ .into_iter()
+ .map(|(n, x0, y0, x1, y1)| (n, (x0 + x1) / 2.0, (y0 + y1) / 2.0))
+ .collect())
+}
+
+struct Alignment {
+ numbers: Vec,
+ turn: f64,
+ shift: (f64, f64),
+ pin1_ipc: bool,
+}
+
+fn bbox_centre(points: impl Iterator- ) -> (f64, f64) {
+ let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
+ for (x, y) in points {
+ x0 = x0.min(x);
+ y0 = y0.min(y);
+ x1 = x1.max(x);
+ y1 = y1.max(y);
+ }
+ ((x0 + x1) / 2.0, (y0 + y1) / 2.0)
+}
+
+fn turn_point(q: usize, x: f64, y: f64) -> (f64, f64) {
+ match q % 4 {
+ 0 => (x, y),
+ 1 => (-y, x),
+ 2 => (-x, -y),
+ _ => (y, -x),
+ }
+}
+
+/// IPC-7352 polarity marking on the silkscreen: a bar beside the cathode of a
+/// polarized two-terminal part, a 0.3 mm dot beside pin 1 of anything else
+/// with more pins.
+fn polarity_mark(package: &Package, lands: &[Land]) -> (Vec, Vec) {
+ let to_px = |mm: f64| mm / PX;
+ let (mut lines, mut circles) = (Vec::new(), Vec::new());
+ let Some(first) = lands.first() else {
+ return (lines, circles);
+ };
+ let gap = SILK_TO_COPPER + SILK_WIDTH / 2.0;
+ if package.layout == Layout::TwoTerminal {
+ if package.polarized {
+ let x = first.left() - gap;
+ lines.push(KiLine {
+ start_x: to_px(x),
+ start_y: to_px(-first.bottom()),
+ end_x: to_px(x),
+ end_y: to_px(-first.top()),
+ width: to_px(SILK_WIDTH),
+ layer: "F.SilkS".into(),
+ });
+ }
+ } else {
+ let r = 0.15;
+ let (cx, cy) = (first.left() - SILK_TO_COPPER - r, first.y);
+ circles.push(KiCircle {
+ center_x: to_px(cx),
+ center_y: to_px(-cy),
+ end_x: to_px(cx + r),
+ end_y: to_px(-cy),
+ // The converter stores circle widths in mm.
+ width: SILK_WIDTH,
+ layer: "F.SilkS".into(),
+ fill: true,
+ });
+ }
+ (lines, circles)
+}
+
+/// Find the quarter turn that puts EasyEDA's pads on the calculated ones.
+fn align(
+ ours: &[(f64, f64)],
+ theirs: &[(String, f64, f64)],
+ first: &str,
+) -> Result {
+ if ours.len() != theirs.len() {
+ return Err(format!(
+ "EasyEDA's footprint has {} numbered pads, the IPC-7352 pattern {}",
+ theirs.len(),
+ ours.len()
+ ));
+ }
+ let c_ours = bbox_centre(ours.iter().copied());
+ let c_theirs = bbox_centre(theirs.iter().map(|p| (p.1, p.2)));
+ let mut fits: Vec = Vec::new();
+ for q in 0..4 {
+ let (tcx, tcy) = turn_point(q, c_theirs.0, c_theirs.1);
+ let shift = (c_ours.0 - tcx, c_ours.1 - tcy);
+ let mut numbers = vec![String::new(); ours.len()];
+ let mut ok = true;
+ for (number, x, y) in theirs {
+ let (rx, ry) = turn_point(q, *x, *y);
+ let (px, py) = (rx + shift.0, ry + shift.1);
+ let nearest = ours
+ .iter()
+ .enumerate()
+ .map(|(i, o)| (i, ((o.0 - px).powi(2) + (o.1 - py).powi(2)).sqrt()))
+ .min_by(|a, b| a.1.total_cmp(&b.1));
+ match nearest {
+ Some((i, d)) if d <= MATCH_TOLERANCE && numbers[i].is_empty() => {
+ numbers[i] = number.clone();
+ }
+ _ => {
+ ok = false;
+ break;
+ }
+ }
+ }
+ if ok {
+ fits.push(Alignment {
+ pin1_ipc: numbers.first().is_some_and(|n| n == first),
+ numbers,
+ turn: q as f64 * 90.0,
+ shift: (round4(shift.0), round4(shift.1)),
+ });
+ }
+ }
+ if fits.is_empty() {
+ return Err(
+ "EasyEDA's pads do not line up with the IPC-7352 pattern (different package?)".into(),
+ );
+ }
+ let best = fits.iter().position(|f| f.pin1_ipc).unwrap_or(0);
+ Ok(fits.swap_remove(best))
+}
+
+fn round4(v: f64) -> f64 {
+ (v * 10_000.0).round() / 10_000.0 + 0.0
+}
+
+/// Lands, silkscreen, assembly outline and courtyard as a converter footprint.
+fn build_footprint(
+ name: &str,
+ package: &Package,
+ lands: &[Land],
+ numbers: &[String],
+) -> KiFootprint {
+ let to_px = |mm: f64| mm / PX;
+ let mut fp = KiFootprint {
+ name: name.to_string(),
+ pads: Vec::new(),
+ tracks: Vec::new(),
+ circles: Vec::new(),
+ arcs: Vec::new(),
+ texts: Vec::new(),
+ lines: Vec::new(),
+ model_3d: None,
+ };
+ for (land, number) in lands.iter().zip(numbers) {
+ fp.pads.push(KiPad {
+ number: number.clone(),
+ pad_type: PadType::Smd,
+ shape: PadShape::Rect,
+ pos_x: to_px(land.x),
+ pos_y: to_px(-land.y),
+ size_x: to_px(land.w),
+ size_y: to_px(land.h),
+ rotation: 0.0,
+ layers: vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()],
+ drill: None,
+ polygon: None,
+ });
+ }
+ let mut lines: Vec = Vec::new();
+ let mut line = |layer: &str, width: f64, a: (f64, f64), b: (f64, f64)| {
+ lines.push(KiLine {
+ start_x: to_px(a.0),
+ start_y: to_px(-a.1),
+ end_x: to_px(b.0),
+ end_y: to_px(-b.1),
+ width: to_px(width),
+ layer: layer.to_string(),
+ });
+ };
+
+ let (bx, by) = (package.body.0 / 2.0, package.body.1 / 2.0);
+ let two_terminal = package.layout == Layout::TwoTerminal;
+
+ // Assembly: the body, pin 1 corner chamfered; a cathode band on diodes.
+ if two_terminal {
+ rect_lines(&mut line, "F.Fab", ASSEMBLY_WIDTH, -bx, -by, bx, by);
+ if package.polarized {
+ let band = -bx + package.body.0 * 0.2;
+ line("F.Fab", ASSEMBLY_WIDTH, (band, -by), (band, by));
+ }
+ } else {
+ let chamfer = (package.body.0.min(package.body.1) * 0.25).min(1.0);
+ let corners = [
+ (-bx + chamfer, by),
+ (bx, by),
+ (bx, -by),
+ (-bx, -by),
+ (-bx, by - chamfer),
+ (-bx + chamfer, by),
+ ];
+ for pair in corners.windows(2) {
+ line("F.Fab", ASSEMBLY_WIDTH, pair[0], pair[1]);
+ }
+ }
+
+ // Silkscreen: the body outline, kept clear of the copper.
+ let keep_out: Vec<(f64, f64, f64, f64)> = lands
+ .iter()
+ .map(|l| {
+ let m = SILK_TO_COPPER + SILK_WIDTH / 2.0;
+ (l.left() - m, l.bottom() - m, l.right() + m, l.top() + m)
+ })
+ .collect();
+ let sx = bx + SILK_WIDTH / 2.0;
+ let sy = by + SILK_WIDTH / 2.0;
+ let outline = [
+ ((-sx, sy), (sx, sy)),
+ ((sx, sy), (sx, -sy)),
+ ((sx, -sy), (-sx, -sy)),
+ ((-sx, -sy), (-sx, sy)),
+ ];
+ let mut outline_drawn = false;
+ for (a, b) in outline {
+ for (p, q) in clip_segment(a, b, &keep_out) {
+ line("F.SilkS", SILK_WIDTH, p, q);
+ outline_drawn = true;
+ }
+ }
+ // Small chips (0402, 0603, ...) leave no room for the body outline between
+ // the lands. The part still gets a mark on the board: the body's two long
+ // sides, as long as the body, moved out just far enough to clear the copper.
+ let (lx0, mut ly0, lx1, mut ly1) = (-bx, -sy, bx, sy);
+ if !outline_drawn {
+ for l in lands {
+ ly0 = ly0.min(l.bottom() - SILK_TO_COPPER - SILK_WIDTH / 2.0);
+ ly1 = ly1.max(l.top() + SILK_TO_COPPER + SILK_WIDTH / 2.0);
+ }
+ line("F.SilkS", SILK_WIDTH, (lx0, ly1), (lx1, ly1));
+ line("F.SilkS", SILK_WIDTH, (lx0, ly0), (lx1, ly0));
+ }
+ // Pin 1 mark: a bar beside a diode's cathode, a dot beside pin 1 otherwise.
+ let (mark_lines, mark_circles) = polarity_mark(package, lands);
+ for l in &mark_lines {
+ let (a, b) = (
+ (l.start_x * PX, -l.start_y * PX),
+ (l.end_x * PX, -l.end_y * PX),
+ );
+ line("F.SilkS", SILK_WIDTH, a, b);
+ }
+ fp.circles.extend(mark_circles);
+
+ // Courtyard: lands and body plus the courtyard excess, on the courtyard grid,
+ // and never inside the silkscreen drawn outside the lands.
+ let (mut x0, mut y0, mut x1, mut y1) = (-bx, -by, bx, by);
+ for l in lands {
+ x0 = x0.min(l.left());
+ y0 = y0.min(l.bottom());
+ x1 = x1.max(l.right());
+ y1 = y1.max(l.top());
+ }
+ let e = package.goals.courtyard;
+ let (x0, y0) = (
+ round_down(x0 - e, COURTYARD_GRID),
+ round_down(y0 - e, COURTYARD_GRID),
+ );
+ let (x1, y1) = (
+ round_up(x1 + e, COURTYARD_GRID),
+ round_up(y1 + e, COURTYARD_GRID),
+ );
+ let silk = SILK_WIDTH / 2.0;
+ let (x0, y0) = (
+ x0.min(round_down(lx0 - silk, COURTYARD_GRID)),
+ y0.min(round_down(ly0 - silk, COURTYARD_GRID)),
+ );
+ let (x1, y1) = (
+ x1.max(round_up(lx1 + silk, COURTYARD_GRID)),
+ y1.max(round_up(ly1 + silk, COURTYARD_GRID)),
+ );
+ rect_lines(&mut line, "F.CrtYd", COURTYARD_WIDTH, x0, y0, x1, y1);
+ fp.lines = lines;
+ fp
+}
+
+fn rect_lines(
+ line: &mut impl FnMut(&str, f64, (f64, f64), (f64, f64)),
+ layer: &str,
+ width: f64,
+ x0: f64,
+ y0: f64,
+ x1: f64,
+ y1: f64,
+) {
+ line(layer, width, (x0, y1), (x1, y1));
+ line(layer, width, (x1, y1), (x1, y0));
+ line(layer, width, (x1, y0), (x0, y0));
+ line(layer, width, (x0, y0), (x0, y1));
+}
+
+/// Parts of the axis-aligned segment `a`-`b` outside every keep-out box, dropping
+/// pieces shorter than 0.2 mm.
+fn clip_segment(
+ a: (f64, f64),
+ b: (f64, f64),
+ keep_out: &[(f64, f64, f64, f64)],
+) -> Vec<((f64, f64), (f64, f64))> {
+ let horizontal = (a.1 - b.1).abs() < 1e-9;
+ let (fixed, mut lo, mut hi) = if horizontal {
+ (a.1, a.0.min(b.0), a.0.max(b.0))
+ } else {
+ (a.0, a.1.min(b.1), a.1.max(b.1))
+ };
+ if lo > hi {
+ std::mem::swap(&mut lo, &mut hi);
+ }
+ let mut pieces = vec![(lo, hi)];
+ for &(x0, y0, x1, y1) in keep_out {
+ let (f0, f1, c0, c1) = if horizontal {
+ (y0, y1, x0, x1)
+ } else {
+ (x0, x1, y0, y1)
+ };
+ if fixed <= f0 || fixed >= f1 {
+ continue;
+ }
+ pieces = pieces
+ .into_iter()
+ .flat_map(|(s, e)| {
+ let mut out = Vec::new();
+ if c0 > s {
+ out.push((s, e.min(c0)));
+ }
+ if c1 < e {
+ out.push((s.max(c1), e));
+ }
+ out
+ })
+ .filter(|(s, e)| e > s)
+ .collect();
+ }
+ pieces
+ .into_iter()
+ .filter(|(s, e)| e - s >= 0.2)
+ .map(|(s, e)| {
+ if horizontal {
+ ((s, fixed), (e, fixed))
+ } else {
+ ((fixed, s), (fixed, e))
+ }
+ })
+ .collect()
+}
+
+/// IPC-7352 rules for a land pattern seex cannot calculate (connectors,
+/// through-hole and other non-standard parts): the manufacturer's lands from
+/// EasyEDA stay, and the rest is brought in line: a courtyard of lands and body
+/// plus 0.25 mm on the 0.05 mm grid, 0.12 mm silkscreen kept 0.2 mm off the
+/// copper, an assembly outline, and plated holes with at least a 0.25 mm annular
+/// ring. Returns the footprint and what was changed.
+pub fn conform(easyeda: &KiFootprint) -> (KiFootprint, Vec) {
+ let mut fp = easyeda.clone();
+ let mut changes = Vec::new();
+ let mm = |px: f64| px * PX;
+ let to_px = |v: f64| v / PX;
+
+ // Plated holes: at least a 0.25 mm annular ring, if the neighbours allow it.
+ const RING: f64 = 0.25;
+ let boxes: Vec<(f64, f64, f64, f64)> = fp.pads.iter().map(pad_box).collect();
+ let mut widened = 0;
+ for i in 0..fp.pads.len() {
+ let pad = &fp.pads[i];
+ if pad.pad_type != PadType::ThroughHole {
+ continue;
+ }
+ let Some(drill) = &pad.drill else { continue };
+ // The ring is checked along each axis of the pad, so a slot in an oval
+ // pad keeps the oval: only the side that is too thin grows.
+ let hole_x = mm(drill.diameter);
+ let hole_y = mm(drill.width.unwrap_or(drill.diameter));
+ let need_x = round_up(hole_x + 2.0 * RING, 0.05);
+ let need_y = round_up(hole_y + 2.0 * RING, 0.05);
+ let (w, h) = (mm(pad.size_x), mm(pad.size_y));
+ let grow_x = (need_x - w).max(0.0);
+ let grow_y = (need_y - h).max(0.0);
+ if grow_x < 1e-6 && grow_y < 1e-6 {
+ continue;
+ }
+ // Pad rotated by 90 or 270 degrees: its x side lies along the board's y.
+ let quarter = ((pad.rotation.rem_euclid(180.0)) - 90.0).abs() < 45.0;
+ let (gx, gy) = if quarter {
+ (grow_y, grow_x)
+ } else {
+ (grow_x, grow_y)
+ };
+ let (x0, y0, x1, y1) = boxes[i];
+ let grown = (x0 - gx / 2.0, y0 - gy / 2.0, x1 + gx / 2.0, y1 + gy / 2.0);
+ let clear = boxes
+ .iter()
+ .enumerate()
+ .all(|(j, b)| j == i || gap_boxes(grown, *b) >= 0.2);
+ if !clear {
+ continue;
+ }
+ let pad = &mut fp.pads[i];
+ pad.size_x = to_px(w + grow_x);
+ pad.size_y = to_px(h + grow_y);
+ widened += 1;
+ }
+ if widened > 0 {
+ changes.push(format!(
+ "{widened} plated pad(s) widened to a {RING:.2} mm annular ring"
+ ));
+ }
+
+ // Silkscreen: 0.12 mm, clear of the copper.
+ let keep_out: Vec<(f64, f64, f64, f64)> = fp
+ .pads
+ .iter()
+ .map(|p| {
+ let (x0, y0, x1, y1) = pad_box(p);
+ let m = SILK_TO_COPPER + SILK_WIDTH / 2.0;
+ (x0 - m, y0 - m, x1 + m, y1 + m)
+ })
+ .collect();
+ let mut lines = Vec::new();
+ let mut clipped = 0;
+ let mut outline: Vec<(f64, f64)> = Vec::new();
+ for line in fp.lines.drain(..) {
+ let a = (mm(line.start_x), -mm(line.start_y));
+ let b = (mm(line.end_x), -mm(line.end_y));
+ match line.layer.as_str() {
+ "F.CrtYd" | "B.CrtYd" => {}
+ "F.SilkS" | "B.SilkS" => {
+ outline.extend([a, b]);
+ let pieces = clip_any(a, b, &keep_out);
+ if pieces.len() != 1 || pieces[0] != (a, b) {
+ clipped += 1;
+ }
+ for (p, q) in pieces {
+ lines.push(KiLine {
+ start_x: to_px(p.0),
+ start_y: to_px(-p.1),
+ end_x: to_px(q.0),
+ end_y: to_px(-q.1),
+ width: to_px(SILK_WIDTH),
+ layer: line.layer.clone(),
+ });
+ }
+ }
+ _ => {
+ if line.layer.ends_with(".Fab") {
+ outline.extend([a, b]);
+ }
+ lines.push(line);
+ }
+ }
+ }
+ // Silkscreen arcs become clipped segments too.
+ for arc in std::mem::take(&mut fp.arcs) {
+ if !arc.layer.ends_with("SilkS") {
+ fp.arcs.push(arc);
+ continue;
+ }
+ let points = arc_points(&arc);
+ for pair in points.windows(2) {
+ let (a, b) = (pair[0], pair[1]);
+ outline.extend([a, b]);
+ for (p, q) in clip_any(a, b, &keep_out) {
+ lines.push(KiLine {
+ start_x: to_px(p.0),
+ start_y: to_px(-p.1),
+ end_x: to_px(q.0),
+ end_y: to_px(-q.1),
+ width: to_px(SILK_WIDTH),
+ layer: arc.layer.clone(),
+ });
+ }
+ }
+ clipped += 1;
+ }
+ // Silkscreen circles: kept where they clear the copper. A circle drawn round
+ // a pad (a test point's ring) that the pad reaches into grows just enough to
+ // clear it, so the mark stays the manufacturer's.
+ let copper: Vec<(f64, f64, f64, f64)> = fp.pads.iter().map(pad_box).collect();
+ let mut circles = Vec::new();
+ for mut c in std::mem::take(&mut fp.circles) {
+ if !c.layer.ends_with("SilkS") {
+ circles.push(c);
+ continue;
+ }
+ let (cx, cy) = (mm(c.center_x), -mm(c.center_y));
+ let r = ((mm(c.end_x) - mm(c.center_x)).powi(2) + (mm(c.end_y) - mm(c.center_y)).powi(2))
+ .sqrt();
+ // Circle widths are stored in mm by the converter.
+ let half = if c.fill { 0.0 } else { SILK_WIDTH / 2.0 };
+ let clear = |r: f64| {
+ copper.iter().all(|b| {
+ let near = (cx - cx.clamp(b.0, b.2)).hypot(cy - cy.clamp(b.1, b.3));
+ let far = (cx - b.0)
+ .abs()
+ .max((cx - b.2).abs())
+ .hypot((cy - b.1).abs().max((cy - b.3).abs()));
+ near >= r + half + SILK_TO_COPPER - 1e-9 || r - half >= far + SILK_TO_COPPER - 1e-9
+ })
+ };
+ if clear(r) {
+ if !c.fill {
+ c.width = SILK_WIDTH;
+ }
+ circles.push(c);
+ continue;
+ }
+ clipped += 1;
+ // Rings round the pads under their centre.
+ let around: Vec<&(f64, f64, f64, f64)> = boxes
+ .iter()
+ .filter(|b| cx > b.0 && cx < b.2 && cy > b.1 && cy < b.3)
+ .collect();
+ if c.fill || around.is_empty() {
+ continue;
+ }
+ let needed = around
+ .iter()
+ .map(|b| {
+ (cx - b.0)
+ .abs()
+ .max((cx - b.2).abs())
+ .hypot((cy - b.1).abs().max((cy - b.3).abs()))
+ })
+ .fold(0.0, f64::max)
+ + SILK_TO_COPPER
+ + half;
+ if needed > r * 2.0 || !clear(needed) {
+ continue;
+ }
+ c.end_x = to_px(cx + needed);
+ c.end_y = to_px(-cy);
+ c.width = SILK_WIDTH;
+ circles.push(c);
+ }
+ fp.circles = circles;
+ if clipped > 0 {
+ changes.push(format!(
+ "silkscreen set to {SILK_WIDTH:.2} mm and kept {SILK_TO_COPPER:.2} mm off the copper"
+ ));
+ }
+
+ // Body outline: EasyEDA's assembly or silkscreen drawing; else the lands.
+ let mut bx = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
+ for &(x, y) in &outline {
+ bx = (bx.0.min(x), bx.1.min(y), bx.2.max(x), bx.3.max(y));
+ }
+ let has_assembly = lines.iter().any(|l| l.layer == "F.Fab");
+ if !has_assembly && bx.0 < bx.2 {
+ let corners = [
+ (bx.0, bx.3),
+ (bx.2, bx.3),
+ (bx.2, bx.1),
+ (bx.0, bx.1),
+ (bx.0, bx.3),
+ ];
+ for pair in corners.windows(2) {
+ lines.push(KiLine {
+ start_x: to_px(pair[0].0),
+ start_y: to_px(-pair[0].1),
+ end_x: to_px(pair[1].0),
+ end_y: to_px(-pair[1].1),
+ width: to_px(ASSEMBLY_WIDTH),
+ layer: "F.Fab".into(),
+ });
+ }
+ changes.push("assembly outline added".into());
+ }
+
+ // A footprint without any silkscreen (test points, pads-only parts) gets an
+ // outline around its lands and body, clear of the copper, so the part is
+ // marked on the board.
+ let has_silk = lines.iter().any(|l| l.layer.ends_with("SilkS"))
+ || fp.circles.iter().any(|c| c.layer.ends_with("SilkS"));
+ let mut cy = bx;
+ if !has_silk && !keep_out.is_empty() {
+ let mut sb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
+ for b in keep_out.iter().copied() {
+ sb = (sb.0.min(b.0), sb.1.min(b.1), sb.2.max(b.2), sb.3.max(b.3));
+ }
+ if bx.0 < bx.2 {
+ let m = SILK_WIDTH / 2.0;
+ sb = (
+ sb.0.min(bx.0 - m),
+ sb.1.min(bx.1 - m),
+ sb.2.max(bx.2 + m),
+ sb.3.max(bx.3 + m),
+ );
+ }
+ // Just outside the keep-out boxes, which count their own edges as inside.
+ let sb = (sb.0 - 1e-6, sb.1 - 1e-6, sb.2 + 1e-6, sb.3 + 1e-6);
+ let corners = [
+ (sb.0, sb.3),
+ (sb.2, sb.3),
+ (sb.2, sb.1),
+ (sb.0, sb.1),
+ (sb.0, sb.3),
+ ];
+ for pair in corners.windows(2) {
+ for (p, q) in clip_any(pair[0], pair[1], &keep_out) {
+ lines.push(KiLine {
+ start_x: to_px(p.0),
+ start_y: to_px(-p.1),
+ end_x: to_px(q.0),
+ end_y: to_px(-q.1),
+ width: to_px(SILK_WIDTH),
+ layer: "F.SilkS".into(),
+ });
+ }
+ }
+ let m = SILK_WIDTH / 2.0;
+ cy = (sb.0 - m, sb.1 - m, sb.2 + m, sb.3 + m);
+ if bx.0 < bx.2 {
+ cy = (
+ cy.0.min(bx.0),
+ cy.1.min(bx.1),
+ cy.2.max(bx.2),
+ cy.3.max(bx.3),
+ );
+ }
+ changes.push("silkscreen outline added around the lands".into());
+ }
+
+ // Courtyard: lands and body plus the courtyard excess, on the 0.05 mm grid,
+ // and round the silkscreen circles.
+ for b in &boxes {
+ cy = (cy.0.min(b.0), cy.1.min(b.1), cy.2.max(b.2), cy.3.max(b.3));
+ }
+ for c in fp.circles.iter().filter(|c| c.layer.ends_with("SilkS")) {
+ let (x, y) = (mm(c.center_x), -mm(c.center_y));
+ let r = (mm(c.end_x) - mm(c.center_x)).hypot(mm(c.end_y) - mm(c.center_y)) + c.width / 2.0;
+ cy = (
+ cy.0.min(x - r),
+ cy.1.min(y - r),
+ cy.2.max(x + r),
+ cy.3.max(y + r),
+ );
+ }
+ if cy.0 < cy.2 {
+ let e = GULL_WING.courtyard;
+ let (x0, y0) = (
+ round_down(cy.0 - e, COURTYARD_GRID),
+ round_down(cy.1 - e, COURTYARD_GRID),
+ );
+ let (x1, y1) = (
+ round_up(cy.2 + e, COURTYARD_GRID),
+ round_up(cy.3 + e, COURTYARD_GRID),
+ );
+ let corners = [(x0, y1), (x1, y1), (x1, y0), (x0, y0), (x0, y1)];
+ for pair in corners.windows(2) {
+ lines.push(KiLine {
+ start_x: to_px(pair[0].0),
+ start_y: to_px(-pair[0].1),
+ end_x: to_px(pair[1].0),
+ end_y: to_px(-pair[1].1),
+ width: to_px(COURTYARD_WIDTH),
+ layer: "F.CrtYd".into(),
+ });
+ }
+ changes.push("courtyard: lands and body + 0.25 mm on the 0.05 mm grid".into());
+ }
+ fp.lines = lines;
+ (fp, changes)
+}
+
+/// Copper extent of a pad (mm, Y up), any rotation.
+fn pad_box(pad: &KiPad) -> (f64, f64, f64, f64) {
+ let (x, y) = (pad.pos_x * PX, -pad.pos_y * PX);
+ let (w, h) = (pad.size_x * PX / 2.0, pad.size_y * PX / 2.0);
+ let (s, c) = pad.rotation.to_radians().sin_cos();
+ let (ex, ey) = ((w * c).abs() + (h * s).abs(), (w * s).abs() + (h * c).abs());
+ let mut b = (x - ex, y - ey, x + ex, y + ey);
+ if let Some(points) = pad.polygon.as_deref() {
+ for (px, py) in parse_points(points) {
+ b = (
+ b.0.min(x + px),
+ b.1.min(y + py),
+ b.2.max(x + px),
+ b.3.max(y + py),
+ );
+ }
+ }
+ b
+}
+
+/// Pad-relative polygon points (mm, Y up) from KiCad's `(xy x y)` list.
+fn parse_points(text: &str) -> Vec<(f64, f64)> {
+ let mut out = Vec::new();
+ for chunk in text.split("(xy").skip(1) {
+ let nums: Vec = chunk
+ .split(|c: char| c.is_whitespace() || c == ')')
+ .filter_map(|t| t.parse().ok())
+ .collect();
+ if let [x, y, ..] = nums.as_slice() {
+ out.push((*x, -*y));
+ }
+ }
+ out
+}
+
+fn gap_boxes(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)) -> f64 {
+ let dx = (a.0 - b.2).max(b.0 - a.2).max(0.0);
+ let dy = (a.1 - b.3).max(b.1 - a.3).max(0.0);
+ (dx * dx + dy * dy).sqrt()
+}
+
+/// Points along a footprint arc (mm, Y up).
+fn arc_points(arc: &crate::kicad::FootprintKiArc) -> Vec<(f64, f64)> {
+ let p = |x: f64, y: f64| (x * PX, -y * PX);
+ let (a, m, b) = (
+ p(arc.start_x, arc.start_y),
+ p(arc.mid_x, arc.mid_y),
+ p(arc.end_x, arc.end_y),
+ );
+ let d = 2.0 * (a.0 * (m.1 - b.1) + m.0 * (b.1 - a.1) + b.0 * (a.1 - m.1));
+ if d.abs() < 1e-12 {
+ return vec![a, m, b];
+ }
+ let sq = |q: (f64, f64)| q.0 * q.0 + q.1 * q.1;
+ let ux = (sq(a) * (m.1 - b.1) + sq(m) * (b.1 - a.1) + sq(b) * (a.1 - m.1)) / d;
+ let uy = (sq(a) * (b.0 - m.0) + sq(m) * (a.0 - b.0) + sq(b) * (m.0 - a.0)) / d;
+ let r = ((a.0 - ux).powi(2) + (a.1 - uy).powi(2)).sqrt();
+ let ang = |q: (f64, f64)| (q.1 - uy).atan2(q.0 - ux);
+ let (a0, am, a1) = (ang(a), ang(m), ang(b));
+ let tau = std::f64::consts::TAU;
+ let ccw = (am - a0).rem_euclid(tau) < (a1 - a0).rem_euclid(tau);
+ let sweep = if ccw {
+ (a1 - a0).rem_euclid(tau)
+ } else {
+ -(a0 - a1).rem_euclid(tau)
+ };
+ (0..=16)
+ .map(|i| {
+ let t = a0 + sweep * i as f64 / 16.0;
+ (ux + r * t.cos(), uy + r * t.sin())
+ })
+ .collect()
+}
+
+/// Parts of the segment `a`-`b` outside every box (any direction), dropping
+/// pieces shorter than 0.1 mm.
+fn clip_any(
+ a: (f64, f64),
+ b: (f64, f64),
+ keep_out: &[(f64, f64, f64, f64)],
+) -> Vec<((f64, f64), (f64, f64))> {
+ let mut pieces = vec![(0.0f64, 1.0f64)];
+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);
+ for &(x0, y0, x1, y1) in keep_out {
+ // Liang-Barsky: the parameter interval of the segment inside the box.
+ let mut t0: f64 = 0.0;
+ let mut t1: f64 = 1.0;
+ let mut inside = true;
+ for (p, q) in [
+ (-dx, a.0 - x0),
+ (dx, x1 - a.0),
+ (-dy, a.1 - y0),
+ (dy, y1 - a.1),
+ ] {
+ if p.abs() < 1e-12 {
+ if q < 0.0 {
+ inside = false;
+ break;
+ }
+ } else {
+ let r = q / p;
+ if p < 0.0 {
+ t0 = t0.max(r);
+ } else {
+ t1 = t1.min(r);
+ }
+ }
+ }
+ if !inside || t0 >= t1 {
+ continue;
+ }
+ pieces = pieces
+ .into_iter()
+ .flat_map(|(s, e)| {
+ let mut out = Vec::new();
+ if t0 > s {
+ out.push((s, e.min(t0)));
+ }
+ if t1 < e {
+ out.push((s.max(t1), e));
+ }
+ out
+ })
+ .filter(|(s, e)| e > s)
+ .collect();
+ }
+ let len = (dx * dx + dy * dy).sqrt();
+ pieces
+ .into_iter()
+ .filter(|(s, e)| (e - s) * len >= 0.1)
+ .map(|(s, e)| ((a.0 + dx * s, a.1 + dy * s), (a.0 + dx * e, a.1 + dy * e)))
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn pad(number: &str, x_mm: f64, y_mm: f64) -> KiPad {
+ KiPad {
+ number: number.into(),
+ pad_type: PadType::Smd,
+ shape: PadShape::Rect,
+ pos_x: x_mm / PX,
+ pos_y: -y_mm / PX,
+ size_x: 0.5 / PX,
+ size_y: 0.5 / PX,
+ rotation: 0.0,
+ layers: vec!["F.Cu".into()],
+ drill: None,
+ polygon: None,
+ }
+ }
+
+ fn footprint(pads: Vec) -> KiFootprint {
+ KiFootprint {
+ name: "t".into(),
+ pads,
+ tracks: vec![],
+ circles: vec![],
+ arcs: vec![],
+ texts: vec![],
+ lines: vec![],
+ model_3d: None,
+ }
+ }
+
+ #[test]
+ fn names_follow_the_ipc_convention() {
+ let name = |pkg: &str, r: &str| package_from_name(pkg, r).unwrap().ipc_name();
+ assert_eq!(name("R0402", "R"), "RESC1005X40N");
+ assert_eq!(name("C0603", "C"), "CAPC1608X90N");
+ assert_eq!(name("LED0603-RD", "LED"), "LEDC1608X80N");
+ assert_eq!(name("LED-SMD_L1.6-W0.8-R-RD", "LED"), "LEDC1608X80N");
+ assert_eq!(
+ name("SOT-23-5_L2.9-W1.6-P0.95-LS2.8-BR", "U"),
+ "SOT95P280X145-5N"
+ );
+ // Narrow (TO-236) and wide (MO-178) three-lead SOT-23.
+ assert_eq!(
+ name("SOT-23-3_L2.9-W1.3-P1.90-LS2.4-BR", "Q"),
+ "SOT95P237X112-3N"
+ );
+ assert_eq!(
+ name("SOT-23-3_L2.9-W1.6-P1.90-LS2.8-BR", "U"),
+ "SOT95P280X145-3N"
+ );
+ assert_eq!(
+ name("SOT-23-3_L2.9-W1.3-P1.90-LS2.4-BR", "Q"),
+ "SOT95P237X112-3N"
+ );
+ assert_eq!(
+ name("SOT-323_L2.0-W1.3-P1.30-LS2.1-BR", "Q"),
+ "SOT65P210X110-3N"
+ );
+ assert_eq!(name("SOD-323_L1.8-W1.3-LS2.5-RD", "D"), "SOD2512X110N");
+ assert_eq!(name("SOD-523_L1.2-W0.8-LS1.6-RD", "D"), "SODFL1608X70N");
+ // SOD-523 flat leads: Z 2.15 as in NXP's footprint, lands 0.55 x 0.40.
+ let p = package_from_name("SOD-523_L1.2-W0.8-LS1.6-RD", "D").unwrap();
+ let l = lands(p.span, p.terminal, p.width, &p.goals);
+ assert_eq!((round2(l.z), round2(l.g), round2(l.x)), (2.15, 1.05, 0.40));
+ assert_eq!(
+ name("SOIC-8_L4.9-W3.9-P1.27-LS6.0-BL", "U"),
+ "SOIC127P600X175-8N"
+ );
+ assert_eq!(
+ name("TSSOP-20_L6.5-W4.4-P0.65-LS6.4-BL", "U"),
+ "SOP65P640X120-20N"
+ );
+ assert_eq!(
+ name("SOT-223-3_L6.5-W3.4-P2.30-LS7.0-BR", "U"),
+ "SOT230P700X180-4N"
+ );
+ // EasyEDA's SOIC-8 variants share one pattern.
+ assert_eq!(
+ name("SOP-8_L5.0-W4.0-P1.27-LS6.0-BL", "U"),
+ "SOIC127P600X175-8N"
+ );
+ assert_eq!(
+ name("SO-8_L4.9-W3.9-P1.27-LS6.0-BL", "U"),
+ "SOIC127P600X175-8N"
+ );
+ assert_eq!(
+ package_from_name("SOP-8_L5.0-W4.0-P1.27-LS6.0-BL", "U").unwrap(),
+ package_from_name("SOIC-8_L4.9-W3.9-P1.27-LS6.1-BL", "U").unwrap()
+ );
+ assert_eq!(name("SMA_L4.3-W2.6-LS5.2-RD", "D"), "DIOM5226X244N");
+ assert_eq!(name("SMC_L6.9-W5.9-LS7.9-BI", "D"), "DIOM7959X262N");
+ assert_eq!(
+ name("ESOP-8_L4.9-W3.9-P1.27-LS6.0-BL-EP3.3X2.4", "U"),
+ "SOIC127P600X175-9N"
+ );
+ assert_eq!(
+ name("LQFP-48_L7.0-W7.0-P0.50-LS9.0-BL", "U"),
+ "QFP50P900X900X160-48N"
+ );
+ assert_eq!(
+ name("QFN-56_L7.0-W7.0-P0.40-BL-EP4.0", "U"),
+ "QFN40P700X700X100-57N"
+ );
+ // ST's UFQFPN is a QFN.
+ assert_eq!(
+ name("UFQFPN-28_L4.0-W4.0-P0.50-BL", "U"),
+ "QFN50P400X400X60-28N"
+ );
+ assert!(package_from_name("USB-C-SMD_TYPE-C-31-M-12", "USBC").is_err());
+ }
+
+ #[test]
+ fn chip_lands_follow_the_equations() {
+ let p = package_from_name("R0603", "R").unwrap();
+ let row = lands(p.span, p.terminal, p.width, &p.goals);
+ // Zmax = 1.50 + 0.70 + sqrt(0.2^2 + 0.05^2 + 0.025^2) = 2.408 -> 2.45
+ assert!((row.z - 2.45).abs() < 1e-9, "{row:?}");
+ // Xmax = 0.70 + sqrt(0.2^2 + 0.05^2 + 0.025^2) = 0.908 -> 0.95
+ assert!((row.x - 0.95).abs() < 1e-9, "{row:?}");
+ assert!(row.g > 0.0 && row.g < 1.0, "{row:?}");
+ let small = package_from_name("R0402", "R").unwrap();
+ assert_eq!(small.goals, CHIP_SMALL);
+ }
+
+ #[test]
+ fn sot23_5_is_turned_to_pin_one_upper_left() {
+ // EasyEDA draws SOT-23-5 with pins 1-3 along the bottom.
+ let easy = footprint(vec![
+ pad("1", -0.95, -1.15),
+ pad("2", 0.0, -1.15),
+ pad("3", 0.95, -1.15),
+ pad("4", 0.95, 1.15),
+ pad("5", -0.95, 1.15),
+ ]);
+ let lp = land_pattern("SOT-23-5_L2.9-W1.6-P0.95-LS2.8-BR", "U", &easy, "1").unwrap();
+ assert_eq!(lp.name, "SOT95P280X145-5N");
+ let first = &lp.footprint.pads[0];
+ assert_eq!(first.number, "1");
+ assert!(
+ first.pos_x < 0.0 && first.pos_y < 0.0,
+ "pin 1 upper left (Y down)"
+ );
+ let numbers: Vec<&str> = lp
+ .footprint
+ .pads
+ .iter()
+ .map(|p| p.number.as_str())
+ .collect();
+ assert_eq!(numbers, ["1", "2", "3", "4", "5"]);
+ assert!(
+ lp.notes
+ .iter()
+ .all(|n| n.starts_with("IPC-7352 lands checked")),
+ "{:?}",
+ lp.notes
+ );
+ // EasyEDA's pin 1 (bottom left) turns to the upper left: a quarter turn clockwise.
+ assert_eq!(lp.turn, 270.0);
+ assert!(lp.footprint.lines.iter().any(|l| l.layer == "F.CrtYd"));
+ }
+
+ #[test]
+ fn led_cathode_goes_left() {
+ // Pin 2 is the cathode and EasyEDA has it on the left already.
+ let easy = footprint(vec![pad("1", 0.8, 0.0), pad("2", -0.8, 0.0)]);
+ let lp = land_pattern("LED0603-RD", "LED", &easy, "2").unwrap();
+ assert_eq!(lp.footprint.pads[0].number, "2");
+ assert!(lp.footprint.pads[0].pos_x < 0.0);
+ assert_eq!(lp.turn, 0.0);
+ }
+
+ #[test]
+ fn small_chips_are_marked_on_the_silkscreen() {
+ // 0402 and 0603 leave no room between the lands: a line above and one
+ // below the lands, clear of the copper and inside the courtyard.
+ for (package, x, half_body) in [("R0402", 0.48, 0.5), ("C0603", 0.78, 0.8)] {
+ let easy = footprint(vec![pad("1", -x, 0.0), pad("2", x, 0.0)]);
+ let reference = &package[..1];
+ let lp = land_pattern(package, reference, &easy, "1").unwrap();
+ let silk: Vec<&KiLine> = lp
+ .footprint
+ .lines
+ .iter()
+ .filter(|l| l.layer == "F.SilkS")
+ .collect();
+ assert_eq!(silk.len(), 2, "{package}");
+ // As long as the body, not the land pattern.
+ for l in &silk {
+ assert!((l.start_x * PX).abs() <= half_body + 1e-9, "{package}");
+ assert!((l.end_x * PX).abs() <= half_body + 1e-9, "{package}");
+ }
+ let lands: Vec<(f64, f64, f64, f64)> = lp.footprint.pads.iter().map(pad_box).collect();
+ let top = lands.iter().map(|b| b.3).fold(f64::MIN, f64::max);
+ for l in &silk {
+ let y = (-l.start_y * PX).abs();
+ assert!(
+ y - SILK_WIDTH / 2.0 >= top + SILK_TO_COPPER - 1e-9,
+ "{package}: {y}"
+ );
+ }
+ let courtyard = lp
+ .footprint
+ .lines
+ .iter()
+ .filter(|l| l.layer == "F.CrtYd")
+ .map(|l| (-l.start_y * PX).abs())
+ .fold(f64::MIN, f64::max);
+ let silk_top = (-silk[0].start_y * PX).abs() + SILK_WIDTH / 2.0;
+ assert!(courtyard >= silk_top - 1e-9, "{package}");
+ }
+ }
+
+ #[test]
+ fn test_point_ring_grows_round_its_pad() {
+ // C2826547: a 2.2 mm square pad with a silkscreen ring inside its corners.
+ let mut lug = pad("1", 0.0, 0.0);
+ lug.size_x = 2.2 / PX;
+ lug.size_y = 2.2 / PX;
+ let mut easy = footprint(vec![lug]);
+ easy.circles.push(KiCircle {
+ center_x: 0.0,
+ center_y: 0.0,
+ end_x: 1.3 / PX,
+ end_y: 0.0,
+ width: 0.15,
+ layer: "F.SilkS".into(),
+ fill: false,
+ });
+ let (fp, _) = conform(&easy);
+ let rings: Vec<&KiCircle> = fp.circles.iter().filter(|c| c.layer == "F.SilkS").collect();
+ assert_eq!(rings.len(), 1);
+ let r = rings[0].end_x * PX;
+ assert!(
+ (r - (1.1f64.hypot(1.1) + SILK_TO_COPPER + SILK_WIDTH / 2.0)).abs() < 1e-6,
+ "{r}"
+ );
+ // The ring is the mark: no outline is added round the pad.
+ assert!(!fp.lines.iter().any(|l| l.layer == "F.SilkS"));
+ }
+
+ #[test]
+ fn pads_only_footprints_get_a_silkscreen_outline() {
+ let mut lug = pad("1", 0.0, 0.0);
+ lug.size_x = 3.0 / PX;
+ lug.size_y = 1.8 / PX;
+ let (fp, changes) = conform(&footprint(vec![lug]));
+ assert!(
+ changes.iter().any(|c| c.contains("silkscreen outline")),
+ "{changes:?}"
+ );
+ let silk: Vec<&KiLine> = fp.lines.iter().filter(|l| l.layer == "F.SilkS").collect();
+ assert_eq!(silk.len(), 4);
+ // 1.5 mm half width + 0.20 mm clearance + half the 0.12 mm line.
+ let x = silk
+ .iter()
+ .map(|l| (l.start_x * PX).abs())
+ .fold(f64::MIN, f64::max);
+ assert!((x - 1.76).abs() < 1e-3, "{x}");
+ }
+
+ #[test]
+ fn qfn_exposed_pad_keeps_its_number_and_clearance() {
+ let p = package_from_name("QFN-16_L3.0-W3.0-P0.50-BL-EP1.7", "U").unwrap();
+ let (lands, row) = layout_lands(&p);
+ assert_eq!(lands.len(), 17);
+ assert!(row.g / 2.0 >= 1.7 / 2.0 + EP_TO_PAD - 1e-9);
+ let mut pads: Vec = lands[..16]
+ .iter()
+ .enumerate()
+ .map(|(i, l)| {
+ let mut p = pad(&(i + 1).to_string(), l.x, l.y);
+ p.size_x = l.w / PX;
+ p.size_y = l.h / PX;
+ p
+ })
+ .collect();
+ // EasyEDA's exposed pad split into four paste windows with one number.
+ for (dx, dy) in [(-0.4, -0.4), (0.4, -0.4), (-0.4, 0.4), (0.4, 0.4)] {
+ pads.push(pad("17", dx, dy));
+ }
+ let lp = land_pattern(
+ "QFN-16_L3.0-W3.0-P0.50-BL-EP1.7",
+ "U",
+ &footprint(pads),
+ "1",
+ )
+ .unwrap();
+ assert_eq!(lp.footprint.pads.len(), 17);
+ assert_eq!(lp.footprint.pads[16].number, "17");
+ assert_eq!(lp.name, "QFN50P300X300X100-17N");
+ }
+
+ #[test]
+ fn two_terminal_lands_cover_the_lead() {
+ // Every two-terminal family but the molded bodies gets lands at least as
+ // wide as the widest lead (the SOD-523 lands once came out narrower).
+ for (name, r) in [
+ ("R0201", "R"),
+ ("R0402", "R"),
+ ("R0603", "R"),
+ ("R0805", "R"),
+ ("R1206", "R"),
+ ("C0402", "C"),
+ ("C0603", "C"),
+ ("LED0603-RD", "LED"),
+ ("SOD-123", "D"),
+ ("SOD-323", "D"),
+ ("SOD-523", "D"),
+ ("SOD-923", "D"),
+ ] {
+ let p = package_from_name(name, r).unwrap();
+ let row = lands(p.span, p.terminal, p.width, &p.goals);
+ assert!(
+ row.x + 1e-9 >= p.width.max,
+ "{name}: {row:?} for lead {:?}",
+ p.width
+ );
+ }
+ }
+
+ #[test]
+ fn parts_off_the_jedec_outline_are_refused() {
+ // A SOD-523 named with a 2.2 mm lead span is not the JEDEC part.
+ let easy = footprint(vec![pad("1", -0.8, 0.0), pad("2", 0.8, 0.0)]);
+ let err = land_pattern("SOD-523_L1.2-W0.8-LS2.2-RD", "D", &easy, "1").unwrap_err();
+ assert!(err.contains("lead span"), "{err}");
+ // Manufacturer lands far outside the calculated ones: a different package.
+ let wide = footprint(vec![pad("1", -1.3, 0.0), pad("2", 1.3, 0.0)]);
+ let err = land_pattern("SOD-523_L1.2-W0.8-LS1.6-RD", "D", &wide, "1").unwrap_err();
+ assert!(err.contains("manufacturer's land"), "{err}");
+ // Manufacturer's lands much wider than the calculated ones: the part's leads
+ // are wider than the outline assumes.
+ let mut fat = footprint(vec![pad("1", -0.8, 0.0), pad("2", 0.8, 0.0)]);
+ for p in &mut fat.pads {
+ p.size_y = 0.9 / PX;
+ }
+ let err = land_pattern("SOD-523_L1.2-W0.8-LS1.6-RD", "D", &fat, "1").unwrap_err();
+ assert!(err.contains("wide"), "{err}");
+ // The goals used before for SOD-523 (QFN, side -0.04 mm) gave lands narrower
+ // than the lead: refused.
+ let mut p = package_from_name("SOD-523_L1.2-W0.8-LS1.6-RD", "D").unwrap();
+ p.goals = NO_LEAD;
+ let row = lands(p.span, p.terminal, p.width, &p.goals);
+ assert!(row.x < p.width.max, "{row:?}");
+ // The real part: lands on the manufacturer's.
+ let lp = land_pattern("SOD-523_L1.2-W0.8-LS1.6-RD", "D", &easy, "1").unwrap();
+ assert_eq!(lp.name, "SODFL1608X70N");
+ }
+
+ #[test]
+ fn mismatched_footprints_are_refused() {
+ let easy = footprint(vec![
+ pad("1", -1.0, 0.0),
+ pad("2", 1.0, 0.0),
+ pad("3", 0.0, 1.0),
+ ]);
+ assert!(land_pattern("R0603", "R", &easy, "1").is_err());
+ }
+
+ #[test]
+ fn conform_adds_courtyard_and_clears_silkscreen() {
+ let mut through = pad("1", -2.54, 0.0);
+ through.pad_type = PadType::ThroughHole;
+ through.size_x = 1.0 / PX;
+ through.size_y = 1.0 / PX;
+ through.drill = Some(crate::kicad::Drill {
+ diameter: 0.8 / PX,
+ width: None,
+ offset_x: 0.0,
+ offset_y: 0.0,
+ });
+ let mut easy = footprint(vec![through, pad("2", 2.54, 0.0)]);
+ easy.lines.push(KiLine {
+ start_x: -4.0 / PX,
+ start_y: 0.0,
+ end_x: 4.0 / PX,
+ end_y: 0.0,
+ width: 0.25 / PX,
+ layer: "F.SilkS".into(),
+ });
+ let (fp, changes) = conform(&easy);
+ assert!(changes.iter().any(|c| c.contains("annular")), "{changes:?}");
+ assert!((fp.pads[0].size_x * PX - 1.30).abs() < 1e-9);
+ let silk: Vec<&KiLine> = fp.lines.iter().filter(|l| l.layer == "F.SilkS").collect();
+ assert_eq!(silk.len(), 3, "split around both pads");
+ assert!(
+ silk.iter()
+ .all(|l| (l.width * PX - SILK_WIDTH).abs() < 1e-9)
+ );
+ assert_eq!(fp.lines.iter().filter(|l| l.layer == "F.CrtYd").count(), 4);
+ assert_eq!(fp.lines.iter().filter(|l| l.layer == "F.Fab").count(), 4);
+ // Diagonal silkscreen is clipped too.
+ assert_eq!(
+ clip_any((-1.0, -1.0), (1.0, 1.0), &[(-0.5, -0.5, 0.5, 0.5)]).len(),
+ 2
+ );
+ }
+
+ #[test]
+ fn slotted_oval_pads_stay_oval() {
+ // USB-C shell lug: 1.0 x 2.1 mm oval pad, 0.6 x 1.7 mm slot.
+ let lug = |rotation: f64| {
+ let mut p = pad("1", 0.0, 0.0);
+ p.pad_type = PadType::ThroughHole;
+ p.shape = PadShape::Oval;
+ p.size_x = 1.0 / PX;
+ p.size_y = 2.1 / PX;
+ p.rotation = rotation;
+ p.drill = Some(crate::kicad::Drill {
+ diameter: 0.6 / PX,
+ width: Some(1.7 / PX),
+ offset_x: 0.0,
+ offset_y: 0.0,
+ });
+ p
+ };
+ for rotation in [0.0, 90.0] {
+ let (fp, changes) = conform(&footprint(vec![lug(rotation)]));
+ assert!(changes.iter().any(|c| c.contains("annular")), "{changes:?}");
+ assert!(
+ (fp.pads[0].size_x * PX - 1.10).abs() < 1e-9,
+ "{}",
+ fp.pads[0].size_x * PX
+ );
+ assert!(
+ (fp.pads[0].size_y * PX - 2.20).abs() < 1e-9,
+ "{}",
+ fp.pads[0].size_y * PX
+ );
+ assert_eq!(fp.pads[0].shape, PadShape::Oval);
+ }
+ // A slot that already has its ring is left alone.
+ let mut ok = lug(0.0);
+ ok.size_x = 1.2 / PX;
+ ok.size_y = 2.3 / PX;
+ let (fp, _) = conform(&footprint(vec![ok]));
+ assert!(
+ (fp.pads[0].size_x * PX - 1.2).abs() < 1e-9
+ && (fp.pads[0].size_y * PX - 2.3).abs() < 1e-9
+ );
+ }
+
+ #[test]
+ fn silkscreen_stays_off_the_copper() {
+ let keep = [(-1.0, -0.5, -0.4, 0.5), (0.4, -0.5, 1.0, 0.5)];
+ let pieces = clip_segment((-1.5, 0.3), (1.5, 0.3), &keep);
+ assert_eq!(pieces.len(), 3);
+ assert!(pieces.iter().all(|((a, _), (b, _))| b - a >= 0.2));
+ assert!(clip_segment((-0.9, 0.0), (-0.5, 0.0), &keep).is_empty());
+ }
+}
diff --git a/src/cadence/mod.rs b/src/cadence/mod.rs
new file mode 100644
index 0000000..6fca886
--- /dev/null
+++ b/src/cadence/mod.rs
@@ -0,0 +1,30 @@
+//! Cadence export: OrCAD Capture symbols and Allegro footprints, written as files the
+//! user imports into Cadence, the way the KiCad and Altium targets write their
+//! libraries.
+//!
+//! Capture's `.olb` and Allegro's `.dra` / `.psm` / `.pad` are undocumented binary
+//! formats that only Cadence's own programs write, so SeEx writes the formats Cadence
+//! reads in their place:
+//!
+//! * symbols as one Capture **Library XML** file, which Capture turns into an `.olb`
+//! with `File > Import > Library XML` (see [`capture`]);
+//! * footprints as Allegro **SKILL** scripts, one per footprint plus a library script
+//! that builds them all into `.dra`, `.psm` and `.pad` files (see [`allegro`]);
+//! * 3D models as STEP files, which the footprints map.
+//!
+//! Lands follow IPC-7352 where SeEx can calculate them (see [`ipc7352`]); other
+//! footprints keep the manufacturer's lands with IPC-7352 courtyard and silkscreen
+//! rules.
+
+pub mod allegro;
+pub mod capture;
+pub mod ipc7352;
+pub mod step_place;
+pub mod workflow;
+
+#[cfg(test)]
+mod tests;
+
+pub use workflow::{
+ CadenceBatchOptions, CadenceBatchSummary, CadenceExportOptions, export_cadence_batch,
+};
diff --git a/src/cadence/step_place.rs b/src/cadence/step_place.rs
new file mode 100644
index 0000000..6932d03
--- /dev/null
+++ b/src/cadence/step_place.rs
@@ -0,0 +1,301 @@
+//! Placing EasyEDA STEP models on Allegro footprints.
+//!
+//! EasyEDA keeps a model in its own coordinates and records where it sits on the
+//! footprint: the model's centre (`c_origin`), its height above the board (`z`) and a
+//! rotation (`c_rotation`). EasyEDA centres the model's outline on `c_origin` and
+//! rests its bottom on the board, so the Allegro mapping is: rotate by `c_rotation`,
+//! then shift the rotated centre onto `c_origin` and the bottom onto `z`.
+
+use crate::cadence::allegro::StepTransform;
+use crate::easyeda::{ComponentData, ModelPlacement};
+use crate::kicad::KiFootprint;
+
+/// EasyEDA canvas unit (10 mil) in millimetres.
+const PX_MM: f64 = 0.254;
+
+/// Bounding box (min, max) of a STEP model's vertices, in the file's units (mm for
+/// EasyEDA models). Falls back to all points when the file has no vertex list.
+pub fn step_bounds(data: &[u8]) -> Option<([f64; 3], [f64; 3])> {
+ let text = String::from_utf8_lossy(data);
+ let mut points: std::collections::HashMap<&str, [f64; 3]> = std::collections::HashMap::new();
+ let mut vertices: Vec<&str> = Vec::new();
+ for statement in text.split(';') {
+ let statement = statement.trim();
+ let Some(rest) = statement.strip_prefix('#') else {
+ continue;
+ };
+ let Some((id, body)) = rest.split_once('=') else {
+ continue;
+ };
+ let id = id.trim();
+ let body = body.trim();
+ if let Some(args) = body.strip_prefix("CARTESIAN_POINT") {
+ if let Some(point) = parse_point(args) {
+ points.insert(id, point);
+ }
+ } else if let Some(args) = body.strip_prefix("VERTEX_POINT") {
+ if let Some(reference) = args.rsplit('#').next() {
+ let reference = reference.trim_end_matches(|c: char| c == ')' || c.is_whitespace());
+ vertices.push(reference);
+ }
+ }
+ }
+ let selected: Vec<[f64; 3]> = if vertices.is_empty() {
+ points.values().copied().collect()
+ } else {
+ vertices
+ .iter()
+ .filter_map(|id| points.get(id).copied())
+ .collect()
+ };
+ if selected.is_empty() {
+ return None;
+ }
+ let mut min = [f64::INFINITY; 3];
+ let mut max = [f64::NEG_INFINITY; 3];
+ for point in selected {
+ for axis in 0..3 {
+ min[axis] = min[axis].min(point[axis]);
+ max[axis] = max[axis].max(point[axis]);
+ }
+ }
+ Some((min, max))
+}
+
+fn parse_point(args: &str) -> Option<[f64; 3]> {
+ let start = args.rfind('(')?;
+ let end = args[start..].find(')')? + start;
+ let values: Vec = args[start + 1..end]
+ .split(',')
+ .filter_map(|value| value.trim().parse::().ok())
+ .collect();
+ match values.as_slice() {
+ [x, y, z] => Some([*x, *y, *z]),
+ [x, y] => Some([*x, *y, 0.0]),
+ _ => None,
+ }
+}
+
+/// Allegro STEP mapping for an EasyEDA model on the footprint whose origin is
+/// `package_origin` (EasyEDA canvas units).
+pub fn allegro_transform(
+ placement: Option<&ModelPlacement>,
+ package_origin: (f64, f64),
+ bounds: Option<([f64; 3], [f64; 3])>,
+) -> StepTransform {
+ let (target_x, target_y, bottom, rotation) = match placement {
+ Some(p) => (
+ (p.origin_x - package_origin.0) * PX_MM,
+ // EasyEDA's y axis points down, Allegro's up.
+ -(p.origin_y - package_origin.1) * PX_MM,
+ p.z * PX_MM,
+ p.rotation.map(normalize_degrees),
+ ),
+ None => (0.0, 0.0, 0.0, [0.0; 3]),
+ };
+ let offset = match bounds {
+ // A model tilted about x or y: keep EasyEDA's numbers, the bounding box of
+ // the unrotated model says nothing useful about the result.
+ Some((min, max)) if rotation[0] == 0.0 && rotation[1] == 0.0 => {
+ let (cx, cy) = ((min[0] + max[0]) / 2.0, (min[1] + max[1]) / 2.0);
+ let (sin, cos) = rotation[2].to_radians().sin_cos();
+ let (rx, ry) = (cx * cos - cy * sin, cx * sin + cy * cos);
+ [target_x - rx, target_y - ry, bottom - min[2]]
+ }
+ _ => [target_x, target_y, bottom],
+ };
+ StepTransform {
+ rotation,
+ offset: offset.map(|v| round4(v) + 0.0),
+ }
+}
+
+/// Where EasyEDA puts the model centre on the footprint (mm, Allegro axes).
+pub fn model_centre_mm(placement: &ModelPlacement, package_origin: (f64, f64)) -> (f64, f64) {
+ (
+ (placement.origin_x - package_origin.0) * PX_MM,
+ -(placement.origin_y - package_origin.1) * PX_MM,
+ )
+}
+
+fn normalize_degrees(angle: f64) -> f64 {
+ let a = angle.rem_euclid(360.0);
+ if (a - 360.0).abs() < 1e-9 {
+ 0.0
+ } else {
+ round4(a)
+ }
+}
+
+fn round4(value: f64) -> f64 {
+ (value * 10_000.0).round() / 10_000.0
+}
+
+/// Allegro mapping of EasyEDA's model on the exported footprint: EasyEDA's
+/// placement on its own footprint, turned and shifted with the footprint when it
+/// was redrawn as an IPC-7352 land pattern (`ipc`: turn in degrees and shift in mm,
+/// `ipc = R(turn) * easyeda + shift`). `data` is the model the export writes.
+pub fn model_on_footprint(
+ component: &ComponentData,
+ easyeda_footprint: &KiFootprint,
+ ipc: Option<(f64, (f64, f64))>,
+ data: Option<&[u8]>,
+ notes: &mut Vec,
+) -> StepTransform {
+ let Some((turn, (sx, sy))) = ipc else {
+ return easyeda_model_transform(component, easyeda_footprint, data, notes);
+ };
+ // The calculated pattern is centred on the package, so the model's outline is
+ // centred on the origin; EasyEDA only decides how it is turned and its height.
+ // EasyEDA's notes about where it put the model do not apply here.
+ let mut easyeda_notes = Vec::new();
+ let transform = easyeda_model_transform(component, easyeda_footprint, data, &mut easyeda_notes);
+ if transform.rotation[0] == 0.0 && transform.rotation[1] == 0.0 {
+ if let Some((min, max)) = data.and_then(step_bounds) {
+ let rz = (transform.rotation[2] + turn).rem_euclid(360.0);
+ let (cx, cy) = ((min[0] + max[0]) / 2.0, (min[1] + max[1]) / 2.0);
+ let (sin, cos) = rz.to_radians().sin_cos();
+ return StepTransform {
+ rotation: [0.0, 0.0, rz],
+ offset: [
+ round4(-(cx * cos - cy * sin)) + 0.0,
+ round4(-(cx * sin + cy * cos)) + 0.0,
+ transform.offset[2],
+ ],
+ };
+ }
+ }
+ notes.extend(easyeda_notes);
+ if transform.rotation[0] != 0.0 || transform.rotation[1] != 0.0 {
+ notes.push("3D: EasyEDA tilts this model; check it in the 3D view".to_string());
+ }
+ let (sin, cos) = turn.to_radians().sin_cos();
+ let [ox, oy, oz] = transform.offset;
+ StepTransform {
+ rotation: [
+ transform.rotation[0],
+ transform.rotation[1],
+ (transform.rotation[2] + turn).rem_euclid(360.0),
+ ],
+ offset: [
+ round4(ox * cos - oy * sin + sx) + 0.0,
+ round4(ox * sin + oy * cos + sy) + 0.0,
+ oz,
+ ],
+ }
+}
+
+/// Allegro mapping for EasyEDA's own model on EasyEDA's footprint.
+fn easyeda_model_transform(
+ component: &ComponentData,
+ footprint: &KiFootprint,
+ data: Option<&[u8]>,
+ notes: &mut Vec,
+) -> StepTransform {
+ let bounds = data.and_then(step_bounds);
+ if bounds.is_none() {
+ notes.push("3D: could not read the model outline; placed at the origin".to_string());
+ }
+ let package_origin = (component.package_bbox_x, component.package_bbox_y);
+ let mut placement = component.model_3d.as_ref().and_then(|m| m.placement);
+ // EasyEDA data sometimes places a model beside its pads. A two-pad part is
+ // symmetric about its pads, so centre it there; otherwise keep EasyEDA's
+ // placement and say so.
+ if let (Some(p), Some((x0, y0, x1, y1))) = (placement.as_mut(), pad_extent_mm(footprint)) {
+ let pads = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
+ let centre = model_centre_mm(p, package_origin);
+ let off = ((centre.0 - pads.0).powi(2) + (centre.1 - pads.1).powi(2)).sqrt();
+ // Farther from the pads than the pads reach (plus a body's overhang): the
+ // placement is plainly wrong (seen 1.1 m off), so centre the model.
+ let reach = (x1 - x0).hypot(y1 - y0) / 2.0 + 3.0;
+ if off > reach || off > 0.25 && footprint.pads.len() == 2 {
+ p.origin_x = package_origin.0 + pads.0 / PX_MM;
+ p.origin_y = package_origin.1 - pads.1 / PX_MM;
+ notes.push(format!(
+ "3D: EasyEDA puts the model {off:.2} mm off its pads; centred it on the pads"
+ ));
+ } else if off > 0.25 && footprint.pads.len() > 2 {
+ notes.push(format!(
+ "3D: EasyEDA puts the model {off:.2} mm from the pad centre; check it in the 3D view"
+ ));
+ }
+ }
+ allegro_transform(placement.as_ref(), package_origin, bounds)
+}
+
+/// (min x, min y, max x, max y) of the pad centres in mm, Allegro axes (Y up).
+fn pad_extent_mm(footprint: &KiFootprint) -> Option<(f64, f64, f64, f64)> {
+ let mut extent: Option<(f64, f64, f64, f64)> = None;
+ for pad in &footprint.pads {
+ let (x, y) = (pad.pos_x * PX_MM, -pad.pos_y * PX_MM);
+ extent = Some(match extent {
+ None => (x, y, x, y),
+ Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
+ });
+ }
+ extent
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const STEP: &str = "ISO-10303-21;\nDATA;\n#1=CARTESIAN_POINT('',(-1.01,-0.41,-0.71));\n#2=CARTESIAN_POINT('',(0.59,0.39,0.));\n#3=CARTESIAN_POINT('Axis',(50.,50.,50.));\n#4=VERTEX_POINT('',#1);\n#5=VERTEX_POINT('',#2);\nENDSEC;\n";
+
+ #[test]
+ fn bounds_use_vertices_only() {
+ let (min, max) = step_bounds(STEP.as_bytes()).unwrap();
+ assert_eq!(min, [-1.01, -0.41, -0.71]);
+ assert_eq!(max, [0.59, 0.39, 0.0]);
+ }
+
+ #[test]
+ fn rotated_off_centre_model_lands_on_its_origin() {
+ // ESD5Z5.0 (C475685): model centre x = -0.21, rotated 180 degrees, origin on
+ // the package origin, bottom 0.71 below the model origin.
+ let placement = ModelPlacement {
+ origin_x: 4000.0,
+ origin_y: 3000.0,
+ z: 0.0,
+ rotation: [0.0, 0.0, 180.0],
+ };
+ let t = allegro_transform(
+ Some(&placement),
+ (4000.0, 3000.0),
+ step_bounds(STEP.as_bytes()),
+ );
+ assert_eq!(t.rotation, [0.0, 0.0, 180.0]);
+ assert_eq!(t.offset, [-0.21, -0.01, 0.71]);
+ }
+
+ #[test]
+ fn origin_offset_is_converted_and_y_flipped() {
+ // CL10A475 (C19666): c_origin 3998.0435,3000.5 on a package at 4000,3000.
+ let placement = ModelPlacement {
+ origin_x: 3998.0435,
+ origin_y: 3000.5,
+ z: 0.0,
+ rotation: [0.0; 3],
+ };
+ let bounds = Some(([-0.8, -0.4, -0.4], [0.8, 0.4, 0.4]));
+ let t = allegro_transform(Some(&placement), (4000.0, 3000.0), bounds);
+ assert_eq!(t.offset, [-0.497, -0.127, 0.4]);
+ assert_eq!(t.rotation, [0.0; 3]);
+ assert_eq!(
+ allegro_transform(None, (0.0, 0.0), None),
+ StepTransform::default()
+ );
+ }
+
+ #[test]
+ fn negative_angles_are_normalised() {
+ let placement = ModelPlacement {
+ origin_x: 0.0,
+ origin_y: 0.0,
+ z: 0.0,
+ rotation: [0.0, 0.0, -90.0],
+ };
+ let t = allegro_transform(Some(&placement), (0.0, 0.0), None);
+ assert_eq!(t.rotation, [0.0, 0.0, 270.0]);
+ }
+}
diff --git a/src/cadence/tests.rs b/src/cadence/tests.rs
new file mode 100644
index 0000000..f46814e
--- /dev/null
+++ b/src/cadence/tests.rs
@@ -0,0 +1,946 @@
+use crate::cadence::allegro::{
+ AllegroFootprint, StepModel, StepTransform, allegro_identifier, build_allegro_footprint_with,
+ footprint_name_for, library_script,
+};
+use crate::cadence::capture::{
+ CAPTURE_GRID, CapturePart, PinLabels, build_capture_part_with, capture_identifier,
+ render_library_xml,
+};
+use crate::kicad::footprint::{
+ Drill, KiArc as FpArc, KiCircle as FpCircle, KiFootprint, KiLine, KiPad, PadShape, PadType,
+};
+use crate::kicad::symbol::{
+ KiCircle, KiPin, KiPolyline, KiRectangle, KiSymbol, KiSymbolUnit, PinStyle, PinType,
+};
+
+fn build_allegro_footprint(footprint: &KiFootprint, name: &str, refdes: &str) -> AllegroFootprint {
+ build_allegro_footprint_with(footprint, name, refdes, None)
+}
+
+fn build_capture_part(symbol: &KiSymbol, name: &str, footprint: &str) -> CapturePart {
+ build_capture_part_with(symbol, name, footprint, PinLabels::default())
+}
+
+/// Padstacks a footprint script creates, in order.
+fn padstacks(script: &str) -> Vec {
+ script
+ .match_indices("seex_padstack(\"")
+ .map(|(at, call)| {
+ let rest = &script[at + call.len()..];
+ rest[..rest.find('"').unwrap()].to_string()
+ })
+ .collect()
+}
+
+fn pin(number: &str, name: &str, x: f64, y: f64, rotation: i32, pin_type: PinType) -> KiPin {
+ KiPin {
+ number: number.to_string(),
+ name: name.to_string(),
+ pin_type,
+ style: PinStyle::Line,
+ pos_x: x,
+ pos_y: y,
+ rotation,
+ length: 10.0,
+ }
+}
+
+/// Op-amp style symbol in the shared model (EasyEDA px, Y up), body 40x40.
+pub(crate) fn sample_symbol() -> KiSymbol {
+ KiSymbol {
+ name: "LM321_C7950".to_string(),
+ reference: "U?".to_string(),
+ value: "LM321 & \"rail\"".to_string(),
+ description: "Single op-amp".to_string(),
+ footprint: String::new(),
+ datasheet: "https://example.test/ds?a=1&b=2".to_string(),
+ manufacturer: "TI".to_string(),
+ lcsc_id: "C7950".to_string(),
+ jlc_id: String::new(),
+ units: Vec::new(),
+ pins: vec![
+ // Left side pins point right (KiCad orientation 0 => EasyEDA rotation 180).
+ pin("3", "+IN", -10.0, 10.0, 180, PinType::Input),
+ pin("4", "-IN", -10.0, -10.0, 180, PinType::Input),
+ // Right side pin.
+ pin("1", "OUT", 50.0, 0.0, 0, PinType::Output),
+ // Top / bottom power pins (KiCad orientation 270 / 90 => EasyEDA 90 / 270).
+ pin("5", "V+", 20.0, 30.0, 90, PinType::PowerIn),
+ pin("2", "V-", 20.0, -30.0, 270, PinType::PowerIn),
+ ],
+ rectangles: vec![KiRectangle {
+ x1: 0.0,
+ y1: 20.0,
+ x2: 40.0,
+ y2: -20.0,
+ stroke_width: 1.0,
+ fill: true,
+ }],
+ circles: vec![KiCircle {
+ cx: 35.0,
+ cy: 0.0,
+ radius: 2.0,
+ stroke_width: 1.0,
+ fill: false,
+ }],
+ arcs: Vec::new(),
+ polylines: vec![KiPolyline {
+ points: vec![(5.0, 15.0), (30.0, 0.0), (5.0, -15.0), (5.0, 15.0)],
+ stroke_width: 1.0,
+ fill: true,
+ }],
+ texts: Vec::new(),
+ }
+}
+
+fn attr(xml: &str, element_start: usize, name: &str) -> String {
+ let rest = &xml[element_start..];
+ let key = format!(" {name}=\"");
+ let start = rest.find(&key).expect("attribute present") + key.len();
+ let end = rest[start..].find('"').unwrap() + start;
+ rest[start..end].to_string()
+}
+
+#[test]
+fn capture_pins_land_on_grid_and_point_away_from_body() {
+ let part = build_capture_part(&sample_symbol(), "LM321_C7950", "c7950_lm321");
+ let xml = &part.xml;
+ let mut count = 0;
+ for (index, _) in xml.match_indices("") {
+ count += 1;
+ let hx: i64 = attr(xml, index, "hotptX").parse().unwrap();
+ let hy: i64 = attr(xml, index, "hotptY").parse().unwrap();
+ let sx: i64 = attr(xml, index, "startX").parse().unwrap();
+ let sy: i64 = attr(xml, index, "startY").parse().unwrap();
+ assert_eq!(hx % CAPTURE_GRID, 0, "hotptX on grid");
+ assert_eq!(hy % CAPTURE_GRID, 0, "hotptY on grid");
+ assert_eq!((hx - sx).abs() + (hy - sy).abs(), 10, "pin length kept");
+ match attr(xml, index, "name").as_str() {
+ "+IN" | "-IN" => assert!(sx > hx, "left pins extend right into the body"),
+ "OUT" => assert!(sx < hx, "right pin extends left into the body"),
+ // Capture Y grows downwards: V+ is on top so its body end is below it.
+ "V+" => assert!(sy > hy),
+ "V-" => assert!(sy < hy),
+ other => panic!("unexpected pin {other}"),
+ }
+ }
+ assert_eq!(count, 5);
+ assert!(part.warnings.is_empty(), "{:?}", part.warnings);
+}
+
+#[test]
+fn capture_part_has_capture_structure_and_metadata() {
+ let part = build_capture_part(&sample_symbol(), "LM321_C7950", "c7950_lm321");
+ let xml = &part.xml;
+ assert!(xml.contains("refdesPrefix=\"U\""));
+ assert!(xml.contains("pcbFootprint=\"c7950_lm321\""));
+ assert!(xml.contains(""));
+ assert!(xml.contains("val=\"https://example.test/ds?a=1&b=2\""));
+ assert!(xml.contains(""));
+ // Pin types: input 0, output 2, power 7.
+ assert!(xml.contains("name=\"+IN\" position=\"0\""));
+ assert!(xml.contains("type=\"7\""));
+ assert!(xml.contains("type=\"2\""));
+ // Physical pin numbers map positions to package pins.
+ assert!(xml.contains(""));
+ assert!(xml.contains(""));
+ // Body rectangle unfilled, triangle polygon filled.
+ assert!(xml.contains("\n \n ",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ];
+ let positions: Vec = order.iter().map(|tag| xml.find(tag).unwrap()).collect();
+ assert!(positions.windows(2).all(|pair| pair[0] < pair[1]));
+}
+
+#[test]
+fn capture_renames_duplicate_pin_numbers() {
+ let mut symbol = sample_symbol();
+ symbol.pins[1].number = "3".to_string();
+ let part = build_capture_part(&symbol, "X", "x");
+ assert!(part.xml.contains("").count(), 2);
+ assert!(xml.contains(""));
+ assert!(xml.contains(""));
+ assert_eq!(xml.matches("").count(), 2);
+ assert_eq!(xml.matches("").count(), 10);
+ // Each section is drawn on its own, at the origin, with its own pin numbers.
+ let sections: Vec<&str> = xml.split("").skip(1).collect();
+ for section in §ions {
+ assert_eq!(section.matches("").count(), 1);
+ assert_capture_body_rules(&format!("{section}"));
+ }
+ assert!(sections[0].contains("number=\"1\""));
+ assert!(sections[1].contains("number=\"10\""));
+ assert!(!sections[1].contains("number=\"1\" "));
+ // Pins never share a connection point.
+ for section in §ions {
+ let mut hot: Vec<(i64, i64)> = pin_points(section)
+ .into_iter()
+ .map(|(_, x, y, _, _)| (x, y))
+ .collect();
+ let count = hot.len();
+ hot.sort();
+ hot.dedup();
+ assert_eq!(hot.len(), count);
+ }
+}
+
+#[test]
+fn single_symbols_stay_homogeneous() {
+ let part = build_capture_part(&sample_symbol(), "LM321_C7950", "c7950_lm321");
+ assert!(part.xml.contains("isHomogeneous=\"1\""));
+ assert_eq!(part.xml.matches("").count(), 1);
+ assert!(part.xml.contains(""));
+}
+
+#[test]
+fn library_xml_wraps_packages() {
+ let part = build_capture_part(&sample_symbol(), "LM321_C7950", "c7950_lm321");
+ let xml = render_library_xml("SeEx.olb", &[part.xml.clone(), part.xml]);
+ assert!(xml.starts_with("").count(), 2);
+ assert_eq!(xml.matches("").count(), 24);
+ assert!(xml.trim_end().ends_with(""));
+}
+
+#[test]
+fn identifiers_are_tool_safe() {
+ assert_eq!(
+ capture_identifier("STM32F103C8T6 (LQFP-48)"),
+ "STM32F103C8T6_LQFP-48"
+ );
+ assert_eq!(capture_identifier("中文"), "PART");
+ assert_eq!(allegro_identifier("SOP-8_L4.9", 31), "sop-8_l4_9");
+ let long = footprint_name_for("C2040", "RP2040 very long package title that keeps going");
+ assert!(long.len() <= 31);
+ assert!(long.starts_with("c2040_"));
+}
+
+fn smd_pad(number: &str, x: f64, y: f64, w: f64, h: f64, rotation: f64) -> KiPad {
+ KiPad {
+ number: number.to_string(),
+ pad_type: PadType::Smd,
+ shape: PadShape::Rect,
+ pos_x: x,
+ pos_y: y,
+ size_x: w,
+ size_y: h,
+ rotation,
+ layers: vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()],
+ drill: None,
+ polygon: None,
+ }
+}
+
+/// SOIC-like footprint with every pad flavour the converter can emit (EasyEDA px, Y down).
+pub(crate) fn sample_footprint() -> KiFootprint {
+ let mut pads = vec![
+ smd_pad("1", -10.0, -15.0, 6.0, 2.4, 0.0),
+ smd_pad("2", -10.0, 15.0, 6.0, 2.4, 0.0),
+ smd_pad("2", 10.0, 15.0, 6.0, 2.4, 90.0),
+ ];
+ pads.push(KiPad {
+ number: "3".into(),
+ pad_type: PadType::ThroughHole,
+ shape: PadShape::Oval,
+ pos_x: 20.0,
+ pos_y: 0.0,
+ size_x: 8.0,
+ size_y: 5.0,
+ rotation: 0.0,
+ layers: vec!["*.Cu".into(), "*.Mask".into()],
+ drill: Some(Drill {
+ diameter: 5.0,
+ width: Some(3.0),
+ offset_x: 0.0,
+ offset_y: 0.0,
+ }),
+ polygon: None,
+ });
+ pads.push(KiPad {
+ number: "4".into(),
+ pad_type: PadType::ThroughHole,
+ shape: PadShape::Circle,
+ pos_x: 30.0,
+ pos_y: 0.0,
+ size_x: 6.0,
+ size_y: 6.0,
+ rotation: 0.0,
+ layers: vec!["*.Cu".into(), "*.Mask".into()],
+ drill: Some(Drill {
+ diameter: 4.0,
+ width: None,
+ offset_x: 0.0,
+ offset_y: 0.0,
+ }),
+ polygon: None,
+ });
+ pads.push(KiPad {
+ number: "EP".into(),
+ pad_type: PadType::Smd,
+ shape: PadShape::Custom,
+ pos_x: 0.0,
+ pos_y: 0.0,
+ size_x: 0.01,
+ size_y: 0.01,
+ rotation: 0.0,
+ layers: vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()],
+ drill: None,
+ polygon: Some(
+ "\n\t\t(primitives \n\t\t\t(gr_poly \n\t\t\t\t(pts (xy -1.00 -0.50) (xy 1.00 -0.50) (xy 1.00 0.50) (xy -1.00 0.50)\n\t\t\t\t) \n\t\t\t\t(width 0.1) \n\t\t\t)\n\t\t)\n\t"
+ .into(),
+ ),
+ });
+ // Via (unnumbered plated) and a mounting hole (NPTH).
+ pads.push(KiPad {
+ number: String::new(),
+ pad_type: PadType::ThroughHole,
+ shape: PadShape::Circle,
+ pos_x: 0.0,
+ pos_y: 0.0,
+ size_x: 2.4,
+ size_y: 2.4,
+ rotation: 0.0,
+ layers: vec!["*.Cu".into(), "*.Mask".into()],
+ drill: Some(Drill {
+ diameter: 1.2,
+ width: None,
+ offset_x: 0.0,
+ offset_y: 0.0,
+ }),
+ polygon: None,
+ });
+ pads.push(KiPad {
+ number: String::new(),
+ pad_type: PadType::NpThroughHole,
+ shape: PadShape::Circle,
+ pos_x: -30.0,
+ pos_y: 0.0,
+ size_x: 12.0,
+ size_y: 12.0,
+ rotation: 0.0,
+ layers: vec!["*.Cu".into(), "*.Mask".into()],
+ drill: Some(Drill {
+ diameter: 12.0,
+ width: None,
+ offset_x: 0.0,
+ offset_y: 0.0,
+ }),
+ polygon: None,
+ });
+
+ let line = |x1: f64, y1: f64, x2: f64, y2: f64, layer: &str| KiLine {
+ start_x: x1,
+ start_y: y1,
+ end_x: x2,
+ end_y: y2,
+ width: 0.6,
+ layer: layer.into(),
+ };
+ KiFootprint {
+ name: "test".into(),
+ pads,
+ tracks: Vec::new(),
+ circles: vec![FpCircle {
+ center_x: -14.0,
+ center_y: -20.0,
+ end_x: -13.0,
+ end_y: -20.0,
+ width: 0.2,
+ layer: "F.SilkS".into(),
+ fill: true,
+ }],
+ arcs: vec![FpArc {
+ start_x: -5.0,
+ start_y: -20.0,
+ mid_x: 0.0,
+ mid_y: -15.0,
+ end_x: 5.0,
+ end_y: -20.0,
+ width: 0.6,
+ layer: "F.SilkS".into(),
+ }],
+ texts: Vec::new(),
+ lines: vec![
+ line(-20.0, -20.0, 20.0, -20.0, "F.SilkS"),
+ line(-20.0, 20.0, 20.0, 20.0, "F.Fab"),
+ line(-40.0, -25.0, 40.0, -25.0, "F.CrtYd"),
+ line(40.0, -25.0, 40.0, 25.0, "F.CrtYd"),
+ line(40.0, 25.0, -40.0, 25.0, "F.CrtYd"),
+ line(-40.0, 25.0, -40.0, -25.0, "F.CrtYd"),
+ line(0.0, 0.0, 5.0, 5.0, "Cmts.User"),
+ ],
+ model_3d: None,
+ }
+}
+
+#[test]
+
+fn allegro_script_creates_shared_padstacks_and_pins() {
+ let fp = build_allegro_footprint(&sample_footprint(), "c1_test", "U?");
+ let script = &fp.script;
+
+ // 6 x 2.4 px = 1.524 x 0.6096 mm -> one shared SMD padstack for pads 1 and both 2s.
+ assert!(
+ padstacks(&fp.script).contains(&"sx_sr152x61".to_string()),
+ "{:?}",
+ padstacks(&fp.script)
+ );
+ assert_eq!(script.matches("seex_padstack(\"sx_sr152x61\"").count(), 1);
+ // Duplicate pad number renamed, rotation preserved.
+ assert!(script.contains("seex_pin(\"sx_sr152x61\" \"2_2\" 2.5400 -3.8100 90.0000)"));
+ // Y is flipped: EasyEDA y = -15 px (up on screen) -> +3.81 mm in Allegro.
+ assert!(script.contains("seex_pin(\"sx_sr152x61\" \"1\" -2.5400 3.8100 0.0000)"));
+ // Oblong pad with an oval slot, plated.
+ assert!(script.contains("seex_slot(1.2700 0.7620 t)"));
+ assert!(script.contains("'OBLONG"));
+ // Round plated pad keeps the DEFAULT_INTERNAL layer with a fallback without it.
+ assert!(script.contains("seex_padstack(\"sx_tc152d102\" seex_drill(1.0160 t)"));
+ // Mechanical hole: non-plated, no pin number.
+ assert!(script.contains("seex_padstack(\"sx_h305\" seex_drill(3.0480 nil)"));
+ assert!(script.contains("seex_pin(\"sx_h305\" nil -7.6200 0.0000 0.0000)"));
+ // Custom polygon pad: anchor pad plus three shapes.
+ assert!(script.contains("seex_shape(\"ETCH/TOP\""));
+ assert!(script.contains("seex_shape(\"PACKAGE GEOMETRY/PASTEMASK_TOP\""));
+ // Courtyard becomes the place bound (80 x 50 px = 20.32 x 12.7 mm).
+ assert!(script.contains(
+ "seex_place_bound(\"PACKAGE GEOMETRY/PLACE_BOUND_TOP\" -10.1600 -6.3500 10.1600 6.3500)"
+ ));
+ assert!(script.contains("\"REF DES/SILKSCREEN_TOP\" \"U*\""));
+ // Silkscreen / assembly graphics, arc, filled dot.
+ assert!(script.contains("seex_line(\"PACKAGE GEOMETRY/SILKSCREEN_TOP\""));
+ // The component frame is the silkscreen: no assembly outline.
+ assert!(!script.contains("seex_line(\"PACKAGE GEOMETRY/ASSEMBLY_TOP\""));
+ assert!(script.contains("seex_text(\"REF DES/ASSEMBLY_TOP\""));
+ assert!(script.contains("seex_arc(\"PACKAGE GEOMETRY/SILKSCREEN_TOP\""));
+ assert!(script.contains("seex_circle(\"PACKAGE GEOMETRY/SILKSCREEN_TOP\""));
+
+ let joined = fp.warnings.join("\n");
+ assert!(joined.contains("1 unnumbered via"), "{joined}");
+ assert!(joined.contains("2_2"));
+ assert!(joined.contains("custom polygon"));
+ assert!(joined.contains("Cmts.User"));
+}
+
+#[test]
+fn allegro_script_parentheses_balance() {
+ let fp = build_allegro_footprint(&sample_footprint(), "c1_test", "R");
+ let mut depth = 0i64;
+ let mut in_string = false;
+ let mut in_comment = false;
+ let mut escaped = false;
+ for ch in fp.script.chars() {
+ if in_comment {
+ in_comment = ch != '\n';
+ continue;
+ }
+ if in_string {
+ match (escaped, ch) {
+ (true, _) => escaped = false,
+ (false, '\\') => escaped = true,
+ (false, '"') => in_string = false,
+ _ => {}
+ }
+ continue;
+ }
+ match ch {
+ ';' => in_comment = true,
+ '"' => in_string = true,
+ '(' => depth += 1,
+ ')' => {
+ depth -= 1;
+ assert!(depth >= 0, "unbalanced ')'");
+ }
+ _ => {}
+ }
+ }
+ assert_eq!(depth, 0);
+ assert!(!in_string);
+}
+
+#[test]
+fn allegro_names_long_padstacks_with_hash() {
+ let mut footprint = sample_footprint();
+ footprint.pads = vec![KiPad {
+ number: "1".into(),
+ pad_type: PadType::ThroughHole,
+ shape: PadShape::Oval,
+ pos_x: 0.0,
+ pos_y: 0.0,
+ size_x: 123.4,
+ size_y: 98.7,
+ rotation: 0.0,
+ layers: vec!["*.Cu".into()],
+ drill: Some(Drill {
+ diameter: 77.7,
+ width: Some(55.5),
+ offset_x: 0.0,
+ offset_y: 0.0,
+ }),
+ polygon: None,
+ }];
+ let fp = build_allegro_footprint(&footprint, "big", "J");
+ let stacks = padstacks(&fp.script);
+ assert_eq!(stacks.len(), 1);
+ assert!(stacks[0].len() <= 20, "{}", stacks[0]);
+ assert!(stacks[0].starts_with("sx_to"));
+}
+
+#[test]
+fn square_smd_pads_use_allegro_square_figure() {
+ let mut footprint = sample_footprint();
+ footprint.pads.truncate(1);
+ footprint.pads[0].size_x = 3.15;
+ footprint.pads[0].size_y = 3.15;
+ let fp = build_allegro_footprint(&footprint, "c2286_led", "LED?");
+ assert!(
+ padstacks(&fp.script).contains(&"sx_sq80".to_string()),
+ "{:?}",
+ padstacks(&fp.script)
+ );
+ assert!(fp.script.contains("'SQUARE"));
+ assert!(
+ !fp.script
+ .contains("seex_pad(\"TOP\" 'RECTANGLE 0.8001 0.8001)")
+ );
+}
+
+#[test]
+fn allegro_script_maps_step_model() {
+ let fp = build_allegro_footprint_with(
+ &sample_footprint(),
+ "c1_test",
+ "U",
+ Some(StepModel {
+ file: "c1_test.step",
+ dir: std::path::Path::new("E:\\Libs\\3D\\Package_SO"),
+ transform: StepTransform {
+ rotation: [0.0, 0.0, 90.0],
+ offset: [0.0, -0.127, 0.7],
+ },
+ }),
+ );
+ assert!(
+ fp.script.contains(
+ " seex_step(\"c1_test.step\" \"E:/Libs/3D/Package_SO\" 0.0000 0.0000 90.0000 0.0000 -0.1270 0.7000)\n seex_end(\"c1_test\")"
+ ),
+ "{}",
+ fp.script
+ );
+ assert!(
+ !build_allegro_footprint(&sample_footprint(), "c1_test", "U")
+ .script
+ .contains(" seex_step(")
+ );
+}
+
+#[test]
+fn padstacks_carry_every_required_layer_and_are_checked() {
+ let footprint = sample_footprint();
+ let fp = build_allegro_footprint(&footprint, "test_tht", "J");
+ // Plated holes: regular, anti-pad and thermal relief on outer and internal layers.
+ for layer in ["TOP", "DEFAULT_INTERNAL", "BOTTOM"] {
+ for kind in ["ANTI", "THERMAL"] {
+ assert!(
+ fp.script
+ .contains(&format!("seex_padt(\"{layer}\" '{kind} ")),
+ "{layer} {kind} missing:\n{}",
+ fp.script
+ );
+ }
+ }
+ assert!(fp.script.contains("seex_pad(\"SOLDERMASK_BOTTOM\" "));
+ // Every padstack is read back and checked.
+ for stack in &padstacks(&fp.script) {
+ assert!(
+ fp.script
+ .contains(&format!("seex_check_padstack(\"{stack}\" ")),
+ "{stack} not checked"
+ );
+ }
+ assert!(fp.script.contains("list(\"ETCH/TOP\" \"THERMAL\")"));
+
+ let mut smd = sample_footprint();
+ smd.pads.truncate(1);
+ smd.pads[0].pad_type = PadType::Smd;
+ smd.pads[0].drill = None;
+ smd.pads[0].layers = vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()];
+ let fp = build_allegro_footprint(&smd, "test_smd", "R");
+ assert!(fp.script.contains("seex_pad(\"PASTEMASK_TOP\" "));
+ assert!(fp.script.contains(
+ "list(list(\"ETCH/TOP\" \"REGULAR\") list(\"PIN/SOLDERMASK_TOP\" \"REGULAR\") list(\"PIN/PASTEMASK_TOP\" \"REGULAR\")) nil)"
+ ));
+}
+
+fn pin_points(xml: &str) -> Vec<(String, i64, i64, i64, i64)> {
+ xml.match_indices("")
+ .map(|(index, _)| {
+ (
+ attr(xml, index, "name"),
+ attr(xml, index, "hotptX").parse().unwrap(),
+ attr(xml, index, "hotptY").parse().unwrap(),
+ attr(xml, index, "startX").parse().unwrap(),
+ attr(xml, index, "startY").parse().unwrap(),
+ )
+ })
+ .collect()
+}
+
+#[test]
+fn off_grid_symbols_are_snapped_to_the_grid() {
+ // EasyEDA symbol drawn on a 0.05" grid: the body and the right pins sit half a
+ // grid off, and a free-standing pin (no body) is off in both directions.
+ let mut symbol = sample_symbol();
+ symbol.rectangles[0].x2 = 45.0;
+ symbol.pins[2].pos_x = 55.0; // OUT, on the moved right edge
+ symbol.pins[2].pos_y = 5.0;
+ symbol
+ .pins
+ .push(pin("6", "NC", 72.5, -15.0, 0, PinType::NoConnect));
+ let part = build_capture_part(&symbol, "T_C1", "t");
+ for (name, hx, hy, sx, sy) in pin_points(&part.xml) {
+ assert_eq!(hx % CAPTURE_GRID, 0, "{name} hotptX {hx}");
+ assert_eq!(hy % CAPTURE_GRID, 0, "{name} hotptY {hy}");
+ assert!(
+ (hx - sx).abs() + (hy - sy).abs() >= 5,
+ "{name} keeps a visible pin"
+ );
+ }
+ // The body grew to the grid.
+ let rect = part.xml.find("").unwrap();
+ for key in ["x1", "x2", "y1", "y2"] {
+ let v: i64 = attr(&part.xml, rect, key).parse().unwrap();
+ assert_eq!(v % CAPTURE_GRID, 0, "body {key} {v}");
+ }
+}
+
+/// Capture's own parts (all of Discrete.olb) keep the body outline on the 0.1"
+/// grid with its top-left corner at the part origin, start every pin on it and draw
+/// everything inside it; the part editor relies on that to move graphics and pins.
+fn assert_capture_body_rules(xml: &str) {
+ let problems = capture_body_problems(xml);
+ assert!(problems.is_empty(), "{problems:?}");
+}
+
+fn capture_body_problems(xml: &str) -> Vec {
+ let mut problems = Vec::new();
+ let Some(bbox) = xml.find("") else {
+ return vec!["no SymbolBBox".to_string()];
+ };
+ let get = |key: &str| attr(xml, bbox, key).parse::().unwrap();
+ let (x1, x2, y1, y2) = (get("x1"), get("x2"), get("y1"), get("y2"));
+ if [x1, x2, y1, y2].iter().any(|v| v % CAPTURE_GRID != 0) || x1 >= x2 || y1 >= y2 {
+ problems.push(format!("outline {x1} {x2} {y1} {y2} off grid"));
+ }
+ if (x1, y1) != (0, 0) {
+ problems.push(format!(
+ "outline starts at ({x1}, {y1}), not at the part origin"
+ ));
+ }
+ for (name, hx, hy, sx, sy) in pin_points(xml) {
+ if (hx - sx).abs() + (hy - sy).abs() < CAPTURE_GRID {
+ problems.push(format!(
+ "pin {name} is shorter than 0.1\" ({hx}, {hy}) to ({sx}, {sy})"
+ ));
+ }
+ let on_edge = ((sx == x1 || sx == x2) && (y1..=y2).contains(&sy))
+ || ((sy == y1 || sy == y2) && (x1..=x2).contains(&sx));
+ if !on_edge {
+ problems.push(format!(
+ "pin {name} starts at ({sx}, {sy}), off the outline {x1} {x2} {y1} {y2}"
+ ));
+ }
+ }
+ let mut at = 0;
+ while let Some(found) = xml[at..].find("") {
+ let index = at + found;
+ for (kx, ky) in [("x1", "y1"), ("x2", "y2")] {
+ let x: i64 = attr(xml, index, kx).parse().unwrap();
+ let y: i64 = attr(xml, index, ky).parse().unwrap();
+ if !((x1..=x2).contains(&x) && (y1..=y2).contains(&y)) {
+ problems.push(format!(
+ "line point ({x}, {y}) outside the outline {x1} {x2} {y1} {y2}"
+ ));
+ }
+ }
+ at = index + 6;
+ }
+ problems
+}
+
+#[test]
+fn filled_shapes_are_closed_polygons() {
+ // EasyEDA's MOSFET arrow: three points, filled, last point not repeated.
+ let mut symbol = sample_symbol();
+ symbol.polylines = vec![KiPolyline {
+ points: vec![(0.0, 0.0), (-6.0, 2.0), (-6.0, -2.0)],
+ stroke_width: 1.0,
+ fill: true,
+ }];
+ let part = build_capture_part(&symbol, "T_Q2", "t");
+ let polygon = part
+ .xml
+ .find("")
+ .expect("filled triangle is a polygon");
+ assert_eq!(attr(&part.xml, polygon, "fillStyle"), "0", "solid fill");
+ let end = polygon + part.xml[polygon..].find("").unwrap();
+ assert_eq!(
+ part.xml[polygon..end].matches("").count(),
+ 4,
+ "closed: 3 corners + first again"
+ );
+}
+
+#[test]
+fn drawn_symbols_get_a_capture_style_outline() {
+ // Schottky diode as EasyEDA draws it: bar and triangle between 0.05" grid lines,
+ // pins ending on the drawing rather than on a body box.
+ let mut symbol = sample_symbol();
+ symbol.rectangles.clear();
+ symbol.circles.clear();
+ symbol.pins = vec![
+ pin("1", "K", -20.0, 0.0, 180, PinType::Passive),
+ pin("2", "A", 20.0, 0.0, 0, PinType::Passive),
+ ];
+ symbol.pins[0].length = 15.0;
+ symbol.pins[1].length = 15.0;
+ symbol.polylines = vec![
+ KiPolyline {
+ points: vec![(-5.0, 6.0), (-5.0, -6.0)],
+ stroke_width: 1.0,
+ fill: false,
+ },
+ KiPolyline {
+ points: vec![(5.0, 6.0), (-5.0, 0.0), (5.0, -6.0), (5.0, 6.0)],
+ stroke_width: 1.0,
+ fill: true,
+ },
+ ];
+ let part = build_capture_part(&symbol, "T_D1", "t");
+ assert_capture_body_rules(&part.xml);
+ // The pins still reach the drawing: lead lines close the gap to the bar/triangle.
+ assert!(part.xml.matches("").count() >= 3, "{}", part.xml);
+
+ // MOSFET gate: a 0.05" pin whose tip falls on the outline once it is on the grid
+ // must not become a zero-length pin.
+ let mut mosfet = symbol.clone();
+ mosfet.pins[0].pos_x = -10.0;
+ mosfet.pins[0].length = 5.0;
+ let part = build_capture_part(&mosfet, "T_Q1", "t");
+ assert_capture_body_rules(&part.xml);
+
+ // Box symbols already follow the rules and keep their pins.
+ let part = build_capture_part(&sample_symbol(), "LM321_C7950", "c7950_lm321");
+ assert_capture_body_rules(&part.xml);
+}
+
+#[test]
+fn bodies_keep_their_shape_on_edges_without_pins() {
+ // Varistor: a flat 0.2" x 0.08" box with pins left and right and a diagonal
+ // that crosses it. Only the pin edges may move to the grid.
+ let mut symbol = sample_symbol();
+ symbol.pins = vec![
+ pin("1", "1", -20.0, 0.0, 180, PinType::Passive),
+ pin("2", "2", 30.0, 0.0, 0, PinType::Passive),
+ ];
+ symbol.rectangles[0] = KiRectangle {
+ x1: -10.0,
+ y1: 4.0,
+ x2: 20.0,
+ y2: -4.0,
+ stroke_width: 1.0,
+ fill: false,
+ };
+ symbol.circles.clear();
+ symbol.polylines = vec![KiPolyline {
+ points: vec![(-10.0, -8.0), (-5.0, -8.0), (15.0, 8.0)],
+ stroke_width: 1.0,
+ fill: false,
+ }];
+ let part = build_capture_part(&symbol, "T_RV1", "t");
+ let rect = part.xml.find("").unwrap();
+ let get = |key: &str| attr(&part.xml, rect, key).parse::().unwrap();
+ assert_eq!(
+ (get("y2") - get("y1")).abs(),
+ 8,
+ "the box stays 0.08\" tall"
+ );
+ assert_eq!((get("x2") - get("x1")).abs(), 30);
+ for (name, hx, hy, _, _) in pin_points(&part.xml) {
+ assert_eq!(hx % CAPTURE_GRID, 0, "{name}");
+ assert_eq!(hy % CAPTURE_GRID, 0, "{name}");
+ }
+}
+
+#[test]
+
+fn half_grid_symbols_move_as_a_whole() {
+ // A two-pin header drawn on the 0.05" grid: both pins sit half a grid off.
+ // The whole drawing moves, so the pins stay 0.1" apart and on the body.
+ let mut symbol = sample_symbol();
+ symbol.pins = vec![
+ pin("1", "1", -20.0, 5.0, 180, PinType::Passive),
+ pin("2", "2", -20.0, -5.0, 180, PinType::Passive),
+ ];
+ symbol.rectangles[0] = KiRectangle {
+ x1: -10.0,
+ y1: 25.0,
+ x2: 20.0,
+ y2: -15.0,
+ stroke_width: 1.0,
+ fill: false,
+ };
+ symbol.polylines.clear();
+ let part = build_capture_part(&symbol, "T_J1", "t");
+ let pins = pin_points(&part.xml);
+ let ys: Vec = pins.iter().map(|p| p.2).collect();
+ assert_eq!((ys[0] - ys[1]).abs(), 10, "{pins:?}");
+ assert!(
+ part.warnings.iter().all(|w| !w.contains("jog")),
+ "{:?}",
+ part.warnings
+ );
+ let rect = part.xml.find("").unwrap();
+ let get = |key: &str| attr(&part.xml, rect, key).parse::().unwrap();
+ assert_eq!((get("y2") - get("y1")).abs(), 40);
+}
+
+#[test]
+fn stacked_pins_stay_on_the_body() {
+ // Three shield pins EasyEDA put on one spot under the body: they are spread
+ // along the bottom edge and the body widens to keep them attached.
+ let mut symbol = sample_symbol();
+ symbol.pins = vec![
+ pin("1", "VBUS", -10.0, 10.0, 180, PinType::Passive),
+ pin("6", "SH", 10.0, -10.0, 270, PinType::Passive),
+ pin("7", "SH", 10.0, -10.0, 270, PinType::Passive),
+ pin("8", "SH", 10.0, -10.0, 270, PinType::Passive),
+ ];
+ symbol.rectangles[0] = KiRectangle {
+ x1: 0.0,
+ y1: 20.0,
+ x2: 20.0,
+ y2: 0.0,
+ stroke_width: 1.0,
+ fill: false,
+ };
+ symbol.polylines.clear();
+ symbol.circles.clear();
+ let part = build_capture_part(&symbol, "T_J2", "t");
+ let rect = part.xml.find("").unwrap();
+ let get = |key: &str| attr(&part.xml, rect, key).parse::().unwrap();
+ let (x1, x2) = (get("x1").min(get("x2")), get("x1").max(get("x2")));
+ let bottom = get("y1").max(get("y2"));
+ let mut xs = Vec::new();
+ for (name, hx, _, sx, sy) in pin_points(&part.xml) {
+ if name == "SH" {
+ assert_eq!(sy, bottom, "attached to the bottom edge");
+ assert!(sx >= x1 && sx <= x2, "{sx} within {x1}..{x2}");
+ xs.push(hx);
+ }
+ }
+ xs.sort();
+ xs.dedup();
+ assert_eq!(xs.len(), 3, "spread apart");
+}
+
+#[test]
+fn assembly_refdes_follows_the_fabrication_conventions() {
+ use crate::cadence::allegro::assembly_refdes_layout as layout;
+ // Always centred on the component: baseline half a text height below.
+ // 0402 (1.0 x 0.5 mm): the smallest size.
+ let (h, rot, x, y) = layout(Some((-0.5, -0.25, 0.5, 0.25)), "R*");
+ assert_eq!((h, rot), (0.5, 0.0));
+ assert!(x.abs() < 1e-9 && (y + 0.25).abs() < 1e-9);
+ // 0603 (1.6 x 0.8 mm): "R*" at 0.59 mm; "LED*" at 0.5 mm, still centred.
+ let (h, _, _, y) = layout(Some((-0.8, -0.4, 0.8, 0.4)), "R*");
+ assert!((h - 1.6 / 2.7).abs() < 1e-9);
+ assert!((y + h / 2.0).abs() < 1e-9);
+ let (h, _, x, y) = layout(Some((1.0, 2.0, 2.6, 2.8)), "LED*");
+ assert_eq!(h, 0.5);
+ assert!((x - 1.8).abs() < 1e-9 && (y - 2.15).abs() < 1e-9);
+ // SOIC-8 standing (3.9 x 4.9 mm): along the long axis, full size.
+ let (h, rot, x, _) = layout(Some((-1.95, -2.45, 1.95, 2.45)), "U*");
+ assert_eq!((h, rot), (1.0, 90.0));
+ assert!((x - 0.5).abs() < 1e-9);
+ // SOT-23 lying (2.9 x 1.3 mm): "Q*" at 1.0 mm.
+ let (h, rot, ..) = layout(Some((-1.45, -0.65, 1.45, 0.65)), "Q*");
+ assert!((h - 1.0).abs() < 1e-9 && rot == 0.0);
+}
+
+#[test]
+fn silkscreen_refdes_fits_the_part() {
+ use crate::cadence::allegro::{silk_refdes_height as height, silk_stroke};
+ // 0402 (place bound about 1.4 mm wide): the smallest size.
+ assert_eq!(height(Some((-0.7, -0.5, 0.7, 0.5)), "R*"), 0.6);
+ // 0603 LED (2.7 mm): "LED*" spans the part.
+ assert!((height(Some((-1.35, -0.75, 1.35, 0.75)), "LED*") - 2.7 / 3.6).abs() < 1e-9);
+ // SOT-23 and larger: the full 1.0 mm.
+ assert_eq!(height(Some((-1.7, -1.6, 1.7, 1.6)), "Q*"), 1.0);
+ assert_eq!(height(None, "U*"), 1.0);
+ assert!((silk_stroke(1.0) - 0.15).abs() < 1e-9);
+ assert!((silk_stroke(0.6) - 0.10).abs() < 1e-9);
+}
+
+#[test]
+fn library_script_builds_every_footprint_in_its_folder() {
+ let script = library_script(
+ "Parts",
+ std::path::Path::new("E:\\Libs\\Parts_allegro"),
+ &["resc1005x40n".to_string(), "sot95p280x145-5n".to_string()],
+ );
+ assert!(script.contains("skill load(\"E:/Libs/Parts_allegro/Parts.il\")"));
+ assert!(script.contains(
+ "seex_build_library(\"E:/Libs/Parts_allegro\" list(\"resc1005x40n\" \"sot95p280x145-5n\"))"
+ ));
+ assert!(script.contains("axlOpenDesign(?design design ?mode \"wf\")"));
+ let code: String = script
+ .lines()
+ .filter(|line| !line.trim_start().starts_with(';'))
+ .collect::>()
+ .join("\n");
+ assert_eq!(code.matches('(').count(), code.matches(')').count());
+}
diff --git a/src/cadence/workflow.rs b/src/cadence/workflow.rs
new file mode 100644
index 0000000..cddd37b
--- /dev/null
+++ b/src/cadence/workflow.rs
@@ -0,0 +1,787 @@
+//! Batch export of LCSC components to Cadence import files.
+//!
+//! ```text
+//!