BREP_app 0.3.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
//! Compiles `assets/glyphs/*.svg` into the inline-icon catalog read by
//! `src/icons.rs`.
//!
//! Those SVGs are the ONLY source of icon artwork in the app. This script
//! compiles them into `&'static str` SVG source so [`crate::icon_text`] can draw
//! them as real images — adding a file registers the icon, with no
//! hand-maintained list to drift. (`BREP_docs` copies the same files into the
//! documentation site.) There is no icon font: a glyph character is only ever a
//! KEY into this catalog.
//!
//! The inline transforms happen here rather than at run time, so the app pays nothing
//! per frame:
//!
//! * **The ink sentinel.** A monochrome icon paints itself `#111`. That is not
//!   artwork, it is "whatever colour the text is" — so a file whose every
//!   `fill` is `#111` is marked `mono` and rewritten
//!   to `#fff`, which the widget multiplies by the live text colour (white is
//!   the identity for a multiply, so the tint lands exactly). A file with ANY
//!   other fill is authored colour: it is left alone and never tinted. That is
//!   the seam for real multi-colour icons — hand-edit a glyph SVG to use real
//!   fills and it renders in colour, with no code change here or anywhere else.
//!   `#111` stays on disk so the glyphs remain visible in an SVG editor.
//!
//! * **The aspect ratio**, read from the viewBox. It is the icon's own box, so
//!   `width/height` is the proportion to draw it at. We trust the viewBox and never parse path
//!   geometry — that is what keeps a hand-authored SVG (groups, strokes,
//!   gradients, whatever an editor emits) working here.
//!
//! A separate tile SVG uses usvg's painted bounds to remove font-era padding.
//! Inline icons retain their original canvas and alignment.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

fn main() {
    let glyph_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/glyphs");
    // The directory catches added/removed glyphs; the per-file lines catch edits.
    println!("cargo:rerun-if-changed={}", glyph_dir.display());
    println!("cargo:rerun-if-changed=build.rs");

    // BTreeMap so the emitted table is sorted by codepoint — `icons::lookup`
    // binary-searches it.
    let mut icons: BTreeMap<u32, Icon> = BTreeMap::new();

    let entries = std::fs::read_dir(&glyph_dir)
        .unwrap_or_else(|e| panic!("read {}: {e}", glyph_dir.display()));
    for entry in entries {
        let path = entry.expect("read glyph dir entry").path();
        if path.extension().and_then(|e| e.to_str()) != Some("svg") {
            continue;
        }
        println!("cargo:rerun-if-changed={}", path.display());
        if let Some(icon) = parse(&path) {
            if let Some(prev) = icons.insert(icon.cp, icon) {
                panic!("two glyph SVGs claim U+{:04X} (one is {})", prev.cp, prev.name);
            }
        }
    }

    assert!(!icons.is_empty(), "no glyph SVGs found in {}", glyph_dir.display());

    let mut out = String::from(
        "// @generated by build.rs from assets/glyphs/*.svg — do not edit.\n\
         /// Every catalogued icon, sorted by codepoint (`lookup` binary-searches this).\n\
         pub static ICONS: &[Icon] = &[\n",
    );
    for icon in icons.values() {
        out.push_str(&format!(
            "    Icon {{ ch: '\\u{{{cp:04X}}}', name: {name:?}, \
             uri: \"bytes://brep-icons/{cp:04X}.svg\", svg: {svg:?}, \
             aspect: {aspect:?}, mono: {mono}, artwork_svg: {artwork_svg:?}, artwork_aspect: {artwork_aspect:?} }},\n",
            cp = icon.cp,
            name = icon.name,
            svg = icon.svg,
            aspect = icon.aspect,
            mono = icon.mono,
            artwork_svg = icon.artwork_svg,
            artwork_aspect = icon.artwork_aspect,
        ));
    }
    out.push_str("];\n");

    let dest = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")).join("icon_catalog.rs");
    std::fs::write(&dest, out).unwrap_or_else(|e| panic!("write {}: {e}", dest.display()));
}

struct Icon {
    cp: u32,
    name: String,
    svg: String,
    aspect: f32,
    mono: bool,
    artwork_svg: String,
    artwork_aspect: f32,
}

/// Read one glyph SVG into a catalog entry, or `None` if it carries no
/// codepoint — nothing in a string could ever select it.
fn parse(path: &Path) -> Option<Icon> {
    let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    let file = path.display();

    let hex = attr(&text, "brep:unicode")?;
    if hex.trim().is_empty() {
        return None;
    }
    let cp = u32::from_str_radix(hex.trim(), 16)
        .unwrap_or_else(|_| panic!("{file}: brep:unicode = {hex:?} is not hex"));
    char::from_u32(cp).unwrap_or_else(|| panic!("{file}: U+{cp:04X} is not a character"));

    // viewBox="minx miny width height" — the icon's own box, so w/h is the
    // proportion to draw it at.
    let vb = attr(&text, "viewBox").unwrap_or_else(|| panic!("{file}: no viewBox"));
    let nums: Vec<f32> = vb
        .split_whitespace()
        .map(|n| n.parse().unwrap_or_else(|_| panic!("{file}: bad viewBox {vb:?}")))
        .collect();
    let [_, _, w, h] = nums[..] else { panic!("{file}: viewBox needs 4 numbers, got {vb:?}") };
    assert!(w > 0.0 && h > 0.0, "{file}: degenerate viewBox {vb:?}");

    // The ink sentinel — see the module comment.
    let mono = fills(&text).all(|f| f.eq_ignore_ascii_case("#111"));
    let svg = if mono { text.replace("\"#111\"", "\"#fff\"") } else { text };

    let name = attr(&svg, "brep:glyph-name").unwrap_or_else(|| format!("U+{cp:04X}"));
    // The parser accounts for transforms, strokes and filter extents. Its
    // serialized geometry is in viewport coordinates, so crop that document.
    let tree = usvg::Tree::from_str(&svg, &usvg::Options::default())
        .unwrap_or_else(|e| panic!("{file}: invalid SVG: {e}"));
    let bounds = tree.root().abs_layer_bounding_box();
    let serialized = tree.to_string(&usvg::WriteOptions::default());
    let body = &serialized[serialized.find('>').expect("SVG root") + 1..];
    let artwork_svg = format!(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"{} {} {} {}\" width=\"{}\" height=\"{}\">{}",
        bounds.x(), bounds.y(), bounds.width(), bounds.height(),
        bounds.width(), bounds.height(), body,
    );
    Some(Icon { cp, name, svg, aspect: w / h, mono, artwork_svg,
        artwork_aspect: bounds.width() / bounds.height() })
}

/// The value of an XML attribute, matched literally as `name="value"`. The
/// glyph SVGs use this metadata format; the SVG parser handles their geometry.
fn attr(text: &str, name: &str) -> Option<String> {
    let at = text.find(&format!("{name}=\""))? + name.len() + 2;
    let rest = &text[at..];
    Some(rest[..rest.find('"')?].to_owned())
}

/// Every `fill="…"` value in the document.
fn fills(text: &str) -> impl Iterator<Item = &str> {
    text.match_indices("fill=\"").filter_map(|(i, m)| {
        let rest = &text[i + m.len()..];
        rest.find('"').map(|end| &rest[..end])
    })
}