fusible 0.20.3

Electronic linter: a fuse that blows to block firmware code generation
//! Subcommand: `fusible body` — build the physical bodies of the project.
//!
//! A body is either authored in any CAD tool (`source:`, optionally produced by a
//! `build:` command) or generated by Fusible (`generate: enclosure`). For the
//! enclosure, Fusible computes the whole layout (outline, screw bosses, edge notches,
//! vents) from Circuit JSON, then hands a JSON spec to an embedded TypeScript generator
//! that runs the OpenCascade kernel (`occt-wasm`) under bun and writes the CAD files.

mod convert;
mod edges;
mod eject;
mod eject_edit;
mod eject_files;
mod eject_manifest;
mod error;
mod generator;
mod generator_setup;
mod jobs;
mod kit;
mod kit_data;
mod kit_levels;
mod layout;
mod settings;
mod spec;
mod step;
mod yaml_lines;

#[cfg(test)]
mod eject_comment_tests;
#[cfg(test)]
mod eject_files_tests;
#[cfg(test)]
mod eject_tests;
#[cfg(test)]
mod generator_tests;
#[cfg(test)]
mod jobs_tests;
#[cfg(test)]
mod kit_tests;
#[cfg(test)]
mod layout_tests;
#[cfg(test)]
mod tests;

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

use crate::commands::build::execute_shell;
use crate::commands::check;
use crate::commands::target::BoardTarget;
use crate::config::{BodyConfig, EnclosureConfig, FusibleConfig};
use crate::gerber::PcbModel;

pub(crate) use convert::convert_to_glb;
pub use eject::eject;
pub use error::BodyError;
use generator::run_generator;
pub(crate) use generator::{generator_dir, run_script};
use jobs::{select_jobs, Job};
pub use kit::kit;
pub(crate) use kit_data::{build_kit, Kit, KitOpening};
use settings::{preview_settings, resolve_settings, Settings};
use spec::{build_spec, preview_from, Output, Spec};
pub(crate) use step::generate_step;

const FORMATS: &[&str] = &["step", "stl", "glb", "brep"];
const DEFAULT_WALL: f64 = 2.0;
const DEFAULT_BOARD_THICKNESS: f64 = 1.6;
const CLEARANCE: f64 = 0.5;
const STANDOFF: f64 = 3.0;
const HEADROOM: f64 = 12.0;
const BOSS_WALL: f64 = 2.0;
const PILOT_UNDERSIZE: f64 = 0.7;
const EDGE_TOLERANCE: f64 = 0.5;
const NOTCH_MARGIN: f64 = 0.5;
const VENT_INSET: f64 = 4.0;

/// A studio preview of the generated enclosure: the GLB bytes plus the frame needed to place it.
///
/// The model frame is in millimetres, Z-up, with X/Y equal to the Circuit JSON pcb
/// coordinates. The floor bottom sits at z=0, the board bottom face at z=`board_z`,
/// and the lid occupies z in [`height`, `height + wall`]. The GLB holds two named
/// nodes, `base` and `lid`. `size` is the outer bounding box: the outline bounds plus
/// `2 * (clearance + wall)` in X/Y, and `height + wall` in Z.
#[derive(Debug, Clone, PartialEq)]
pub struct BodyPreview {
    pub glb: Vec<u8>,
    pub board_z: f64,
    pub board_thickness: f64,
    pub height: f64,
    pub wall: f64,
    pub size: [f64; 3],
}

static PREVIEW_COUNTER: AtomicU64 = AtomicU64::new(0);

/// Builds the bodies selected by `target`.
///
/// `target` names a body, or a board (name or circuit file) whose generated bodies are
/// built. Without it, every body of the manifest is built. A board without bodies gets
/// a default enclosure. CLI `overrides` win over each generated body's settings.
/// Generated bodies are **fuse-gated**: a blown fuse stops them before any file is written.
pub fn run(
    target: Option<PathBuf>,
    overrides: EnclosureConfig,
    skip_lint: bool,
) -> Result<(), BodyError> {
    let (manifest, cfg) = FusibleConfig::nearest().unzip();
    let root = manifest
        .as_deref()
        .and_then(Path::parent)
        .unwrap_or(Path::new("."));
    for job in select_jobs(&cfg.unwrap_or_default(), target)? {
        match job {
            Job::Authored { name, body } => build_authored(&name, &body, root)?,
            Job::Generated { name, body, board } => {
                let settings = resolve_settings(overrides.clone(), body.enclosure)?;
                build_generated(&name, &board, &settings, skip_lint)?;
            }
        }
    }
    Ok(())
}

