Construction software that runs on your own hardware. I hold none of your data. Why I build it this way →
Menu
  1. Home
  2. Get involved
  3. Writing a plugin
Contact
For developers

Write a plugin

A plugin is your own check or count, run inside Excalibur View on the drawing that's open. It reads the sheets and the takeoff, and hands back what it found and the fixes it suggests. Somebody clicks a fix, or doesn't. This page goes from an empty folder to a plugin on every seat in an office.

The plugin contract on GitHub

What it gets and what it can do

For each sheet the command covers, a plugin is handed the sheet's name, size and scale. If the command asks for them, it also gets the words printed on the sheet and its straight line-work with each line's pen width. Then every markup on those sheets: its kind, subject, label and points, its length in feet up any slope, area, count and quantity, the custom columns and the pounds. The office's column names come with it, so it knows which column is LBS Per FT.

It hands back a title, a summary, findings and tables. A finding is a note, a check or a problem, on a sheet, with an area the program outlines while the results are open. A finding can carry fixes, and a fix is a list of actions from this list and nothing else:

ActionWhat it does
set_sheet_scaleSets a sheet's scale, by ratio: 48 for 1/4" = 1'-0"
set_subjectChanges a markup's subject
set_columnWrites one of a markup's six custom columns
set_quantityHow many pieces a markup stands for
set_slopeA pitch, as rise over run
add_lengthTakes off a length along given points, with the office's tool for that subject when the chest has one
add_countCounts one item at each point given
add_areaTakes off the area inside an outline
add_cloudClouds an area, with a note
add_textPuts words in a box on the sheet

The program carries out a fix only when somebody clicks it, and each click is one step on the Undo list.

A plugin is a WebAssembly module, and one that imports anything is refused. So it has no files, no network and no clock, and it can't reach the program. Each run gets sixty billion instructions of work and a gigabyte and a half of memory, and is thrown away afterwards. The worst a plugin can do is be wrong.

Set up

Plugins are written in Rust. Anything else that compiles to WebAssembly and exports the same three functions works too, but the examples here are Rust.

  1. Install Rust from rustup.rs.
  2. Add the WebAssembly target: rustup target add wasm32-unknown-unknown
  3. Make a library: cargo new --lib acme-shop-checks
  4. Make its Cargo.toml look like this:
[package]
name = "acme-shop-checks"
version = "1.0.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
plugin-api = { git = "https://github.com/cngmoney88/excalibur-view", branch = "main", features = ["guest"] }
serde_json = "1"

[profile.release]
opt-level = "s"
lto = true
panic = "abort"
strip = true

The contract comes straight from the published source. Keep Cargo.lock in your repository so it only changes when you update it.

The code

A plugin is two functions and a macro. manifest says what it is and puts its commands in the Plugins menu. run does one command.

This one has two commands. Sheets With No Scale finds sheets with no scale set and offers the scale printed on the sheet as a fix. Lengths With No Weight finds lengths whose tool has no unit weight, and totals pounds by subject. It builds and runs as it stands. Put it in src/lib.rs:

use plugin_api::{Action, Command, Finding, Input, Level, Manifest, Needs, Output, Scope, Table};

pub fn manifest() -> Manifest {
    Manifest {
        id: "acme-shop-checks".into(),
        name: "Acme Shop Checks".into(),
        version: env!("CARGO_PKG_VERSION").into(),
        publisher: "Acme Steel".into(),
        description: "Missing scales and missing weights, before a number goes out.".into(),
        abi: plugin_api::ABI,
        commands: vec![
            Command {
                id: "scales".into(),
                name: "Sheets With No Scale".into(),
                description: "Finds sheets with no scale set and offers the one printed on them.".into(),
                scope: Scope::Drawing,
                needs: Needs { words: true, lines: false },
            },
            Command {
                id: "weights".into(),
                name: "Lengths With No Weight".into(),
                description: "Lengths whose tool has no unit weight, and pounds by subject.".into(),
                scope: Scope::Drawing,
                needs: Needs::default(),
            },
        ],
        settings: vec![],
    }
}

pub fn run(input: Input) -> Result<Output, String> {
    match input.command.as_str() {
        "scales" => Ok(scales(&input)),
        "weights" => Ok(weights(&input)),
        other => Err(format!("There is no command called {other}.")),
    }
}

plugin_api::plugin!(manifest, run);

/// The ratio for a scale as it is printed on a sheet.
fn ratio_of(text: &str) -> Option<f64> {
    let text = text.to_uppercase().replace(' ', "");
    [("1/8\"=1'-0\"", 96.0), ("3/16\"=1'-0\"", 64.0), ("1/4\"=1'-0\"", 48.0),
     ("3/8\"=1'-0\"", 32.0), ("1/2\"=1'-0\"", 24.0), ("3/4\"=1'-0\"", 16.0)]
        .iter()
        .find(|(printed, _)| text.contains(printed))
        .map(|(_, ratio)| *ratio)
}

fn scales(input: &Input) -> Output {
    let mut findings = Vec::new();
    for sheet in input.sheets.iter().filter(|s| s.scale.is_none()) {
        let printed = sheet.words.iter().find_map(|w| ratio_of(&w.text).map(|r| (w, r)));
        let finding = Finding::new(Level::Problem, format!("{} has no scale set.", sheet.name)).on(sheet.page);
        findings.push(match printed {
            Some((word, ratio)) => finding.at(word.area).fix(
                format!("Set it to {}", word.text),
                vec![Action::SetSheetScale { page: sheet.page, ratio, text: word.text.clone() }],
            ),
            None => finding,
        });
    }
    Output {
        title: "Sheets with no scale".into(),
        summary: format!("{} of {} sheets have no scale.", findings.len(), input.sheets.len()),
        findings,
        tables: vec![],
    }
}

