BREP_app 0.4.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
//! Documents: new / load / save-as-text / import / export / tabs. Files never
//! cross this boundary — the host reads and writes them and passes content.
use crate::automation::command::{parse_args, schema_of, Annotations, CommandSpec, Ctx, Empty, Handler, NoArgs, Outcome, Phase};
use crate::document::Document;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct LoadArgs {
    /// A `.BREP.json` document as text.
    pub json: String,
    /// The document's display name (the tab title). Optional.
    #[serde(default)]
    pub name: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ImportFormat {
    Step,
    Stl,
    Obj,
    Iges,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImportArgs {
    pub format: ImportFormat,
    /// The file's name, for the feature label.
    pub name: String,
    /// File content, base64.
    pub base64: String,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExportFormat {
    /// The native document (`history_request_json`).
    Brep,
    Step,
    Stl,
    Iges,
    /// Sheet-metal flat pattern.
    Dxf,
    Svg,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExportArgs {
    pub format: ExportFormat,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IndexArgs {
    pub index: usize,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CloseArgs {
    pub index: usize,
    /// Close even if the document has unsaved changes.
    #[serde(default)]
    pub discard: bool,
}

#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PartAttributeArgs {
    /// The attribute name, exactly as the BOM column configuration writes it
    /// after `part.` (`Part_Number`, `Material`, a custom `Alloy_Temper`).
    pub field: String,
    /// The value to store. A string, a number, or `null` / `""` to REMOVE the
    /// attribute — the last one removed takes the empty record with it, so a
    /// document that was never annotated saves exactly as before.
    pub value: Value,
}

#[derive(Serialize, schemars::JsonSchema)]
pub struct Tab {
    pub index: usize,
    pub title: String,
    pub name: Option<String>,
    pub dirty: bool,
}

#[derive(Serialize, schemars::JsonSchema)]
pub struct Tabs {
    pub active: usize,
    pub tabs: Vec<Tab>,
}

#[derive(Serialize, schemars::JsonSchema)]
pub struct Exported {
    pub format: String,
    pub text: String,
    pub bytes: usize,
}

fn tabs(ctx: &Ctx<'_>) -> Tabs {
    let docs = &ctx.app.docs;
    Tabs {
        active: docs.active_index(),
        tabs: docs
            .iter()
            .enumerate()
            .map(|(i, d)| Tab { index: i, title: d.title(), name: d.name().map(str::to_string), dirty: d.is_dirty() })
            .collect(),
    }
}

fn doc_new(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let _: NoArgs = parse_args(args)?;
    let mut engine = ctx.app.docs.spawn_engine();
    let _ = engine.set_history_json(crate::document::EMPTY_DOCUMENT);
    let index = ctx.app.docs.open_document(Document::new(engine));
    Ok(Outcome::Done(json!({ "index": index })))
}

fn doc_load(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: LoadArgs = parse_args(args)?;
    let mut engine = ctx.app.docs.spawn_engine();
    let report = engine.load_model_and_fit(&a.json)?;
    let mut doc = Document::new(engine);
    doc.set_name(a.name.clone());
    doc.mark_clean();
    let index = ctx.app.docs.open_document(doc);
    Ok(Outcome::Done(json!({ "index": index, "name": a.name, "report": parse(&report) })))
}

fn doc_json(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let _: NoArgs = parse_args(args)?;
    let text = ctx.app.docs.engine().history_request_json();
    Ok(Outcome::Done(json!({ "document": parse(&text), "bytes": text.len() })))
}

fn part_attributes_get(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let _: NoArgs = parse_args(args)?;
    let attributes = ctx.app.docs.engine().document_part_attributes();
    Ok(Outcome::Done(json!({ "attributes": attributes })))
}

fn part_attribute_set(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: PartAttributeArgs = parse_args(args)?;
    let engine = ctx.app.docs.engine_mut();
    engine.set_document_part_attribute(&a.field, a.value)?;
    Ok(Outcome::Done(json!({ "attributes": engine.document_part_attributes() })))
}

fn doc_import(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: ImportArgs = parse_args(args)?;
    let bytes = base64_decode(&a.base64)?;
    let engine = ctx.app.docs.engine_mut();
    let report = match a.format {
        ImportFormat::Step => engine.import_step_feature(&String::from_utf8_lossy(&bytes))?,
        ImportFormat::Iges => engine.import_iges_feature(&String::from_utf8_lossy(&bytes))?,
        ImportFormat::Stl => engine.import_stl_feature(&bytes)?,
        ImportFormat::Obj => engine.import_obj_bytes_feature(&bytes)?,
    };
    Ok(Outcome::Done(json!({ "name": a.name, "report": parse(&report) })))
}

fn doc_export(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: ExportArgs = parse_args(args)?;
    // The root PRODUCT takes the document's name, as the UI's Export does.
    let document_name = ctx.app.docs.active().name().unwrap_or("Part").to_string();
    let engine = ctx.app.docs.engine_mut();
    let (format, text) = match a.format {
        ExportFormat::Brep => ("brep", engine.history_request_json()),
        ExportFormat::Step => ("step", engine.export_step_text_named(&document_name)?),
        ExportFormat::Stl => ("stl", engine.export_stl_text()?),
        ExportFormat::Iges => ("iges", engine.export_iges_text()?),
        ExportFormat::Dxf => ("dxf", engine.export_flat_pattern_dxf()?),
        ExportFormat::Svg => ("svg", engine.export_flat_pattern_svg()?),
    };
    let bytes = text.len();
    serde_json::to_value(Exported { format: format.into(), text, bytes }).map(Outcome::Done).map_err(|e| e.to_string())
}

fn docs_list(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let _: NoArgs = parse_args(args)?;
    serde_json::to_value(tabs(ctx)).map(Outcome::Done).map_err(|e| e.to_string())
}

fn doc_activate(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: IndexArgs = parse_args(args)?;
    if a.index >= ctx.app.docs.len() {
        return Err(format!("no document {} ({} open)", a.index, ctx.app.docs.len()));
    }
    ctx.app.docs.activate(a.index);
    serde_json::to_value(tabs(ctx)).map(Outcome::Done).map_err(|e| e.to_string())
}

fn doc_close(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let a: CloseArgs = parse_args(args)?;
    let Some(doc) = ctx.app.docs.get(a.index) else {
        return Err(format!("no document {} ({} open)", a.index, ctx.app.docs.len()));
    };
    if doc.is_dirty() && !a.discard {
        return Err(format!("document {} has unsaved changes; pass discard: true to close it anyway", a.index));
    }
    ctx.app.docs.close(a.index);
    serde_json::to_value(tabs(ctx)).map(Outcome::Done).map_err(|e| e.to_string())
}

fn doc_mark_clean(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
    let _: NoArgs = parse_args(args)?;
    ctx.app.docs.active_mut().mark_clean();
    Ok(Outcome::Done(json!({})))
}

pub(crate) fn parse(text: &str) -> Value {
    serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.to_string()))
}

/// Standard base64 (with or without padding); no dependency.
pub(crate) fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
    fn val(c: u8) -> Result<u32, String> {
        Ok(match c {
            b'A'..=b'Z' => (c - b'A') as u32,
            b'a'..=b'z' => (c - b'a') as u32 + 26,
            b'0'..=b'9' => (c - b'0') as u32 + 52,
            b'+' | b'-' => 62,
            b'/' | b'_' => 63,
            _ => return Err(format!("invalid base64 byte {c:#x}")),
        })
    }
    let mut out = Vec::with_capacity(s.len() * 3 / 4);
    let mut acc: u32 = 0;
    let mut bits = 0;
    for &c in s.as_bytes() {
        if c == b'=' || c == b'\n' || c == b'\r' || c == b' ' {
            continue;
        }
        acc = (acc << 6) | val(c)?;
        bits += 6;
        if bits >= 8 {
            bits -= 8;
            out.push(((acc >> bits) & 0xff) as u8);
        }
    }
    Ok(out)
}

pub static COMMANDS: &[CommandSpec] = &[
    CommandSpec { name: "doc_new", group: "document", doc: "Open a new, empty document as a tab and make it active.", phase: Phase::Mutate, annotations: Annotations::MUTATE, args_schema: schema_of::<NoArgs>, result_schema: schema_of::<Empty>, handler: Handler::App(doc_new) },
    CommandSpec { name: "doc_load", group: "document", doc: "Open a `.BREP.json` document (passed as text) in a new tab, run its history and fit the camera. Returns the run report.", phase: Phase::Mutate, annotations: Annotations::MUTATE, args_schema: schema_of::<LoadArgs>, result_schema: schema_of::<Empty>, handler: Handler::App(doc_load) },
    CommandSpec { name: "doc_json", group: "document", doc: "The active document as `.BREP.json` (the history request with metadata).", phase: Phase::Read, annotations: Annotations::READ, args_schema: schema_of::<NoArgs>, result_schema: schema_of::<Empty>, handler: Handler::App(doc_json) },
    CommandSpec { name: "doc_import", group: "document", doc: "Append an Import 3D Model feature from STEP / IGES text or STL / OBJ bytes (base64). STL and OBJ go through mesh reconstruction on the runner.", phase: Phase::Mutate, annotations: Annotations::MUTATE, args_schema: schema_of::<ImportArgs>, result_schema: schema_of::<Empty>, handler: Handler::App(doc_import) },
    CommandSpec { name: "doc_export", group: "document", doc: "Export the active document as text: brep (native JSON), step, stl, iges, or the sheet-metal flat pattern as dxf / svg.", phase: Phase::Read, annotations: Annotations::READ, args_schema: schema_of::<ExportArgs>, result_schema: schema_of::<Exported>, handler: Handler::App(doc_export) },
    CommandSpec { name: "part_attributes_get", group: "document", doc: "The active document's own BOM part attributes (`partAttributes`) — the record the toolbar's Part Properties dialog edits, and the one an assembly's BOM reads off this part.", phase: Phase::Read, annotations: Annotations::READ, args_schema: schema_of::<NoArgs>, result_schema: schema_of::<Empty>, handler: Handler::App(part_attributes_get) },
    CommandSpec { name: "part_attribute_set", group: "document", doc: "Write ONE of the active document's own BOM part attributes (Part_Number, Material, Mass, or any custom field); a null or empty value removes it. One undo step per field.", phase: Phase::Mutate, annotations: Annotations::MUTATE, args_schema: schema_of::<PartAttributeArgs>, result_schema: schema_of::<Empty>, handler: Handler::App(part_attribute_set) },
    CommandSpec { name: "docs_list", group: "document", doc: "The open document tabs and which is active.", phase: Phase::Read, annotations: Annotations::READ, args_schema: schema_of::<NoArgs>, result_schema: schema_of::<Tabs>, handler: Handler::App(docs_list) },
    CommandSpec { name: "doc_activate", group: "document", doc: "Make the tab at `index` the active document.", phase: Phase::Mutate, annotations: Annotations::MUTATE_NOWAIT, args_schema: schema_of::<IndexArgs>, result_schema: schema_of::<Tabs>, handler: Handler::App(doc_activate) },
    CommandSpec { name: "doc_close", group: "document", doc: "Close the tab at `index`; refuses a dirty document unless `discard` is true.", phase: Phase::Mutate, annotations: Annotations::DESTRUCTIVE, args_schema: schema_of::<CloseArgs>, result_schema: schema_of::<Tabs>, handler: Handler::App(doc_close) },
    CommandSpec { name: "doc_mark_clean", group: "document", doc: "Mark the active document as saved (the host wrote `doc_json` to disk).", phase: Phase::Mutate, annotations: Annotations::MUTATE_NOWAIT, args_schema: schema_of::<NoArgs>, result_schema: schema_of::<Empty>, handler: Handler::App(doc_mark_clean) },
];