forked from 1b-agent/fieldcraft-upstream
Toolbars for every capability, VectorCraft icons, coming soon for planned ones (#20)
* feat(engine): capability catalogue — every toolbar entry, ready or coming soon, and docs/features.md Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * feat: 98 toolbar icons drawn with VectorCraft from recipes (cargo xtask icons), attributed Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * feat(ui): toolbar of tabs and panels from the catalogue, VectorCraft icons recoloured per theme, coming-soon entries greyed with their milestone; ui.toolbar Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * refactor: ready entries are their command; one tooltip; tabs and panels as catalogue runs; icon cache per theme and size; ui.toolbar read-only Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: roadmap notes the toolbar and icons Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * feat(ui): deck entries run deck.write with their format, ready when the writer is in FORMATS; file and edit entries; compact and collapsed panels instead of scrolling Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: features page after the Nastran writer (#24) made its deck entry ready Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: features page after the LS-DYNA writer (#25) made its deck entry ready Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * feat: solve.static, solve.frequency and solve.probe on the toolbar (Solve and Results tabs) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
320 files changed
+2990
-88
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
//! `cargo xtask assets`: every non-code asset in the repository must have a row in
|
||||
//! ATTRIBUTION.md (author, source, licence). Icons are drawn in code and need no file.
|
||||
//! ATTRIBUTION.md (author, source, licence), toolbar icons (`assets/icons`) included.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! `cargo xtask icons [ID…]`: draw the toolbar icons with VectorCraft.
|
||||
//!
|
||||
//! Each icon is a recipe, `assets/icons/src/<id>.json`: a list of shapes on a 24 × 24 pixel
|
||||
//! artboard, each a path (`d`), `circle` `[cx, cy, r]`, `rect` `[x, y, w, h]` (optional
|
||||
//! `radius`), `line` `[x1, y1, x2, y2]` or `star` `[cx, cy, outer, inner, points]`, painted with
|
||||
//! palette roles (`fill`, `stroke`: `line`, `accent`, `soft`, `warm`; a shape with neither is
|
||||
//! stroked with `line`), an optional stroke `width` (default 1.5; round caps and joins) and
|
||||
//! `dash` (`[dash, gap]`). The task turns every recipe into VectorCraft
|
||||
//! commands, runs them headlessly with `vectorcraft-cli run` (`VECTORCRAFT_CLI`, else
|
||||
//! `vectorcraft-cli` on the PATH) and writes `assets/icons/<id>.vectorcraft` (the editable
|
||||
//! source) and `assets/icons/<id>.svg` (what the app draws). The roles' colours are the dark
|
||||
//! theme's; the app recolours each role with its theme tokens (`crates/ui-egui/src/icons.rs`).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Palette roles and the colour each is drawn in (the dark theme's tokens). Keep in step with
|
||||
/// `PALETTE` in `crates/ui-egui/src/icons.rs`.
|
||||
pub const PALETTE: &[(&str, &str)] = &[("line", "#dde2ea"), ("accent", "#4f9ce8"), ("soft", "#2d4e70"), ("warm", "#f0b44a")];
|
||||
|
||||
/// The artboard side (pixels) and the default stroke weight.
|
||||
const SIZE: f64 = 24.0;
|
||||
const STROKE: f64 = 1.5;
|
||||
|
||||
pub fn run(root: &Path, only: &[&str]) -> Result<(), String> {
|
||||
let dir = root.join("assets/icons");
|
||||
let src = dir.join("src");
|
||||
let mut recipes: Vec<String> = std::fs::read_dir(&src)
|
||||
.map_err(|e| format!("{}: {e}", src.display()))?
|
||||
.flatten()
|
||||
.filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".json")).map(str::to_string))
|
||||
.filter(|id| only.is_empty() || only.contains(&id.as_str()))
|
||||
.collect();
|
||||
recipes.sort();
|
||||
if recipes.is_empty() {
|
||||
return Err("no icon recipes to draw".into());
|
||||
}
|
||||
let cli = std::env::var("VECTORCRAFT_CLI").unwrap_or_else(|_| "vectorcraft-cli".into());
|
||||
for id in &recipes {
|
||||
let path = src.join(format!("{id}.json"));
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let recipe: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let steps = commands(id, &recipe, &dir.join(format!("{id}.vectorcraft"))).map_err(|e| format!("{id}: {e}"))?;
|
||||
let mut c = Command::new(&cli);
|
||||
c.arg("run");
|
||||
for (cmd, params) in &steps {
|
||||
c.args(["--cmd", cmd, "--params", ¶ms.to_string()]);
|
||||
}
|
||||
c.arg("--export").arg(dir.join(format!("{id}.svg")));
|
||||
let out = c.output().map_err(|e| format!("{cli}: {e} (set VECTORCRAFT_CLI to the vectorcraft-cli binary)"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("{id}: vectorcraft-cli failed:\n{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
}
|
||||
println!("icons: drew {} icon(s) into assets/icons", recipes.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn colour(role: &Value) -> Result<Value, String> {
|
||||
match role {
|
||||
Value::Null => Ok(json!({"none": true})),
|
||||
Value::String(r) => {
|
||||
PALETTE.iter().find(|(name, _)| name == r).map(|(_, hex)| json!({"color": hex})).ok_or_else(|| format!("unknown role `{r}`"))
|
||||
}
|
||||
other => Err(format!("a role is a name, not {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn numbers<const N: usize>(v: &Value) -> Result<[f64; N], String> {
|
||||
let a = v.as_array().filter(|a| a.len() == N).ok_or_else(|| format!("expected {N} numbers, got {v}"))?;
|
||||
let mut out = [0.0; N];
|
||||
for (o, x) in out.iter_mut().zip(a) {
|
||||
*o = x.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("expected numbers, got {v}"))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The VectorCraft commands that draw one recipe and save it to `source`.
|
||||
fn commands(id: &str, recipe: &Value, source: &Path) -> Result<Vec<(String, Value)>, String> {
|
||||
let shapes = recipe.get("shapes").and_then(Value::as_array).ok_or("a recipe is {\"shapes\": [...]}")?;
|
||||
let mut out = vec![("file.new".to_string(), json!({"width": SIZE, "height": SIZE, "units": "Pixels", "name": id, "created": null}))];
|
||||
for s in shapes {
|
||||
let create = if let Some(d) = s.get("d").and_then(Value::as_str) {
|
||||
("path.create", json!({"d": d}))
|
||||
} else if let Some(c) = s.get("circle") {
|
||||
let [cx, cy, r] = numbers::<3>(c)?;
|
||||
("shape.ellipse", json!({"x": cx - r, "y": cy - r, "width": 2.0 * r, "height": 2.0 * r}))
|
||||
} else if let Some(r) = s.get("rect") {
|
||||
let [x, y, width, height] = numbers::<4>(r)?;
|
||||
let radius = s.get("radius").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
("shape.rectangle", json!({"x": x, "y": y, "width": width, "height": height, "radius": radius}))
|
||||
} else if let Some(l) = s.get("line") {
|
||||
let [x1, y1, x2, y2] = numbers::<4>(l)?;
|
||||
("shape.line", json!({"x1": x1, "y1": y1, "x2": x2, "y2": y2}))
|
||||
} else if let Some(st) = s.get("star") {
|
||||
let [cx, cy, radius1, radius2, points] = numbers::<5>(st)?;
|
||||
("shape.star", json!({"cx": cx, "cy": cy, "radius1": radius1, "radius2": radius2, "points": points}))
|
||||
} else {
|
||||
return Err(format!("a shape needs `d`, `circle`, `rect`, `line` or `star`: {s}"));
|
||||
};
|
||||
out.push((create.0.to_string(), create.1));
|
||||
let (fill, stroke) = match (s.get("fill"), s.get("stroke")) {
|
||||
(None, None) => (Value::Null, json!("line")),
|
||||
(f, st) => (f.cloned().unwrap_or(Value::Null), st.cloned().unwrap_or(Value::Null)),
|
||||
};
|
||||
out.push(("paint.setFill".into(), colour(&fill)?));
|
||||
out.push(("paint.setStroke".into(), colour(&stroke)?));
|
||||
if !stroke.is_null() {
|
||||
let width = s.get("width").and_then(Value::as_f64).unwrap_or(STROKE);
|
||||
let dash = s.get("dash").cloned().unwrap_or(Value::Null);
|
||||
out.push(("stroke.set".into(), json!({"weight": width, "cap": "round", "join": "round", "dash": dash})));
|
||||
}
|
||||
}
|
||||
out.push(("document.save".into(), json!({"path": source, "compress": true, "modified": null})));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_recipe_becomes_vectorcraft_commands() {
|
||||
let recipe = json!({"shapes": [
|
||||
{"d": "M4 4 L20 20"},
|
||||
{"circle": [12, 12, 3], "fill": "accent"},
|
||||
{"rect": [2, 2, 4, 4], "radius": 1, "fill": "soft", "stroke": "line", "width": 1},
|
||||
]});
|
||||
let steps = commands("x.y", &recipe, Path::new("/tmp/x.y.vectorcraft")).unwrap();
|
||||
let ids: Vec<&str> = steps.iter().map(|(c, _)| c.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
"file.new",
|
||||
"path.create",
|
||||
"paint.setFill",
|
||||
"paint.setStroke",
|
||||
"stroke.set",
|
||||
"shape.ellipse",
|
||||
"paint.setFill",
|
||||
"paint.setStroke",
|
||||
"shape.rectangle",
|
||||
"paint.setFill",
|
||||
"paint.setStroke",
|
||||
"stroke.set",
|
||||
"document.save"
|
||||
]
|
||||
);
|
||||
assert_eq!(steps[5].1, json!({"x": 9.0, "y": 9.0, "width": 6.0, "height": 6.0}));
|
||||
assert_eq!(steps[3].1, json!({"color": "#dde2ea"}));
|
||||
assert!(commands("x", &json!({"shapes": [{"d": "M0 0", "fill": "pink"}]}), Path::new("x")).is_err());
|
||||
assert!(commands("x", &json!({"shapes": [{"circle": [1, 2]}]}), Path::new("x")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod assets;
|
||||
mod icons;
|
||||
mod layers;
|
||||
mod licences;
|
||||
mod test;
|
||||
@@ -19,6 +20,9 @@ usage: cargo xtask <command>
|
||||
commands:
|
||||
layers enforce the crate dependency layering (docs/architecture.md)
|
||||
assets check that every non-code asset file is attributed in ATTRIBUTION.md
|
||||
icons [ID…] draw the toolbar icons from their recipes (assets/icons/src/*.json) with
|
||||
VectorCraft (VECTORCRAFT_CLI, else vectorcraft-cli on the PATH): writes each
|
||||
icon's .vectorcraft source and .svg export
|
||||
licences [--check]
|
||||
write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate);
|
||||
--check fails when it is out of date
|
||||
@@ -39,6 +43,7 @@ fn main() -> ExitCode {
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("icons") => icons::run(&root(), &rest),
|
||||
Some("licences") => licences::run(&root(), rest.contains(&"--check")),
|
||||
Some("test") => {
|
||||
let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((&rest[..], &[][..]), |i| (&rest[..i], &rest[i + 1..]));
|
||||
|
||||
Reference in new issue
Block a user