commit b64ec3f698e6beef5ba787f34d666608fa8574ce Author: jyviko <2501648+jyviko@users.noreply.github.com> Date: Tue Oct 13 13:56:42 2026 -0400 ModelCraft foundation: workspace, quality gates, FE model, command engine, geometry boundary A pure-Rust CAE modeling workspace following the SolidCraft conventions: - modelcraft-model: nodes and elements by label with hostile-input validation - modelcraft-geometry: SolidCraft's kernel (pinned git dependency) behind our own API; tessellation into a face-tagged boundary surface for volume meshing - modelcraft-engine: CommandSpec registry, Session::execute with rollback and panic guard, scripts and a journal - modelcraft-cli: list commands, run scripts - xtask: layers (only geometry may use solidcraft-*), assets, licences, tests, wasm, ci - AGENTS.md, architecture, ADR 0001 (libraries and licences), roadmap, CI Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..35049cb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6ab0d3b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + +jobs: + ci: + name: cargo xtask ci + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - run: cargo xtask ci + + advisories: + name: Dependency advisories and sources + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@stable + - name: Install cargo-deny + run: cargo install cargo-deny --version 0.20.2 --locked + - run: cargo deny --locked check --warn unmaintained advisories bans sources diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ccb83c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/target*/ +/plan/ +/dist/ +/.agent-files/ +/worktrees/ +*~ +.DS_Store +*.swp diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f611856 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,96 @@ +# ModelCraft — instructions for agents + +ModelCraft is a clean-room, open-source, pure-Rust CAE modeling application: it turns geometry +into analysis-ready finite element models (volume meshes, materials, sections, boundary +conditions, loads and steps), writes them as Abaqus input, Nastran bulk data and LS-DYNA keyword +decks, and solves linear static and modal problems with its own solver. It targets the +pre-processing done today in Abaqus/CAE, Nastran pre-processors and LS-PrePost, and aims to beat +them on meshing robustness, speed, openness and agent control. + +Siblings with the same conventions: `../solidcraft` (parametric CAD, Fusion-class), and the +storytold Craft apps (`storytold/cadcraft`, `vectorcraft`, `photocraft`, …). Code adapted from +them (MIT OR Apache-2.0) is noted in ATTRIBUTION.md. + +## Product name +ModelCraft everywhere: crates `modelcraft-*`, binaries `modelcraft` and `modelcraft-cli`, +`.modelcraft` models, `MODELCRAFT_*` environment variables, docs, CI and packaging. + +## Start every session here +1. Read `ROADMAP.md` (milestones, what is next) and your task. +2. `docs/architecture.md`: crates, layers, the meshing pipeline. +3. `docs/adr/`: library decisions (what we may depend on, what is rejected for licence reasons). +4. Meshing work: `docs/meshing/literature.md` (the papers each algorithm comes from). + +## Never crash +People trust ModelCraft with their models; a crash loses their work. **This outranks feature work.** +- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, + `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). +- **Errors are `Result`** through the crate's error type and `?`. An unfinished feature + returns an error ("not supported yet: …"); it never panics. +- **Input-derived numbers are hostile** (command parameters, model files, solver decks, + control-channel input): `get()` not `[i]`, finite checks, caps on sizes and loop counts. +- **Bound every iterative algorithm.** Mesh refinement, smoothing, untangling and solver + iterations have explicit iteration and element-count caps and report when they stop early. +- **SolidCraft calls are guarded:** everything that calls `solidcraft-kernel` goes through + `solidcraft_kernel::guard` inside `modelcraft-geometry`, which turns a panic into an error. +- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an error or + an escaped panic restores the model and reports `EngineError`. +- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`). +- Every production crate root carries + `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. + +## Non-negotiables +- **Independent.** Behaviour is designed from public documentation, published papers, general + CAE conventions and user feedback. Never copy another product's code, icons, artwork, help + text, element or material libraries, templates or examples. +- **No commercial CAE products.** Agents must not drive, script, automate or extract data from + commercial CAE or meshing products (Abaqus, MSC/NX/Simcenter Nastran, LS-DYNA, LS-PrePost, + HyperMesh, ANSA, Femap, Patran, ANSYS, …): no test data, measurements, decks, results or ids + produced with them. Deck formats come from the vendors' publicly available reference manuals; + we write our own descriptions and our own examples, never copy manual text or example decks. +- **Expected values are analytic.** Reference tests compare with closed-form solutions (patch + tests, beams, plates, Lamé cylinders, Kirsch plates, natural frequencies) and with mesh + invariants (positive Jacobians, conserved volume, watertight boundaries). +- **Algorithms come from papers.** Meshing and solver methods are implemented from published + papers and theses, cited in the module docs and in `docs/meshing/literature.md`. +- **Never read, copy or link GPL/LGPL/AGPL code**: TetGen, CGAL, Gmsh, Netgen, CalculiX, + Code_Aster, OpenRadioss, Elmer, deal.II, FreeFEM, Salome, OpenFOAM — don't even read their + sources. Permissively licensed code (MIT, BSD, Apache-2.0, MPL-2.0) may be read for reference; + anything adapted is noted in ATTRIBUTION.md with its licence. +- **Assets — absolutely essential.** ModelCraft contains **no iconography, images, fonts or + artwork from commercial CAE products — ever.** Icons are drawn in code. Any file asset must be + original or openly licensed and **must have a row in ATTRIBUTION.md** (`cargo xtask assets` + enforces it). Screenshots of commercial software are never committed. +- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` + (`CommandSpec`: our own dotted, lower-case id `.[.]` such as + `mesh.node.add` or `mesh.tet.generate`, never another product's keyword; label; params doc; + JSON `run`) + tests. The UI, scripts, CLI and control channel all reach the same commands. +- **Programmatic calls never open dialogs.** +- **Layering** is enforced by `cargo xtask layers` (`docs/architecture.md`): L0 `model` → L1 + `geometry`, `mesh`, `solver`, `decks`, `render` → L2 `doc` → L3 `io` → L4 `engine` → L5 + `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **Only + `crates/geometry` may use `solidcraft-*`.** +- **Determinism.** The same input gives the same mesh and the same deck, bit for bit, on every + platform: no hash-order iteration into output, no thread-count-dependent results. +- **Robust geometry.** Meshing decisions (orientation, in-sphere, intersection) use exact or + adaptive-precision predicates, never a bare floating-point sign. +- **Units.** The model is unit-consistent and never converts (the user picks e.g. mm, t, s, N, + MPa); geometry from SolidCraft is in millimetres. Z is up. +- **Rust only.** **Never break wasm**: everything below the apps builds for + `wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`). +- **Quality gates** before every commit: `cargo xtask ci` (fmt, assets, licences, layers, clippy + -D warnings, tests, wasm). +- **Publishing**: `origin` is github.com/jyviko/modelcraft. Changes land through pull requests: + branch, push, open a PR, let CI run, then `gh pr merge --squash` when green. Rebase onto `main` + (never merge `main` into a branch); never force-push `main`. Never commit `plan/`, + `.agent-files/`, `worktrees/` or `target*/`; check `git ls-files` before pushing. + +## Running +- `cargo run -p modelcraft-cli -- commands` lists every command with its parameters. +- `cargo run -p modelcraft-cli -- run script.json` runs a script (a JSON array of + `{"command", "params"}`) and prints the model summary. +- Build with `CARGO_TARGET_DIR=/target`. Parallel agents: separate target dirs, one + worktree each. + +## Roadmap +`ROADMAP.md` (committed) tracks milestones. Update it whenever a milestone task lands. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 0000000..33e88a3 --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1,23 @@ +# Attribution + +Every non-code asset in this repository (images, icons, fonts, example geometry) is listed here +with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an +asset file is missing from this table. + +**Policy (mandatory):** ModelCraft contains **no iconography, images, artwork, fonts, templates, +example decks or help text from commercial CAE products.** Every asset is original work by +ModelCraft contributors or third-party material under an open licence. Screenshots of commercial +software are never committed. + +## Files + +| File | Author | Source | Licence | +|---|---|---|---| + +## Adapted code + +| File | Adapted from | Licence | +|---|---|---| +| xtask/src/layers.rs | SolidCraft `xtask/src/layers.rs` (itself adapted from CADCraft's xtask) | MIT OR Apache-2.0 | +| xtask/src/assets.rs, xtask/src/licences.rs, xtask/src/test.rs, xtask/src/main.rs | SolidCraft `xtask/src/` | MIT OR Apache-2.0 | +| clippy.toml, rustfmt.toml | SolidCraft | MIT OR Apache-2.0 | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..864d0da --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing + +Thanks for helping. Read [AGENTS.md](AGENTS.md) first: it holds the rules every change follows, +whether a person or an agent writes it. + +1. Branch from `main`, keep a pull request to one topic. +2. Run `cargo xtask ci` before pushing; CI runs the same gates. +3. User-visible behaviour is a command (`crates/engine/src/cmd/`) with tests, including hostile + parameters. +4. Meshing and solver algorithms cite the paper they come from in the module docs. +5. Never contribute code, text or data from GPL-family projects or commercial CAE products + (see [docs/adr/0001-libraries.md](docs/adr/0001-libraries.md)). + +By contributing you agree your work is licensed under MIT OR Apache-2.0. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..630fcc6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1056 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + +[[package]] +name = "array-macro" +version = "2.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220a2c618ab466efe41d0eace94dfeff1c35e3aa47891bdb95e1c0fefffd3c99" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "branches" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f11502672c5570f77f6bdf573332483f8475bab6a7fda00f1fae8ddb5a6245c0" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx", + "num-traits", + "serde", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "katexit" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccfb0b7ce7938f84a5ecbdca5d0a991e46bc9d6d078934ad5e92c5270fe547db" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matext4cgmath" +version = "0.1.0" +source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd" +dependencies = [ + "cgmath", + "katexit", + "num-complex", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "modelcraft-cli" +version = "0.1.0" +dependencies = [ + "modelcraft-engine", + "serde_json", +] + +[[package]] +name = "modelcraft-engine" +version = "0.1.0" +dependencies = [ + "log", + "modelcraft-model", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "modelcraft-geometry" +version = "0.1.0" +dependencies = [ + "solidcraft-geom", + "solidcraft-kernel", + "thiserror 2.0.21", +] + +[[package]] +name = "modelcraft-model" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rclite" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e09665c494a9de2bd230e1aedc0c2fb20481a51f28f7db84d0c9bcfe2fe537b2" +dependencies = [ + "branches", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "solidcraft-geom" +version = "0.8.0" +source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd" +dependencies = [ + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "solidcraft-kernel" +version = "0.8.0" +source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd" +dependencies = [ + "log", + "serde", + "serde_json", + "solidcraft-geom", + "thiserror 2.0.21", + "truck-meshalgo", + "truck-modeling", + "truck-shapeops", + "truck-stepio", + "truck-topology", +] + +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "truck-base" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c279de9e92e5dc20a188deb0bb9a4bd421a6a185e57e03e19025e84a36c5a05" +dependencies = [ + "cgmath", + "matext4cgmath", + "rustc-hash", + "serde", +] + +[[package]] +name = "truck-derivers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421ff1c5a303aed64e295d9b30e8424ee7a2fa251617a59ae4acfd078496009f" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "truck-geometry" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9d85f203fdbc2d206a38b8e7c4417a05a2eb589be8b81ecfe9f98faafc425d" +dependencies = [ + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geotrait", +] + +[[package]] +name = "truck-geotrait" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a31fe98d5a0589ad20576260e214af23dea21a8d125da81ad3bbcb666f411f" +dependencies = [ + "getrandom", + "rand", + "thiserror 1.0.69", + "truck-base", + "truck-derivers", +] + +[[package]] +name = "truck-meshalgo" +version = "0.4.0" +source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd" +dependencies = [ + "array-macro", + "derive_more", + "itertools", + "rayon", + "rustc-hash", + "spade", + "truck-base", + "truck-geometry", + "truck-polymesh", + "truck-topology", +] + +[[package]] +name = "truck-modeling" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8082c2dfac8c2732a014d81807fb5d24dbac0d72ee4de2a705b7fbc05fa05b18" +dependencies = [ + "derive_more", + "rustc-hash", + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geometry", + "truck-geotrait", + "truck-polymesh", + "truck-topology", +] + +[[package]] +name = "truck-polymesh" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8f22def310af4a89c37beb4839bbf702b302d2c112ea29553759f0e16622fbf" +dependencies = [ + "array-macro", + "bytemuck", + "itertools", + "rustc-hash", + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geotrait", +] + +[[package]] +name = "truck-shapeops" +version = "0.4.0" +source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd" +dependencies = [ + "derive_more", + "itertools", + "rustc-hash", + "truck-base", + "truck-geometry", + "truck-geotrait", + "truck-meshalgo", + "truck-topology", +] + +[[package]] +name = "truck-stepio" +version = "0.3.0" +source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd" +dependencies = [ + "chrono", + "truck-geometry", + "truck-modeling", + "truck-polymesh", + "truck-topology", +] + +[[package]] +name = "truck-topology" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf62a1610405c5e39ab5eb6948f326fc1641689734a6b8207a80792133fe0e78" +dependencies = [ + "parking_lot", + "rayon", + "rclite", + "rustc-hash", + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geotrait", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8927afc --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,73 @@ +[workspace] +resolver = "3" +members = ["crates/*", "apps/*", "xtask"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.95" +authors = ["ModelCraft contributors"] +repository = "https://github.com/jyviko/modelcraft" + +[workspace.dependencies] +# internal +modelcraft-model = { path = "crates/model" } +modelcraft-geometry = { path = "crates/geometry" } +modelcraft-engine = { path = "crates/engine" } + +# foundation +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["float_roundtrip"] } +log = "0.4" + +# Geometry: SolidCraft's B-rep kernel (MIT OR Apache-2.0), pinned. Only crates/geometry uses it +# (plan/adr/0001-libraries.md). +solidcraft-geom = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" } +solidcraft-kernel = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" } + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" +# `!(x > tol)` is our idiom for "not a usable number" (NaN-safe). +neg_cmp_op_on_partial_ord = "allow" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 1 + +# Tests and lints (`cargo xtask ci`, GitHub CI). Debug assertions are off because truck's +# debug-only checks reject valid geometry (SolidCraft builds its tests the same way). +[profile.ci] +inherits = "dev" +opt-level = 1 +debug = "line-tables-only" +debug-assertions = false +overflow-checks = true +incremental = false + +[profile.ci.package."*"] +opt-level = 2 + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 16 +debug = "line-tables-only" + +# SolidCraft builds truck with local fixes (its vendor/ directory). Patches don't reach a +# dependent workspace, so ModelCraft applies the same ones from the same pinned commit; keep this +# list and the rev in step with SolidCraft's [patch.crates-io]. +[patch.crates-io] +truck-shapeops = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" } +truck-stepio = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" } +truck-meshalgo = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" } +matext4cgmath = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" } diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..019bdae --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ModelCraft contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..815f97b --- /dev/null +++ b/NOTICE @@ -0,0 +1,19 @@ +ModelCraft +Copyright (c) 2026 ModelCraft contributors. +Licensed under the MIT License or the Apache License, Version 2.0, at your option +(LICENSE-MIT, LICENSE-APACHE). + +Third-party material + +Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt +(`cargo xtask licences`). Geometry comes from SolidCraft (https://github.com/bherbruck/solidcraft, +MIT OR Apache-2.0), which builds on the truck crates (https://github.com/ricosjp/truck, Apache-2.0, +Copyright (c) RICOS Co. Ltd.), some with SolidCraft's modifications. + +Some code is adapted from sibling projects (SolidCraft, CADCraft), licensed MIT OR Apache-2.0; +ATTRIBUTION.md lists the files. + +ModelCraft is an independent project and is not affiliated with, sponsored by or endorsed by +Dassault Systèmes, MSC Software, Siemens Digital Industries Software or Ansys, Inc. Abaqus, +Nastran, MSC Nastran, Simcenter Nastran and LS-DYNA are trademarks of their respective owners; +they are used here only to describe file compatibility. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c13993 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# ModelCraft + +An open-source, clean-room CAE modeling application in pure Rust: geometry to analysis-ready +finite element models, with robust tetrahedral and hexahedral meshing, solver decks for Abaqus, +Nastran and LS-DYNA, and its own linear static and modal solver. Desktop, browser and AI agents +drive the same commands. + +**Status:** early. The workspace, quality gates, the FE model, the command engine and the +geometry boundary (SolidCraft's kernel and tessellator) are in place; meshing is next. See +[ROADMAP.md](ROADMAP.md). + +## Build + +```sh +cargo xtask ci # fmt, assets, licences, layers, clippy, tests, wasm +cargo run -p modelcraft-cli -- commands # every command and its parameters +cargo run -p modelcraft-cli -- run script.json # run a command script +``` + +Rust 1.95 or later, with the `wasm32-unknown-unknown` target for `cargo xtask ci`. + +## Documentation + +- [Architecture](docs/architecture.md): crates, layers and the meshing pipeline +- [Libraries and licences](docs/adr/0001-libraries.md) +- [Contributing](CONTRIBUTING.md) and [agent instructions](AGENTS.md) + +## License + +Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option. Notices +are in [NOTICE](NOTICE); third-party licences in [THIRD-PARTY-LICENSES.txt](THIRD-PARTY-LICENSES.txt). + +ModelCraft is an independent project, not affiliated with, sponsored or endorsed by Dassault +Systèmes, MSC Software, Siemens or Ansys. Abaqus, Nastran, MSC Nastran, Simcenter Nastran and +LS-DYNA are trademarks of their owners and are named only to describe file compatibility. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..8f4b307 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,16 @@ +# Roadmap + +ModelCraft's goal is CAE pre-processing on par with Abaqus/CAE, Nastran pre-processors and +LS-PrePost, with hexahedral meshing as the centre of gravity, plus its own linear solver. + +| Milestone | Scope | Status | +|---|---|---| +| M0 Foundation | Workspace, quality gates (`cargo xtask ci`), FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft's kernel and tessellator | done | +| M1 Surface preparation | Robust adaptive-precision predicates; weld, closed/manifold/orientation checks and feature edges on the tessellated boundary; mesh quality metrics | next | +| M2 Tetrahedral meshing | 3D Delaunay (Bowyer–Watson), boundary recovery, Delaunay refinement to a size field, Voronoi-based optimization (CVT/ODT), sliver removal; linear and quadratic tets | planned | +| M3 Hexahedral meshing (core) | Method from the literature survey: a hex mesher whose output is always valid, then quality-driven methods; hex-dominant fallback | planned | +| M4 Model definition | Materials, sections, sets and surfaces, boundary conditions, loads, steps, output requests | planned | +| M5 Solver decks | Abaqus input, Nastran bulk data, LS-DYNA keyword: writers first, then readers | planned | +| M6 Linear solver | Element library, sparse assembly, linear static and modal analysis, verified against analytic solutions | planned | +| M7 Desktop and agents | egui desktop app, control channel, MCP server, browser build | planned | +| M8 Post-processing | Results display: deformed shape, contours, probes | planned | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7d466f5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security policy + +ModelCraft is young; only `main` and the latest release get fixes. + +Please **don't open a public issue** for a security problem. Report it privately through the +repository's [Security tab](https://github.com/jyviko/modelcraft/security/advisories/new) with +what you found, how to reproduce it (a model, deck or script), the version and platform, and the +impact you expect. + +Reports are especially useful for opening untrusted models, decks or scripts (crashes, runaway +memory or CPU, path tricks) and for the control channel once it exists. diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt new file mode 100644 index 0000000..bcd2b0f --- /dev/null +++ b/THIRD-PARTY-LICENSES.txt @@ -0,0 +1,576 @@ +Third-party software in ModelCraft + +ModelCraft (the desktop app, modelcraft-cli and the web app) is built from the 14 crates below. +Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files. +ModelCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE. + +itoa 1.0.18 (MIT OR Apache-2.0) +log 0.4.34 (MIT OR Apache-2.0) +memchr 2.8.3 (Unlicense OR MIT) +proc-macro2 1.0.107 (MIT OR Apache-2.0) +quote 1.0.47 (MIT OR Apache-2.0) +serde 1.0.229 (MIT OR Apache-2.0) +serde_core 1.0.229 (MIT OR Apache-2.0) +serde_derive 1.0.229 (MIT OR Apache-2.0) +serde_json 1.0.154 (MIT OR Apache-2.0) +syn 3.0.9 (MIT OR Apache-2.0) +thiserror 2.0.21 (MIT OR Apache-2.0) +thiserror-impl 2.0.21 (MIT OR Apache-2.0) +unicode-ident 1.0.27 ((MIT OR Apache-2.0) AND Unicode-3.0) +zmij 1.0.23 (MIT) + +================================================================================ +itoa 1.0.18, proc-macro2 1.0.107, quote 1.0.47, serde 1.0.229, serde_core 1.0.229, serde_derive +1.0.229, serde_json 1.0.154, syn 3.0.9, thiserror 2.0.21, thiserror-impl 2.0.21, unicode-ident +1.0.27 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +================================================================================ +itoa 1.0.18, proc-macro2 1.0.107, quote 1.0.47, serde 1.0.229, serde_core 1.0.229, serde_derive +1.0.229, serde_json 1.0.154, syn 3.0.9, thiserror 2.0.21, thiserror-impl 2.0.21, unicode-ident +1.0.27, zmij 1.0.23 +================================================================================ + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +log 0.4.34 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +================================================================================ +log 0.4.34 +================================================================================ + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +memchr 2.8.3 +================================================================================ + +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +memchr 2.8.3 +================================================================================ + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to + +================================================================================ +memchr 2.8.3 +================================================================================ + +This project is dual-licensed under the Unlicense and MIT licenses. + +You may use this code under the terms of either license. + +================================================================================ +unicode-ident 1.0.27 +================================================================================ + +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. diff --git a/apps/modelcraft-cli/Cargo.toml b/apps/modelcraft-cli/Cargo.toml new file mode 100644 index 0000000..e3c1a00 --- /dev/null +++ b/apps/modelcraft-cli/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "modelcraft-cli" +description = "ModelCraft from the command line: list commands, run scripts" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +modelcraft-engine = { workspace = true } +serde_json = { workspace = true } diff --git a/apps/modelcraft-cli/src/main.rs b/apps/modelcraft-cli/src/main.rs new file mode 100644 index 0000000..acefeef --- /dev/null +++ b/apps/modelcraft-cli/src/main.rs @@ -0,0 +1,58 @@ +//! modelcraft-cli commands +//! modelcraft-cli run +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::process::ExitCode; + +use modelcraft_engine::Session; +use serde_json::json; + +const USAGE: &str = "\ +usage: + modelcraft-cli commands list every command with its parameters + modelcraft-cli run run a script (a JSON array of {\"command\", \"params\"}) + and print the model summary +"; + +/// Largest script accepted (bytes). +const MAX_SCRIPT_BYTES: u64 = 256 * 1024 * 1024; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let result = match args.first().map(String::as_str) { + Some("commands") => { + for c in modelcraft_engine::commands() { + println!("{:<24} {:<18} {}", c.id, c.label, c.params); + } + Ok(()) + } + Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)), + 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 + } + } +} + +fn run(path: &str) -> Result<(), String> { + let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len(); + if size > MAX_SCRIPT_BYTES { + return Err(format!("{path}: larger than {} MB", MAX_SCRIPT_BYTES / (1024 * 1024))); + } + let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; + let script: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?; + let mut session = Session::default(); + session.run_script(&script).map_err(|e| e.to_string())?; + let info = session.execute("model.info", &json!({})).map_err(|e| e.to_string())?; + println!("{}", serde_json::to_string_pretty(&info).map_err(|e| e.to_string())?); + Ok(()) +} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..4b6c79f --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 0000000..62e877e --- /dev/null +++ b/crates/engine/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "modelcraft-engine" +description = "ModelCraft's command engine: every user-visible action as a command with a JSON form" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +modelcraft-model = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs new file mode 100644 index 0000000..c05b07d --- /dev/null +++ b/crates/engine/src/cmd/mesh.rs @@ -0,0 +1,58 @@ +use modelcraft_model::{ElementKind, MAX_LABEL}; +use serde_json::{Value, json}; + +use super::{label, xyz}; +use crate::{CommandSpec, EngineError, Result, Session}; + +pub const NODE_ADD: CommandSpec = CommandSpec { + id: "mesh.node.add", + label: "Add Node", + params: r#"{"label": 1, "xyz": [x, y, z]} — adds the node, or moves it if the label exists"#, + run: node_add, +}; + +pub const NODE_REMOVE: CommandSpec = + CommandSpec { id: "mesh.node.remove", label: "Remove Node", params: r#"{"label": 1} — only a node no element uses"#, run: node_remove }; + +pub const ELEMENT_ADD: CommandSpec = CommandSpec { + id: "mesh.element.add", + label: "Add Element", + params: r#"{"label": 1, "kind": "rod2|beam2|tri3|tri6|quad4|quad8|tet4|tet10|wedge6|hex8|hex20", "nodes": [labels]} — adds or replaces"#, + run: element_add, +}; + +pub const ELEMENT_REMOVE: CommandSpec = + CommandSpec { id: "mesh.element.remove", label: "Remove Element", params: r#"{"label": 1}"#, run: element_remove }; + +fn node_add(s: &mut Session, p: &Value) -> Result { + let l = label(p, "label")?; + s.model.set_node(l, xyz(p, "xyz")?)?; + Ok(json!({"label": l})) +} + +fn node_remove(s: &mut Session, p: &Value) -> Result { + s.model.remove_node(label(p, "label")?)?; + Ok(Value::Null) +} + +fn element_add(s: &mut Session, p: &Value) -> Result { + let l = label(p, "label")?; + let name = p.get("kind").and_then(Value::as_str).unwrap_or_default(); + let kind = ElementKind::from_name(name).ok_or_else(|| EngineError::Params(format!("unknown element kind `{name}`")))?; + let raw = p.get("nodes").and_then(Value::as_array).ok_or_else(|| EngineError::Params("`nodes` must be a list of node labels".into()))?; + if raw.len() != kind.node_count() { + return Err(EngineError::Params(format!("{name} needs {} nodes, got {}", kind.node_count(), raw.len()))); + } + let nodes = raw + .iter() + .map(|v| v.as_u64().and_then(|n| u32::try_from(n).ok()).filter(|n| (1..=MAX_LABEL).contains(n))) + .collect::>>() + .ok_or_else(|| EngineError::Params(format!("`nodes` must be node labels in 1..={MAX_LABEL}")))?; + s.model.set_element(l, kind, nodes)?; + Ok(json!({"label": l})) +} + +fn element_remove(s: &mut Session, p: &Value) -> Result { + s.model.remove_element(label(p, "label")?)?; + Ok(Value::Null) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs new file mode 100644 index 0000000..38a59b7 --- /dev/null +++ b/crates/engine/src/cmd/mod.rs @@ -0,0 +1,29 @@ +//! The command registry. Each area has its own module; [`ALL`] lists every command. + +mod mesh; +mod model; + +use serde_json::Value; + +use crate::{CommandSpec, EngineError, Result}; + +pub static ALL: &[CommandSpec] = &[model::INFO, mesh::NODE_ADD, mesh::NODE_REMOVE, mesh::ELEMENT_ADD, mesh::ELEMENT_REMOVE]; + +/// A required label parameter (1..=u32::MAX; the model checks its own range). +pub(crate) fn label(p: &Value, key: &str) -> Result { + p.get(key) + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| EngineError::Params(format!("`{key}` must be a positive whole number"))) +} + +/// A required `[x, y, z]` parameter. +pub(crate) fn xyz(p: &Value, key: &str) -> Result<[f64; 3]> { + let bad = || EngineError::Params(format!("`{key}` must be [x, y, z]")); + let a = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 3).ok_or_else(bad)?; + let mut out = [0.0; 3]; + for (o, v) in out.iter_mut().zip(a) { + *o = v.as_f64().ok_or_else(bad)?; + } + Ok(out) +} diff --git a/crates/engine/src/cmd/model.rs b/crates/engine/src/cmd/model.rs new file mode 100644 index 0000000..347ef9f --- /dev/null +++ b/crates/engine/src/cmd/model.rs @@ -0,0 +1,10 @@ +use serde_json::{Value, json}; + +use crate::{CommandSpec, Result, Session}; + +pub const INFO: CommandSpec = CommandSpec { id: "model.info", label: "Model Info", params: "{}", run: info }; + +fn info(s: &mut Session, _: &Value) -> Result { + let counts: serde_json::Map = s.model.element_counts().into_iter().map(|(k, n)| (k.name().to_string(), json!(n))).collect(); + Ok(json!({"nodes": s.model.nodes().len(), "elements": counts})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 0000000..79d1060 --- /dev/null +++ b/crates/engine/src/lib.rs @@ -0,0 +1,155 @@ +//! The command engine. +//! +//! Everything a user can do is a command ([`CommandSpec`]) with a JSON form. The desktop UI, +//! scripts, the CLI and the control channel all run the same commands through +//! [`Session::execute`], which never lets a failure or a panic damage the model: the model is +//! restored and an [`EngineError`] comes back. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod cmd; + +use modelcraft_model::Model; +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum EngineError { + #[error("unknown command `{0}`")] + UnknownCommand(String), + #[error("{0}")] + Params(String), + #[error(transparent)] + Model(#[from] modelcraft_model::Error), + /// A command panicked; the model is unchanged. + #[error("internal error in {command}: {message}")] + Internal { command: String, message: String }, +} + +pub type Result = std::result::Result; + +/// A command: one user-visible action. +pub struct CommandSpec { + /// Our own dotted, lower-case id: `.[.]`, such as `mesh.node.add`. + pub id: &'static str, + pub label: &'static str, + /// The JSON parameters, documented for scripts, `modelcraft-cli commands` and agents. + pub params: &'static str, + pub run: fn(&mut Session, &Value) -> Result, +} + +/// All commands. +pub fn commands() -> &'static [CommandSpec] { + cmd::ALL +} + +pub fn find_command(id: &str) -> Option<&'static CommandSpec> { + commands().iter().find(|c| c.id == id) +} + +/// The model being edited and the commands that built it. +#[derive(Debug, Clone, Default)] +pub struct Session { + pub model: Model, + /// Commands that changed the model, in order (a script that rebuilds it). + pub journal: Vec<(String, Value)>, +} + +impl Session { + /// Run a command. On an error or a panic the model is left as it was. + pub fn execute(&mut self, id: &str, params: &Value) -> Result { + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + let before = self.model.clone(); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params))); + let result = match outcome { + Ok(r) => r, + Err(p) => { + let message = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); + log::error!("command {id} panicked: {message}"); + Err(EngineError::Internal { command: id.to_string(), message }) + } + }; + match &result { + Ok(_) if self.model != before => self.journal.push((id.to_string(), params.clone())), + Ok(_) => {} + Err(_) => self.model = before, + } + result + } + + /// Run a script: a JSON array of `{"command": id, "params": {...}}`. + pub fn run_script(&mut self, script: &Value) -> Result<()> { + let steps = script.as_array().ok_or_else(|| EngineError::Params("a script is a JSON array of steps".into()))?; + for (i, step) in steps.iter().enumerate() { + let id = step.get("command").and_then(Value::as_str).ok_or_else(|| EngineError::Params(format!("step {}: no `command`", i + 1)))?; + let params = step.get("params").cloned().unwrap_or(Value::Object(Default::default())); + self.execute(id, ¶ms).map_err(|e| EngineError::Params(format!("step {} ({id}): {e}", i + 1)))?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn command_ids_are_unique_and_well_formed() { + let mut ids: Vec<&str> = commands().iter().map(|c| c.id).collect(); + for id in &ids { + assert!(id.split('.').count() >= 2 && id.chars().all(|c| c.is_ascii_lowercase() || c == '.' || c == '_'), "{id}"); + } + ids.sort_unstable(); + let n = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), n); + } + + #[test] + fn script_builds_a_hex_and_failures_leave_the_model_alone() { + let mut s = Session::default(); + let mut script = Vec::new(); + for (i, c) in [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]].iter().enumerate() { + script.push(json!({"command": "mesh.node.add", "params": {"label": i + 1, "xyz": c}})); + } + script.push(json!({"command": "mesh.element.add", "params": {"label": 1, "kind": "hex8", "nodes": [1,2,3,4,5,6,7,8]}})); + s.run_script(&Value::Array(script)).unwrap(); + assert_eq!(s.journal.len(), 9); + let info = s.execute("model.info", &json!({})).unwrap(); + assert_eq!(info["nodes"], 8); + assert_eq!(info["elements"]["hex8"], 1); + + let model = s.model.clone(); + assert!(s.execute("mesh.element.add", &json!({"label": 2, "kind": "tet4", "nodes": [1, 2, 3, 99]})).is_err()); + assert!(s.execute("mesh.node.remove", &json!({"label": 1})).is_err()); + assert_eq!(s.model, model); + assert_eq!(s.journal.len(), 9); + } + + #[test] + fn hostile_params_never_panic() { + let hostile = [ + json!(null), + json!([]), + json!("x"), + json!({}), + json!({"label": -1}), + json!({"label": 1e300}), + json!({"label": u64::MAX}), + json!({"label": 1, "xyz": [1, 2]}), + json!({"label": 1, "xyz": ["a", null, {}]}), + json!({"label": 1, "xyz": [1e308, -1e308, 0]}), + json!({"label": 1, "kind": "hex8", "nodes": null}), + json!({"label": 1, "kind": "nope", "nodes": [1]}), + json!({"label": 1, "kind": "tet4", "nodes": [1, 1, 1, 1]}), + json!({"label": 1, "kind": "hex20", "nodes": vec![u32::MAX; 20]}), + ]; + for spec in commands() { + for p in &hostile { + let mut s = Session::default(); + let _ = s.execute(spec.id, &p.clone()); + } + } + assert!(Session::default().execute("no.such", &json!({})).is_err()); + } +} diff --git a/crates/geometry/Cargo.toml b/crates/geometry/Cargo.toml new file mode 100644 index 0000000..9bfa3d5 --- /dev/null +++ b/crates/geometry/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "modelcraft-geometry" +description = "ModelCraft's geometry boundary: CAD import, measures and tessellation, implemented with SolidCraft's B-rep kernel" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +solidcraft-geom = { workspace = true } +solidcraft-kernel = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/geometry/src/lib.rs b/crates/geometry/src/lib.rs new file mode 100644 index 0000000..27ec0b5 --- /dev/null +++ b/crates/geometry/src/lib.rs @@ -0,0 +1,141 @@ +//! The geometry boundary. +//! +//! Meshing in ModelCraft starts from a triangulated boundary surface. This crate produces it: +//! B-rep bodies come from SolidCraft's kernel (`solidcraft-kernel`, a pinned git dependency; +//! plan/adr/0001-libraries.md) and leave it as a [`SurfaceMesh`] from SolidCraft's tessellator, +//! with each triangle tagged by the B-rep face it lies on (for feature edges and for loads and +//! boundary conditions on faces). No SolidCraft type appears in the public API, so the kernel can +//! be replaced without touching the rest of the workspace. Only this crate may depend on +//! `solidcraft-*` (`cargo xtask layers` enforces it). +//! +//! Lengths are millimetres. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use solidcraft_kernel::{Body, KernelError, guard}; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum Error { + #[error("invalid input: {0}")] + Invalid(String), + #[error("the geometry operation failed: {0}")] + Failed(String), +} + +pub type Result = std::result::Result; + +impl From for Error { + fn from(e: KernelError) -> Self { + match e { + KernelError::Invalid(m) => Error::Invalid(m), + other => Error::Failed(other.to_string()), + } + } +} + +/// A solid body. +#[derive(Debug, Clone)] +pub struct Shape { + pub name: String, + body: Body, +} + +/// Mass properties and extent of a [`Shape`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Measure { + pub volume: f64, + pub area: f64, + pub centroid: [f64; 3], + pub bbox_min: [f64; 3], + pub bbox_max: [f64; 3], + pub faces: usize, +} + +/// A triangulated boundary surface: the input to volume meshing. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SurfaceMesh { + pub positions: Vec<[f64; 3]>, + /// Counter-clockwise seen from outside. + pub triangles: Vec<[u32; 3]>, + /// The B-rep face each triangle lies on. + pub tri_face: Vec, +} + +fn xyz(v: solidcraft_geom::Vec3) -> [f64; 3] { + [v.x, v.y, v.z] +} + +/// A box between two corners. +pub fn block(a: [f64; 3], b: [f64; 3]) -> Result { + let v = |p: [f64; 3]| solidcraft_geom::Vec3 { x: p[0], y: p[1], z: p[2] }; + let body = guard("block", || solidcraft_kernel::box_solid(v(a), v(b)))?; + Ok(Shape { name: "Block".into(), body }) +} + +impl Shape { + pub fn measure(&self) -> Result { + let m = guard("measure", || solidcraft_kernel::measure(&self.body))?; + Ok(Measure { + volume: m.volume, + area: m.area, + centroid: xyz(m.centroid), + bbox_min: xyz(m.bbox.min), + bbox_max: xyz(m.bbox.max), + faces: m.faces, + }) + } + + /// Triangulate the boundary within `tolerance` (chordal deviation, mm). + pub fn tessellate(&self, tolerance: f64) -> Result { + if !(tolerance > 0.0 && tolerance.is_finite()) { + return Err(Error::Invalid(format!("tessellation tolerance must be a positive number, got {tolerance}"))); + } + let m = guard("tessellate", || self.body.tessellate(tolerance))?; + Ok(SurfaceMesh { positions: m.positions.into_iter().map(xyz).collect(), triangles: m.triangles, tri_face: m.tri_face }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn block_measures() { + let m = block([0., 0., 0.], [10., 20., 30.]).unwrap().measure().unwrap(); + assert!((m.volume - 6000.0).abs() < 1e-6, "{}", m.volume); + assert!((m.area - 2200.0).abs() < 1e-6, "{}", m.area); + assert_eq!(m.faces, 6); + assert_eq!(m.bbox_max, [10., 20., 30.]); + } + + #[test] + fn tessellation_covers_every_face_and_encloses_the_volume() { + let mesh = block([0., 0., 0.], [2., 3., 4.]).unwrap().tessellate(0.01).unwrap(); + assert_eq!(mesh.triangles.len(), mesh.tri_face.len()); + let mut faces: Vec = mesh.tri_face.clone(); + faces.sort_unstable(); + faces.dedup(); + assert_eq!(faces.len(), 6); + // Divergence theorem: the outward-oriented triangles enclose the block's volume. + let p = |i: u32| mesh.positions[i as usize]; + let six_v: f64 = mesh + .triangles + .iter() + .map(|t| { + let (a, b, c) = (p(t[0]), p(t[1]), p(t[2])); + a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]) + }) + .sum(); + assert!((six_v / 6.0 - 24.0).abs() < 1e-9, "{}", six_v / 6.0); + } + + #[test] + fn hostile_input_is_an_error() { + assert!(block([0.; 3], [0.; 3]).is_err()); + assert!(block([0.; 3], [f64::NAN, 1., 1.]).is_err()); + let b = block([0.; 3], [1.; 3]).unwrap(); + for t in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert!(b.tessellate(t).is_err()); + } + } +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml new file mode 100644 index 0000000..3af3b4e --- /dev/null +++ b/crates/model/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "modelcraft-model" +description = "ModelCraft's solver-neutral finite element model: nodes, elements and their validation" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs new file mode 100644 index 0000000..8dc6612 --- /dev/null +++ b/crates/model/src/lib.rs @@ -0,0 +1,285 @@ +//! The solver-neutral finite element model. +//! +//! A [`Model`] holds what every solver deck describes: nodes and elements addressed by the +//! positive integer labels the decks use. Materials, sections, sets, boundary conditions, loads +//! and steps join it as their tasks land (plan/architecture.md §4). Solver formats (Abaqus input, +//! Nastran bulk data, LS-DYNA keyword) translate to and from this model; nothing here knows any +//! of them. +//! +//! The model is unit-consistent: it stores numbers in whatever consistent system the user chose +//! (for example mm, tonne, s, N, MPa) and never converts them. +//! +//! Every mutation validates its input, because labels and coordinates come from files, scripts +//! and the control channel: a bad value is an [`Error`], never a panic. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// The largest node or element label. Nastran's GRID and element ids stop at 99 999 999 (eight +/// digits in a small field), the tightest of the three formats. +pub const MAX_LABEL: u32 = 99_999_999; + +/// Caps on model size, so hostile input cannot exhaust memory. +pub const MAX_NODES: usize = 50_000_000; +pub const MAX_ELEMENTS: usize = 50_000_000; + +/// Coordinates beyond this magnitude are rejected (1e9 model units). +pub const MAX_COORD: f64 = 1e9; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum Error { + #[error("invalid input: {0}")] + Invalid(String), + #[error("not found: {0}")] + NotFound(String), + #[error("limit reached: {0}")] + Limit(String), +} + +pub type Result = std::result::Result; + +/// A node: a point in model space. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Node { + pub xyz: [f64; 3], +} + +/// Element topology. Node order follows each family's usual corner-first convention (corners, +/// then mid-side nodes), which all three target formats share for these shapes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ElementKind { + /// Two-node axial member. + Rod2, + /// Two-node beam. + Beam2, + /// Three-node triangle (shell or membrane). + Tri3, + /// Six-node triangle. + Tri6, + /// Four-node quadrilateral. + Quad4, + /// Eight-node quadrilateral. + Quad8, + /// Four-node tetrahedron. + Tet4, + /// Ten-node tetrahedron. + Tet10, + /// Six-node wedge (pentahedron). + Wedge6, + /// Eight-node hexahedron. + Hex8, + /// Twenty-node hexahedron. + Hex20, +} + +impl ElementKind { + pub const ALL: [ElementKind; 11] = [ + ElementKind::Rod2, + ElementKind::Beam2, + ElementKind::Tri3, + ElementKind::Tri6, + ElementKind::Quad4, + ElementKind::Quad8, + ElementKind::Tet4, + ElementKind::Tet10, + ElementKind::Wedge6, + ElementKind::Hex8, + ElementKind::Hex20, + ]; + + pub fn node_count(self) -> usize { + match self { + ElementKind::Rod2 | ElementKind::Beam2 => 2, + ElementKind::Tri3 => 3, + ElementKind::Quad4 | ElementKind::Tet4 => 4, + ElementKind::Tri6 | ElementKind::Wedge6 => 6, + ElementKind::Quad8 | ElementKind::Hex8 => 8, + ElementKind::Tet10 => 10, + ElementKind::Hex20 => 20, + } + } + + /// Topological dimension: 1 for line elements, 2 for shells, 3 for solids. + pub fn dimension(self) -> u8 { + match self { + ElementKind::Rod2 | ElementKind::Beam2 => 1, + ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => 2, + ElementKind::Tet4 | ElementKind::Tet10 | ElementKind::Wedge6 | ElementKind::Hex8 | ElementKind::Hex20 => 3, + } + } + + /// The name used in commands and `.modelcraft` files (`hex8`, `tet10`, …). + pub fn name(self) -> &'static str { + match self { + ElementKind::Rod2 => "rod2", + ElementKind::Beam2 => "beam2", + ElementKind::Tri3 => "tri3", + ElementKind::Tri6 => "tri6", + ElementKind::Quad4 => "quad4", + ElementKind::Quad8 => "quad8", + ElementKind::Tet4 => "tet4", + ElementKind::Tet10 => "tet10", + ElementKind::Wedge6 => "wedge6", + ElementKind::Hex8 => "hex8", + ElementKind::Hex20 => "hex20", + } + } + + pub fn from_name(name: &str) -> Option { + ElementKind::ALL.into_iter().find(|k| k.name() == name) + } +} + +/// An element: a topology and its node labels. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Element { + pub kind: ElementKind, + pub nodes: Vec, +} + +/// Nodes and elements by label. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Model { + nodes: BTreeMap, + elements: BTreeMap, +} + +fn check_label(what: &str, label: u32) -> Result<()> { + if label == 0 || label > MAX_LABEL { + return Err(Error::Invalid(format!("{what} label {label} is outside 1..={MAX_LABEL}"))); + } + Ok(()) +} + +impl Model { + pub fn nodes(&self) -> &BTreeMap { + &self.nodes + } + + pub fn elements(&self) -> &BTreeMap { + &self.elements + } + + pub fn node(&self, label: u32) -> Option<&Node> { + self.nodes.get(&label) + } + + pub fn element(&self, label: u32) -> Option<&Element> { + self.elements.get(&label) + } + + /// Add a node, or move an existing one (decks redefine nodes the same way). + pub fn set_node(&mut self, label: u32, xyz: [f64; 3]) -> Result<()> { + check_label("node", label)?; + if let Some(c) = xyz.iter().find(|c| !(c.abs() <= MAX_COORD)) { + return Err(Error::Invalid(format!("node {label}: coordinate {c} is not a finite number within ±{MAX_COORD}"))); + } + if !self.nodes.contains_key(&label) && self.nodes.len() >= MAX_NODES { + return Err(Error::Limit(format!("a model holds at most {MAX_NODES} nodes"))); + } + self.nodes.insert(label, Node { xyz }); + Ok(()) + } + + /// Add an element, or replace an existing one. Its nodes must exist and be distinct. + pub fn set_element(&mut self, label: u32, kind: ElementKind, nodes: Vec) -> Result<()> { + check_label("element", label)?; + if nodes.len() != kind.node_count() { + return Err(Error::Invalid(format!("element {label}: {} needs {} nodes, got {}", kind.name(), kind.node_count(), nodes.len()))); + } + for (i, n) in nodes.iter().enumerate() { + if !self.nodes.contains_key(n) { + return Err(Error::NotFound(format!("element {label}: node {n}"))); + } + if nodes.get(..i).is_some_and(|before| before.contains(n)) { + return Err(Error::Invalid(format!("element {label}: node {n} appears twice"))); + } + } + if !self.elements.contains_key(&label) && self.elements.len() >= MAX_ELEMENTS { + return Err(Error::Limit(format!("a model holds at most {MAX_ELEMENTS} elements"))); + } + self.elements.insert(label, Element { kind, nodes }); + Ok(()) + } + + /// Remove a node that no element uses. + pub fn remove_node(&mut self, label: u32) -> Result<()> { + if let Some((e, _)) = self.elements.iter().find(|(_, e)| e.nodes.contains(&label)) { + return Err(Error::Invalid(format!("node {label} is used by element {e}"))); + } + self.nodes.remove(&label).map(|_| ()).ok_or_else(|| Error::NotFound(format!("node {label}"))) + } + + pub fn remove_element(&mut self, label: u32) -> Result<()> { + self.elements.remove(&label).map(|_| ()).ok_or_else(|| Error::NotFound(format!("element {label}"))) + } + + /// Element counts by kind, in [`ElementKind::ALL`] order, kinds with none left out. + pub fn element_counts(&self) -> Vec<(ElementKind, usize)> { + ElementKind::ALL.into_iter().map(|k| (k, self.elements.values().filter(|e| e.kind == k).count())).filter(|(_, n)| *n > 0).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cube() -> Model { + let mut m = Model::default(); + let corners = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]]; + for (i, c) in corners.into_iter().enumerate() { + m.set_node(i as u32 + 1, c).unwrap(); + } + m.set_element(1, ElementKind::Hex8, (1..=8).collect()).unwrap(); + m + } + + #[test] + fn builds_a_hex() { + let m = cube(); + assert_eq!(m.nodes().len(), 8); + assert_eq!(m.element_counts(), vec![(ElementKind::Hex8, 1)]); + } + + #[test] + fn rejects_bad_labels_and_coordinates() { + let mut m = Model::default(); + assert!(m.set_node(0, [0.; 3]).is_err()); + assert!(m.set_node(MAX_LABEL + 1, [0.; 3]).is_err()); + assert!(m.set_node(1, [f64::NAN, 0., 0.]).is_err()); + assert!(m.set_node(1, [f64::INFINITY, 0., 0.]).is_err()); + assert!(m.set_node(1, [2e9, 0., 0.]).is_err()); + assert!(m.nodes().is_empty()); + } + + #[test] + fn rejects_bad_connectivity() { + let mut m = cube(); + assert!(matches!(m.set_element(2, ElementKind::Tet4, vec![1, 2, 3]), Err(Error::Invalid(_)))); + assert!(matches!(m.set_element(2, ElementKind::Tet4, vec![1, 2, 3, 99]), Err(Error::NotFound(_)))); + assert!(matches!(m.set_element(2, ElementKind::Tet4, vec![1, 2, 3, 3]), Err(Error::Invalid(_)))); + assert_eq!(m.elements().len(), 1); + } + + #[test] + fn used_nodes_cannot_be_removed() { + let mut m = cube(); + assert!(m.remove_node(1).is_err()); + m.remove_element(1).unwrap(); + m.remove_node(1).unwrap(); + assert!(m.remove_node(1).is_err()); + } + + #[test] + fn kind_names_round_trip() { + for k in ElementKind::ALL { + assert_eq!(ElementKind::from_name(k.name()), Some(k)); + assert_eq!(serde_json::to_value(k).unwrap(), serde_json::json!(k.name())); + } + assert_eq!(ElementKind::from_name("C3D8"), None); + } +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..9bb91c6 --- /dev/null +++ b/deny.toml @@ -0,0 +1,16 @@ +# Licences are covered by `cargo xtask licences --check`. +[advisories] +unmaintained = "all" +yanked = "warn" + +[bans] +multiple-versions = "warn" +wildcards = "warn" +allow-wildcard-paths = true + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +# SolidCraft's kernel and its patched truck crates, pinned by rev in Cargo.toml. +allow-git = ["https://github.com/bherbruck/solidcraft"] diff --git a/docs/adr/0001-libraries.md b/docs/adr/0001-libraries.md new file mode 100644 index 0000000..d2d2ad4 --- /dev/null +++ b/docs/adr/0001-libraries.md @@ -0,0 +1,52 @@ +# ADR 0001: Libraries and licences + +Status: accepted (2026-10-13) + +## Context + +ModelCraft is MIT OR Apache-2.0. Everything it links must allow that, and the meshing and solver +algorithms, where the obvious open-source implementations are GPL-family, must come from papers. + +## Decision + +**Geometry: SolidCraft's kernel**, as a git dependency pinned by rev +(`solidcraft-kernel`, `solidcraft-geom`; MIT OR Apache-2.0, built on the Apache-2.0 truck +crates). ModelCraft uses its B-rep bodies and its tessellation; it does not read CAD exchange +files itself. Only `modelcraft-geometry` may depend on it. + +**Meshing: our own**, implemented from published papers (`docs/meshing/literature.md`). +Robust predicates follow Shewchuk's adaptive-precision arithmetic (the paper; his reference +`predicates.c` is public domain). A permissive crate may be adopted instead after review; record +it here. + +**Linear algebra:** to be decided in the solver task, among permissively licensed pure-Rust +crates (for example `faer`, MIT). Record the choice here. + +**Allowed:** MIT, BSD-2/3, Apache-2.0, ISC, Zlib, Unicode, MPL-2.0 (as an unmodified dependency), +CC0 and public domain. + +**Rejected — never read, copy or link:** + +| Project | Licence | Would have offered | +|---|---|---| +| TetGen | AGPL-3.0 | constrained Delaunay tetrahedralization | +| CGAL | GPL-3.0 / LGPL-3.0 | Delaunay, mesh generation | +| Gmsh | GPL-2.0+ | tet and hex meshing | +| Netgen | LGPL-2.1 | tet meshing | +| CalculiX | GPL-2.0 | implicit solver, Abaqus-like input | +| Code_Aster | GPL-3.0 | implicit solver | +| OpenRadioss | AGPL-3.0 | explicit solver | +| Elmer | GPL-2.0 / LGPL-2.1 | multiphysics solver | +| deal.II, FreeFEM, Salome | LGPL | FE libraries, pre-processing | +| OpenCASCADE | LGPL-2.1 | B-rep kernel | + +**Commercial products** (Abaqus, Nastran, LS-DYNA, LS-PrePost, HyperMesh, ANSA, Femap, Patran, +ANSYS) are never run, scripted or used as a source of data. Deck formats come from the vendors' +public reference manuals, described in our own words with our own examples. + +## Consequences + +- The tet and hex meshers are significant original work; the literature survey decides the + order and the methods. +- SolidCraft and ModelCraft move together: bumping the pinned rev bumps the repeated + `[patch.crates-io]` entries in the same commit. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..579cd5d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,60 @@ +# Architecture + +ModelCraft turns geometry into finite element models, writes them for the solvers engineers +already use, and solves linear problems itself. + +``` +SolidCraft B-rep ──tessellate──▶ boundary surface ──▶ volume mesh ──▶ FE model ──▶ Abaqus .inp + (solidcraft-kernel) (welded, watertight, tet (Delaunay) materials, Nastran .bdf + face-tagged) hex (core goal) sections, LS-DYNA .k + BCs, loads, ──▶ own linear + steps solver +``` + +## Crates and layers + +`cargo xtask layers` enforces the table in `xtask/src/layers.rs`: a crate may depend only on +crates in strictly lower layers. + +| Layer | Crate | Role | Status | +|---|---|---|---| +| L0 | `modelcraft-model` | Solver-neutral FE model: nodes, elements by label; materials, sections, sets, BCs, loads and steps as they land. Knows no file format. | started | +| L1 | `modelcraft-geometry` | The only door to SolidCraft: B-rep bodies, measures, tessellation into a face-tagged `SurfaceMesh`. No SolidCraft type in its API. | started | +| L1 | `modelcraft-mesh` | Surface preparation (weld, watertightness, feature edges), robust predicates, Delaunay tetrahedralization and refinement, Voronoi-based optimization, hex meshing, quality metrics. | planned | +| L1 | `modelcraft-solver` | Element formulations, assembly, sparse linear static and modal solution. | planned | +| L1 | `modelcraft-decks` | Abaqus input, Nastran bulk data and LS-DYNA keyword readers and writers, to and from `model`. | planned | +| L1 | `modelcraft-render` | Display data (meshes, contour maps) without a GPU dependency. | planned | +| L2 | `modelcraft-doc` | The document: geometry, mesh, model, results, history. | planned | +| L3 | `modelcraft-io` | `.modelcraft` files, result files. | planned | +| L4 | `modelcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts. | started | +| L5 | `modelcraft-ui-egui`, `modelcraft-mcp` | Desktop UI, control channel and MCP server. | planned | +| apps | `modelcraft`, `modelcraft-cli`, `modelcraft-web` | Desktop, command line, browser. | cli started | + +Rules beyond the layer order: +- Nothing below L5 uses egui, eframe, winit, wgpu or rfd. +- Only `modelcraft-geometry` uses `solidcraft-*`, pinned by rev in the workspace `Cargo.toml`. + SolidCraft's `[patch.crates-io]` (its fixed truck crates) does not reach dependents, so the + workspace repeats it from the same rev; bump both together. +- Everything below the apps builds for `wasm32-unknown-unknown`. + +## Meshing pipeline + +1. **Boundary surface.** SolidCraft tessellates each body; every triangle carries its B-rep face, + so feature edges and face-based loads and BCs survive meshing. +2. **Surface preparation.** Weld coincident vertices, check the surface is closed, manifold and + consistently oriented, find feature edges (face boundaries, sharp dihedral angles). +3. **Tetrahedral meshing.** 3D Delaunay tetrahedralization of the boundary points with exact + predicates, boundary recovery, Delaunay refinement to a size field, then Voronoi-based + optimization (centroidal Voronoi / optimal Delaunay) and sliver removal. +4. **Hexahedral meshing (the core).** All-hex first where it can be guaranteed valid, with + hex-dominant as the fallback; the method is chosen from the literature survey + (`docs/meshing/literature.md`). +5. **Quality.** Scaled Jacobian, aspect ratio, dihedral angles; every generated mesh is checked + for positive Jacobians and conserved volume before it reaches the model. + +## Commands + +Every user-visible action is a `CommandSpec` in `crates/engine/src/cmd/.rs` with a JSON +form. `Session::execute` clones the model, runs the command under `catch_unwind`, and restores +the model on an error or a panic; commands that changed the model are journalled, so the +journal replays as a script. diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..3121924 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 150 +use_small_heuristics = "Max" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..df7dfa8 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +serde_json = { workspace = true } diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs new file mode 100644 index 0000000..700af3b --- /dev/null +++ b/xtask/src/assets.rs @@ -0,0 +1,40 @@ +//! `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. + +use std::path::Path; + +const ASSET_EXT: &[&str] = + &["png", "jpg", "jpeg", "svg", "ico", "icns", "ttf", "otf", "woff", "woff2", "gif", "bmp", "webp", "wav", "mp3", "stl", "step", "stp", "obj"]; +const SKIP_DIRS: &[&str] = &["target", ".git", "plan", "dist"]; + +fn walk(dir: &Path, root: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + let name = e.file_name().to_string_lossy().to_string(); + if p.is_dir() { + if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') { + walk(&p, root, out); + } + } else if let Some(ext) = p.extension().map(|x| x.to_string_lossy().to_ascii_lowercase()) + && ASSET_EXT.contains(&ext.as_str()) + && let Ok(rel) = p.strip_prefix(root) + { + out.push(rel.to_string_lossy().replace('\\', "/")); + } + } +} + +pub fn run(root: &Path) -> Result<(), String> { + let attribution = std::fs::read_to_string(root.join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?; + let mut files = Vec::new(); + walk(root, root, &mut files); + files.sort(); + let missing: Vec<&String> = files.iter().filter(|f| !attribution.contains(f.as_str())).collect(); + if missing.is_empty() { + println!("assets: {} asset file(s), all attributed", files.len()); + Ok(()) + } else { + Err(format!("assets missing from ATTRIBUTION.md:\n{}", missing.iter().map(|m| format!(" {m}")).collect::>().join("\n"))) + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs new file mode 100644 index 0000000..80b40ba --- /dev/null +++ b/xtask/src/layers.rs @@ -0,0 +1,340 @@ +//! Dependency layering rules (plan/architecture.md §3). +//! +//! Adapted from SolidCraft's xtask (MIT OR Apache-2.0), see ATTRIBUTION.md. +//! +//! The rule engine works on a small, metadata-independent model so it can be +//! unit-tested; `from_metadata` builds that model from `cargo metadata`. + +use serde_json::Value; + +/// Where a workspace crate sits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Class { + /// Regular layered crate. + Layer(u8), + /// Layer 0, and additionally may depend on no workspace crate at all. + Standalone, + /// Test tooling: may depend on anything up to L5 (it sits at L6 for rule + /// purposes); other crates may use it only as a dev-dependency. + Testkit, + /// Binaries and build tooling: exempt from the rules. + Exempt, +} + +impl Class { + fn layer(self) -> Option { + match self { + Class::Layer(l) => Some(l), + Class::Standalone => Some(0), + Class::Testkit => Some(6), + Class::Exempt => None, + } + } +} + +/// The layering table. Names are package names without the `modelcraft-` +/// prefix. +pub const TABLE: &[(&str, Class)] = &[ + ("model", Class::Layer(0)), + ("geometry", Class::Layer(1)), + ("mesh", Class::Layer(1)), + ("solver", Class::Layer(1)), + ("decks", Class::Layer(1)), + ("render", Class::Layer(1)), + ("doc", Class::Layer(2)), + ("io", Class::Layer(3)), + ("engine", Class::Layer(4)), + ("ui-egui", Class::Layer(5)), + ("mcp", Class::Layer(5)), + ("testkit", Class::Testkit), + // apps and tooling + ("modelcraft", Class::Exempt), + ("cli", Class::Exempt), + ("web", Class::Exempt), + ("xtask", Class::Exempt), +]; + +/// Explicit orderings *within* a layer (earlier may be used by later). +pub const INTRA_LAYER_ORDER: &[&[&str]] = &[]; + +fn intra_layer_allowed(from: &str, to: &str) -> bool { + let (from, to) = (short_name(from), short_name(to)); + INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) { + (Some(f), Some(t)) => t < f, + _ => false, + }) +} + +/// External crates that constitute a UI toolkit / windowing dependency. +/// Entries ending in `*` are prefixes. +pub const UI_CRATES: &[&str] = &["egui", "eframe", "egui-wgpu", "wgpu", "winit", "egui_kittest", "rfd", "bevy*"]; + +/// The only crate that may depend on SolidCraft (`solidcraft-*`): the geometry boundary. +pub const SOLIDCRAFT_GATEWAY: &str = "geometry"; + +/// First layer allowed to use UI crates. +pub const UI_MIN_LAYER: u8 = 5; + +pub fn short_name(pkg: &str) -> &str { + pkg.strip_prefix("modelcraft-").unwrap_or(pkg) +} + +pub fn classify(pkg: &str) -> Option { + let s = short_name(pkg); + TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c) +} + +fn is_ui_crate(name: &str) -> bool { + UI_CRATES.iter().any(|p| match p.strip_suffix('*') { + Some(prefix) => name.starts_with(prefix), + None => name == *p, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DepKind { + Normal, + Dev, + Build, +} + +#[derive(Debug, Clone)] +pub struct Dep { + pub name: String, + pub kind: DepKind, + /// `true` if the dependency is a workspace member. + pub workspace: bool, +} + +#[derive(Debug, Clone)] +pub struct Crate { + pub name: String, + pub deps: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Violation { + Unregistered { krate: String }, + Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind }, + StandaloneHasWorkspaceDep { krate: String, dep: String }, + TestkitAsNormalDep { krate: String }, + UiBelowL5 { krate: String, dep: String, layer: u8 }, + SolidCraftOutsideGeometry { krate: String, dep: String }, +} + +impl std::fmt::Display for Violation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Violation::Unregistered { krate } => { + write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)") + } + Violation::Upward { krate, dep, from, to, kind } => { + write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers") + } + Violation::StandaloneHasWorkspaceDep { krate, dep } => { + write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}") + } + Violation::TestkitAsNormalDep { krate } => { + write!(f, "{krate}: modelcraft-testkit may only be a dev-dependency") + } + Violation::UiBelowL5 { krate, dep, layer } => { + write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L5+") + } + Violation::SolidCraftOutsideGeometry { krate, dep } => { + write!(f, "{krate} depends on `{dep}`; only modelcraft-{SOLIDCRAFT_GATEWAY} may use SolidCraft") + } + } + } +} + +/// Check all rules. Returns violations sorted for stable output. +pub fn check(crates: &[Crate]) -> Vec { + let mut out = Vec::new(); + for c in crates { + let Some(class) = classify(&c.name) else { + out.push(Violation::Unregistered { krate: c.name.clone() }); + continue; + }; + if class == Class::Exempt { + continue; + } + let layer = class.layer().unwrap_or(0); + for d in &c.deps { + // Self dev-dependencies (e.g. to enable features in tests) are fine. + if d.name == c.name { + continue; + } + if d.workspace { + if class == Class::Standalone { + out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() }); + continue; + } + match classify(&d.name) { + // Unregistered deps are reported on their own entry. + None => {} + Some(Class::Testkit) if d.kind != DepKind::Dev => { + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); + } + Some(Class::Testkit) => {} + Some(dc) => { + let to = dc.layer().unwrap_or(u8::MAX); + if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) { + out.push(Violation::Upward { + krate: c.name.clone(), + dep: d.name.clone(), + from: layer, + to: if to == u8::MAX { 7 } else { to }, + kind: d.kind, + }); + } + } + } + } else if d.name.starts_with("solidcraft-") && short_name(&c.name) != SOLIDCRAFT_GATEWAY { + out.push(Violation::SolidCraftOutsideGeometry { krate: c.name.clone(), dep: d.name.clone() }); + } else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) { + out.push(Violation::UiBelowL5 { krate: c.name.clone(), dep: d.name.clone(), layer }); + } + } + } + out.sort_by_key(|v| v.to_string()); + out.dedup(); + out +} + +/// Build the model from `cargo metadata --format-version 1 --no-deps`. +pub fn from_metadata(meta: &Value) -> Result, String> { + let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?; + let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect(); + let mut out = Vec::new(); + for p in pkgs { + let name = p["name"].as_str().ok_or("package without name")?.to_owned(); + let mut deps = Vec::new(); + for d in p["dependencies"].as_array().into_iter().flatten() { + let dname = d["name"].as_str().unwrap_or_default().to_owned(); + let kind = match d["kind"].as_str() { + Some("dev") => DepKind::Dev, + Some("build") => DepKind::Build, + _ => DepKind::Normal, + }; + let workspace = members.contains(&dname.as_str()) || d["path"].is_string(); + deps.push(Dep { name: dname, kind, workspace }); + } + out.push(Crate { name, deps }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +pub fn describe(class: Option) -> String { + match class { + Some(Class::Layer(l)) => format!("L{l}"), + Some(Class::Standalone) => "L0 standalone".into(), + Some(Class::Testkit) => "testkit".into(), + Some(Class::Exempt) => "exempt".into(), + None => "UNREGISTERED".into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate { + Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() } + } + use DepKind::*; + + #[test] + fn clean_downward_graph_passes() { + let g = [ + c("modelcraft-model", &[("serde", Normal, false)]), + c("modelcraft-doc", &[("modelcraft-model", Normal, true)]), + c("modelcraft-engine", &[("modelcraft-doc", Normal, true), ("modelcraft-testkit", Dev, true)]), + c("modelcraft-ui-egui", &[("modelcraft-engine", Normal, true), ("egui", Normal, false)]), + c("modelcraft-cli", &[("modelcraft-ui-egui", Normal, true)]), + ]; + assert!(check(&g).is_empty(), "{:?}", check(&g)); + } + + #[test] + fn upward_dependency_flagged() { + let v = check(&[c("modelcraft-doc", &[("modelcraft-engine", Normal, true)])]); + assert!(matches!(v[..], [Violation::Upward { from: 2, to: 4, .. }])); + } + + #[test] + fn sideways_dependency_flagged() { + let v = check(&[c("modelcraft-mesh", &[("modelcraft-solver", Normal, true)])]); + assert!(matches!(v[..], [Violation::Upward { from: 1, to: 1, .. }])); + } + + #[test] + fn self_dev_dependency_ignored() { + assert!(check(&[c("modelcraft-doc", &[("modelcraft-doc", Dev, true)])]).is_empty()); + } + + #[test] + fn upward_dev_dependency_flagged() { + let v = check(&[c("modelcraft-model", &[("modelcraft-doc", Dev, true)])]); + assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }])); + } + + #[test] + fn ui_crates_below_l6_flagged() { + for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] { + let v = check(&[c("modelcraft-engine", &[(dep, Normal, false)])]); + assert!(matches!(v[..], [Violation::UiBelowL5 { layer: 4, .. }]), "{dep}"); + } + assert!(check(&[c("modelcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty()); + assert!(check(&[c("modelcraft-ui-egui", &[("winit", Normal, false)])]).is_empty()); + } + + #[test] + fn only_geometry_uses_solidcraft() { + assert!(check(&[c("modelcraft-geometry", &[("solidcraft-kernel", Normal, false)])]).is_empty()); + let v = check(&[c("modelcraft-mesh", &[("solidcraft-geom", Normal, false)])]); + assert!(matches!(v[..], [Violation::SolidCraftOutsideGeometry { .. }])); + } + + #[test] + fn unregistered_crate_is_error() { + let v = check(&[c("modelcraft-mystery", &[])]); + assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "modelcraft-mystery")); + assert!(v[0].to_string().contains("register")); + } + + #[test] + fn testkit_only_as_dev_dependency() { + let v = check(&[c("modelcraft-doc", &[("modelcraft-testkit", Normal, true)])]); + assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }])); + assert!(check(&[c("modelcraft-doc", &[("modelcraft-testkit", Dev, true)])]).is_empty()); + assert!(check(&[c("modelcraft-testkit", &[("modelcraft-engine", Normal, true)])]).is_empty()); + } + + #[test] + fn apps_and_xtask_exempt() { + for app in ["modelcraft", "modelcraft-cli", "xtask"] { + assert!(check(&[c(app, &[("egui", Normal, false), ("modelcraft-ui-egui", Normal, true)])]).is_empty()); + } + } + + #[test] + fn metadata_parsing() { + let meta: Value = serde_json::from_str( + r#"{"packages":[ + {"name":"modelcraft-doc","dependencies":[ + {"name":"modelcraft-model","kind":null,"path":"/x/crates/model"}, + {"name":"serde","kind":null}, + {"name":"proptest","kind":"dev"}]}, + {"name":"modelcraft-model","dependencies":[]} + ]}"#, + ) + .unwrap(); + let g = from_metadata(&meta).unwrap(); + assert_eq!(g.len(), 2); + let doc = g.iter().find(|c| c.name == "modelcraft-doc").unwrap(); + assert!(doc.deps[0].workspace && !doc.deps[1].workspace); + assert_eq!(doc.deps[2].kind, Dev); + assert!(check(&g).is_empty()); + } +} diff --git a/xtask/src/licences.rs b/xtask/src/licences.rs new file mode 100644 index 0000000..bfac246 --- /dev/null +++ b/xtask/src/licences.rs @@ -0,0 +1,179 @@ +//! `cargo xtask licences [--check]`: THIRD-PARTY-LICENSES.txt, the licences of every crate built +//! into the shipped programs (modelcraft, modelcraft-cli, modelcraft-web), read from +//! `cargo metadata` and the crates' own licence files, so a release download can carry them. +//! `--check` (part of `cargo xtask ci`) fails when the committed file is out of date, e.g. after a +//! dependency changed. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::path::Path; + +use serde_json::Value; + +use crate::cargo; + +pub const FILE: &str = "THIRD-PARTY-LICENSES.txt"; +const ROOTS: &[&str] = &["modelcraft", "modelcraft-cli", "modelcraft-web"]; + +pub fn run(root: &Path, check: bool) -> Result<(), String> { + let text = generate()?; + let path = root.join(FILE); + if check { + let old = std::fs::read_to_string(&path).unwrap_or_default(); + if old.replace("\r\n", "\n") != text { + return Err(format!("{FILE} is out of date (dependencies changed): run `cargo xtask licences` and commit it")); + } + println!("licences: {FILE} is up to date"); + return Ok(()); + } + std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?; + println!("licences: wrote {FILE} ({} KB)", text.len() / 1024); + Ok(()) +} + +struct Crate { + name: String, + version: String, + license: String, + repository: String, + /// (file name, text) of the licence files shipped in the crate. + files: Vec<(String, String)>, +} + +fn generate() -> Result { + let out = cargo().args(["metadata", "--format-version", "1", "--locked"]).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))); + } + let meta: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata JSON: {e}"))?; + let packages: BTreeMap<&str, &Value> = meta["packages"].as_array().into_iter().flatten().filter_map(|p| Some((p["id"].as_str()?, p))).collect(); + let nodes: BTreeMap<&str, &Value> = + meta["resolve"]["nodes"].as_array().into_iter().flatten().filter_map(|n| Some((n["id"].as_str()?, n))).collect(); + let members: BTreeSet<&str> = meta["workspace_members"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + + // Everything reachable from the shipped programs through normal (not dev) dependencies, on any + // platform. + let mut seen: BTreeSet<&str> = BTreeSet::new(); + let mut queue: VecDeque<&str> = packages + .iter() + .filter(|(id, p)| members.contains(*id) && ROOTS.contains(&p["name"].as_str().unwrap_or_default())) + .map(|(id, _)| *id) + .collect(); + while let Some(id) = queue.pop_front() { + if !seen.insert(id) { + continue; + } + for d in nodes.get(id).and_then(|n| n["deps"].as_array()).into_iter().flatten() { + let normal = d["dep_kinds"].as_array().into_iter().flatten().any(|k| k["kind"].is_null()); + if normal && let Some(pkg) = d["pkg"].as_str() { + queue.push_back(pkg); + } + } + } + + let mut crates = Vec::new(); + for id in seen { + let Some(p) = packages.get(id) else { continue }; + if members.contains(id) { + continue; + } + let s = |k: &str| p[k].as_str().unwrap_or_default().to_string(); + let dir = Path::new(p["manifest_path"].as_str().unwrap_or_default()).parent().map(Path::to_path_buf).unwrap_or_default(); + crates.push(Crate { + name: s("name"), + version: s("version"), + license: s("license"), + repository: s("repository"), + files: licence_files(&dir, p), + }); + } + crates.sort_by(|a, b| (&a.name, &a.version).cmp(&(&b.name, &b.version))); + Ok(render(&crates)) +} + +/// The licence and notice files at the top of a crate's package (and its `license-file`). +fn licence_files(dir: &Path, p: &Value) -> Vec<(String, String)> { + let mut names: BTreeSet = BTreeSet::new(); + if let Ok(rd) = std::fs::read_dir(dir) { + for e in rd.flatten() { + let n = e.file_name().to_string_lossy().to_string(); + let u = n.to_ascii_uppercase(); + if e.path().is_file() && ["LICENSE", "LICENCE", "COPYING", "NOTICE", "UNLICENSE", "COPYRIGHT"].iter().any(|k| u.starts_with(k)) { + names.insert(n); + } + } + } + if let Some(f) = p["license_file"].as_str() { + names.insert(f.to_string()); + } + names + .into_iter() + .filter_map(|n| { + let text = std::fs::read_to_string(dir.join(&n)).ok()?; + let text = text.replace("\r\n", "\n").trim().to_string(); + (!text.is_empty()).then(|| (Path::new(&n).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or(n), text)) + }) + .collect() +} + +fn render(crates: &[Crate]) -> String { + let rule = "=".repeat(80); + let mut out = format!( + "Third-party software in ModelCraft\n\nModelCraft (the desktop app, modelcraft-cli and the web app) is built from the {} crates below.\n\ + Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.\n\ + ModelCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.\n\n", + crates.len() + ); + for c in crates { + out += &format!("{} {} ({})\n", c.name, c.version, if c.license.is_empty() { "see its licence file" } else { &c.license }); + } + // One copy of each distinct licence text, with the crates it covers. + let mut by_text: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut without: Vec<&Crate> = Vec::new(); + for c in crates { + if c.files.is_empty() { + without.push(c); + } + for (_, text) in &c.files { + by_text.entry(text.as_str()).or_default().push(format!("{} {}", c.name, c.version)); + } + } + let mut sections: Vec<(Vec, &str)> = by_text.into_iter().map(|(t, v)| (v, t)).collect(); + sections.sort(); + for (names, text) in sections { + out += &format!("\n{rule}\n{}\n{rule}\n\n{text}\n", wrap(&names.join(", "))); + } + if !without.is_empty() { + out += &format!( + "\n{rule}\nCrates whose package carries no licence file (their licence is named in Cargo.toml; the MIT and Apache-2.0\ntexts are as in ModelCraft's own LICENSE-MIT and LICENSE-APACHE)\n{rule}\n\n" + ); + for c in without { + out += &format!( + "{} {}: {}{}\n", + c.name, + c.version, + c.license, + if c.repository.is_empty() { String::new() } else { format!(" ({})", c.repository) } + ); + } + } + + out +} + +/// Wrap a long list of crate names at 100 columns. +fn wrap(s: &str) -> String { + let mut out = String::new(); + let mut col = 0; + for w in s.split(' ') { + if col > 0 && col + w.len() + 1 > 100 { + out.push('\n'); + col = 0; + } else if col > 0 { + out.push(' '); + col += 1; + } + out += w; + col += w.len(); + } + out +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..33bd207 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,159 @@ +//! Workspace tooling: `cargo xtask `. +//! +//! 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 assets; +mod layers; +mod licences; +mod test; + +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +const USAGE: &str = "\ +usage: cargo xtask + +commands: + layers enforce the crate dependency layering (docs/architecture.md) + assets check that every non-code asset file is attributed in ATTRIBUTION.md + licences [--check] + write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate); + --check fails when it is out of date + test [CARGO ARGS] [-- TEST ARGS] + build the tests with the ci profile, then run every test binary at once + (and the doc tests alongside) + wasm cargo check every library crate for wasm32-unknown-unknown + ci fmt --check, assets, licences --check, layers, clippy -D warnings, then the tests + with the wasm check alongside (stops at the first failing step) +"; + +fn main() -> ExitCode { + let args: Vec = 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("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..])); + test::run(cargo_args, test_args, vec![test::doc_tests(cargo_args, test_args)]) + } + Some("ci") => cmd_ci(), + 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 { + 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 = 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) 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 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 = + crates.iter().filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_)))).map(|c| c.name.clone()).collect(); + 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 (and doc tests) run while the test +/// binaries do, which leaves cargo's build lock free. +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), test::doc_tests(&[], &[])])?; + eprintln!("ci: all gates passed"); + Ok(()) +} diff --git a/xtask/src/test.rs b/xtask/src/test.rs new file mode 100644 index 0000000..799c820 --- /dev/null +++ b/xtask/src/test.rs @@ -0,0 +1,105 @@ +//! `cargo xtask test`: build the workspace's tests once, then run every test binary at the same +//! time. `cargo test` runs them one after another, so one slow binary held up all the others; +//! the doc tests and any other `side` jobs run alongside on their own thread. + +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::Instant; + +use crate::{cargo, root}; + +/// A test executable from `cargo test --no-run`. +struct TestBin { + label: String, + exe: String, + dir: String, +} + +/// Build the tests with `cargo_args` (e.g. `--locked`), run each binary with `test_args`, and run +/// the `side` jobs one after another meanwhile. Each prints a line when it ends; a failure prints +/// its whole output. +pub fn run(cargo_args: &[&str], test_args: &[&str], side: Vec<(String, Command)>) -> Result<(), String> { + let bins = build(cargo_args)?; + let start = Instant::now(); + let (tx, rx) = mpsc::channel::<(String, Result, f64)>(); + let mut failed = Vec::new(); + std::thread::scope(|scope| { + for b in &bins { + let tx = tx.clone(); + scope.spawn(move || { + let t0 = Instant::now(); + let mut c = Command::new(&b.exe); + c.args(test_args).current_dir(&b.dir).env("CARGO_MANIFEST_DIR", &b.dir); + let _ = tx.send((b.label.clone(), finish(c), t0.elapsed().as_secs_f64())); + }); + } + let tx_side = tx.clone(); + scope.spawn(move || { + for (label, c) in side { + let t0 = Instant::now(); + let _ = tx_side.send((label, finish(c), t0.elapsed().as_secs_f64())); + } + }); + drop(tx); + for (label, result, secs) in rx { + match result { + Ok(summary) => println!("ok {label:<44} {secs:6.1}s {summary}"), + Err(output) => { + println!("FAIL {label:<44} {secs:6.1}s\n{output}"); + failed.push(label); + } + } + } + }); + println!("test: {} binaries in {:.1}s", bins.len(), start.elapsed().as_secs_f64()); + if failed.is_empty() { Ok(()) } else { Err(format!("failed: {}", failed.join(", "))) } +} + +/// Run to the end: the last `test result:` line on success, all output on failure. +fn finish(mut c: Command) -> Result { + let out = c.stdin(Stdio::null()).output().map_err(|e| format!("failed to spawn: {e}"))?; + let stdout = String::from_utf8_lossy(&out.stdout); + if out.status.success() { + Ok(stdout.lines().rev().find(|l| l.starts_with("test result:")).unwrap_or("").trim_start_matches("test result: ").to_string()) + } else { + Err(format!("{stdout}{}\n({})", String::from_utf8_lossy(&out.stderr), out.status)) + } +} + +/// `cargo test --workspace --profile ci --no-run`, and the executables it built. +fn build(cargo_args: &[&str]) -> Result, String> { + let mut c = cargo(); + c.args(["test", "--workspace", "--profile", "ci", "--no-run", "--message-format=json-render-diagnostics"]).args(cargo_args); + eprintln!("$ cargo test --workspace --profile ci --no-run"); + let out = c.stderr(Stdio::inherit()).output().map_err(|e| format!("cargo test --no-run: failed to spawn: {e}"))?; + if !out.status.success() { + return Err(format!("cargo test --no-run: exited with {}", out.status)); + } + let mut bins: Vec = Vec::new(); + for line in String::from_utf8_lossy(&out.stdout).lines() { + let Ok(m) = serde_json::from_str::(line) else { continue }; + let Some(exe) = m["executable"].as_str().filter(|_| m["reason"] == "compiler-artifact" && m["profile"]["test"] == true) else { + continue; + }; + if bins.iter().any(|b| b.exe == exe) { + continue; + } + let manifest = std::path::Path::new(m["manifest_path"].as_str().unwrap_or_default()).to_path_buf(); + let dir = manifest.parent().map(|d| d.to_path_buf()).unwrap_or_else(root); + let pkg = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let kind = m["target"]["kind"][0].as_str().unwrap_or(""); + let label = format!("{pkg} {kind} {}", m["target"]["name"].as_str().unwrap_or("")); + bins.push(TestBin { label, exe: exe.to_string(), dir: dir.to_string_lossy().into_owned() }); + } + if bins.is_empty() { + return Err("cargo test --no-run built no test executables".into()); + } + Ok(bins) +} + +/// The doc tests (`cargo test --doc`), to run as a side job. +pub fn doc_tests(cargo_args: &[&str], test_args: &[&str]) -> (String, Command) { + let mut c = cargo(); + c.args(["test", "--workspace", "--profile", "ci", "--doc"]).args(cargo_args).arg("--").args(test_args); + ("doc tests".into(), c) +}