forked from 1b-agent/fieldcraft-upstream
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
commit
b64ec3f698
38 files changed
+3931
No files matched your search
@@ -0,0 +1,2 @@
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
/target*/
|
||||
/plan/
|
||||
/dist/
|
||||
/.agent-files/
|
||||
/worktrees/
|
||||
*~
|
||||
.DS_Store
|
||||
*.swp
|
||||
@@ -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<T, E>`** 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 `<area>.<thing>[.<variant>]` 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=<repo>/target`. Parallel agents: separate target dirs, one
|
||||
worktree each.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks milestones. Update it whenever a milestone task lands.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
Generated
+1056
File diff suppressed because it is too large.
Load diff
+73
@@ -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" }
|
||||
+177
@@ -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
|
||||
+21
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+16
@@ -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 |
|
||||
+11
@@ -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.
|
||||
@@ -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 <http://unlicense.org/>
|
||||
|
||||
================================================================================
|
||||
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.
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,58 @@
|
||||
//! modelcraft-cli commands
|
||||
//! modelcraft-cli run <script.json>
|
||||
#![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 <script.json> 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<String> = 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(())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
s.model.remove_node(label(p, "label")?)?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn element_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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::<Option<Vec<u32>>>()
|
||||
.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<Value> {
|
||||
s.model.remove_element(label(p, "label")?)?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -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<u32> {
|
||||
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)
|
||||
}
|
||||
@@ -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<Value> {
|
||||
let counts: serde_json::Map<String, Value> = 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}))
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, EngineError>;
|
||||
|
||||
/// A command: one user-visible action.
|
||||
pub struct CommandSpec {
|
||||
/// Our own dotted, lower-case id: `<area>.<thing>[.<variant>]`, 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<Value>,
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
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::<String>().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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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<T> = std::result::Result<T, Error>;
|
||||
|
||||
impl From<KernelError> 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<u32>,
|
||||
}
|
||||
|
||||
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<Shape> {
|
||||
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<Measure> {
|
||||
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<SurfaceMesh> {
|
||||
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<u32> = 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// 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> {
|
||||
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<u32>,
|
||||
}
|
||||
|
||||
/// Nodes and elements by label.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Model {
|
||||
nodes: BTreeMap<u32, Node>,
|
||||
elements: BTreeMap<u32, Element>,
|
||||
}
|
||||
|
||||
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<u32, Node> {
|
||||
&self.nodes
|
||||
}
|
||||
|
||||
pub fn elements(&self) -> &BTreeMap<u32, Element> {
|
||||
&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<u32>) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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/<area>.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.
|
||||
@@ -0,0 +1,2 @@
|
||||
max_width = 150
|
||||
use_small_heuristics = "Max"
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -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<String>) {
|
||||
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::<Vec<_>>().join("\n")))
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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<Class> {
|
||||
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<Dep>,
|
||||
}
|
||||
|
||||
#[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<Violation> {
|
||||
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<Vec<Crate>, 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<Class>) -> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
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<String> = 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<String>> = 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<String>, &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
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Workspace tooling: `cargo xtask <command>`.
|
||||
//!
|
||||
//! Pure Rust (std + serde_json). External tools (`cargo`) are invoked through
|
||||
//! `std::process::Command`. Adapted from SolidCraft's xtask (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod assets;
|
||||
mod layers;
|
||||
mod licences;
|
||||
mod test;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
layers enforce the crate dependency layering (docs/architecture.md)
|
||||
assets check that every non-code asset file is attributed in ATTRIBUTION.md
|
||||
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<String> = std::env::args().skip(1).collect();
|
||||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("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<serde_json::Value, String> {
|
||||
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
|
||||
}
|
||||
|
||||
fn cmd_layers() -> Result<(), String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
println!("Dependency layering (docs/architecture.md)\n");
|
||||
println!("{:<28} {:<14} workspace deps", "crate", "layer");
|
||||
for c in &crates {
|
||||
let ws: Vec<String> = c
|
||||
.deps
|
||||
.iter()
|
||||
.filter(|d| d.workspace)
|
||||
.map(|d| {
|
||||
let k = match d.kind {
|
||||
layers::DepKind::Normal => "",
|
||||
layers::DepKind::Dev => " (dev)",
|
||||
layers::DepKind::Build => " (build)",
|
||||
};
|
||||
format!("{}{k}", layers::short_name(&d.name))
|
||||
})
|
||||
.collect();
|
||||
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
|
||||
}
|
||||
let violations = layers::check(&crates);
|
||||
println!();
|
||||
if violations.is_empty() {
|
||||
println!("OK: {} crates, no layering violations.", crates.len());
|
||||
Ok(())
|
||||
} else {
|
||||
println!("{} violation(s):", violations.len());
|
||||
for v in &violations {
|
||||
println!(" - {v}");
|
||||
}
|
||||
Err(format!("{} layering violation(s)", violations.len()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Never break wasm: every layered crate (everything below the apps) 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<String> =
|
||||
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(())
|
||||
}
|
||||
@@ -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<String, String>, 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<String, String> {
|
||||
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<Vec<TestBin>, 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<TestBin> = Vec::new();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let Ok(m) = serde_json::from_str::<serde_json::Value>(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)
|
||||
}
|
||||
Reference in new issue
Block a user