forked from 1b-agent/fieldcraft-upstream
The desktop's ModelApp in a web page through eframe's web runner (WebGPU, WebGL2 fallback, `?webgl` to force it), built with Trunk. The browser services sit behind the UI's host-service seams: File > Open uses the browser's file picker, dropped files are read asynchronously, and both go into the page's files; Save, Save As and Write Deck hand the written file out as a download. Autosaves go to the recovery folder as on the desktop and are mirrored to local storage, so File > Recover offers what a closed or crashed page left. `?sample=NAME` opens a sample. A loading screen with the app icon, and a message instead of the app when neither WebGPU nor WebGL2 is available. `cargo xtask wasm` (and so `cargo xtask ci`) now checks fieldcraft-web for wasm32 with the library crates. docs/web.md: running it locally, what works in the browser and what does not. Part of #119 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
213 lines
9.1 KiB
Rust
213 lines
9.1 KiB
Rust
//! Workspace tooling: `cargo xtask <command>`.
|
|
//!
|
|
//! Pure Rust (std + serde_json). External tools (`cargo`) are invoked through
|
|
//! `std::process::Command`. Adapted from SolidCraft's xtask (MIT OR Apache-2.0), see
|
|
//! ATTRIBUTION.md.
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
mod app_icon;
|
|
mod assets;
|
|
mod icons;
|
|
mod layers;
|
|
mod licences;
|
|
mod test;
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, ExitCode};
|
|
|
|
const USAGE: &str = "\
|
|
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
|
|
app-icon draw the application icon family (assets/app-icon) with VectorCraft, then
|
|
write the iconset, icns, ico and Icon Composer document (macOS: sips,
|
|
iconutil, magick; actool for Assets.car when available)
|
|
licences [--check]
|
|
write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate);
|
|
--check fails when it is out of date
|
|
test [--heavy] [CARGO ARGS] [-- TEST ARGS]
|
|
build the tests with the ci profile, then run every test binary at once;
|
|
--heavy runs only the ignored tests instead (heavy verification and corpus
|
|
tests, and the benchmarks) and the doc tests alongside: the CI heavy job
|
|
corpus [--part NAME] [--no-write]
|
|
build every meshing test corpus part (tests/corpus, analytic shapes), tessellate
|
|
and mesh it, and write the results to docs/meshing/corpus.md (release build);
|
|
--part NAME --design PATH writes that part's .solidcraft design instead
|
|
oracle [--case NAME]... [--bless NAME|all]... [--quick] [--no-write]
|
|
run the verification and validation cases (tests/oracle, docs/verification.md)
|
|
against their references and fixtures; write docs/oracle.md after a full run;
|
|
--bless freezes a passing case's results in its fixture.json; --quick skips
|
|
cases marked slow (release build)
|
|
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
|
|
ci fmt --check, assets, licences --check, layers, clippy -D warnings, then the tests
|
|
(not the heavy ones) with the wasm check alongside (stops at the first failing
|
|
step)
|
|
";
|
|
|
|
fn main() -> ExitCode {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
|
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("app-icon") => app_icon::run(&root()),
|
|
Some("licences") => licences::run(&root(), rest.contains(&"--check")),
|
|
Some("test") => {
|
|
let (heavy, rest) = match rest.split_first() {
|
|
Some((&"--heavy", after)) => (true, after),
|
|
_ => (false, &rest[..]),
|
|
};
|
|
let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((rest, &[][..]), |i| (&rest[..i], &rest[i + 1..]));
|
|
if heavy {
|
|
let ignored: Vec<&str> = ["--ignored"].into_iter().chain(test_args.iter().copied()).collect();
|
|
test::run(cargo_args, &ignored, vec![test::doc_tests(cargo_args, test_args)])
|
|
} else {
|
|
test::run(cargo_args, test_args, vec![])
|
|
}
|
|
}
|
|
Some("ci") => cmd_ci(),
|
|
Some("corpus") => {
|
|
// A binary of the testkit, so xtask itself stays free of SolidCraft.
|
|
let mut c = cargo();
|
|
c.args(["run", "--release", "-p", "fieldcraft-testkit", "--bin", "corpus", "--"]).args(&rest);
|
|
run(c, "corpus")
|
|
}
|
|
Some("oracle") => {
|
|
// A binary of the testkit, which has the engine and SolidCraft scripts for geometry.
|
|
let mut c = cargo();
|
|
c.args(["run", "--release", "-p", "fieldcraft-testkit", "--bin", "oracle", "--"]).args(&rest);
|
|
run(c, "oracle")
|
|
}
|
|
Some("wasm") => cmd_wasm(),
|
|
Some("-h" | "--help" | "help") | None => {
|
|
print!("{USAGE}");
|
|
Ok(())
|
|
}
|
|
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
|
|
};
|
|
match result {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(e) => {
|
|
eprintln!("error: {e}");
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Workspace root (parent of the xtask crate).
|
|
pub fn root() -> PathBuf {
|
|
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
here.parent().unwrap_or(here).to_path_buf()
|
|
}
|
|
|
|
pub fn cargo() -> Command {
|
|
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
|
|
c.current_dir(root());
|
|
c
|
|
}
|
|
|
|
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
|
eprintln!("$ {what}");
|
|
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
|
|
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
|
|
}
|
|
|
|
pub fn metadata() -> Result<serde_json::Value, String> {
|
|
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
|
if !out.status.success() {
|
|
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
|
}
|
|
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
|
|
}
|
|
|
|
fn cmd_layers() -> Result<(), String> {
|
|
let crates = layers::from_metadata(&metadata()?)?;
|
|
println!("Dependency layering (docs/architecture.md)\n");
|
|
println!("{:<28} {:<14} workspace deps", "crate", "layer");
|
|
for c in &crates {
|
|
let ws: Vec<String> = c
|
|
.deps
|
|
.iter()
|
|
.filter(|d| d.workspace)
|
|
.map(|d| {
|
|
let k = match d.kind {
|
|
layers::DepKind::Normal => "",
|
|
layers::DepKind::Dev => " (dev)",
|
|
layers::DepKind::Build => " (build)",
|
|
};
|
|
format!("{}{k}", layers::short_name(&d.name))
|
|
})
|
|
.collect();
|
|
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
|
|
}
|
|
let violations = layers::check(&crates);
|
|
println!();
|
|
if violations.is_empty() {
|
|
println!("OK: {} crates, no layering violations.", crates.len());
|
|
Ok(())
|
|
} else {
|
|
println!("{} violation(s):", violations.len());
|
|
for v in &violations {
|
|
println!(" - {v}");
|
|
}
|
|
Err(format!("{} layering violation(s)", violations.len()))
|
|
}
|
|
}
|
|
|
|
/// Never break wasm: every layered crate (everything below the apps) and the web app must build
|
|
/// for the browser.
|
|
fn cmd_wasm() -> Result<(), String> {
|
|
let (c, n) = wasm_check()?;
|
|
run(c, &format!("cargo check --target wasm32-unknown-unknown ({n} crates)"))?;
|
|
println!("wasm: {n} crates build for wasm32-unknown-unknown");
|
|
Ok(())
|
|
}
|
|
|
|
/// The app that runs in the browser (apps/fieldcraft-web), checked with the layered crates.
|
|
const WEB_APP: &str = "fieldcraft-web";
|
|
|
|
/// The wasm check and how many crates it covers. It uses the ci profile so the host build
|
|
/// scripts and proc macros built for clippy and the tests are reused.
|
|
fn wasm_check() -> Result<(Command, usize), String> {
|
|
let crates = layers::from_metadata(&metadata()?)?;
|
|
let set: Vec<String> = crates
|
|
.iter()
|
|
.filter(|c| c.name == WEB_APP || matches!(layers::classify(&c.name), Some(layers::Class::Layer(_))))
|
|
.map(|c| c.name.clone())
|
|
.collect();
|
|
if !set.iter().any(|c| c == WEB_APP) {
|
|
return Err(format!("wasm: the web app `{WEB_APP}` is not in the workspace"));
|
|
}
|
|
let mut c = cargo();
|
|
c.args(["check", "--target", "wasm32-unknown-unknown", "--profile", "ci"]);
|
|
for p in &set {
|
|
c.args(["-p", p]);
|
|
}
|
|
Ok((c, set.len()))
|
|
}
|
|
|
|
/// The quick gates first, so they fail fast; the wasm check runs while the test binaries do,
|
|
/// which leaves cargo's build lock free. The heavy tests and the doc tests are the CI heavy job's
|
|
/// (`cargo xtask test --heavy`).
|
|
fn cmd_ci() -> Result<(), String> {
|
|
let mut c = cargo();
|
|
c.args(["fmt", "--all", "--", "--check"]);
|
|
run(c, "cargo fmt --check")?;
|
|
assets::run(&root())?;
|
|
licences::run(&root(), true)?;
|
|
cmd_layers()?;
|
|
let mut c = cargo();
|
|
c.args(["clippy", "--workspace", "--all-targets", "--profile", "ci", "--", "-D", "warnings"]);
|
|
run(c, "cargo clippy -D warnings")?;
|
|
let (wasm, n) = wasm_check()?;
|
|
test::run(&[], &[], vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm)])?;
|
|
eprintln!("ci: all gates passed");
|
|
Ok(())
|
|
}
|