/// Runs the `build:` command of an authored body from `root`, then checks that its `source` exists.
pub(crate) fn build_authored(name: &str, body: &BodyConfig, root: &Path) -> Result<(), BodyError> {
    let path = body.source.clone().unwrap_or_default();
    if let Some(command) = &body.build {
        println!("🛠️  Building body `{name}`: \"{command}\"...");
        let failed = || BodyError::BuildFailed {
            name: name.to_string(),
            command: command.clone(),
        };
        let status = execute_shell(command, Some(root)).map_err(|_| failed())?;
        status.success().then_some(()).ok_or_else(failed)?;
    }
    if !path.is_file() {
        return Err(BodyError::MissingSource {
            name: name.to_string(),
            path,
        });
    }
    println!("✅ Body {name} (authored) -> {}", path.display());
    Ok(())
}

fn build_generated(
    name: &str,
    board: &BoardTarget,
    settings: &Settings,
    skip_lint: bool,
) -> Result<(), BodyError> {
    let circuit_json = fuse_gate(board, skip_lint)?;
    let out_dir = std::path::absolute(&settings.out_dir)?;
    let outputs = settings
        .formats
        .iter()
        .map(|&format| Output {
            format,
            path: out_dir.join(format!("{name}.{format}")),
        })
        .collect();
    std::fs::create_dir_all(&out_dir)?;
    for output in generate(&circuit_json, settings, outputs)?.outputs {
        println!(
            "✅ Body {name} ({}) -> {}",
            output.format,
            output.path.display()
        );
    }
    Ok(())
}

/// Builds a GLB preview of the mechanical model for the dev studio.
///
/// Uses the `mechanical:` section of the manifest, but always renders GLB into a
/// temporary file inside the generator cache and ignores `out_dir` and `format`.
/// Unlike [`run`], the preview is not fuse-gated. See [`BodyPreview`] for the frame.
pub fn preview(
    circuit_json: &str,
    manifest: Option<EnclosureConfig>,
) -> Result<BodyPreview, BodyError> {
    let settings = preview_settings(manifest.unwrap_or_default())?;
    let id = PREVIEW_COUNTER.fetch_add(1, Ordering::Relaxed);
    let path = generator_dir().join(format!("preview-{}-{id}.glb", std::process::id()));
    let outputs = vec![Output {
        format: "glb",
        path: path.clone(),
    }];
    let result = generate(circuit_json, &settings, outputs)
        .and_then(|spec| Ok(preview_from(&spec, std::fs::read(&path)?)));
    let _ = std::fs::remove_file(&path);
    result
}

/// Returns the settings of the first generated body for `board`, for the dev studio.
///
/// A generated body without `board:` fits every board.
pub(crate) fn preview_config(board: Option<&str>) -> Option<EnclosureConfig> {
    FusibleConfig::nearest().and_then(|(_, cfg)| fitted_settings(cfg, board))
}

fn fitted_settings(cfg: FusibleConfig, board: Option<&str>) -> Option<EnclosureConfig> {
    cfg.bodies
        .into_values()
        .find(|body| {
            body.generate.is_some() && (body.board.is_none() || body.board.as_deref() == board)
        })
        .map(|body| body.enclosure)
}

/// Lints the board unless `skip_lint` is set, and returns its Circuit JSON.
fn fuse_gate(board: &BoardTarget, skip_lint: bool) -> Result<String, BodyError> {
    if skip_lint {
        println!("⚠️  Skipping electrical rule verification (--skip-lint requested).\n");
        return Ok(check::load_circuit_source(&board.entry)?.1);
    }
    println!("🔍 Verifying electrical rules before generating bodies (fuse check)...");
    let (_, source) = check::lint_source(&board.entry, board.mcu.clone())?;
    println!("⚡ Fuse intact — proceeding with body generation.\n");
    Ok(source)
}

fn generate(
    circuit_json: &str,
    settings: &Settings,
    outputs: Vec<Output>,
) -> Result<Spec, BodyError> {
    let spec = build_spec(&PcbModel::parse(circuit_json), settings, outputs)?;
    run_generator(&spec)?;
    Ok(spec)
}