← Commit history
.gitignoreadded+9
@@ -0,0 +1,9 @@+# Rust build output+/target++# Compiled binary is built by build.sh at publish time, never committed+# (keeps the repo small and prevents stale binaries / accidental stub publishes).+/bin/adom-footprint++# Package tarballs+*.tgz
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-footprint"-version = "1.0.10"+version = "1.0.11" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-footprint"-version = "1.0.10"+version = "1.0.11" edition = "2021" description = "Create, preview, lint, and deliver KiCad footprints (.kicad_mod) with interactive FpView viewer" 
SKILL.md+51−14
@@ -133,22 +133,59 @@ Footprints are stored at:  ## RefDes / Value text placement -`create` auto-places the `reference` (RefDes, `REF**`) and `value` text CLEAR of-the **whole** footprint — outside the outer pad/courtyard/body extent, not just-the body:+Placement is ONE universal geometry, computed once in `fp_gen::text_placement`+(the single source of truth used both to emit the `.kicad_mod` fp_text and the+`textPlacement` metadata). Exporters handle per-tool syntax (Y-flip, layer+numbers, anchor) — do NOT add a per-tool kicad/fusion/altium/orcad switch. -- reference: centered above the top edge, at `y ≈ -(max pad/courtyard top extent + 1mm)`-  (KiCad Y is down, so above = negative Y)-- value: centered below the bottom edge, at `y ≈ +(max pad/courtyard bottom extent + 1mm)`-- size 1mm (KiCad default); reference on `F.SilkS`, value on `F.Fab`+**The rule** (`create` applies it automatically; keep it if hand-placing): -This matters because **adom-lbr carries your `.kicad_mod` text placement through+- Center horizontally (`x = 0`).+- RefDes (`REF**`) just **beyond the top extent**; Value just **beyond the bottom+  extent** — ~1 mm clearance off the **full** pads ∪ courtyard ∪ body bbox (not+  just the body). KiCad Y is down, so "above" = negative Y.+- Size ~1 mm (KiCad default), scaled down for tiny parts so the label is never+  larger than the part. reference on `F.SilkS`, value on `F.Fab`.+- Keep it user-visible and editable (the user can nudge it in the app).++**Why it matters:** **adom-lbr carries your `.kicad_mod` text placement through verbatim** into the Fusion `.lbr` (and KiCad/Altium/OrCAD) — it no longer invents its own. Where you put RefDes/Value here is exactly what the user sees in every-tool. Text only ~3mm from center (the old default) lands ON the pads of larger-parts (e.g. an LQFP-64's pads reach ±5.9mm) and shows "smeared across the-footprint" in Fusion.--The text stays user-visible and editable, so the user can nudge it in the-adom-footprint app. adom-lbr's `name-value-overlap` lint warns (and its export+tool. Text only ~3 mm from center (the old default) lands ON the pads of larger+parts (e.g. an LQFP-64's pads reach ±5.9 mm) and shows "smeared across the+footprint" in Fusion. adom-lbr's `name-value-overlap` lint warns (and its export reprimands) if the text ever sits back on pad copper.++**Two carry-through limits in adom-lbr's `.lbr` path today** (so keep parts that+must read well in Fusion at ~1 mm and horizontal):++- text **size** is hardcoded to 1.27 mm in the `.lbr` — your size only reaches+  KiCad/Altium/OrCAD.+- text **rotation** is dropped — keep `rotation = 0` (horizontal).++(adom-lbr will be updated to honor size + rotation.)++### `textPlacement` metadata++Beyond the `.kicad_mod` fp_text (the primary contract), adom-footprint also emits+an explicit `textPlacement` block so exporters **validate against your intent**+instead of re-deriving from pads. Inject it into a ds2sf+`<mpn>-footprint.extracted.json` (or a create JSON) with:++```bash+adom-footprint placement --file <mpn>-footprint.extracted.json --write+```++```json+"textPlacement": {+  "units": "mm",+  "yAxis": "down",+  "originRelative": "footprint-center",+  "reference": { "x": 0, "y": -6.95, "size": 1.0, "rotation": 0, "anchor": "center", "layer": "F.SilkS" },+  "value":     { "x": 0, "y":  6.95, "size": 1.0, "rotation": 0, "anchor": "center", "layer": "F.Fab" },+  "courtyard": { "top": -5.95, "bottom": 5.95, "left": -5.95, "right": 5.95 }+}+```++`courtyard` is the keep-out the text must clear. **Invariant (Y-down):**+`reference.y <= courtyard.top` AND `value.y >= courtyard.bottom`.
VERSION+1−1
@@ -1 +1 @@-1.0.10\ No newline at end of file+1.0.11\ No newline at end of file
build.shadded+21
@@ -0,0 +1,21 @@+#!/bin/bash+#+# build.sh — invoked automatically by release.sh before `adompkg publish`.+#+# Builds the release binary and stages it into bin/ so the published tarball+# always ships a REAL binary. (The repo intentionally does NOT commit the+# compiled binary — see .gitignore — so without this step a publish would ship+# an empty/missing bin/adom-footprint and `adompkg install` would be broken.+# This is exactly the regression that shipped a 91-byte stub in earlier tags.)+#+set -euo pipefail+DIR="$(cd "$(dirname "$0")" && pwd)"+cd "$DIR"++cargo build --release+cargo test --release            # keep the golden + placement tests green on every publish++mkdir -p bin+cp target/release/adom-footprint bin/adom-footprint+chmod 0755 bin/adom-footprint+echo "build.sh: staged bin/adom-footprint -> $(./bin/adom-footprint --version)"
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-footprint",-  "version": "1.0.10",+  "version": "1.0.11",   "type": "app",   "description": "KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360",   "dependencies": {
page.json+6−4
@@ -1,6 +1,6 @@ {   "slug": "adom-footprint",-  "version": "1.0.10",+  "version": "1.0.11",   "type": "app",   "description": "KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360",   "dependencies": {@@ -19,8 +19,10 @@     "pads"   ],   "hero": {-    "type": "image",-    "path": "adom-footprint-hero-v4.png"+    "type": "billboard",+    "headline": "KiCad footprints, instantly",+    "subhead": "Toggle copper, fab and paste; render and deliver footprints to KiCad and Fusion 360.",+    "screenshot": "screenshots/shot.png"   },   "scripts": {     "install": "./install.sh",@@ -36,4 +38,4 @@   "org": "adom",   "updated_at": "2026-06-25T01:59:28.504Z",   "source_visibility": "public"-}+}\ No newline at end of file
src/fp_gen.rs+159−13
@@ -43,6 +43,102 @@ fn round2(x: f64) -> f64 {     (x * 100.0).round() / 100.0 } +/// Render a rotation suffix for a `(at x y[ rot])` token (omitted when 0).+fn rot(r: f64) -> String {+    if r != 0.0 { format!(" {}", n(r)) } else { String::new() }+}++/// One placed text item (RefDes or Value). Y is footprint-down (KiCad convention).+#[derive(serde::Serialize, Clone)]+pub struct TextItem {+    pub x: f64,+    pub y: f64,+    pub size: f64,+    pub rotation: f64,+    pub anchor: String,+    pub layer: String,+}++/// The keep-out the RefDes/Value text must stay OUTSIDE — the full footprint+/// extent (pads ∪ courtyard ∪ body). Named `courtyard` to match what every+/// downstream exporter validates against.+#[derive(serde::Serialize, Clone)]+pub struct KeepoutRect {+    pub top: f64,+    pub bottom: f64,+    pub left: f64,+    pub right: f64,+}++/// Explicit RefDes/Value placement INTENT, so exporters (adom-lbr → Fusion/.lbr,+/// Altium, OrCAD) validate against it instead of re-deriving from pads. This is+/// the SINGLE placement — exporters handle per-tool syntax (Y-flip, layer+/// numbers, anchor), there is NO per-tool switch here. Serializes to the+/// `textPlacement` block of `<mpn>-footprint.extracted.json`.+///+/// Invariant (Y-down): `reference.y <= courtyard.top` AND `value.y >= courtyard.bottom`.+#[derive(serde::Serialize, Clone)]+pub struct TextPlacement {+    pub units: String,+    #[serde(rename = "yAxis")]+    pub y_axis: String,+    #[serde(rename = "originRelative")]+    pub origin_relative: String,+    pub reference: TextItem,+    pub value: TextItem,+    pub courtyard: KeepoutRect,+}++/// Clearance (mm) the text sits beyond the full footprint extent.+const TEXT_CLEARANCE_MM: f64 = 1.0;++/// Text height (mm). 1 mm (KiCad default) for normal parts; scaled down so the+/// label is never larger than a tiny part. NOTE: adom-lbr's .lbr path currently+/// hardcodes 1.27 mm and drops rotation, so this size only reaches+/// KiCad/Altium/OrCAD — keep parts that must read well in Fusion at ~1 mm.+fn text_size(input: &FpInput) -> f64 {+    let (xmin, ymin, xmax, ymax) = bbox(input);+    let max_extent = (xmax - xmin).max(ymax - ymin);+    let s = (max_extent / 2.5).clamp(0.5, 1.0);+    (s * 10.0).round() / 10.0+}++/// The canonical RefDes/Value placement for a footprint — the one source of+/// truth used both to emit the `.kicad_mod` fp_text and the `textPlacement`+/// metadata. Centered on X; RefDes ~1 mm beyond the top extent, Value ~1 mm+/// beyond the bottom extent, off the full pads ∪ courtyard ∪ body bbox.+pub fn text_placement(input: &FpInput) -> TextPlacement {+    let (xmin, ymin, xmax, ymax) = bbox(input);+    let size = text_size(input);+    TextPlacement {+        units: "mm".into(),+        y_axis: "down".into(),+        origin_relative: "footprint-center".into(),+        reference: TextItem {+            x: 0.0,+            y: round2(ymin - TEXT_CLEARANCE_MM),+            size,+            rotation: 0.0,+            anchor: "center".into(),+            layer: "F.SilkS".into(),+        },+        value: TextItem {+            x: 0.0,+            y: round2(ymax + TEXT_CLEARANCE_MM),+            size,+            rotation: 0.0,+            anchor: "center".into(),+            layer: "F.Fab".into(),+        },+        courtyard: KeepoutRect {+            top: round2(ymin),+            bottom: round2(ymax),+            left: round2(xmin),+            right: round2(xmax),+        },+    }+}+ pub fn generate_kicad_mod(input: &FpInput) -> String {     let mut s = format!("(footprint \"{}\"\n", input.name);     s += "  (version 20240108)\n  (generator \"adom-footprint\")\n  (layer \"F.Cu\")\n";@@ -82,21 +178,27 @@ pub fn generate_kicad_mod(input: &FpInput) -> String {     }      // Reference and Value — placed CLEAR of the WHOLE footprint (pads ∪ courtyard-    // ∪ body), not just the body. adom-lbr now carries this placement through to+    // ∪ body) via text_placement(), the single source of truth shared with the+    // exported `textPlacement` metadata. adom-lbr carries this placement through to     // the Fusion .lbr (and KiCad/Altium/OrCAD) verbatim — it no longer invents its     // own — so RefDes/Value that sit on pad copper here show up smeared across the-    // part in every tool. We anchor the RefDes ~1 mm above the top extent and the-    // Value ~1 mm below the bottom extent (KiCad Y is down, so above = -Y). Both-    // stay on F.SilkS / F.Fab at the default 1 mm size and remain user-visible and-    // editable, so the user can nudge them in the adom-footprint app. (adom-lbr's-    // `name-value-overlap` lint flags it if text ever lands back on copper.)-    let (_, ymin, _, ymax) = bbox(input);-    let ref_y = round2(ymin - 1.0);-    let val_y = round2(ymax + 1.0);-    s += &format!("  (fp_text reference \"REF**\" (at 0 {})\n", n(ref_y));-    s += "    (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n";-    s += &format!("  (fp_text value \"{}\" (at 0 {})\n", input.name, n(val_y));-    s += "    (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n";+    // part in every tool. RefDes goes ~1 mm above the top extent, Value ~1 mm below+    // the bottom (KiCad Y is down, so above = -Y); both stay user-visible/editable.+    // (adom-lbr's `name-value-overlap` lint flags it if text ever lands on copper.)+    let tp = text_placement(input);+    let emit_text = |s: &mut String, kind: &str, label: &str, t: &TextItem| {+        let th = round2(t.size * 0.15);+        *s += &format!(+            "  (fp_text {} \"{}\" (at {} {}{})\n",+            kind, label, n(t.x), n(t.y), rot(t.rotation)+        );+        *s += &format!(+            "    (layer \"{}\") (effects (font (size {} {}) (thickness {}))))\n",+            t.layer, n(t.size), n(t.size), n(th)+        );+    };+    emit_text(&mut s, "reference", "REF**", &tp.reference);+    emit_text(&mut s, "value", &input.name, &tp.value);      // Pads     for pad in &input.pads {@@ -194,4 +296,48 @@ mod tests {         // And it must NOT fall back to the old courtyard-only placement (±5.8).         assert!(!got.contains("(at 0 5.8)") && !got.contains("(at 0 -5.8)"), "must not use courtyard-only placement");     }++    #[test]+    fn text_placement_holds_invariant() {+        // reference.y <= courtyard.top AND value.y >= courtyard.bottom (Y-down),+        // for both a large part and a tiny one, and the .kicad_mod fp_text must+        // agree with the metadata (single source of truth).+        let big = FpInput {+            name: "LQFP".into(),+            pads: vec![+                Pad { number: "1".into(), x: -3.5, y: 5.9, dx: 0.3, dy: 1.5, shape: None, angle: None, layers: None },+                Pad { number: "2".into(), x: 3.5, y: -5.9, dx: 0.3, dy: 1.5, shape: None, angle: None, layers: None },+            ],+            body: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),+            courtyard: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),+            description: String::new(),+            tags: String::new(),+        };+        let tp = text_placement(&big);+        assert_eq!((tp.reference.x, tp.value.x), (0.0, 0.0), "centered on X");+        assert!(tp.reference.y <= tp.courtyard.top, "ref clears top keep-out");+        assert!(tp.value.y >= tp.courtyard.bottom, "value clears bottom keep-out");+        assert_eq!(tp.reference.rotation, 0.0, "horizontal (rotation dropped in .lbr)");+        assert_eq!(text_size(&big), 1.0, "normal part keeps 1 mm");+        // .kicad_mod agrees with metadata+        let km = generate_kicad_mod(&big);+        assert!(km.contains(&format!("(at 0 {})", n(tp.reference.y))), "kicad_mod ref y matches metadata");+        assert!(km.contains(&format!("(at 0 {})", n(tp.value.y))), "kicad_mod value y matches metadata");++        // tiny part scales text down (but invariant still holds)+        let tiny = FpInput {+            name: "R_0402".into(),+            pads: vec![+                Pad { number: "1".into(), x: -0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: None, angle: None, layers: None },+                Pad { number: "2".into(), x: 0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: None, angle: None, layers: None },+            ],+            body: Some(Rect { x1: -0.5, y1: -0.27, x2: 0.5, y2: 0.27 }),+            courtyard: Some(Rect { x1: -0.93, y1: -0.47, x2: 0.93, y2: 0.47 }),+            description: String::new(),+            tags: String::new(),+        };+        let tt = text_placement(&tiny);+        assert!(tt.reference.size < 1.0, "tiny part scales text below 1 mm, got {}", tt.reference.size);+        assert!(tt.reference.y <= tt.courtyard.top && tt.value.y >= tt.courtyard.bottom, "invariant holds for tiny part");+    } }
src/main.rs+59−8
@@ -55,6 +55,17 @@ enum Cmd {         #[arg(long)]         out: PathBuf,     },+    /// Compute the canonical RefDes/Value `textPlacement` for a footprint and+    /// (with --write) inject it into a `<mpn>-footprint.extracted.json` so every+    /// exporter validates against ONE explicit placement instead of re-guessing.+    /// Accepts a ds2sf extracted.json or an adom-footprint create JSON.+    Placement {+        #[arg(long)]+        file: PathBuf,+        /// Write the `textPlacement` block back into the JSON file (in place).+        #[arg(long)]+        write: bool,+    },     /// Export a self-contained, INTERACTIVE HTML package (pan/zoom + a Layers     /// panel with the InstaPCB paste-dot overlay) for embedding in an iframe —     /// e.g. a wiki component page. No server, no external assets.@@ -188,6 +199,7 @@ fn run() -> Result<()> {             Ok(())         }         Cmd::RenderDs2sf { file, out } => cmd_render_ds2sf(file, out),+        Cmd::Placement { file, write } => cmd_placement(file, write),         Cmd::Embed { file, out } => cmd_embed(file, out),         Cmd::Ui { action, args, port } => cmd_ui(action, args, port),         Cmd::Preview { file, out } => cmd_preview(file, out),@@ -274,8 +286,19 @@ fn cmd_create(file: Option<PathBuf>, out: Option<PathBuf>) -> Result<()> { fn cmd_render_ds2sf(file: PathBuf, out: PathBuf) -> Result<()> {     let raw = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;     let v: serde_json::Value = serde_json::from_str(&raw).context("parsing ds2sf footprint JSON")?;+    let input = fpinput_from_ds2sf(&v)?;+    let kicad_mod = fp_gen::generate_kicad_mod(&input);+    let svg = render::render_footprint_svg(&kicad_mod, fp_gen::bbox(&input))?;+    fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;+    println!("OK: rendered ds2sf footprint → {} ({} pads)", out.display(), input.pads.len());+    Ok(())+}++/// Build an `FpInput` from a ds2sf `<mpn>-footprint.extracted.json` value+/// (pads[].at/.size, bodyDimensions, optional courtyard rect).+fn fpinput_from_ds2sf(v: &serde_json::Value) -> Result<fp_gen::FpInput> {     let name = v.get("footprintName").and_then(|x| x.as_str()).unwrap_or("footprint").to_string();-    let arr = v.get("pads").and_then(|x| x.as_array()).ok_or_else(|| anyhow!("ds2sf footprint has no pads[]"))?;+    let arr = v.get("pads").and_then(|x| x.as_array()).ok_or_else(|| anyhow!("footprint JSON has no pads[]"))?;     let num = |a: Option<&serde_json::Value>| a.and_then(|x| x.as_f64()).unwrap_or(0.0);     let mut pads = Vec::new();     for p in arr {@@ -293,7 +316,7 @@ fn cmd_render_ds2sf(file: PathBuf, out: PathBuf) -> Result<()> {         });     }     if pads.is_empty() {-        return Err(anyhow!("ds2sf footprint extraction has no pads"));+        return Err(anyhow!("footprint extraction has no pads"));     }     // Body outline from bodyDimensions ([w,h] or {x,y}/{w,h}), centered.     let body = v.get("bodyDimensions").and_then(|b| {@@ -305,18 +328,46 @@ fn cmd_render_ds2sf(file: PathBuf, out: PathBuf) -> Result<()> {         };         Some(fp_gen::Rect { x1: -w / 2.0, y1: -h / 2.0, x2: w / 2.0, y2: h / 2.0 })     });-    let input = fp_gen::FpInput {+    // Optional explicit courtyard rect {x1,y1,x2,y2} if the extraction has one.+    let courtyard = v.get("courtyard").and_then(|c| {+        let g = |k: &str| c.get(k).and_then(|x| x.as_f64());+        Some(fp_gen::Rect { x1: g("x1")?, y1: g("y1")?, x2: g("x2")?, y2: g("y2")? })+    });+    Ok(fp_gen::FpInput {         name,         pads,         body,-        courtyard: None,+        courtyard,         description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),         tags: String::new(),+    })+}++/// Compute the canonical `textPlacement` and (optionally) write it into the JSON.+/// Accepts a ds2sf extracted.json (pads[].at/.size) or an adom-footprint create+/// JSON (pads[].x/.dx). Placement is computed ONCE here (fp_gen::text_placement) —+/// exporters consume it; they do not re-derive it.+fn cmd_placement(file: PathBuf, write: bool) -> Result<()> {+    let raw = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;+    let mut v: serde_json::Value = serde_json::from_str(&raw).context("parsing footprint JSON")?;+    let is_create = v.get("pads").and_then(|p| p.as_array()).and_then(|a| a.first())+        .map(|p| p.get("x").is_some() && p.get("dx").is_some())+        .unwrap_or(false);+    let input = if is_create {+        let c: CreateInput = serde_json::from_value(v.clone()).context("parsing create JSON")?;+        to_input(&c)+    } else {+        fpinput_from_ds2sf(&v)?     };-    let kicad_mod = fp_gen::generate_kicad_mod(&input);-    let svg = render::render_footprint_svg(&kicad_mod, fp_gen::bbox(&input))?;-    fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;-    println!("OK: rendered ds2sf footprint → {} ({} pads)", out.display(), input.pads.len());+    let tp = fp_gen::text_placement(&input);+    let tp_val = serde_json::to_value(&tp)?;+    println!("{}", serde_json::to_string_pretty(&tp_val)?);+    if write {+        let obj = v.as_object_mut().ok_or_else(|| anyhow!("JSON root is not an object; cannot add textPlacement"))?;+        obj.insert("textPlacement".to_string(), tp_val);+        fs::write(&file, serde_json::to_string_pretty(&v)? + "\n").with_context(|| format!("writing {}", file.display()))?;+        eprintln!("wrote textPlacement → {}", file.display());+    }     Ok(()) } 
tests/golden/R_0402.kicad_mod+2−2
@@ -14,9 +14,9 @@   (fp_line (start -0.8 -0.5700000000000001) (end -0.2 -0.5700000000000001)     (stroke (width 0.12) (type default)) (layer "F.SilkS"))   (fp_text reference "REF**" (at 0 -1.47)-    (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15))))+    (layer "F.SilkS") (effects (font (size 0.7 0.7) (thickness 0.11))))   (fp_text value "R_0402_1005Metric" (at 0 1.47)-    (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))+    (layer "F.Fab") (effects (font (size 0.7 0.7) (thickness 0.11))))   (pad "1" smd roundrect (at -0.485 0) (size 0.6 0.7)     (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25))   (pad "2" smd roundrect (at 0.485 0) (size 0.6 0.7)