BREP_render 0.4.0

BREP Rust rendering engine: kernel-fed scene store + wgpu renderer (headless artifact, desktop window, and wasm canvas shells).
Documentation
use super::*;
use serde_json::Value;

// ===========================================================================
// BOM export (assemblies build-spec §9) — the parts list straight off the
// MAIN-SIDE parts library + live component projection, both already warm via
// the assembly sync (`sync_assembly` re-executes main-side after each applied
// run). One row per parts-library entry: `{partName, sourceKey, quantity}`
// with quantity = live ACOMP instance count. A sub-assembly is ONE row at
// this level — its internal parts are its own document's business (the
// rigid-nesting model). Exported as CSV and JSON through the file dialog's
// Export modal; deliberately small: no per-configuration quantities, no
// extra columns, no localization.
// ===========================================================================

/// One BOM row: a parts-library entry + its live instance count. Field order
/// IS the exported JSON key order (serde serializes structs in declaration
/// order), so the record shape stays `{partName, sourceKey, quantity}`.
#[derive(serde::Serialize)]
struct BomRow {
    #[serde(rename = "partName")]
    part_name: String,
    #[serde(rename = "sourceKey")]
    source_key: String,
    quantity: usize,
}

/// Quote a CSV field only when it needs it (comma / quote / CR / LF),
/// doubling embedded quotes — minimal RFC-4180 so a part name or store path
/// with a comma can never shear a row.
fn csv_field(text: &str) -> String {
    if text.contains([',', '"', '\n', '\r']) {
        format!("\"{}\"", text.replace('"', "\"\""))
    } else {
        text.to_string()
    }
}

impl EngineState {
    /// The BOM rows in parts-library order (BTreeMap ⇒ alphabetical part
    /// name — deterministic). Quantity counts component RECORDS (instances),
    /// not member solids, so a multi-body part is still one per placement.
    /// Errs (`"no components in the assembly"`) on a componentless document.
    fn bom_rows(&mut self) -> Result<Vec<BomRow>, String> {
        self.ensure_assembly_synced();
        if self.assembly_components.is_empty() {
            return Err("no components in the assembly".into());
        }
        let library: std::collections::BTreeMap<String, Value> =
            serde_json::from_str(&brep_kernel::parts_library_json())
                .map_err(|error| format!("parts library unreadable: {error}"))?;
        Ok(library
            .into_iter()
            .map(|(part_name, entry)| {
                let quantity = self
                    .assembly_components
                    .iter()
                    .filter(|record| record.part_name == part_name)
                    .count();
                BomRow {
                    source_key: entry
                        .get("sourceKey")
                        .and_then(Value::as_str)
                        .unwrap_or_default()
                        .to_string(),
                    part_name,
                    quantity,
                }
            })
            // The kernel GCs zero-instance entries at the end of every run;
            // the filter keeps the export honest mid-mutation regardless.
            .filter(|row| row.quantity > 0)
            .collect())
    }

    /// Export the assembly BOM as CSV: the exact `partName,sourceKey,quantity`
    /// header, one line per parts-library entry, LF endings.
    pub fn export_bom_csv(&mut self) -> Result<String, String> {
        let mut out = String::from("partName,sourceKey,quantity\n");
        for row in self.bom_rows()? {
            out.push_str(&format!(
                "{},{},{}\n",
                csv_field(&row.part_name),
                csv_field(&row.source_key),
                row.quantity
            ));
        }
        Ok(out)
    }

    /// Export the assembly BOM as a JSON array of the same records — the CSV
    /// sibling of [`Self::export_bom_csv`].
    pub fn export_bom_json(&mut self) -> Result<String, String> {
        serde_json::to_string(&self.bom_rows()?)
            .map_err(|error| format!("BOM serialize: {error}"))
    }

    // ======================================================================
    // BOM ATTRIBUTES — the editable columns behind the BOM panel
    //
    // Two stores, because the data has two lifetimes:
    //
    // * PART attributes (Part Number, Material, Mass…) describe the PART, so
    //   they live on the part's OWN document, under the top-level
    //   [`PART_ATTRIBUTES`] key: `partsLibrary[part].document.partAttributes`.
    //   Being on the part document means they travel WITH the part — the
    //   write-through lane saves that same document back to its `sourceKey`,
    //   so opening the part standalone shows the same Part Number. `History`
    //   keeps unknown top-level document keys verbatim (`from_request_json`
    //   parses into a `Value` and only lifts out the keys it owns), so the key
    //   round-trips through save/open with no format work.
    //
    // * OCCURRENCE attributes (Item Number, Reference Designator, Find
    //   Number…) describe ONE PLACEMENT, so they live on the placing ACOMP
    //   feature, under [`OCCURRENCE_ATTRIBUTES`] —
    //   `feature.inputParams.bom`. NESTED rather than flat so a user-added
    //   custom column can never collide with a schema param (`isFixed`,
    //   `partName`, `transform`); the ACOMP builder reads its four keys by
    //   name and ignores the rest, and the solver write-back fold
    //   (`assembly_apply_document_json`) INSERTS `transform`/`isFixed` into the
    //   existing params object rather than rebuilding it, so a solve never
    //   strips this.
    //
    // Quantity is deliberately absent from both: it is DERIVED (how many
    // occurrences a packed row rolls up), never stored, so it can never
    // disagree with the model.
    // ======================================================================