fn weights(input: &Input) -> Output {
    let mut findings = Vec::new();
    let mut by_subject: std::collections::BTreeMap<String, (f64, f64, f64)> = Default::default();
    for m in input.markups.iter().filter(|m| m.kind == "length" || m.kind == "polylength") {
        let Some(feet) = m.length else { continue };
        let row = by_subject.entry(m.subject.clone()).or_default();
        row.0 += m.quantity;
        row.1 += feet * m.quantity;
        match m.pounds {
            Some(lb) => row.2 += lb,
            None => findings.push(
                Finding::new(Level::Check, format!("{} has no unit weight.", m.subject))
                    .on(m.page)
                    .at(bounds(&m.points))
                    .about(m.id.clone()),
            ),
        }
    }
    let rows = by_subject
        .iter()
        .map(|(s, (n, ft, lb))| vec![s.clone(), format!("{n}"), format!("{ft:.1}"), format!("{lb:.0}")])
        .collect();
    Output {
        title: "Lengths with no weight".into(),
        summary: format!("{} lengths have no unit weight.", findings.len()),
        findings,
        tables: vec![Table {
            title: "By subject".into(),
            columns: vec!["Subject".into(), "Pieces".into(), "Feet".into(), "Pounds".into()],
            rows,
            totals: vec![],
        }],
    }
}

fn bounds(points: &[[f64; 2]]) -> [f64; 4] {
    let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
    for p in points {
        b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])];
    }
    b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_printed_scale_reads_as_its_ratio() {
        assert_eq!(ratio_of("SCALE: 1/4\" = 1'-0\""), Some(48.0));
        assert_eq!(ratio_of("NTS"), None);
    }

    #[test]
    fn a_sheet_with_no_scale_is_found() {
        let text = std::fs::read_to_string("tests/S-201.json").unwrap();
        let mut input: Input = serde_json::from_str(&text).unwrap();
        // As if nobody had set the scale yet.
        input.sheets[0].scale = None;
        input.command = "scales".into();
        let output = run(input).unwrap();
        assert_eq!(output.findings.len(), 1);
    }
}

Coordinates are in points, measured right and down from the top left of the sheet as it shows on screen. Lengths are in feet and areas in square feet, whatever units the sheet is set up in. A setting you declare in the manifest appears above the results, and input.number, input.toggle and input.text read it back.

For a bigger example, the Mesa Fab estimating plugin checks scales against what's printed, reads member sizes off the drawing, finds heavy lines nobody took off and counts connections.

Test it on a real sheet

None of this needs a signature. Saving a sheet and the check both need Excalibur View 0.6.7 or newer.

  1. Open a drawing in Excalibur View, go to the sheet you want, and pick Plugins → Save This Sheet for a Plugin Test… It writes the sheet as a plugin would be handed it: words, line-work, scale, markups and column names, as JSON. The free version does this.
  2. Save it in your project as tests/S-201.json. The test at the bottom of the code above reads it and checks that the sheet is offered its scale.
  3. Run cargo test.
  4. Build the module: cargo build --release --target wasm32-unknown-unknown
  5. Run the built module in the same sandbox, with the same limits, that a signed plugin gets:
    "%LOCALAPPDATA%\Excalibur Hyperview\Hyperview.exe" --plugin-check target\wasm32-unknown-unknown\release\acme_shop_checks.wasm tests\S-201.json
    On a Mac the program is Excalibur View.app/Contents/MacOS/Excalibur View and takes the same flag.

The check reads the manifest and runs every command against the saved sheet, or against an empty drawing if you don't give it one. A window then says whether the plugin is ready to be signed, and what each command found. The full answers go in acme_shop_checks-check.json beside the module. Nothing is installed, and nothing shows up in the Plugins menu.

Get it signed

Excalibur View loads a plugin only when a key it was built with signed those exact bytes. It's the same rule it uses for its own updates, because an office server hands plugins to every seat and running a server shouldn't be enough to decide what runs on them.

Email the .wasm to hello@excaliburct.com with its id and version. Once I've read what it does, you get back a signed .hvplugin. The signature covers that build only, so a new version needs signing again.

Pick an id that starts with your company, like acme-shop-checks. It can have letters, digits, dashes and underscores. A newer version with the same id replaces the older one wherever it's installed.

If you'd rather nobody else signed it, you can build Excalibur View yourself from the source with your own key added, and your plugins run in your copies only. How that works.

Put it on every seat

On one computer, pick Plugins → Add Plugin… and choose the .hvplugin. Its commands show up in the Plugins menu.

With the Office server, do that as an administrator and the plugin goes to the server, which checks the signature again and gives it to every seat when they next check in. Plugins → Manage Plugins… shows what each seat has and who signed it, and an administrator can take one off the office's list.

Connecting another program

A plugin runs inside Excalibur View. To connect an estimating system, an ERP or your own scripts, use the Office server's API: projects, drawing sets, markups as PDF annotations, and the takeoff as JSON or CSV. Every server describes its own API at /openapi.json, and hyperview:// links open a set in the program from anywhere.

The integration guide has the keys, the routes and how writing back works.

Send me the first build

Or tell me what you want a plugin to check, and I'll say whether the contract can do it yet.