master
John Lauer 1.2.0: part number placed by the mark planner (long axis, upright with pin 1 toward the viewer, same mark as the etched… 3681866 18d ago
//! The part-number mark, placed by construction: the named outlines and the name-lasered icon come
//! from the shared MARK PLANNER (adom-aiflow's `tools/laser-etch`, vendored here in `planner/` and
//! installed to ~/.local/share/adom-chip-thumbnailer/planner). The same code as `adom-aiflow-etch`, so
//! the thumbnailer and the etch can never disagree:
//!
//!   * the mark runs along the longest usable dimension (chip R/C/L between the end caps, ICs along the
//!     pin rows, LEDs on the front side wall), as large as fits, one line or two equal lines;
//!   * the outline pose is the standard iso pose: pin 1 (pins 1 to 3) toward the viewer, the name upright
//!     and left to right; the outline `rot` is the planner's, never a fixed 180 / 90;
//!   * every named SVG is gated; a failing one is written as `*.FAILED-GATE.svg` with a loud warning and
//!     Hint lines, never silently as `-named`.
//!
//! Plain python3 only (stdlib); the geometry runs on the shared service-step2glb.

use crate::library::ChipPaths;
use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;

pub struct MarkOutcome {
    pub status: String,
    pub plan: Option<PathBuf>,
    pub posed: Option<PathBuf>,
    pub etched: Option<PathBuf>,
    pub outlines: Vec<(PathBuf, String)>,
    pub summary: String,
    pub hints: Vec<String>,
}

/// Where the planner's Python lives: $ADOM_MARK_PLANNER, this package's installed copy, the binary's
/// sibling `planner/`, or an installed adom-aiflow (`adom-aiflow-etch` sits next to the same files).
pub fn planner_dir() -> Option<PathBuf> {
    let ok = |p: &Path| p.join("mark_outline.py").is_file() && p.join("mark_etch.py").is_file();
    if let Ok(p) = std::env::var("ADOM_MARK_PLANNER") {
        let p = PathBuf::from(p);
        if ok(&p) {
            return Some(p);
        }
    }
    let home = std::env::var("HOME").unwrap_or_default();
    let mut cands = vec![PathBuf::from(&home).join(".local/share/adom-chip-thumbnailer/planner")];
    if let Ok(exe) = std::env::current_exe() {
        if let Some(d) = exe.parent() {
            cands.push(d.join("planner"));
            cands.push(d.join("../planner"));
        }
    }
    if let Ok(t) = std::fs::canonicalize(PathBuf::from(&home).join(".local/bin/adom-aiflow-etch")) {
        if let Some(d) = t.parent() {
            cands.push(d.to_path_buf());
        }
    }
    cands.into_iter().find(|p| ok(p))
}

fn last_json(stdout: &str) -> Option<serde_json::Value> {
    stdout.lines().rev().find(|l| l.trim_start().starts_with('{')).and_then(|l| serde_json::from_str(l).ok())
}

fn run_py(dir: &Path, script: &str, args: &[String]) -> Result<(bool, String, String)> {
    let out = Command::new(std::env::var("AIFLOW_PYTHON").unwrap_or_else(|_| "python3".into()))
        .arg(dir.join(script))
        .args(args)
        .env("AIFLOW_ETCH_CLIENT", "chip-thumbnailer/chip-fetcher")
        .output()
        .with_context(|| format!("run python3 {script} (is python3 installed?)"))?;
    Ok((
        out.status.success(),
        String::from_utf8_lossy(&out.stdout).to_string(),
        String::from_utf8_lossy(&out.stderr).to_string(),
    ))
}

fn hints_of(stderr: &str) -> Vec<String> {
    stderr.lines().filter(|l| l.starts_with("Hint:") || l.starts_with("ERROR")).map(str::to_string).collect()
}

/// Etch (if needed) and draw the named outlines for one chip; writes into the chip folder:
///   <MPN>-etched.step                          the model with its flat Adom.LaserEtch mark
///   <MPN>-3d-outline-<style>.svg               plain outlines, standard pose
///   <MPN>-3d-outline-<style>-named.svg         the name = the mark (gate PASS/WARN)
///   <MPN>-3d-outline-<style>-named.FAILED-GATE.svg   only when the gate failed (loud warning)
///   <MPN>.mark-plan.json                       the planner's JSON (schema adom/mark-plan@1)
pub fn named_family(chip: &ChipPaths, up: Option<&str>) -> Result<MarkOutcome> {
    let dir = planner_dir().ok_or_else(|| {
        anyhow!("the mark planner is not installed (reinstall adom-chip-thumbnailer, or install adom-aiflow; or set ADOM_MARK_PLANNER)")
    })?;
    let step = chip.step.as_ref().ok_or_else(|| anyhow!("no .step for {}", chip.mpn))?;
    let mut hints = Vec::new();
    let mut common: Vec<String> = Vec::new();
    if let Some(fp) = &chip.kicad_mod {
        common.push("--footprint".into());
        common.push(fp.display().to_string());
    } else {
        hints.push("Hint: WARNING no <MPN>.kicad_mod beside the STEP: pin 1 is assumed at the negative end; add the footprint for a certain reading direction".into());
    }
    if let Some(u) = up {
        common.push("--up".into());
        common.push(u.into());
    }
    let etched = chip.dir.join(format!("{}-etched.step", chip.mpn));
    let mut have_etched = etched.is_file();
    if !have_etched {
        let mut a = vec!["--in".into(), step.display().to_string(), "--mpn".into(), chip.mpn.clone(), "--out".into(), etched.display().to_string()];
        a.extend(common.iter().cloned());
        let (ok, _so, se) = run_py(&dir, "mark_etch.py", &a)?;
        hints.extend(hints_of(&se));
        have_etched = ok && etched.is_file();
        if !have_etched {
            hints.push("Hint: no etched STEP (see the lines above); the named outline is drawn from the plain model on the planned band".into());
        }
    }
    let posed = chip.dir.join(format!(".posed-{}.step", chip.mpn));
    let mut a = vec![
        step.display().to_string(),
        "--mpn".into(),
        chip.mpn.clone(),
        "--out-dir".into(),
        chip.dir.display().to_string(),
        "--styles".into(),
        "all".into(),
        "--stem".into(),
        chip.mpn.clone(),
        "--write-plain".into(),
        "--posed-out".into(),
        posed.display().to_string(),
    ];
    a.extend(common.iter().cloned());
    if have_etched {
        a.push("--etched".into());
        a.push(etched.display().to_string());
    }
    let (_ok, so, se) = run_py(&dir, "mark_outline.py", &a)?;
    hints.extend(hints_of(&se));
    let v = last_json(&so).ok_or_else(|| anyhow!("mark_outline.py gave no result: {}", se.lines().last().unwrap_or("")))?;
    let outlines = v["outlines"]
        .as_array()
        .map(|a| {
            a.iter()
                .map(|o| (PathBuf::from(o["path"].as_str().unwrap_or("")), o["status"].as_str().unwrap_or("?").to_string()))
                .collect()
        })
        .unwrap_or_default();
    let plan = v["plan"].as_str().map(PathBuf::from);
    let summary = plan
        .as_ref()
        .and_then(|p| std::fs::read_to_string(p).ok())
        .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
        .and_then(|j| j["summary"].as_str().map(str::to_string))
        .unwrap_or_default();
    Ok(MarkOutcome {
        status: v["status"].as_str().unwrap_or("FAIL").to_string(),
        plan,
        posed: if posed.is_file() { Some(posed) } else { None },
        etched: if have_etched { Some(etched) } else { None },
        outlines,
        summary,
        hints,
    })
}

/// Remove name variants an older thumbnailer wrote with fixed rotations (named90, named180, named
/// outlines from a previous run), so a stale wrong-way name can never be picked up.
pub fn clear_stale_named(chip: &ChipPaths) {
    if let Ok(rd) = std::fs::read_dir(&chip.dir) {
        for e in rd.flatten() {
            let n = e.file_name().to_string_lossy().to_string();
            if !n.starts_with(&chip.mpn) {
                continue;
            }
            let stale = (n.contains("-3d-outline-") && (n.contains("-named") || n.contains("named90") || n.contains("named180")))
                || n.contains("-3d-iso-named90")
                || n.ends_with("-3d-iso-named-debug.png");
            if stale {
                let _ = std::fs::remove_file(e.path());
            }
        }
    }
}