    /// Read a part's attribute record (`{}` when the part has none / is
    /// unknown). Never errs — a BOM row for a part mid-import simply shows
    /// blanks.
    pub fn part_attributes(&self, part_name: &str) -> Value {
        self.history
            .parts_library()
            .get(part_name)
            .and_then(|entry| entry.get("document"))
            .and_then(|document| document.get(PART_ATTRIBUTES))
            .filter(|value| value.is_object())
            .cloned()
            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
    }

    /// A part's `(sourceKey, sourceSignature)` — what the app's write-through
    /// lane needs to decide whether the file on disk is still the one this
    /// entry was built from. `None` for an unknown part; the key is returned
    /// even when EMPTY (embedded-only), because "" is exactly what the
    /// write-through lane checks for.
    pub fn part_source(&self, part_name: &str) -> Option<(String, String)> {
        let entry = self.history.parts_library().get(part_name)?;
        Some((
            entry
                .get("sourceKey")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .to_string(),
            entry
                .get("sourceSignature")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .to_string(),
        ))
    }

    /// A part's embedded document as text — the payload the app writes through
    /// to the part's `sourceKey` after an attribute edit.
    pub fn part_document_json(&self, part_name: &str) -> Option<String> {
        self.history
            .parts_library()
            .get(part_name)
            .and_then(|entry| entry.get("document"))
            .map(|document| document.to_string())
    }

    /// Write ONE part attribute. `Value::Null` (or an empty string) REMOVES the
    /// key, so clearing a cell leaves no `""` litter in the saved document.
    ///
    /// # Why this is not just a document edit
    ///
    /// The document's `partsLibrary` block is a MIRROR of the kernel's
    /// main-side store, not the truth: `sync_assembly` re-serializes the store
    /// over the block after every run, and the per-run request does not carry
    /// the block at all ([`History::prefix_request`] omits it). So a change
    /// written only into the block is erased by the next sync. This therefore
    /// writes BOTH: the kernel store (via `refresh_library_entry`, which is the
    /// same door edit-in-place and update-components use) and the document
    /// block + undo checkpoint. [`Self::undo`] re-installs the rewound block
    /// into the store, which is what makes the pair rewind together.
    ///
    /// The `snapshot` is deliberately KEPT: an attribute is not geometry, so
    /// there is nothing to re-evaluate. `refresh_library_entry` still marks the
    /// entry dirty (its contract), so the ACOMP self-heal re-derives it on the
    /// next run — correct, just not free. That is the price of the attributes
    /// living on the part document, and it is why the panel commits a text cell
    /// on focus-loss rather than per keystroke.
    pub fn set_part_attribute(
        &mut self,
        part_name: &str,
        key: &str,
        value: Value,
    ) -> Result<(), String> {
        if key.is_empty() {
            return Err("part attribute: empty key".to_string());
        }
        let mut block = self.history.parts_library().clone();
        let entry = block
            .get_mut(part_name)
            .ok_or_else(|| format!("no parts-library entry '{part_name}'"))?;
        let document = entry
            .get_mut("document")
            .filter(|value| value.is_object())
            .ok_or_else(|| format!("part '{part_name}': malformed document"))?;
        write_attribute(document, PART_ATTRIBUTES, key, value)?;
        let document_text = document.to_string();
        let signature = super::document_signature(&document_text);
        entry["sourceSignature"] = Value::String(signature.clone());

        // The kernel store is the block's authority — write it there too, or
        // the next `sync_assembly` mirrors the OLD entry back over this edit.
        brep_kernel::refresh_library_entry(part_name, &signature, &document_text)
            .map_err(|error| format!("part '{part_name}': {error:?}"))?;
        self.history
            .set_parts_library_edited(block, Some(&format!("partattr:{part_name}:{key}")));
        self.rerun_history();
        Ok(())
    }

    // --- The document's OWN part attributes -------------------------------
    //
    // The same `partAttributes` record, on the document you have OPEN rather
    // than on a library entry's embedded one. A part document IS a part, so it
    // carries the BOM data of the part it describes — and an assembly document
    // does too, because a rigidly nested assembly is one BOM row in its parent.
    // The toolbar's Properties dialog is the door; the BOM panel's part columns
    // are the other one, onto the same key of a different document.

    /// The open document's own attribute record (`{}` when it has none).
    /// Never errs — a document that was never annotated simply reads blank.
    pub fn document_part_attributes(&self) -> Value {
        self.history
            .part_attributes_block()
            .filter(|value| value.is_object())
            .cloned()
            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
    }

    /// Write ONE attribute on the open document. `Value::Null` (or an empty
    /// string) REMOVES the key, and the last key takes the empty record with
    /// it — [`write_attribute`]'s rules, shared with the library-entry lane, so
    /// a document that was never annotated serializes exactly as before.
    ///
    /// Unlike [`Self::set_part_attribute`] there is no kernel store to keep in
    /// step: this record belongs to the document in hand, not to a
    /// parts-library mirror, so the write is document + undo checkpoint and
    /// nothing else. The re-run that follows is not for geometry — the history
    /// is unchanged, so every feature is a cache hit — it is what moves the
    /// applied-run generation, which is what refreshes the tab's dirty dot.
    pub fn set_document_part_attribute(
        &mut self,
        key: &str,
        value: Value,
    ) -> Result<(), String> {
        if key.is_empty() {
            return Err("part attribute: empty key".to_string());
        }
        // Rebuild the record through the SHARED writer by handing it an owner
        // shaped like the document, so create / clear / drop-empty behave
        // identically on both doors rather than being written twice.
        let mut owner = Value::Object(serde_json::Map::new());
        if let Some(block) = self.history.part_attributes_block() {
            let block = block.clone();
            if let Some(object) = owner.as_object_mut() {
                object.insert(PART_ATTRIBUTES.to_string(), block);
            }
        }
        write_attribute(&mut owner, PART_ATTRIBUTES, key, value)?;
        let block = owner
            .as_object_mut()
            .and_then(|object| object.remove(PART_ATTRIBUTES));
        self.history
            .set_part_attributes_block(block, Some(&format!("docpartattr:{key}")));
        self.rerun_history();
        Ok(())
    }

    /// Read ONE occurrence's attribute record (`{}` when it has none).
    pub fn occurrence_attributes(&self, component_id: &str) -> Value {
        self.history
            .index_of(component_id)
            .and_then(|index| self.history.feature_params(index))
            .and_then(|params| params.get(OCCURRENCE_ATTRIBUTES).cloned())
            .filter(Value::is_object)
            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
    }

    /// Write ONE occurrence attribute across `component_ids` — ONE undo step
    /// however many ids there are.
    ///
    /// The fan-out lane: a PACKED BOM row rolls up every occurrence of a part
    /// whose occurrence data matches, and editing that row's cell must apply to
    /// all of them. One id (the unpacked case) is the same call with a
    /// one-element slice, so there is no second code path to keep in step.
    /// `Value::Null` / `""` removes the key.
    pub fn set_occurrence_attribute(
        &mut self,
        component_ids: &[String],
        key: &str,
        value: Value,
    ) -> Result<(), String> {
        if key.is_empty() {
            return Err("occurrence attribute: empty key".to_string());
        }
        let mut edits: Vec<(String, Value)> = Vec::with_capacity(component_ids.len());
        for id in component_ids {
            let index = self
                .history
                .index_of(id)
                .ok_or_else(|| format!("no component feature '{id}'"))?;
            let mut params = self
                .history
                .feature_params(index)
                .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
            if !params.is_object() {
                return Err(format!("component '{id}': malformed inputParams"));
            }
            write_attribute(&mut params, OCCURRENCE_ATTRIBUTES, key, value.clone())?;
            edits.push((id.clone(), params));
        }
        self.update_many_feature_params(&edits)?;
        Ok(())
    }
}

/// The part document's attribute-record key (see the module's BOM-attributes
/// block). A top-level document key, so it rides save/open untouched.
pub const PART_ATTRIBUTES: &str = "partAttributes";

/// The ACOMP `inputParams` attribute-record key.
pub const OCCURRENCE_ATTRIBUTES: &str = "bom";

/// Set (or, for a null/empty value, REMOVE) `record[key]` inside `owner`'s
/// attribute record, creating the record on first write and dropping it again
/// when the last attribute goes — so a document that was never annotated
/// serializes exactly as it did before (the `metadata` field's convention).
fn write_attribute(
    owner: &mut Value,
    record_key: &str,
    key: &str,
    value: Value,
) -> Result<(), String> {
    let object = owner
        .as_object_mut()
        .ok_or_else(|| "attribute owner is not an object".to_string())?;
    let clearing = matches!(&value, Value::Null)
        || matches!(&value, Value::String(text) if text.is_empty());
    if clearing {
        let mut empty = false;
        if let Some(record) = object.get_mut(record_key).and_then(Value::as_object_mut) {
            record.remove(key);
            empty = record.is_empty();
        }
        if empty {
            object.remove(record_key);
        }
        return Ok(());
    }
    let record = object
        .entry(record_key.to_string())
        .or_insert_with(|| Value::Object(serde_json::Map::new()));
    if !record.is_object() {
        *record = Value::Object(serde_json::Map::new());
    }
    record
        .as_object_mut()
        .expect("just normalized to an object")
        .insert(key.to_string(), value);
    Ok(())
}

// BREP private tests: e54c6e3cc5cd69f9